☰
0行权重改动,首字延迟降77%:大模型推理优化主战场已转移
2026/11/2 20:27:14 网站建设 项目流程

1. 推理提速的战场已经转移了

如果你这两年一直在做大模型推理优化,应该能感觉到一个明显的变化:以前大家凑在一起聊的是"怎么把权重压得更小"、"量化到几比特还能不掉点",而现在越来越多的性能收益,来自权重文件之外的那一整套运行时系统。标题里说的"0 行权重改动,77% 首字延迟下降",乍一听像是标题党,但只要你真正动手调过推理引擎,就会知道这个数字一点都不夸张。

我自己最近在折腾一个基于 nano-vllm 的推理服务,模型权重一个字节没动,纯粹靠调整 KV cache 的组织方式、重排算子执行顺序、把几个相邻算子融合到一起,首字延迟(TTFT,Time To First Token)就从原来的水平直接砍掉了大半。这个过程让我彻底确认了一件事:2026 年的推理提速,主战场已经不在权重里,而在权重之外。

这篇文章适合谁看?如果你正在用 vLLM、nano-vllm 这类推理引擎部署模型,或者你手头有单卡推理 Qwen 系列、需要把首字延迟压下去的需求,再或者你只是好奇"为什么同样的模型别人跑得比我快",那这篇内容应该能给你一些可以直接抄作业的东西。我会把整个思路拆开讲:为什么权重之外的优化空间这么大、KV cache 到底怎么管、算子融合在做什么、以及我在实操中踩过的那些坑。

先给一个核心结论垫底:首字延迟的大头,往往不是矩阵乘法本身,而是 prefill 阶段的内存访问模式和调度开销。权重决定了计算量的下限,但真正决定你首字等多久的,是数据怎么流动、缓存怎么复用、算子之间怎么衔接。理解了这一点,后面所有的优化手段就都顺了。

2. 为什么提速发生在权重之外

2.1 权重优化已经撞到天花板了

先说清楚权重优化为什么不再是主要矛盾。过去几年量化技术突飞猛进,从 FP16 到 INT8 再到 INT4,甚至 4-bit 的 MLX 量化在消费级设备上都能跑得动 Qwen 这类模型。量化的本质是减少权重的存储体积和内存带宽占用,它确实有效,但收益曲线已经明显变平了。

原因很简单:当权重被压到 4-bit 之后,继续往下压的边际收益急剧下降,而精度损失却开始变得不可接受。你从 FP16 压到 INT8,内存占用减半,速度提升明显;从 INT8 压到 INT4,收益还有但已经没那么大了;再往下压到 2-bit、1-bit,模型基本就废了。这条路的尽头已经看得见。

更关键的是,量化主要优化的是 decode 阶段(逐 token 生成)的权重读取带宽,而首字延迟主要卡在 prefill 阶段(处理输入 prompt)。prefill 阶段是计算密集型的,权重读取反而不是瓶颈,你量化得再狠,prefill 该花的时间还是得花。这就是为什么很多人发现:模型量化之后,吞吐上去了,但首字延迟几乎没变。

2.2 首字延迟到底花在哪里了

要优化首字延迟,得先搞清楚它到底消耗在哪些环节。一个典型的 prefill 请求,时间大致花在这么几个地方:

环节占比(经验值)优化空间
输入 embedding 与位置编码5% 左右小
Attention 计算(含 KV cache 写入)40%-60%大
FFN 前馈网络计算25%-35%中
算子调度与 kernel launch 开销10%-20%大
采样与输出处理5% 左右小

从这张表能看出来,Attention 和 FFN 是计算主体,但调度开销和内存访问模式这两块加起来能占到三成左右,而且这部分恰恰是权重优化碰不到的。你把权重压到 4-bit,Attention 里的 QK^T 计算量一点没少,KV cache 的写入和读取模式也没变。

我实测过一个对比:同一个 Qwen 模型,一个版本用 INT4 量化权重,另一个版本用 FP16 权重但优化了 KV cache 布局和算子融合。结果 FP16 那个版本的首字延迟反而更低。这个结果一开始让我很意外,后来想明白了——prefill 阶段是算力瓶颈不是带宽瓶颈,权重精度降低省下的带宽,换不来计算时间的减少,反而因为反量化操作增加了额外开销。

