Jellow 编辑发布,AI 辅助生成并完成技术核对。 计算示例和实测结果会在文中分别说明。
把 BF16 模型换成 4bit,显存占用明显降了,生成速度却只涨了一点。该怀疑量化方案,还是推理框架?
先别急着换框架。模型文件的大小、一次前向需要搬运的数据量、GPU 完成这些工作的时间,是三件不同的事。量化直接改变其中一部分,剩下的部分可能很快成为主要开销。
这篇文章只讨论稠密自回归模型的权重量化,重点是 W4A16 的 Decode 阶段。这里 W 表示权重精度,A 表示激活精度;不把原生低精度计算、KV Cache 量化、MoE 或跨卡通信混到同一个加速数字里。Prefill 与 Decode 的基本区别,可以先看系列第一篇。
4bit 描述的是哪一段数据
W4A16 的含义不是“整个模型用 4bit 计算”。以 TensorRT-LLM 文档中的 weight-only 路径为例,权重以低位宽存储,线性层使用时进行反量化,激活仍为 FP16 或 BF16。官方精度说明把 W4A16 与其他精度方案分开列出。
只算权重存储,也未必能从 16bit 精确降到四分之一。假设原权重占 W 字节,其中比例 f 被量化;每 G 个被量化的权重共用一个 16bit scale,暂不考虑 zero-point、对齐和其他元数据,那么:
量化后权重 / 原权重 ≈ (1 - f) + f / 4 + f / G
最后一项来自 scale:原来每个权重是 2 字节,一组 G 个权重额外保存 2 字节,所以相对于原权重的比例是 f / G。
取 f = 0.9,G = 128,结果约为 0.332。这个假想格式的压缩比约 3.01 倍,尚未计入工作区、激活、KV Cache 和分配器预留。它说明为什么“4bit 模型”不足以推算进程总显存;实际数字要看格式和加载结果。
Decode 每一步,还要读什么
考虑一个简化场景:单卡、稠密模型、完整因果注意力,一个 Decode 批次包含 B 条序列,每条生成一个 token。忽略片上缓存复用差异,假设批内共享一次权重读取,历史 KV 各读一次。
这一步的主要读取量可以粗估为:
读取量 ≈ W + B × K
W 是整次前向需要读取的权重字节数;K 是一条序列当前历史 KV 的总字节数。式子省略了激活读写、新 KV 写入、scale 读取以及其他算子流量,因此只是定位瓶颈的模型,不能直接当作 profiler 结果。
对使用常规 K、V 布局的完整注意力,单条序列每个历史 token 的 KV 大小为:
c = 2 × L × Hkv × D × bytes_per_element
K = S × c
其中 2 对应 K 和 V,L 为层数,Hkv 为 KV 头数,D 为头维度,S 为上下文长度。假设 L=32、Hkv=8、D=128,KV 使用 2 字节精度,则 c=128 KiB/token。当 S=8192 时,每条序列的历史 KV 就有 1 GiB。
这一步没有改变 KV 精度。权重量化之后,这 1 GiB 仍然在。
下面另设一组便于心算的权重数字:BF16 路径每步读取 14 GiB,量化路径连同相关元数据读取 4 GiB。这两个数是独立的假设输入,不是上面 f、G 示例的测量结果。 假设两条路径的有效带宽均为 1 TiB/s,暂时忽略计算和额外开销:
| Decode 批次 B | BF16 读取时间 | 量化读取时间 | 仅按读取估计的加速比 |
|---|---|---|---|
| 1 | 14.65 ms | 4.88 ms | 3.00× |
| 8 | 21.48 ms | 11.72 ms | 1.83× |
| 32 | 44.92 ms | 35.16 ms | 1.28× |
所有数字都是计算示例。两条路径各有相同的 B,不是在拿单请求和高并发互相比。
加速比来自:
Sread = (W16 + B × K) / (W4 + B × K)
当 B × K 越来越大,分子和分母里相同的部分占比升高,加速比便趋近 1。反过来,如果上下文很短、批次很小,权重流量占主导,权重量化就更值得优先验证。
还有一个容易误读的地方:B 增加时,表中的一步耗时也增加,但一步产出了 B 个 token。系统吞吐应按 B / step_time 算,不能只看一步越来越慢就认定总吞吐下降;同样,总吞吐增加也不意味着每个用户的输出间隔更短。
把反量化和计算放回去
TensorRT 的 weight-only 实现会读取 4bit 权重,再反量化并做高精度点积。低位宽存储因此不自动等于同倍数的算术加速。TensorRT 11.2.1 的实现说明直接描述了这条执行路径。
用一个粗略的 roofline 视角,可以把单步时间写成:
Tstep ≳ max(实际搬运字节 / 有效带宽, 实际运算量 / 有效算力)
这不是把整个模型当成一个能完美重叠的大算子。真实前向由许多有依赖的算子组成,还要考虑调度、同步和启动开销。它的用处是提醒我们:降低字节数,只压低了其中一个约束。
反量化也不能机械地当成一段独立时间加上去。有的实现把它融合到矩阵乘里,与访存或计算部分重叠;有的形状却会因解包、寄存器压力或低效内核丢掉收益。应该看实际执行的 kernel 和时间分布。
为了估算余量,可以暂时再作一个更强的假设:两条路径都只受带宽限制,而且量化路径多出一段无法重叠的额外耗时 Δ。那么量化更快的条件是:
Δ < (W16 - W4) / BW
在上表的假设下,右边约为 9.77 ms。超过它,少搬权重省出的时间就被抵消。这个阈值与 B 无关,是因为模型假设两条路径的 KV 流量、带宽完全相同;实际内核一变,这个简化条件也要重算。
一个可以自己改参数的计算器
下面的 Python 只依赖标准库,保存后直接运行。它不调用 GPU,也不预测某张显卡的性能。compute_floor_ms 和 extra_ms 是留给读者探索边界的假设参数,不能不经测量就填成“真实开销”。
GiB = 2**30
TiB = 2**40
def step_ms(weight_gib, batch, kv_gib=1.0, bandwidth=TiB,
compute_floor_ms=0.0, extra_ms=0.0):
read_ms = (weight_gib + batch * kv_gib) * GiB / bandwidth * 1000
return max(read_ms, compute_floor_ms) + extra_ms
print("B bf16_ms w4_ms speedup")
for batch in (1, 8, 32):
bf16 = step_ms(14, batch)
w4 = step_ms(4, batch)
print(f"{batch:2d} {bf16:8.2f} {w4:6.2f} {bf16 / w4:7.2f}")
# 人为加入 12 ms 无法重叠的额外开销,构造更慢的反例。
print("extra_12ms_speedup:", round(step_ms(14, 1) /
step_ms(4, 1, extra_ms=12), 3))
# 同样的 50 ms 计算约束主导两条路径:搬运减少也不改变结果。
print("compute_bound_speedup:", step_ms(14, 1, compute_floor_ms=50) /
step_ms(4, 1, compute_floor_ms=50))
最后两行分别得到约 0.868 和 1.0。前者是额外开销吃掉收益的反例,后者是计算约束盖住访存收益的反例。这两种情况都不需要“量化失败”才能发生。
长输入 Prefill 应单独测量。一次处理更多 token 会改变矩阵形状和权重复用程度;Decode 上得到的比例,没有理由原样搬到 Prefill。
实际选型时,把两场实验分开做
第一场实验固定工作负载,回答“相同工作是不是更快”。使用同一模型来源、tokenizer、输入 token、输出长度设置和 KV 精度,在同一 GPU 上比较 BF16 与 W4A16。保持推理框架、注意力实现等尽可能一致,记录无法一致的部分;固定一个可容纳的批次后,再分别测短、长上下文。单独记录 Prefill、Decode、端到端延迟,以及真正执行的 kernel。
第二场实验允许利用量化省下的显存,回答“同一硬件能服务多少人”。逐步增加负载,比较满足首 token 延迟和输出间隔要求的吞吐。此时更高吞吐可能来自容纳了更多 KV,而不是每个请求本身更快。报告里应把这个原因写出来。
两场实验都需要先过质量门槛。位宽和误差之间没有一个适用于所有模型、所有任务的固定换算。AWQ 论文利用激活统计识别重要通道并调整缩放,说明权重如何量化同样关键;论文中的效果不能替代你自己的任务检查。
做代码生成,就检查可执行正确率;做结构化提取,就检查字段准确率和格式通过率。测试数据与量化校准数据分开,尤其保留真实业务中较长、较难的输入。若质量不达标,先比较更合适的校准方式或更高精度方案,再讨论吞吐。
当结果不符合预期,可以按观察到的现象缩小范围:
| 现象 | 下一步检查 |
|---|---|
| 短上下文、小批次也更慢 | 是否使用预期的量化 kernel;是否存在解包、转换或回退路径 |
| 短上下文有效,长上下文收益缩小 | 实际 KV 流量和注意力耗时是否已占主要部分 |
| 显存降了,固定批次速度接近 | 原瓶颈是否在计算、启动或其他未被量化的操作 |
| 高并发吞吐更高,输出间隔变大 | 是否只是批次增大;延迟约束下的有效吞吐是否仍有收益 |
量化值得做的理由可以是省显存、降低单请求延迟,也可以是在同样延迟要求下多接请求。先把希望得到的收益说清楚,后面的测试才不会被一个漂亮的“4bit”标签带偏。
技术资料核对日期:2026-09-05。本文代码用于验证计算关系,未进行 GPU 性能实测;运行时请以所用版本的文档和 profiler 结果为准。