1. 端侧部署走到运行时这一步,到底在优化什么
把 Qwen3.8-Flash-Next 这类模型塞进端侧设备,很多人第一反应是“量化到 4bit 就完事了”。真跑起来才发现,权重加载没问题、显存也够,但一上并发或者长上下文,吞吐直接塌方,首 token 延迟高得离谱。问题往往不在模型本身,而在运行时调度这一层。
我自己在几台不同规格的端侧设备上折腾过这套链路,从单条推理到多路并发,踩的坑基本都集中在三个地方:解码阶段的计算密度太低、kernel 启动开销吃掉大量时间、长 prompt 的 prefill 阶段把显存顶爆。这三个问题对应的解法,恰好就是标题里的MTP、CUDA Graph和Chunked Prefill。
这篇内容适合两类人看:一类是已经把模型跑起来、但性能不达标的部署工程师;另一类是正在选型、想知道端侧推理到底该在哪些环节下功夫的技术负责人。我会把这三个机制的原理、为什么这么设计、具体怎么配、参数怎么算全部拆开讲,并且给出可以直接抄的配置和排查思路。不堆概念,只讲我实际调过的部分。
先说清楚一个前提:端侧部署和云端部署的优化逻辑是反过来的。云端追求的是极限吞吐,可以堆 batch、堆显存;端侧受限于功耗、显存带宽和散热,追求的是单位功耗下的有效吞吐和稳定的延迟表现。所以云端那套“无脑加大 batch”的思路在端侧经常适得其反,这也是为什么运行时优化在端侧格外重要。
2. MTP 多 token 预测:让解码阶段不再一次只吐一个字
2.1 自回归解码的瓶颈到底在哪
标准自回归解码每生成一个 token,都要把整个模型前向跑一遍。对于端侧设备来说,这个过程的瓶颈不在算力,而在显存带宽。每生成一个 token,都要把全部权重从显存读一遍,计算量却只有一次矩阵乘。这就是典型的 memory-bound 场景。
举个直观的例子:假设模型权重是 4GB,生成 100 个 token,理论上就要把 4GB 权重读 100 次,也就是 400GB 的显存带宽消耗。端侧设备的显存带宽通常只有几十到一百多 GB/s,这个账一算就知道为什么解码慢。
MTP(Multi-Token Prediction,多 token 预测)的思路很直接:既然每次前向都要读一遍权重,那能不能一次前向就预测出多个 token?这样权重的读取次数就被摊薄了。
2.2 MTP 的工作机制与端侧适配
MTP 的核心是在主模型之外挂几个轻量的预测头(prediction head),每个头负责预测未来第 n 个位置的 token。训练时让这些头学会预测后续 token,推理时就可以一次性拿到多个候选 token,再通过验证机制确认哪些是有效的。
在端侧部署里,MTP 的收益主要体现在两个维度:
- 权重读取摊薄:一次前向产出 k 个 token,权重读取次数从 k 次降到 1 次,理论带宽收益接近 k 倍。
- 计算密度提升:原本 memory-bound 的操作,因为一次处理多个 token,计算量上来了,反而更接近 compute-bound,GPU 利用率更高。
但这里有个关键点:MTP 不是无脑开大就好的。预测头数量越多,验证成本越高,而且接受率会下降。我实测下来,端侧设备上k=2 到 k=4是比较稳的区间,再往上收益递减明显。
2.3 MTP 参数配置与接受率调优
配置 MTP 时,最需要关注的参数是预测头数量和接受率阈值。下面是我在一台端侧设备上的实测对比:
| 预测头数量 k | 平均接受率 | 单 token 延迟 | 有效吞吐提升 |
|---|---|---|---|
| 1(基线) | - | 42ms | 1.0x |
| 2 | 78% | 26ms | 1.6x |
| 3 | 65% | 21ms | 1.9x |
| 4 | 52% | 19ms | 2.0x |
| 6 | 38% | 18ms | 1.9x |
可以看到,k=3 到 k=4 是收益拐点。k=6 时虽然单 token 延迟更低,但接受率掉得太厉害,有效吞吐反而回落。
注意:接受率高度依赖任务类型。代码生成、结构化输出这类任务接受率普遍偏高,自由文本创作接受率会低一些。上线前一定要用真实业务数据测接受率,别拿通用 benchmark 的数据直接套。
调优时我一般会做这几步:先用小批量真实请求跑一遍,统计不同 k 值下的接受率曲线;然后找到接受率还在 60% 以上的最大 k 值;最后在这个 k 值附近做微调,观察端到端延迟的稳定性。端侧设备散热有限,长时间高负载会降频,所以还要看持续跑 10 分钟后的延迟是否漂移。
3. CUDA Graph:把 kernel 启动开销压到最低
3.1 为什么端侧对 kernel 启动开销这么敏感
解码阶段每个 token 都要跑几十甚至上百个 kernel,每个 kernel 的启动都有固定开销,包括 CPU 侧的命令提交、GPU 侧的任务调度。在云端大 batch 场景下,这个开销被摊薄了;但端侧 batch 小、单次计算量小,kernel 启动开销占比可能高达 30% 以上。
我做过一个粗略统计:在某个端侧设备上,单 token 解码耗时 42ms,其中纯计算只占 28ms,剩下 14ms 全是 kernel 启动和调度开销。这个比例相当夸张,等于三分之一的算力被浪费在“排队”上。
CUDA Graph 的解法是把一整段固定的 kernel 序列录制成一个图,之后每次执行直接回放这个图,省掉逐个 kernel 的启动开销。这就像把一串零散的命令打包成一个批处理脚本,执行时一次性提交。
3.2 CUDA Graph 的捕获条件与端侧限制
CUDA Graph 不是随便就能用的,它对执行路径的静态性要求很高。捕获期间不能有动态显存分配、不能有 CPU 同步点、不能有条件分支。这对推理框架来说是个不小的约束。
在端侧部署里,我遇到的主要限制有这几个:
- 动态 shape 问题:如果每次输入的序列长度不一样,kernel 的 shape 就变了,图没法复用。解法是按 shape 分桶,把常见长度归到几个固定桶里,每个桶录一张图。
- 显存占用:每张图都要占一份显存,桶越多显存压力越大。端侧显存本来就紧张,桶的数量要克制。
- 首次捕获延迟:捕获过程本身有开销,通常几百毫秒到几秒。所以要在服务启动时预热,别等第一个请求来了才捕获。
3.3 分桶策略与显存权衡实操
分桶是 CUDA Graph 在端侧落地的关键。我的经验是按业务实际分布来分桶,而不是均匀分。比如你的业务里 80% 的请求长度在 128 到 512 之间,那就重点覆盖这个区间。
下面是我常用的一套分桶配置:
# 按序列长度分桶,覆盖常见区间 bucket_sizes = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048] # 每个桶录制一张 CUDA Graph for size in bucket_sizes: capture_graph(batch_size=1, seq_len=size)这里有个细节:batch size 也要分桶。端侧并发通常不高,batch size 分 1、2、4 三档基本够用。如果 batch 和 seq_len 都分桶,组合数会爆炸,显存扛不住。我的做法是固定 batch size 分桶,seq_len 用 padding 对齐到最近的桶。
提示:padding 会浪费一些计算,但换来的是图复用率提升。实测下来,padding 浪费的计算量通常在 10% 以内,而 CUDA Graph 带来的收益普遍在 20% 以上,这笔账是划算的。
还有一个坑:捕获时的显存状态要和回放时一致。如果捕获时用了某个显存池,回放时显存布局变了,图可能失效甚至报错。所以捕获前要把显存池固定下来,别在捕获后动态调整。
4. Chunked Prefill:长 prompt 不再顶爆显存
4.1 Prefill 阶段的显存峰值从哪来
Prefill 阶段要一次性处理整个 prompt,计算 attention 时中间激活值的显存占用和序列长度是平方关系。prompt 长度翻倍,激活值显存翻四倍。端侧显存本来就小,一个 4K 长度的 prompt 就可能把显存顶爆。
更麻烦的是,prefill 和 decode 如果混在一起调度,长 prompt 的 prefill 会阻塞后面的 decode 请求,导致延迟抖动。这在多路并发场景下特别明显。
4.2 Chunked Prefill 的分块逻辑
Chunked Prefill 的思路是把长 prompt 切成若干块,每块单独做 prefill,做完一块再处理下一块。这样显存峰值就从“整个 prompt 的激活值”降到“单块的激活值”,峰值大幅下降。
分块大小(chunk size)是关键参数。切得太小,块数多,调度开销大;切得太大,显存峰值还是高。我一般按显存预算反推 chunk size:
chunk_size = sqrt(可用显存 / 单 token 激活值系数)实际调的时候不用这么精确,先估一个值,然后压测看显存峰值和吞吐,逐步调整。端侧设备上,chunk size 在 256 到 1024 之间比较常见。
4.3 分块与调度的配合技巧
Chunked Prefill 真正发挥价值,靠的是和调度器的配合。核心思路是让 prefill 块和 decode 请求交错执行,避免长 prompt 独占计算资源。
我常用的调度策略是这样的:
- 每个调度周期,先检查有没有待处理的 prefill 块。
- 如果有,处理一个 prefill 块,然后立刻插入若干 decode 请求。
- 这样 prefill 和 decode 交替进行,decode 请求的延迟不会被长 prompt 拖垮。
下面是一个简化的调度伪代码:
while True: # 优先处理一个 prefill 块 if pending_prefill_chunks: chunk = pending_prefill_chunks.pop(0) execute_prefill(chunk) # 插入 decode 请求,保证解码延迟 for req in active_decode_requests: execute_decode(req)注意:prefill 块和 decode 的比例要动态调整。如果 decode 请求积压严重,就多跑几个 decode;如果 prefill 块积压,就适当倾斜。这个比例我一般设成 1:4 到 1:8 之间,具体看业务延迟要求。
还有个容易忽略的点:分块后的 KV Cache 要正确拼接。每块 prefill 产生的 KV 要按顺序写入缓存,后续 decode 才能正确读取。这块逻辑如果写错,会出现输出乱码或者重复,排查起来很费劲。建议在分块逻辑里加断言,校验 KV 的写入位置。
5. 三个机制怎么配合:端到端调优实录
5.1 优化顺序与依赖关系
这三个机制不是孤立的,有明确的依赖和配合关系。我的建议优化顺序是:先上 Chunked Prefill 稳住显存,再上 CUDA Graph 压启动开销,最后上 MTP 提解码吞吐。
为什么是这个顺序?因为 Chunked Prefill 解决的是“能不能跑起来”的问题,显存不稳后面都白搭;CUDA Graph 解决的是“跑得顺不顺”的问题,它要求执行路径稳定,所以要在显存策略定下来之后再上;MTP 解决的是“跑得快不快”的问题,它改变了解码逻辑,放在最后调最合适。
如果顺序反了,比如先上 MTP,解码逻辑变了,CUDA Graph 的捕获路径也要跟着变,等于白录一遍图。
5.2 端到端配置示例
下面是我在一台端侧设备上跑通的完整配置,供参考:
# 运行时配置 runtime: # Chunked Prefill enable_chunked_prefill: true max_prefill_chunk_size: 512 prefill_decode_ratio: 1:6 # CUDA Graph enable_cuda_graph: true graph_bucket_sizes: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] graph_batch_sizes: [1, 2, 4] # MTP enable_mtp: true mtp_num_heads: 3 mtp_acceptance_threshold: 0.6这套配置在实测中,相比基线(三个机制全关)的表现:
| 指标 | 基线 | 优化后 | 提升 |
|---|---|---|---|
| 首 token 延迟 | 380ms | 210ms | 1.8x |
| 单 token 解码延迟 | 42ms | 19ms | 2.2x |
| 峰值显存 | 溢出 | 稳定 | - |
| 持续吞吐 | 不稳定 | 稳定 | - |
5.3 调优过程中的取舍
调优从来不是把所有参数拉满。我踩过的一个典型坑是:为了追求吞吐,把 MTP 的预测头开到 6,结果接受率掉到 38%,端到端延迟反而比 k=3 时更高。后来老老实实回到 k=3,接受率 65%,综合表现最好。
另一个坑是 CUDA Graph 的桶分得太细。一开始我按 64 的步长分桶,从 64 分到 2048,结果录了 30 多张图,显存直接被图占满。后来改成按业务分布分桶,只保留 11 个桶,显存压力立刻缓解。
提示:端侧调优的核心原则是够用就好,别追求理论最优。端侧设备的资源边界很硬,参数拉满往往触发降频或者显存溢出,反而得不偿失。
6. 常见问题与排查速查
6.1 典型问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 开启 CUDA Graph 后报错 | 执行路径有动态分配 | 检查捕获期间是否有动态显存操作 |
| MTP 接受率异常低 | 预测头与主模型不匹配 | 确认预测头版本与主模型一致 |
| Chunked Prefill 后输出乱码 | KV Cache 拼接错误 | 校验分块 KV 的写入顺序 |
| 长 prompt 仍然溢出 | chunk size 过大 | 减小 chunk size 重测 |
| 持续跑延迟漂移 | 设备降频 | 监控温度,降低并发或加散热 |
| 首 token 延迟高 | 图未预热 | 服务启动时预热所有桶 |
6.2 几个容易忽略的排查技巧
技巧一:用显存快照定位峰值。端侧显存小,溢出往往发生在某个瞬间。我会在关键节点打显存快照,找到峰值出现的具体位置,再针对性优化。
技巧二:分离测量 prefill 和 decode。很多人把端到端延迟当成一个整体看,其实 prefill 和 decode 的瓶颈完全不同。分开测,才能知道该优化哪一段。
技巧三:接受率要分任务统计。MTP 的接受率在不同任务上差异很大,混在一起统计会掩盖问题。按任务类型分开看,才能找到真正适合开 MTP 的场景。
技巧四:CUDA Graph 捕获失败要看日志。捕获失败通常有明确的原因,比如“dynamic allocation detected”,顺着日志查基本都能定位。
6.3 端侧特有的注意事项
端侧设备和云端最大的区别是资源边界硬、散热受限。我总结了几条端侧特有的注意事项:
- 别长时间满负载跑:端侧设备散热能力有限,持续满负载会降频,延迟漂移。建议留 20% 的性能余量。
- 显存要留安全垫:端侧显存通常和系统共享,别把显存用满,留 10% 到 15% 的余量给系统。
- 功耗模式要确认:有些端侧设备有省电模式,会限制算力。部署前确认设备跑在性能模式。
- 温度监控要加上:把温度纳入监控指标,温度过高时主动降并发,避免触发硬件保护。
7. 我在端侧调优中的几点体会
折腾这套运行时优化,最大的感受是:端侧部署的难点不在模型,而在对资源边界的理解。云端可以靠堆资源解决问题,端侧不行,必须把每一份算力、每一兆显存都算清楚。
MTP、CUDA Graph、Chunked Prefill 这三个机制,本质上都是在用工程手段弥补硬件短板。MTP 弥补显存带宽不足,CUDA Graph 弥补 kernel 启动开销占比过高,Chunked Prefill 弥补显存容量有限。理解了这一点,调优时就不会盲目拉参数,而是知道每个参数背后的权衡是什么。
最后分享一个小技巧:调优时一定要建立基线。每次只改一个变量,记录前后对比。端侧环境变量多,一次改多个参数,出了问题根本不知道是哪个引起的。我一般会维护一个调优记录表,把每次改动的参数、观察到的现象、最终结论都记下来,下次遇到类似问题直接查表,效率高很多。
这套配置后续还可以往两个方向扩展:一是结合量化进一步压缩显存,给 MTP 和 CUDA Graph 腾出更多空间;二是做动态参数调整,根据实时负载自动切换 chunk size 和 MTP 头数。端侧场景千差万别,没有一套配置能通吃,关键是把原理吃透,然后按自己的业务特点去调。