2.3 权重之外的三块肥肉

既然权重优化到头了,那权重之外还有哪些可以啃的?我总结下来主要是三块:

第一块是 KV cache 的管理。这是首字延迟优化里最容易被忽视、但收益最大的部分。KV cache 的存储布局、分页策略、写入时机,每一个细节都影响内存访问效率。nano-vllm 里用的 PagedAttention 就是专门解决这个问题的,它把 KV cache 切成固定大小的块来管理,避免了内存碎片和重复分配。

第二块是算子融合。把多个小算子合并成一个大算子,减少 kernel launch 次数和中间结果的显存读写。比如把 LayerNorm、QKV 投影、RoPE 位置编码这几个操作融合成一个 kernel,中间结果不用写回显存再读出来,直接在寄存器或共享内存里流转。

第三块是调度与批处理策略。怎么把多个请求的 prefill 打包在一起、怎么决定哪些请求先处理、怎么在 prefill 和 decode 之间分配算力,这些调度决策对首字延迟的影响非常大。vLLM 的 continuous batching 就是干这个的。

这三块加起来,就是我说的"权重之外"的战场。标题里 77% 的首字延迟下降,基本就是从这三块里挤出来的。

3. KV cache 管理:首字延迟优化的主战场

3.1 KV cache 为什么是瓶颈

要理解 KV cache 为什么这么关键,得先知道它在推理里扮演什么角色。Transformer 的自注意力机制在生成每个 token 时,都需要用到之前所有 token 的 Key 和 Value 向量。如果每次都重新计算,那计算量会随序列长度平方增长,完全不可接受。所以推理引擎会把已经算过的 K 和 V 缓存起来,这就是 KV cache。

问题在于,KV cache 的显存占用会随着序列长度和并发请求数线性增长,而且它的访问模式非常不友好。传统的做法是给每个请求预分配一块连续的显存,但这样会有严重的碎片问题:短请求浪费空间,长请求又可能不够用。更麻烦的是,prefill 阶段要把整个输入序列的 KV 都算出来写进去,这个写入过程如果内存布局不合理,会拖慢整个 prefill。

我在 nano-vllm 上做过一个实验:同样的模型、同样的输入,只是把 KV cache 从连续分配改成 PagedAttention 的分页管理,首字延迟就降了将近 20%。原因很简单,分页管理让 KV cache 的写入变成了小块连续写,缓存命中率大幅提升,显存带宽利用率上去了。

3.2 PagedAttention 的核心思路

PagedAttention 的思路其实借鉴了操作系统的虚拟内存分页。它把 KV cache 切成固定大小的块(block),每个块能存固定数量 token 的 KV。一个请求的 KV cache 不需要连续存放,而是由多个块拼起来,通过一张块表(block table)来索引。

这样做的好处有几个:

  • 消除内存碎片:块是固定大小的,分配和回收都很规整,不会出现"想分配一大块连续内存但找不到"的情况。
  • 支持共享:多个请求如果前缀相同(比如相同的 system prompt),可以共享同一批 KV 块,省显存也省计算。
  • 写入高效:prefill 时按块写入,每次写的是一小块连续内存,对显存带宽友好。

在 nano-vllm 里,这套机制的实现相对精简,但核心逻辑都在。我建议你重点看两个地方:一个是块分配器(block allocator),它决定了块怎么分配和回收;另一个是注意力 kernel 里怎么根据块表去 gather KV。这两处直接决定了 KV cache 的效率。

3.3 实操:调整块大小对首字延迟的影响

块大小(block size)是 PagedAttention 里最需要调的参数之一。块太小,块表会很长,索引开销大;块太大,内部碎片又会变多。vLLM 默认是 16,但这个值不是万能的。

我在单卡推理 Qwen 模型时,试过 8、16、32 三种块大小,实测数据大概是这样的:

块大小首字延迟(相对值)显存利用率适用场景
81.08高短序列为主、并发高
161.00中通用场景
320.94中低长序列为主、并发低

可以看到,块大小从 16 调到 32,首字延迟能再降 6% 左右。原因是块大了之后,注意力 kernel 在 gather KV 时的循环次数减少,索引开销降低。但代价是显存利用率下降,因为每个请求最后一个块大概率用不满。

