动画图解 LLM 系统

Sparse attention:只看需要的历史

上下文很长,当前 token 一定要翻遍每一页吗?

→ 逐步推进 · ← 回看 · 拖动时间轴观察数据流

动画图解 LLM 系统

Full attention:每个 Q 都看全部可见历史

训练与 prefill 中,attention 的配对数随序列长度近似平方增长。

每个格子是一对 Q 与 K。因果模型只使用下三角:位置 i 能读取 1…i。

从第 1 行扫到第 8 行,读取次数是 1 + 2 + … + 8 = 36。未来位置始终不可见。

稀疏化是在这个可见区域里再选一部分位置。只要 mask 允许,选中的位置照常做点积、softmax 和加权求和。

工程细节

稀疏 mask 在 softmax 之前生效

第 i 个 query 允许读取的集合记作 Si。先选集合,再在集合内部重新归一化。

logitᵢⱼ = qᵢᵀkⱼ / √d,j ∈ Sᵢ
aᵢⱼ = exp(logitᵢⱼ) / Σₖ∈Sᵢ exp(logitᵢₖ)
yᵢ = Σⱼ∈Sᵢ aᵢⱼ vⱼ

不能先做完整 softmax,再把没选中的权重置零而不归一化。那会让权重和小于 1,输出幅值也改变。

Sᵢ 必须非空;通常至少保留当前 token 或局部窗口。实现用 masked fill(−∞),再减去行最大值求 softmax。

工程细节

同样的 V,换集合就会换输出

logits = [0, ln 2, ln 3],V = [2, 4, 8]。完整权重是 [1/6, 2/6, 3/6],输出 17/3 ≈ 5.667。

只选位置 {0, 2}:
权重 = [1/4, 3/4]
y = 1/4 × 2 + 3/4 × 8 = 6.5

若把完整权重的中间项直接清零,得到 1/6 × 2 + 3/6 × 8 = 4.333;它并不是这个稀疏 mask 定义的 attention。

kernel 的正确性应与“相同 mask 的 dense 参考”比较;任务质量再与原始 full attention 比较。

动画图解 LLM 系统

滑动窗口:只看最近的 w 个 token

很多局部关系发生在附近,例如短语、语法和刚刚出现的实体。

把窗口宽度设为 3:位置 6 只读取 4、5、6。更早的 token 本层不直接读取。

当新 token 到来,窗口向前滑一格。读取量由全部历史变为固定的 3 份 KV。

堆叠多层后,信息可以逐层传播到更远处;单层直接可见范围仍然由窗口决定。

窗口宽度 3 为演示值;计算成本按每个 query 访问的 key 数量计算。

工程细节

窗口的精确配对数,不只是 O(nw)

含自己的因果窗口宽 w。前 w−1 行还没填满,后面每行恰好读 w 个位置;假设 n ≥ w。

配对数 = 1 + 2 + … + (w−1) + w(n−w+1)
= nw − w(w−1)/2

n = 4096、w = 256:配对数 = 1,048,576 − 32,640 = 1,015,936。full causal 是 4096 × 4097 / 2 = 8,390,656。

这组算术约少 8.26 倍配对。实际延迟还包括投影、softmax、访存和 kernel 调度。

工程细节

多层窗口扩大的是间接感受野

L 层、每层宽 w → 最远可传播 L(w−1) 个位置
最终位置可受影响的范围宽度 ≤ 1 + L(w−1)

L = 4、w = 3:最后一层位置 10 可以间接受到位置 2 的影响,范围宽 9;每层仍只直接读取 3 个位置。

这条路径经过多次压缩与变换,不等同于最后一层直接拿到位置 2 的原始 KV。精确复制远处数字,可能仍需要远程读取。

推导假设每层都是相同因果局部窗口、没有全局 token 或跨层跳接扩大可见性。

动画图解 LLM 系统

局部之外,再留少量远程入口

固定稀疏模式可以组合局部窗口与全局位置。

橙色位置是最近窗口。紫色位置是允许额外访问的全局 token,例如某些结构性标记。

当前 Q 同时读取两类位置:近处保细节,远程入口传递更早的信息。

用于自回归生成时,全局位置也受因果约束:任何一行都不能看见未来。

Longformer 的原始设置包含双向任务;这里画的是保持 causal mask 的自回归示意。

动画图解 LLM 系统

动态稀疏:让内容决定读取哪里

“最近”不等于“相关”。重要答案也许在几千个 token 之前。

查询是“订单号是什么?” 当前 Q 先经过选择器,找出更值得读取的历史位置。

选择器输出位置索引,例如 2、5、7。attention kernel 只对这些位置的完整 K、V 做计算。

选择也有成本:若选择器先扫描所有完整 K,仍会搬大量数据。轻量索引或压缩表示能减少这一步的开销。

分数与选中位置为示意;最终集合还可以加入局部窗口和必要的保留位置。

工程细节

选择器先要赚回自己的时间

tsparse = tselect + tgather + tattn(k)
只有 tsparse < tattn(n),attention 部分才更快

