跳至内容
13/30第 13 章,共 30 章

让推理更便宜:KV cache、批处理与量化

同一模型同一问题,8.8 秒与 78.9 秒输出逐 token 相同;再用三种方式实测 INT4,而非空口断言。

本页内容

同一个模型,在同一台机器上,用同样的 48 个 token 回答同一个问题。两次输出逐 token 完全相同——这是检查过的,不是假设。

TEXT
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,大约都要花掉 2N2N FLOPs。

为了生成一个 token,decoder-only transformer 会拿到目前为止的整个序列,让它穿过每一层,然后从最后一个位置读出概率分布。接着它追加被选中的 token,再做一遍。这个描述是正确的,也正是慢速运行所做的事。

它也极其浪费,原因来自第 9 章的因果 mask。位置 7 的 key 和 value 向量由位置 7 的输入以及它之前的位置计算而来。当位置 8 到来时,位置 7 看不到它——这就是“因果”的意思——所以位置 7 的 key 和 value 与之前是完全相同的数字。慢速运行却仍然在每一步重新计算它们。

所以,把它们存起来。这个存储就是 key-value cache,也是语言模型服务中最重要的优化:

generate.pyPYTHON
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,d=64d = 64,用两种方式计算生成的一步:

context 中的 token全部重算使用 cache比率score 矩阵
1280.59 ms0.062 ms10x65,536 B vs 512 B
2561.20 ms0.163 ms7x262,144 B vs 1,024 B
5127.03 ms0.078 ms90x1,048,576 B vs 2,048 B
102417.31 ms0.114 ms152x4,194,304 B vs 4,096 B
204859.83 ms0.214 ms279x16,777,216 B vs 8,192 B
4096236.18 ms0.284 ms832x67,108,864 B vs 16,384 B

最右列就是原因。重算会在每一步构建完整的 n×nn \times n attention 矩阵——也就是第 9 章渐近记号框里的 O(n2)O(n^2),每个 token 都要付一次。使用 cache 时,你只构建一行 1×n1 \times n:在 4,096 个 token 时,是 67 MB 的 score 对 16 KB。

用乘加次数代替毫秒,可以把机器因素从论证中移除。从冷启动生成 TT 个 token:

生成的 token使用 cache重算比率
1282.6 M192.0 M73x
51223.1 M7.36 G318x
2048293.7 M392.6 G1,336x

每一步中,cached 版本相对于 context 是线性的,未 cached 版本是二次的;把一次生成求和,就是 O(T2)O(T^2)O(T3)O(T^3),比率会无限增长。开头测到的九倍差异只覆盖了 48 个 token——还不到那张表的第一行。

cache 也改变了必须放在内存里的东西。在一块 8 GB 笔记本 GPU 上用 fp16 生成 256 个 token,取 allocator 峰值并减去常驻权重:

峰值工作内存
使用 cache21.8 MB
重算181.7 MB

多 8.3 倍内存,却只是为了更慢地产生相同的 token。这是第 5 章许下的承诺,从一个意想不到的方向抵达:在那里,反向模式自动微分必须保留每个中间值用于反向传播,激活值主导了训练内存。在推理时没有反向传播,也没有任何东西需要为它保留——所以主导内存的变成了 cache,而且它是一个有意的选择,而不是不可避免的成本。

再看一次快速运行:它的第一个 token 和另外四十七个不一样。

TEXT
prefill, 40 prompt tokens : 1.0224 s   ->  25.6 ms per token
decode,  47 steps         : 0.1665 s mean per step

prompt 的成本是每 token 25.6 ms,而每个生成 token 的成本是 166 ms。同一模型、同一硬件、同一权重,每 token 相差六倍——而方向和大多数人的直觉相反。prompt 才是便宜的部分。生成分成两个阶段,它们确实有不同的物理属性:

对整个 prompt 做一次前向传播。每个 token 都并行处理,所以每个权重矩阵只从内存加载一次,并与数百个 token 向量组成的矩阵相乘——这是矩阵-矩阵乘法,每搬动一个字节都有大量算术,正是 GPU 擅长的事。Prefill 是 compute-bound,成本大致随 prompt 长度线性增长。

