☰
昇腾NPU上Qwen图像编辑模型服务化推理部署与性能调优实践
2026/10/22 9:56:22 网站建设 项目流程

这段时间我们组一直在折腾一件事:把一个图像编辑服务从熟悉的GPU环境迁到昇腾NPU上。模型本体是社区开源的Qwen-image-Edit,说它是“小模型”其实也不算小,单权重十几个GB,只是和大几千亿参数的基础大模型放在一起显得小。真正麻烦的是它要对外提供HTTP服务,被几十个内部调用方按真实业务流量打,延迟、并发、长时间稳定性都得兜得住。这篇围绕“小模型在昇腾NPU上的推理部署”记录一次完整的服务化实践,包括怎么搭环境、怎么把模型跑起来、怎么设计服务框架、怎么调性能,以及踩过的坑和排查思路。如果你也在做类似的事,前面几张图可以略看,后面几段经验直接抄。

1. 项目背景与目标拆解:所谓的“小模型”为什么不好部署

1.1 业务场景:一句话改图

我所在的部门主要做设计协作工具,面向的业务场景里有一个高频需求:用户上传一张商品图,输入一句文本指令,比如“把背景换成米色”、“去掉模特手中的杯子”、“把裙子改成红色”,系统要自动输出一张编辑后的图片。这种能力以前靠设计师手动修图,现在希望用模型自动化。

这个任务说到底是“文生图+图像编辑”的混合体。它和纯文生图的区别在于:输入里除了文本,还包含原图以及需要修改的区域描述;模型不仅要知道“生成什么”,还得知道“在哪个区域改”和“哪些像素不要动”。这种模型通常不是一个简单扩散模型搞定一切,而是把视觉编码、文本编码、扩散采样、解码几个模块串起来,链路比普通单任务模型要长。

我们最初在GPU服务器上验证,效果达到了可以上线的水平。但随后算力资源安排发生了变化,新的推理集群主要提供的算力就是昇腾NPU。这就意味着整个推理链路要迁移,不只是改一行device参数。

1.2 为什么选型时锁定了 Qwen-image-Edit

模型选型是在迁移之前就定下来的,因为“能不能迁”很大程度上取决于模型架构是否好适配。

当时对比过几种方案:直接调闭源API,数据合规和成本都不可控;自训练一个特效小模型,时间和标注成本下不来;最后落在了社区开源的Qwen-image-Edit这一类图像编辑模型上。理由很朴素:

  • 权重开源,可以放在我们自己的服务器上;
  • 模型虽然只有十几GB,但对编辑类任务的语义理解、区域保持和背景一致性做得不错;
  • 社区使用量大,兼容性问题比较容易被发现,尤其当我们遇到算子不支持时,踩坑记录多,排查速度能快一点。

很多人问“十几GB也算小模型?”我说一个场景:如果不考虑算力限制,把这类模型和一个几百B的通用对话大模型比,它确实很小。但在单卡推理场景下,这已经是偏重的负载了,尤其扩散模型要反复迭代采样,几十步的循环每一步都可能成为性能瓶颈。

1.3 部署目标:不只是把模型跑起来

我们把目标拆成了三层。

第一层是功能目标:服务能接收图片和指令,返回编辑图。这是底线。

第二层是性能目标:单图推理时间不能超过5秒,越稳定越好;支持至少4到8路并发请求,且在高并发下不能因为显存或者线程问题直接崩掉。

第三层是稳定性目标:服务要能连续运行一周以上,内存不持续增长,NPU显存不泄漏,调用方超时不会拖垮整个进程。

这三层目标指导了我们后续所有技术选型。比如服务框架选型、是否使用图模式、队列长度限制、批量推理开关,都是围绕“延迟、吞吐、稳定性”这三个维度做权衡。

2. 昇腾NPU环境搭建:驱动、CANN与torch_npu的一次性对齐

2.1 硬件配置与系统基础

先说硬件。我们拿到的是昇腾910B系列加速卡,单机8卡,每卡显存48GB。这个规格对Qwen-image-Edit来说是比较充裕的,单卡可以放下整个模型,不需要做张量并行。

系统侧是openEuler 22.03,内核和固件驱动在采购时由厂商预装好。拿到机器之后第一件事不是急着装Python环境,而是先确认驱动和固件版本一致。

建议用下面的命令先看一次设备状态:

npu-smi info

