正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码完全拆解(七):TileLang 特化算子与双平台——一套接口如何同时落到 CUDA 与 Ascend
前六篇讲到哪里,这一篇补哪一块
DeepInfer 是 DeepSeek 官方开源的高性能推理引擎,这一系列沿它的源码逐模块拆解。前六篇已经把”引擎骨架”讲完了:
- (一)整体架构与模块全景:仓库地形、
entrypoints/llm.py的装配线、一次请求如何穿过引擎,以及 DeepInfer 相对 vLLM/SGLang/TensorRT-LLM 最有辨识度的六条设计。 - (二)异步调度与 ZOS:
Worker.execute_model_step的步编排、seqlens_q的 GPU→CPU 修正、运行时耗时估计器如何把实测耗时喂回调度器。 - (三)KV 内存与 SWA 有界重放:
cache_engine.py的两本块账、YoCo 截断的 mask/compact 双模式与等价性验证方法。 - (四)MoE 专家并行与 EPLB:logical/physical expert 的放置算法、冗余专家的取整规则与在线重排。
- (五)投机解码:DSpark 变长验证、DFlash 草稿头与置信度调度。
- (六)结构化输出与确定性:语法树异步编译、投机回滚与 batch-invariant。
这六篇里反复出现同一类动作:在 GPU 上把一批形状不规则的索引算出来、把一批 token 搬来搬去、把一批 logits 归约成 token。前六篇一直把它们当作”某个函数”一笔带过。这一篇把这层打开:DeepInfer 把这类算子集中放在 deepinfer/model_executor/layers/tilelang_ops/ 下,用 TileLang 写了一套自定义 kernel,而且同一套 Python 接口要同时落到 NVIDIA CUDA 与 华为 Ascend 两套后端上。
这一层有多少东西?tilelang_ops/ 顶层有 24 个模块,加上 cuda/ 与 asc/ 两个子目录,合计约 33 个文件、23145 行 kernel 代码。本篇不追求讲完每一个(那需要一整本书的篇幅),而是先把分发机制、模块分类、双平台对应关系三件事讲透,再挑几个最有代表性的 kernel 逐行拆解。
为什么推理引擎需要”自家的一套小算子”
通用框架留下的缝隙
先说清楚这一层存在的理由。一个推理引擎的主干计算(GEMM、Attention)已经被高度优化:DeepInfer 直接复用 DeepSeek 自家的几个成熟库——FlashMLA 做 MLA 注意力、DeepGEMM 做 FP8 矩阵乘、DeepEP 做 MoE 的 dispatch/combine。官方 README 把这份清单列得很明确:
Optimized kernels: FlashMLA, DeepGEMM, DeepEP, TileKernels, DeepSelect, and custom TileLang kernels.
最后那一项 “custom TileLang kernels” 指向的就是 deepinfer/model_executor/layers/tilelang_ops。前面几个是外部独立仓库,最后一个是引擎自带的一整套胶水 kernel。
缝隙出在主干之间。一次 decode step 里,除了那几个大 kernel(几十微秒到毫秒级),还有几十个小动作:
- 把本步要喂给模型的 token id 拼成扁平输入(包括上一步投机出的草稿 token);
- 修正每条序列的
seqlens_k(考虑上一步被接受了几个草稿 token); - 把每条序列的逻辑位置翻成物理块索引、拼出块表与 slot 映射;
- 把新算出的 K/V 按分页布局量化后写回 KV 池;
- 把分块的 KV 从池里读出来、反量化后交给 MLA;
- 对 logits 做温度缩放、top-k/top-p 过滤然后采样(含拒绝采样);
- 把结构化输出的 bitmask 应用到 logits 上;
- 把多模态图像 embedding 回填到占位 token 的位置。
这些动作单个只要几微秒,但有两个特点让它们不适合用 PyTorch 原生算子拼:
第一,形状极度不规则且动态。 每条序列本步要跑几个 token 不一样(普通解码是 1,投机解码是 1~7),每条序列的逻辑位置到物理块的映射不一样,MoE 每层每个 token 选中的专家不一样。用 PyTorch 拼这些,必然出现大量小 kernel launch(每个 launch 有固定开销)、大量中间张量分配(显存分配器压力)、以及 host 与 device 之间反复同步(每次同步几十微秒)。一个 decode step 本身可能只有几百微秒,这些开销占比能到两位数百分比。
第二,它们是索引算术,不是浮点算术。 真正难的不是算得快,而是算对:块边界、回滚、对齐、mask 这些逻辑一旦有一处索引偏差,输出就会静默错掉——不报错,只是结果变差。这类代码用 CUDA C++ 写出来很长很啰嗦,用 PyTorch 写出来又会引入新的同步点。
所以 DeepInfer 的选择是:用 TileLang 把这些小算子重写成融合 kernel,一个 kernel 里把”索引计算 + 数据搬运 + 计算”全做完。
TileLang 是什么,为什么选它
TileLang 是 tile 级 kernel DSL(官方仓库、官方文档),DeepInfer 依赖的是 tilelang==0.1.15(见 requirements.txt)。它的官方文档把编译流程描述得很清楚:

(图源:TileLang 官方文档 The Tile Language: A Brief Introduction)
关键在于这份描述与硬件后端解耦:从 IRModule 开始,TileLang 生成的是 “target-specific source code”,官方文档写的是 “C/CUDA/HIP/LLVM/…”,对目标平台的表述是 “tuned for the desired backends or GPU architectures (e.g., NVIDIA, AMD)“。文档首页的 API 索引里直接存在一整套 tilelang.ascend.* 模块(ascend/backend、ascend/codegen、ascend/language/dma、ascend/language/gemm_op 等),说明 Ascend 后端是 TileLang 的一等公民而不是外挂。
这正是 DeepInfer 能同时支持 “NVIDIA SM100 GPUs” 与 “Huawei Ascend 950 NPUs” 的技术基础。
TileLang 还提供了三个层次的编程接口,这个分层在本篇后面讲”为什么两个后端写法不同”时很重要:
| 层次 | 面向 | 提供的抽象 |
|---|---|---|
| Beginner(硬件无关) | 只关心计算逻辑 | 纯 tile 级描述,不管内存层次(官方标注尚未完全实现) |
| Developer(硬件感知 + Tile Library) | 了解 GPU 内存层次的开发者 | 预置的 tile 级原语与优化模式,不碰线程细节 |
| Expert(硬件感知 + 线程原语) | 需要精细控制的专家 | 直接给出线程原语与内存原语,可手工控制布局与同步 |
官方文档明确说这三种接口可以在同一个 kernel 里混用。这一点在 DeepInfer 的代码里体现得非常明显——下面会看到同一个 kernel 里既有 T.copy 这种 Tile Library 级原语,也有 T.Parallel 这种线程级描述。
分发机制:ops.py 如何把后端差异关进一个文件
先看整个子系统最核心的设计——它只有 38 行,但决定了 33 个 kernel 模块如何被调用。
1"""Kernel dispatcher: interface modules do2``from deepinfer.model_executor.layers.tilelang_ops import ops`` and call3``ops.xxx_tl``; backend selection happens only in this file.4
5Re-exports all ``xxx_tl`` factories of the selected backend.6"""7
8import importlib9import pkgutil10from types import ModuleType11from typing import Any12
13from deepinfer import PLATFORM14
15_pkg = importlib.import_module(f"{__package__}.{PLATFORM.tilelang_ops_backend}")16for _info in pkgutil.iter_modules(_pkg.__path__):17 _mod = importlib.import_module(f"{_pkg.__name__}.{_info.name}")18 for _name, _obj in vars(_mod).items():19 if _name.startswith("_") or isinstance(_obj, ModuleType) or not _name.endswith("_tl"):20 continue21 assert globals().get(_name, _obj) is _obj, f"backend symbol collision: {_name}"22 globals()[_name] = _obj23
24
25class KernelUnavailableError(AttributeError):26 """Selected backend lacks this kernel factory; conftest.py turns it into27 a test skip on non-cuda backends. Subclasses AttributeError to keep the28 module ``__getattr__`` / ``hasattr`` protocol intact."""逐点拆开:
(1)后端目录名来自平台对象。 PLATFORM.tilelang_ops_backend 是一个字符串,在 platform.py 里定义:PlatformGPU.tilelang_ops_backend = "cuda",NPU 平台对应 "asc"。ops.py 自己不知道有哪些后端,它只负责 importlib.import_module("...tilelang_ops.cuda") 或 "...tilelang_ops.asc"。新增一个后端只需要在 platform.py 里加一个类、在目录下加一个子包,ops.py 一行不改。
(2)发现是自动的。 pkgutil.iter_modules 遍历选定后端目录下的所有模块并逐个 import。这意味着”这个后端支持哪些 kernel”不是维护出来的清单,而是目录内容本身——加一个文件就多一个 kernel,删一个就少一个。
(3)命名约定就是接口契约。 筛选条件是 _name.endswith("_tl") 且不是下划线开头、不是模块对象。所以每个后端模块必须把自己对外的 kernel 工厂命名为 xxx_tl,其余符号(辅助函数、常量)自动被跳过。后缀 _tl 显然指 TileLang,它同时起两个作用:在调用侧一眼看出”这是 TileLang kernel 不是普通 PyTorch 函数”,在分发侧充当命名空间过滤。
(4)碰撞检测写在导入期。 assert globals().get(_name, _obj) is _obj, f"backend symbol collision: {_name}"。如果两个模块定义了同名 xxx_tl,导入时立刻断言失败并报出名字,而不是等到运行时某个调用静默走到错误的实现。这是一个很划算的设计:把”重名”这个低级错误从运行时的疑难杂症变成导入时的明确崩溃。
(5)缺失也有明确语义。 KernelUnavailableError 继承 AttributeError,注释点明了两个用途:一是保持 hasattr / __getattr__ 协议正常(属性不存在就该抛 AttributeError 的子类);二是让测试框架 conftest.py 能把”当前后端没这个 kernel”转成 skip 而不是 fail。这解释了为什么 asc/ 目录下模块数量与 cuda/ 不完全一致——Ascend 后端缺少某些 kernel 是预期状态,不是 bug。
调用侧因此长这样,完全没有平台分支:
1from deepinfer.model_executor.layers.tilelang_ops import ops2
3def some_layer_forward(...):4 ops.gather_input_ids_zos_tl(...) # CUDA 上走 cuda/gather_input_ids_zos.py5 ops.load_paged_kv_cache_tl(...) # Ascend 上走 asc/load_paged_kv_cache.py对比另一种常见写法——在每个调用点写 if platform.is_gpu: ... else: ...。那种写法的问题不只是丑:它把平台知识散布到几十个调用点,每加一个后端就要改几十处,而且容易漏。DeepInfer 的做法是把平台知识收敛到一个文件加一个目录结构,调用点只认 ops.xxx_tl 这一个契约。这是典型的”间接层换可维护性”。
模块分类:33 个 kernel 在干什么
打开后端目录(以 cuda/ 为例),模块可以按职责分成六族。下表按族列出代表模块与它解决的问题——这张表就是这一层的源码地图。
| 族 | 代表模块 | 解决的问题 |
|---|---|---|
| 输入准备 | gather_input_ids_zos.py、gather_engram_tokens_zos.py、gather.py、image_embed_ops.py | 按步把 token id 拼成扁平输入;记忆/外部表 gather;图像 embedding 回填占位位置 |
| KV 索引与元数据 | get_kv_index.py、kv_index_cat.py、prepare_v4_decode_metadata.py、compute_seqlens_k_zos.py、swa_index.py | 逻辑位置→物理块索引、块表拼接、注意力元数据构造、seqlens_k 修正、SWA 窗口索引展开 |
| KV 读写与量化 | load_paged_kv_cache.py、store_kv_cache.py、cache_common.py、compress.py、quant.py(cuda) / kv_quant.py(asc) | 分页 KV 的读写、FP8/FP4 量化编码与反量化、块边界与对齐处理 |
| MoE 与路由辅助 | candidate_blocks.py、sum_smaller_probs.py、outlier_detection.py | 专家路由的候选块组织、概率归约、离群值检测 |
| 采样与输出 | sampler.py、fused_sample.py、random_sampling.py、fused_transform.py、structure_output.py、norm.py | logits 变换、过滤、采样、结构化输出 bitmask 应用、归一化 |
| 投机与状态 | dflash.py、dflash_copy.py、schedule_mtp_varlen.py、store_mtp_info.py、state_cache.py、prepare_cp_mirror.py | 草稿头前向、变长验证调度、状态槽读写、CP 镜像准备 |
几个观察:
带 _zos 后缀的模块直接对应第二篇的零开销调度。 gather_input_ids_zos.py、gather_engram_tokens_zos.py、compute_seqlens_k_zos.py 这三个是”延后一拍”编排的直接产物——原本需要 CPU 先算好再喂给 GPU 的东西,现在改成 GPU 上自己算,从而把 CPU 后处理移出关键路径。后缀是它在代码里的标记。
fused_* 前缀是融合的信号。 fused_sample.py(CUDA 524 行 / Ascend 673 行)和 fused_transform.py(CUDA 619 行 / Ascend 899 行)是把”变换 + 过滤 + 采样”多个步骤塞进一个 kernel。这是小算子层最重要的价值来源:不是让每个小步骤更快,而是让多个小步骤共享一次 launch 和一次显存往返。
注意顶层模块与后端模块的分工。 structure_output.py、state_cache.py、schedule_mtp_varlen.py、gather_input_ids_zos.py 等文件在顶层也有一份,同时 cuda/ 下也有一份。看顶层那份的内容就明白了:它不是 kernel,而是Python 侧的封装函数 + 参考实现 + pytest 用例。以 schedule_mtp_varlen.py 为例,它定义了 compute_seqlens_q_by_confidence_rates()(调 ops.xxx_tl)、compute_seqlens_q_by_confidence_rates_ref()(纯 PyTorch 参考实现),以及参数化的 test_* 用例断言两者 torch.equal。顶层管”这个算子的语义是什么、怎么验证它正确”,后端目录管”在当前硬件上怎么把它算出来”。 这个分层贯穿整个 tilelang_ops/。
双平台对应关系:接口层与实现层怎么分家
先看目录结构本身传递的信息:
1tilelang_ops/2├── ops.py ← 分发器(唯二与平台相关的地方)3├── conftest.py ← 测试框架:GPU 绑定、benchmark 基线配置4├── common.py ← 共享:量化常量与数学辅助5├── cache_common.py ← 共享:KVCacheFormat 与 KV 布局定义6├── schedule_mtp_varlen.py ← 共享:算子语义 + 参考实现 + 测试7├── gather_input_ids_zos.py← 共享:同上8├── structure_output.py ← 共享:同上9├── ...(其余语义/测试模块)10├── cuda/ ← NVIDIA 后端实现(28 个模块)11│ ├── gather_input_ids_zos.py12│ ├── load_paged_kv_cache.py13│ ├── fused_sample.py14│ ├── quant.py ← CUDA 特有15│ └── ...16└── asc/ ← Ascend 后端实现(27 个模块)17 ├── gather_input_ids_zos.py18 ├── load_paged_kv_cache.py19 ├── fused_sample.py20 ├── kv_quant.py ← Ascend 特有21 ├── rstd_1ulp.py ← Ascend 特有22 └── ...两个后端目录的差异精确到三个文件:
1=== 只在 cuda/ 有 === === 只在 asc/ 有 ===2outlier_detection.py kv_quant.py3prepare_decode_metadata.py prepare_v4_decode_metadata.py4quant.py rstd_1ulp.py对上层的”接口模块”(比如模型里的某个 layer)来说,它 import 的是 ops,调用 ops.xxx_tl。它既不知道后端是 CUDA 还是 Ascend,也不需要知道。
但接口的粒度是关键设计决策。看行数对比:cuda/fused_sample.py 524 行,asc/fused_sample.py 673 行;cuda/fused_transform.py 619 行,asc/fused_transform.py 899 行。同名文件在两边的行数差 20%~45%,说明它们不是同一份代码的复制粘贴,而是各自按硬件的并行模型与 intrinsics 写了实现,只对外暴露同样的 _tl 工厂名和同样的参数签名。
对同一个算子 gather_input_ids_zos,两边的写法差异是理解这套抽象的最佳样本。CUDA 版:
1with T.Kernel(n_seq, threads=128) as seq_idx:2 T.pdl_sync()3 sq = seqlens_q[seq_idx]4 q_base = T.if_then_else(seq_idx == 0, 0, cu_seqlens_q[seq_idx - 1])5 pidx = remap[seq_idx]6
7 for j in T.Parallel(max_seqlen_q):8 if j < sq:9 if pidx >= 0:10 if j == 0:11 out_input_ids[q_base + j] = prev_sampled_token_ids[pidx]12 else:13 out_input_ids[q_base + j] = prev_predict_token_ids[pidx, j - 1]14 else:15 out_input_ids[q_base + j] = fresh_tokens[seq_idx, j]一个 block 一条序列(T.Kernel(n_seq, threads=128)),线程在 max_seqlen_q 上并行,直接读写全局内存。这依赖 GPU 的 SIMT 模型:几百个线程各自算自己的下标、各自访存,靠硬件做合并。
Ascend 版:
1num_cores = tile_kernels.get_num_vec_cores()2BR = 83BS = 84threads = BR * BS5num_stages = 46ALIGN_I32 = 32 // 47out_len_aligned = T.ceildiv(BR * max_seqlen_q, ALIGN_I32) * ALIGN_I328
9with T.Kernel(num_cores) as pid:10 sq_ub = T.alloc_shared((ALIGN_I32,), "int32")11 remap_ub = T.alloc_shared((ALIGN_I32,), "int32")12 qbase_ub = T.alloc_shared((ALIGN_I32,), "int32")13 out_ub = T.alloc_shared((out_len_aligned,), "int32")14 T.annotate_buffer_versions({sq_ub: num_stages, remap_ub: num_stages, ...})15
16 for block in T.Persistent([T.ceildiv(n_seq, BR)], num_cores, pid, group_size=1, num_stages=num_stages):17 ...18 T.copy(seqlens_q[blk_start : blk_start + blk_len], sq_ub[:blk_len])19 ...20 with T.SimtVF(threads=threads):21 for j1 in T.serial(0, max_seqlen_q, BS):22 for i, j in T.Parallel(BR, BS):23 ...24 out_ub[out_off] = ...25 T.copy(out_ub[:blk_total], out_input_ids[block_qbase : block_qbase + blk_total])差异是结构性的,不是风格性的:
| 维度 | CUDA 版 | Ascend 版 |
|---|---|---|
| 并行单位 | T.Kernel(n_seq):一个 block 一条序列 | T.Persistent(...):num_cores 个核持久化,按 8 条序列一块轮转 |
| 中间数据 | 直接读写全局内存 | 先 T.copy 进 UB(Unified Buffer,T.alloc_shared)再计算 |
| 流水 | 靠硬件 warp 调度 | 显式 num_stages=4 + T.annotate_buffer_versions 做多缓冲 |
| 线程原语 | T.Parallel | T.SimtVF(SIMT 向量化前端)内嵌 T.Parallel |
| 对齐约束 | 隐式(硬件处理) | 显式 ALIGN_I32 = 32 // 4,输出缓冲区长度必须按 8 个 int32 对齐 |
为什么会差这么多? 因为两种硬件的存储层次不同。GPU 的 shared memory 是可选的优化手段,不显式管理也能跑(走 L1/L2);Ascend 的 AI Core 则要求计算前先把数据搬进 UB,这是硬约束,不搬就跑不起来。于是”多缓冲 + 分段拷贝”在 Ascend 版里成了必需,在 CUDA 版里可以省略。
这就是为什么接口必须停在”算法级”。 如果用更低的抽象统一两边(比如强制两边都写显式 UB 拷贝),CUDA 侧会白白损失性能;如果用更高的抽象(一份代码两端编译),UB 大小、核数、对齐这些硬件约束根本表达不出来。三种方案对比:
| 方案 | 做法 | 问题 |
|---|---|---|
| A. 抽象过高 | 写一份通用描述,编译器落两端 | 硬件约束(warp 数、UB 大小、对齐、量化格式)表达不出来 |
| B. 抽象过低 | 每后端独立一套代码,上层自己选 | 平台知识散布到所有调用点,新增后端成本极高 |
| C. 接口级统一(DeepInfer 的选择) | 上层统一走 ops,语义与参考实现共享,kernel 实现按后端各写 | 需维护同名文件,但平台差异被限制在后端目录内 |
DeepInfer 选了 C,证据有三处:ops.py 注释明确写 “backend selection happens only in this file”;common.py 的量化常量、cache_common.py 的 KVCacheFormat 放顶层共享;KernelUnavailableError 专门处理后端缺 kernel——如果两个后端必然一一对应,就不需要这套机制。
代价也很明确:同名文件必须行为一致。这正是顶层那些 *_ref 参考实现与 pytest 用例存在的意义:语义在顶层定义一次、验证一次,两端的 kernel 都要对得上。
逐 kernel 拆解:三类典型算子
下面挑三类最有代表性的 kernel,把”索引算术”到底难在哪讲清楚。选择标准是:它们分别对应第二篇(ZOS)、第三篇(KV 分页)、第五篇(投机解码)讲过的机制,正好把前几篇的抽象层落到具体代码。
类一:ZOS 的输入 gather 与 seqlens_k 修正
第二篇讲过 ZOS 的核心是”把 CPU 后处理移出关键路径”。最典型的例子就是上面那段 gather_input_ids_zos。
先看它的参数表,这比函数体更能说明问题:
1fresh_tokens # (n_seq, max_seqlen_q) int32 本步新算出的 token id2prev_sampled_token_ids# (prev_n_seq,) int32 上一步采样出的 token3prev_predict_token_ids# (prev_n_seq, nextn) int32 上一步投机预测的草稿 token4remap # (n_seq,) int32 -1 或上一步批次索引5seqlens_q # (n_seq,) int32 本步每条序列的 query 长度6cu_seqlens_q # (n_seq,) int32 累积长度7out_input_ids # (total_q,) int32 扁平化输出核心逻辑只有几行:
1pidx = remap[seq_idx]2if pidx >= 0: # 这条序列上一步存在,且投机解码开了3 if j == 0:4 out_input_ids[q_base + j] = prev_sampled_token_ids[pidx] # 第一个 token 是上一步采出来的5 else:6 out_input_ids[q_base + j] = prev_predict_token_ids[pidx, j-1] # 后面都是上一步草稿预测的7else:8 out_input_ids[q_base + j] = fresh_tokens[seq_idx, j] # 新序列,用现成的 token三个设计要点:
(1)元数据在 GPU 上自产自用。 常规做法是调度器在 CPU 上整理”这一步要跑哪些 token”,再拷贝给 GPU。这条路径上的”整理列表 + 拷贝”就是 CPU 关键路径开销。ZOS 反过来:CPU 只提供静态的、与批次无关的信息,哪些序列、每条多少 token、上一步的草稿 token 放哪,全部以张量形式直接传给 GPU,由 kernel 自己算下标。第二篇讲过的 seqlens_q GPU→CPU 修正路径正是这条思路的另一面——需要时也由 kernel 现算。
(2)remap 把”上一步”和”这一步”缝合起来。 remap[i] = pidx 表示当前第 i 条序列对应上一步批次里的第 pidx 条;-1 表示这是新序列。这一个数组就消掉了”调度器必须知道谁是谁”的耦合:kernel 不需要知道序列的全局身份,只需要一个从本步批次指向上步批次的映射。第二篇讲”延后一拍”时,正确性全靠这个映射维持。
(3)T.pdl_sync() 是重叠的落地形式。 每个 kernel 开头都有这行。PDL(Programmatic Dependent Launch)允许后续 kernel 在前一个 kernel 还没完全结束时就开始启动,靠 kernel 内的显式同步点(griddepcontrol.wait)来保证数据依赖。这里 T.pdl_sync() 就是那个同步点——本 kernel 要读的 seqlens_q / remap 等元数据可能正是上一个 kernel 刚写出来的。全 cuda/ 目录下有 28 个模块使用 pdl_sync,几乎是全量覆盖,说明这不是个别 kernel 的优化,而是整层的启动策略。
compute_seqlens_k_zos 是同一思路的另一半——修正 KV 侧的长度记账:
1with T.Kernel(T.ceildiv(n_seq, BLOCK_SIZE), threads=BLOCK_SIZE) as bx, T.Parallel(BLOCK_SIZE) as tx:2 sk = T.alloc_var("int32")3 seq_idx = bx * BLOCK_SIZE + tx4 T.pdl_sync()5 if seq_idx < n_seq:6 sk = seqlens_k[seq_idx] + seqlens_q[seq_idx]7 prev_idx = remap[seq_idx]8 if prev_idx != -1:9 sk = sk - nextn + prev_num_accepted_tokens[prev_idx]10 seqlens_k[seq_idx] = sk读法:本步的 KV 长度 = 上一步的 KV 长度 + 本步 query 长度。但如果这条序列上一步做过投机解码,上一步”以为”会新增 nextn 个 KV(草稿头一次写进去的),实际只接受了 prev_num_accepted_tokens 个,所以要减掉差额。这就是第五篇讲的”被拒绝草稿 token 的 KV 回滚”在长度记账上的体现——回滚不是撤销写入,而是修正账本,让后续写入覆盖掉多余位置。
注意这里用 T.ceildiv(n_seq, 128) 个 block 各管 128 条序列,是扁平的向量化写法,与 gather 那个”一 block 一序列”完全不同。同一个目录里两种线程组织并存,说明组织方式是按数据形状选的,不是统一模板。
类二:分页 KV 的读写——一个被参数化的 kernel
第三篇讲过 DeepInfer 的 KV 池与两本块账。落到 kernel 层,load_paged_kv_cache.py(382 行)与 store_kv_cache.py(380 行)是这一层的核心,它们的设计方式最能体现这套代码的工程思路:不是写很多个 kernel,而是写一个被参数化的 kernel。
看 load_paged_kv_cache_tl 的工厂签名:
1def load_paged_kv_cache_tl(2 layout: KVCacheFormat,3 block_size: int,4 fp4_value_block_stride: int = 0,5 fp4_value_stride: int = 0,6 fp4_scale_stride: int = 0,7) -> object:8 """Build a compact-range paged-cache load kernel from ``layout``."""layout 是 cache_common.py 里的 KVCacheFormat——一个 frozen dataclass,描述”一个 token 在 KV 缓存里的逻辑与物理内容”。以 V4.1 的实际配置为例:
1V41_FORMAT = KVCacheFormat(2 quantized_dtype="e4m3", # FP8 E4M33 quantized_elements=512, # 每个 token 512 个量化元素4 elements_per_sf=32, # 每 32 个元素共享一个 scale5 bf16_elements=0, # 不再支持 FP8/BF16 混合缓存6 do_cast=True, # 读取时需要反量化7 sf_format="ue8m0", # scale 用 UE8M0(8 位指数)8 note="V41, main/swa kv.",9 token_interleaved=True, # 每个 token 内部按 [数据 | scale] 交错存放10)11V41_FP4_FORMAT = KVCacheFormat(12 quantized_dtype="e2m1", # FP4 E2M113 quantized_elements=512,14 elements_per_sf=16,15 do_cast=True, sf_format="e4m3",16 note="V41, FP4 main kv.", token_interleaved=True,17)18MXFP8_INDEX_KV_FORMAT = KVCacheFormat(19 quantized_dtype="e4m3", quantized_elements=128,20 elements_per_sf=32, do_cast=False, # 索引缓存不反量化,直接当索引读21 sf_format="ue8m0", note="V41, index kv.",22)KVCacheFormat 的 __post_init__ 把大量约束写成了断言(不满足直接抛错),例如”量化和 BF16 不能混存”、“主缓存/SWA 缓存必须 token 交织”。这是把 KV 布局的规则从文档搬进代码——布局不对会在构造时就炸,而不是运行时算出错误结果。
基于这个参数,kernel 工厂在构建期就完成了分支选择。看 load_paged_kv_cache_tl 里这几行:
1is_quantized = layout.quantized_dtype is not None2is_index = is_quantized and not layout.do_cast3is_plain_bf16= not is_quantized4is_fp4_cast = layout.do_cast and layout.quantized_dtype == "e2m1"5
6vector_size = min(group_size, 32, value_token_alignment)7token_tile = block_size if is_index else min(block_size, 8 if is_plain_bf16 else 32)8threads = 128 if is_index and layout.quantized_dtype == "e4m3" and block_size == 32 else 256线程数、tile 大小、向量宽度都是根据 layout 在 Python 层算出来的常量,然后被 trace 进 kernel。这带来了两个直接收益:
- 没有运行时分支。 kernel 里不存在
if layout.is_fp4这类判断,所有分支在生成期就消解了。编译出的 kernel 针对这一种布局是”平的”,没有额外寄存器与指令开销。 - 一个算子覆盖一批布局。 FP8 主缓存、FP4 主缓存、FP8 索引缓存、FP4 索引缓存、纯 BF16,共用同一份 kernel 描述。这正是”KV 布局是编译期常量”这一设计的价值所在——第九篇讲 TensorRT-LLM KV 压缩时也提到类似思路,但那里的布局差异更多落在框架层。
再看 kernel 内部的寻址,它揭示了分页访存真正的难点:
1load_segments: T.Tensor((num_segments, 4), T.int32)2# 每一行 = (src_block_id, src_begin, src_end, dst_offset)3
4with T.Kernel(num_segments, num_token_tiles, threads=threads) as (segment_idx, token_tile_idx):5 T.pdl_sync()6 src_block_id = load_segments[segment_idx, 0]7 src_begin = load_segments[segment_idx, 1]8 src_end = load_segments[segment_idx, 2]9 dst_offset = load_segments[segment_idx, 3]10 ...11 for token_offset, group_chunk in T.Parallel(token_tile, valid_groups * channel_chunks):12 block_offset = token_base + token_offset13 if block_offset < block_size and src_begin <= block_offset and block_offset < src_end:14 dst = dst_offset + block_offset - src_begin15 ...16 if src_block_id == -1:17 for channel in T.vectorized(vector_size):18 kv[dst, channel_base + channel] = 0 # 无效块填 0关键在 load_segments 这个 (num_segments, 4) 的数组。分页 KV 的读取被预先切成了”段”:每段就是”从物理块 A 的 [src_begin, src_end) 区间,拷到输出的 dst_offset 起”。这样切的好处是:
- 段内是连续物理地址,可以向量化访存(
T.vectorized(vector_size))。分页布局下块内连续、块间不连续,所以必须按块边界把访问拆开——这就是前面说的”跨块拆分”。 - 段表本身由别的 kernel 或调度器产出,
T.pdl_sync()保证它已经可见。 src_block_id == -1表示无效块(比如序列还没填满、或页表里的空位),直接写 0,不需要额外判空分支。把”无效”编码进索引本身,比额外传一个 mask 数组更省带宽。
同样,store_kv_cache_tl 也接受 layout 与 block_size,并且在工厂层就算好了量化参数:
1if is_quantized:2 value_dim, value_dtype = quantized_elements, T.float4_e2m1fn3 if is_fp8:4 value_dtype = T.float8_e4m3fn5 valid_groups = (quantized_elements + layout.elements_per_sf - 1) // layout.elements_per_sf6 sf_dim = layout.sf_storage_elements7 sf_dtype = {"ue8m0": "uint8", "e4m3": "float8_e4m3fn", "fp32": "float32"}[layout.sf_format]8 blk, threads = min(max(4096 // input_dim, 1), 16), 649else:10 value_dim, value_dtype = input_dim, "bfloat16"11 blk, threads = max(4096 // input_dim, 1), 256注意 blk, threads 的取值逻辑:量化路径是 min(max(4096 // input_dim, 1), 16) 配 64 线程,非量化路径是 max(4096 // input_dim, 1) 配 256 线程。这是按”每个线程处理的目标元素数”反推线程规模——量化后一个元素只占 4 或 8 位,同样的字节预算能装更多元素,线程组织也必须跟着变。
写入侧的量化是就地完成的。store_kv_cache.py 里有一个 T.macro:
1@T.macro2def _amax_to_ue8m0_scales(blk, groups, amax, scale_local, sf_ue8m0):3 """Convert bf16 amax values to fp8 quant multipliers and ue8m0 scales."""4 for i, j in T.Parallel(blk, groups):5 scale = T.max(T.cast(amax[i, j], "float32"), FP8_AMAX_MARGIN) / FLOAT8E4M3_MAX6 exp_scale = ue8m0_exp_scale(scale)7 scale_local[i, j] = T.reinterpret("float32", (exp_scale + 127) << 23)8 sf_ue8m0[i, j] = T.cast(-exp_scale + 127, "uint8")逐行读:先取该组的 amax(绝对值最大),下限钳到 FP8_AMAX_MARGIN = 1e-4(防止全零组产生除零或 scale 溢出),除以 FLOAT8E4M3_MAX = 448.0 得到缩放因子;再用 ue8m0_exp_scale 把它折成纯指数形式;最后 (exp_scale + 127) << 23 是把指数直接拼成 float32 的位模式(IEEE 754 的指数域在 bit 23~30),省掉浮点乘法,直接用位运算做缩放。sf_ue8m0[i,j] = -exp_scale + 127 则是同一个指数的取反映射,写成 uint8 存进缓存——反量化时用它把数据乘回去。
T.macro 本身值得注意:它是 TileLang 的编译期展开原语,在 trace 阶段把这段代码内联进调用点,而不是生成一个函数调用。所以这些”看起来像函数”的东西在生成代码里是展开的,没有调用开销。
关于量化常量,common.py 里还有一组:
1FP4_AMAX_MARGIN = 6.0 * (2**-126)2FLOAT4_E2M1_MAX = 6.03FP8_AMAX_MARGIN = 1e-44FLOAT8E4M3_MAX = 448.05FLOAT8E4M3_MIN_POSITIVE = 2**-9FLOAT4_E2M1_MAX = 6.0 和 FLOAT8E4M3_MAX = 448.0 分别是两种格式的可表示最大值(E2M1 的绝对值最大是 6,E4M3 最大是 448);FLOAT8E4M3_MIN_POSITIVE = 2^-9 是最小正规格化数——这三个数在量化与反量化里反复出现,所以集中定义。
类三:投机验证的调度与草稿搬运
第五篇讲过 DSpark 的变长验证与 DFlash 草稿头。变长验证在 kernel 层的难点是”每一行要验证的 token 数不同”,这正好是 GPU 不擅长的形状。
schedule_mtp_varlen.py 解决的就是这件事。它顶层的封装函数签名与文档字符串把语义讲得很清楚:
1def compute_seqlens_q_by_confidence_rates(2 total_q: int,3 remap: torch.Tensor, # [num_seqs] int32,上一步批次索引或 -14 prev_confidence_rates: torch.Tensor, # [n_prev_seq, nextn] float32,上一步 MTP 头的逐位置置信度5 seqlens_q: torch.Tensor, # [num_seqs] int32 (out),本步每条序列的 query 长度6) -> None:7 """Distribute total_q tokens across sequences based on MTP confidence rates.8
9 Fuses remap scatter: confidence_rates[i] = prev_confidence_rates[remap[i]] if remapped else 0.0.10 """也就是说:本步总共能验证 total_q 个 token,怎么在序列之间分配,由每条序列上一步的草稿置信度决定。看它的 PyTorch 参考实现,算法是两段:
1# 阶段 1:按 remap 把上一步置信度捞过来(新序列视为 0),再沿 nextn 维做累积乘积2mask = remap != -13safe_remap = remap.clamp(min=0)4gathered_rates = prev_confidence_rates[safe_remap]5gathered_rates = torch.where(mask.unsqueeze(1), gathered_rates, torch.zeros_like(gathered_rates))6cum_rates = torch.cumprod(gathered_rates, dim=1) # [num_seqs, nextn]7
8# 阶段 2:把 (序列, 位置) 全部展平,按累积置信度全局排序,前 extra_budget 名获胜9extra_budget = total_q - num_seqs # 每条序列先保底 1 个 token10flat_rates = cum_rates.reshape(-1)11sorted_indices = torch.argsort(flat_rates, descending=True, stable=True)12ranks = torch.empty(len(flat_rates), device=...); ranks[sorted_indices] = torch.arange(...)13wins = (ranks.reshape(num_seqs, nextn) < extra_budget).to(torch.int32).sum(dim=1)14seqlens_q.copy_(1 + wins) # 每条序列 = 1 个保底 + 赢到的名额三个设计要点:
(1)cumprod 而不是直接用单点置信度。 要验证第 k 个草稿 token,前面 k−1 个必须先被接受,所以第 k 位的”有效价值”是前 k 个位置置信度的连乘。用累积乘积表达”越靠后的草稿越可能白跑”,这是投机解码深度控制的核心直觉(第五篇讲的投机深度控制在调度侧的落点就在这里)。
(2)全局排序 + 排名阈值,而不是逐序列独立决定。 把 [num_seqs, nextn] 展平成 num_seqs * nextn 个元素做一次全局 argsort,取排名 < extra_budget 的为胜出者。这样做的好处是名额自动流向”边际价值最高”的位置,而不是简单地每条序列分同样多。注意这里用 stable=True 保证相同置信度时按扁平索引升序破平——确定性要求渗透到了排序 API 的选择上(第六篇讲过 batch-invariant 与确定性采样)。
(3)保底 1 个 token。 extra_budget = total_q - num_seqs,seqlens_q = 1 + wins。每条序列至少跑一个 token(否则它这一步什么都不产出),剩下的预算才拿去竞争。这是”不饿死任何请求”的调度底线。
顶层文件里还有完整的验证与基准测试:test_compute_seqlens_q_by_confidence_rates 用 9 组形状参数(从 (1, 5, 4) 到 (256, 7, 300))覆盖 has_remap 的两种情况,断言 kernel 结果与参考实现 torch.equal;test_compute_seqlens_q_benchmark 则在 num_seqs 从 64 到 1024 上记录耗时。这就是前面说的”顶层管语义、后端管实现”的具体形态——语义正确性由与硬件无关的参考实现锚定,性能由基准记录跟踪。
dflash.py(CUDA 224 行 / Ascend 150 行)与 dflash_copy.py(151 行)是这一族的另外两块。第五篇讲过 DFlash 草稿头必须可被 CUDA Graph 捕获,这要求形状在捕获时固定,所以 dflash.py 的形状参数来自”桶”而不是运行时真实 batch。这是一个跨层的约束:kernel 的写法被上游的图捕获需求决定。dflash_copy.py 则负责把草稿 token 的 KV 从草稿头位置搬回主模型位置——第五篇讲的”被拒绝草稿 token 的分页 KV 回滚”,在代码层就靠这类拷贝 kernel 实现:回滚不是撤销写入,而是改变逻辑到物理的映射并覆盖后续位置,所以必须有一个精确控制拷贝范围的 kernel。这与 compute_seqlens_k_zos 里那句 sk - nextn + prev_num_accepted_tokens 是同一件事的两面。
类四:采样融合——把多 pass 压成单 pass
fused_sample.py 与 fused_transform.py 是这一层行数最多的两个模块(CUDA 侧合计 1143 行)。它们融合了整条采样链路。
为什么值得融合?decode 阶段的采样输入是 [total_q, vocab_size] 的 logits,vocab 通常十万量级。这一步的访存量与模型层相当,但计算量极小——纯带宽受限。传统做法要过好几遍 logits:一遍温度缩放、一遍算 top-k、一遍算 top-p、一遍采样。融合成一个 kernel 后只读一遍、写一遍。对于带宽受限的算子,减少 pass 数就是全部收益来源。
看 fused_sample_tl 的工厂签名与其中一段配置逻辑:
1def fused_sample_tl(vocab_size: int, do_sample: bool, do_reject_sampling: bool) -> Any:2 assert vocab_size >= 32, "fused_sample requires vocab_size >= 32"3 assert not do_reject_sampling or do_sample, "reject sampling requires sampling"4 ...5 if vocab_size >= 8192:6 THREADS, LSE_BD, SAMPLE_BD = 256, 8192, 20487 else:8 LSE_BD = tilelang.next_power_of_2(vocab_size)9 THREADS = min(128, LSE_BD)10 SAMPLE_BD = LSE_BD11
12 FILTER_BD = min(LSE_BD, 4096)13 TIE_BD = min(FILTER_BD, 256)14 TIE_GROUPS = FILTER_BD // TIE_BD15 N_FILTER_CHUNKS = T.ceildiv(vocab_size, FILTER_BD)16 N_LSE_CHUNKS = T.ceildiv(vocab_size, LSE_BD)17 N_SAMPLE_CHUNKS = T.ceildiv(vocab_size, SAMPLE_BD)18 NUM_STAGES = 2 if vocab_size > LSE_BD else 019
20 RADIX_BITS = 8 if LSE_BD <= 1024 else 11几个值得注意的决策:
- 三套 tile 宽度并存(
LSE_BD/FILTER_BD/SAMPLE_BD),代码注释给出了理由:“Keep LSE’s reduction tree; smaller filter/sample tiles reduce register use.”——log-sum-exp 需要一棵完整的归约树所以 tile 要大,而过滤与采样阶段 tile 小一点能省寄存器。同一个 kernel 里不同阶段用不同 tile 宽度,这是融合带来的自由度,分开写反而做不到。 RADIX_BITS = 8 if LSE_BD <= 1024 else 11,注释:“Small LSE tiles use 256 buckets to reduce histogram clearing/scanning; larger tiles use 2048 buckets to save one radix pass.”——基数选择是”桶清理开销”与”基数轮数”之间的权衡,tile 小时桶少更划算(省清理),tile 大时桶多更划算(少一轮扫描)。do_reject_sampling是一个独立的编译期开关,且断言”拒绝采样必须开着采样”。拒绝采样是投机解码的验证环节,与普通采样共用一遍 logits 遍历,所以必须融合进同一个 kernel 而不是单独跑一遍。
common.py 里有一段关于拒绝采样偏差的推导,值得单独看一眼——这是”每个结论都要有依据”的好例子:
1关于 DRAFT_PROB_PENALTY:2设 p 为 draft 分布,q 为 target 分布。先从 p 采样;若采到 token j 且 p[j] <= q[j],则直接接受;3否则从归一化后的 reject 权重中采样:r[i] = max(q[i] - alpha * p[i], 0), alpha = exp(-DRAFT_PROB_PENALTY).4...5所以对任意 token j,有6 q[j] - eps / (1 + eps) <= A[j] <= (1 + eps) * q[j],7其中 eps = 1 - exp(-DRAFT_PROB_PENALTY)。标准投机采样要求草稿分布与目标分布完全一致才无偏;DeepInfer 引入了一个 DRAFT_PROB_PENALTY(alpha = exp(-penalty))主动让草稿分布”偏保守”,代价是引入了偏差,换来的是草稿被接受的稳定性。这段推导给出了偏差的双边界:任何 token 的输出概率 A[j] 与目标概率 q[j] 之间,相对误差被 1+ε 与 1−ε/(1+ε) 夹住。惩罚系数不是一个拍脑袋的魔法数字,而是一个有明确误差上界的旋钮——这个结论对写 kernel 的人直接有用:他知道调大 penalty 会在哪个方向上把输出推偏多少。
工程细节:从源码里能读出的约束
最后把这些散落在代码里的工程约束归拢一下——它们是”写这个 kernel”和”用这个 kernel”之间真正的接口。
内存层次必须显式声明,布局由编译器推导而不是手写。 TileLang 要求显式 alloc_shared / alloc_fragment / alloc_local / alloc_var,而不是让编译器猜。官方文档把这一步讲得很细:T.alloc_shared 对应 GPU 的 shared memory(片上、快、同一线程块内共享),T.alloc_fragment 对应寄存器文件(保存累加器与部分和,进一步降低延迟);而”每个线程实际拿多少寄存器”由编译期的一个 Layout Inference Pass 推导出来的 T.Fragment 布局对象决定。

(图源:TileLang 官方文档 The Tile Language: A Brief Introduction)
这就是”写 TileLang 而不是写 CUDA”的取舍所在:布局推理让同一份 tile 级描述能适配不同线程组织与不同硬件,代价是布局不再由你亲手指定——所以 DeepInfer 那些对访存形态敏感的地方(块内连续、块间不连续的分页 KV)选择用线程原语 + 段表把访存形态写成确定的,而不是交给推导。TileLang 把这两条路放在同一门语言里,正是它被选中的原因。
具体到 Ascend 版,“数据必须先搬进 UB”是硬约束(对应上面的 T.alloc_shared 在这个后端落到 Unified Buffer);CUDA 版里显式内存层次则是性能决策——把不规则索引先算进寄存器,再从连续区间做向量化访存,是这类算子的标准手法。
T.assume 把不变量告诉编译器。 整个 cuda/ 目录下有 87 处 T.assume。例如 load_paged_kv_cache 里:
1T.assume(-1 <= src_block_id < num_blocks)2T.assume(0 <= src_begin <= src_end)3T.assume(src_end <= block_size)4T.assume(value_block_stride % 32 == 0)5T.assume(value_token_stride % value_token_alignment == 0)这些断言在正确性上是冗余的——调用方本来就该保证。它们的作用是让编译器知道边界,从而省掉它自己加的保守检查(比如对每个访存做越界防护),同时也能据此做向量化。所以这不是防御性编程,是性能契约:写错了不是”安全地慢一点”,而是未定义行为。这解释了为什么 T.assume 和 T.ASSERT 是两种不同的东西,本文代码里用的是前者。
StridedTensor 把物理布局写进类型。 cuda/ 目录下出现 56 次 StridedTensor。以 KV 缓存参数为例:
1kv_cache_value: T.StridedTensor((num_blocks, block_size, value_dim), value_strides, value_dtype)2value_strides = (value_block_stride, value_token_stride, 1)value_stride 是运行时的动态变量(value_token_stride = T.dynamic("stride_kvcache_value")),不是编译期常量。也就是说逻辑形状是编译期定的、物理步长是运行时传的。这个区分很实际:逻辑形状(块数、块大小、维度)决定了 thread 组织与向量化策略,必须编译期定;而物理步长可能因为内存对齐要求、页表实现、图捕获时的缓冲复用而变化,编译期定不下来。把”形状”和”步长”分开处理,是这套 kernel 能复用在不同内存布局上的前提。
整数溢出与 dtype。 索引算术用 int32 还是 int64 是个真实决策:int32 访存更快、寄存器压力更小,但总 token 位置在长上下文下会超过 231。DeepInfer 的 KV 池采用块索引而非全局线性索引,一个直接好处就是索引值域被压回 int32 安全区——num_blocks 而不是 total_k 才是索引的上界。
PDL 是整层的启动策略,不是局部技巧。 28 个后端模块用到 T.pdl_sync()。它的含义是:本 kernel 要读的元数据可能由前一个 kernel 刚写出,与其让 GPU 等整个前序 kernel 结束(end-of-kernel 同步),不如让本 kernel 提前启动、在真正需要数据时才同步。这一层小 kernel 密集,正好是 PDL 收益最大的场景——它们彼此依赖链短、单个耗时小,启动延迟的占比很高。
Ascend 侧的文件不对称说明硬件差异不是被抽象掉的。 asc/kv_quant.py、asc/rstd_1ulp.py 与 CUDA 侧的 outlier_detection.py、quant.py 各自存在,说明某些数值处理必须跟着硬件走。“1ulp”(一个最小精度单位)这类命名透露的是归约精度策略——不同硬件在浮点归约路径上不同,需要不同的误差控制手段,硬套同一份实现反而会损失性能或精度。
测试结构说明契约强度。 顶层每个算子模块都带着 _ref 参考实现与参数化 pytest;conftest.py 把 KernelUnavailableError 转成 skip,并配有 benchmark 插件(TK_BENCHMARK_BASELINES_DIR、TK_BENCHMARK_REGRESSION_THRESHOLD=0.15、TK_BENCHMARK_MIN_DELTA_US=3.0 ——回归阈值 15% 且小于 3 微秒的差异不计,这两个数说明”多小的抖动可以忽略”是被明确定义过的)。这意味着测试用例是按”算法”写的、按后端跑的:同一个语义测试在两个后端都跑,谁都跑不了就 skip。
这一层在整个引擎里的位置
把本篇与前六篇接起来看,DeepInfer 在这一层的分层大致是这样:
1调用层 模型 layer / worker / sampler —— 只认 ops.xxx_tl2 ↓3分发层 ops.py + platform.py —— 唯二知道后端的地方4 ↓5语义层 顶层模块:封装 + _ref 参考实现 + pytest 用例 —— 与硬件无关6 ↓7实现层 cuda/ | asc/ —— 按硬件各写,共享接口与算法结构8 ↓9共享层 common.py(量化常量)/ cache_common.py(KVCacheFormat) —— 公式与常量10 ↓11底层库 FlashMLA / DeepGEMM / DeepEP / TileKernels / DeepSelect / TileLang这里有个值得记住的判断:DeepInfer 没有试图用一套代码适配所有硬件。它做的是”把平台知识收敛到极小面积”(一个分发文件 + 一个平台类),再把”语义”从”实现”里剥出来放到顶层,最后用参考实现与测试约束两端行为一致。这是工程可维护性与”一份代码到处跑”的理想之间的一次明确取舍。
底层库里还有两个本篇没展开的:TileKernels 是 DeepSeek 用 TileLang 写的一批通用 kernel(Ascend 版 kernel 里直接 import tile_kernels 使用它的 get_num_vec_cores() 与 quant 模块),DeepSelect 从命名看与选择/路由相关。它们与 tilelang_ops 的分工(哪些算子放引擎内、哪些放外部库)是后续可以单独展开的话题。
小结
tilelang_ops存在的理由是主干大 kernel 之间的小算子缝隙:形状不规则、索引算术为主、单次开销小但数量多,用 PyTorch 拼会引入大量 launch 与同步开销。官方 README 把它列为 “custom TileLang kernels”,与 FlashMLA/DeepGEMM/DeepEP/TileKernels/DeepSelect 并列。ops.py用 38 行完成分发:后端目录名来自PLATFORM,模块靠pkgutil自动发现,接口契约是_tl后缀命名,导入期断言拦重名,KernelUnavailableError让后端缺失变成测试 skip。平台知识被收敛到ops.py+platform.py+ 两个后端目录。- 模块分六族:输入准备、KV 索引与元数据、KV 读写与量化、MoE 与路由辅助、采样与输出、投机与状态。带
_zos的后缀对应零开销调度,带fused_的对应融合优化。 - 顶层与后端是”语义 vs 实现”的分层:顶层模块放封装函数、
_ref参考实现与 pytest 用例,后端目录放具体 kernel。语义定义一次、验证一次,两端 kernel 都要对得上。 - 双平台是”接口停在算法级、实现跟着硬件走”:同一个
gather_input_ids_zos,CUDA 版一个 block 一条序列直接读写全局内存,Ascend 版按核持久化、先拷贝进 UB、用num_stages=4做多缓冲——因为 Ascend 的 “数据必须先搬进 UB” 是硬约束。两后端目录只差三个文件(quant.py/outlier_detection.py/prepare_decode_metadata.py对kv_quant.py/rstd_1ulp.py/prepare_v4_decode_metadata.py)。 - KV 读写是”一个被参数化的 kernel”:
KVCacheFormat描述一个 token 的逻辑与物理内容,工厂在构建期就把线程数、tile、向量宽度算成常量,kernel 内没有运行时布局分支。寻址靠(src_block_id, src_begin, src_end, dst_offset)四元组的段表,-1编码无效块,段内向量化。 - 真难点是索引算术不是浮点算术:分页寻址的跨块拆分与对齐、变长验证的全局排序装箱、
seqlens_k的回滚修正、gather 的元数据 GPU 侧自产。 - 工程契约集中在三处:
T.assume(把不变量告诉编译器,省掉保守检查)、StridedTensor(形状编译期定、步长运行时定)、T.pdl_sync(28 个模块共享的提前启动策略)。
下一篇回到模型侧:DeepInfer 的注意力层与 MLA 在引擎里的组织方式,以及 V4.1-Flash 的模型模块(model_executor/models/v4_modules/)如何把 FlashMLA、YoCo 与 DSpark 三个机制拼成一次前向。更早的机制背景可以回看 DeepInfer 源码完全拆解(一):整体架构与模块全景,KV 侧的两本块账见 (三):KV 内存与 SWA 有界重放。
参考资料
- DeepInfer 官方仓库(README 功能清单与支持硬件)
- DeepInfer tilelang_ops 目录(本文拆解的 kernel 集合)
- DeepInfer ops.py(后端分发器源码)
- DeepInfer platform.py(后端目录名与平台能力定义)
- DeepInfer cache_common.py(KVCacheFormat 与 KV 布局定义)
- DeepInfer common.py(量化常量与 DRAFT_PROB_PENALTY 推导)
- DeepInfer gather_input_ids_zos.py(ZOS 输入 gather kernel,CUDA 版)
- DeepInfer compute_seqlens_k_zos.py(seqlens_k 修正 kernel)
- DeepInfer load_paged_kv_cache.py(分页 KV 读取与反量化 kernel)
- DeepInfer store_kv_cache.py(分页 KV 写入与量化 kernel)
- DeepInfer schedule_mtp_varlen.py(变长验证调度:语义、参考实现与测试)
- DeepInfer fused_sample.py(采样融合 kernel,CUDA 版)
- TileLang 官方仓库
- TileLang 官方文档:The Tile Language: A Brief Introduction
- TileLang 官方文档:编程指南总览
- TileKernels 官方仓库(DeepSeek 的 TileLang 通用 kernel 集)
- DeepSelect 官方仓库
- FlashMLA 官方仓库(MLA 注意力 kernel)
- DeepGEMM 官方仓库(FP8 矩阵乘内核)
- DeepEP 官方仓库(MoE dispatch/combine)
- DeepSeek-V4.1-Flash 模型卡
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



