这段时间我们组一直在折腾一件事:把一个图像编辑服务从熟悉的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空间,同时把文本指令编码成条件向量。整个链路大致是这样的:
- 读入原始图片,做resize、归一化、padding;
- 文本指令通过tokenizer和文本编码器得到文本embedding;
- 原始图片通过VAE编码器得到图像latent;
- 如果需要区域编辑,还要把编辑mask图像编码并拼入条件;
- 将latent和条件送入扩散模型的UNet或Transformer结构,进行多次迭代采样;
- 每步采样会用调度器更新latent;
- 最终latent通过VAE解码器还原成RGB图片;
- 后处理裁剪掉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并发时延迟变大但吞吐不再增长。
调优后的数据:
| 并发数 | 平均单请求延迟 | 整体吞吐 |
|---|---|---|
| 1 | 2.2秒 | 0.45 QPS |
| 4 | 2.6秒 | 1.5 QPS |
| 8 | 3.4秒 | 2.3 QPS |
| 12 | 4.5秒 | 2.7 QPS |
| 16 | 6.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上的服务化是完全可以达到生产水平的。另外建议每次改动后都把延迟、显存、吞吐三个指标记录下来,改完一样就对比一次,这样可以快速判断优化方向对不对,而不是凭感觉调参数。
这个实践后续还可以往两个方向扩展:一是把采样步数做成动态调节,让请求在排队时间短的时候自动提高质量;二是加上多个模型实例的负载均衡,把单卡扩展到多卡,横向扩容会容易很多。