让推理更便宜:KV cache、批处理与量化
同一模型同一问题,8.8 秒与 78.9 秒输出逐 token 相同;再用三种方式实测 INT4,而非空口断言。
本页内容
同一个模型,在同一台机器上,用同样的 48 个 token 回答同一个问题。两次输出逐 token 完全相同——这是检查过的,不是假设。
with a key-value cache: 8.85 s ( 6.01 tokens/second)
without a key-value cache: 78.95 s ( 0.60 tokens/second)只改了一个参数:use_cache=False。模型、prompt、采样和算术都没有任何不同,第二次运行也没有因为多花时间而更准确。它白白慢了九倍。
这就是本章的形状。这里的一切——cache、batch、量化后的权重——都是为了停止为不会改变答案的工作付费,或者弄清更便宜的答案到底要付出什么代价。第 10 章给出了训练的价目表。这里则是你会永远支付的另一边的价目表:一个部署后的模型在其余生里,每次请求、每生成一个 token,大约都要花掉 FLOPs。
第二次运行的时间去了哪里
链接到此部分:第二次运行的时间去了哪里为了生成一个 token,decoder-only transformer 会拿到目前为止的整个序列,让它穿过每一层,然后从最后一个位置读出概率分布。接着它追加被选中的 token,再做一遍。这个描述是正确的,也正是慢速运行所做的事。
它也极其浪费,原因来自第 9 章的因果 mask。位置 7 的 key 和 value 向量由位置 7 的输入以及它之前的位置计算而来。当位置 8 到来时,位置 7 看不到它——这就是“因果”的意思——所以位置 7 的 key 和 value 与之前是完全相同的数字。慢速运行却仍然在每一步重新计算它们。
所以,把它们存起来。这个存储就是 key-value cache,也是语言模型服务中最重要的优化:
out = model(prompt_ids, use_cache=True) # prefill: the whole prompt
past = out.past_key_values
nxt = out.logits[:, -1].argmax(-1, keepdim=True)
for _ in range(n - 1):
out = model(nxt, past_key_values=past, use_cache=True)
past = out.past_key_values
nxt = out.logits[:, -1].argmax(-1, keepdim=True)看循环里喂给模型的是什么:nxt,一个 token。不是整个序列。新 token 的 query 会对所有缓存的 key 做 attention,而那些缓存的 key 本来就不会改变。这不是近似——上面那个相同输出检查正是重点。cache 并不是用质量换速度;它是在删除冗余算术。
为了清楚地看出缩放规律,把 transformer 拿掉,只计时一个 attention head,,用两种方式计算生成的一步:
| context 中的 token | 全部重算 | 使用 cache | 比率 | score 矩阵 |
|---|---|---|---|---|
| 128 | 0.59 ms | 0.062 ms | 10x | 65,536 B vs 512 B |
| 256 | 1.20 ms | 0.163 ms | 7x | 262,144 B vs 1,024 B |
| 512 | 7.03 ms | 0.078 ms | 90x | 1,048,576 B vs 2,048 B |
| 1024 | 17.31 ms | 0.114 ms | 152x | 4,194,304 B vs 4,096 B |
| 2048 | 59.83 ms | 0.214 ms | 279x | 16,777,216 B vs 8,192 B |
| 4096 | 236.18 ms | 0.284 ms | 832x | 67,108,864 B vs 16,384 B |
最右列就是原因。重算会在每一步构建完整的 attention 矩阵——也就是第 9 章渐近记号框里的 ,每个 token 都要付一次。使用 cache 时,你只构建一行 :在 4,096 个 token 时,是 67 MB 的 score 对 16 KB。
用乘加次数代替毫秒,可以把机器因素从论证中移除。从冷启动生成 个 token:
| 生成的 token | 使用 cache | 重算 | 比率 |
|---|---|---|---|
| 128 | 2.6 M | 192.0 M | 73x |
| 512 | 23.1 M | 7.36 G | 318x |
| 2048 | 293.7 M | 392.6 G | 1,336x |
每一步中,cached 版本相对于 context 是线性的,未 cached 版本是二次的;把一次生成求和,就是 对 ,比率会无限增长。开头测到的九倍差异只覆盖了 48 个 token——还不到那张表的第一行。
cache 也改变了必须放在内存里的东西。在一块 8 GB 笔记本 GPU 上用 fp16 生成 256 个 token,取 allocator 峰值并减去常驻权重:
| 峰值工作内存 | |
|---|---|
| 使用 cache | 21.8 MB |
| 重算 | 181.7 MB |
多 8.3 倍内存,却只是为了更慢地产生相同的 token。这是第 5 章许下的承诺,从一个意想不到的方向抵达:在那里,反向模式自动微分必须保留每个中间值用于反向传播,激活值主导了训练内存。在推理时没有反向传播,也没有任何东西需要为它保留——所以主导内存的变成了 cache,而且它是一个有意的选择,而不是不可避免的成本。
Prefill 和 decode 是两台不同的机器
链接到此部分:Prefill 和 decode 是两台不同的机器再看一次快速运行:它的第一个 token 和另外四十七个不一样。
prefill, 40 prompt tokens : 1.0224 s -> 25.6 ms per token
decode, 47 steps : 0.1665 s mean per stepprompt 的成本是每 token 25.6 ms,而每个生成 token 的成本是 166 ms。同一模型、同一硬件、同一权重,每 token 相差六倍——而方向和大多数人的直觉相反。prompt 才是便宜的部分。生成分成两个阶段,它们确实有不同的物理属性:
Prefill
链接到此部分:Prefill对整个 prompt 做一次前向传播。每个 token 都并行处理,所以每个权重矩阵只从内存加载一次,并与数百个 token 向量组成的矩阵相乘——这是矩阵-矩阵乘法,每搬动一个字节都有大量算术,正是 GPU 擅长的事。Prefill 是 compute-bound,成本大致随 prompt 长度线性增长。
Decode
链接到此部分:Decode每个 token 做一次前向传播,batch 为一,序列长度为一。每个权重矩阵仍然要完整地从内存加载,并与单个向量相乘——这是矩阵-向量乘法,每搬动一个字节几乎没有多少算术。Decode 是 memory-bandwidth-bound,其每 token 成本几乎不取决于 context 的长度。
两半都可以测量。Prefill,对 个 token 做一次 pass:
| prompt token | 秒 | 每 token 毫秒 |
|---|---|---|
| 16 | 0.3515 | 21.97 |
| 32 | 0.5254 | 16.42 |
| 64 | 1.0491 | 16.39 |
| 128 | 1.6552 | 12.93 |
| 256 | 3.0965 | 12.10 |
Decode,用一个 token 对 的 cache 做计算:
| cached token | 一个 token 的毫秒数 |
|---|---|
| 16 | 110.05 |
| 64 | 97.57 |
| 256 | 108.53 |
| 1024 | 103.86 |
第二张表值得读两遍。从 16 个 context token 到 1,024 个——需要 attend 的历史多了六十四倍——一步的成本却没有可测量的变化。对 cache 做 attention 是真实工作,但它被一个固定成本淹没了:为了产生一个向量,必须把五亿个权重拖过内存总线。这个固定成本就是下一节所有内容的原因。
这两个阶段是每个服务系统都会报告的两个数字的源头。首 token 时间本质上就是 prefill,它会随 prompt 增长,这就是长对话启动很慢的原因。每秒 token 数是 ,而且大致恒定,所以回复随后会均匀流出。一个 chat 先启动得慢、再平滑流式输出,并不是渲染技巧。它就是这两张表。
cache 也是账单
链接到此部分:cache 也是账单cache 用内存换算术,而它要的内存并不小。context 中的每个 token、每一层,都会为每个 key-value head 保存一个 key 向量和一个 value 向量:
其中 2 来自 key 和 value;其余都是架构。对于本章一直测量的模型——24 层、14 个 query head、2 个 key-value head、head dimension 64——在 fp16 中就是每 token 字节。
这个领域的公式很容易差一个二倍因子,所以别盲信它,用 allocator 检查:
KV cache tensors per layer: (1, 2, 295, 64) float16
measured: 3,624,960 bytes for 295 tokens = 12,288 bytes/token
formula : 2 * 24 * 2 * 64 * 2 = 12,288 bytes/token完全吻合,而且在试过的每种形状上都保持精确:
| batch | context | 实测 cache | 预测值 | 峰值工作内存 |
|---|---|---|---|---|
| 1 | 512 | 6.0 MB | 6.0 MB | 15.4 MB |
| 1 | 16,384 | 192.0 MB | 192.0 MB | 207.3 MB |
| 1 | 65,536 | 768.0 MB | 768.0 MB | 793.7 MB |
| 8 | 4,096 | 384.0 MB | 384.0 MB | 401.5 MB |
| 32 | 2,048 | 768.0 MB | 768.0 MB | 794.2 MB |
| 64 | 1,024 | 768.0 MB | 768.0 MB | 797.0 MB |
| 128 | 512 | 768.0 MB | 768.0 MB | 816.4 MB |
最后三行值得再看一遍。32 个用户每人 2,048 个 token,64 个用户每人 1,024 个,128 个用户每人 512 个——cache 在三种情况下都是 768 MB,因为三者都持有 65,536 个 token。**cache 只取决于常驻 token 的总数,而不取决于它们在用户之间如何分布。**这个事实就是 batching 一节的基础。
MQA 和 GQA 从何而来
链接到此部分:MQA 和 GQA 从何而来第 9 章介绍了 multi-query attention 和 grouped-query attention,并把原因留到本章。原因就是那个公式,尤其是其中的 。
标准 multi-head attention 会给每个 query head 配自己的 key 和 value head。这里的模型有 14 个 query head;如果使用完整 multi-head attention,它的 cache 将是每 token 字节——84 KB 而不是 12 KB,正好多七倍,也就是 query head 与 key-value head 的比率。
Multi-query attention1 把这件事推到极限:所有 query head 共享一个 key-value head。Grouped-query attention2 则是胜出的折中方案——少量 key-value head,每个由一组 query head 共享——因为 MQA 的质量损失是真实的,而 GQA 的不是。它们都不节省算术。它们存在的意义,是把那个公式除以一个整数;一旦长 context 让 cache 成为硬约束,它们就立刻传遍了整个行业。
而这种约束来得很快。对于一个 7B 级模型,32 层、8 个维度为 128 的 key-value head,fp16 中 cache 是每 token 128 KB:
| context token | 一个用户 | 8 个用户 | 64 个用户 |
|---|---|---|---|
| 4,000 | 0.49 GB | 3.91 GB | 31.2 GB |
| 32,000 | 3.91 GB | 31.25 GB | 250.0 GB |
| 128,000 | 15.62 GB | 125.00 GB | 1,000.0 GB |
| 1,000,000 | 122.07 GB | 976.56 GB | 7,812.5 GB |
该模型自身权重在 fp16 中是 13.0 GB,也就是本章末尾表中的数字。所以在 128,000-token context 下,一个用户的 cache 就比模型本身还大。这就是第 16 章会转化为金钱的算术,也解释了为什么长对话不仅仅是慢——只要请求还活着,它就会占用一台机器的固定切片。
Batching:上升的数字和下降的数字
链接到此部分:Batching:上升的数字和下降的数字Decode 是 memory-bound:权重被拖过总线以产生一个 token,而算术单元闲着。所以,把更多工作放进同一步。一次运行多个请求,权重读取一次,就能服务所有请求。在同一模型上测量,每个请求持有 64-token cache 并解码一个 token:
| batch | 每步 latency | throughput | latency vs B=1 |
|---|---|---|---|
| 1 | 0.1286 s | 7.78 tok/s | 1.00x |
| 2 | 0.1839 s | 10.88 tok/s | 1.43x |
| 4 | 0.1909 s | 20.95 tok/s | 1.49x |
| 8 | 0.2781 s | 28.76 tok/s | 2.16x |
| 16 | 0.3430 s | 46.64 tok/s | 2.67x |
| 32 | 0.6302 s | 50.78 tok/s | 4.90x |
把右边两列对照着读,因为它们就是全部要点。从一个请求到十六个请求,throughput 乘以 6.0,任何单个请求的等待时间乘以 2.67。batch 让服务器更好,也让每个用户更差。
这不是可以调掉的 bug;它就是这个交易本身,而且两边各有名字。Latency 是等待回复的人所感受到的东西。Throughput 是账单要除以的东西。没有哪个设置能同时改善二者。
还要注意它在哪里停下。从 16 到 32,throughput 只增加 9%,而 latency 近乎翻倍:这一步已经不再是 memory-bound,而变成 compute-bound,越过这个拐点后,batch 就买不到什么了。每个 deployment 都有这样的拐点;它在你的系统上的位置必须测量,但它的存在不需要证明。
静态 batching 浪费了它赢来的大部分东西
链接到此部分:静态 batching 浪费了它赢来的大部分东西朴素的 batching 方法是收集 个请求,一起运行,并在全部完成时返回。但它们不会同时结束:有些回复是 20 个 token,有些是 500 个。固定 batch 会一直运行到其中最长的成员完成,而每个已经完成的请求在此之前仍会占着自己的 slot,贡献 padding。
取 64 个请求,输出长度有现实偏斜——中位数 18 个 token,最长 231 个,总计 1,874 个——并用 8 个 slot 的实测每步成本模拟两种策略:
| 策略 | wall clock | throughput | 每请求平均 latency | 浪费的 slot-step |
|---|---|---|---|---|
| 8 个一组的静态 batch | 176.9 s | 10.6 tok/s | 83.2 s | 3,214 |
| 连续式,8 个 slot | 109.0 s | 17.2 tok/s | 8.1 s | 0 |
Throughput 提升 1.6x。平均 latency 提升超过十倍,因为在静态 batching 下,一个四步就完成的请求仍要等一个 231-token 邻居完成,用户才会听到任何东西。
Continuous batching3 就是修复办法,而且它听起来有多简单,实际就有多简单:batch 不是一个固定组,而是一组 slot;某个 slot 一释放,下一个排队请求就在下一步进入。调度器以一个 token 而不是一个请求为粒度工作。现在所有生产中的 serving stack 都这么做。
它还有另一半,也就是 cache。来来去去的 slot 会让 cache 内存碎片化,而为每个 slot 预留其最大可能 context 会浪费大部分预留量。PagedAttention4 从操作系统借来答案:把 cache 存在固定大小的 block 中,并为每个序列维护一张 block table,这样一个序列的 cache 可以在物理上分散、逻辑上连续——这也让两个有共享前缀的序列可以共享保存该前缀的 block。这就是 vLLM 建立其上的东西,也解释了为什么一个 serving engine 本质上是一个附着了 transformer 的内存 allocator。
量化,以及第一个出错的地方
链接到此部分:量化,以及第一个出错的地方账单的另一半是权重本身。五亿个参数每个四字节是 1.98 GB;每个两字节是 0.99 GB;每个一字节是 0.49 GB。每个权重的 bit 更少,会缩小磁盘上的模型,缩小内存中的模型,并且——因为 decode 是 bandwidth-bound——让每一步更快,因为要搬动的字节更少。
最简单的方案是对称绝对最大值量化,三行就够:
qmax = 2 ** (bits - 1) - 1
scale = W.abs().max() / qmax
Wq = torch.round(W / scale).clamp(-qmax - 1, qmax)
W_hat = Wq * scale # dequantized选择一个 scale,让最大权重映射到最大整数;除以 scale,四舍五入,存下整数和 scale。重建时再乘回去。它一点也不聪明,但它能工作——直到不能为止。
在模型的真实权重上测量:全部 168 个投影矩阵,3.578 亿个参数,相对误差 :
| 方案 | 平均相对误差 | 最差矩阵 |
|---|---|---|
| INT8,整个矩阵一个 scale | 0.0400 | 0.1487 |
| INT8,每个输出行一个 scale | 0.0100 | 0.0149 |
| INT4,整个矩阵一个 scale | 0.6026 | 0.9931 |
| INT4,每个输出行一个 scale | 0.1790 | 0.2589 |
| INT4,每 128 个一组一个 scale | 0.1323 | 0.1992 |
| NF4,每 64 个 block 一个 scale | 0.0952 | 0.1205 |
| INT3,每 128 个一组一个 scale | 0.3044 | 0.4123 |
| INT2,每 128 个一组一个 scale | 0.7790 | 0.8076 |
第四行就是崩塌。最差矩阵相对误差 0.99,意味着重建结果几乎没有保留原矩阵的任何东西——矩阵被替换成了幅度差不多的噪声。原因可以在同一个实验的单个矩阵上看到:
model.layers.12.mlp.down_proj.weight (896 x 4864)
mean |w| 0.01386 std 0.01822 max |w| 0.43945 max/std 24.1
weights beyond 6 sigma: 692 of 4,358,144 (0.016 %)六千个权重里有一个超过六个标准差,最大的离均值 24 个标准差。整个矩阵共用一个 scale 时,就是这一个权重为全部 430 万个权重设定了步长。8 bit 有 256 个台阶,典型权重仍能落到有意义的位置。4 bit 只有 16 个,最外侧还被一个几乎没有谁拥有的值占着,而普通权重——也就是几乎所有权重——会被舍入到两三个不同等级。
那一行之后的所有方法,都是在不同粒度上做同一种修复:让 scale 管更小的地盘。按输出行划分把误差除以 3.4;按连续 128 个权重分组又再除一次。代价是记账——每 128 个一组配一个 16-bit scale,相当于每个权重 bit 而不是 4 bit——而它买回了大部分差距。
NF4 从另一侧入手。5 量化等级不必等间距。一个 block 内的权重大致服从正态分布,所以把 16 个等级选为正态分布的分位数:在权重真正聚集的零附近更密,在它们并不常出现的尾部更疏。同样四个 bit,同样 block scaling,但 block 更小——每权重 4.25 bit,对比 group-128 的 4.125——实测误差从 0.1323 降到 0.0952,低了 28%。其中一部分来自更细的 block,另一部分来自把等级放到质量所在的位置;要把两者分开,还需要第三行实验。
离群特征
链接到此部分:离群特征第 2 章的浮点数框以一个承诺结尾:本章会把权重量化到 8 bit 和 4 bit,并发现少数拒绝被压缩的离群特征。它们就在这里,也解释了为什么“把数字四舍五入就行”从来不可能适用于激活值。
上面的权重已经很不乖了。activations 则完全是另一个级别。取一个普通的 84-token prompt,捕获每层的 residual stream,并测量 896 个维度中每个维度达到的最大幅度:
| 层 | 最大 |h| | 中位数维度的最大 |h| | 比率 | 超过中位数 6x 的维度 |
|---|---|---|---|---|
| 1 | 6.19 | 0.339 | 18x | 2 |
| 4 | 1543.48 | 1.550 | 996x | 34 |
| 8 | 1571.63 | 1.498 | 1049x | 36 |
| 12 | 1575.03 | 1.546 | 1019x | 34 |
| 16 | 1579.60 | 1.617 | 977x | 32 |
| 20 | 1577.98 | 2.361 | 668x | 24 |
| 24 | 204.44 | 10.760 | 19x | 12 |
第 62 维达到 1,579.6,而中位数维度从未超过 1.6。这不是某个 token 或某一层的偶然:同一个维度在第 4 层出现,到第 20 层仍在那里,数值几乎相同。这些就是 outlier features,6 而且它们是系统性的——是训练后模型的属性,不是输入的属性。
第 16 层这 896 个逐维最大值的直方图让形状清晰无误:
0 - 1 | ######################################## 254
1 - 2 | ######################################## 283
2 - 4 | ######################################## 226
4 - 8 | ######################################## 93
8 - 16 | ################## 18
16 - 32 | ######### 9
32 - 64 | ####### 7
64 - 128 | ##### 5
128 - 256 | 0
256 - 512 | 0
512 - 1024 | 0
1024 - 4096 | # 1九百个维度整齐堆在 8 以下,中间整整三个 octave 什么都没有,然后一个维度独自站在最远端。现在把这个张量量化到 INT8,并数一数发生了什么:
| 方案 | 相对误差 | 整个张量使用的不同整数等级 |
|---|---|---|
| 整个张量一个 scale | 0.1083 | 256 个中的 14 个 |
| 每个 token(每行)一个 scale | 0.0433 | 158 |
| 整个张量,1 个离群维度保留 fp32 | 0.0442 | 48 |
| 整个张量,4 个离群维度保留 fp32 | 0.0279 | 57 |
| 整个张量,16 个离群维度保留 fp32 | 0.0085 | 102 |
256 个等级只用了 14 个。scale 由 1,579.6 决定,所以每一步宽 12.44,而典型 activation——中位幅度 0.26,99 分位数 2.51——根本无处落脚。按维度看更刺眼:
single tensor-wide scale = 12.4378
dim 826 (max |h| = 4.77): 1 distinct level out of 256
dim 336 (max |h| = 1.62): 1 distinct level out of 256
dim 96 (max |h| = 0.69): 1 distinct level out of 256
after excluding the top 4 dimensions, scale = 0.5749 (22x smaller)
dim 826: 8 levels dim 336: 4 levels dim 96: 3 levels一个等级。 整个维度、每个 token,都量化成同一个数字。分配了八个 bit,实际大约用了零个,而读取这些 activations 的模型拿到的是一个常数。
这个测量就是人们实际使用的每种技术的理由:
把离群值留在外面。 LLM.int8()6 分解矩阵乘法:幅度极端的维度用 16 bit 计算,其余部分用 INT8,最后把两半相加。上面的表就是收据——移除四个维度会把误差降低近四倍。SmoothQuant7 则迁移困难:用逐 channel 因子除以 activations,并把对应的权重列乘以它,这会让乘积不变,同时把 outlier 从无法吸收它的张量中移到可以吸收它的张量中。
选择舍入方式,不要只是舍入。 上面的内容都没有问矩阵是用来做什么的。GPTQ8 逐列量化,并在每列之后调整剩余的全精度列,以补偿已经产生的误差——它最小化的是该层在真实输入上的输出误差,而不是权重误差。AWQ9 发现少数权重 channel 比其余重要得多,利用 activation 统计找到它们,并在量化前把它们放大,让它们落到更细的等级上。二者都需要校准集;二者都不需要梯度。
查看详情
GGUF,以及文件格式和这一切有什么关系。
GGUF 不是量化方法;它是 llama.cpp 使用的容器,而 gguf vs gptq 对比中的混淆来自把二者当成同一种东西。GGUF 在一个可内存映射的文件里保存张量、tokenizer、架构元数据和 chat template,并在其中携带一族 block 方案——像 Q4_K_M 这样的名字编码了每权重 bit 数、block 大小,以及是否有些张量保持更高精度。
真正重要的工程差异是:GPTQ 和 AWQ 产出的是为 GPU kernel 优化的权重,而 GGUF 的方案是在 CPU 上以映射文件而非加载文件的方式廉价解码的。这就是为什么同样号称“4-bit 7B 模型”会以不同大小、不同质量同时存在于两个世界;也解释了为什么诚实的比较从来不是格式,而是下面这种在你自己的任务上运行的测量。
量化到底要付出什么代价:实测
链接到此部分:量化到底要付出什么代价:实测几乎每篇关于量化的文章都会停在上一节:解释方法,引用压缩率,然后声称质量“基本保留”。第 4 章讲的是不要欺骗自己,所以我们来弄清楚。
同一个模型,用每种方案原地量化权重,然后做三项测量:在 2,048 个 held-out 英文散文 token 上的 perplexity——这里用的是本课程草稿,这也是为什么仓库会替换成一本固定公版书,并打印同形状但数字不同的表——一组 16 个简短事实问题,在 greedy decoding 下有已知答案;以及在给定相同 context 时,量化模型与全精度模型选择相同 token 的比例。
| 方案 | 平均权重误差 | perplexity | 问题组 | 与 fp32 一致 |
|---|---|---|---|---|
| fp32(参考) | 0.0000 | 23.08 | 13/16 | 100.0 % |
| INT8 per tensor | 0.0400 | 23.58 | 13/16 | — |
| INT8 per row | 0.0100 | 22.96 | 13/16 | 98.6 % |
| INT4 per tensor | 0.6026 | 365,416,000 | 0/16 | — |
| INT4 per row | 0.1790 | 46.18 | 6/16 | 58.3 % |
| INT4 group 128 | 0.1323 | 31.08 | 10/16 | 71.5 % |
| NF4 block 64 | 0.0952 | 24.55 | 11/16 | 84.7 % |
| INT3 group 128 | 0.3044 | 213.09 | 0/16 | 5.6 % |
| INT2 group 128 | 0.7790 | 26,325,436 | 0/16 | 0.0 % |
这张表里有四件事值得直说。
正确做 INT8 是免费的。 Per-row INT8 得到 22.96,而参考值是 23.08——差距是两百分之一,属于噪声,应读作“相同”。噪声指向哪边并不稳定:在仓库的公版语料上,同样两个方案得到 22.24 对 22.18,距离只有一半,而且方向相反。它在 144 个生成 token 中有 142 个与全精度模型一致。相对于 fp32 参考,只需四分之一内存;相对于你实际会部署的 fp16,只需一半内存;且没有可检测成本。粗心做 INT8 也几乎免费:每个矩阵一个 scale 只花 0.5 perplexity 点,问题答案一个不少。八个 bit 足够宽容,所以粒度几乎不重要;这也正是人们从 INT8 推广到 INT4 然后受伤的原因。
每个 tensor 一个 scale 的 INT4 会摧毁模型。 Perplexity 3.65 亿:不是退化,而是被消灭。随后粒度就是全部游戏——per-tensor 365,416,000,per-row 46.18,per-group-of-128 31.08,NF4 24.55。同样每权重四个 bit,最差和最好之间相差一千五百万倍。
Perplexity 是粗糙工具,问题组更粗糙。 NF4 和 group-128 INT4 之间的 perplexity 差距是 6.5 点,问题组只差一个问题——而第 4 章的置信区间告诉我们,十六题差一题什么也区分不了。还有比区间更尖锐的演示:把模型默认的 repetition penalty 关掉,这才是 greedy decoding 的真实含义,然后运行同一组问题,这两行会交换位置。十六题差一题不是小效应,而是没有效应。第 8 章的警告也适用:perplexity 只能在共享 tokenizer 的模型之间比较,所以别人文章里的数字不能和你的数字比较。
**一致率这一列是三者中最锋利的,**而且几乎免费:先让全精度模型用 greedy 方式运行,然后在每个位置问量化模型,如果给定相同 prefix,它会选择什么。它有 144 个独立观察,而不是 16 个;不需要 ground truth;并且在问题组跳变式退化的地方,它会平滑退化。它也正是下一节需要的量。
这就是第 1 章对本章许下的承诺,准时抵达:数学说明 4-bit 模型是可能的,而工程决定它是否可用。
Speculative decoding
链接到此部分:Speculative decoding第 12 章宣布了它,并把账单留在这里。
这个想法直接来自 prefill/decode 的分裂。验证一个提议的 token 序列,需要对 个位置做一次前向传播——矩阵-矩阵乘法,几乎不比对一个位置做 pass 更贵。所以:
Draft
链接到此部分:Draft一个小而便宜的模型自回归地生成 个候选 token。
Verify
链接到此部分:Verify大模型对所有 个候选一次性做一次前向传播,产生它在每个位置本会说出的内容。
Accept
链接到此部分:Accept保留二者一致的最长 prefix,再加上大模型在第一个分歧处免费给出的 token。丢弃其余部分,然后重新开始。
输出分布不变。对于 greedy decoding,这很明显——只有目标模型本来也会生成的 token 才会被接受。对于采样,则需要修改后的接受规则,Leviathan 等人证明由此得到的分布完全等于目标分布。10 这是本章第二个精确优化。
因此一切都取决于接受率 ,它是可测的——就是上面的一致率列,所以才在那里计算它。把每个量化模型当作全精度目标的 draft,在 144 个生成位置上测量:
| draft model | acceptance | 最长接受 run | 每次目标 pass 的期望 token, |
|---|---|---|---|
| fp32(目标自身) | 100.0 % | 48 | 5.00 |
| INT8 per row | 98.6 % | 48 | 4.86 |
| NF4 block 64 | 84.7 % | 20 | 3.69 |
| INT4 group 128 | 71.5 % | 13 | 2.85 |
| INT4 per row | 58.3 % | 7 | 2.24 |
| INT3 group 128 | 5.6 % | 2 | 1.06 |
| INT2 group 128 | 0.0 % | 0 | 1.00 |
在 draft 长度 下,每次验证 pass 期望接受的 token 数是
而净加速还要除以 draft 自身成本,也就是目标每 token 成本的一个分数 :
| acceptance | , | , | , | , |
|---|---|---|---|---|
| 30 % | 1.19x | 1.02x | 0.79x | 0.79x |
| 50 % | 1.61x | 1.38x | 1.08x | 1.11x |
| 70 % | 2.31x | 1.98x | 1.54x | 1.78x |
| 90 % | 3.41x | 2.93x | 2.28x | 3.40x |
加粗那项值得记住:speculative decoding 可能让生成变慢。 在 30% 接受率、draft 成本为目标五分之一时,你支付了五次前向传播,却只保留 1.4 个 token。最后一列是另一个陷阱——更长的 draft 只有在接受率高时才有帮助,因为一个 -token 猜测的尾部几乎永远到不了。接受率 90% 时 值得 3.40x;接受率 30% 时只值 0.79x:同一配置是赢还是亏,取决于你流量上测到的一个数字。
蒸馏,以及 soft label 承载了什么
链接到此部分:蒸馏,以及 soft label 承载了什么量化通过用更少 bit 存储同一个函数来缩小模型。蒸馏则通过训练一个更小模型去模仿更大模型来缩小模型11——这个想法比深度学习早了将近十年。12
微妙之处在于学生到底从什么中学习。不是正确答案:它本可以直接在正确答案上训练。教师额外提供的是整个分布。问模型某个短语后面接什么,并越过 argmax 看一眼:
"She poured the milk into the"
' jug' 0.1355 ' cup' 0.1051 ' bowl' 0.0605 ' large' 0.0380 ' milk' 0.0360hard label 只说 jug,除此之外什么也不说。soft label 说 jug,也说 cup 几乎一样好,bowl 也合理,而 large——一个形容词,一种完全不同的语法延续——仍然活着。这就是最初的论点:这是一个 7,但它看起来相当像 1,而这种相似性是 hard label 丢掉的信息。
这也是蒸馏使用温度的原因。在 softmax 之前把 logits 除以 ,会把分布拉平,并提高次优项的相对权重:在这个短语上,top token 与第三名之间的比率从 时的 2.24 降到 时的 1.50——这是前者的平方根,也正是把 logits 除以二对比率所做的事。排序不变,但 loss 的 attention 更多落在接近命中的项上。学生的梯度携带的是教师的不确定性,而不只是它的判决。
8、16 和 24 GB 能装下什么
链接到此部分:8、16 和 24 GB 能装下什么本章的所有内容现在都变成一个求和:
其中 是所有并发请求中常驻的总 token 数。应用它:7B 和 70B 行假设 8 个维度为 128 的 key-value head;13B 行使用 40 个 head 的完整 multi-head attention,这是那几代模型的构建方式——差异立刻显现。
8 GB
| model | precision | weights | overhead 后剩余 | 可容纳的 context token |
|---|---|---|---|---|
| 7B | fp16 | 13.0 GB | 放不下 | — |
| 7B | int8 | 6.5 GB | 放不下 | — |
| 7B | int4 (g128) | 3.4 GB | 3.1 GB | 25,710 |
| 13B | int4 (g128) | 6.2 GB | 0.3 GB | 337 |
| 70B | int4 (g128) | 33.6 GB | 放不下 | — |
16 GB
| model | precision | weights | overhead 后剩余 | 可容纳的 context token |
|---|---|---|---|---|
| 7B | fp16 | 13.0 GB | 1.5 GB | 11,972 |
| 7B | int8 | 6.5 GB | 8.0 GB | 65,378 |
| 7B | int4 (g128) | 3.4 GB | 11.1 GB | 91,246 |
| 13B | int8 | 12.1 GB | 2.4 GB | 3,136 |
| 13B | int4 (g128) | 6.2 GB | 8.3 GB | 10,822 |
24 GB
| model | precision | weights | overhead 后剩余 | 可容纳的 context token |
|---|---|---|---|---|
| 7B | fp16 | 13.0 GB | 9.5 GB | 77,508 |
| 7B | int8 | 6.5 GB | 16.0 GB | 130,914 |
| 7B | int4 (g128) | 3.4 GB | 19.1 GB | 156,782 |
| 13B | int8 | 12.1 GB | 10.4 GB | 13,622 |
| 13B | int4 (g128) | 6.2 GB | 16.3 GB | 21,308 |
| 70B | int4 (g128) | 33.6 GB | 放不下 | — |
看 8 GB 表里的 13B 行。权重能放下——8 GB 里用了 6.2 GB——所以按通常说法,一个 13B 模型“能在 8 GB 卡上运行”。它有 337 个 token 的 context,这不是一次对话,几乎连 prompt 都勉强。“能不能放下”是错误问题。正确问题是“带多少 context、同时给多少用户”。
再看 16 GB 的两行 int8。7B 有 65,378 个 token,13B 有 3,136 个——仅仅多 5.6 GB 权重,就带来二十倍差异,因为这里的 13B 使用 multi-head attention,它的 cache 每 token 要 800 KB,而 7B 是 128 KB。两个大小相近的模型,一个无法用于长 context,原因却不会出现在任何 model card 的标题里。
接下来去哪里
链接到此部分:接下来去哪里十三章之前,这还只是一个带两个权重和一个 bias 的感知机。现在它已经是一个 transformer:被设计、被训练、被对齐,被教会在困难问题上花 compute,并以测得的每 token 成本被服务出来——里面已经没有任何盒子没打开。
到这里结束,而且是有意在这里结束。
第 14 章从模型身处别处开始。它不在你的进程里,不在你的内存里,也不在你能打印的变量里:它在一台你不管理的机器上,藏在 API key、端口和账单之后。这里测量的一切仍在发生——prefill 仍在第一个 token 之前运行,cache 仍随对话增长,你所在的 batch 仍属于别人,也仍决定你的 latency——但从现在开始,你会通过 Server-Sent Events 的流、一个 finish_reason,以及带有 Retry-After header 的 HTTP 429 来观察它。问题会随观察位置改变:不再是这个梯度如何计算,而是为什么我的账单翻了三倍。语言也会改变,而第 14 章会解释这条规则,而不是直接宣布它——到这里为止,代码持有的是权重、梯度、logits 和 tokenizer 字节;从那里开始,它持有的是连接、重试、取消和累积状态。跨过去之后,你身后的十三章并不会被丢弃。它们描述的是端口另一边正在运行的东西。
来源与方法
链接到此部分:来源与方法有两个省略是有意的。FlashAttention (Dao et al., arXiv:2205.14135) 不是另一种 attention——它通过 tiling 计算同一个函数,让 score 矩阵永远不被写入内存,所以本章第二张表里的 67 MB 在实践中会小于算术所暗示的数值。kernel 本身也交给别处:Stanford CS336 的第 10 讲以本文不试图达到的深度讲 inference systems,而 llama.cpp 仓库和 GGUF 规范是 CPU 侧的主要来源。
参考资料
链接到此部分:参考资料-
Shazeer, N. Fast Transformer Decoding: One Write-Head is All You Need. arXiv:1911.02150 (2019)。这篇论文很大程度上是一个 memory-bandwidth 论证,读起来也正是如此。 ↩
-
Ainslie, J. et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. arXiv:2305.13245 (2023)。包含把现有 multi-head checkpoint 转换过去的 uptraining 配方,这就是 GQA 传播如此之快的原因。 ↩
-
Yu, G.-I., Jeong, J. S., Kim, G.-W., Kim, S. and Chun, B.-G. Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI 2022。引入 iteration-level scheduling——continuous batching——以及 selective batching。 ↩
-
Kwon, W. et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023), SOSP 2023。vLLM 建立其上的论文;§3 完整展开了操作系统类比。 ↩
-
Dettmers, T., Pagnoni, A., Holtzman, A. and Zettlemoyer, L. QLoRA: Efficient Finetuning of Quantized LLMs. arXiv:2305.14314 (2023)。NF4 在 §3 中定义;上文测量使用的 16 个 level 值就是这篇论文推导出的值。 ↩
-
Dettmers, T., Lewis, M., Belkada, Y. and Zettlemoyer, L. LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. arXiv:2208.07339 (2022)。§4 中的 outlier-feature 分析是上文测量现象的来源,也包括离群值会在规模上系统性出现这一发现。 ↩ ↩2
-
Xiao, G., Lin, J., Seznec, M., Wu, H., Demouth, J. and Han, S. SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models. arXiv:2211.10438 (2022)。 ↩
-
Frantar, E., Ashkboos, S., Hoefler, T. and Alistarh, D. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. arXiv:2210.17323 (2022)。 ↩
-
Lin, J. et al. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration. arXiv:2306.00978 (2023)。 ↩
-
Leviathan, Y., Kalman, M. and Matias, Y. Fast Inference from Transformers via Speculative Decoding. arXiv:2211.17192 (2022)。定理 1 证明输出分布不变;Chen et al. (arXiv:2302.01318) 独立发表了同一个想法。 ↩
-
Hinton, G., Vinyals, O. and Dean, J. Distilling the Knowledge in a Neural Network. arXiv:1503.02531 (2015)。温度和“dark knowledge”论证。 ↩
-
Buciluă, C., Caruana, R. and Niculescu-Mizil, A. Model Compression. KDD 2006。早九年的蒸馏,面向的是 ensemble 而不是 transformers。 ↩