这个命令会显示每张NPU卡的型号、显存、温度、驱动版本和固件版本。如果驱动和固件版本对不上,后面跑模型时会出现各种莫名其妙的算子报错,而且问题很难定位到软件栈上层。

另外,昇腾NPU不像普通GPU设备那样即插即用,torch_npu依赖CANN的运行时环境。所以安装顺序不能乱:先保证驱动固件正常,再装CANN Toolkit,再装torch_npu,最后装PyTorch和相关依赖。顺序反了的话,就算import不报错,运行到某个算子时也容易崩溃。

2.2 软件栈选型:为什么我们用torch_npu而不是MindSpore Lite

昇腾NPU上跑模型有几种主流路线。一种是使用MindSpore生态,把模型转成MindIR格式后,用推理引擎加载;另一种是使用torch_npu,直接在PyTorch生态里通过插件把算子调度到NPU上,模型代码不需要大改。

我们选择了torch_npu直连方案,核心考虑是Qwen-image-Edit原生的模型代码基于PyTorch编写,里面有大量自定义前向逻辑和动态shape操作。如果走MindSpore路线,要把整个模型做一次框架迁移,部分不常见的算子还得手写或绕行,排错代价太高。torch_npu方案虽然也会在算子层面遇到兼容问题,但至少代码结构原地不动,迁移风险小很多。

我们最终锁定的软件版本组合是:Python 3.10,PyTorch 2.1.0,torch_npu 2.1.0,CANN 8.0。这个组合在310B系列上经过了我们相对完整的回归验证,稳定性比新版本更可预期。

给大家一个工具表参考:

软件栈方案代码改动量算子兼容性动态Shape支持启动成本我们的选择
MindSpore Lite推理大,需模型转换较依赖算子集较弱,需固定shape学习成本高否
torch_npu直连小,基本保留PyTorch代码中上,常用算子覆盖广支持,但存在首次编译开销低是
MindIE大模型推理需要TSZ图编译适合LLM类结构适合固定输入高否

2.3 权重准备与环境自检

权重文件是在GPU环境上先下载好的,保持Hugging Face格式目录结构:包含config文件、模型权重、分词器、图像处理器等。迁移到NPU服务器时直接打包拷贝过去即可,不需要额外转换格式。

这里有个容易踩坑的点:不要直接用git clone拉大文件再手动解压,建议用huggingface-cli download或.tar分卷传输,免得下到一半断掉导致权重hash不一致。权重大小在几十GB到上百GB之间时,校验一下sha256能省下很多调试时间。

环境装好后,先做一个最小验证,确认torch_npu的算子调用链路没有问题:

import torch import torch_npu # 检查NPU设备数量 print(torch_npu.npu.device_count()) x = torch.randn(4096, 4096, device="npu") y = x @ x.T print(y.sum().item())

如果这一步能正常输出结果,说明CANN的运行时、算子库和torch_npu的插件接通了。接下来再跑一个更接近真实模型的小型推理,比如加载一个视觉编码器跑一遍forward,确认能前向计算。不要等整个模型加载完成才做自检,不然环境问题会被模型代码的错误掩盖住。

3. 推理链路跑通:从模型加载到单张图输出的关键改动

3.1 图像编辑模型的推理链路拆解

Qwen-image-Edit这类图像编辑模型,推理流程比普通文生图多了一个前提:需要把原始输入图“编码”进latent空间,同时把文本指令编码成条件向量。整个链路大致是这样的:

  1. 读入原始图片,做resize、归一化、padding;
  2. 文本指令通过tokenizer和文本编码器得到文本embedding;
  3. 原始图片通过VAE编码器得到图像latent;
  4. 如果需要区域编辑,还要把编辑mask图像编码并拼入条件;
  5. 将latent和条件送入扩散模型的UNet或Transformer结构,进行多次迭代采样;
  6. 每步采样会用调度器更新latent;
  7. 最终latent通过VAE解码器还原成RGB图片;
  8. 后处理裁剪掉padding区域。

这段链路虽然概念清晰,但落到代码上会有很多细节:比如输入图片的长宽比不一样,resize策略是直接拉伸还是等比缩放后padding;比如mask图像是否需要单独归一化;比如采样步数在质量和时延之间如何取舍。这些细节都会直接影响输出效果和推理速度。

3.2 设备迁移与精度设置

我们的做法很简单:拿到模型后,统一把参数从CPU搬到NPU,并把精度设置为bf16。

