课程 04 · 计算内核
这一课把 slot_mapping 这个 int32 数组追到底:谁构造它、谁消费它、为什么 prefill 和 decode 的 FlashAttention 调用长得完全不一样。
模型本身不关心调度。它只问:这次要算哪些 token、它们在序列里的位置是什么、KV 写到哪、历史 KV 去哪读。prepare_prefill()[L129–170] 把调度器的状态翻译成这五样:
| 张量 | 形状 / 类型 | 作用 |
|---|---|---|
input_ids | [总 token 数] int64 | 把所有序列的 token 首尾相接压平 |
positions | [总 token 数] int64 | 每个 token 的绝对位置,喂给 RoPE |
cu_seqlens_q / k | [bs+1] int32 | 前缀和,标记每条序列在压平数组里的区间 |
slot_mapping | [总 token 数] int32 | 本课主角:每个 token 的 KV 写到哪个物理槽 |
block_tables | [bs, 最大块数] int32 | 每条序列的块表,padding 用 -1 |
cu_seqlens 是变长 batch 的通用表示法:序列 i 的 token 落在 [cu_seqlens[i], cu_seqlens[i+1])。这样一次前向就能处理长度各异的序列,不需要 padding。
model_runner.py · L151–161start_block = start // self.block_size
end_block = (end + self.block_size - 1) // self.block_size
for i in range(start_block, end_block):
slot_start = seq.block_table[i] * self.block_size
if i == start_block:
slot_start += start % self.block_size # 第一块可能从中间开始
if i != end_block - 1:
slot_end = seq.block_table[i] * self.block_size + self.block_size
else:
slot_end = seq.block_table[i] * self.block_size + end - i * self.block_size
slot_mapping.extend(range(slot_start, slot_end))
KV cache 在显存里是一个扁平的大张量:
self.kv_cache = torch.empty(2, num_layers, num_blocks, block_size, num_kv_heads, head_dim)
所以「第 b 个块的第 j 格」对应的槽号就是 b * block_size + j。slot_mapping 就是把每个待写入的 token 映射到这个一维槽号——它是一条序列的块表在时间维度上的展开。
block_table 回答「这条序列的块在哪」,slot_mapping 回答「这批 token 的 KV 该写进哪个槽」。前者是静态映射,后者是这一次前向的动态展开。
layers/attention.py · L10–30@triton.jit
def store_kvcache_kernel(key_ptr, key_stride, value_ptr, value_stride,
k_cache_ptr, v_cache_ptr, slot_mapping_ptr, D: tl.constexpr):
idx = tl.program_id(0) # 第 idx 个 token
slot = tl.load(slot_mapping_ptr + idx)
if slot == -1: return # padding 槽,跳过
key_offsets = idx * key_stride + tl.arange(0, D)
key = tl.load(key_ptr + key_offsets)
value = tl.load(value_ptr + value_offsets)
cache_offsets = slot * D + tl.arange(0, D)
tl.store(k_cache_ptr + cache_offsets, key)
tl.store(v_cache_ptr + cache_offsets, value)
一个 program 处理一个 token,每个 program 内部并行搬运 D = num_heads * head_dim 个数。为什么值得写 Triton?因为这是纯搬运:PyTorch 的索引赋值 k_cache[slots] = k 会触发一次 gather/scatter 的算子调度,而这里合并成一次 kernel,且没有中间张量。
layers/attention.py · L59–75def forward(self, q, k, v):
context = get_context()
if k_cache.numel() and v_cache.numel():
store_kvcache(k, v, k_cache, v_cache, context.slot_mapping) # 先写
if context.is_prefill:
if context.block_tables is not None: # 前缀缓存命中
k, v = k_cache, v_cache # 读整块 paged cache
o = flash_attn_varlen_func(q, k, v,
max_seqlen_q=context.max_seqlen_q, cu_seqlens_q=context.cu_seqlens_q,
max_seqlen_k=context.max_seqlen_k, cu_seqlens_k=context.cu_seqlens_k,
softmax_scale=self.scale, causal=True, block_table=context.block_tables)
else:
o = flash_attn_with_kvcache(q.unsqueeze(1), k_cache, v_cache,
cache_seqlens=context.context_lens, block_table=context.block_tables,
softmax_scale=self.scale, causal=True)
三个关键差异:
varlen:一次算 N 个 query,用 cu_seqlens 描述变长 batch,Q 和 K 是同一次前向里算出来的。with_kvcache:Q 只有 1 个 token(所以要 unsqueeze(1) 变成 [bs, 1, heads, dim]),K/V 全部从 cache 读。k, v = k_cache, v_cache 把「这次算的 K/V」换成「整块 cache」,再靠 block_table 让 kernel 只读有效部分——那些已经算过的前缀就不必重算了。先 store_kvcache 写,再调注意力。所以 with_kvcache 读到的是包含本 token 的完整历史——decode 的第 t 步能看见位置 t 的自己,causal mask 保证它不会看见未来。
layers/embed_head.py · L56–66def forward(self, x):
context = get_context()
if context.is_prefill:
last_indices = context.cu_seqlens_q[1:] - 1 # 每条序列的最后一个位置
x = x[last_indices].contiguous()
logits = F.linear(x, self.weight)
...
因果注意力下,位置 i 的隐藏状态只依赖 [0, i]。生成第一个新 token 只需要整段 prompt 的最后一个位置的输出。如果对 4096 个位置都算 vocab_size 维的 logits,等于把 15 万 × 4096 的矩阵乘白做一遍——在 0.6B 模型上这只是浪费,在 100B 模型上就是灾难。
这也是为什么 ParallelLMHead 在 prefill 时先 gather 再取:每个 rank 只持有词表的一部分,最后拼起来得到完整 logits。
layers/sampler.py · L7–12@torch.compile
def forward(self, logits, temperatures):
logits = logits.float().div_(temperatures.unsqueeze(dim=1))
probs = torch.softmax(logits, dim=-1)
sample_tokens = probs.div_(torch.empty_like(probs).exponential_(1).clamp_min_(1e-10)).argmax(dim=-1)
看起来像技巧,其实是一个精确的数学等价:
若
E_i ~ Exp(1)独立同分布,则argmax_i (p_i / E_i)的分布恰好是Multinomial(p)。
这叫 exponential race(也叫 Gumbel-max 的变体):把每个候选的「等待时间」定义为 E_i / p_i,谁先到就是谁。p_i 越大,等待时间越短,被选中的概率越大——而且严格等于 p_i。
为什么不用 torch.multinomial?两个原因:multinomial 在 CUDA 上不保证与 torch.compile 兼容的确定性实现;而这种纯张量运算可以整体编译进一张图,省掉一次 kernel launch 和一个随机数生成器调用。
@torch.compile 只加在采样这一步,因为它 shape 固定([bs, vocab])、逻辑简单,是最安全的编译目标。整模型编译是 vLLM 的路线,风险大得多。
block_size = 4。序列块表 [7, 9],已缓存 0 个 token,本轮要算 6 个 token(整段 prompt)。写出 slot_mapping。
start = 0, end = 6。第 0 块(物理块 7)覆盖 token 0–3 → 槽 7*4+0 … 7*4+3 = 28,29,30,31;第 1 块(物理块 9)覆盖 token 4–5 → 槽 9*4+0, 9*4+1 = 36,37。
结果:[28,29,30,31,36,37]。注意物理上不连续——这正是分页的意义。
如果这轮是 chunked prefill 的第二块(已缓存 4 个 token,本轮算 2 个),则 start=4, end=6,start_block=1,start % block_size = 0,结果同样是 [36,37]。
prepare_decode 的 slot_mapping 为什么只有一个元素?decode 每序列只写 1 个 token 的 KV,所以每条序列对应一个槽:block_table[-1] * block_size + last_block_num_tokens - 1(model_runner L181)。last_block_num_tokens 是最后一块里已有的 token 数,减 1 得到这个 token 的 0-based 槽内偏移。
block_size 和 FlashAttention 的 block 粒度不一致,会发生什么?FlashAttention 的 block_table 参数会按它自己的粒度去索引 KV cache。如果两者对不上,读到的是错位的 K/V——不会报错,只会默默输出胡话。这就是 config.py 里 assert self.kvcache_block_size % 256 == 0 存在的原因。
logits.float()?logits 可能是 bf16,直接除以 temperature 会损失精度,softmax 在低精度下更容易溢出或产生 0 概率。转成 fp32 再算,代价很小(只在最后一步做)。
「PagedAttention 的 kernel 怎么知道 KV 在哪?」
「推理时 KV 存在一个大张量里,按块切分、物理上不连续。注意力 kernel 拿到的是 block_table——每条序列的逻辑块到物理块映射,加上这次的 slot_mapping——每个待写 token 到物理槽的一维索引。写入时 Triton kernel 按 slot 逐 token 搬运;读取时 FlashAttention 用 block_table 做间接寻址,所以它能在分页的显存上直接算注意力,不需要先拼成连续内存。」
想深挖就问:「FlashAttention 内部怎么处理 block_table?」——那是 kernel 细节,我会给你论文对应章节,不会要求你手写。