Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

大模型推理过程与加速

你向工坊助手发送了一段长问题。页面先安静了一会儿,随后文字一小段一小段地出现。为什么第一段等得久,后面却能接着往外冒?如果模型每次只生成一个词元,它会不会反复读同一段话?

在第二十一章,我们用最直白的循环生成文本:把整个已有序列交给模型,选出下一个词元,追加回去,再来一次。这个办法容易理解,也确实能工作;但旧前缀里的许多计算,被一遍又一遍地重做了。

这一章给已有的小 Transformer 装上 KV 缓存,再测量它究竟省下了什么。随后看看批处理、注意力内核和推测解码,各自在解决哪一类等待。

1. 第一个词元之前,模型在忙什么

收到提示以后,模型先处理已经给出的全部词元。这个阶段常叫 Prefill,可以理解为“把提示读进去,并准备好后面要用的状态”。

假设提示有 128 个词元。虽然注意力使用因果掩码,每个位置只能看自己和过去,但这些输入已经全部知道,因此一层里的许多位置可以一起计算。最后一个位置的输出分数,给出第一个新词元的概率分布。

选出第一个新词元后,模型才能用它去预测第二个;第二个选出来以后,才能继续预测第三个。这段逐步生成的过程叫 Decode。

阶段当前已知的输入主要工作
Prefill完整提示处理提示中的多个位置,得到第一步预测与缓存
Decode刚生成的新词元及已有缓存处理新位置,预测下一个词元

一个词元不一定等于一个汉字,也不一定能独立解码成完整字符。这里先在词元 ID 层面计时;页面怎样把它们组成流式文本,留到服务一章处理。

长提示通常会增加 Prefill 的工作量。Decode 则要反复执行模型,读取权重与已有状态。根据批次、上下文长度和硬件不同,瓶颈可能变化,不能只凭“这叫 Decode”就断言它一定受某一种带宽限制。

2. KV 缓存:旧前缀不变,就不必重新抄一遍

回忆注意力的三个角色:新位置用查询 QQ 去寻找相关内容,用键 KK 比较匹配程度,再按权重汇总值 VV。

在因果解码器中,旧位置不能看到未来。只要模型参数、旧词元和位置编号都不变,追加新词元不会改变旧位置的表示;每一层旧位置的 K,VK,V 因而也可以继续使用。KV Cache 就是把各层这些张量保留下来。

新的一步只计算新位置的 Q,K,VQ,K,V,把新 K,VK,V 接到缓存后面,再让新 QQ 与所有允许访问的键交互。旧位置的 QQ 不需要为这次预测重算,也不需要作为 KV 缓存的一部分保存。

这不等于“不再读历史”。新词元仍然要对历史的键和值做注意力计算,只是不用再次构造旧位置的整套表示。

缓存里装了多少数

假设有 LL 层、批次大小为 BB、已缓存 TT 个位置,每层有 HKVH_{\text{KV}} 组键和值,每个头的宽度为 dhd_h,每个数占 ss 字节。那么,键和值的张量大小合计为:

MKV=2LBTHKVdhs M_{\text{KV}}=2LBTH_{\text{KV}}d_hs

前面的 2 对应 K 和 V 两份。这里假设批次中每条序列都按相同长度存储,没有额外的分配余量。

普通多头注意力中,HKVdhH_{\text{KV}}d_h 就是模型宽度。本章沿用的实现属于这种情况。另一类设计让多个查询头共享较少的 KV 组,比如 GQA,算缓存时就不能把查询头数直接代入 HKVH_{\text{KV}}。

缓存会随序列长度和并行请求数增长。它省下重复计算,却增加了持续保留的状态;也不包含权重、临时注意力张量和框架开销。长对话很多时,“模型本身装得下”仍然不够。

3. 给小 Transformer 接上缓存,最容易错在哪里

本章复用第二十章的实现,保留同样的预归一化、因果注意力、前馈层和可学习位置嵌入,不重新训练模型。

输入的新块有 cc 个位置,旧缓存有 tt 个位置。每层中:

  • 新查询的形状为 (B, H, c, d_h)。
  • 拼接后的键和值为 (B, H, t + c, d_h)。
  • 注意力分数为 (B, H, c, t + c)。

最后两个轴不再总是正方形。这会带来一个很容易藏住的错误。

新位置的编号,不能从零重新开始