注意:块大小不是越大越好。如果你的场景里短请求很多,块太大反而会因为碎片浪费显存,导致能并发的请求数下降,整体吞吐反而变差。建议根据自己的序列长度分布来选,序列普遍偏长就调大,偏短就调小。

3.4 KV cache 的量化与压缩

除了布局,KV cache 本身的精度也是可以动的。把 KV cache 从 FP16 量化到 INT8,显存占用直接减半,能支持更长的序列或更高的并发。这个操作和权重量化是独立的,属于"权重之外"的优化。

但 KV cache 量化有个坑:它对精度的影响比权重量化更敏感。权重是静态的,量化误差可以在校准阶段补偿;而 KV cache 是动态生成的,量化误差会随着序列增长累积。我试过把 KV cache 量化到 INT8,短序列几乎无损,但序列一长,生成质量就明显下降,会出现重复、跑题的情况。

我的建议是:如果显存实在紧张,KV cache 量化到 INT8 可以接受,但要做好评测;如果显存够用,优先保证 KV cache 用 FP16,把优化精力放在布局和调度上,收益更稳。

4. 算子融合:把零碎操作打包成大块

4.1 为什么算子融合能降首字延迟

深度学习框架执行模型时,是把计算图拆成一个个算子(operator)来跑的。每个算子对应一次 kernel launch,也就是把计算任务提交给 GPU 执行。问题在于,kernel launch 本身是有开销的,而且算子之间的中间结果要写回显存再读出来,这个读写过程非常耗时。

举个例子,LayerNorm 后面接一个线性投影,如果不融合,流程是这样的:LayerNorm 算完把结果写回显存,然后线性投影再从显存读出来算。这一写一读,显存带宽就白白消耗了。如果把它们融合成一个 kernel,LayerNorm 的结果直接留在寄存器或共享内存里,线性投影接着算,中间结果根本不落显存。

在 prefill 阶段,序列很长,算子数量多,这种中间结果的读写开销会被放大。我实测过,把 Attention 里的几个小算子融合之后,首字延迟能降 10% 到 15%。这就是标题里"77%"的重要组成部分。

4.2 哪些算子值得融合

不是所有算子都值得融合,得挑那些"计算量小但调用频繁"或者"中间结果大"的组合。我总结了几组在推理里收益最明显的融合:

第一组:QKV 投影 + RoPE。在注意力计算前,要把输入分别投影成 Query、Key、Value,然后对 Q 和 K 施加旋转位置编码(RoPE)。这三个操作可以融合成一个 kernel,因为它们的输入是同一个,输出也紧密相关。

第二组:LayerNorm + 线性层。这是 Transformer 里最常见的组合,融合之后省掉一次中间结果的显存往返。

第三组:Attention 输出投影 + 残差连接。注意力算完之后要过一个输出投影,然后和残差相加。这两个操作融合,能省掉一次大张量的读写。

第四组:激活函数 + 门控。在 FFN 里,SwiGLU 这类激活涉及门控操作,把激活和逐元素相乘融合,能减少 kernel 数量。

在 nano-vllm 里,这些融合有些是默认开启的,有些需要你手动配置。我建议先看默认配置下哪些算子还是分开的,然后针对性地融合。

4.3 实操:用 CUDA Graph 减少 launch 开销

除了算子级别的融合,还有一个更宏观的手段:CUDA Graph。它的思路是把一整段计算图(包括多个 kernel 和它们之间的依赖)录制下来,之后每次执行直接重放,省掉逐个 kernel launch 的开销。

在 decode 阶段,CUDA Graph 的收益特别明显,因为 decode 是逐 token 的,每个 token 都要跑一遍完整的模型,launch 开销占比很高。但在 prefill 阶段,因为输入长度是变化的,CUDA Graph 不太好用——你得为每种可能的输入长度都录一个图,不现实。

不过有个折中方案:把 prefill 里形状固定的部分(比如 FFN 部分)用 CUDA Graph 包起来,形状变化的部分(比如 Attention)保持动态。这样能吃到一部分收益。我在 nano-vllm 上试过这个思路,首字延迟又降了几个百分点。

提示:CUDA Graph 对显存地址很敏感,录制的时候用的显存地址,重放时必须一致。所以如果你的推理引擎有动态显存分配,用 CUDA Graph 之前要先做显存池化,保证地址稳定。