每个 token 做一次前向传播,batch 为一,序列长度为一。每个权重矩阵仍然要完整地从内存加载,并与单个向量相乘——这是矩阵-向量乘法,每搬动一个字节几乎没有多少算术。Decode 是 memory-bandwidth-bound,其每 token 成本几乎不取决于 context 的长度。

两半都可以测量。Prefill,对 PP 个 token 做一次 pass:

prompt token每 token 毫秒
160.351521.97
320.525416.42
641.049116.39
1281.655212.93
2563.096512.10

Decode,用一个 token 对 CC 的 cache 做计算:

cached token一个 token 的毫秒数
16110.05
6497.57
256108.53
1024103.86

第二张表值得读两遍。从 16 个 context token 到 1,024 个——需要 attend 的历史多了六十四倍——一步的成本却没有可测量的变化。对 cache 做 attention 是真实工作,但它被一个固定成本淹没了:为了产生一个向量,必须把五亿个权重拖过内存总线。这个固定成本就是下一节所有内容的原因。

这两个阶段是每个服务系统都会报告的两个数字的源头。首 token 时间本质上就是 prefill,它会随 prompt 增长,这就是长对话启动很慢的原因。每秒 token 数1/decode step1/\text{decode step},而且大致恒定,所以回复随后会均匀流出。一个 chat 先启动得慢、再平滑流式输出,并不是渲染技巧。它就是这两张表。

cache 用内存换算术,而它要的内存并不小。context 中的每个 token、每一层,都会为每个 key-value head 保存一个 key 向量和一个 value 向量:

bytes per token=2×L×Hkv×dhead×bytes per element\text{bytes per token} = 2 \times L \times H_{kv} \times d_{\text{head}} \times \text{bytes per element}

其中 2 来自 key 和 value;其余都是架构。对于本章一直测量的模型——24 层、14 个 query head、2 个 key-value head、head dimension 64——在 fp16 中就是每 token 2×24×2×64×2=12,2882 \times 24 \times 2 \times 64 \times 2 = 12{,}288 字节。

这个领域的公式很容易差一个二倍因子,所以别盲信它,用 allocator 检查:

TEXT
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

完全吻合,而且在试过的每种形状上都保持精确:

batchcontext实测 cache预测值峰值工作内存
15126.0 MB6.0 MB15.4 MB
116,384192.0 MB192.0 MB207.3 MB
165,536768.0 MB768.0 MB793.7 MB
84,096384.0 MB384.0 MB401.5 MB
322,048768.0 MB768.0 MB794.2 MB
641,024768.0 MB768.0 MB797.0 MB
128512768.0 MB768.0 MB816.4 MB

最后三行值得再看一遍。32 个用户每人 2,048 个 token,64 个用户每人 1,024 个,128 个用户每人 512 个——cache 在三种情况下都是 768 MB,因为三者都持有 65,536 个 token。**cache 只取决于常驻 token 的总数,而不取决于它们在用户之间如何分布。**这个事实就是 batching 一节的基础。

第 9 章介绍了 multi-query attention 和 grouped-query attention,并把原因留到本章。原因就是那个公式,尤其是其中的 HkvH_{kv}

标准 multi-head attention 会给每个 query head 配自己的 key 和 value head。这里的模型有 14 个 query head;如果使用完整 multi-head attention,它的 cache 将是每 token 2×24×14×64×2=86,0162 \times 24 \times 14 \times 64 \times 2 = 86{,}016 字节——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,0000.49 GB3.91 GB31.2 GB
32,0003.91 GB31.25 GB250.0 GB
128,00015.62 GB125.00 GB1,000.0 GB
1,000,000122.07 GB976.56 GB7,812.5 GB

该模型自身权重在 fp16 中是 13.0 GB,也就是本章末尾表中的数字。所以在 128,000-token context 下,一个用户的 cache 就比模型本身还大。这就是第 16 章会转化为金钱的算术,也解释了为什么长对话不仅仅是慢——只要请求还活着,它就会占用一台机器的固定切片。

Batching:上升的数字和下降的数字

链接到此部分:Batching:上升的数字和下降的数字

Decode 是 memory-bound:权重被拖过总线以产生一个 token,而算术单元闲着。所以,把更多工作放进同一步。一次运行多个请求,权重读取一次,就能服务所有请求。在同一模型上测量,每个请求持有 64-token cache 并解码一个 token:

