← All notes

vLLM 量化与推测解码:怎样减少容量、带宽与串行轮次

Table of contents

本篇要回答什么

模型名写着 INT4,整台服务的显存能否直接除以四?FP8 与 INT8 同为 1 byte,为什么误差和 kernel 完全不同?推测解码先由小模型“猜”多个 tokens,最终输出为什么仍能服从目标模型分布?

量化减少某类数据的表示宽度;推测解码尝试减少目标模型串行 decode 的轮数。二者改变的是不同成本项,都需要把容量、正确性、kernel 支持和端到端性能分开验证。

1. 从实数到有限编码

仿射量化可写为:

\[q=\operatorname{clamp} \left(\operatorname{round}(x/s)+z,q_{min},q_{max}\right), \qquad \hat x=s(q-z).\]

$s$ 是 scale,表示整数刻度的一格对应多大实数范围;$z$ 是 zero point;round 和 clamp 说明信息通常不可逆。

若把校准区间 $[x_{min},x_{max}]$ 铺到整数范围,一种常见选择是

\[s=\frac{x_{max}-x_{min}}{q_{max}-q_{min}}, \qquad z=\operatorname{round}(q_{min}-x_{min}/s).\]

这只是建立概念的 min-max 方案,不等于 AWQ、GPTQ 或所有生产 kernel 的完整算法。scale 可以按 tensor、channel、group 或 block 计算;粒度越细通常越能适应局部动态范围,但 metadata 和 kernel 寻址也更复杂。

bit、byte 与编码数量

$b$ bits 最多表示 $2^b$ 个不同编码;8 bits=1 byte,所以 INT8 有 256 个 bit patterns,INT4 有 16 个,两个 INT4 values 理论上可打包进 1 byte。编码数量不是可表示实数的“精度保证”:scale、zero point、动态范围、舍入、饱和和数据分布共同决定误差。

以 unsigned $b$-bit 仿射量化为例,$q_{min}=0,q_{max}=2^b-1$。要求 $x_{min}\mapsto q_{min}$、$x_{max}\mapsto q_{max}$:

\[q_{min}\approx x_{min}/s+z,\qquad q_{max}\approx x_{max}/s+z.\]

两式相减得到

\[s=\frac{x_{max}-x_{min}}{q_{max}-q_{min}},\]

再代回得到 $z\approx q_{min}-x_{min}/s$,随后 round 并 clamp 到合法整数范围。对称量化常固定 $z=0$,用最大绝对值确定 $s$;非对称量化可更充分覆盖偏置区间,但 zero-point 运算/metadata 可能更复杂。

可执行的 per-tensor 与 per-group 量化

def affine_quantize(x, bits=8, group_size=None):
    flat = [float(v) for v in x]
    if group_size is None:
        group_size = len(flat)
    qmin, qmax = 0, 2**bits - 1
    q, xhat, meta = [], [], []
    for start in range(0, len(flat), group_size):
        g = flat[start:min(start + group_size, len(flat))]
        lo, hi = min(min(g), 0.0), max(max(g), 0.0)  # 保证实数 0 可表示
        scale = (hi - lo) / (qmax - qmin) if hi != lo else 1.0
        zero = max(qmin, min(qmax, round(qmin - lo / scale)))
        qg = [max(qmin, min(qmax, round(v / scale) + zero)) for v in g]
        q.extend(qg)
        xhat.extend(scale * (code - zero) for code in qg)
        meta.append((scale, zero))
    return q, xhat, meta

x = [-8.0, -0.2, 0.1, 0.3, 7.0, 7.1, 7.2, 7.3]
_, tensor_hat, tensor_meta = affine_quantize(x, bits=4)
_, group_hat, group_meta = affine_quantize(x, bits=4, group_size=4)
mae = lambda a, b: sum(abs(u-v) for u, v in zip(a, b)) / len(a)
print("per-tensor MAE", mae(x, tensor_hat), tensor_meta)
print("per-group  MAE", mae(x, group_hat), group_meta)
assert len(tensor_meta) == 1 and len(group_meta) == 2

输入是 float tensor、bits 和 group size;输出是量化 codes、反量化近似与每组 (scale,zero)。shape 不变,但最后一维被逻辑分组。状态无变化。它实现 min-max affine 关系,不能模拟 INT4 packed storage、outlier 策略、AWQ/GPTQ 校准或真实 GPU kernel;per-group 也不保证对每组数据误差一定更小,只提供更细局部范围。

2. 权重、激活、累加器和 KV 必须分开

线性层

\[Y=XW^T\]