假想测量:full attention 2.0 ms;选择 0.35 ms、gather 0.25 ms、稀疏计算 0.50 ms,总计 1.10 ms,attention 加速 1.82×。

若其他网络层还花 6 ms,端到端从 8.0 降到 7.1 ms,只快 1.13×。小上下文中 full 只需 0.4 ms 时,选择开销可能已不划算。

选择器若扫描低维索引,仍然可能随 n 线性增长;“主 attention 只读 k 个”不是整个算法 O(k) 的保证。

工程细节

top-k 也必须遵守因果约束

valid = key_position <= query_position
index_score = where(valid, index_score, -inf)
chosen = topk(index_score, k=min(k, valid_count))
chosen = sorted(unique(chosen + local_positions))
y = attention(q, gather(K, chosen), gather(V, chosen))

先 mask 再 top-k,避免未来高分把过去候选挤掉。全局位置与局部窗口取并集时去重,否则同一个 token 会被重复计权。

多 query 的稀疏索引可不同。若为了方便共用一组块,块内每个 query 仍需自己的精确 causal mask。

逐步算一遍

选择、重归一化、读取内容,顺序不能乱

已缩放 logits 为 [0, ln 2, ln 3],选择位置 {0, 2},对应 V 为 [2, 8]。

候选 {0, 2}:exp logits = [1, 3]在候选内归一化:权重 [0.25, 0.75]读取 V:0.25 × 2 + 0.75 × 80

所有数值为手算教学示例。

动画图解 LLM 系统

GPU 更喜欢整块读取

零散地省算术,不一定省时间;内存访问和矩阵乘法都需要规律。

逐 token 选择可能产生许多零碎地址。GPU 要反复 gather,计算块也不容易填满。

把相邻 token 组织成 block,再按 block 选择。一次搬一整块,方便连续读取与 tiled kernel。

块越大,调度更规整,也会多读一些不太相关的 token。块大小需要与选择粒度一起权衡。

逐步算一遍

选了 3 个 token,为什么读了 12 个?

历史长度 16,块宽 4,目标位置 {1, 6, 14}(从 0 编号)。每个目标落在不同块里。

位置 1 → block 0 → 读取 [0, 1, 2, 3]位置 6 → block 1 → 读取 [4, 5, 6, 7]位置 14 → block 3 → 读取 [12, 13, 14, 15]0

这里区分“选块后块内全部参与”与“块内仍做 token mask”;两者语义不同,读取成本可以相同。

工程细节

把稀疏模式写成 kernel 能消费的索引

按 query block 存储可见 key blocks。row_ptr 指出每行在 col_idx 中的范围,避免保存巨大布尔矩阵。

row_ptr = [0, 1, 3, 5]     # 3 个 query blocks
col_idx = [0, 0, 1, 0, 2]  # 依次存每行选中的 key blocks
# query block 1: col_idx[1:3] == [0, 1]
for key_block in col_idx[row_ptr[r]:row_ptr[r+1]]:
    load(K[key_block], V[key_block])
    update_online_softmax(q_block, k_block, v_block)

query / key block 指的是逻辑位置块。分页 KV 还需要块表把逻辑位置映射到物理显存地址。

工程细节

块可见,不等于块内所有元素都可见

query block 覆盖位置 4…7,key block 也覆盖 4…7。它们处于因果矩阵对角线上。

queryK4K5K6K7
Q4✓屏蔽屏蔽屏蔽
Q5✓✓屏蔽屏蔽
Q6✓✓✓屏蔽
Q7✓✓✓✓

跳过块内 mask 会泄露未来。只在整个 key block 的最大位置不超过最早 query 时,才可以整块免 mask。

动画图解 LLM 系统

少读多少?先看 k 与 n

序列长 n;每个 query 最多读取 k 个位置;head dimension 是 d。

Full causal attention 的 prefill 配对数约 n²/2;decode 的新 Q 读取 n 个位置。

若每个 Q 只读 k 个位置,attention 核心约为 prefill O(nkd)、decode O(kd)。

总时间还包括选择、索引、数据搬运与其他网络层。只有这些成本也划算,稀疏化才会带来端到端收益。

示意 n = 16、k = 4;复杂度忽略层数与头数,且不计选择器。

工程细节

把复杂度换成 FLOP 与读取字节

单个 query head,n = 32768、k = 1024、d = 128、BF16。点积与 value 加权各约 2kd FLOP。

部分FullSparse 主 attention
核心 FLOP4nd = 16,777,2164kd = 524,288
K/V 载荷2nd × 2 = 16 MiB2kd × 2 = 0.5 MiB
低维选择扫描无另计:例如 n × 16 × 2 = 1 MiB

如果索引维度是 16 且每步全扫,光索引就要读 1 MiB。还未计索引计算、输出 top-k、非连续访问与多个 head 的共享。

这是一个明确的教学配置。选择器与主 attention 的精度、头共享方式可以不同。

工程细节

漏选的损失比“命中几个”更有意义

假设完整 attention 在三个关键位置上的权重为 0.80、0.15、0.05。选中两个位置的 recall 都是 2/3,但漏掉谁差别很大。

选中 0.80 + 0.15