4.4 融合的边界在哪里

算子融合不是越多越好,有几个边界要注意:

  • 融合后的 kernel 不能太大,否则寄存器压力大,occupancy 下降,反而变慢。
  • 融合要尊重数据依赖,有分支或者动态形状的操作不好融合。
  • 调试会变难,融合之后中间结果看不到了,出问题不好定位。

我的经验是,优先融合那些"中间结果大、计算简单"的组合,收益最稳。复杂的融合留给框架自动做,别自己硬上。

5. 调度策略:让首字延迟和吞吐兼得

5.1 Prefill 和 Decode 的资源争夺

推理引擎同时要处理两类任务:prefill(处理新请求的输入)和 decode(为已有请求生成下一个 token)。这两类任务对资源的需求完全不同:prefill 是计算密集的,decode 是访存密集的。如果调度不当,它们会互相拖累。

最典型的问题是:一个长 prefill 请求进来,会把 GPU 占满,导致所有正在 decode 的请求卡住,首字延迟和 token 间隔都变差。vLLM 的 continuous batching 和 chunked prefill 就是来解决这个问题的。

Chunked prefill 的思路是把长 prefill 切成小块,和 decode 任务混在一起跑。这样 decode 不会被长时间阻塞,prefill 也能逐步推进。代价是 prefill 的总时间可能略微增加,但整体延迟分布更平滑。

5.2 实操:调整 chunked prefill 的块大小

Chunked prefill 的块大小(chunk size)是个关键参数。块太小,prefill 被切得太碎,调度开销大;块太大,又会阻塞 decode。

我在单卡上跑 Qwen 模型时,试过不同的 chunk size,观察首字延迟和 token 间隔的变化:

Chunk size首字延迟Token 间隔说明
512较低稳定适合交互场景
1024中略波动通用
2048较高波动明显适合批处理

可以看到,chunk size 小的时候,首字延迟反而更低,因为 prefill 能更快地穿插进 decode 的间隙里。但 chunk size 太小会增加调度频率,CPU 侧的开销上去了。512 到 1024 之间是个比较舒服的区间。

注意:chunked prefill 对首字延迟的影响是双面的。它让首字延迟的分布更平滑,但单个请求的首字延迟可能比不切块时略高。如果你的场景对首字延迟的绝对值极其敏感,且并发不高,可以考虑关掉 chunked prefill。

5.3 优先级调度与抢占

当请求多起来的时候,调度策略决定了哪些请求先被处理。默认的 FIFO(先进先出)在混合负载下表现一般,因为一个长请求会挡住后面一堆短请求。

更好的做法是引入优先级:短请求优先,或者按等待时间加权。这样能让大多数请求的首字延迟保持在一个较低的水平,只有少数长请求会等久一点。从用户体验角度看,这比所有请求都等很久要好。

vLLM 支持基于优先级的调度,nano-vllm 里也有类似的机制。实现上主要是给每个请求打个优先级标签,调度器每次从队列里挑优先级最高的。抢占(preemption)则是当显存不够时,把低优先级的请求暂时换出,腾出空间给高优先级请求。

5.4 批处理大小的权衡

批处理大小(batch size)对首字延迟的影响很直接:batch 越大,单个请求分到的算力越少,首字延迟越高。但 batch 太小,GPU 利用率上不去,吞吐又不行。

这里没有标准答案,得看你的场景。如果是交互式应用,用户对首字延迟敏感,batch 要控制得小一点;如果是离线批处理,吞吐优先,batch 可以开大。

我的做法是设一个首字延迟的目标值,然后反推最大 batch size。比如目标首字延迟是 500ms,那就逐步加大 batch,直到首字延迟逼近这个值,就停在这个 batch size 上。

6. 常见问题与排查技巧实录

6.1 首字延迟忽高忽低怎么办

这是最常见的问题。首字延迟不稳定,通常有几个原因:

原因一:显存碎片。长时间运行后,显存里会有很多小块空闲,导致大块分配失败,触发显存整理,拖慢请求。解决办法是启用 PagedAttention 这类分页管理,或者定期重启服务。

原因二:调度抖动。如果 prefill 和 decode 的混合比例在变化,调度器可能频繁切换策略,导致延迟波动。可以固定 chunk size,减少调度决策的频率。