至少涉及:常驻权重 $W$、随本轮 tokens 变化的激活 $X/Y$、点积累加器,以及服务生命周期中增长的 KV Cache。一个 INT4 checkpoint 通常只说明权重表示。

对象 主要随什么增长 量化后的直接影响
权重 参数量 常驻容量和权重读取带宽
激活 本轮 token/batch shape 中间容量、通信和算子输入
accumulator 点积实现 数值范围和精度
KV Cache 活跃请求的历史 tokens 并发容量与 attention 读取带宽

W4A16 通常表示 4-bit 权重、16-bit 激活;仍未说明 scale、zero point、打包布局、accumulator 和 KV dtype。因此不能从“权重四倍压缩”推出总显存四倍下降。

权重 payload 可粗写为

\[B_{weight}=N_{param}b_w/8+B_{meta},\]

而普通 KV payload 为

\[B_{KV}=2LTN_{kv}De+B_{scale/alignment}.\]

两者增长规律不同。

activation(激活)泛指模型 forward 中由当前输入产生的中间 tensor,例如 embedding、hidden states、Q/K/V、MLP 中间值和 layer output。激活函数(如 SiLU/GELU)是生成其中一部分激活的算子;二者有关但不是同义词。权重随模型保存,激活随请求和本轮 shape 产生。

显存(HBM)是 GPU 芯片外接/封装内的高带宽存储,不是执行乘加的 Tensor Core/CUDA core。简化路径是:HBM 中的权重/激活 → cache/shared memory/register → 计算单元 → 结果写回。decode 每次只处理少量 tokens,却要逐层读取大量权重,因此搬运常很重要。

W4A16 不要求先把整份 INT4 权重永久展开成 BF16 副本。专用 kernel 通常读取 packed INT4 与 group scales,在寄存器/片上 tile 内解包和缩放,与 BF16/FP16 activation 相乘,并用更高精度累加;展开值用完即丢弃。若 backend 不支持而真的预先全量反量化,容量优势会大幅丢失,这正是必须核对实际 kernel/fallback 的原因。

1024 个 INT4 参数的纯 payload 是 $1024\times4/8=512$ bytes。group_size=128 时有 8 groups;若每组保存 2-byte scale + 2-byte zero,则 metadata 32 bytes,总计 544 bytes。4096 个 INT4、group size 64 则 payload 2048 bytes、64 组 metadata 256 bytes、合计 2304 bytes。真实 checkpoint 还含 tensor headers、对齐和未量化参数。

3. FP8、INT8 与 INT4

FP8 和 INT8 都占 1 byte,只说明存储宽度相同:

  • INT8 把 bits 解释为整数编码,常配合显式 scale/zero point;
  • FP8 按符号、指数、尾数解释,E4M3 倾向更多有效数字,E5M2 倾向更大动态范围;
  • INT4 只有 16 个编码,打包、group size 和反量化路径更关键。

同样减少一半 byte,延迟也不会自动减半。若某算子 80% 时间用于读权重、20% 是其他成本,即使权重读取理想减半,总时间也只是

\[0.8/2+0.2=0.6,\]

即约 1.67 倍;scale、解包、反量化和对齐还会改变结果。prefill 偏计算受限、decode 偏带宽受限时,同一量化方案的收益也可能不同。

格式 典型编码 动态范围/精度倾向 常见额外信息
INT4 16 个整数编码 极窄,强依赖 group scale packed layout、scale、可选 zero
INT8 256 个整数编码 均匀格点 scale、可选 zero
FP8 E4M3 1 sign/4 exponent/3 mantissa 较多尾数,范围较小 tensor/block scale、饱和策略
FP8 E5M2 1/5/2 范围更大,尾数更少 同上
BF16 1/8/7 范围接近 FP32 通常无需显式量化 scale

格式名仍不足以定义数值行为:是否保留 Inf/NaN、subnormal、有限值变体、舍入模式与 scaling recipe 都要看硬件和 kernel 契约。

4. AWQ、GPTQ 与 Marlin 是两层问题

AWQ 与 GPTQ 主要回答“怎样从浮点权重得到误差较小的低比特权重”:

  • AWQ 使用校准激活统计识别更敏感的权重通道,并通过缩放等方法保护它们;activation-aware 不表示最终一定把激活存成 INT4;
  • GPTQ 是训练后量化方法,利用二阶信息近似并在逐层处理时补偿已引入误差,不是简单逐元素四舍五入。

Marlin 更接近执行层:怎样让打包权重、scale/zero point 与半精度激活在 GPU 上高效完成量化矩阵乘。

