DeepSeek V4 源码剖析
第5章 sparse_attn 与 FlashMLA:稀疏注意力落到 GPU 上
o = sparse_attn(q, kv, self.attn_sink, topk_idxs, self.softmax_scale)——inference/model.py:528V4 的全部 attention 革命(MLA + Compressor + Indexer),最后必须落到一个 GPU kernel 上才能产生工程价值。这一章拆两份实现:官方仓库里那份可读的 TileLang 参考实现,和 FlashMLA 里那份为 H800 / B200 手写的 CUDA。
5.1 引子:从一行 PyTorch 到 GPU 上的真实计算
V4 的 Attention.forward 里 attention 的"实际计算"被压缩成一行:
o = sparse_attn(q, kv, self.attn_sink, topk_idxs, self.softmax_scale)
参数:
q:query,形状[B, S, n_heads=128, head_dim=512],BF16(kernel 签名里写死T.Tensor[(b, m, h, d), BF16])kv:decode 时是 KV cache(滑窗 + 压缩段拼起来的完整 KV),形状[B, kv_cache_size, head_dim=512];prefill 时是本段的kv与 Compressor 输出拼起来的张量(inference/model.py:524-528)attn_sink:每 head 一个 float32 标量,形状[n_heads]topk_idxs:稀疏选取的 KV 位置索引,在ratio=4的层上形状最多为[B, S, window_size + index_topk] = [B, S, 1152](ratio=128的层是滑窗 128 条加全部end_pos // 128条压缩 KV,ratio=0的层只有滑窗 128 条),类型为 int32(inference/model.py:515有一行topk_idxs = topk_idxs.int()),-1表示无效槽位softmax_scale:缩放系数head_dim ** -0.5
sparse_attn 是一个从 kernel 模块导入的函数:
from kernel import act_quant, fp4_act_quant, fp8_gemm, fp4_gemm, sparse_attn, hc_split_sinkhorn
这里要先纠正一个很容易想当然的误解:这个 kernel 不是 FlashMLA。它就是 V4 仓库自己的 inference/kernel.py(536 行),和 model.py 平级——model.py 用的是 from kernel import ... 这种同目录导入。打开它会看到,里面没有一行 CUDA C++,全部是 TileLang:
import tilelang
import tilelang.language as T
@tilelang.jit(pass_configs=pass_configs)
def sparse_attn_kernel(h: int, d: int, scale=None):
...
也就是说,官方给的是一份用 DSL 写的、可读的参考实现,运行时由 TileLang JIT 编译成 GPU 代码。而 FlashMLA 是另一件事:它是 DeepSeek 单独维护的手写 CUDA 内核库,README 自述"powering the DeepSeek-V3 and DeepSeek-V3.2-Exp models",V4 发布时它并没有被 inference/ 目录引用。但——FlashMLA 里已经躺着 V4 的代码路径,这是本章后半段要拆的重点。
flowchart TB
subgraph Ref["官方参考实现(本章 §5.2-§5.5)"]
P1["Attention.forward:<br/>sparse_attn(q, kv, sink, idxs, scale)"]
P2["inference/kernel.py:355<br/>sparse_attn(...) 包装"]
P3["inference/kernel.py:277<br/>@tilelang.jit sparse_attn_kernel"]
P4["TileLang JIT → PTX/SASS"]
P1 --> P2 --> P3 --> P4
end
subgraph Prod["生产路径(本章 §5.6-§5.9)"]
F1["FlashMLA csrc/api/api.cpp<br/>sparse_decode_fwd / sparse_prefill_fwd"]
F2["csrc/sm90/decode/sparse_fp8/<br/>run_flash_splitkv_mla_fp8_sparse_kernel"]
F3["csrc/sm100/decode/head64/<br/>KernelTemplate<ModelType><br/>(h_q=128、d_qk=512 时改走<br/>prefill/sparse/fwd_for_small_topk/head128)"]
F1 --> F2
F1 --> F3
end
本章拆这两条链——先读懂 TileLang 那份把语义讲清楚,再去 FlashMLA 里核对生产实现真正长什么样。
5.2 sparse_attn 的接口语义
sparse_attn(q, kv, sink, idxs, scale) 的语义是:
对每个 query token 的每个 head,仅计算 q · kv[idxs] 的内积,做 softmax + 加权求和,然后跨 head 输出。
伪代码形式:
def sparse_attn(q, kv, attn_sink, topk_idxs, softmax_scale):
# q: [B, S, H, D]
# kv: [B, T, D] (T = window_size + max_seq_len // ratio)
# attn_sink: [H]
# topk_idxs: [B, S, K] (K = window_size + index_topk = 1152)
# softmax_scale: scalar
# 返回 o: [B, S, H, D]
B, S, H, D = q.shape
K = topk_idxs.shape[-1]
o = torch.zeros_like(q)
for b in range(B):
for s in range(S):
for h in range(H):
# gather K 个 KV 位置
gathered_kv = kv[b, topk_idxs[b, s]] # [K, D]
# mask 掉 -1 位置
valid = topk_idxs[b, s] >= 0
# logits = q · k / sqrt(D)
logits = (q[b, s, h] @ gathered_kv.T) * softmax_scale
logits = torch.where(valid, logits, float("-inf"))
# 拼接 attn_sink
logits_with_sink = torch.cat([logits, attn_sink[h:h+1]])
weights = F.softmax(logits_with_sink, dim=0)
# 加权求和(不包括 sink)
o[b, s, h] = (weights[:K, None] * gathered_kv).sum(dim=0)
return o
这只是语义模型——真实 CUDA 实现要快几个数量级。但理解这个伪代码后,CUDA 实现就是"如何把这段循环并行化、向量化、利用 TensorCore"的工程问题。
5.3 FlashMLA 仓库的代码组织
把 FlashMLA(github.com/deepseek-ai/FlashMLA,本章核对的是 71c7379,2026-03-31,V4 发布前 main 分支上的最后一版)的目录摊开:
FlashMLA/
├── csrc/
│ ├── api/
│ │ ├── api.cpp # pybind:sparse_decode_fwd / dense_decode_fwd /
│ │ │ # sparse_prefill_fwd / dense_prefill_fwd / dense_prefill_bwd
│ │ ├── sparse_decode.h
│ │ └── sparse_fwd.h
│ ├── params.h # enum class ModelType { V32, MODEL1 };
│ ├── sm90/
│ │ ├── decode/sparse_fp8/ # splitkv_mla.cuh + instantiations/{model1,v32}_persistent_h{64,128}.cu
│ │ └── prefill/sparse/ # phase1.cuh + instantiations/phase1_k{512,576}[_topklen].cu
│ ├── sm100/
│ │ ├── decode/head64/ # KernelTemplate<ModelType>
│ │ └── prefill/sparse/ # fwd/head{64,128}/ 与 fwd_for_small_topk/head128/
│ └── smxx/decode/combine/ # split-KV 的 combine kernel(attn_sink 在这里参与归一化)
├── flash_mla/ # Python 包装:get_mla_metadata / flash_mla_with_kvcache /
│ # flash_mla_sparse_fwd
├── tests/
└── benchmark/bench_flash_mla.py
三条可以直接 grep 出来的观察:
1. ModelType 只有两个取值:V32 和 MODEL1。
csrc/params.h:5:
enum class ModelType {
V32,
MODEL1
};
MODEL1 是什么?看 csrc/sm100/decode/head64/config.h:33 起按 ModelType 分叉的那组常量(节选):
static constexpr int D_Q = MODEL_TYPE == ModelType::V32 ? 576 : 512;
static constexpr int D_V = 512;
static constexpr int D_NOPE = MODEL_TYPE == ModelType::V32 ? 512 : 448;
static constexpr int D_ROPE = 64;
static constexpr int QUANT_TILE_SIZE = MODEL_TYPE == ModelType::V32 ? 128 : 64;
static constexpr bool V_HAVE_ROPE = MODEL_TYPE == ModelType::V32 ? false : true;
static constexpr int NUM_SCALES_EACH_TOKEN = MODEL_TYPE == ModelType::V32 ? 4 : 8;
MODEL1 那一列——head_dim 512、nope 448、rope 64、量化块 64、V 也带 RoPE——与 V4 的 config.json(head_dim=512、qk_rope_head_dim=64)和 model.py 里那两行(act_quant(kv[..., :-rd], 64, ...) 的块大小 64、apply_rotary_emb(o[..., -rd:], freqs_cis, True) 的输出反旋转)逐条对上。448/64 = 7 个 scale,padding 到 8,就是 NUM_SCALES_EACH_TOKEN = 8。换句话说:V4 的内核路径在模型发布前近一个月就已经躺在 FlashMLA 里了,只是用了一个不说破的代号。
2. 每个架构一套独立实现,且实例化是按"模型 × head 数"展开的。
csrc/sm90/decode/sparse_fp8/instantiations/model1_persistent_h128.cu 全文只有一行有效代码:
template void run_flash_splitkv_mla_fp8_sparse_kernel<ModelType::MODEL1, 128>(const SparseAttnDecodeParams ¶ms);
同目录下还有 model1_persistent_h64.cu、v32_persistent_h128.cu、v32_persistent_h64.cu——四份组合各编一次。prefill 侧则按 head_dim_k 展开:sm90::fwd::run_fwd_phase1_kernel<576, false> 与 <512, false>,_topklen 后缀那两个对应"每条序列 topk 长度不同"的变体。
3. PyTorch binding 极薄。csrc/api/api.cpp 一共只有五行 m.def(...),C++ 入口做的是形状 / dtype 检查 + 按架构 dispatch(arch.is_sm90a() / arch.is_sm100f()),业务逻辑全在 kernel 里。
5.4 稀疏 attention 的 GPU 优化挑战
稀疏 attention 比 dense attention 在 GPU 上更难优化。dense attention 的内存访问模式是连续的——每个 query 顺序读 KV cache 从 0 到 T。稀疏 attention 的内存访问模式是索引跳跃的——每个 query 按 topk_idxs 跳着读 KV。
这带来三个挑战:
挑战一:合并访问失效
GPU 的全局内存读取按 32-byte / 128-byte cache line 合并。dense attention 的 KV 读取连续,多个线程一次读取一条 cache line。稀疏 attention 的 KV 读取分散,多个线程可能读到不同的 cache line——内存带宽利用率下降。
官方 TileLang 参考实现的做法是最朴素的一种——按 block=64 一批,把索引对应的 KV 逐元素搬进 shared memory,无效槽位(-1)填 0:
for i in T.Parallel(block):
idxs[i] = T.if_then_else(t * block + i < topk, topk_idxs[by, bx, t * block + i], -1)
for i, j in T.Parallel(block, d):
kv_shared[i, j] = T.if_then_else(idxs[i] != -1, kv[by, idxs[i], j], 0)
注意这里的关键:gather 的粒度是"一整条 KV"(d=512 个连续元素),不是单个标量。所以合并访问并没有真的失效——每条 KV 内部仍然是连续的 512×2 字节,只是条与条之间跳着走。这也是 V4 这种 MQA-style 单组 512 维 KV 在稀疏 gather 下的一个好处:head_dim 越大,一次 gather 搬的连续块越大,索引跳跃的相对代价越低。
挑战二:相邻 query 的索引重叠没有被利用
两个相邻 query token 的 topk_idxs 会有重叠(至少滑窗那 128 个位置几乎完全重合),但参考实现里每个 (batch, seq_pos) 是一个独立的 CTA(with T.Kernel(m, b, threads=threads) as (bx, by)),各自把自己那 1152 条 KV 从头搬一遍——重叠部分被重复读取,只能指望 L2 命中。这是"可读的参考实现"与"生产内核"之间最典型的一类差距。
挑战三:tile 大小与 K 的关系
FlashAttention 的标准做法是把 KV 分成 tiles,逐 tile 处理。参考实现里这几个数字是写死的:
num_stages = 2
threads = 256
block = 64
num_blocks = tilelang.cdiv(topk, block)
block=64、K=1152 ⟹ 每个 query 跨 18 个 tile,kernel 用 online softmax 在 tile 间累积分子分母,最后统一归一化。
FlashMLA 那边的分块常量是另一套:csrc/sm90/decode/sparse_fp8/components/config.h:19 的 PAGE_BLOCK_SIZE = 64、同文件 :16 的 QUANT_TILE_SIZE = 128(V3.2 路径)——而 V4(MODEL1)路径在 SM90(csrc/sm90/decode/sparse_fp8/config.h:28)和 SM100 上用的都是 QUANT_TILE_SIZE = 64,正好对应 model.py 里 act_quant(kv[..., :-rd], 64, ...) 那个 64。
5.5 online softmax:稀疏 attention 的核心算法挑战
V4 的 sparse_attn kernel 必须解决一个问题:当一个 query 跨多个 tile 计算时,怎么在 tile 之间正确累积 softmax 的分子分母?
这个问题在 dense FlashAttention 里就已经存在——每个 query 看完整 KV,KV 被分 tile 处理。FlashAttention 的解法是 online softmax 算法(Milakov & Gimelshein 2018):
对于每个 tile, 维护两个 running quantity:
m: 当前已见 logits 的最大值
l: 当前的 sum of exp(logits - m)
收到新 tile 的 logits 时:
m_new = max(m, max(new_logits))
l_new = exp(m - m_new) * l + sum(exp(new_logits - m_new))
o_new = exp(m - m_new) * o + sum(exp(new_logits - m_new) * new_v)
最后归一化:
o = o / l
这个算法的妙处在于 m 和 l 的更新可以增量进行,且数学上等价于 dense softmax。
V4 的 sparse_attn 的 online softmax 多了一个复杂性:attn_sink。sink 不是某个 tile 里的位置,而是一个 head-level 的常数 logit——必须把它"虚拟"地参与到 softmax 归一化里。
参考实现的处理方式比"把 sink 当成第 0 个 tile"更省事——它把 sink 放到最后一步补进分母:
for t in T.Pipelined(num_blocks, num_stages=num_stages):
... # 常规 online softmax,sink 完全不参与
sum_exp[i] = sum_exp[i] * scores_scale[i] + scores_sum[i]
...
for i in T.Parallel(h):
sum_exp[i] += T.exp(attn_sink[i] - scores_max[i]) # ← 循环结束后才补
for i, j in T.Parallel(h, d):
acc_o[i, j] /= sum_exp[i]
为什么这样是对的?因为 scores_max 在循环结束时已经是全局最大值,sum_exp 是以它为基准的 ,此时补上 恰好就是"把 sink 当成一个虚拟位置参与归一化"。而 acc_o 全程不加 sink 项——sink 只进分母,不进分子,这正是"什么都不选"的语义。
FlashMLA 的 prefill kernel 和不拆分的 decode 与参考实现一样,在最后归一化时把 sink 补进分母(如 csrc/sm90/prefill/sparse/phase1.cuh:206 的 1.0f / (rL[i] + exp2f(attn_sink - rM[i])));一旦走 split-KV,就留到 combine 阶段处理,用的是 log-sum-exp 形式(csrc/smxx/decode/combine/combine.cu:107):
float attn_sink = __ldg(params.attn_sink + q_head_idx);
global_lse += log2f(1 + exp2f(attn_sink*CUDART_L2E_F - global_lse));
数学上等价(),但因为 split-KV 会把一条序列的 KV 拆到多个 CTA 上算、最后再合并,sink 只能在合并那一步加一次,不能每个 split 都加。同一个文件里还处理了两个边界:attn_sink = +inf(所有真实位置权重归零)和 attn_sink = -inf(等价于没有 sink)。
这种"sink-aware online softmax"是 V4 / V3.2 这条稀疏路径的特化部分,标准 FlashAttention 的 online softmax 没有这一项。
flowchart LR
subgraph TileFlow["online softmax 的 tile 流"]
T0["tile 0: 64 个 KV"] -->|更新 m,l,o| T1["tile 1: 64 个 KV"]
T1 -->|更新 m,l,o| T2["tile 2: ..."]
T2 -->|...| TN["tile N"]
TN -->|最后除以 l| Out["o 输出"]
end
Sink["attn_sink<br/>循环结束后补进分母"] -.参与归一化.-> Out
参考实现在每个 (batch, seq_pos) 上跑这套 sink-aware online softmax,跨 18 个 KV tile(K=1152 / block=64)累积,最后统一归一化输出。
5.6 索引 gather 的硬件路径
稀疏 attention 在 SM90 / SM100 上的"索引 gather"是 V4 工程化的一个具体硬件挑战。让我们看一段简化的 CUDA 伪代码理解它在硬件层做了什么:
// 简化的稀疏 KV gather kernel(C++/CUDA)
__global__ void gather_kv_for_sparse_attn(
const __nv_fp8_e4m3* kv_global, // [B, T, D] 全部 KV
const int* topk_idxs_global, // [B, S, K] topk 索引
__nv_fp8_e4m3* kv_gathered_smem, // shared memory 输出
int B, int S, int T, int D, int K)
{
int b = blockIdx.x;
int s = blockIdx.y;
int tid = threadIdx.x;
// 每个 thread 负责 K/blockDim.x 个 KV 位置
for (int k_local = tid; k_local < K; k_local += blockDim.x) {
int kv_idx = topk_idxs_global[b * S * K + s * K + k_local];
if (kv_idx < 0) continue; // mask -1 invalid
// gather D 维 (D=512)
// SM90: prefill 用 cp.async 异步拷贝,decode 用 128-bit 全局 load 后在寄存器里反量化
// SM100: 用 TMA 的 gather4 模式
int dst_offset = k_local * D;
int src_offset = b * T * D + kv_idx * D;
// ... TMA / cp.async copy ...
}
}
实际 FlashMLA 的 kernel 比这复杂得多。SM90 的 decode 路径叫 run_flash_splitkv_mla_fp8_sparse_kernel(csrc/sm90/decode/sparse_fp8/splitkv_mla.cuh),名字里的三个词就是它的三件事——splitkv(把一条序列的 KV 拆到多个 CTA,最后由 csrc/smxx/decode/combine/combine.cu 合并)、fp8(KV cache 以 FP8 存,反量化在 components/dequant.h)、sparse(按 indices gather)。SM100 的 csrc/sm100/prefill/sparse/fwd_for_small_topk/head128/ 这份 kernel 用 CUtensorMap 走 TMA,其 config.h 里的 TmaParamsForDecode 结构体给 kv_nope / kv_rope / extra_kv_nope / extra_kv_rope 分别建了 tensor map——nope 段与 rope 段是分开搬的,正对应 V4 里"nope 走量化、rope 保 BF16"的切分。
但核心思想不变:把 topk 选取的 KV 搬到 SMEM,然后正常做 attention。FlashMLA 的工程价值就在于"把这条 gather + GEMM + softmax 流水线优化到接近硬件极限"。
5.7 SM90 vs SM100:两套 kernel 的差异
V4 同时支持 H100/H800(SM90)和 B200(SM100)。FlashMLA 给两套架构写了完全独立的 kernel:
先看 FlashMLA 自己声明的支持矩阵(README 里的表,原样搬过来):
| Kernel | GPU Architecture | MLA Mode | KVCache Format |
|---|---|---|---|
| Dense Decoding | SM90 | MQA | BF16 |
| Sparse Decoding | SM90 & SM100 | MQA | FP8 |
| Dense Prefill | SM100 | MHA | |
| Sparse Prefill | SM90 & SM100 | MQA |
注意 Dense Decoding 只有 SM90、Dense Prefill 只有 SM100——这不是疏漏,是"哪条路径值得投人力"的结果:稀疏路径两个架构都全须全尾。
代码组织上的差异也很直白:SM90 的 decode 走 sparse_fp8/splitkv_mla.cuh(split-KV + persistent kernel,实例化按 h64/h128 展开),SM100 的 decode 走 head64/KernelTemplate<ModelType>;SM100 的 prefill 还额外分出一条 fwd_for_small_topk/ 路径(h_q=128 且 topk <= 1280 时选它,csrc/api/sparse_fwd.h:225)。这条路径还兼做 decode:h_q=128、d_qk=512(即 V4 Pro 的形状)的稀疏 decode 在 SM100 上调用的正是它的 DecodeWithSplitKV 模式(csrc/api/sparse_decode.h:179)。这些都是可以直接 ls 出来的事实。
性能数字(引自 FlashMLA README 的 Performance 一节,H800 SXM5 + CUDA 12.8;注意两行稀疏数字是 V3.2 的 DSA 内核在发布时公布的,dense 一行是 V3 的 dense MLA 内核,V4 路径没有单独公布过 benchmark):
| 内核 | H800 SXM5 | B200 |
|---|---|---|
| Sparse MLA decoding(FP8 KV cache,矩阵乘走 BF16) | 410 TFLOPS | 350 TFlops(README 注明"尚未真正优化") |
| Sparse MLA prefill | 640 TFlops | 1450 TFlops(CUDA 12.9) |
| Dense MLA decoding | 3000 GB/s(访存受限)/ 660 TFLOPS(算力受限) | — |
这张表里有个容易看反的地方:B200 的稀疏 decode(350)比 H800(410)还低,README 直接写了原因——那条路径还没优化。所以"新卡一定更快"在这里不成立;真正拉开差距的是 prefill(640 → 1450)。本专栏不为 V4 编造任何未公开的吞吐数字。
5.8 V4 sparse_attn 与 FlashAttention v3 的对比
FlashAttention v3 是 dense attention 在 H100 / B200 上的事实标准。把 V4 的 sparse_attn 与 FA3 横向对比:
| 维度 | FlashAttention v3 | V4 sparse_attn |
|---|---|---|
| KV 访问模式 | 连续 | 索引 gather |
| 支持的 head_dim | 最大 256 | FlashMLA 只接受 d_qk ∈ {512, 576}、d_v = 512(源码 TORCH_CHECK);参考实现是符号维度,不限死 |
| 支持的稀疏度 | 不支持 | top-K |
| 是否支持 sink | 不支持 | 支持 |
| 是否支持 grouped O | 不支持(O 投影外置) | 不支持(O 投影也外置) |
| 主要应用场景 | dense attention 全部模型 | V3.2(DSA)+ V4 |
| 长上下文成本 | O(n²) FLOPs | O(n) FLOPs (K 固定) |
V4 的 sparse_attn 不能替代 FA3——它是为 V4 这种"超大 head_dim + 稀疏选取 + sink"的特定形态量身定做的。反过来 FA3 也不能跑 V4——FA3 的 dense KV 假设与 V4 的 topk_idxs 接口完全不兼容。
这种"特化 kernel"的代价是 V4 必须自带一套内核——要么直接用官方那份 TileLang 参考实现,要么接 FlashMLA。从 FlashMLA README 的数字看,同一张 H800 上稀疏 decode 是 410 TFLOPS、dense decode 在算力受限配置下是 660 TFLOPS:稀疏路径的硬件利用率更低(两者不是同一个 kernel、KV 格式也不同,稀疏路径多了 gather 和 FP8 反量化,README 没有拆分原因),但它只需要算 1152 个位置而 dense 要算全部——总账仍然是稀疏赢。
5.9 vLLM / SGLang 集成 sparse_attn 的工程接缝
把 sparse_attn 集成进 vLLM / SGLang 这类推理引擎,至少要做四件事:
事项一:确定内核来源
有两条路:直接把官方那份 TileLang 参考实现拉进依赖(好处是跨架构、坏处是性能与手写 CUDA 有差距),或者接 FlashMLA(性能好,但只有 SM90 / SM100,且需要 CUDA 12.8+、SM100 还要 12.9+)。选后者的话还要处理"用户硬件不在支持矩阵内"的回退——FlashMLA 自己不提供回退,TORCH_CHECK 会直接抛错。
事项二:传 topk_idxs 给 kernel
引擎需要在每次 forward 时为每个 attention layer 计算 topk_idxs——这意味着引擎要 invoke Indexer,传 query / 中间表示给 Indexer,再把 Indexer 的输出送给 sparse_attn。这个 dataflow 在 vLLM / SGLang 之前的代码里完全不存在——必须新增。
事项三:KV cache 形状改造
vLLM 默认的 PagedAttention KV cache 形状是 [block, block_size, head, dim]。V4 的 KV cache 是 [B, window + n/ratio, dim]——不分 head(MQA-style),不按 block 切。集成时要么扩展 PagedAttention 的形状抽象,要么新增一个"V4-style KV cache" 类型。第 19 章 §19.3 会指出这块该在哪里动刀——那一节写的是推断,不是已存在的 PR。
事项四:与调度器的协调
V4 的 prefill / decode 走不同 codepath,且 prefill 有"压缩 KV 一次性算"的批量步骤。vLLM 的调度器需要识别"这是 V4 模型",分别调度 prefill 和 decode 阶段,避免把它们错误合并到同一个 CUDA stream。
第 19 章 §19.3 会把 vLLM 侧需要动刀的位置逐条列出(写作时 vLLM 尚无 V4 支持,那一节是推断而非已有 PR)。本章只到这里——给读者建立"sparse_attn 需要外部生态怎么配合"的全局认知。
5.10 动手实验:跑通 FlashMLA 的 V4 测试
# 1. 拉取 FlashMLA(注意有 submodule)
git clone https://github.com/deepseek-ai/FlashMLA.git flash-mla
cd flash-mla
git submodule update --init --recursive
# 2. 编译(README 要求:SM90 / SM100,CUDA 12.8+,SM100 内核需要 CUDA 12.9+)
pip install -v .
# 3. 跑稀疏路径的测试(仓库里真实存在的两个文件)
python tests/test_flash_mla_sparse_decoding.py
python tests/test_flash_mla_sparse_prefill.py
# 4. 性能基准
python benchmark/bench_flash_mla.py
没有 SM90 / SM100 就跑不了——csrc/api/sparse_fwd.h 里第一件事就是
TORCH_CHECK(is_sm90a || is_sm100f, "Sparse Attention Forward Kernel is only supported on SM90a and SM100f architectures.")。
A100(SM80)不在支持矩阵里,仓库也没有 Triton 回退。
想读懂 V4 那条路径,最省事的入口不是跑 benchmark,而是 grep:
grep -rn "MODEL1" csrc/ | head # V4 路径的所有分叉点
sed -n '30,45p' csrc/sm100/decode/head64/config.h # 512 / 448 / 64 / 8 那组常量
把它和 inference/config.json 的 head_dim / rope_head_dim 并排看,比任何 TFlops 数字都更能说明"这套内核到底是给谁写的"。
5.11 sparse_attn 的成本模型:什么随 n 变、什么不变
本专栏不给未经实测的加速倍数。但成本模型是可以从源码直接读出来的,而它比任何倍数都更有用——它告诉你上下文变长时,哪一项会涨、哪一项不涨。
把一个 ratio=4 层里与上下文长度 有关的项列全:
| 项 | 复杂度 | 1M 下的绝对值(每 query token 的乘加) |
|---|---|---|
Indexer 打分(index_score einsum) |
~2147M —— 唯一随 增长的大项 | |
| Compressor 的 wkv / wgate | (逐 token 恒定) | ~18M |
sparse_attn 的 QK + PV |
~151M —— 与 无关 | |
| Q / O 投影 | ~296M |
三条能直接用来做容量规划的结论:
ratio=4层上sparse_attn本身的成本与上下文无关。 无论 8K 还是 1M,每个 query token 都只看128 + 1024 = 1152个位置(ratio=128的层要看全部n/128条压缩 KV,仍随 增长,只是斜率小得多)。这是 V4 敢把max_position_embeddings写到 1048576 的底气。- 随 增长的是 Indexer,不是 attention。 想继续往上扩上下文,要动的是打分那一环(比如分层筛选),而不是 kernel。
- 短上下文下 V4 的架构红利拿不到。 8K 时 Indexer 只有 ~16M 乘加、
sparse_attn仍然是 151M(上下文不到 4K 时还会因为min(index_topk, end_pos // ratio)而更少),但 Compressor / Indexer / gather 这些固定开销一分不少。第 3 章 §3.15 按结构推演过:V4 的甜区在 64K 以上(本专栏的估计,不是实测)。
至于稀疏比例:1M context、ratio=4 的层里候选是 262144 条,选 1024 条,稀疏比 0.39%。index_topk 如果调大,多出来的成本只落在上面那张表的第三行(QK + PV 与 topk 成正比),相对 2147M 的打分开销几乎看不见——这也说明首发当天(04-24)把 index_topk 从 512 翻成 1024 在推理算力上代价不大。
5.12 判断 sparse_attn 是不是瓶颈
生产里最常见的问题不是"sparse_attn 有多快",而是"它是不是当前的瓶颈"。这个判断不需要任何厂商数字,只需要把两件事对上:
第一件:这一层到底是算力受限还是访存受限。
decode 阶段每条序列每层要读 1152 条 × 512 维 × 1 字节(FP8 KV,按每维 1 字节粗算;FlashMLA 的 MODEL1 格式每条实为 584 字节:448 字节 FP8 + 64 维 BF16 RoPE + 8 字节 scale,见 tests/quant.py:56,合约 673 KB,量级不变)≈ 590 KB,算 M 次乘加(约 302 MFLOP)。算术强度 ≈ 512 FLOP/Byte。
这个数字之所以这么高,正是 MQA 的功劳:128 个 query head 共用同一份 KV,KV 只读一遍却被用了 128 次。它落在 H800 的机器平衡点(FlashMLA 稀疏 decode 的矩阵乘走 BF16,所以看 BF16 峰值算力 ÷ HBM 带宽,量级约几百 FLOP/Byte)附近——也就是说 sparse_attn 的 decode 既可能算力受限也可能访存受限,取决于 batch、topk 和实际达到的带宽。FlashMLA README 给 dense decode 同时报 "3000 GB/s(访存受限)" 和 "660 TFLOPS(算力受限)" 两个数,就是这个原因:同一个 kernel 在不同配置下落在不同的受限区,报单一数字没有意义。
实际含义:调优前先测出你这档配置落在哪一侧。落在访存侧就去看 KV 的存储精度和 gather 的局部性,落在算力侧才去看 tile 配置和 TensorCore 利用率——两边的优化手段完全不同。
prefill 阶段则相反:一次算 个 query,KV 被复用,算力占比大幅上升——所以 FlashMLA 基本把 prefill 和 decode 拆成了两套独立的 kernel(csrc/*/prefill/sparse/ 与 csrc/sm90/decode/sparse_fp8/、csrc/sm100/decode/head64/);例外是 SM100 的 fwd_for_small_topk/head128,同一份模板按 SparseAttnFwdMode 实例化出 prefill 与 split-KV decode 两个版本。
第二件:它前面还有一个串行依赖。
Indexer 必须先输出 topk_idxs,sparse_attn 才能开始(inference/model.py:511 → 514 → 528)。§4.6 算过,1M 下 Indexer 的算力是这一层 attention 的四倍多。所以如果你观测到"attention 段很慢",先排除是不是 Indexer 慢——两者在 profile 里挨着,很容易归错账。
排查顺序:GPU 利用率 → 是 Indexer 还是 sparse_attn → 访存还是算力 → SMEM / tile 配置。第一步利用率就明显偏低的话,问题多半根本不在 attention 上,而在数据加载、通信或 Python 侧调度。
5.13 sparse_attn 的"工程债务"清单
任何工业级 kernel 都有它的工程债务——为快速发布而留下的"以后再优化" 项。FlashMLA 的稀疏 MLA kernel 也有。把可观察到的工程债务列出来:
债务 1:一个仓库里塞了 2 个模型 × 2 个阶段 × 2 个架构的笛卡尔积
FlashMLA 同时维护 dense(V3)与 sparse(V3.2 的 DSA、V4 的 MODEL1)两条路径,每条又分 prefill / decode,每个又分 SM90 / SM100。instantiations/ 目录里那一堆 model1_persistent_h128.cu / v32_persistent_h64.cu / phase1_k512.cu / phase1_k576.cu 就是这个笛卡尔积的显式展开——每个组合单独编译一次。好处是每种配置都能被独立特化到极致,代价是编译时间和二进制体积随组合数爆炸,以及支持矩阵里那几个空格(Dense Decoding 只有 SM90、Dense Prefill 只有 SM100)。
债务 2:SM80(A100)路径直接缺席
FlashMLA 的 README 支持矩阵里只有 SM90 / SM100,csrc/api/sparse_fwd.h 里的 TORCH_CHECK(is_sm90a || is_sm100f, ...) 把这件事写死成了运行时断言——不是"性能差",是根本跑不了。A100 上想跑 V4 的 sparse_attn,只能退回官方那份 TileLang 参考实现,或者自己写一份。
债务 3:稀疏度是"半动态"的
index_topk 写在 config.json 里,改它不需要重编内核——FlashMLA 的 sparse_attn_prefill_interface 是从 indices.size(2) 读 topk 的,参考实现里 topk 更是 T.symbolic("topk")(inference/kernel.py:284)。但 Q/K 的 head dim 不行(内核侧叫 d_qk,取的就是 q.size(2),csrc/api/sparse_fwd.h:127):同文件 :131 的 TORCH_CHECK(d_qk == 576 || d_qk == 512, "Invalid d_qk: ", d_qk) 只认这两个值(576 = V3.2、512 = V4),下一行 d_v 更是硬性 512。能调的是选多少条,不能调的是每条多宽。
这里要把 pin 版与 V4 的 index_topk=1024(首发当天 04-24 就已改成 1024)的关系说清楚。按 71c7379 的源码判断(本机无 GPU,未实跑):推理前向只从 indices 读 topk,不设取值白名单,只要求它是分块的整数倍(SM90 prefill 要求 128 的倍数,csrc/sm90/prefill/sparse/phase1.cuh:577;其余路径是 64 或 128 的倍数),V4 ratio=4 层的 128 + 1024 = 1152 条都满足。pin 版没有的是另一个参数 indexer_topk——让 prefill kernel 额外输出前 indexer_topk 条的 lse_indexer,提交标题称之为 "DSA backward indexer LSE",是为训练侧准备的。它在 pin 之后才出现,而且都在 nv_dev 分支上:04-27 的 13d173a 加入时只接受 0 / 512 / 2048,INDEXER_TOPK=1024 要到 04-30 的 b7643bd 才支持。换句话说,本章核对的 pin 版能接 V4 形状的推理调用,但没有为 V4 的 1024 准备这套训练用的 LSE 实例化。
债务 4:调度元数据要手工管理
flash_mla_with_kvcache(flash_mla/flash_mla_interface.py:53)需要先调 get_mla_metadata 拿一个 FlashMLASchedMeta,而这个 meta 的复用条件写在 docstring 里(同文件 :80)且相当苛刻——"只有当张量形状以及 cache_seqlens / topk_length / extra_topk_length 的值都不变时才能跨次复用"。在 continuous batching 这种每步都在变的场景里,这个约束意味着引擎侧必须自己判断什么时候要重建 meta,判断错了就是静默的错误结果。
债务 5:错误诊断信息有限
如果 sparse_attn 跑出错(如 decode 侧 topk_idxs 的 page block 编码错了),错误信息通常是 CUDA error,不直接指向问题位置。FlashMLA 缺一套 debug build——开启后可以打印每个 tile 的状态。这是开源项目的常见短板。
理解这些债务让你在使用 FlashMLA 时心里有底——遇到问题时知道去哪里查、知道哪些限制是"暂时的",避免被"看似不一致的现象" 困惑。
5.14 sparse_attn 工程师速记
部署或调试 V4 sparse_attn 时最常用的几条速记规则。打印一张贴在工位上:
速记 1:版本要求(FlashMLA README 的 Requirements 一节)
- SM90 / SM100;CUDA 12.8+(SM100 内核要 12.9+);PyTorch 2.0+
- A100(SM80)不在支持矩阵内,
TORCH_CHECK会直接拦下
速记 2:官方公布过的性能数字(FlashMLA README,H800 SXM5 + CUDA 12.8)
- 稀疏 MLA decoding:410 TFLOPS
- 稀疏 MLA prefill:640 TFlops(B200 + CUDA 12.9 上 1450)
- dense MLA decoding:访存受限 3000 GB/s,算力受限 660 TFLOPS
- 稀疏两条是 V3.2 的 DSA 内核数字,dense 一条是 V3 的 dense MLA 内核;V4 路径(
ModelType::MODEL1)没有单独公布过
速记 3:典型形状参数
topk_idxs:ratio=4层上最多[B, S, 1152](=window_size128 +index_topk1024),int32,-1 为无效- KV cache:每层
[B, window_size + max_seq_len // ratio, 512] - 单序列 KV cache 总和:BF16 下 ~8.3 GB、混合格式(RoPE 64 维 BF16 + 其余 FP8,不计 scale)下 ~4.7 GB(1M context,不含 Indexer 缓存,见 §3.10)
速记 4:常见错误信号
Sparse Attention Forward Kernel is only supported on SM90a and SM100f architectures.(prefill,csrc/api/sparse_fwd.h:115)/Unsupported architecture for sparse decode fwd(decode,csrc/api/sparse_decode.h:380):GPU 架构不在支持矩阵内topk (...) mod B_TOPK (...) must be 0(如csrc/sm100/decode/head64/kernel.cuh:859):topk 不是分块大小的整数倍- 输出 NaN:先查 attn_sink 等权重是否加载(
attn_sink由torch.empty创建,inference/model.py:456,没加载就是未初始化内存)
速记 5:优化优先级
- 检查 GPU 利用率(nvidia-smi)—— 明显偏低多半是 IO/通信瓶颈,sparse_attn 不是元凶
- 检查 tile size 与 SMEM 余量
- 检查 stream 配置(DeepGEMM 与 sparse_attn 是否在同一 stream 串行)
速记 6:与 vLLM 集成的关键文件
vllm/attention/backends/flashmla.py(MLA 的 attention backend;注意文件名没有下划线)vllm/model_executor/models/deepseek_v2.py(DeepSeek 系列的 model class。本专栏锁定的 vLLM 0.8.5 里没有 deepseek_v4.py——这个文件同时服务 V2 与 V3:DeepseekV3ForCausalLM继承自DeepseekV2ForCausalLM,registry.py 里两个名字都映射到它。V4 要落到 vLLM, 按同样的方式扩展这里)vllm/distributed/device_communicators/(设备间通信器所在目录;0.8.5 里还没有 DeepEP 集成,第 16 章讨论接入方式)
5.15 两份实现的分工:TileLang 参考实现 vs FlashMLA
V4 这一条稀疏注意力路径上,同时存在两份实现。它们不是"新旧版本"的关系,而是两种不同的工程目的:
| 维度 | inference/kernel.py 的 TileLang 版 |
FlashMLA 的 CUDA 版 |
|---|---|---|
| 位置 | V4 模型仓库自带,536 行整个文件 | 独立仓库,csrc/ 下上万行 |
| 写法 | @tilelang.jit + T.Kernel / T.gemm / T.Parallel |
CUDA C++ + CUTLASS + PTX 内联 |
| 分块 | block=64、threads=256、num_stages=2,写死 |
按架构 / 模型 / head 数展开成一堆 instantiations/*.cu |
| KV 精度 | BF16 进 kernel(QAT 模拟量化在 Python 侧做完) | decode 路径原生吃 FP8 KV cache,dequant 在 kernel 内 |
| prefill / decode | 同一个 kernel,靠 topk 的符号维度自适应 |
基本拆成两套 kernel、两套目录(SM100 的 fwd_for_small_topk/head128 兼做两者) |
| split-KV | 没有 | 有,splitkv_mla.cuh + 独立的 combine.cu |
| 支持架构 | 凡是 TileLang 能编的 CUDA 卡 | 只有 SM90 / SM100 |
| 目的 | 把语义讲清楚、让人能读、让结果可复现 | 把这台机器榨干 |
对读者的实际建议:先读 TileLang 那份。它把"gather → GEMM → online softmax → sink → 归一化"这条链压在 80 行里,每一步都能对上第 2-4 章讲过的数学;把它读透之后再去看 FlashMLA,你会发现后者多出来的那上万行几乎全部在解决同一件事——怎么让数据搬运不成为瓶颈(TMA、split-KV、persistent kernel、FP8 KV cache、nope/rope 分离搬运),算法本身一行没变。
这也是本专栏反复强调的一点:算法的复杂度在论文里,工程的复杂度在访存里。
5.16 sparse_attn 与"动态批处理(continuous batching)"的协同
vLLM 的核心调度优化是"continuous batching"——多个请求的 prompt / decode 共享 GPU 跑在一个 batch 里。V4 的 sparse_attn 与这套调度有几个微妙的协同点。
协同点 1:每请求独立的 topk_idxs
continuous batching 把多个请求的 query 拼到一起跑 attention。V4 的 sparse_attn 接受 [B, S, K] 的 topk_idxs——每条 sequence 有独立的稀疏选择。
协同点 2:长短 prompt 共存
某些请求是 64K context、某些是 1K——一起 batch 时长 prompt 的 KV cache 占大头,但 sparse_attn 的计算仍可控(ratio=4 层 K 最多 1152)。这是 V4 在 mixed workload 下的红利。
协同点 3:prefill / decode 的混合 batching
vLLM 的 chunked prefill 让 prefill 与 decode 在同一 batch 内跑。V4 的 sparse_attn 需要分别处理两种 phase——prefill 走批量 codepath,decode 走增量 codepath。kernel 需要支持"同一 batch 内同时跑两种 codepath"。
协同点 4:prefix caching 与稀疏 KV
vLLM 的 prefix caching 复用相同前缀的 KV——多个请求共享 KV cache 的某部分。V4 的稀疏 KV 让这种复用更复杂——压缩 KV 段可以共享,滑窗段每请求独立。详见第 19 章关于 SGLang RadixAttention 的讨论。
协同点 5:抢占(preemption)
vLLM 的调度器会抢占 / 恢复请求——把"被抢占请求的 KV cache" 暂存到 CPU 后再恢复。V4 的稀疏 KV 要支持这种 swap,swap 单位会变成"滑窗段 + 压缩段"两块,不再是单一 KV block(本专栏的推断,锁定的 vLLM 0.8.5 尚无 V4 支持)。
5.17 延伸阅读
- FlashAttention v3(arXiv:2407.08608):dense attention 的 SM90 实现
- Native Sparse Attention(arXiv:2502.11089):稀疏 attention 训练的理论
- DeepSeek FlashMLA 仓库(
github.com/deepseek-ai/FlashMLA):本章 §5.3、§5.6-§5.8、§5.13-§5.15 的一手依据,核对的是71c7379(2026-03-31);README 的 Performance 一节是本章唯一引用的性能数字来源 - V4 仓库自带的
inference/kernel.py(536 行 TileLang):本章 §5.1-§5.5 的一手依据 - TileLang 项目:理解
T.Kernel/T.gemm/T.Pipelined这套 DSL 的语义 - 《vLLM 推理内核深度解析》专栏第 4-5 章:PagedAttention 与 V4 的 KV cache 对接
- 本专栏第 19 章 §19.3:vLLM 适配 V4 要动哪些地方(推断,非已有 PR)
5.18 本章小结
- V4 的
sparse_attn在 PyTorch 是一行调用,官方参考实现是inference/kernel.py里 80 行 TileLang——不是 CUDA C++,也不依赖 FlashMLA - 但 FlashMLA(
71c7379,V4 发布前近一个月)里已经有 V4 的路径:enum class ModelType { V32, MODEL1 }的MODEL1分支,其D_Q=512 / D_NOPE=448 / D_ROPE=64 / QUANT_TILE_SIZE=64 / V_HAVE_ROPE=true与 V4 的 config 逐条吻合 attn_sink只进 softmax 的分母不进分子:参考实现在循环结束后补exp(sink - max),FlashMLA 不拆分时同样在最终归一化里补,走 split-KV 时在combine.cu里用lse += log2(1 + exp2(sink - lse)),三者等价- 官方公布过的性能数字全部来自 FlashMLA README 且属于 V3.2 的 DSA 内核(H800 上稀疏 decode 410 TFLOPS、稀疏 prefill 640 TFlops);V4 路径没有单独公布过 benchmark
ratio=4层上sparse_attn的成本与上下文长度无关(只看 1152 个位置),随 增长的是它上游的 Indexer- 集成进 vLLM / SGLang 需要四件事:内核依赖、传 topk_idxs、改 KV cache 形状、调度器协调
第 6 章我们离开 attention 内部,来到 V4 长上下文工程的另一支柱:YaRN RoPE——V4 预训练按 4K→16K→64K→1M 逐步扩展上下文,RoPE 怎样配合这一过程。