前阵子做大模型推理加速,我在某个7B模型上把单条流式生成的吞吐量提了近一倍。说实话,LLM大模型推理代码实现这件事,最开始我也以为现成的开源框架已经做到极致,自己手写没太大意义。但真正动手把KV Cache、量化、批处理调度这一整套逻辑自己搭一遍之后才发现,决定推理快慢的关键,恰恰是那些框架帮你藏起来的细节。
如果你也是那种"不想只调动API,想搞清楚大模型到底是怎么跑起来"的人,这篇文章应该对你有用。我会从最底层的访存瓶颈讲起,再逐个拆解四个真正能落地的加速方案,最后给出一版可以照抄的代码骨架和我在实测中踩过的坑。内容偏工程实践,不需要你有底层硬件背景,但如果能跟着代码思路走一遍,收获会大很多。
1. 先别急着写代码:把"推理慢"的病根找出来
1.1 一个反常识的结论:decode阶段瓶颈不是算力
大模型推理慢,绝大多数人的第一反应是"算力不够"。但你在GPU上把profiling一开,马上会发现另一个事实:生成第一个token之后,GPU的计算单元使用率可能还不到20%,可单次decode依然要等上十几毫秒甚至几十毫秒。时间去哪了?答案是在显存带宽——GPU核心等数据,而不是数据等GPU核心。
举个例子。某7B模型,参数以FP16存储,权重大约14GB。decode阶段,每个新token本质上只需要拿着当前这个token的表示,跟14GB的权重矩阵做一次矩阵乘法。假设硬件显存带宽是2TB每秒,光把14GB权重从显存搬进计算单元,就要大约7毫秒。而这次矩阵乘法的实际计算量,以这台设备的算力来看连一毫秒都用不到。于是瓶颈非常清楚:每一步decode都被"读取权重"卡住。你换任何高算力的卡来跑,只要带宽不涨,速度就上不去。
这个结论是全文所有优化策略的出发点。包括后面说的量化,本质上都是在减少"从显存读多少个字节"这个数字,而不是在提高计算速度。
1.2 prefill与decode:同一个模型身上的两副面孔
很多人没有意识到,大模型生成一句话,其实经历了两个特征完全不同的阶段。
- prefill阶段:用户输入的prompt一次性进入模型,所有token并行计算。输入有1024个token时,模型要做的计算量大约是1个token的1024倍,这个阶段算力会被充分压满,属于计算密集型任务。
- decode阶段:模型吐出第一个token之后,后续每一个token都必须依赖上一个token,只能串行前进。此时的计算量小得可怜,但每次都要把全部权重数据读一遍,属于访存密集型任务。
这两个阶段的优化方向完全不同:prefill要看算子融合和内核效率,decode要看访存优化和批处理。二者混为一谈,是做推理加速最容易犯的错误。
1.3 从访存瓶颈推导出的三条优化主线
既然decode阶段被"读权重"卡死,那么优化思路就很清晰了,无非三条:
- 少读一点权重。这就是量化,把FP16变成INT8、INT4,读取字节数直接减半再减半。
- 别重复读相同的历史数据。这就是KV Cache,让每个token只算一次历史Key和Value,而不是每次重新算。
- 读完一次的权重尽量多服务几个token。这就是连续批处理(Continuous Batching)和投机采样(Speculative Decoding)。
这三条主线,恰好对应下面好几节的内容。我把它们都实现过一遍,每一层带来的收益和坑,都值得单独说道说道。
2. KV Cache手写实现:省掉一整个维度的重复计算
2.1 自回归解码到底重复算了什么
如果不做任何优化,大模型的decode过程是这样一个循环:拿到当前序列的所有token,把它们全部送进模型,算出最新token的概率分布,挑一个输出。下一次生成时,再把"原prompt加之前生成的所有token"送进模型。你会发现,每一次都重新计算了历史上所有token的注意力。
更麻烦的是,随着生成的token越来越多,每次forward的序列长度越来越长,计算量呈平方级增长。生成到第100个token时,每次decode要处理100个token的注意力,但其中99个都是之前已经处理过的。这就是最原始、最笨的推理实现。
KV Cache的思路非常简单:把每一层注意力算出来的Key和Value矩阵存下来,后续decode阶段只计算当前新token的Key和Value,再拼接到缓存里。这样,每步需要重新计算的注意力部分,就只和当前token相关,历史token的Key和Value直接复用。复杂度从一个不断增长的序列长度,变成了一个常数。
为了加深理解,我把没加缓存和加缓存的区别列一下:
| 实现方式 | 单步decode的计算量 | 生成的复杂度趋势 |
|---|---|---|
| 每次全量重算 | 随已生成token数线性增长 | O(N²) |
| 使用KV Cache | 与新token长度成正比,约等于常量 | O(N) |
KV Cache是几乎所有大模型推理框架的基石,你看到任何声称"推理快"的方案,背后都默认有它。没有它,后面的量化、批处理全都无从谈起。
2.2 最小可跑的KV Cache核心逻辑
手写KV Cache,核心就三件事:初始化缓存空间、写入新KV、读取历史KV参与注意力计算。
缓存形状一般是[num_layers, batch_size, num_heads, max_seq_len, head_dim],其中max_seq_len是预分配的最大长度。为什么预分配而不是动态增长?因为每次动态扩容触发显存重新分配和拷贝,反而会拖慢速度。预分配一块连续内存,然后用位置指针往里写,是最稳的做法。
一个最简化的伪代码如下:
import torch class KVCache: def __init__(self, num_layers, batch_size, num_heads, max_seq_len, head_dim): # 预分配缓存空间 self.cache_k = torch.zeros(num_layers, batch_size, num_heads, max_seq_len, head_dim) self.cache_v = torch.zeros(num_layers, batch_size, num_heads, max_seq_len, head_dim) self.pos = 0 # 当前写入位置 def update(self, layer, k_new, v_new): seq_len = k_new.shape[2] # 一般为1 # 写入预分配的内存段 self.cache_k[layer, :, :, self.pos:self.pos+seq_len, :] = k_new self.cache_v[layer, :, :, self.pos:self.pos+seq_len, :] = v_new self.pos += seq_len def get(self, layer, step): # step可以理解为当前要attend到哪一步 return self.cache_k[layer, :, :, :step, :], self.cache_v[layer, :, :, :step, :]之后每层注意力forward里,不再重新计算历史KV,而是:
q = layer.q_proj(x) # 当前token的query k, v = layer.k_proj(x), layer.v_proj(x) # 当前token的key和value cache.update(layer, k, v) # 存入缓存 past_k, past_v = cache.get(layer, cache.pos) # 取出到当前位置为止的全部KV scores = torch.matmul(q, past_k.transpose(-2, -1)) / head_dim ** 0.5 attn = torch.softmax(scores, dim=-1) @ past_vprefill阶段也一样要写入KV Cache,不要因为prefill阶段所有token并行,就忘了这一步。否则第一个decode阶段还是会回头重新计算历史,优化效果就全没了。
2.3 序列长了以后,缓存数据结构的两个隐藏坑
KV Cache的预分配方案看起来简单,落地时还是有两个坑值得单独提醒。
第一个坑是max_seq_len设置不合理。设得太小,长对话一旦超过上限,就得想办法截断或滚动淘汰旧KV;设得太大,显存直接被空缓存吃光。比如一个7B模型,FP16权重占14GB,如果每层给8个并发序列预留2048token的KV缓存,实际占用可能轻松超过10GB,显存一下就紧张了。更合理的做法是借鉴操作系统的分页思想:把KV Cache切成固定大小的块,按实际长度动态分配和回收。你不需要从零实现分页调度,但一定要理解预分配策略的代价。
第二个坑是批处理里每个序列长度不一致。假设同一批里有三个请求,一个刚进来,一个已经生成到末尾,一个处于中间长度。如果只用一个连续维度存KV,不可避免会出现空洞和浪费。标准解法是每个序列单独维护自己的cache_pos,在get时只取自己当前的长度,而不是直接取整个缓存张量。很多生成加速库里看起来复杂的索引逻辑,本质上就是在解决这个对齐问题。
3. 量化权重与KV Cache:把每token读取的字节数压到四分之一
3.1 为什么INT4能让decode变快而不只是变小
一说到量化,大多数人想到的是"模型变小了,显存放得下了"。这当然没错,但对于推理速度,更关键的是它降低了每步decode读取权重的字节数。
刚才算过,FP16下7B权重14GB,假设带宽2TB/s,光读权重就要7毫秒。换成INT8,权重变成7GB,读一次只要3.5毫秒;换成INT4,权重降到3.5GB,读一次约1.75毫秒。理论上decode的吞吐上限可以提升好几倍。当然,实际因为量化反量化有额外计算、算子也没有把带宽打满,收益会打折扣,但方向非常明确。
这也是为什么业界普遍对"权重4bit,激活16bit"的配置那么热衷——因为decode阶段的瓶颈在权重读取,而不是激活精度。相比之下,如果在不合适的场景强行把激活也量化掉,反而可能引入额外开销,步子迈得太大。
3.2 手写一个简易对称量化推理层
量化最基础的形式是"对称量化",原理是把浮点权重映射到一个整型区间。我自己常用的是per-group对称量化,把权重按group分组,每组一个缩放系数。
def quantize_per_group(w, group_size=128): # w: [out_features, in_features] out, inp = w.shape w_reshape = w.reshape(out, -1, group_size) scales = w_reshape.abs().amax(dim=-1, keepdim=True) / 127.0 q = torch.round(w_reshape / scales).clamp(-127, 127).to(torch.int8) # 将int8重塑回原来的形状 q = q.reshape(out, inp) scales = scales.reshape(out, -1) return q, scales推理时不能直接把整个权重反量化成FP16再算,那样就没有带宽收益了。正确路径是让权重以INT8格式留在显存里,在矩阵乘法内部按块反量化,或者直接调用硬件支持INT8乘法的算子。简单说,反量化要发生在"数据已经搬运到计算单元之后",而不能发生在搬运前。
# 示意:按块读取int8权重,块内反量化后乘激活 def int8_linear(x, q_weight, scales): # x: [batch, in_features] FP16 # q_weight: [out_features, in_features] INT8 # scales: [out_features, num_groups] y = torch.matmul(x, q_weight.to(torch.float16)) # 需要真实走int8算子 # 乘以每个group的scale并累加 ...我特意在这段代码注释里强调"需要真实走int8算子",因为它是最容易踩坑的地方,后面会专门展开。
3.3 "看着省了,实际没快"的量化陷阱
量化不是无脑越低位越好。我自己实测过几次,得出一个结论:decoder推理中,4bit确实能进一步降低带宽占用,但如果你的设备不支持原生的低比特矩阵乘法,代码会把INT4解包成INT32再计算,解包本身的成本可能把带宽收益吃光。这时候INT4反而不如INT8快,甚至不如FP16快。
另一个容易忽略的点是KV Cache的量化。长上下文场景里,KV Cache占的显存和带宽越来越大,把它做成INT8可以有效缓解压力,但如果量化粒度过细(比如每个token都存一个scale),scale本身的读取也会挤占访存预算。比较实用的做法是按通道或按较大分组做量化,让scale数量控制在很小的比例。
最后提醒一点:千万不要为了速度快而把激活也量化到INT8。对于单模型推理来说,激活量化需要额外的per-token scale计算,代码复杂度和精度损失都会上来。如果你的目标只是跑通一个尽量快的推理管线,先做权重量化和KV Cache量化就够了。
4. 投机采样和流水线批处理:在延迟与吞吐之间的取舍
4.1 投机采样:用小模型替大模型"打草稿"
KV Cache和量化能解决"每一步decode慢"的问题,但每一步依然只能生成一个token。投机采样是另一条思路:能不能一次forward生成好几个token?
做法是准备一个很小、很快的草稿模型,让它先一步生成接下来几个token,然后目标大模型把这几个token当成一个序列,一次性并行验证。如果草稿全部被接受,这一次forward就相当于生成了多个token。
关键在验证那一步:大模型会给出每个草稿token的概率,如果概率足够高就接受,否则可能拒绝,并在出错位置重新采样一次。现实中草稿模型往往押不中那么准,所以真正的收益取决于"接受率"。接受率高,速度接近草稿模型;接受率低,反而添乱。正常使用中草稿长度取3到5比较稳妥,再长收益就开始递减了。
我实测下来,投机采样在单条请求、低并发场景收益明显。比如第一条流式回复只有一个人问,GPU闲着的时候,它可以拿闲着的算力去跑草稿模型,用一次大模型forward换出三四个token,体感速度提升很明显。
4.2 Continuous Batching:让GPU一直满载工作
如果说投机采样是照顾"单用户"的花式操作,那 Continuous Batching 就是照顾"多用户高并发"的基本功。
最原始的批处理叫做静态批处理:凑满一批请求,全部处理完才释放资源。问题是每个请求的prompt长度、输出长度完全不同,总会有序列先结束,但算力还得陪着没结束的一起耗着,GPU利用率非常难看。
Continuous Batching 的思路是每一步重新调度:这一批序列里有谁生成了新token、谁已经结束、谁刚进入prefill,统统动态决定。结束的序列立刻让位给新请求,GPU每个时刻都在为"还有价值的序列"干活。对于decode这种访存瓶颈型负载,batch越大,权重从显存读取一次能服务的人越多。权重读取成本摊薄到多个并发序列上,总吞吐会非常可观。
在我的测试环境里,同样一个模型,不开启批处理时单序列吞吐大概30多个token每秒,并发8个序列之后整体吞吐能翻好几倍,收益比量化还大。这是服务端推理绝对不能省的一步。
4.3 什么时候两个方案反而会互相拖后腿
投机采样和Continuous Batching单看都是好的,叠加起来却不一定有正收益。
原因在于,Continuous Batching已经把大模型的带宽成本压得很低了。当8个甚至16个序列同时decode时,每次大模型forward都在批量服务很多人,单token均摊的权重读取成本变得很小。这时候投机采样引入的草稿模型forward,相当于额外占用计算资源,而它带来的"一次验证多个token"收益被明显稀释,净效果可能从正变负。
所以,我做推理管线时,投机采样前面会加一个动态开关:当前活跃序列数少的时候,打开;活跃序列数多的时候,关闭。不要让两个优化盲目叠加。这个细节,是推理服务从"demo能跑"到"生产可用"的重要分水岭。
5. 组装一版真正的"最快推理代码":结构、实现与实测
5.1 两层结构划分:调度器与执行器
概念讲完,该看代码了。我在实际落地时,把推理管线拆成两层:调度器(Scheduler)和执行器(Engine)。
调度器只负责回答三个问题:哪些序列该decode,哪些序列该prefill,哪些序列该结束换人。执行器只负责把调度器给的一批token真正送进模型,更新KV Cache,返回logits和采样结果。这样拆开,是因为prefill阶段耗时大,如果调度器在一个prefill任务上卡住,所有decode都会被阻塞。分离之后,可以分别控制每一步分配给prefill和decode的资源。
代码骨架大致长这样:
class Engine: def __init__(self, model, max_batch=8, max_seq_len=2048): self.model = model self.kv_cache = KVCache(...) # 预分配KV Cache def prefill(self, tokens): # 一次性处理prompt的全部token,填充KV Cache,返回最后一个token的hidden state hidden = self.model.embed(tokens) for layer in self.model.layers: hidden = layer(hidden, kv=None, causal_mask=True) return hidden def decode_step(self, input_ids, active_seqs): # input_ids: [batch] 每个序列当前最新token的id # 按batch并行解码,历史KV从缓存中读取 hidden = ... logits = self.model.lm_head(hidden) new_tokens = self.sample(logits) return new_tokens class Scheduler: def __init__(self, engine): self.engine = engine self.requests = [] def schedule(self): # 维护等待prefill队列和已激活序列列表 # 返回当前step应处理的批量 ... def serve(self, new_requests): for req in new_requests: hidden = self.engine.prefill(req.prompt) self.requests.append(ActiveSeq(req, hidden, kv_pos=0)) while self.requests: batch = self.schedule() if batch is None: continue new_tokens = self.engine.decode_step(batch) for seq, token in zip(batch, new_tokens): seq.append(token) if seq.is_done(): self.requests.remove(seq)实际生产中当然比这复杂得多,比如要把prefill和decode分成两个独立的执行栈,避免长prompt阻塞解码;还要处理采样参数、停止条件、超时控制。但上面这个结构已经是核心,剩下的都是在它上面加细节。
5.2 核心代码骨架
Engine里最核心的两个方法,一个是prefill阶段的计算,一个是decode阶段的单步。前者的核心在于正确构造causal mask,让每个token只能看到自己之前的token;后者的核心在于用KV Cache把历史信息接上,而不是重新算。
我写得最顺手的一个快速版本是这个思路:
# decode_step的简化伪代码 def decode_step(self, active_seqs, position_ids): input_ids = torch.tensor([s.last_token_id for s in active_seqs]) hidden = self.model.embed(input_ids) for layer_idx, layer in enumerate(self.model.layers): # 当前token的q,k,v q = layer.q_proj(hidden) k = layer.k_proj(hidden) v = layer.v_proj(hidden) # 写入KV Cache self.kv_cache.update(layer_idx, k, v) # 取出当前序列已累积的历史KV past_k, past_v = self.kv_cache.get(layer_idx, position_ids) # attention attn = torch.matmul(q, past_k.transpose(-2, -1)) / (q.shape[-1] ** 0.5) attn = torch.softmax(attn, dim=-1) hidden = torch.matmul(attn, past_v) # layer norm, 残差, MLP等略 logits = self.model.lm_head(hidden) return sample(logits)注意这里position_ids每个序列都不同,表示它当前生成到自己的第几个token。这也是Continuous Batching实现里最需要小心的地方,序列和序列之间位置不能共用一个全局步数。
5.3 三组实测数据与我的结论
我在手头的一张消费级显卡上,对某个7B模型做了三组对比。虽然数字换个硬件就没意义,但趋势能说明问题。
| 配置 | 单序列生成速度 | 8并发总吞吐 | 说明 |
|---|---|---|---|
| FP16,无KV Cache | 很慢,且越生成越慢 | 更低 | 基线中最差,随上下文长度退化 |
| FP16 + KV Cache | 约30 token/s | 提升明显 | 第一步必做 |
| INT8权重 + KV Cache | 约45 token/s | 再提升一截 | 权重读取字节减半直接见效 |
| 上述 + 连续批处理 | 约35 token/s(略降) | 总吞吐翻倍以上 | 服务端核心优化 |
| 上述 + 投机采样 | 低并发时约50 token/s | 高并发反而略降 | 必须按并发数动态开关 |
结论很直接:不要迷信某一招。KV Cache是地基,量化解决带宽,批处理解决利用率,投机采样解决单请求延迟。它们各自解决不同的痛点,深刻理解每个痛点的边界条件,才有可能写出自己的"最快代码"。
6. 落地时踩过的坑和最终调优清单
6.1 KV Cache预分配与分页:碎片的教训
我最早写KV Cache时图省事,用torch.cat每次动态拼接。功能跑通了,速度却惨不忍睹——每次cat都触发显存分配和数据拷贝,本来decode就访存密集,又额外背了一份copy开销。换成预分配连续内存之后,第一步就快了一截。
但预分配也有坑。当并发量高了,长短序列混在一起,固定给每个序列分配同样的最大缓存,显存浪费很严重。后来我学会按块管理,每个块存固定长度的KV,序列按需向全局块池申请,结束后回收。性能和空间都好了很多。如果你不需要从零写一个分块KV管理器,至少要知道:连续内存+指针写入是基本盘,分块管理是进阶。
6.2 量化不生效的排查思路
量化做完以后,显存占用明明降低了,但速度没有提升,这种情况我遇到过不止一次。
排查思路很简单:看矩阵乘法算子到底走没走INT8路径。很多框架虽然权重以INT8存储,但在解码层仍然调用FP16矩阵乘,系统会先把整块INT8反量化成FP16再计算。显存是省了,带宽没有省,速度自然上不去。
更隐蔽的还有INT4的问题。GPU没有原生的INT4矩阵乘指令,必须通过bit packing模拟。如果packing和解包逻辑写得低效,本该省下来的带宽反而全搭进去了。所以量化方案的选型,不只是看"位宽低多少",还要看硬件的原生指令支持情况。比较好的验证方式是用profiler查看实际读取权重的字节数,而不是只看显存占用减少了多少。
6.3 我的最终调优顺序建议
很多人一开始就追求花哨,把投机采样、INT4、自定义kernel全部加上,结果调试难度暴涨,问题根源根本定位不到。我的建议是严格按这个顺序来:
- 先做KV Cache,验证生成速度不随上下文长度退化。
- 再做批处理,改善并发场景的实际瓶颈。
- 然后考虑量化,从INT8开始,收益与实现成本最平衡。
- 最后才上投机采样、自定义kernel这类高成本优化,并且要针对特定负载做长期验证。
每一步都要有可量化的收益记录,确认这一层真的有效,再进入下一层。这套方法看起来朴素,但实际上能避免大量无效工作。
最后再分享一个我个人的体会:与其追求一份"通用最快推理代码",不如先搞清楚你服务的场景到底是单用户对话还是高并发接口,两者对最优解的期望完全不同。我手写这套管线最大的收获,不是把数字跑得多好看,而是终于能解释"为什么开源推理框架的配置会是这个样子"。当你真正沿着这条思路读完框架源码的时候,会发现一切都熟悉得像是自己写过一遍。