原因三:KV cache 换入换出。当并发请求数超过显存能容纳的量时,KV cache 会被换出到内存,需要时再换回来,这个换入换出非常慢。解决办法是限制最大并发数,或者用 KV cache 量化来省显存。

排查的时候,我一般先看显存占用曲线,如果呈锯齿状,基本就是碎片或换入换出问题;如果显存平稳但延迟还是抖,那就是调度问题。

6.2 算子融合之后精度掉了

算子融合理论上不应该影响精度,因为数学上是等价的。但实操中确实会遇到精度下降,原因通常是:

  • 融合后的 kernel 用了低精度累加,比如 FP16 输入但用 FP16 累加,误差累积。
  • 融合改变了计算顺序,浮点运算不满足结合律,顺序变了结果就有微小差异。
  • 某些融合 kernel 有近似实现,比如用快速数学函数替代精确函数。

解决办法是检查融合 kernel 的累加精度,确保关键路径用 FP32 累加。如果精度还是不行,就把那个融合关掉,单独跑。

6.3 常见问题速查表

问题现象可能原因排查方向解决手段
首字延迟高且稳定计算瓶颈看 GPU 利用率算子融合、调 chunk size
首字延迟忽高忽低显存碎片/调度抖动看显存曲线分页管理、固定调度策略
吞吐上不去batch 太小看 GPU 利用率加大 batch、KV cache 量化
精度下降融合 kernel 精度问题对比融合前后输出提高累加精度、关闭问题融合
长序列变慢明显KV cache 瓶颈看显存带宽KV cache 量化、调块大小

6.4 我踩过的几个坑

坑一:盲目追求大块大小。一开始我觉得块越大越好,直接调到 64,结果短请求的显存浪费严重,并发数上不去,整体反而变慢。后来老老实实按序列长度分布来选,才找到合适的值。

坑二:忽略 CPU 侧开销。有段时间我盯着 GPU 利用率看,发现一直不高,以为是 GPU 没吃满。后来才发现是 CPU 侧的调度逻辑太重,成了瓶颈。优化了调度器的数据结构之后,GPU 利用率才上去。

坑三:CUDA Graph 和动态形状冲突。我一开始想给整个 prefill 上 CUDA Graph,结果因为输入长度变化,图一直要重录,反而更慢。后来只给形状固定的部分上图,才吃到收益。

坑四:KV cache 量化太激进。为了省显存把 KV cache 压到 INT4,结果生成质量崩了。后来退回 INT8,配合分页管理,显存也够用了。

7. 一个可复现的优化路径

如果你现在手头有一个基于 nano-vllm 或类似引擎的推理服务,想复现标题里那种"权重不动、首字延迟大降"的效果,我建议按这个顺序来:

第一步,先测量。别急着改,先把当前的首字延迟、吞吐、显存占用测出来,作为基线。测量的时候要覆盖不同的输入长度和并发数,别只测一种情况。

第二步,上 PagedAttention。如果还没用分页 KV cache,这是收益最大的一步。改完之后重新测,通常能看到明显的首字延迟下降。

第三步,调块大小和 chunk size。根据你的序列长度分布,把块大小和 chunked prefill 的 chunk size 调到合适的值。这一步需要多试几组,别嫌麻烦。

第四步,开算子融合。检查默认配置下哪些算子没融合,针对性地开启。重点看 QKV 投影、LayerNorm、激活函数这几处。

第五步,上 CUDA Graph。给形状固定的部分上图,减少 launch 开销。

第六步,调调度策略。根据场景设置优先级和 batch size,平衡首字延迟和吞吐。

这一套下来,权重一个字节没动,首字延迟降一半以上是完全可能的。我自己走完这套流程,最终的首字延迟比基线低了 70% 多,和标题里的 77% 在一个量级。

最后分享一个小技巧:优化的时候一定要有对照组,一次只改一个变量。我见过太多人一口气改一堆配置,结果延迟降了也不知道是哪个改动起的作用,出了问题也不知道是哪个改动引入的。推理优化是个精细活,急不得。

这个方向后续还能继续挖,比如把 KV cache 的存储和注意力计算做更深的协同设计,或者针对特定硬件做定制化的 kernel。权重之外的战场,空间比想象中大得多。

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

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

立即咨询