pipe = QwenImageEditPipeline.from_pretrained(model_dir) pipe.to("npu") pipe.eval()

说明一下,我这里写的QwenImageEditPipeline是项目内部的封装类,不是某个固定库的公开API,大家理解成“模型推理入口”就行。内部的forward逻辑我们尽量保持原生代码,只是把device参数从cuda改成npu。

精度方面,bf16比fp16在数值范围上更安全,尤其在一些大数值中间变量上不容易溢出。但bf16在NPU上的算子性能和fp16不完全一样,不同算子各有偏好。我们最终的策略是:模型整体用bf16,但某些数值敏感的归一化层保留fp32,避免出现颜色条纹或细节损失。

3.3 动态Shape是迁移过程中的第一道坎

昇腾NPU和GPU很不一样的一点是:NPU在做算子编译时,往往需要知道确定的shape信息。GPU上常见的做法是每个请求动态计算shape,PyTorch动态图直接执行;NPU上则倾向于走“预先编译、运行时复用”的路径。

第一次加载模型跑推理时,我们遇到了一个非常典型的场景:两个尺寸不同的输入图片,模型前后两次推理耗时差距很大。第一张图可能只要4秒,换了一张非正方形图后突然要20多秒,日志里大量出现编译相关的时间消耗。

这个问题的根因是:NPU为当前输入shape做了一次算子编译,后续遇到新shape又要重新编译,编译开销被计入了单次请求延迟。

我们的解决思路是:在服务层固定输入分辨率候选集。具体做法是维护一个包含若干个标准尺寸的列表,比如576×576,576×768,768×768等,请求进来后先按长边比例缩放到最接近的候选尺寸,再padding到标准尺寸。这样模型侧看到的输入shape集合是有限的,第一次编译后后续请求全部复用已有编译结果。

CANDIDATE_SIZES = [(576, 576), (768, 768), (768, 576), (896, 768)] def normalize_input_image(image): target_w, target_h = select_closest_size(image.size) image = resize_and_pad(image, target_w, target_h) return image

这个改动把动态shape带来的编译开销基本消除了。副作用是输出图像会有少量padding区域,在后处理时裁掉即可。

3.4 单卡单并发验证

环境改造完,我们写了一个最小测试脚本,传一张图加一句“把背景换成浅灰色”,观察第一张输出图和显存占用。

测试结果是:在576×576分辨率、20步采样的条件下,单图推理大约2.8秒,其中采样循环约占2秒,编码解码占0.8秒,NPU显存峰值约26GB。这个数值给了我们信心,单卡可以支撑服务化。

但此时只是“能跑通”,离“可服务”还差很远。因为脚本只处理一个请求,服务化之后要面对的是几十个请求排队、并发进入、超时重试等工程问题。

4. 服务化设计:HTTP接口、并发闸门与进程模型

4.1 服务框架选型与整体拓扑

服务框架我们用了FastAPI,理由有两个:一是异步支持好,在处理等待型IO时不会阻塞事件循环;二是数据校验、接口文档自动生成,省去不少周边工作。

但FastAPI本身只是Web框架,真正占用NPU做推理的是另一个独立进程。整体拓扑可以简化成这样:

用户请求 → HTTP API进程 → 内部请求队列 → 推理Worker进程 → NPU推理 → 返回结果

API进程和推理进程之间不是直接同步调用关系,而是通过一个队列解耦。这样设计的好处是:如果模型推理阻塞,HTTP层仍然能继续接收请求并排队,不会因为一个慢请求把整个服务卡死。

4.2 并发控制与队列策略

模型推理听起来是“来一个请求跑一个”,但并发太高时会有问题。diffusion模型的采样循环里要经过几十次模型前向,每一步都会使用大量计算和显存。如果直接把多个请求同时扔给同一个模型进程,显存竞争会让每个请求都变慢,极端情况直接被OOM杀掉。

我们的做法是给推理Worker加一个并发闸门:同一时刻只有一个请求在做NPU推理,其他请求在队列里等待。

import asyncio class InferenceGate: def __init__(self, worker): self.worker = worker self._lock = asyncio.Semaphore(1) async def submit(self, payload): async with self._lock: return await self.worker.infer(payload)

有人会问:“这样不就把NPU利用率降低了吗?”其实不是。文生图场景下,单卡跑扩散模型时,单请求已经能把算力打得很满。与其让两个请求抢算力、都跑得慢,不如一个按顺序跑、另一个等待,反而整体吞吐更可控。