若已经缓存了位置 0~3,下一块的两个位置就应编号为 4、5。它们使用位置表中的第 4、5 项,也分别只能看到不晚于自己的键:

允许访问  ⟺  j≤t+i \text{允许访问}\iff j\leq t+i

ii 是新块内从 0 开始的查询下标,jj 是键的绝对位置。本例 t=4t=4,所以第一行可看位置 0~4,第二行可看位置 0~5。

图 1:有了缓存,因果关系仍按序列中的绝对位置判断。下图橙色竖线左边是旧缓存,右边是本次新增的键和值。

不要直接对这个长方形画一条从左上角开始的下三角线。极端一点,若只来了一个新查询,而缓存加新位置共有四个键,这个查询应该能看到全部四个键;左上对齐的下三角掩码却只会留下第一个。

在当前 PyTorch 的 scaled_dot_product_attention(SDPA)接口中,非方形张量的 is_causal=True 使用左上对齐的因果偏置。因此本例显式传入绝对位置构造的布尔掩码,设 is_causal=False;其中 True 表示允许参与注意力。推理时还显式设置 dropout_p=0.0。官方接口说明给出了这些约定。

可以用一个不依赖训练结果的例子检查:让四个键得到相同分数,值分别为 1、3、5、7。允许访问全部位置,输出应为平均值 4;只留下第一个位置,输出就变成 1。程序能正常运行,并不能替代这一步语义检查。

缓存属于某个模型和某段前缀

本例缓存对象保存模型引用、每层 K/V 和长度,并检查模型身份、形状、设备、精度和上下文上限。每次调用返回新缓存,不原地改写传入的张量。

不过,程序无法仅凭这些字段证明“你传来的确实是原前缀的下一个词元”,也不能自动发现同一模型对象的权重被你悄悄改过。调用者仍要保证参数和前缀一致。把一个用户的缓存直接交给另一个用户,既可能算错,也可能破坏请求隔离。

本例使用 eval 模式和 inference_mode,没有训练时的随机失活。它不实现滑动窗口;超过位置表长度就报错。直接扔掉一段缓存、把位置从零重排,并不等价于原模型继续生成。

4. 先核对结果,再数省了多少计算

给模型 8 个提示词元,继续生成 12 个新词元。没有缓存时,每轮输入长度依次为:

8,9,10,…,19 8,9,10,\ldots,19

因此每层一共要处理 8+9+⋯+19=1628+9+\cdots+19=162 个投影位置。

使用缓存后,首次处理 8 个位置,后面 11 次各处理 1 个,总共只有 19 个。最后生成的第 12 个新词元已经是最终输出,没有再送回模型,因此最终输出序列长 20,缓存长度却是 19。这个差一,常常会让第一次统计的人疑惑。

图 2:固定提示长 8、生成长 12 的运算规模计数,不是运行时间。分数元素数按单条序列、每层、每个注意力头计算,包含随后被掩码遮住的格子。

无缓存的密集分数矩阵元素数为:

82+92+⋯+192=2330 8^2+9^2+\cdots+19^2=2330

缓存路径则为:

82+(9+10+⋯+19)=218 8^2+(9+10+\cdots+19)=218

这些数还不是 FLOPs:每个分数要做一个头宽度上的点积,后面还有与 V 相乘、前馈层等操作。若算所有头、所有层、整个批次,还要乘对应数量。把“分数矩阵格子数”叫成“总计算量”,会把不同概念混在一起。

本例使用两层、宽度 32 的模型,批次为 1,双精度每个数 8 字节。最终 KV 张量的实际大小为:

2×2×1×19×32×8=19456 byte 2\times2\times1\times19\times32\times8 =19456\ \text{byte}

我们同时核对完整重算、先算 8 个位置再算后 3 个位置,以及 SDPA 路径的分数;生成的 12 个词元 ID 也一致。这里允许浮点舍入范围内的微小分数差异,不声称不同计算内核必然逐位相同。

5. 很多人一起问时,速度怎样算

只看一个请求,每步只处理一个新位置,模型的权重可能反复被读取,却没有太多输入来分摊这次读取。把多个请求组成批次,常能增加权重复用,让设备忙得更充分。

但“每秒处理更多词元”和“每个人更早收到回答”不是同一件事。