保留 attention mass 0.95,漏掉 0.05;重新归一化后主要信息可能仍保留。

选中 0.15 + 0.05

保留 mass 0.20,漏掉最重要位置。虽然索引 recall 相同,输出可能完全改变。

attention mass 仍只是诊断指标;最终要检查模型输出与任务质量。采样部分层和 query 做 full 参考,避免评估本身太贵。

工程细节

训练阶段还要决定选择器怎么学

固定窗口不需要学习索引;动态 top-k 对“哪些位置被选中”通常没有普通的连续梯度。

被选中的主路径

Q、K、V 的 attention 运算可正常反传。未被选中的位置在该 query 上通常收不到这条 attention 梯度。

索引器的学习信号

可通过辅助目标、蒸馏完整 attention 分布或架构专用训练设计学习。具体目标决定了它学会找什么。

把预训练 full 模型临时套上稀疏 mask,是一种近似推理策略;它与从训练阶段就采用该稀疏结构不是同一件事。

动画图解 LLM 系统

少读历史,与压小历史,是两件事

可以让同一模型同时使用稀疏访问和压缩后的 KV 表示。

左边保留原始位置,但当前 Q 只选择一部分来读。没有被选中的位置仍可能留在 cache。

右边把四个位置变成一个摘要;即使读取这个摘要,也不是逐个读取原来的四份细节。

组合时,先决定“历史怎么存”,再决定“当前读哪些”。是否释放旧 KV,要看未来查询还会不会需要它。

动画图解 LLM 系统

从任务出发选择稀疏模式

局部模式

规则简单、访问连续、kernel 好实现。适合大量依赖近邻的层,也可以和 full attention 层交替使用。

内容选择

能找回远处的相关位置。需要训练或设计选择器,并控制漏选与选择开销。

块级模式

更容易把理论节省变成 GPU 吞吐。要检查块大小、gather 开销与稀疏 kernel 的利用率。

评估真正的难题

长距离检索、跨段推理、多处信息组合;同时记录延迟、吞吐和缓存占用。

工程细节

稀疏后更慢,先看三段时间

假想 trace:原 attention 1.4 ms;新方案选择 0.7 ms、gather 0.6 ms、稀疏计算 0.4 ms,共 1.7 ms。

发现可能原因下一步实验
选择占 0.7 ms扫描过宽或 top-k 太贵降低索引维度,测选块粒度
gather 占 0.6 ms位置离散、重复搬运排序索引、合并相邻块
计算仅 0.4 ms主矩阵确实变小不要继续只优化这一小段

每次只改一个变量,并用相同 mask 对齐正确性。若质量下降,先查 causal、去重、位置编码和归一化,再调选择预算。

练习 1

长度 8,窗口 3,省掉多少配对?

窗口包含当前 token。求 full causal 与窗口 attention 的精确配对数。再问:4 层窗口 3 的最大感受野宽是多少?
展开参考解答

Full = 8 × 9 / 2 = 36。窗口 = 1 + 2 + 6 × 3 = 21,也等于 8 × 3 − 3 × 2 / 2。减少 15/36 ≈ 41.67%。

4 层结构的理论上限为 1 + 4 × (3−1) = 9;本题序列仅 8 个 token,实际最多 min(8,9) = 8。多层间接传播不等于一层直接读取。

先写出假设和单位,再代入数字。

练习 2

块读取能兑现多少理论节省?

n = 1024,块宽 16,选中 32 个 token,分别落在 24 个不同块。假设每个块都完整读取,求读取 token 数、目标利用率、相对 full 的流量比例。
展开参考解答

读取 24 × 16 = 384 个 token。目标利用率 = 32 / 384 = 8.33%。

相对 full 流量 = 384 / 1024 = 37.5%,节省 62.5%;不能按只读 32 个 token 声称节省 96.875%。

先写出假设和单位,再代入数字。

练习 3

未来信息从哪里漏进来了?

一个 chunk 包含位置 8…11。选择器对每个 query 都先看完整 K0…K11,取 top-4 后再丢掉未来索引。这个流程有什么问题?如何修正并测试?
展开参考解答

未来高分会占用 top-4 名额,丢掉后候选可能不足,甚至为空;选择结果也依赖未来内容。

先按各 query 的绝对位置屏蔽未来,再做 top-k。测试时修改未来 token,确认较早 query 的输出完全不变;同时覆盖候选数不足与全局 / 局部去重。

先写出假设和单位,再代入数字。

动手实验

让 sparse gather 对上 dense masked 参考

用 NumPy 在 CPU 上跑一个可检查的参考实现。小尺寸便于把中间张量打印出来。

python3 -m pip install numpy
python3 lab.py
python3 lab.py --seed 7
masked dense vs gather: PASS
future perturbation: PASS
window pairs n=8,w=3: 21
3 targets / 12 loaded = 25.0%

把窗口改成局部 + 固定全局位置,先去重;再故意让对角块免 mask,观察未来扰动检查失败。

下载实验代码 lab.py

这是正确性与成本的教学实验;耗时不代表 GPU kernel 性能。

参考资料

参考与延伸阅读