队列长度要有上限。如果调用方短时间打入大量请求,队列一直堆积,可能导致用户等待时间远超预期,还会放大内存占用。我们设置队列最大长度50,超过后直接返回“429 Too Many Requests”,让调用方重试或降级。

4.3 API接口和推理Worker的分工

API进程只做三件事:接收请求、校验参数、把任务扔进队列并等待结果。推理Worker进程只做两件事:从队列取任务、调用模型返回结果。

如果要优化排队等待的效率,可以用异步装饰器处理长时间等待。核心结构可以这样理解:

@app.post("/v1/edit") async def edit_image(request: EditImageRequest): image = decode_base64(request.image) task = {"image": image, "prompt": request.prompt} result = await gate.submit(task) return {"output": encode_base64(result)}

这种方式让服务天然拥有了超时控制、重试和熔断的发挥空间。

4.4 部署形态:容器还是裸机

部署形态上我们一开始犹豫过。容器化部署很标准,但在昇腾NPU环境里,容器需要传递设备文件、驱动库和CANN运行时,配置复杂度比较高。如果团队比较小,迁移时间紧张,建议先直接用systemd守护进程跑,把服务化跑稳了再考虑容器化。

我们最终用的是systemd管理两个进程:一个uvicorn API进程,一个推理Worker进程。监控发现进程异常退出后,systemd会自动拉起。显存泄漏这类问题,至少不会因为单个请求把自己搞到长期不可用。

同时加了一个简单的健康检查接口,每30秒检测一次NPU是否可用、模型是否还在运行、队列积压是否正常。如果队列积压超过阈值,健康检查返回异常,前端的负载均衡会暂时切走流量。

5. 性能调优与稳定性实测:从3秒到4QPS的调整记录

5.1 先测基线,再谈优化

性能优化最忌讳一上来就各种参数乱调。我们先把基线测清楚:单卡单并发、576×576分辨率、20步采样、bf16精度,记录延迟、显存占用和整机功耗。

基线数据大概是这样:

项目基线值
单图推理平均耗时2.8秒
P95延迟3.4秒
NPU显存占用26GB
单进程内存8GB
队列长度=0时吞吐约0.35 QPS

单并发只有0.35 QPS,这个数据显然满足不了业务。但别急着上高并发,先把单请求延迟压下去,再提高并发数。

5.2 采样步数和分辨率:延迟的“大头”

扩散模型的延迟与采样步数线性相关。20步降到16步,理论上可以减少20%的采样时间,但输出质量会略有下降。这个下降不是均匀的,而是和图像内容有关。我们用同一组测试图对比了16步和20步的输出:纯色背景、简单构图场景几乎没区别;复杂前景、细碎边缘场景会出现轻微模糊。

我们的做法是:默认用16步,高精度模式用24步,让用户按需求选择。不要一刀切,让质量敏感型业务和速度敏感型业务都能接受。

分辨率对延迟的影响更直接,几乎按面积等比增长。768×768比576×576慢了约70%。所以我们在接入层做了图片尺寸上限控制,默认最长边不超过768,超过则等比缩小。

5.3 图模式与算子融合带来的收益

torch_npu直连虽然方便,但PyTorch的动态调度逻辑会有不少额外开销。我们后续尝试把模型切换到图模式执行,相当于把静态的计算图提前构建好,运行时跳过Python侧逐算子调度。

这个改造需要模型结构尽量静态。由于我们已经在服务层限定了输入shape集合,模型内部的shape路径基本固定,所以图模式切换的收益比较明显。切完图模式后,单图延迟从2.8秒降到2.2秒左右,约20%的收益。

但图模式也有坑:遇到尚未支持的特殊算子,编译会直接报错。我们的策略是保留一条动态图路径作为fallback,图模式编译失败时自动回退,保证服务不中断。

5.4 并发数、请求队列和吞吐的平衡

优化完单图延迟后,我们逐步把并发数从1调到8。结果是吞吐量先上升,然后进入平台期,到16并发时延迟变大但吞吐不再增长。

调优后的数据:

并发数平均单请求延迟整体吞吐
12.2秒0.45 QPS
42.6秒1.5 QPS
83.4秒2.3 QPS
124.5秒2.7 QPS
166.2秒2.7 QPS