校准数据 → AWQ / GPTQ → 量化权重、scale、zero、布局
                                  │
输入激活 ─────────────────────────┼→ Marlin 等 kernel → 输出激活

因此 GPTQ-Marlin 并不矛盾:前者描述权重如何产生,后者描述兼容时怎样执行。两个 4-bit checkpoints 也未必可互换,元数据协议和打包布局可能不同。

vLLM v0.20.0 可从 model_executor/layers/quantization/__init__.py 的方法注册开始,继续检查对应 QuantizationConfig 的 activation dtype、最低计算能力、bits/group size 和 get_quant_method。注册存在只证明框架认识该方法;启动日志、实际层选择和 profiler 才能证明本次动态路径。

更细的责任图是:

离线量化/模型制作者
  calibration samples
       ├─ AWQ:用激活统计寻找敏感通道,选择保护/缩放方案
       └─ GPTQ:逐层近似最小化输出误差,用 Hessian/二阶近似补偿
             ↓
  packed qweight + scales + zeros + group_size + metadata
             ↓ checkpoint load
vLLM QuantizationConfig
  验证格式/平台/activation dtype,给具体 layer 选择 quant method
             ↓
Marlin 或其他 execution kernel
  半精度 activation + packed low-bit weight
  → 解包/缩放/矩阵乘/高精度累加 → output activation

GPTQ 的直觉不是“更聪明的 round”一句话,而是某列权重量化产生误差后,根据近似 Hessian $H\approx X^TX$ 评估它对层输出的影响,并调整尚未量化的权重来补偿。AWQ 则利用激活观测到“少数权重通道对输出尤其重要”,通过等价缩放等方式降低这些通道量化误差。二者都不能仅靠名字推断 group size、zero point 或最终 kernel。

5. KV Cache 量化的容量账

以每 token 112 KiB 的 BF16 KV 为例,1-byte 格式的理论 payload 为 56 KiB/token。4096 tokens 从 448 MiB 降到 224 MiB,但真实运行时至少还有:

\[B_{runtime}=B_{KV\ payload}+B_{scales}+B_{block\ alignment}+B_{allocator/reserve}.\]

更多可用 byte 能否变成更多 blocks,还取决于启动 profiling 后剩余显存、block size、backend layout、graph/workspace 和模型常驻对象。

K 的量化误差会改变 attention logits,V 的误差会改变加权结果,并经过后续投影和层继续传播。因此验证至少分成:

  1. 质量:固定 tokenized prompts 和采样,比较 logits、输出或任务指标;
  2. 容量:记录分配 blocks、最大 token 容量和 OOM 边界;
  3. 性能:固定到达过程和输入输出长度,比较 TTFT、ITL、吞吐。

CLI 能解析 --kv-cache-dtype 不等于具体 backend 能消费该格式。应沿 CacheConfig.cache_dtype、KV quant mode、backend capability 和实际 forward/log 逐层确认。

容量核算器:

from math import ceil