静态批处理可以等一组请求凑齐后一起运行。实现简单,但为了凑批而等待会增加延迟;长短回答混在一起,已经结束的位置还可能闲置。连续批处理则在生成步骤之间移走已结束的请求,让新的请求进入,尽量减少空位。

连续批处理也需要管理每个请求自己的长度、缓存、结束条件和输出顺序。新请求的长 Prefill 可能拖慢正在 Decode 的请求;分块 Prefill 可以把长提示拆成更小的调度单元,但分多大、怎样与 Decode 交错,仍要按实际负载测量。

至少分别看这些量:

指标本章的解释
首词元延迟,TTFT从计时起点到第一个新词元产生
生成总时长从计时起点到最后一个新词元产生
后续生成速度第一个词元之后,平均每秒产生多少新词元
系统吞吐量在一段墙钟时间内,所有请求合计处理多少工作

若共生成 N>1N>1 个词元,第一和最后一个到达时间分别为 t1,tNt_1,t_N,本章按下面的方式计算后续速度:

vdecode=N−1tN−t1 v_{\text{decode}}=\frac{N-1}{t_N-t_1}

只有一个词元时,这个量没有定义。还应观察逐词元间隔和请求延迟的分布;很高的平均吞吐量,可能掩盖少数用户等得特别久的情况。

从客户端发送请求开始测得的 TTFT,可以包含排队、网络、分词和计算。下面的本地实验从生成循环开始计时,输入 ID 已经准备好,模型也已经加载;不能把它直接当成在线服务的端到端延迟。

6. 注意力加速,不只靠缓存

少把大矩阵搬来搬去

第十九章的直观实现先构造整个注意力分数矩阵,再计算 softmax,随后与 V 相乘。长序列下,中间矩阵的读写本身就很可观。

FlashAttention通过分块和重新组织计算,减少在较慢内存与片上存储之间搬运中间结果。它计算的是精确注意力的数学形式,不是靠随意丢掉一些注意力项来近似;浮点运算顺序变化仍可能造成数值差异。标准密集注意力的二次方配对工作也没有因此消失。

SDPA 是一个统一接口,具体设备、形状和精度会影响它选择哪种实现。我们在 CPU 上核对 SDPA 与手写注意力的一致性,没有把这个实验称作 GPU FlashAttention 跑分。

别让缓存空间被零零碎碎地浪费掉

还有一种浪费发生在分配空间时:不知道回答会多长,给每个请求预留很大一块,可能空着;每次增长都重新分配并复制,也可能昂贵。

PagedAttention把 KV 存储分成块,通过映射组织请求的逻辑序列,从而改善空间管理;一些场景还能共享相同前缀对应的块。这解决的是缓存分配与访问问题,与 FlashAttention 减少注意力中间读写的重点不同。

本章实现使用 torch.cat 拼接缓存,便于看清数据关系,每步仍会复制已有 KV。它没有预分配或分页管理。真实服务怎样设置缓存容量和请求上限,要在这套原理之外继续做工程工作。

7. 推测解码:先拟草稿,再由目标模型核对

自回归生成的困难在于:不知道前一个词元,就没法知道下一个位置真正的上下文。一个较便宜的草稿模型可以先猜几个词元;目标模型再把这段已知草稿一起评估,按规则接纳前面连续通过的部分,处理第一次被拒绝的位置。这样有机会用一次目标模型执行换来多个输出词元。

对于随机采样,不能简单地“看起来差不多就接受”。Leviathan 等人的推测解码论文给出了一种保持目标分布的校正方法。下面只看一个位置,把这一步算清楚。

用 p(x)p(x) 表示目标模型希望采样的分布,q(x)q(x) 表示草稿分布。先从 qq 抽一个候选 xx,以如下概率接受:

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

这个候选由 qq 抽到,因此只需在 q(x)>0q(x)>0 的位置计算比值。如果拒绝,则从下面的残差分布重新抽取:

r(x)=max⁡(p(x)−q(x),0)∑zmax⁡(p(z)−q(z),0) r(x)= \frac{\max(p(x)-q(x),0)} {\sum_z\max(p(z)-q(z),0)}

分母为零意味着两个分布相同,不会发生拒绝,也就不需要残差采样。温度或其他采样变换若已改变分布,校正也必须针对实际使用的分布来做。

试一组只有三个候选的数:

p=[0.6,0.3,0.1],q=[0.2,0.5,0.3] p=[0.6,0.3,0.1],\qquad q=[0.2,0.5,0.3]

