做LLM推理服务的人都有同感:模型权重占的显存其实不大,真正让显存曲线一路往上冲的,是并发请求的调度方式和KV Cache的分配策略。这个项目把560M参数的对话模型SeqGPT-560M部署到单卡推理服务,通过动态batch和KV Cache复用两项改造,把显存峰值压低了35%,同时单卡吞吐量也明显提升。
这个成绩在行业内不算惊天动地,但如果你正守着有限的显存跑生成式模型,里面涉及的思路会比单纯看一个“35%”有参考价值得多:静态batch到底浪费在哪?动态batch是不是越大越好?KV Cache复用怎么保证不产出错误的回复?35%又是从哪个环节一步步抠出来的?我会把整个方案的思考过程、调度器设计、前缀复用实现、性能分析方法和踩坑记录都展开讲,适合正在做LLM推理部署或性能调优的工程师参考。
1. 项目定位:瓶颈不在权重,在算力排队与显存预支
1.1 560M模型为什么还需要做算力优化
很多人一听560M参数,第一反应是“这模型这么小,随便跑”。独立测一条请求确实如此,前向计算很快,单条生成也不会吃多少显存。可一旦放到线上服务里,问题立刻变味:几十路并发请求同时挤进来,每个请求都要维护自己的历史状态、各自走到不同的长度,GPU的计算资源和显存资源就开始互相争夺。
SeqGPT-560M的结构属于典型的decoder-only架构,大约二十多层Transformer层。模型权重用半精度存下来不到2GB,放在24GB显存的中高端GPU上显得很宽裕。但KV Cache不一样,它是随请求数、序列长度线性增长的。假设单条请求最大生成长度设为2048,hidden维度1024,多头注意力的头数和头维度乘积等于hidden维度,那么每层每token要缓存一组Key和Value,两层乘起来,单条请求的KV Cache也要占用数十MB。并发32路请求,光KV Cache就可能吃掉好几个GB。
更麻烦的是静态batch策略下的显存预支。传统做法是攒够一批请求再统一推理,而且为了省事,往往会按最大长度给每个请求预留KV Cache。实际上真正生成到2048长度的请求很少,很多对话两三句就结束了,预留的空间白白锁死在显存里。模型权重没变,显存却因为“提前占位”和“并发堆积”被大量浪费。所以优化方向很清楚:不是去动模型结构,而是让算力不空等,让显存不预支。
1.2 优化组合拳选型:为什么是动态batch加KV Cache复用
当时列了几个可选方案:权重量化、激活值重计算、动态batch、KV Cache复用。权重量化收益明确但会带来精度损失,而且560M模型本身对量化误差的容忍度一般。激活值重计算适合长序列单请求场景,对高并发帮助有限。真正卡住服务吞吐的,一个是GPU算力在等待中空转,一个是重复计算浪费算力,所以最终选了动态batch和KV Cache复用。
这两个方案一个解决“时间上的碎片化”,一个解决“空间上的浪费”。动态batch让GPU只需要处理正在活跃生成的请求,某条请求一结束,新请求可以立即顶上空位,不再等同一批的其他请求一起结束。KV Cache复用则是把多轮对话、系统提示词、few-shot前缀里重复计算的前向过程直接跳过去。两条思路叠加后,效果不是简单相加:前缀被复用后KV Cache占用下降,动态batch又能把省出来的显存迅速放进新的并发位,形成正反馈。
1.3 35%显存峰值降幅是怎么构成的
优化前后对比时,基线用的是静态batch方案:固定批大小、按最大序列长度预分配KV Cache池。优化后整个显存峰值降了35%,粗略拆解一下,大头来自KV Cache从“按最大长度预分配”变成“按实际长度按块分配”。这一步就让每路请求的显存占用贴近真实生成长度,而不是贴着上限。
中间一块来自前缀复用,线上大量请求共享同一个系统提示词,这部分前向计算被跳过之后,缓存池的命中压力小了很多。小头来自调度带来的池化复用,动态batch让KV Cache池里的空闲块能立刻被新请求拿到,减少了反复申请释放带来的临时显存尖峰。三块叠加之后,显存峰值在压测场景下从明显偏高降到了可控区间,也就是降低了35%。
2. 动态batch:告别空等批,让GPU每一帧都在有效干活
2.1 静态batch的三个低效时刻:空等、拖尾、提前占位
先复盘静态batch是怎么浪费算力的。第一种是空等。请求到的速度不均匀,比如每秒平均来5个请求,但一秒内可能前400毫秒来了1个,后600毫秒来了4个。静态batch只能等攒够一批再推理,攒批的过程里GPU闲着。
第二种是拖尾效应。一个batch里有的请求生成20个token就结束,有的要生成200个。计算必须等最长的那个生成完,整个batch才能一起结束。前面那些提前结束的请求虽然不再参与计算,但它们所占的batch槽位和显存还留在原地,没法释放给新请求,边上还在生成的请求白白拉长了整体延迟。
第三种就是前面说的提前占位。静态batch为了保证生成过程中不OOM,倾向于按max_length预留KV Cache,一批16路请求,每路都按2048预留,实际平均长度可能只有200。多出来的空间全程没有用到,却把GPU显存卡得死死的。三种浪费叠加之下,硬件的真实利用率往往只有十几二十个百分点,这就是为什么“模型不大但服务很慢”。
2.2 迭代级调度的设计与落地:请求队列、事件驱动、插入时机
动态batch的核心是迭代级调度,和传统训练时的step级调度有本质区别。传统训练里一个step处理完整的一个batch,所有样本同时开始同时结束。推理的动态batch则是把调度粒度缩小到每次decode迭代:每生成一个token,都检查有没有请求结束,有结束就让出槽位,立刻把等待队列里的新请求补进来继续下一轮。这样GPU几乎每一轮推理都在处理当前活跃的请求集合,而不是一等等半天。
我的实现里调度器由三个组件组成。请求管理器维护两个队列:等待队列存新到请求,活跃列表存正在生成的请求。显存块管理器负责KV Cache块的分配与回收,每个活跃请求持有若干块。调度器是事件驱动的,触发时机有三个:新请求到达、某个请求生成完最后一个token、批内任意请求的剩余生成长度达到设定阈值。触发后,调度器扫描队列后部的请求,只要显存池还有空闲块、batch没有达到上限,就执行插入。
这里必须注意,动态batch不是无脑把活跃batch塞满。塞太多请求进来,单次前向计算量暴增,每条请求的延迟反而被拉高。调度器还做了一项小优化:把共享前缀的请求尽量放进同一个batch。这样前向时虽然请求是独立的,但前面的公共部分在计算上仍然能获得Cache友好性,后面再分叉。
2.3 关键阈值参数:batch上限、调度窗口、最小批量
动态batch要调好,三个参数最关键:最大批量、调度窗口、最小调度批量。
最大批量上限主要看显存预算和单token生成延迟预算。我压测过一组数据,批量16时单个decode迭代大概2毫秒,批量32时要到3.4毫秒。如果对每条请求的生成速度有硬要求,就不能一味加大批量,而要在吞吐和延迟之间取平衡。建议你用真实服务的长尾token长度分布做一次模拟,先统计平均和P95生成长度,再算出每路请求需要的平均KV Cache块数,最后用显存预算反推batch上限。我这边最后定在24,既吃满了算力又不至于让P95延迟翻倍。
调度窗口控制的是请求在队列里等待补位的最长时间。窗口设太短,调度器频繁触发,CUDA kernel启动开销占比上升;设太长,新请求的首token延迟明显变大。实测下来2到10毫秒之间比较合理,我用的是5毫秒,效果最稳。
最小调度批量是用来防止碎片化调度的。如果每次只补一个请求就触发前向,kernel反复启动会拖低整体吞吐。我设成4,少于4个待补请求就攒着,等下一次触发窗口统一处理。这几个参数相互牵连,建议每次改一个维度,单独看吞吐和P95变化,不要一把梭全改。
3. KV Cache复用:让相同的前缀只算一次
3.1 换一种理解方式:KV Cache就是算力版的“结果缓存”
要理解KV Cache复用,先得理解KV Cache是干什么的。Transformer在推理时,每生成一个新token,都要拿这个token的query去和历史上所有token的key做注意力计算,再和value加权求和。如果不做缓存,每生成一个token就得把前面所有token的key、value重新算一遍,计算量随序列长度平方增长,根本扛不住。
KV Cache就是把每一步算出来的key和value矩阵缓存下来,生成下一个token时直接取用,复杂度从平方变成线性。理解了这一层,“复用”就非常自然了:如果两个请求的前缀token完全一样,那它们在这段前缀上算出来的key和value几乎完全一样,理论上只需要算一次。这个优化在对话场景里特别有效,因为大量请求共享同一个系统提示词、同一套few-shot示例,或者是同一段多轮对话历史的延续。
不过这里强调一个前提:“前缀token完全一样”不是指文本字符串一样。tokenizer在不同上下文里可能把同一段文本切成不同的token序列,所以复用匹配必须基于token ID序列,而不是原始文本。这是我后面踩坑才知道的,先埋个伏笔。
3.2 前缀库设计与块级复用:哈希索引、引用计数、块粒度
实现KV Cache复用,一开始最容易想到的办法是直接缓存“完整前缀”。但线上前缀长度五花八门,完整匹配命中率很低,所以必须做块级复用。把前缀切成固定长度的块,比如每64个token一个块,对每个块内的token ID序列算哈希,用哈希做索引。
我搭建了一个前缀库,结构上是一个前缀树加哈希索引。每个节点对应一个块,块内保存三样东西:这一段token序列的哈希值、这一段计算出的KV数据、被多少个请求引用的计数。新请求进入时,把它的token序列从头开始按块哈希,去前缀树里逐块匹配。每命中一个块,后续计算就往前跳过一段。
这里有个关键的工程决策:块的KV数据是独占还是共享。如果直接让多个请求共享同一份显存数据,必须保证没有任何一个请求会去改写它。我的做法是:复用的块全部标记为只读,跳过时只在前向计算里读取这块KV数据,所有写入都发生在新分配的块上。引用计数用来管理生命周期,当没有请求引用某个缓存块时,它才会被回收到空闲池。这个设计避免了复杂的写时复制逻辑,也保证了并发安全。
3.3 别被“正确性”拖后腿:位置编码、padding与一致性校验
KV Cache复用最大的风险在位置编码上。很多模型使用旋转位置编码,位置信息是“叠加”到query和key上的。如果一个请求的前缀长度是200,后面新计算的部分位置是从200开始的;而另一个请求如果复用了前缀后,自己的新token是从自己的位置开始算。最简单的办法是严格限制只复用等长前缀,并且保证复用时新token的位置编号直接从复用处接着算。实际线上服务里,前缀长度相同是常见场景,比如系统提示词固定、多轮对话首次接入,所以这个限制不算苛刻。
padding也得小心。动态batch里不同请求长度不同,前向时通常要做padding对齐。如果构造函数时mask搞错,让padding位置参与了KV缓存计算,复用出来的数据就带误差。我建议对复用路径单独写一个对齐逻辑,所有复用块对应的mask位置强制整段mask掉,保证不会把padding历史的key算进后续注意力。
上线前做一致性校验:让同一个请求分别走完整计算路径和KV Cache复用路径,对比每一层输出的logits。浮点运算顺序不同会有极小数误差,但要控制在1e-4数量级以下。如果误差很大,说明复用数据有问题,优先检查位置编码和mask。
4. 显存峰值降低35%:从基线测量到优化落地的完整过程
4.1 先把显存画出来:不会测量就谈不上优化
动手优化前,我花了不少时间做显存画像。方法是定期从GPU驱动层读取显存占用曲线,同时记录每时每刻的活跃请求数和平均生成长度,把三者画在同一张时间轴上。光这一步就看到了几个关键事实:显存占用几乎是瞬间冲高的,因为静态batch一次性给16路请求各预留了最大长度;生成过程中显存占用绝大多数时间没有变化,说明预留的空间根本没用到;只有请求批次切换的瞬间才有短暂的释放和申请。
把显存占用拆分成三块:模型权重、KV Cache池、前向计算产生的临时激活值。模型权重是固定的,KV Cache池是大头,激活值在高并发下也不可忽略。有了这个拆分表,才能确定优化重点。当时测量结果显示KV Cache池占了显存峰值的六成左右,激活值占三成,剩下的是框架和碎片开销。所以优化优先级很清晰:先搞KV Cache,再顺手优化激活值复用。
测量工具上,用推理框架自带的profiler接口做算子级分析,用显存监控指令记录全局占用和进程占用差。注意要压测一段时间,而不是只跑几十个请求。我自己压了3轮各15分钟,取稳定态的峰值,因为短暂尖峰和平均占用都可能误导决策。
4.2 三步走:Cache池化、动态调度、前缀复用
实施第一步是KV Cache池化。把原来“每个请求各自持有整段Cache”改成“统一的大池子按块分配”。块大小设为64个token的KV数据,请求生成过程中按需领取新块。这一步做完,只验证了代码正确性和极短期压测的显存曲线,先不急着上动态batch,避免两个大改动一起出问题时没法定位。
第二步是接入动态调度器。把原来的静态batch循环改成迭代级调度,请求管理器、块管理器、调度器三层接好。这一步做完上线前,要做长时间稳定性压测,因为调度器的bug经常是偶发的,运行半小时才出现一次OOM或卡死。
第三步才是加前缀复用。前缀库上线后,小流量试运行观察命中率和响应延迟变化。命中率低于预期时,通常不是前缀库本身的问题,而是线上请求的真实前缀分布和预想不一致。我去翻了一段真实日志发现,由于一些请求历史轮次过多,前缀树的深度很深,匹配反而变慢。后来对超过一定长度的前缀直接整段哈希复用,不去逐块匹配,效果立竿见影。
4.3 优化效果实测:显存、吞吐、延迟三方对比
压测场景是模拟线上对话服务的请求分布,平均并发约24路,请求生成长度从16到512不等。
| 指标 | 基线(静态batch) | 优化后(动态batch+KV复用) | 变化 |
|---|---|---|---|
| 显存峰值(GB) | 18.6 | 12.1 | 降低35% |
| 吞吐(tokens/s) | 2140 | 3980 | 提升约86% |
| P95首token延迟(ms) | 96 | 71 | 降低26% |
| P95单token生成延迟(ms) | 38 | 42 | 略有上升 |
显存峰值的下降和预期一致。吞吐提升主要来自两个地方:一是GPU空等时间大幅减少,二是前缀命中后跳过了一段前向计算。注意P95单token生成延迟其实小幅上升了,原因很简单,动态batch把更多请求塞进了同一批计算,单步计算量变大。这个trade-off是可以接受的,因为整体吞吐上来了,而且首token延迟反而更低。
显存优化确认后,我还顺手观察了激活值峰值。动态batch下并发可能有波动,临时激活值会跟着batch size跳变。后来加了激活值显存的复用和释放策略,把一部分临时buffer的分配次数压下来,显存曲线变得更平滑。
5. 实测中踩过的坑:问题卡壳点与排查思路
5.1 动态batch把P99延迟打高了,怎么调
第一次把动态batch上线后,吞吐确实涨了,但P99首token延迟从70毫秒跳到140毫秒。排查下来是调度器太激进了:每来一个请求,只要有空槽位,立刻触发调度把它塞进正在执行的batch里。结果kernel不断被打断重启,启动开销反而拖慢了整体节奏。解法就是前面说的引入最小调度批量和调度窗口,把高频低收益的调度合并成低频高收益的调度。另外给等待队列加了优先级标记,普通请求最多等两个窗口,优先保证首token延迟不失控。
5.2 前缀复用导致输出偶发崩溃,谁的锅
一度出现很低概率的错误回复,概率大约万分之几。一开始怀疑是KV数据被并发请求改写了,检查引用计数和只读逻辑都没有问题。后来用一个单独进程复现,把复用路径的logits和完整路径对比,发现误差在可接受范围内。真正的元凶是tokenizer的切分不一致:相同文本在不同上下文里可能因为前后token拼接而被切成不同的token序列,前缀的字符串一样但token ID序列不一样,哈希匹配时偶发撞上同一哈希值。修法是在哈希索引里再保存一份完整token ID序列做精确比对,哈希只用来快速筛掉不匹配项,一致性校验通过后才允许复用这条规则加到代码里。
5.3 显存碎片越来越多,OOM却报不出原因
运行时间长了以后,显存整体占用缓慢上升,最终偶发OOM。用监控工具看,进程占用并没有明显涨,可疑的是碎片的累积。原因是块粒度为64个token,请求长度不一导致分配和回收的块size分布很碎,长时间运行后显存空洞增多。后来把分配策略从“按需小块”改成“按需分配、回收时优先合并相邻块”,并且把块大小从64改成128,碎片率明显下降。大块确实会在短请求场景浪费一点空间,但整体稳定性好很多。
5.4 多卡场景下的KV Cache一致性维护
这部分是我后来送测多卡推理时踩的。单卡版本里每个请求的KV Cache在本地维护,非常好理解。但多卡做张量并行时,模型权重被切分到多张卡上,每个请求的KV Cache也要按相同方式切分到各卡。前缀库如果只维护在主卡上,会出大问题:不同卡上相同前缀的KV数据必须分别缓存,任何一张卡命中而另一张卡没命中,都会导致对不齐。解法是让前缀库的索引在每张卡上各存一份,块数据的引用计数统一管理,释放时广播。多卡场景的显存优化效果比单卡更明显,因为每张卡的池化空间都得到了复用。
写在最后的一点经验
这套优化做下来,最大的体会是:LLM推理优化不是找一两个魔法参数就能解决的,而是要把“请求如何进入调度器”“KV Cache如何分配和复用”“GPU kernel何时启动”这三层串起来通盘考虑。动态batch解决的是“不要让GPU空等”,KV Cache复用解决的是“不要让重复计算反复发生”,两者缺一不可。35%显存降幅看起来很漂亮,但真正长期受益的是服务稳定性:显存占用平缓之后,OOM频率降了一个量级,再也不用半夜起来扩容。
最后再分享一个实用技巧:这类优化一定要建一套自动化的回归校验流程。每次改完调度器或前缀库,自动跑一批固定请求,把输出结果和基线做diff。LLM的输出有随机性,不好直接对文本,我们是对每段输出的语义向量做距离判断,超过阈值就告警。这套流程后来帮我们拦下了至少两次隐蔽的回归问题。优化一时爽,守住正确性才是持久战。