Skip to content

量化、KV缓存与吞吐预算 ​

权重内存粗估为参数量×每参数bit/8,还需量化尺度与元数据。推理还要为KV缓存、工作缓冲、激活和并发留空间,不能把“7B×4bit约3.5GB”当作整机最低配置。

python
def weight_bytes(parameters, bits):
    return parameters*bits/8
def kv_bytes(batch, tokens, layers, kv_heads, head_dim, bytes_per_value):
    return 2*batch*tokens*layers*kv_heads*head_dim*bytes_per_value
weights = weight_bytes(7_000_000_000,4)
cache = kv_bytes(1,4096,32,8,128,2)
assert weights == 3_500_000_000
assert cache == 536870912
assert kv_bytes(2,4096,32,8,128,2) == 2*cache
print(round(weights/2**30,2), round(cache/2**30,2))

输出约3.26GiB权重、0.5GiB KV;GB与GiB单位不同。KV公式适用于这里声明的注意力配置与未压缩缓存,GQA的KV头数可少于query头数;滑动窗口、缓存量化或引擎内存管理会改变实际分配。计算预算不是硬件实测。

Prefill处理输入上下文并建立KV,decode逐token使用缓存生成。长输入拉高首token延迟,长输出拉高总时长;更多并发可能提高总吞吐却使单请求等待更久。动态批处理把处于不同阶段的请求调度在一起,不能假设batch越大越好。

量化减少权重存储和带宽,可能改变质量;4bit、8bit、不同分组尺度不是简单同等压缩。用目标任务比较,特别检查数值、代码与长上下文。CPU/GPU offload、统一内存、显存带宽决定实际瓶颈,不能用参数数直接估算token/s。

分页KV和prefix cache解决缓存管理与重复前缀成本,缓存命中必须匹配模板、token序列、模型与必要的租户隔离。超长会话应压缩或截断可丢弃历史,不能任由KV耗尽。背压、最大并发和请求取消能防止单个客户端占满资源。

从真实 int8 张量追到线性输出误差 ​

前面的字节数只回答容量问题。量化还要确定哪些浮点数共享一个整数格点。设尺度 scale=s>0、零点 z=0,本实验使用 qint8 容器,量化为 q=clamp(round(w/s),-128,127),反量化为 w_hat=q*s。使用 max(abs(W))/127 覆盖正负最大绝对值,通常负侧 -128 这个额外格点不会用到;这与显式采用 [-127,127] 的对称算法略有区别,应写清容器与校准规则。

这里用真实 torch.quantize_per_tensor 产生量化张量,再查看 int_repr 与 dequantize,不是只把普通浮点列表重命名为“量化”。复用本书 CPU 深实验环境,或创建独立环境:

bash
python3.12 -m venv .venv-quant
source .venv-quant/bin/activate
python -m pip install torch==2.8.0
python quantization_lab.py

以下全部保存为 quantization_lab.py,无模型权重下载。CPU 输入 X 的形状为2×4,权重 W 为2×4,输出为2×2;比较时始终使用同一份 X、W 和 bias。权重量化是 W-only,对照激活量化时才改变 X 的表示。

python
import torch
import torch.nn.functional as F

torch.set_num_threads(1)
# 输入维4,输出维2;第二行权重尺度大,第一行包含小权重。
W=torch.tensor([[.02,-.03,.04,.05],[2.2,-3.1,4.2,-5.3]],dtype=torch.float32)
X=torch.tensor([[.5,-.25,.75,1.],[2.,-1.,.5,-.5]],dtype=torch.float32)
bias=torch.tensor([.1,-.2])

def quantize(t,scale):
    if not torch.isfinite(t).all() or not scale>0:raise ValueError('finite tensor and positive scale required')
    q=torch.quantize_per_tensor(t,float(scale),0,torch.qint8)
    # PyTorch qint8 的容器范围是 [-128,127],零点固定为0。
    expected=torch.clamp(torch.round(t/scale),-128,127).to(torch.int8)
    assert torch.equal(q.int_repr(),expected)
    return q

def error(reference,actual):return (reference-actual).abs().max().item()
def clipped(t,scale):
    codes=torch.round(t/scale)
    return int(((codes < -128)|(codes > 127)).sum())