草稿把 B、C 猜得太多,把 A 猜得太少。直接接受各候选贡献的概率质量是 min⁡(p,q)=[0.2,0.3,0.1]\min(p,q)=[0.2,0.3,0.1],总和为 0.6;剩下 0.4 的拒绝概率,经残差分布全部补给 A。最终正好回到 [0.6,0.3,0.1][0.6,0.3,0.1]。

图 3:这里的柱子是对所有可能分支的概率核算,不是抽几次样本得到的频数。绿色部分是直接接受,浅蓝部分是拒绝后补回。

完整多词元算法还需要处理连续接受、第一次拒绝后的草稿丢弃、缓存回退,以及全部接受时的额外采样。本章代码只枚举单个位置的概率校正,没有实现多词元推测加速器。

“分布保持一致”也不等于“使用相同随机种子就逐字输出一样”。不同算法消耗随机数的方式可以不同。是否更快,还取决于草稿成本、接受率、核对批次和硬件;草稿经常猜错时,多做的工作可能得不偿失。

8. 运行缓存实验,再做一次实际计时

先检查缓存是否算对

在仓库根目录运行:

uv run --python 3.12 --with-requirements requirements.txt python src/examples/cached_decoder.py

单独下载时,把 cached_decoder.py 与 tiny_transformer.py 放在同一目录。使用 Python 3.12 和同一份固定依赖,不下载语料或模型。

展开 KV 缓存与单位置概率校正的完整实现
"""第 37 章:为已有 TinyTransformer 添加显式 KV 缓存;不训练新模型。"""

from dataclasses import dataclass
from math import sqrt
import time
import torch
from tiny_transformer import TinyTransformer


@dataclass
class Cache:
    model: TinyTransformer
    layers: list
    length: int

    @property
    def nbytes(self):
        return sum(t.numel()*t.element_size() for pair in self.layers for t in pair)