batch每步 latencythroughputlatency vs B=1
10.1286 s7.78 tok/s1.00x
20.1839 s10.88 tok/s1.43x
40.1909 s20.95 tok/s1.49x
80.2781 s28.76 tok/s2.16x
160.3430 s46.64 tok/s2.67x
320.6302 s50.78 tok/s4.90x

把右边两列对照着读,因为它们就是全部要点。从一个请求到十六个请求,throughput 乘以 6.0,任何单个请求的等待时间乘以 2.67。batch 让服务器更好,也让每个用户更差。

这不是可以调掉的 bug;它就是这个交易本身,而且两边各有名字。Latency 是等待回复的人所感受到的东西。Throughput 是账单要除以的东西。没有哪个设置能同时改善二者。

还要注意它在哪里停下。从 16 到 32,throughput 只增加 9%,而 latency 近乎翻倍:这一步已经不再是 memory-bound,而变成 compute-bound,越过这个拐点后,batch 就买不到什么了。每个 deployment 都有这样的拐点;它在你的系统上的位置必须测量,但它的存在不需要证明。

静态 batching 浪费了它赢来的大部分东西

链接到此部分:静态 batching 浪费了它赢来的大部分东西

朴素的 batching 方法是收集 BB 个请求,一起运行,并在全部完成时返回。但它们不会同时结束:有些回复是 20 个 token,有些是 500 个。固定 batch 会一直运行到其中最长的成员完成,而每个已经完成的请求在此之前仍会占着自己的 slot,贡献 padding。

取 64 个请求,输出长度有现实偏斜——中位数 18 个 token,最长 231 个,总计 1,874 个——并用 8 个 slot 的实测每步成本模拟两种策略:

策略wall clockthroughput每请求平均 latency浪费的 slot-step
8 个一组的静态 batch176.9 s10.6 tok/s83.2 s3,214
连续式,8 个 slot109.0 s17.2 tok/s8.1 s0

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——让每一步更快,因为要搬动的字节更少。

最简单的方案是对称绝对最大值量化,三行就够:

quantize.pyPYTHON
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 亿个参数,相对误差 WW^/W\lVert W - \hat{W}\rVert / \lVert W \rVert

方案平均相对误差最差矩阵
INT8,整个矩阵一个 scale0.04000.1487
INT8,每个输出行一个 scale0.01000.0149
INT4,整个矩阵一个 scale0.60260.9931
INT4,每个输出行一个 scale0.17900.2589
INT4,每 128 个一组一个 scale0.13230.1992
NF4,每 64 个 block 一个 scale0.09520.1205
INT3,每 128 个一组一个 scale0.30440.4123
INT2,每 128 个一组一个 scale0.77900.8076

第四行就是崩塌。最差矩阵相对误差 0.99,意味着重建结果几乎没有保留原矩阵的任何东西——矩阵被替换成了幅度差不多的噪声。原因可以在同一个实验的单个矩阵上看到:

TEXT
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,相当于每个权重 4+16/128=4.1254 + 16/128 = 4.125 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 的维度
16.190.33918x2
41543.481.550996x34
81571.631.4981049x36
121575.031.5461019x34
161579.601.617977x32
201577.982.361668x24
24204.4410.76019x12

第 62 维达到 1,579.6,而中位数维度从未超过 1.6。这不是某个 token 或某一层的偶然:同一个维度在第 4 层出现,到第 20 层仍在那里,数值几乎相同。这些就是 outlier features6 而且它们是系统性的——是训练后模型的属性,不是输入的属性。

第 16 层这 896 个逐维最大值的直方图让形状清晰无误:

TEXT
     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,并数一数发生了什么:

方案相对误差整个张量使用的不同整数等级
整个张量一个 scale0.1083256 个中的 14 个
每个 token(每行)一个 scale0.0433158
整个张量,1 个离群维度保留 fp320.044248
整个张量,4 个离群维度保留 fp320.027957
整个张量,16 个离群维度保留 fp320.0085102

256 个等级只用了 14 个。scale 由 1,579.6 决定,所以每一步宽 12.44,而典型 activation——中位幅度 0.26,99 分位数 2.51——根本无处落脚。按维度看更刺眼:

TEXT
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.000023.0813/16100.0 %
INT8 per tensor0.040023.5813/16
INT8 per row0.010022.9613/1698.6 %
INT4 per tensor0.6026365,416,0000/16
INT4 per row0.179046.186/1658.3 %
INT4 group 1280.132331.0810/1671.5 %
NF4 block 640.095224.5511/1684.7 %
INT3 group 1280.3044213.090/165.6 %
INT2 group 1280.779026,325,4360/160.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 模型是可能的,而工程决定它是否可用。

第 12 章宣布了它,并把账单留在这里。

这个想法直接来自 prefill/decode 的分裂。验证一个提议的 γ\gamma token 序列,需要对 γ\gamma 个位置做一次前向传播——矩阵-矩阵乘法,几乎不比对一个位置做 pass 更贵。所以:

一个小而便宜的模型自回归地生成 γ\gamma 个候选 token。

大模型对所有 γ\gamma 个候选一次性做一次前向传播,产生它在每个位置本会说出的内容。

保留二者一致的最长 prefix,再加上大模型在第一个分歧处免费给出的 token。丢弃其余部分,然后重新开始。

输出分布不变。对于 greedy decoding,这很明显——只有目标模型本来也会生成的 token 才会被接受。对于采样,则需要修改后的接受规则,Leviathan 等人证明由此得到的分布完全等于目标分布。10 这是本章第二个精确优化。

因此一切都取决于接受率 α\alpha,它是可测的——就是上面的一致率列,所以才在那里计算它。把每个量化模型当作全精度目标的 draft,在 144 个生成位置上测量:

draft modelacceptance最长接受 run每次目标 pass 的期望 token,γ=4\gamma = 4
fp32(目标自身)100.0 %485.00
INT8 per row98.6 %484.86
NF4 block 6484.7 %203.69
INT4 group 12871.5 %132.85
INT4 per row58.3 %72.24
INT3 group 1285.6 %21.06
INT2 group 1280.0 %01.00

在 draft 长度 γ\gamma 下,每次验证 pass 期望接受的 token 数是

E[tokens]=1αγ+11α\mathbb{E}[\text{tokens}] = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}

而净加速还要除以 draft 自身成本,也就是目标每 token 成本的一个分数 cc

acceptancec=0.05c=0.05, γ=4\gamma=4c=0.1c=0.1, γ=4\gamma=4c=0.2c=0.2, γ=4\gamma=4c=0.1c=0.1, γ=8\gamma=8
30 %1.19x1.02x0.79x0.79x
50 %1.61x1.38x1.08x1.11x
70 %2.31x1.98x1.54x1.78x
90 %3.41x2.93x2.28x3.40x

加粗那项值得记住:speculative decoding 可能让生成变慢。 在 30% 接受率、draft 成本为目标五分之一时,你支付了五次前向传播,却只保留 1.4 个 token。最后一列是另一个陷阱——更长的 draft 只有在接受率高时才有帮助,因为一个 γ\gamma-token 猜测的尾部几乎永远到不了。接受率 90% 时 γ=8\gamma = 8 值得 3.40x;接受率 30% 时只值 0.79x:同一配置是赢还是亏,取决于你流量上测到的一个数字。

量化通过用更少 bit 存储同一个函数来缩小模型。蒸馏则通过训练一个更小模型去模仿更大模型来缩小模型11——这个想法比深度学习早了将近十年。12

微妙之处在于学生到底从什么中学习。不是正确答案:它本可以直接在正确答案上训练。教师额外提供的是整个分布。问模型某个短语后面接什么,并越过 argmax 看一眼:

TEXT
"She poured the milk into the"
  ' jug' 0.1355   ' cup' 0.1051   ' bowl' 0.0605   ' large' 0.0380   ' milk' 0.0360

hard label 只说 jug,除此之外什么也不说。soft label 说 jug,也说 cup 几乎一样好,bowl 也合理,而 large——一个形容词,一种完全不同的语法延续——仍然活着。这就是最初的论点:这是一个 7,但它看起来相当像 1,而这种相似性是 hard label 丢掉的信息。

