Lab 5: Gemma4 端到端推理优化¶
参考资料:
GPTQ权重优化¶
首先学习一下原理:
基础¶
Gemma 4 12B是一个 Decoder-only 的小模型,实际上这指的是,在多模态情境下不使用encoder也获得了多模态理解和推理的能力.
Decoder-only 语言模型把序列概率分解为 \(p(x_1,\ldots,x_L)=\prod_{t=1}^{L}p(x_t\mid x_{<t})\).
推理包括两个阶段:Prefill 一次处理 prompt 中的全部 token,并生成各层的 KV Cache;Decode 每轮只输入一个新 token,复用历史 KV Cache 计算下一个 token. Prefill 通常包含较大的矩阵乘,偏计算密集;Decode 的矩阵乘退化为矩阵向量乘,反复读取全部权重,通常更受显存带宽和 kernel 发射开销限制.
Gemma 4 的缩放点积注意力为 $$ A=\frac{QK^T}{\sqrt{d_h}}+M,\qquad P=\operatorname{softmax}(A),\qquad O=PV. $$
因果掩码保证位置 \(i\) 只能看到不晚于自身的位置. Gemma4 混合使用全局注意力与窗口大小为 \(W=1024\) 的滑动窗口注意力. 后者只保留 \(j\le i,\,j>i-W\) 范围内的键值. 真正高效的实现应跳过窗口外的分数计算,而不是先计算完整 \(L\times L\) 分数矩阵再加掩码.
Grouped Query Attention 令多个 Query heads 共享一个 KV head. 若 Query head 数为 \(H_q\)、KV head 数为 \(H_{kv}\),则Query head \(h\) 使用 $$ h_{kv}=\left\lfloor\frac{h}{H_q/H_{kv}}\right\rfloor. $$
Gemma4-12B 的滑窗层为 \(H_q=16,H_{kv}=8\),全局层为 \(H_q=16,H_{kv}=1\). GQA 不减少 Query 数量,但减少 K/V 投影、注意力计算和 KV Cache;实现时不应物化重复的 K/V.
Gemma4 的前馈层为
其中\(\operatorname{SiLU}(x)=x\sigma(x)\). 因此每层包含 gate、up、down 三个大矩阵(这在lab3中已有涉及). Decode 每一步都要读取这些权重,所以W4A16 量化可同时降低常驻显存和权重读取量.
权重与 KV Cache 显存¶
设模型有 \(N\) 层,隐藏维度 \(D\),Query heads 为 \(H_q\),KV heads 为 \(H_{kv}\),head dimension 为 \(D_h\),FFN 中间维度为 \(F\),词表为 \(V\). 忽略 Norm 等小参数,参数量近似为 $$ P\approx 2VD+ND(2H_qD_h+2H_{kv}D_h+3F). $$
\(x\)-bit 权重占用约为
12B 参数的 BF16 理论值约 24 GB,而 INT4 理论值约 6 GB;实际 INT4 checkpoint 还包含 scale、zero point、未量化参数、对齐和运行时内存.
静态 BF16 KV Cache 的一般表达式为
其中 2 表示 K 和 V. 对于混合注意力,更准确地写为
滑窗层采用 ring cache 后变为
INT4 线性量化¶
实数权重 \(r\) 和整数 code \(q\) 的映射为
本框架每个输出通道、每 group_size 个输入列共享一组量化参数. 对称 INT4 使用有符号值 \([-8,7]\),checkpoint 中编码为 \([0,15]\),反量化为
$$
\hat r=s(q_{code}-8),\qquad
s=\frac{\max |r|}{7}.
$$
非对称量化使用
一个 uint8 的低、高清半字节依次保存两个 INT4 code. 输入维度补齐到 group size 的整数倍;补齐位置必须编码成反量化后的零.
从 RTN 到 GPTQ¶
RTN 只最小化权重数值误差 \(\lVert W-Q\rVert\),没有考虑输入分布. 设校准激活为 \(X\in\mathbb R^{N\times d_{in}}\),线性层权重\(W\in\mathbb R^{d_{out}\times d_{in}}\),更有意义的层重构目标是 $$ \min_Q\lVert XW^T-XQ^T\rVert_F^2 =\min_Q\lVert X(W-Q)^T\rVert_F^2. $$
忽略不影响优化的常数,其 Hessian 为 \(H=\frac{2}{N}X^TX\),\(H\) 的对角项表示单个输入通道的重要程度,非对角项表示通道相关性. GPTQ按输入列量化:当前列舍入后,利用逆 Hessian 调整尚未量化的列,让后续列补偿当前误差.
令\(H^{-1}=U^TU\),且 \(U\) 为上三角矩阵. 第 \(i\) 列由 \(w_i\) 量化为 \(q_i\) 后,归一化误差为
剩余权重更新为
该更新不会改变已经量化的列,只把误差沿校准数据所揭示的相关方向传播. 预测的二次损失可累计为
若某输入通道在校准集中恒为零,则对应 Hessian 对角项为零,称为 dead column. 该列不影响校准输出,可以把对应权重清零并把 Hessian 对角项设为1. 随后加入阻尼
阻尼保证 Cholesky 分解稳定;过大会削弱通道相关信息. 实现中使用 FP32 构造 Hessian、求 Cholesky 因子并传播误差.
端到端推理性能优化¶
这一部分我是把完整推理按阶段和矩阵形状采样. 端到端日志负责观察总时间、OOM和batch效果,Linear微基准负责判断相同INT4权重在decode与prefill中应该走哪条路径,CUDA allocator报错用来定位峰值显存来自哪个临时张量.
Baseline表现¶
最初的int4_reference在每次Linear forward时都执行:
也就是说,虽然checkpoint是INT4,运行时却反复物化完整BF16权重. Decode一次只算很少的行,这个反量化成本无法摊薄;随后每个token、每一层还要重新做一次. 性能公开集共10个请求、生成320个token,batch size 1需要:
从调用结构和后面的对照试验可以判断,主要问题并不是Python调度,而是每步反量化、读取大权重和大量小kernel. 仅把请求组成batch之后就降到了86.0530 s,也说明decode中重复读取权重是主要矛盾:一次读权重同时服务更多请求,可以明显提高有效计算量.
Linear形状采样¶
模型中最有代表性的MLP投影在decode阶段形状约为:
对这一形状单独采样,自写Triton INT4 kernel约为1.73 ms,ATen的INT4 Tensor Core路径约为0.235 ms,后者快约7.4倍. 这解释了为什么74秒左右的Triton版本仍有非常大的优化空间:decode反复执行小\(M\)投影,单次省下的1 ms会被层数和token数放大.
但是把\(M\)换成prefill的长序列之后,结论正好相反. ATen INT4小矩阵kernel并不适合大\(M\),反量化一次再调用cuBLAS反而更快. 所以采样给出的结论不是“某个kernel永远最快”,而是要按负载分派:
| 阶段 | 矩阵特点 | 最终路径 | 原因 |
|---|---|---|---|
| Decode | \(M\le64\),反复调用 | ATen INT4 Tensor Core | 不物化BF16权重,小矩阵延迟最低 |
| Prefill | \(M\)较大 | 反量化 + cuBLAS | 大GEMM效率高,反量化成本可被多token摊薄 |
| 不支持ATen私有算子的环境 | 任意 | Triton / reference fallback | 保留可运行性和正确性 |
显存采样¶
仅看平均显存没有意义,10 GiB环境真正卡住的是某一个层、某一次prefill的峰值申请. 调试日志中出现过以下具有代表性的分配失败:
其中约770 MiB的申请来自长prompt下SDPA的GQA fallback:全局注意力只有1个KV head,但实现可能临时扩展成16个head. 另外,prefill同时保留ATen pack和原始packed权重,再物化数百MiB的BF16大投影,也容易触发allocator重试甚至OOM.
因此最后的显存策略是:
- GPU常驻ATen格式的INT4权重,服务decode;
- 原checkpoint布局的packed权重留在pinned CPU内存,只在当前层prefill时搬到GPU;
- 滑窗层KV Cache改成ring buffer,只保留最近1024个位置;
- prefill的attention按query维分块,控制score和probability的峰值;
- GQA decode不再物化重复K/V.
这实际上是用少量PCIe传输换取显存空间. 如果显存足够,两套布局都常驻GPU会更简单;但在10 GiB限制下,峰值是否能过比省一次H2D更重要.
迭代过程¶
下面的数据都来自相同的10条公开性能请求和320个生成token. MIG会有一定波动,所以表格主要用来看数量级和优化方向:
| 版本 | Batch size | elapsed_s |
吞吐量 | 现象 |
|---|---|---|---|---|
| reference路径 | 1 | 248.0881 s | 1.2899 token/s | 每次Linear物化BF16权重 |
| 仅增加batch | 4 | 86.0530 s | 3.7186 token/s | 权重读取由多个请求摊薄 |
| Triton INT4 + 分块attention | 4 | 74.5443 s | 4.2927 token/s | 小矩阵仍是热点 |
| 中间复测 | 4 | 76.6967 s | 4.1723 token/s | MIG运行存在波动 |
| ATen INT4 + 混合prefill | 4 | 42.5252 s | 7.5250 token/s | decode投影明显加快 |
| 加GQA/调度并增至batch 5 | 5 | 36.3942 s | 8.7926 token/s | 已接近36秒阈值 |
| 最终版本 | 5 | 35.6799 s | 8.9686 token/s | 达到性能满分区间 |
中间也试过一些看起来合理、实际上会负优化的方法:
- 所有阶段都强制使用ATen INT4:大\(M\) prefill反而比cuBLAS慢;
- prefill直接使用SDPA GQA:fallback可能展开KV,在长prompt时申请约770 MiB;
- attention始终用FP32 softmax:batch 5峰值显存过高;
- 把query chunk继续缩小:显存会下降,但kernel数量和发射开销增加;
- 盲目继续增大batch:padding和峰值显存开始抵消并行收益.
所以最后取batch size 5和query chunk 384. 这两个数字都不是理论上的固定最优值,而是当前数据集、10 GiB显存和这套实现共同约束下的实验结果.
多种优化方法的尝试¶
融合INT4反量化与矩阵乘¶
第一版优化写了Triton kernel,直接从uint8取出低/高半字节,根据K方向的group找到scale,然后在寄存器中反量化并做矩阵乘. 它避免了完整BF16权重张量:
low = packed & 0x0F
high = packed >> 4
code = tl.where(k & 1 == 0, low, high)
weight = (code.to(tl.float32) - 8.0) * scale
acc += tl.dot(x, weight)
原理上,reference路径的显存流量近似包含“读INT4 + 写BF16 + 再读BF16”,融合以后变为“读INT4和scale后直接参与计算”. Decode本身算术强度低,因此减少中间权重的写回和二次读取会直接改善性能.
后来又使用PyTorch内部的Tensor Core INT4算子. checkpoint把较早的K元素放在低半字节,而ATen pack要求放在高半字节,所以模型加载时先交换nibble,再一次性转换:
swapped = (qweight << 4) | (qweight >> 4)
int4pack = torch._convert_weight_to_int4pack(swapped.contiguous(), 8)
output = torch._weight_int4pack_mm(
inputs, int4pack, group_size, scale_and_zero
)
这一步必须放在计时区间之前,否则每轮重新pack会把收益全部吃掉. ATen量化参数最后一维为(scale, additive_offset);对称量化的offset为0,非对称情况应为\((8-z)s\).
Prefill和Decode分流¶
最开始我希望一个融合kernel处理所有形状,但是采样结果不支持这种做法. 最终按输入行数判断:小于等于64行时走ATen INT4;大矩阵则从CPU取本层的packed权重,反量化为输入dtype后调用F.linear.
为了减少pageable内存的隐式staging copy,CPU上的权重在加载时调用pin_memory(),搬运时使用non_blocking=True. 对特别大的投影还会先查询剩余显存;如果连BF16临时权重都放不下,就直接回退到INT4 GEMM,避免先触发一次必然失败的allocator申请.
共享embedding和LM head也需要单独处理. Gemma4词表大小为262144,若每轮输出投影都反量化整张embedding表,它会成为decode的另一个大热点. 最终lookup所需的checkpoint布局权重放在CPU,LM head使用ATen INT4 pack,从而既保留共享权重语义,又避免GPU上额外常驻约0.5 GiB副本.
分块Attention与GQA¶
Prefill若一次物化完整\(L\times L\) attention score,长序列很容易超过显存. 我沿query维每384个位置切一块:
for start in range(0, query_length, 384):
query_chunk = query[:, :, start:start + 384]
scores = torch.matmul(query_chunk, key.transpose(2, 3))
scores.add_(mask_chunk)
probability = torch.softmax(scores, dim=-1)
outputs.append(torch.matmul(probability, value))
峰值score空间从\(O(BH_qL^2)\)降到\(O(BH_qCL)\),其中\(C=384\). 总计算量没有变化,所以这只是显存分块,不是真正跳过无效计算的Flash Attention.
Decode只有一个query token,滑窗层直接调用支持enable_gqa=True的fused SDPA;全局层则保持单KV head,通过广播完成矩阵乘,不生成16份cache. 这样不仅减少显存流量,还减少了逐head kernel发射.
实现GQA时我还踩了几个shape相关的坑:
SlidingAttentionLayer漏存num_qo_heads,量化阶段直接报AttributeError;[:, head]会移除singleton维度,正确写法应是[:, head:head+1];- query分块以后mask也必须使用当前的
position_chunk,否则长度598的序列和512大小的chunk无法广播.
这些问题不一定在短样本中出现,所以最后必须重新跑完整62条质量集,而不能只用前几条样本判断正确性.
Ring KV Cache¶
原始实现为所有层都按max_sequence_length静态分配KV Cache. 但是滑窗层永远只访问最近\(W=1024\)个位置,因此可以用:
把新KV覆盖到ring buffer中. Decode读取时再按逻辑位置恢复顺序. 混合注意力下,显存从:
变成:
全局层仍然需要完整历史,不能一起截断. Ring Cache主要释放了batch增大所需的显存,本身不一定直接缩短很多计算时间.
静态Batch重排¶
静态batch中只要有一个请求还没有生成完,其他已经结束的请求所在slot仍会造成浪费. prompt长度差别很大时,padding也会让prefill执行很多无效位置.
因此在所有请求已知的公开评测中,我先按prompt长度、再按生成长度排序,然后才组成静态batch. 这样相近长度的请求更容易放到一起,可以同时减少:
- prefill padding;
- 短请求等待长请求的空decode轮次;
- 单个batch的意外显存峰值.
这还不是continuous batching,因为一个请求结束后不会立刻补入新请求;但是对于只有10条、全部预先到达的性能集,重排已经能取得大部分容易获得的收益.
去掉计时路径中的同步¶
原始指标代码为了精确测每个小阶段,会频繁执行torch.cuda.synchronize()并查询峰值显存. 但是正式指标是整个数据集的端到端elapsed_s,每token同步会破坏CUDA异步流水.
最终配置设置:
同时运行脚本增加--no-progress,避免每个请求完成后刷新终端进入端到端时间. 总计时边界仍然保证GPU工作完成,只是不再在内部每个小区间强制同步.
量化正确性修正¶
这次有一个问题很值得单独记下来:packed INT4的解包不能直接把结果交错写回原来的uint8视图. 错误版本近似为:
uint8并不存在两个独立的uint4子元素,第一次赋值会修改后面还需要右移读取的存储,导致大量错误token. 最后改成先独立计算,再stack:
另外还修正了embedding scale链式赋值提前引用hidden_states的问题. 这些修正说明“程序能生成文本”不能代替精度验证;性能优化过程中只要改了权重布局、attention shape或embedding,都需要重新计算NLL.
最终结果¶
质量评测使用完整62条公开样本,共20418个有效token,最终结果为:
| 指标 | BF16参考 | W4A16结果 | 差值/要求 |
|---|---|---|---|
| Mean NLL | 2.308456 | 2.376512 | \(\Delta\mathrm{NLL}=0.068057\) |
| Perplexity | — | 10.767283 | 比值1.070426 |
| 质量要求 | — | Passed | \(\Delta\mathrm{NLL}<0.1\)满分线 |
不同序列长度桶的结果为:
| 长度桶 | 序列数 | INT4 Mean NLL | \(\Delta\mathrm{NLL}\) |
|---|---|---|---|
| 128 | 32 | 3.026874 | 0.081730 |
| 256 | 16 | 2.477330 | 0.046893 |
| 512 | 8 | 2.380981 | 0.056245 |
| 1024 | 4 | 1.990667 | 0.082277 |
| 2048 | 2 | 2.011638 | 0.073156 |
所有长度桶都低于0.1,说明提升阻尼、修改运行时kernel和修正embedding之后,并没有只在短序列上碰巧通过.
最终性能结果:
requests: 10
generated_tokens: 320
elapsed_s: 35.6798625930096
requests_per_s: 0.280270
generated_tokens_per_s: 8.968644
batch_size: 5
相对248.0881 s的参考路径,端到端时间减少:
加速比为:
相对76.6967 s的batch-4 Triton版本又缩短了53.48%. 最终质量和性能都进入100分区间,对应验证日志分别为:
results/quality-final-184607.log;results/lab5-baseline-184599.log.
总结¶
这次最重要的结论是:端到端推理优化不能只优化一个算子. Baseline的主要问题是decode中反复物化BF16权重;换成INT4 Tensor Core以后,瓶颈又移动到prefill、attention临时显存、LM head和batch利用率. 最终版本实际上是几种方法的组合:
- GPTQ保证W4A16精度;
- decode使用ATen INT4 Tensor Core;
- prefill使用CPU/GPU混合权重和cuBLAS;
- query分块、GQA广播和ring KV Cache控制10 GiB下的峰值显存;
- 长度感知的batch重排提高有效计算比例;
- 去掉计时路径中的逐token同步.
还有两个限制需要注意. 第一,_convert_weight_to_int4pack和_weight_int4pack_mm是PyTorch私有接口,换PyTorch版本后要重新验证;第二,35.6799 s距离36 s阈值只有约0.32 s,MIG邻居负载会造成波动,正式提交最好重复运行并取中位数. 如果继续优化,我会优先考虑真正不物化score的Flash Attention和continuous batching,而不是继续微调固定的chunk size.