def kv_capacity(pool_bytes, *, layers, kv_heads, head_dim,
                bits, block_size, scale_bytes_per_block=0):
    payload_per_token = 2 * layers * kv_heads * head_dim * bits / 8
    payload_per_block = payload_per_token * block_size
    block_bytes = payload_per_block + scale_bytes_per_block
    blocks = int(pool_bytes // block_bytes)
    return {"bytes_per_token_payload": payload_per_token,
            "blocks": blocks,
            "token_slots": blocks * block_size,
            "unused_pool_bytes": pool_bytes - blocks * block_bytes}

bf16 = kv_capacity(1 << 30, layers=28, kv_heads=8, head_dim=128,
                   bits=16, block_size=16)
fp8 = kv_capacity(1 << 30, layers=28, kv_heads=8, head_dim=128,
                  bits=8, block_size=16)
assert bf16["bytes_per_token_payload"] == 112 * 1024
assert fp8["bytes_per_token_payload"] == 56 * 1024
assert fp8["token_slots"] == 2 * bf16["token_slots"]

在假设 scale overhead 为 0 且 pool 固定 1 GiB 时,slot 数理想翻倍。真实 pool 预算、scale 粒度、对齐、backend 和 allocator 取整会使结果偏离;更多 slots 也只有在 workload 受 KV 容量限制时才可能提高并发。

v0.20.0 中 KV dtype、scale 与真实分配在哪里

按固定版本从配置到执行核对:

CLI / EngineArgs: --kv-cache-dtype
  → CacheConfig.cache_dtype                 # 用户意图/规范化配置
  → v1/kv_cache_interface.py quant mode     # 按 dtype 分类 cache spec
  → AttentionBackend.supports_kv_cache_dtype
  → 具体 backend.supported_kv_cache_dtypes  # 静态能力与 layout 约束
  → GPUModelRunner / worker 初始化 cache tensors、scale 路径
  → 启动日志 + forward trace                # 动态选择证据

量化方式、scale 来源/布局不能只从一个 flag 推出:可能来自 checkpoint tensor/metadata、cache config、backend 定义和 runner 初始化。calculate_kv_scales 等旧配置在固定版本若已有弃用提示,就应继续查 backend/checkpoint,而不是沿用旧版经验。

“初始化时已经分好 blocks”与“请求时还要分配”指两个层次:engine 启动时预分配整个 GPU KV pool 和固定 physical block slots;请求到来时,Scheduler 只是在这批既有 slots 中给请求分配 logical ownership/block ids。后者通常不新建 CUDA 大 tensor,但会修改 free queue、ref count 与 block table。

6. 推测解码减少目标模型串行轮次

普通 decode 必须先得到 $x_1$ 才能构造 $x_2$ 的上下文。推测解码让较便宜的 proposer 先给出 $k$ 个候选,再让 target model 一次并行验证多个位置:

proposer:     x1? → x2? → x3?
target:       [x1?, x2?, x3?] 一次前向获得各位置 logits
sampler:      从左到右接受;首次拒绝后,后续候选全部作废

后一个候选依赖前一个候选作为上下文,所以不能跳过前面的拒绝继续接受后面的 token。

大小模型的概率不是“提前全知道”。proposer 每生成一个 draft token 时保留该位置的 $q(\cdot\mid context)$ 或至少候选概率;拿到整段 draft 后,target 用一次 batched forward 对这些候选上下文并行产生各位置的 $p(\cdot\mid context)$。然后 sampler 才同时拥有对应位置的 p/q,逐位置做接受检验。

小模型串行提出 d1,d2,d3,并保存 q1(d1),q2(d2),q3(d3)
              ↓
大模型对 [context+d1+d2+d3] 一次前向
得到 p1(.),p2(.),p3(.) 以及下一位置 p4(.)
              ↓
从 d1 开始接受/拒绝

target “一次并行验证”仍计算每个候选位置的 logits,并非不用大模型。

7. 拒绝采样为什么保持目标分布

设 proposer 分布为 $q$,target 分布为 $p$。先从 $q$ 抽到候选 $x$,以

\[a(x)=\min\left(1,\frac{p(x)}{q(x)}\right)\]

接受。候选直接成为输出 $x$ 的概率质量为

\[q(x)a(x)=\min(q(x),p(x)).\]

若拒绝,从正残差分布采样:

\[r(x)= \frac{\max(p(x)-q(x),0)} {\sum_y\max(p(y)-q(y),0)}.\]

\[p=[0.6,0.3,0.1],\qquad q=[0.3,0.4,0.3].\]

直接接受质量是 [0.3,0.3,0.1],总计 0.7;拒绝质量为 0.3。正残差归一化为 [1,0,0],最终:

\[[0.3,0.3,0.1]+0.3[1,0,0] =[0.6,0.3,0.1]=p.\]

因此“先猜再验”在使用正确接受/修正规则时不会把最终随机采样分布替换成 $q$。

源码中的三类结果要分开:accepted 是从左到右连续通过的 draft tokens;recovered 是首次拒绝处从残差分布得到的 token;bonus 只可能在所有候选接受后附加。

证明中的关键恒等式是

\[\sum_x\min(p_x,q_x)=1-\sum_x(p_x-q_x)_+ =1-\sum_x(q_x-p_x)_+.\]

令拒绝总概率 $Z=\sum_x(p_x-q_x)_+$。最终输出 $x$ 的质量为

\[\underbrace{\min(p_x,q_x)}_{\text{直接接受}} +\underbrace{Z\frac{(p_x-q_x)_+}{Z}}_{\text{拒绝后恢复}} =\min(p_x,q_x)+(p_x-q_x)_+=p_x.\]

精确枚举与 Monte Carlo

import random

p = [0.6, 0.3, 0.1]
q = [0.3, 0.4, 0.3]
accepted_mass = [min(px, qx) for px, qx in zip(p, q)]
reject_prob = 1.0 - sum(accepted_mass)
positive = [max(px-qx, 0.0) for px, qx in zip(p, q)]
residual = [x/sum(positive) for x in positive]
exact = [a + reject_prob*r for a, r in zip(accepted_mass, residual)]
assert all(abs(a-b) < 1e-12 for a, b in zip(exact, p))

rng = random.Random(7)
counts = [0, 0, 0]
for _ in range(300_000):
    x = rng.choices(range(3), weights=q)[0]
    if rng.random() < min(1.0, p[x]/q[x]):
        out = x
    else:
        out = rng.choices(range(3), weights=residual)[0]
    counts[out] += 1
empirical = [n/sum(counts) for n in counts]
print(exact, empirical)
assert max(abs(a-b) for a, b in zip(empirical, p)) < 0.005

输入是同一上下文下 target 分布 $p$ 与 proposer 分布 $q$;输出是一个最终 token。循环状态只有计数器。枚举证明单 token 修正分布,Monte Carlo 检查实现。真实推测解码对一串候选从左到右重复条件化,并处理数值稳定、batch、top-k/p、grammar、bonus 和 KV commit;这段代码不证明完整 serving path。

完整候选时间线:

draft: d1 d2 d3 d4
verify d1: accept
verify d2: accept
verify d3: reject → sample recovered r3
d4: 依赖被拒的 d3,直接作废
本轮提交: [d1,d2,r3]

若 d1..d4 全接受:可再从 target 的下一个位置取 bonus token

accepted=2 并不表示目标模型“只算了 2 个位置”;target 可能已并行给出候选位置 logits,但控制状态/KV 只能提交保持正确上下文的连续接受前缀和恢复 token。

第三个候选被拒绝时,并不缺“第三个位置其他 token 的概率”:target forward 已产生完整 $p_3(\cdot)$,proposer 也提供 $q_3(\cdot)$。sampler 用两者构造正残差 $[p_3-q_3]_+$,从中抽 recovered token。第四个候选依赖被拒绝的第三个上下文,所以作废;下一轮以 recovered token 为真实历史继续。

8. 接受率高仍不保证更快

粗略成本为

\[\text{cost per final token} \approx \frac{C_{draft}+C_{verify}+C_{sampling}} {E[N_{output\ per\ step}]}.\]

只有每轮有效输出数的增长快于 proposer、target verification、logits、KV 和 padding 成本,端到端才加速。大并发下,target decode 本来就可能填满 GPU,多验证候选还会抢占其他请求的 token budget。

v0.20.0 可从 config/speculative.py::SpeculativeConfig 看候选数、method 和 draft 配置,从 v1/spec_decode 看 proposer,从 v1/sample/rejection_sampler.py 看验证与拒绝采样。

9. 可归因的优化实验

不要同时打开五个开关再把全部收益归给其中一个。先固定模型、commit、GPU、tokenized workload、到达率、采样、warm-up 和测量窗口,然后每次改变一个主变量:

实验 权重 KV 推测解码 问题
A BF16 BF16 关闭 基线
B INT4 BF16 关闭 权重量化净影响
C BF16 FP8 关闭 KV 量化净影响
D INT4 FP8 关闭 两种量化交互
E INT4 FP8 开启 推测解码增量

同时记录正确性、峰值显存、TTFT/ITL/E2E 分位数、request/token throughput、preemption、cache hit、实际 backend/kernel 和 speculative acceptance。吞吐提高而 P99 TTFT 变差、blocks 增加但质量下降、接受率高却不加速,都是解释机制的重要反指标。

实验还需设置门槛:先确认同一 tokenized workload、相同 sampling/seed(或统计等价)、无错误/OOM,再比较性能。量化实验要报告模型质量或 logits 差异;推测解码要报告 target-call 次数、draft/accepted/recovered/bonus tokens、每轮有效输出和 verification batch shape。否则“acceptance=90%”无法说明 proposer 占了多少时间,也无法解释为何没有加速。

常见误区

  • “INT4 模型的所有显存都变为四分之一”:激活、KV、未量化层和 workspace 不会同比变化。
  • “FP8 就是占 1 byte 的 INT8”:编码规则与动态范围不同。
  • “checkpoint 更小必然更快”:执行 kernel、shape 和瓶颈决定速度。
  • “speculative acceptance 就是 APC hit”:一个是候选验证,一个是前缀 KV 复用。

本篇总结

量化应先回答“哪个对象、什么格式、何种 scale、由哪个 kernel 消费”;推测解码应先回答“proposer 成本、target 验证和每轮有效输出如何平衡”。任何开关都只能在固定 workload 和正确性门槛下,通过容量、延迟与吞吐的对照实验建立收益。


上一篇:Scheduler · 系列首页 · 下一篇:FlashAttention

资料