这也是蒸馏使用温度的原因。在 softmax 之前把 logits 除以 TT,会把分布拉平,并提高次优项的相对权重:在这个短语上,top token 与第三名之间的比率从 T=1T = 1 时的 2.24 降到 T=2T = 2 时的 1.50——这是前者的平方根,也正是把 logits 除以二对比率所做的事。排序不变,但 loss 的 attention 更多落在接近命中的项上。学生的梯度携带的是教师的不确定性,而不只是它的判决。

本章的所有内容现在都变成一个求和:

memory=N×bytes per weightfixed+T×2LHkvdhead×bytesgrows with every token+runtime overheadcall it 1.5 GB\text{memory} = \underbrace{N \times \text{bytes per weight}}_{\text{fixed}} + \underbrace{T \times 2 L H_{kv} d_{\text{head}} \times \text{bytes}}_{\text{grows with every token}} + \underbrace{\text{runtime overhead}}_{\text{call it 1.5 GB}}

其中 TT 是所有并发请求中常驻的 token 数。应用它:7B 和 70B 行假设 8 个维度为 128 的 key-value head;13B 行使用 40 个 head 的完整 multi-head attention,这是那几代模型的构建方式——差异立刻显现。

8 GB

modelprecisionweightsoverhead 后剩余可容纳的 context token
7Bfp1613.0 GB放不下
7Bint86.5 GB放不下
7Bint4 (g128)3.4 GB3.1 GB25,710
13Bint4 (g128)6.2 GB0.3 GB337
70Bint4 (g128)33.6 GB放不下

16 GB

modelprecisionweightsoverhead 后剩余可容纳的 context token
7Bfp1613.0 GB1.5 GB11,972
7Bint86.5 GB8.0 GB65,378
7Bint4 (g128)3.4 GB11.1 GB91,246
13Bint812.1 GB2.4 GB3,136
13Bint4 (g128)6.2 GB8.3 GB10,822

24 GB

modelprecisionweightsoverhead 后剩余可容纳的 context token
7Bfp1613.0 GB9.5 GB77,508
7Bint86.5 GB16.0 GB130,914
7Bint4 (g128)3.4 GB19.1 GB156,782
13Bint812.1 GB10.4 GB13,622
13Bint4 (g128)6.2 GB16.3 GB21,308
70Bint4 (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 计算同一个函数,让 n×nn \times n score 矩阵永远不被写入内存,所以本章第二张表里的 67 MB 在实践中会小于算术所暗示的数值。kernel 本身也交给别处:Stanford CS336 的第 10 讲以本文不试图达到的深度讲 inference systems,而 llama.cpp 仓库和 GGUF 规范是 CPU 侧的主要来源。

  1. Shazeer, N. Fast Transformer Decoding: One Write-Head is All You Need. arXiv:1911.02150 (2019)。这篇论文很大程度上是一个 memory-bandwidth 论证,读起来也正是如此。

  2. Ainslie, J. et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. arXiv:2305.13245 (2023)。包含把现有 multi-head checkpoint 转换过去的 uptraining 配方,这就是 GQA 传播如此之快的原因。

  3. 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。

  4. Kwon, W. et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023), SOSP 2023。vLLM 建立其上的论文;§3 完整展开了操作系统类比。

  5. Dettmers, T., Pagnoni, A., Holtzman, A. and Zettlemoyer, L. QLoRA: Efficient Finetuning of Quantized LLMs. arXiv:2305.14314 (2023)。NF4 在 §3 中定义;上文测量使用的 16 个 level 值就是这篇论文推导出的值。

  6. 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

  7. 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)。

  8. Frantar, E., Ashkboos, S., Hoefler, T. and Alistarh, D. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. arXiv:2210.17323 (2022)。

  9. Lin, J. et al. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration. arXiv:2306.00978 (2023)。

  10. 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) 独立发表了同一个想法。

  11. Hinton, G., Vinyals, O. and Dean, J. Distilling the Knowledge in a Neural Network. arXiv:1503.02531 (2015)。温度和“dark knowledge”论证。

  12. Buciluă, C., Caruana, R. and Niculescu-Mizil, A. Model Compression. KDD 2006。早九年的蒸馏,面向的是 ensemble 而不是 transformers。

准备好让 LIA 替你选模型了吗?

所有 AI 模型都在一处——今天就免费开始。