结论很清楚:在单卡NPU范围内,12并发附近是收益拐点,再多并发只会增加排队延迟,不会提升总吞吐。我们最终把服务的并发闸门定在8,并用队列吸收突发流量。

6. 踩坑记录:四个真实问题及完整排查链路

6.1 坑一:前几次推理极慢,日志里全是编译时间

现象:同一张测试图,第一次请求耗时26秒,第二次开始稳定在3秒以内。换一个输入尺寸后,又出现长耗时首次推理。

一开始我们以为是环境初始化慢,直到重跑时发现耗时集中在第一次对某个新shape执行调用。通过开启CANN的调试日志,可以看到大量Compile级别日志,定位到是算子编译缓存生效问题。

修复方式是主动预热:服务启动后,自动把候选尺寸列表里的所有分辨率都跑一遍空推理,让编译结果提前生成。这样做之后,线上请求基本碰不到首次编译耗时。

这套排障思路总结成一句话:看到“第一次慢、第二次快”,先怀疑编译缓存,不要盲目优化算子。

6.2 坑二:出图颜色偏淡,还有细纹

现象:迁移到NPU后,同一份权重、同一个seed,生成的图片整体颜色偏淡,边缘还有轻微条纹。

最初怀疑是NPU算子精度问题,但单算子比对误差在可接受范围。后来发现是我们统一把模型切到bf16时,某些LayerNorm层在bf16下精度损失被累积放大,导致颜色分布偏移。

修复方式是把部分数值敏感层固定为fp32,不跟随模型整体精度设置。这要靠经验,不是每个模型都会遇到,出现颜色问题时优先排查混合精度的敏感层。

6.3 坑三:多Worker导致NPU显存翻倍,直接OOM

现象:我们最初觉得API进程多开几个Worker能提高并发,于是用gunicorn起了4个Worker进程。结果4个进程同时加载模型,NPU显存直接超了,服务在压力测试时被OOM杀掉。

排查链路很清晰:npu-smi info查看显存,发现4个进程各自占了一份模型显存,每个约26GB,4份加起来远超48GB。

修复方式:放弃“多进程加载模型”的方案,改成单Worker进程+内部队列。对外并发通过API层和队列控制,而不是通过多开进程来实现。这个坑其实在GPU部署时也存在,只是GPU显存更大容易掩盖。

6.4 坑四:上游客户端超时,引发请求雪崩

现象:某一天压测时,上游调用方设置的超时时间是5秒。当某个大图请求排队时间超过5秒后,上游主动断开连接并重新发起请求。结果新请求又进入队列排到后面,再次超时,导致同一任务被反复提交,队列积压越来越多。

排查链路:看监控发现请求QPS瞬间上升,但图片输出没有相应增长。进一步看日志,大量任务在队列中反复出现,且携带相同的request_id,定位到是上游超时重试。

修复方式:一方面建议调用方把超时时间设置为10秒以上,因为单图推理本身就要3秒;另一方面在API层增加排队位置的预估回报,如果预估等待时间超过阈值,直接返回429,而不是让调用方盲目等到超时。这个改动上线后,雪崩问题没有再出现。

7. 落地表现与后续规划

经过上面这些调整,最终跑在昇腾NPU上的Qwen-image-Edit服务基本达到了业务要求。单图平均推理耗时2.2秒,P95延迟3.5秒,支持8路并发,单卡单机整机吞吐稳定在2.5 QPS左右,连续运行两周没有出现显存泄漏或进程崩溃。

这套方案的产出不只是让业务方用上了图像编辑能力,还沉淀了一套针对昇腾NPU的推理部署流程:从环境自检、输入尺寸约束、编译预热、精度策略到队列熔断,每一步都有对应的工程文档和监控指标。后续如果再迁移其他小模型,大部分经验可以复用。

我个人的体会是,昇腾NPU的坑大多不在“跑不起来”,而在“跑起来之后的性能和稳定性”。只要把输入shape收敛住、把编译预热做掉、把并发闸门和超时策略设计清楚,小模型在NPU上的服务化是完全可以达到生产水平的。另外建议每次改动后都把延迟、显存、吞吐三个指标记录下来,改完一样就对比一次,这样可以快速判断优化方向对不对,而不是凭感觉调参数。

这个实践后续还可以往两个方向扩展:一是把采样步数做成动态调节,让请求在排队时间短的时候自动提高质量;二是加上多个模型实例的负载均衡,把单卡扩展到多卡,横向扩容会容易很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询