tilelang量化内核拆解:DeepSeek-V4.1-Flash-NVFP4的fp4_gemm与sparse_attn实现
DeepSeek-V4.1-Flash-NVFP4 是 NVIDIA 用 Model Optimizer 将 552B 参数的 MoE 多模态大模型量化到 NVFP4 格式的开源权重仓库,随附一套可直接运行的参考推理实现。它的核心正是 inference/kernel.py 里的一组 tilelang 量化内核,其中fp4_gemm(FP8×FP4 混合精度矩阵乘)与sparse_attn(稀疏注意力)最关键。本文不用大段代码,带你快速看懂这两个量化内核的设计思路与实现细节,并了解它们如何被模型主流程驱动。
📦 NVFP4 量化模型速览:552B 参数如何瘦身
先建立背景。DeepSeek-V4.1-Flash 原生是 MoE 架构(CED + 压缩稀疏注意力 CSA2,支持 1M 上下文),主干 552B 参数,prefill 阶段仅激活 8B、decode 阶段激活 16B,另带 196B 的 Engram 条件记忆。本仓库的量化要点:
| 项目 | 说明 |
|---|---|
| 量化格式 | NVFP4(W4A4:权重 FP4、激活 FP8),组大小 16/32 |
| 量化范围 | 384 个路由专家 × 40 层的w1/w2/w3投影 |
| Engram 表 | 从 FP8 转为 MXFP4(E2M1 两值打包 + 每 32 元素一个 UE8M0 scale) |
| 模型体积 | 约 476 GiB → 约 400 GiB,导出为 48 个 safetensors 分片 |
| 目标硬件 | NVIDIA Blackwell(实测 GB300) |
完整模型卡见 README.md,量化配置记录在 hf_quant_config.json 与 config.json 中。
🗺️ kernel.py 内核地图:一个文件看懂 6 个 tilelang 内核
整个推理内核只有约 600 行,全部集中在 inference/kernel.py。每个内核都遵循同一套模式:用@tilelang.jit编译、通过T.symbolic让 M 维度动态化、内部 FP32 计算、shared memory + fragment 两级缓冲。
| 内核 | 位置 | 职责 |
|---|---|---|
act_quant | kernel.py#L40-L124 | 按组 FP8 量化激活(支持原地反量化回 BF16) |
fp4_quant | kernel.py#L127-L204 | 按组 FP4 量化,E8M0/E4M3 scale 可选 |
fp8_gemm | kernel.py#L207-L307 | FP8×FP8 分块缩放 GEMM |
fp4_gemm | kernel.py#L477-L591 | FP8 激活 × FP4 权重混合精度 GEMM |
sparse_attn | kernel.py#L310-L403 | top-k 索引收集 + 在线 softmax 稀疏注意力 |
hc_split_sinkhorn | kernel.py#L406-L474 | Hyper-Connections 的双随机化混合矩阵 |
一个值得注意的工程细节:文件顶部通过pass_configs关闭了 warp specialization 与 TMA lower(见 kernel.py#L9-L12),保证内核在这类小规模块上稳定可编译。此外,fast_log2_ceil/fast_pow2两个工具函数(kernel.py#L22-L33)直接改写 IEEE 754 位模式来计算 2 的幂,避免慢速的 log/ceil 指令——这正是 E8M0 scale(只能取 2 的幂)快速取整的关键。
⚡ fp4_gemm 实现拆解:FP8 × FP4 混合精度 GEMM
数据布局:权重为什么占一半空间
fp4_gemm计算C[M,N] = A_fp8[M,K] @ B_fp4[N,K]^T(kernel.py#L477-L559):
- A(激活):FP8 E4M3,沿 K 方向每 32 或 128 元素共享一个 scale(
act_block_size可配); - B(权重):FP4 E2M1,每 2 个值打包进 1 字节,存储形状是
[N, K//2],每 32 元素配一个 E8M0 scale(scales_b: [N, K//32]); - C(输出):BF16。
这就是 NVFP4 的精髓:权重每个数只花 4 bit,靠更细的 scale 粒度(1×32)补偿低精度带来的动态范围损失。
主循环:两个累加器是关键设计
内核切块为block_M=32, block_N=128, block_K=32,K 方向流水线 2 级。每轮迭代做四件事:
- 搬运:把 A 的 FP8 块和 B 的 FP4 块拷入 shared memory;
- 升精度:FP4 先转 FP32 再转 FP8(注释写明是"避免 C++ 重载歧义",kernel.py#L538-L540),这样就能复用 Tensor Core 的 FP8×FP8
T.gemm; - 缩放:每行激活 scale、每列权重 scale 取出;
- 两级累加:
C_local_accum += C_local * scale_a * scale_b。
为什么要C_local和C_local_accum两个累加器?因为 scale 是"每 32 个 K 元素一个",必须等一个 K 块的点积算完才能乘上对应 scale,再并入总累加器——若混在一个累加器里就无法做分块缩放。同时T.use_swizzle(panel_size=10)调整块调度顺序,提升 L2 缓存命中率。
激活侧的 FP8 量化由act_quant完成(kernel.py#L40-L95):每 32 个元素求 absmax,scale 取 2 的幂(round_scale路径走位操作快速取整),再 clamp 到 FP8 表示范围。inplace=True时做"量化+反量化"融合,用于 KV cache 原地写回——省一次显存往返。
🧩 sparse_attn 实现拆解:索引收集 + 在线 softmax
DeepSeek-V4.1-Flash 的稀疏注意力(CSA2)思路是:每个 query 只看滑动窗口内的原始 KV+indexer 选出的 top-k 个压缩 KV 位置,而不是全部历史。sparse_attn内核(kernel.py#L310-L389)把这个"按索引取 KV 再做注意力"一次完成:
- 并行粒度:每个 (batch, 序列位置) 一个线程块,256 线程。q 加载到 shared memory 后常驻,top-k 的 KV 索引
topk_idxs(int32,无效槽为 -1)分块(block=64)流水线收集; - 索引收集:
kv_shared[i, j] = idxs[i] != -1 ? kv[by, idxs[i], j] : 0,无效位置取 0,随后打分时置为-inf,softmax 后自然归零; - 在线 softmax:FlashAttention 式的 running max/sum。每来一批 KV 就更新
scores_max,旧的部分和acc_o乘上exp(旧max - 新max)修正、sum_exp同步缩放,避免物化完整注意力矩阵; - attn_sink:一个可学习的每头偏置
attn_sink,在最后并入sum_exp(相当于给"什么都不看"的默认槽一个可学权重); - 数值稳健:running max 初值取
-1e30而非-inf——若某行 top-k 全为 -1(无有效索引),exp(-inf - -inf)会产生 NaN;用有限下界则输出自然全零,与训练内核约定一致(kernel.py#L352-L355)。
Python 封装sparse_attn(kernel.py#L392-L403)还会把不足 16 的注意力头数补齐到 16 再调用、结束后裁掉,换取更规整的向量化宽度。
🔗 model.py 如何驱动这些量化内核
内核不是孤立的,inference/model.py 展示了它们在真实模型中的位置:
- GEMM 分发:
linear()(model.py#L181-L207)按权重 dtype 自动选路——FP4 权重 →act_quant+fp4_gemm;FP8 权重 →act_quant+fp8_gemm;其余走 PyTorch 原生。MoE 的路由专家(Expert,model.py#L830-L851)正是以 FP4 存储w1/w2/w3的; - 注意力:
Attention.forward(model.py#L765-L789)把滑动窗口 KV(FP8 原地量化,act_quant(..., True))与压缩 KV 的索引拼接后,一次sparse_attn调完两路 KV;压缩 KV 用 16 组 + E4M3 scale 的fp4_act_quant写缓存(model.py#L759-L761);indexer 的 q/k 则用 32 组 + E8M0——同一套量化内核,两种配置; - Engram 条件记忆:inference/engram.py 用压缩词表 + 素数桶哈希把 n-gram 查表结果门控进残差流,查找表即上文提到的 MXFP4 Engram 权重;
- Hyper-Connections:残差流以
hc_mult=4份并行副本传递,hc_split_sinkhorn内核负责把混合系数矩阵 Sinkhorn 迭代成双随机矩阵。
🚀 快速运行步骤:编译内核并自测
想亲手跑起来验证这些内核?四步即可(参考 inference/README.md):
# 1. 安装依赖(tilelang 会按需 JIT 编译内核) python -m pip install -r requirements.txt # 2. 将 HuggingFace 权重转换为按 TP rank 切分的检查点,专家权重转 FP4 python convert.py --hf-ckpt-path /path/to/ckpt --save-path /path/to/out \ --model-parallel 8 --expert-dtype fp4 --tokenizer-path /path/to/ckpt # 3. 运行示例(TXT 或 Harmony JSON 输入) INPUT_FILE=examples/example.txt ./run.sh # 4. 形状与内核连通性自测:构建小模型,跑 prefill + 22 步 decode, # 真实调用 dense-fp8 / MoE-fp4 内核 python model.py注意python model.py的自测权重是未初始化的,它验证的是张量形状与内核调用链路而非数值精度;生产部署建议按 README.md 用 vLLM/SGLang 在 Blackwell GPU 上服务化。
📝 小结:这套内核设计教给我们的
- 混合精度可以"降权不降算":
fp4_gemm把权重存成 4 bit,计算时升回 FP8 喂给 Tensor Core,精度损失由更细的 1×32 E8M0 scale 兜底; - scale 粒度决定累加结构:分块缩放天然要求"块内点积 → 乘 scale → 块外累加"的两级累加器模式;
- 稀疏注意力 = 索引收集 + 在线 softmax:
topk_idxs把"看哪些位置"变成纯数据,内核内用 running max/sum 保证 O(1) 额外显存; - 数值细节决定可用性:
-1e30替代-inf、FP4→FP8 必须过 FP32、位操作快速算 2 的幂——这些"不起眼"的代码正是参考实现最有价值的部分。
如果你想继续深入,建议按 inference/kernel.py → inference/model.py → inference/engram.py 的顺序通读,配合python model.py自测,即可完整复现这条 NVFP4 推理链路。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考