昇腾950PR上Qwen3-27B解码性能优化实战:从带宽瓶颈到连续批处理 📅 发布时间:2026/9/16 23:30:28 👁 浏览次数: 最近一直在搞昇腾950PR上大模型部署的事情手上的模型是千问3系27B参数版本也就是常说的Qwen3-27B我在工单里写的全名是“Qwen3.8-27B”其实指的就是同一份权重。这轮测试的目标很单纯把decode阶段的性能优化做实同时把优化方法、测试脚本和踩坑记录都沉淀下来免得下次换卡换模型又从头摸。对于没做过大模型推理优化的朋友decode可以理解成模型“逐字吐答案”的过程。大模型一次只能生成一个token生成完才能继续下一个没法并行。线上服务里用户等首字往往比其他阶段慢但更让用户着急的是后续每个字出来的间隔。所以这轮性能优化把大头放在decode上而不是一开始就去抠prefill。这篇记录的适合人群大致是在用昇腾环境做千问Qwen一类开源模型部署的工程师想了解decode优化手段选型的架构师以及刚入门推理性能优化、想知道“到底该往哪使劲”的新人。整个测试过程用的都是常见工具和命令不依赖特殊环境照着操作基本能复现。1. 为什么偏偏要盯住 decode 阶段1.1 prefill 和 decode 的瓶颈不一样大模型推理被切成两个阶段prefill 阶段处理用户输入的 prompt一次性把所有 token 并行计算所以它是计算密集型的芯片利用率高吞吐也高decode 阶段则根据之前所有 token 去预测下一个 token每次只能推进一个 token数据复用度极低。这里有个非常关键的硬件视角。27B 模型即便是 FP8 量化权重也有约 27GB。decode 每生成一个 token理论上都得把这几十 GB 权重从头到尾读一遍。而单张昇腾950PR 的 HBM 带宽再高也没有快到能让 27GB 权重在 1 毫秒内读完。所以 decode 天然是访存受限的不是算力受限。很多人一开始把优化精力放在矩阵算子上发现提升有限就是因为没意识到瓶颈在内存带宽。用生活经验类比prefill 像是一车货一次性送进仓库decode 像仓库管理员一件一件把货拿给客户。管理员再快也要受仓库出口通道宽度限制这就是 HBM 带宽。所以 decode 性能优化的核心思路不是让管理员手速更快而是让通道尽量满载一次多服务几个客户。1.2 这次测试想回答什么问题性能优化不能稀里糊涂做。我在开工前把目标写成了三句话确认昇腾950PR 在 decode 场景下真实的吞吐和延迟基线验证 FP8/INT8 量化、连续批处理、prefill 与 decode 分离等方法对 decode 的实际收益整理一套可复用的测试脚本和排查手册后续 Qwen 换版本、换任务类型时能快速复用。指标上我最关心的不是单次最快而是三个首 token 延迟TTFT、平均生成吞吐token/s以及 P95/P99 的尾延迟。只优化一个指标往往会让整体变糟比如无脑增加并发单请求变慢用户照样觉得难用。平衡点就是我们这轮要找到的东西。2. 测试环境准备好才敢说后面数字有意义2.1 硬件与软件栈测试服务器是 Atlas 系列整机上面插了昇腾950PR 推理卡单卡显存是 64GB 配置不同固件批次可能有差异。卡间走 HCCS和 CPU 之间通过 PCIe 访问这套拓扑在昇腾服务器里算很常见。系统是 Ubuntu 22.04CANN 包直接用的官方发布版本。补充一句昇腾上的推理栈和 CUDA 生态不太一样。习惯 PyTorch vLLM 的朋友刚上手会不习惯这里一般用 MindIE 推理引擎PyTorch 侧通常配 torch_npu 跑训练或在线调试部署推理路径建议直接用 MindIE 的容器镜像镜像里已经把驱动、固件、CANN 和 MindIE 绑定好了。我第一次为了图省事在宿主机上硬装结果版本一乱报错查了大半天。后来改成容器挂载方式干净利落。关键版本信息我列一个简表方便复现组件本次版本说明驱动/固件随固件包更新别轻易混插旧版本CANN8.0 主干版本以上低于该版本对长序列支持不完整MindIE官方发布容器包含全套运行环境模型Qwen3-27B千问 3 代 27B 稠密版基准脚本自写 Python支持流式请求计时注意版本号不用严格照抄但最好别低于表格里的主干版本否则一些优化开关会缺失测出来的数据也没有可比性。2.2 模型获取与量化准备模型从 ModelScope 或 Hugging Face 下载都可以。国内网络环境下 ModelScope 会更稳我平时习惯用 hf 的 download 命令核心代码其实差不多。给一个 hf 侧的示例huggingface-cli download Qwen/Qwen3-27B \ --local-dir /data/models/qwen3-27b如果是 ModelScope可以换成modelscope download --model Qwen/Qwen3-27B --local_dir /data/models/qwen3-27b下载完成后先检查目录是否完整尤其是 safetensors 分片文件数量和 config.json 里的架构字段。很多部署问题都是权重文件不全导致的比如只有 4 个分片却只下载了 2 个加载时既不报错也不警告推理结果却乱七八槽。量化选择上第一轮我用了 FP8第二轮测了 INT8。FP8 对生成质量影响小带宽又能比 FP16 省一半非常适合 decode 优化。INT4 虽然更省显存但 27B 这种规模直接 INT4长文本生成时会出现可感知的重复和逻辑塌缩我建议做生产场景至少从 INT8 起步追求极致吞吐再考虑 INT4。2.3 压测脚本应该量什么性能测试最怕的就是脚本把指标算错。我写压测脚本只干三件事发请求、解析流式返回、记录时间戳。脚本要支持并发和固定 max_tokens控制变量。核心部分大概长这样import time, requests def single_request(prompt, max_tokens128): url http://127.0.0.1:8000/v1/chat/completions payload { model: qwen3-27b, messages: [{role: user, content: prompt}], max_tokens: max_tokens, temperature: 0, stream: True, } t0 time.perf_counter() first_token_time None token_count 0 with requests.post(url, jsonpayload, streamTrue) as resp: for line in resp.iter_lines(): if not line: continue text line.decode(utf-8) # SSE 数据行以 data: 开头最后一个 data: [DONE] if text.startswith(data:) and [DONE] not in text: if first_token_time is None: first_token_time time.perf_counter() - t0 token_count 1 t_total time.perf_counter() - t0 return { ttft: first_token_time, latency: t_total, tokens: token_count, decode_tps: (token_count - 1) / (t_total - (first_token_time or 0)), }跑用例时我会固定 prompt 长度默认 256 token输出长度 128连续跑 50 次取均值。并发测试由外层循环控制比如 4、8、16、32 路同时发统计每路平均值和整体吞吐。这套逻辑简单但比用 ab 压 HTTP 接口准确因为 SSE 流里的 token 粒度才是模型真实输出粒度。2.4 没优化之前先看原生表现有多差不做任何调优直接拿 FP8 权重跑单流请求结果很能说明问题指标数值prefill 处理速度约 650 token/s首 token 延迟256 prompt0.41s 左右decode 平均速度15.8 token/s单请求每 token 时间约 63msNPU 利用率decode 阶段7%-12%decode 阶段利用率只有 7%这不是卡坏了恰恰是访存受限的典型现象AI Core 在等数据从 HBM 搬过来大部分时间闲等。看到这个数字我心里反而有底了知道优化空间还在方向也明确想办法让显存带宽“满载”。3. decode 性能优化的三板斧3.1 第一板斧连续批处理让通道别闲着既然瓶颈是内存带宽最直接的办法就是让一个解码步里同时处理多个序列也就是把多个用户的请求合并进同一个 batch。这样权重只有一份但多个序列可以共享读取开销显存带宽利用率自然上去了。思想上和快餐店排队很像收银员NPU接待得再快如果每次只服务一个顾客大部分时间还是在等待顾客选菜。连续批处理相当于把几个顾客同时放进收银区选菜时间重叠了收银员几乎一直在干活。昇腾推理引擎里的做法是开启动态批。MindIE 里对应有几个配置项比如 max_batch_size、max_seq_len、调度策略等。压测时我从 batch1 开始逐步加到 4、16、32同时观察每个请求的 token 速度。加了连续批处理之后整体的每秒 token 数呈接近线性的增长单请求速度会有轻微下降但用户完全无感知。需要特别注意的是连续批处理不是无脑堆 batch。显存里最大的吞金兽除了权重还有每个序列独立的 KV cache。batch 越大KV cache 占的显存越多一旦显存爆了触发换出延迟反而急剧劣化。这是后面会展开讲的坑。3.2 第二板斧prefill 和 decode 解耦处理prefill 和 decode 的计算特点完全不同把它们混在一个请求里调度器就要频繁切换模式产生气泡。比如一个超长 prompt 的 prefill 进来会把正在 decode 的请求挤到边上导致所有用户都卡一下。更合理的做法是把两个阶段分开处理业内叫 prefill 和 decode 分离甚至拆成两套实例。我在这轮测试里先做了进程内分离prefill 阶段预留独立的计算通道decode 阶段用连续批处理去跑中间通过队列衔接。效果挺明显P95 的 TTFT 从 1.2 秒左右降到了 0.6 秒以内同时长 prompt 请求对短请求的影响也小了很多。这个环节的教训是分离一定先看用户的请求分布。如果平时 prompt 都很短prefill 只占极短时间强行分离反而增加一次调度和拷贝开销。像我们业务里有人偶尔上传几千字的上下文分离的收益就非常可观。3.3 第三板斧量化把权重“瘦身”decode 是带宽瓶颈所以权重越小每个 token 需要搬运的数据就越少速度提升非常直接。FP16 到 FP8权重读取量直接减半decode 速度理论上接近翻倍。实际测下来虽然没有两倍那么夸张但收益也非常可观。昇腾上做量化权重侧我用的 FP8 静态量化KV cache 侧也可以量化到 INT8。KV cache 量化在长上下文场景下能省不少显存但会带来轻微精度损失具体是否接受得看模型任务。我的经验是先只量化权重等确认质量下降在可接受范围再决定要不要动 KV cache。算子层面也有优化空间比如把 QKV 三个矩阵乘合成一个算子减少多次启动的开销把 RMSNorm 和量化 op 融合到上一轮计算里减少中间张量的搬运。昇腾里这些融合很多在 MindIE 默认图优化里已经做了不需要手写算子只有到后期追求极限吞吐才值得去用自定义算子或者自动化调优工具做进一步分析。3.4 还有两块容易被忽略的图模式与亲和性MindIE 默认会把模型编译成静态图图优化会在编译时把能合并的计算节点合并掉这比逐个算子执行少了大量内核启动开销。我见过有人为了图快用了即时执行模式结果 decode 反而慢了一截原因就是图优化没吃上。CPU 侧的亲和性绑定同样不能忽视。昇腾 950PR 插在服务器上如果 CPU 和卡不在同一个 NUMA 节点每次通信都要跨节点绕延迟多出几十微秒。decode 场景对单步延迟敏感几十微秒已经不能忽略。做法是把服务进程绑定在设备对应 NUMA 节点上同时把 IO 线程和计算线程分开绑核避免线程在 CPU 核心间迁移造成缓存抖动。3.5 KV cache 显存规划要提前算推理引擎里 KV cache 的显存占用不是小事尤其是在多路并发下。粗略公式是每个 token 的 KV cache 字节数等于 2 乘以层数乘以 KV head 数乘以 head 维度再乘以单元素字节数。27B 模型按常见配置计算4K 上下文和 32K 上下文占用的显存能差出去几十 GB。所以启动服务之前一定要先按公式估算再决定 max_batch_size 和 max_seq_len 的组合。我习惯的做法是把 KV cache 的预算控制在总显存的 30%-40%权重占 50% 左右剩下留作中间激活和碎片缓冲。如果预算超了优先压 max_seq_len而不是压 batch。因为长序列请求往往是少数限制它影响的用户有限而 batch 直接决定吞吐上限压得太狠整体性能就上不去。4. 优化后的实际数据4.1 一轮轮加效果怎么变我把整个过程拆成 5 个阶段每个阶段只加一类优化方便看清每项的真实贡献配置单流 decode (token/s)batch16 总吞吐 (token/s)TTFT P95 (s)FP16 原生11.2-1.34FP8 基础15.82061.18FP8 连续批处理14.94861.05FP8 批处理 分离14.75120.63最终 图优化/亲和性16.35480.55看到这个表的读者先别急着去对绝对值。这是我们在自己机器、自己固件版本下测出来的昇腾卡在不同批次固件之间性能可能有浮动。我更想让大家看的是趋势连续批处理把总吞吐拉高了prefill 和 decode 分离把尾延迟压了下来亲和性和图优化则稳定了单流指标。单流 decode 在最终配置下是 16.3 token/s看着不高对吧但这里需要说明单流本身就不是 decode 优化的主要目标。在真实服务里同一时刻通常有几十个请求在批处理里跑平均每路请求的速度会随着 batch 增大下降但整体吞吐持续上升。单纯盯着单流数字评价一个服务容易误判。4.2 并发从 1 加到 128延迟的临界点在哪并发压测是这次测试最花时间的部分。我从 batch1 一路加到 128记录两件事每路请求的平均 decode 速度以及全系统的总吞吐。batch1 时每路能有 15-16 token/s总吞吐只有 15 token/sbatch8 时每路还有 11 token/s总吞吐上升到 90batch32 时每路 7 token/s总吞吐冲到 220 左右batch128 时每路降到 3 token/s 出头总吞吐反而开始波动偶尔出现明显抖动。这个临界点在哪非常关键。我把 batch 加到 128 后观察 NPU 显存发现剩余容量只剩个位数 GBKV cache 开始在边缘试探。一旦有一两个长序列把 KV cache 撑爆引擎要做显存迁移或者重新调度整个服务的延迟曲线就会掉头向上。所以最后我选择了 batch64 附近作为生产阈值的参考点既保留一定余量也保证 P99 延迟不失控。4.3 稳定性波动和长上下文压力短 prompt 压测只是开胃菜长上下文才是 decode 的修罗场。我额外用 4K 和 8K 长度的输入各跑了一轮发现 decode 速度会随序列长度缓慢下滑。原因很直接序列越长KV cache 越大每一步计算时对 KV cache 的读写量也在涨带宽竞争更激烈。这就引出一个容易被忽略的经验性能测试报告的“decode token/s”必须注明上下文长度。同一个模型接 256 token 和接 8K tokendecode 速度能差 30% 以上。如果不写明条件拿短上下文的数据去评估长文档产品排产和容量规划都会失算。5. 实战中踩过的坑和排查技巧5.1 显存 OOMKV cache 配置不合理刚开始我把 max_seq_len 设成和模型训练长度一致比如 32K结果显存预分配直接在启动阶段爆掉。后来才明白KV cache 的显存占用是 2 乘层数乘 KV heads 乘 head_dim 乘序列长度乘 batch 的乘积序列长度翻一倍显存占用就翻一倍而不是线性增长里的小数点。调整策略是把 max_seq_len 按业务实际需求来比如先定 8K把 KV cache 量化打开再根据剩余显存调整 batch。另外 MindIE 有显存分配策略配置建议让服务使用静态内存池而不是频繁申请释放否则运行几小时后碎片化会让 OOM 提前到来。5.2 decode 输出突然开始重复或乱码有一版对比测试我用 temperature0.7 但没关闭采样结果同一个问题在不同并发下输出质量差别很大甚至出现整段重复。排查了很久才发现是基准脚本里没有把 do_sample 设为 false导致贪婪解码失效模型在采样路径上随机性放大。这种问题排在 trace 里极难定位因为不是每次必现只有高并发或长输出时才明显。建议所有 benchmark 脚本显式传 greedy 参数把采样参数固定死。毕竟稳定性测试不允许输出带随机性否则 token 数和时间戳全是噪声。5.3 NPU 利用率上不去先查数据搬运有一轮优化后 AI Core 利用率卡在 30% 上不去怎么加 batch 都不见涨。后来用 profiling 工具看时间线发现相当比例时间花在 host 端数据搬运和设备端内存拷贝上。根因是服务脚本每轮请求都重新构造输入张量频繁做 CPU 到 NPU 的跨设备拷贝。解决方法是把输入处理也放进图里减少跨设备往返同时在批量入口处统一拼接 token而不是逐条请求拼接。改完之后利用率跳到 60% 以上decode 吞吐也跟着上去了。5.4 多实例部署时每张卡都成了“独行侠”并发测试后半段我打算多实例部署每张卡独立跑一份模型服务再用负载均衡把请求分发出去。结果发现两卡之间性能差了不少查下来是一张卡的 PCIe 链路降速了另一张则和 CPU 抢占 NUMA 资源。昇腾排查步骤比较固定先用 npu-smi info 看每张卡的温度、功耗和链路速率确认降速或数位异常再用 numactl --hardware 查 CPU 和设备的 NUMA 关系把进程绑到正确的节点。多实例部署时每张卡都绑独立 CPU 核心组否则临界资源一挤性能就会互相拖累。6. 这轮测试留下的几点经验如果让我把这次昇腾950PR 的 decode 性能优化浓缩成一句话那就是先搞清楚瓶颈在带宽还是算力再做对应优化。27B 模型 decode 阶段访存受限因此所有能减少权重搬运、能让带宽满载、能减少调度气泡的手段都有收益反过来单纯调矩阵算子、堆算力很可能事倍功半。具体落地时我的优先级是量化瘦身优先连续批处理次之prefill/decode 分离按业务需求决定图和亲和性属于“白捡的优化”顺手就做。显存规划和压力测试一定跑在扩容之前不要拿 4K 上下文的数据去估算 32K 场景。后续我打算继续折腾两块一是投机采样用一个小模型做草案、大模型做校验理论上能让单流 decode 再上一个台阶二是把长上下文的 KV cache 量化方案完全跑通目标是让 27B 模型稳定服务 32K 上下文而不降质。目前还没有完全调完等数据有了再来更新这篇记录。