scale_w=W.abs().max().item()/127
qw=quantize(W,scale_w)
restored=qw.dequantize()
y_float=F.linear(X,W,bias)
y_weight=F.linear(X,restored,bias)
assert clipped(W,scale_w)==0
assert error(W,restored)<=scale_w/2+1e-6
assert torch.allclose(restored,qw.int_repr().float()*scale_w)
print('scale_w',scale_w,'weight codes',qw.int_repr().tolist())
print('float output',y_float.tolist())
print('weight-quant output',y_weight.tolist(),'max output error',error(y_float,y_weight))
# 输出误差是 X * (W-W_hat)^T;偏置未量化,不进入这项差值。
bound=X.abs().sum(dim=1,keepdim=True)*(scale_w/2)
assert torch.all((y_float-y_weight).abs()<=bound+1e-6)

# 故意用不代表线上分布的校准样本:只覆盖 [-.1,.1]。
calibration=torch.tensor([-.1,0.,.1])
small_scale=calibration.abs().max().item()/127
bad_x=quantize(X,small_scale)
y_bad=F.linear(bad_x.dequantize(),restored,bias)
# 仅供诊断的 oracle 范围:使用当前测试批最大值,不能把它伪称独立校准验证。
oracle_scale=X.abs().max().item()/127
good_x=quantize(X,oracle_scale)
y_oracle=F.linear(good_x.dequantize(),restored,bias)
assert clipped(X,small_scale)>0 and clipped(X,oracle_scale)==0
assert error(y_float,y_bad)>error(y_float,y_oracle)
print('bad calibration clipped',clipped(X,small_scale),'error',error(y_float,y_bad))
print('oracle range error',error(y_float,y_oracle))
print('weight payload bytes',W.numel()*W.element_size(),'->',qw.numel()*qw.element_size())

本环境的 weight scale 约0.04173,小权重0.02被舍入为0;weight payload从32降到8字节。浮点第一行输出约[0.1975,-0.4750],只量化权重后约[0.18346,-0.46083],最大输出误差约0.02327。无裁剪时每个权重的舍入误差不超过s/2,但输出把四项误差按输入加权累加,因此不能直接要求输出误差也小于s/2;代码里的界来自三角不等式。

随后将校准范围故意缩到[-0.1,0.1],真实输入8个值全部超范围,最大输出误差约10.76。饱和后再反量化只能返回端点附近数值,不能恢复原来的2.0或-1.0;此时s/2舍入界已经不适用。诊断对照使用测试批最大绝对值重新选尺度,裁剪为0,最大误差约0.08107。这个 oracle 使用了测试数据,只能定位范围问题,不能充当经过独立验证的生产校准器。实际校准集应覆盖预期请求分布,冻结比例后在留出的任务集检查裁剪率与输出退化。

更大的范围也不是越大越好:s变大,零附近可分辨的差异变粗。第二行的大权重决定本例全张量尺度,牺牲了第一行小权重的精度。按输出通道量化为每行选择独立s,可以缓解跨行尺度差异,却不能消除同一行内的离群值;下一道练习比较这两种情况。

这里只核算权重有效载荷;scale、zero_point、张量对象、对齐、打包格式和运行时临时区另占空间。这个极小张量的元数据开销可能抵消节省。实验保留原浮点权重及反量化副本以作对照,实际进程内存不会因此降到四分之一;F.linear 在反量化后的 float32 上执行,也没有证明整数内核更快。它证明量化编码与误差回归,不等于整模型质量、GGUF表现、QLoRA训练或GPU吞吐验收。

接口与格点机制可对照 PyTorch 2.8 量化文档中的量化/反量化 API;实际运行采用固定2.8.0 CPU行为。

练习 ​

保持输入和偏置不变,将权重第二行最后一个值改为80。比较全张量尺度与按输出行的通道尺度:哪一行改善,哪一行仍受离群值影响?打印每个输出维度的最大误差,不仅报告全矩阵平均值。再用校准集[-0.5,0.25,0.5]量化新输入[-2,0.25,0,0.5],确认负侧裁剪及其输出误差,并说明为什么扩大范围和增加bit数是不同决策。

在原配置下把上下文翻倍、batch翻倍,计算KV倍率;模型权重为什么不会同样翻四倍?

完成后查看本章答案。

下一章:本地服务、兼容接口与维护。

用心记录每一步成长