@torch.inference_mode()
def cached_forward(model, ids, cache=None, use_sdpa=False):
    if model.training:
        raise ValueError("缓存接口只用于 eval 模式的推理")
    if ids.ndim != 2 or ids.shape[0] < 1 or ids.shape[1] < 1:
        raise ValueError("输入须为非空 [batch, length]")
    batch, length = ids.shape
    start = 0 if cache is None else cache.length
    config = model.config
    if cache is not None:
        if cache.model is not model or len(cache.layers) != config["layers"]:
            raise ValueError("缓存不属于当前模型")
        expected = (batch, config["heads"], start, config["width"]//config["heads"])
        for key, value in cache.layers:
            if key.shape != expected or value.shape != expected:
                raise ValueError("缓存形状或长度不一致")
            if key.device != ids.device or value.device != ids.device:
                raise ValueError("缓存与输入设备不同")
            if key.dtype != model.tokens.weight.dtype or value.dtype != key.dtype:
                raise ValueError("缓存精度不同")
    if start+length > config["max_context"]:
        raise ValueError("超过位置表长度;本例不实现滑动窗口")
    position = torch.arange(start, start+length, device=ids.device)
    x = model.tokens(ids)+model.positions(position)
    new_layers = []
    for index, block in enumerate(model.blocks):
        attention = block.attention
        normalized = block.norm1(x)
        projected = attention.qkv(normalized).chunk(3, dim=-1)
        query, key, value = [
            part.reshape(batch, length, attention.heads, -1).transpose(1, 2)
            for part in projected]
        if cache is not None:
            old_key, old_value = cache.layers[index]
            key = torch.cat([old_key, key], dim=-2)
            value = torch.cat([old_value, value], dim=-2)
        key_position = torch.arange(start+length, device=ids.device)
        allowed = key_position[None, :] <= position[:, None]
        if use_sdpa:
            # 非方形注意力显式传绝对位置掩码,不直接套 is_causal=True。
            mixed = torch.nn.functional.scaled_dot_product_attention(
                query, key, value, attn_mask=allowed, dropout_p=0.0, is_causal=False)
        else:
            scores = query@key.transpose(-2, -1)/sqrt(query.shape[-1])
            weights = scores.masked_fill(~allowed, float("-inf")).softmax(-1)
            mixed = weights@value
        mixed = mixed.transpose(1, 2).contiguous().reshape(batch, length, config["width"])
        x = x+attention.output(mixed)
        x = x+block.feedforward(block.norm2(x))
        new_layers.append((key, value))
    logits = model.output(model.final_norm(x))
    return logits, Cache(model, new_layers, start+length)


@torch.inference_mode()
def generate(model, prompt, new_tokens, cached=True):
    if new_tokens < 1 or prompt.ndim != 2:
        raise ValueError("至少生成一个词元,提示为二维 ID")
    if prompt.shape[1]+new_tokens-1 > model.config["max_context"]:
        raise ValueError("生成所需输入超过位置表长度")
    model.eval()
    sequence = prompt.clone()
    cache, positions, pairs = None, 0, 0
    stamps = []
    started = time.perf_counter()
    for index in range(new_tokens):
        inputs = sequence if not cached or cache is None else sequence[:, -1:]
        if cached:
            logits, cache = cached_forward(model, inputs, cache)
        else:
            logits = model(inputs)
        # 每序列、每层的投影位置数;分数元素按每层、每个注意力头计数。
        positions += inputs.shape[1]
        pairs += inputs.shape[1]*sequence.shape[1]
        token = logits[:, -1].argmax(-1, keepdim=True)
        sequence = torch.cat([sequence, token], dim=1)
        stamps.append(time.perf_counter()-started)
    return dict(ids=sequence, projected_positions=positions, score_elements=pairs,
                cache_bytes=cache.nbytes if cache else 0,
                first_seconds=stamps[0], total_seconds=stamps[-1], token_seconds=stamps)


def corrected_distribution(target, draft):
    """枚举一次草稿采样、接受或残差重采样,验证分布;不是多词元加速内核。"""
    p, q = torch.tensor(target, dtype=torch.float64), torch.tensor(draft, dtype=torch.float64)
    if (p.shape != q.shape or p.ndim != 1 or torch.any(p < 0) or torch.any(q < 0)
            or not torch.isfinite(p).all() or not torch.isfinite(q).all()
            or not torch.isclose(p.sum(), torch.tensor(1., dtype=p.dtype))
            or not torch.isclose(q.sum(), torch.tensor(1., dtype=q.dtype))):
        raise ValueError("需要两个等长的归一化非负概率向量")
    accepted_mass = torch.minimum(p, q)
    rejection = 1-accepted_mass.sum()
    residual = (p-q).clamp_min(0)
    correction = residual/residual.sum() if residual.sum() > 0 else torch.zeros_like(p)
    return accepted_mass+rejection*correction, float(accepted_mass.sum())


def main():
    torch.set_num_threads(1)
    torch.manual_seed(2026)
    model = TinyTransformer(35, max_context=32, width=32, heads=4, layers=2).double().eval()
    ids = torch.randint(0, 35, (2, 11))
    with torch.inference_mode():
        whole = model(ids)
        _, cache = cached_forward(model, ids[:, :8])
        chunk, _ = cached_forward(model, ids[:, 8:], cache)
        optimized, _ = cached_forward(model, ids[:, 8:], cache, use_sdpa=True)
    print("分块缓存与完整重算分数一致:", torch.allclose(chunk, whole[:, 8:], atol=1e-12))
    print("显式掩码的 SDPA 与手写注意力一致:", torch.allclose(chunk, optimized, atol=1e-12))
    prompt = ids[:1, :8]
    without, with_cache = [generate(model, prompt, 12, cached=flag) for flag in [False, True]]
    print("连续生成 12 个词元的 ID 一致:", torch.equal(without["ids"], with_cache["ids"]))
    print("每层投影位置数:", without["projected_positions"], "→", with_cache["projected_positions"])
    print("每层、每个头的密集分数元素数:", without["score_elements"], "→", with_cache["score_elements"])
    print("最终保留的 KV 张量:", with_cache["cache_bytes"], "字节")
    restored, accepted = corrected_distribution([.6, .3, .1], [.2, .5, .3])
    print("推测采样接受概率:", f"{accepted:.3f}")
    print("接受加残差重采样后的分布:", [round(x, 3) for x in restored.tolist()])
    print("本例随机权重用于核对计算;默认不报告随机波动的运行时间。")


if __name__ == "__main__":
    main()

输出如下:

分块缓存与完整重算分数一致: True
显式掩码的 SDPA 与手写注意力一致: True
连续生成 12 个词元的 ID 一致: True
每层投影位置数: 162 → 19
每层、每个头的密集分数元素数: 2330 → 218
最终保留的 KV 张量: 19456 字节
推测采样接受概率: 0.600
接受加残差重采样后的分布: [0.6, 0.3, 0.1]
本例随机权重用于核对计算;默认不报告随机波动的运行时间。

模型用固定随机种子初始化,没有经过语言训练。这里比较的是同一网络的两种计算路径,不拿生成 ID 去评价语言能力。

再回答“这次究竟快了多少”

计时改用两层、宽度 64、4 个头、128 项词表的随机小模型,FP32、CPU 单线程。分别输入 16 或 128 个提示词元,固定生成 32 个词元,不因 EOS 提前结束。

每种条件先预热 3 次,再测量 7 次;缓存与重算的运行顺序交替,降低固定顺序带来的偏差。每轮保留逐词元时间、生成 ID、运算计数和缓存大小,并检查同条件两种路径的 ID 一致。

随书记录在 2026-10-20(北京时间)生成,环境为 Python 3.12.12、PyTorch 2.14.1、macOS 26.6.2 arm64。下表均为中位数:提示以词元计,首词元和总时长以毫秒计,后续速度以 token/s 计。

提示KV首词元/总时长后续速度
16否0.16/6.434948.7
16是0.16/3.778597.8
128否0.52/20.261570.3
128是0.53/4.368104.2

斜线两侧依次是首词元时间和总时长。每个指标先在单次运行中计算,再取七次的中位数。因此,表中的后续速度不必恰好等于用两个耗时的中位数相减后再相除的结果。

图 4:纵轴范围相同;柱高是总时长中位数,细线是这 7 次的最小值到最大值,不是置信区间。原始测量随代码提供。

首次都要处理完整提示,所以 KV 并没有让第一步突然消失。重复生成时,缓存减少旧位置的重算,长提示下的差别尤其明显。最终保留的 KV 张量,短提示条件为 48128 字节,长提示条件为 162816 字节;无缓存路径不持续保留 KV,但仍有前向临时张量。

这些速度来自一个很小的随机模型和很小的词表,还包含 Python 循环、采样与张量拼接的开销;不包含模型加载、分词、网络或请求排队。它们不能当作真实大语言模型的服务速度,也不保证换一台电脑仍有相同比例。

先复核随书记录:

uv run --python 3.12 --with-requirements requirements.txt python src/examples/inference_benchmark.py

默认模式会核对保存的模型指纹、输入、输出 ID 与计数,并汇总旧记录;不会把这次复核说成重新测得了旧时间。想测自己的机器,用另一个输出文件:

uv run --python 3.12 --with-requirements requirements.txt python src/examples/inference_benchmark.py --run --out runs/inference-benchmark.json

单独下载时,除了前面的两份脚本,还需要 inference_benchmark.py,以及放在相邻 data/ 目录中的原始测量。

展开计时与记录复核的完整实现
"""第 37 章:显式 --run 测量 CPU KV 缓存;默认读取随书保存的原始测量。"""

import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import platform
import statistics
import torch
from cached_decoder import TinyTransformer, generate

DATA = Path(__file__).with_name("data")/"inference-benchmark.json"
CONFIG = dict(vocab_size=128, max_context=512, width=64, heads=4, layers=2)
LENGTHS = [16, 128]
NEW_TOKENS, WARMUPS, REPEATS = 32, 3, 7


def model_and_inputs():
    torch.set_num_threads(1)
    torch.manual_seed(2036)
    model = TinyTransformer(**CONFIG).eval()
    inputs = [torch.randint(0, CONFIG["vocab_size"], (1, length)) for length in LENGTHS]
    return model, inputs


def model_hash(model):
    digest = hashlib.sha256()
    for key, value in model.state_dict().items():
        digest.update(key.encode())
        digest.update(value.detach().numpy().tobytes())
    return digest.hexdigest()


def summarize(rows):
    result = []
    for length in LENGTHS:
        for cached in [False, True]:
            selected = [r for r in rows if r["length"] == length and r["cached"] == cached]
            totals = [1000*r["total_seconds"] for r in selected]
            first = [1000*r["first_seconds"] for r in selected]
            speed = [(NEW_TOKENS-1)/(r["total_seconds"]-r["first_seconds"]) for r in selected]
            result.append(dict(length=length, cached=cached,
                               median_total_ms=statistics.median(totals),
                               median_first_ms=statistics.median(first),
                               median_decode_tps=statistics.median(speed),
                               min_total_ms=min(totals), max_total_ms=max(totals),
                               cache_bytes=selected[0]["cache_bytes"]))
    return result


def benchmark(path):
    model, inputs = model_and_inputs()
    rows = []
    for prompt in inputs:
        for _ in range(WARMUPS):
            for cached in [False, True]:
                generate(model, prompt, NEW_TOKENS, cached)
        expected = None
        for trial in range(REPEATS):
            # 交替顺序,减少总让一个方案先运行的偏差;仍不能消除系统噪声。
            for cached in ([False, True] if trial % 2 == 0 else [True, False]):
                row = generate(model, prompt, NEW_TOKENS, cached)
                ids = row.pop("ids").tolist()
                if expected is None:
                    expected = ids
                assert ids == expected, "缓存前后生成不同,不能直接比较速度"
                rows.append(dict(length=prompt.shape[1], cached=cached, trial=trial, ids=ids, **row))
    report = dict(complete=True, measured_at=datetime.now(timezone.utc).isoformat(),
                  environment=dict(python=platform.python_version(), torch=torch.__version__,
                                   platform=platform.platform(), processor=platform.processor(),
                                   device="cpu", dtype="float32", threads=1),
                  config=CONFIG, seed=2036, new_tokens=NEW_TOKENS, warmups=WARMUPS,
                  repeats=REPEATS, prompts=[p.tolist() for p in inputs],
                  model_sha256=model_hash(model), rows=rows)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n")
    return report


def checked_report(path=DATA):
    report = json.loads(path.read_text())
    assert report["complete"] and report["config"] == CONFIG
    assert report["new_tokens"] == NEW_TOKENS and report["repeats"] == REPEATS
    assert report["warmups"] == WARMUPS and report["seed"] == 2036
    model, prompts = model_and_inputs()
    assert report["model_sha256"] == model_hash(model)
    assert report["prompts"] == [p.tolist() for p in prompts]
    assert len(report["rows"]) == len(LENGTHS)*2*REPEATS
    keys = {(r["length"], r["cached"], r["trial"]) for r in report["rows"]}
    assert keys == {(length, cached, i) for length in LENGTHS
                    for cached in [False, True] for i in range(REPEATS)}
    for row in report["rows"]:
        stamps = row["token_seconds"]
        assert len(stamps) == NEW_TOKENS and 0 < stamps[0] < stamps[-1]
        assert stamps == sorted(stamps)
        assert row["first_seconds"] == stamps[0] and row["total_seconds"] == stamps[-1]
    # 用当前代码复核每种输入/路径的实际输出与计数,不把它当作重新测量原时间。
    for prompt in prompts:
        for cached in [False, True]:
            current = generate(model, prompt, NEW_TOKENS, cached)
            for row in report["rows"]:
                if row["length"] == prompt.shape[1] and row["cached"] == cached:
                    assert row["ids"] == current["ids"].tolist()
                    for key in ["projected_positions", "score_elements", "cache_bytes"]:
                        assert row[key] == current[key]
    return report


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--run", action="store_true")
    parser.add_argument("--out", type=Path)
    args = parser.parse_args()
    if args.run and args.out is None:
        parser.error("请用 --out 保存自己的测量,不覆盖随书记录")
    report = benchmark(args.out) if args.run else checked_report()
    print("以下为本次测量:" if args.run else "以下为随书保存的 CPU 单线程测量:")
    for row in summarize(report["rows"]):
        print(f"提示 {row['length']:3d};缓存={row['cached']};"
              f"首词元中位数={row['median_first_ms']:.2f} ms;"
              f"总时长中位数={row['median_total_ms']:.2f} ms;"
              f"后续速度中位数={row['median_decode_tps']:.1f} token/s;"
              f"KV={row['cache_bytes']} 字节")
    print("每种条件预热 3 次、测量 7 次;同条件的生成 ID 完全一致。")
    print("随机权重只用于性能路径比较,不代表语言能力;本地结果可能不同。")


if __name__ == "__main__":
    main()

默认输出:

以下为随书保存的 CPU 单线程测量:
提示  16;缓存=False;首词元中位数=0.16 ms;总时长中位数=6.43 ms;后续速度中位数=4948.7 token/s;KV=0 字节
提示  16;缓存=True;首词元中位数=0.16 ms;总时长中位数=3.77 ms;后续速度中位数=8597.8 token/s;KV=48128 字节
提示 128;缓存=False;首词元中位数=0.52 ms;总时长中位数=20.26 ms;后续速度中位数=1570.3 token/s;KV=0 字节
提示 128;缓存=True;首词元中位数=0.53 ms;总时长中位数=4.36 ms;后续速度中位数=8104.2 token/s;KV=162816 字节
每种条件预热 3 次、测量 7 次;同条件的生成 ID 完全一致。
随机权重只用于性能路径比较,不代表语言能力;本地结果可能不同。

9. 先猜结果,再修改一处

  1. 为什么 Prefill 可以一起处理多个已知位置,而普通自回归 Decode 不能提前知道后面所有词元?
  2. 旧缓存长 6,新输入 3 个位置。它们的位置编号是什么?第一行新查询可以看到多少个键?
  3. 若 K/V 都是 FP32,两层、宽度 64、批次 1、缓存长 159,KV 张量共有多少字节?把请求批次翻倍会怎样?
  4. 为什么图 2 生成了 12 个新词元,缓存只增长了 11 个位置?如果继续生成第 13 个,会怎样?
  5. 四个等分的注意力权重对应值 1、3、5、7。正确输出是多少?错误使用左上对齐单行因果掩码会得到什么?
  6. 一个请求共生成 5 个词元,第一词元在 0.2 秒出现,最后一个在 1.0 秒出现。后续生成速度是多少?为什么不是 5/15/1 的同一口径?
  7. 在推测采样例子里,草稿抽到 B 时的接受概率是多少?拒绝后为什么不能直接再从原目标分布 pp 抽一次?
  8. 把性能实验的提示长度或模型宽度改变,保存一份新记录。先确认两种路径输出一致,再比较首词元和总时长;哪一个变化更明显?
  9. 给多个用户复用一个缓存对象,会有哪些问题?模型参数更新以后,旧缓存还能直接使用吗?
  10. SDPA 比手写实现快,是否就证明使用了某个指定的 GPU 内核?PagedAttention 是否会让标准注意力不再需要读取历史 KV?
展开参考答案与实验提示
  1. Prefill 的输入已经给定,只需按因果关系限制访问;自由生成时,下一个输入取决于刚选出的词元。并行处理已知位置和提前确定未知输出是两回事。
  2. 编号为 6、7、8。第一行可看位置 0~6,共 7 个键;不能把新块的位置从零重启。
  3. 2×2×1×159×64×4=1628162\times2\times1\times159\times64\times4=162816 字节。同样长度和布局下,批次翻倍,KV 张量字节数也翻倍;这不包括其他内存。
  4. 第一个输出由提示的最后一个位置预测;最后一个输出还没有再送回模型。继续生成时,需要处理刚才的第 12 个输出,让缓存再增长一格。
  5. 全部可见时为 4;只留下第一个键时为 1。形状合法仍可能语义错误。
  6. (5−1)/(1.0−0.2)=5(5-1)/(1.0-0.2)=5 token/s。5/15/1 恰好也为 5,但它把首词元之前的等待算进去了,口径不同;把首词元时间改为 0.4 秒,两者就不相等。
  7. 0.3/0.5=0.60.3/0.5=0.6。已经直接接受的部分并不与 pp 成比例,拒绝分支需要补足缺少的概率质量。若拒绝后直接抽 pp,本例最终会得到 [0.44,0.42,0.14][0.44,0.42,0.14],而非目标分布。
  8. 保存完整环境、配置和逐次结果,不只摘一个最快值。修改模型配置以后,旧记录的指纹与配置检查应拒绝把它当成同一实验;可以先使用 --run 生成自己的记录,再读取分析。
  9. 用户前缀不同,会改变注意力历史,可能造成答案串扰和数据泄露。参数更新后旧 K/V 也不再对应新模型,应按模型版本与具体前缀管理缓存。
  10. 不能,要检查实际分派的后端和设备。PagedAttention 改善存储组织;普通密集注意力的新查询仍然要访问允许的历史键和值。

下一章量化、蒸馏与模型压缩会接着问:除了少做重复工作,能不能让需要保存和计算的模型本身也小一点?