跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 33 章一次真实开源的完整拆解57 分钟14 习题8 自测题

TileKernels(上):一份算法,两个后端

TileKernels 是用 TileLang 写的生产算子库,同一份算法提供 CUDA 与 Ascend 两个后端。以 top-k gate 为例逐行对照两种写法,讲清哪些部分平台无关、哪些必须重写,以及量化、RoPE、Rand 这些算子族的接口。

本章进度状态 未读完习题 0 / 14自测 未作答

学习目标

读完本章,你应当能够:

  1. 说清 TileKernels 的三层目录约定——*_kernel.py(平台无关的 Python 包装:参数校验、 输出分配、后端分派)、*_cuda.py / *_asc.py(两份 @tilelang.jit 的 T.prim_func)、 torch/ + testing/(参考实现与对拍工具)——并解释每一层该放什么、不该放什么, 以及为什么这样切能让同一份 Python API 同时喂给两个后端。
  2. 逐行读懂 moe/topk_gate_kernel.py 的后端分派:is_ascend() 怎么判断、为什么昇腾分支 延迟 import、get_num_vec_cores() / get_pdl() 这类运行期探测值如何变成 @tilelang.jit 的编译期常量并进入缓存键。
  3. 把 topk_gate 的 CUDA 版与 Ascend 版逐段对照:并说得出 T.Kernel(num_tokens, threads=32) 与 T.Kernel(num_vec_cores) 的差别在哪、 T.alloc_fragment / T.alloc_reducer / T.alloc_shared 各自解决什么问题、 Ascend 版的 T.annotate_buffer_versions、T.SimdVF()、T.Pipelined(..., num_stages=...)、 T.view、vector_length = 64 分别在补偿什么。
  4. 解释为什么两边的并行单位不同(NVIDIA 的线程/线程块 vs 昇腾的 AI Core / Vector Core / SIMD lane),以及为什么 Ascend 版必须显式指定缓冲区版本数。
  5. 说清量化算子族(per-token / per-block / per-channel cast、cast_back、SwiGLU 融合)三种 缩放粒度的语义差别、fp8/fp4 的换算口径,以及「为什么要把 SwiGLU 和量化融合」, 并把它们接到第 27 章的数值正确性口径上。
  6. 用 RoPE 与 Rand 两个小算子说明「双后端」不是大算子的专利;并区分本章中 哪些结论是从源码结构直接读出的、哪些必须真机验证。

前置知识

  • 第 07 章:@tilelang.jit、T.Kernel、T.copy 的语义,以及 JIT 与缓存。
  • 第 09 章:T.copy / T.Parallel / T.reduce_* / T.fill 的语义与降级行为。
  • 第 11 章:T.Pipelined 与多缓冲、num_stages 的含义。
  • 第 13 章:逐元素、归一化、RoPE 的算子结构与「融合」的动机。
  • 第 19 章:昇腾上的最小 kernel、AIC/AIV、UB/L1/L0/L0C、Set/Wait 同步,以及 「两棵树」(适配器 A 树 / 主干 M 树)这张对照表。本章直接建立在第 19 章之上: 第 19 章教你写一个昇腾 kernel,本章教你让同一个算法同时拥有两块芯片的实现。
  • 第 25 章:昇腾后端 codegen 与 T.SimdVF / T.Pipelined / AutoSchedule 的原理。
  • 第 27 章:低精度与量化的数值口径(block scaling、累加精度、舍入)。本章的量化算子族 是它在真实库里的落地,本章不重复推导。

不需要你会写 CUDA,也不需要你会写 Ascend C;也不需要你手上有 NVIDIA GPU 或昇腾 NPU ——本章所有示例的最高验证级别是仅静态核对,下面先说清楚这意味着什么。


本章的验证边界(务必先读)

写作环境是 macOS (Apple Silicon)、Python 3.11,既没有 NVIDIA GPU,也没有昇腾 NPU (见 版本锁定)。因此本章遵守与第 19 章相同的两条硬规矩:

  1. 所有 kernel 示例的最高验证级别是「仅静态核对」。代码能通过 python3 -m py_compile 或 python3 -m ast,调用的 API 逐个在锁定版本源码里比对过存在性与签名, 但没有任何一条性能数字、任何一个「Kernel Output Match!」来自本机实测。
  2. 任何需要真实硬件才能产生结果的步骤,一律写成前提 / 命令 / 期望输出 / 验证级别 四段式;「期望输出」一律表述为「在满足前提的机器上应当看到」。

本章引用的代码有两类来源,请先认准:

记号 路径 说明
TK 库 TileKernels/ TileKernels 仓库快照,commit 66258df6175d(2026-09-30)
M 树 _vendor/tilelang-v0.1.15/ TileLang 主干 v0.1.15(本章用它核对 API)

一条特别重要的边界:TileKernels 的 Ascend 后端用的是主干 M 树的昇腾方言 (from tilelang.ascend import language as T / target='ascend'),不是第 19 章里那个 适配器 A 树(tilelang.language + target='ascendc'/'pto')。凡是本章出现「A 树」的地方, 都只是拿第 19 章的对照来帮助理解;TileKernels 一行 A 树代码都没有。

本章出现的数字只有三类来源:TileKernels 自述文件/上游文档(带 corpus 路径)、 上游源码里的常量(带文件与行号)、本书的算术推导(由 code/ 里的纯 Python 程序复算)。 三者都会显式标注。凡是只有宣传口径、没有条件说明的数字,本章一律不采用。


1. 问题的由来:为什么是 TileKernels

1.1 它在这次开源里的位置

本次(2026-09-30)官方发布说明这样描述这批昇腾组件:

本次开源还包括昇腾平台核心计算与通信组件,为不同使用场景提供可复用的基础能力: DeepGEMM 加速通用矩阵运算;DeepEP 提供高效的大规模跨设备通信;TileKernels 提供数据处理 所需的常规向量计算和访存算子;FlashMLA 提供稀疏注意力算子……在多项关键测试用例中, 上述组件的计算与通信性能已接近硬件上限。 (DeepSeek 开源昇腾基础组件(官方发布说明) 第 12 行)

这段话把 TileKernels 的定位说得很准:它不是去抢 GEMM 或注意力的风头,而是补上 「其余那些不显眼、但每次前向反向都要跑几十个」的算子——MoE 路由、量化、RoPE、 超连接门控、随机数。这类算子的共同点是:算法简单、访存密集、形状琐碎,在真实 训练/推理里占的总时间不低,而且极难靠手写 CUDA / Ascend C 逐个维护。

TileKernels 自述文件给出的能力清单是:

TileKernels is a library of dozens of highly optimized kernels implemented in [TileLang] ... It provides kernels for several common operations in LLM training and inference, including mixture-of-experts routing, Engram, quantization, and manifold hyper-connections. Most kernels achieve performance close to the hardware's compute or memory bandwidth limits. All of these kernels have already been used in our internal training and inference workloads. (TileKernels/README.md 第 3 行;中文对照见 同文件 :5)

⚠️ 「性能接近硬件上限」「已用于内部训练与推理」是厂商自述,本章不把它当作可复现的 结论。本章关心的是代码结构:这些算子确实存在、确实同时有 CUDA 版与 Ascend 版, 而且两份实现共享同一个 Python API。

同一天的 News 一节把本章的主题直接写成了标题:

[2026-09-30] Huawei Ascend support: Added Huawei Ascend support ... Following the NVIDIA path, the kernels now ship a second backend that is selected automatically at runtime, so the same Python APIs run on both NVIDIA GPUs and Huawei NPUs. (资料库索引)

自述的依赖门槛也一并抄在这里,后文会用到(资料库索引):

组件 要求
Python ≥ 3.12
PyTorch ≥ 2.13
TileLang ≥ 0.1.15
CUDA 后端 NVIDIA SM90 / SM100,CUDA Toolkit ≥ 13.1
Ascend 后端 Ascend 950 NPU,CANN ≥ 9.2.0

注意「TileLang ≥ 0.1.15」这一条:本书锁定的主干正好是 v0.1.15,也就是说 TileKernels 要求的就是本书讲解的那个版本,不存在「书里的 API 与库里的 API 对不上」 的问题。(版本锁定)

1.2 为什么值得为它单开一章:它是全书中心问题的实验室

全书反复问的一个问题是:同一份算法,在不同芯片上到底差多少?(第 06 章的抽象对照、 第 19 章的昇腾实战、第 25 章的昇腾后端,都在不同层次回答它。)TileKernels 把这个问题的 答案做成了一份可以 git clone 的代码:同一个仓库里,每个算子都有 xxx_cuda.py 和 xxx_asc.py 两个文件,它们实现同一个算法,但用 TileLang 写成了 两种完全不同的形状。

这给本章带来三个别的章节给不了的东西:

  1. 「平台无关」与「平台相关」的边界,第一次变成了可见的文件边界。 你不用猜哪里是 抽象的尽头——*_kernel.py 之外的部分,就是必须为芯片重写的部分。
  2. 对照是「同题同解」,不是「同题异解」。 两个文件的目标函数(算法、数值口径、 输出布局)完全相同,差异全部来自硬件,因此每一条差异都能干净地归因。
  3. 能看出「重写」的工作量到底有多大。 你可以数一数:同一件事,两边各写了多少行、 哪些行是逐字相同的、哪些行是必须重写的。

1.3 本章范围与路线图

本章只覆盖 TileKernels 里体量小、但结构完整的那一半:

目录 本章讲什么
tile_kernels/moe/(topk_gate 家族、normalize_weight) 核心对照样本
tile_kernels/quant/(per-token / per-block / per-channel cast、cast_back、SwiGLU fwd/bwd) 算子族地图 + 融合动机
tile_kernels/transform/(RoPE) 小算子也能双后端
tile_kernels/rand/ 一个诚实的反例:只有一个后端

Engram 与流形超连接(mHC)留给第 34 章。路线图:

§2 三层结构        §3 后端分派       §4 top-k gate 逐段对照(本章重心)
   │                   │                          │
   └───────────┬───────┴──────────────┬───────────┘
               ▼                      ▼
        §5 量化算子族          §6 RoPE / Rand
               └──────────┬───────────┘
                          ▼
                 §7 方法论落点(哪些重写、少在哪)
                          ▼
                    §8 小结 + 常见坑

2. 概念与模型:三层结构

TileKernels 的三层结构与后端分派外层 *_kernel.py(平台无关)共享资产(不进 kernel)否是对拍用户代码topk_gate(scores, k)外层:校验 + 分配 + 短路dim / contiguous / dtype / shape 上限;torch.empty;num_tokens == 0 直接返回is_ascend() ?get_*_kernel_cuda(...)use_pdl=get_pdl() → @tilelang.jit 编译 / 取缓存get_*_kernel_asc(...)num_vec_cores=get_num_vec_cores()(延迟 import)CUDA kernelT.Kernel(num_tokens, threads=32)Ascend kernelT.Kernel(num_vec_cores)return topk_idxtorch/ 参考实现slow but correcttesting/ 对拍与计时assert_equal / calc_diffconfig.py核数 / PDL / 容量探测共享算术quant/common.py 的 get_sf_and_inv
外层 *_kernel.py 只做校验、分配与分派;两个后端各是一个 *_cuda.py / *_asc.py;参考实现、对拍工具、配置与共享算术全程不进 kernel。
  • 分派发生在「工厂函数被调用」这一步,不是「kernel 被执行」这一步;所有型号相关的值在这一刻成为编译期常量,并进入 JIT 缓存键。
  • CUDA 工厂在模块顶层 import;昇腾工厂只在 is_ascend() 为真时才 import(延迟 import)。
  • tests/** 只调用外层 API,所以同一份测试同时覆盖两个后端。

2.1 三种文件后缀就是三层

打开 TileKernels 的 tile_kernels/,你会看到对每个算子都成立的一个命名规律。 以本章的主角 top-k gate 为例:

tile_kernels/moe/
├── topk_gate_kernel.py    ← 平台无关:参数校验、输出分配、后端分派
├── topk_gate_cuda.py      ← CUDA 后端:一个 @tilelang.jit 的 T.prim_func
├── topk_gate_asc.py       ← Ascend 后端:另一个 @tilelang.jit 的 T.prim_func
├── moe_topk_gate_forward_{kernel,cuda,asc}.py
├── moe_topk_gate_backward_{kernel,cuda,asc}.py
├── normalize_weight_{kernel,cuda,asc}.py
└── sqrt_softplus.py       ← 两边共享的数学近似(按后端给不同的实现)

同一规律在别的目录也一样成立:quant/per_token_cast_{kernel,cuda,asc}.py、 transform/rope_{kernel,cuda,asc}.py。(.../TileKernels/tile_kernels/ 目录树, 见 资料库索引 的 Project Structure 一节。)

外层的两个目录补上这个闭环:

  • tile_kernels/torch/:同一批算子的纯 PyTorch 参考实现。例如 torch/topk.py:18-21 的 stable_topk 就是「torch.sort(descending=True, stable=True) 取前 k」——三行,慢,但绝对正确。它是两个 kernel 共同的「标准答案」。 tile_kernels/torch/__init__.py:1-25 把这些参考实现集中导出。
  • tile_kernels/testing/:对拍与计时工具。testing/numeric.py:33-38 的 calc_diff 是「相对误差」(下文会用到)、testing/numeric.py:5-30 的 assert_equal 是逐字节 比对、testing/generator.py 负责生成被测形状。测试文件(tests/**)则把这些拼起来。

2.2 每一层该放什么

这是本章第一条要记牢的规则。三层各自的职责边界很不平均——大量代码在最外层, 而两个后端各自只留「纯计算」:

层 放什么 不放什么
*_kernel.py 1) 输入校验(dim/contiguous/dtype/shape 上限)
2) 把「可选参数是否存在」折叠成布尔开关,供 JIT 做特化
3) 输出张量分配(torch.empty)
4) 后端分派(if is_ascend(): ... else: ...)
5) 空输入短路(if num_tokens == 0: return)
6) 调用 kernel(...)
任何 T.prim_func、任何 T.* 原语、任何硬件概念
*_cuda.py 一个 @tilelang.jit(...) 工厂函数,返回一个 T.prim_func;里面只有算法与 CUDA 侧的并行映射 参数校验、torch.empty、后端判断
*_asc.py 同左,但用 tilelang.ascend.language 与 simd as S;只有算法与昇腾侧的并行映射 同上

用一句话概括:外层的每一行都在回答「要不要算、算什么形状、用哪种芯片」, 内层的每一行都在回答「这块芯片上怎么算」。

三类「平台相关但又不属于 kernel」的常客,都有各自固定的去处:

内容 放哪 例子
数学近似(不同芯片精度不同) 单文件、按后端分函数 moe/sqrt_softplus.py:23-25 的 sqrt_softplus_forward_cuda 与 :29+ 的 Ascend 多项式版本
运行期探测(核数、PDL、UB 容量) config.py config.py:24-83
输出量纲/形状推导、缩放因子分配 共享的配置模块 quant/common.py

2.3 一次调用的完整链路

把上面拼起来,一次 topk_gate(scores, num_topk) 的完整链路是:

(对应 图:01-three-layers 的图 1)

  用户代码            外层(平台无关)                 内层(平台相关)
  ────────            ────────────────                 ────────────────
  topk_gate(s, k)
        │
        ▼
   [校验] dim/contiguous/float32, k<=min(E,32)
        │
        ▼
   [分配] topk_idx = torch.empty((T,k), int64)
        │
        ▼
   [空输入短路] num_tokens == 0 -> 直接返回
        │
        ▼
   [分派] is_ascend()?
     ├─ 否 ──► get_topk_gate_kernel_cuda(E, k, use_pdl=get_pdl())
     │             └─► @tilelang.jit -> 编译/取缓存 -> kernel(...)
     │                    └─► CUDA: T.Kernel(num_tokens, threads=32)  ...
     └─ 是 ──►(延迟 import)get_topk_gate_kernel_asc(E, k, get_num_vec_cores())
                   └─► @tilelang.jit -> 编译/取缓存 -> kernel(...)
                          └─► Ascend: T.Kernel(num_vec_cores)  ...
        │
        ▼
   return topk_idx

这张图里有两个细节,很多人第一遍会看漏,但它们是整个设计的支点:

  • 分派发生在「工厂函数被调用」这一步,不是「kernel 被执行」这一步。 两个 get_*_kernel 都是 @tilelang.jit 装饰的工厂:你调它,它返回一个已经编译好的 kernel。因此 is_ascend() 决定的是去哪个工厂,而型号相关的一切(核数、线程数) 在工厂被调用时就被烧进编译产物里了。
  • 短路发生在最前面。 num_tokens == 0 时直接返回空张量,不编译、不启动。 这不是优化,而是正确性的一部分:空的 shape 会让后续的 T.ceildiv/align 产生 奇怪的结果,也会让 Ascend 版的 T.device_assert 在没有任何行可处理时失去意义。

2.4 为什么值得这样分层

如果只是「代码整洁」,不值得单开一节。这三层切法真金白银地解决了四个问题:

  1. 后端隔离:*_cuda.py 里根本不 import 昇腾的东西,*_asc.py 里也根本不 import CUDA 的东西。因此「两份实现」在物理上就是两个文件,不存在条件分支缠绕在 kernel 里。 代价是外层必须知道「现在是谁」,这正是 config.py 的职责。
  2. 可测性:torch/ 提供慢但正确的参考,testing/ 提供对拍与误差口径。测试文件 只调用外层 API(见 §3.4 的 tests/moe/test_topk_gate.py),所以同一份测试同时 覆盖两个后端。这是「一份算法、两个后端」在工程上最划算的地方。
  3. 可缓存:所有「形状相关」的值(num_experts、num_topk、num_vec_cores)都是 编译期常量,作为工厂函数的参数传进去。同一组参数第二次调用直接命中 JIT 缓存 (第 07 / 26 章),不会重复编译。
  4. 可增后端:加一个后端 = 加一个 _xxx.py + 在外层加一个分支。不需要动算法, 也不需要动测试。

2.5 常见坑:这一层最常见的一种错

把运行期才有的量(比如 num_tokens)当成编译期常量写进 kernel 的 T.Kernel 参数里,是这一层最典型的错误。TileKernels 的写法是:

# .../TileKernels/tile_kernels/moe/topk_gate_cuda.py:13
num_tokens = T.dynamic('num_tokens')

@T.prim_func
def topk_gate_kernel(
    scores: T.Tensor[(num_tokens, num_experts), T.float32],
    topk_idx: T.Tensor[(num_tokens, num_topk), T.int64],
):
    with T.Kernel(num_tokens, threads=num_threads) as pid:   # 第 22 行
        ...

T.dynamic('num_tokens') 声明「这个维度运行时才知道」;T.Kernel(num_tokens, ...) 于是 变成一个运行期网格。这样同一个编译产物可以服务任意 token 数,不必为每个 batch 重新编译;代价是网格尺寸不再是编译期常量,编译器能做的静态检查少了一部分。 num_experts / num_topk 这类量则相反,它们在 topk_gate_kernel.py:38 被当作 get_topk_gate_kernel_asc(...) 的参数,是编译期常量,因为算子要为它们做布局特化。


3. 运行期后端选择

3.1 config.py:全部运行期探测的家

整个后端选择的依据集中在 tile_kernels/config.py。先看最核心的四行:

# .../TileKernels/tile_kernels/config.py:24-31
@functools.lru_cache(maxsize=1)
def is_ascend() -> bool:
    return os.path.exists('/dev/davinci_manager')

@functools.lru_cache(maxsize=1)
def get_device() -> str:
    return 'npu' if is_ascend() else 'cuda'

判据是「设备文件是否存在」,不是「设备是否可用」。 /dev/davinci_manager 是昇腾 驱动暴露给用户态的设备节点;它在昇腾机器上一定存在,在 CUDA 机器上一定不存在。 这么写有两个后果,都要记住:

  • 它是进程级、一次性的结论(lru_cache(maxsize=1))。一个进程不可能同时是 CUDA 机器和昇腾机器,所以缓存不会出错;但这也意味着同一进程里无法切换后端, 想同时对比两个后端只能开两个进程。
  • 它不检查 NPU 是否真的可用(驱动装了但没卡、卡被占满,它照样返回 True)。 真正的可用性检查发生在后端自己启动 kernel 的时候。

接着是「有多少个核」这件事。这里有一个必须看清楚的区别:

# .../TileKernels/tile_kernels/config.py:39-71
@functools.lru_cache(maxsize=None)
def get_device_num_sms() -> int:
    if is_ascend():
        prop = torch.npu.get_device_properties(torch.npu.current_device())
        return prop.cube_core_num            # 昇腾:Cube 核数
    else:
        prop = torch.cuda.get_device_properties(torch.cuda.current_device())
        return prop.multi_processor_count    # NVIDIA:SM 数

# Pick the accessor that matches the kernel type:
#   - Mix kernel (AIC + AIV): use `get_num_ai_cores`
#   - pure cube kernel:       use `get_num_cube_cores`
#   - pure vector kernel:     use `get_num_vec_cores`
get_num_ai_cores = get_num_sms
get_num_cube_cores = get_num_sms

def get_num_vec_cores() -> int:
    return get_num_sms() * 2

config.py:62-65 那段注释就是设计意图的自述:同一个「核数」,在三种核上的含义不同。 昇腾的一个 AI Core 由 1 个 Cube 核(AIC)和 2 个 Vector 核(AIV)组成(第 19 章 §2.1), 所以纯向量算子要开的并行单位是 cube_core_num * 2。这就是为什么 get_num_vec_cores() 要乘 2,而 get_num_cube_cores 直接等于 get_num_sms。

再往下还有两个「容量」探测和一个「开关」:

# .../TileKernels/tile_kernels/config.py:74-83
@functools.lru_cache(maxsize=None)
def get_max_smem_per_sm() -> int:
    prop = torch.cuda.get_device_properties(torch.cuda.current_device())
    return prop.shared_memory_per_multiprocessor      # 只对 CUDA 有意义

@functools.lru_cache(maxsize=None)
def get_max_ub_per_vector_core(use_simt: bool = True) -> int:
    assert is_ascend()
    return 216 * 1024 if use_simt else 248 * 1024      # 单位:字节

get_max_ub_per_vector_core 是本文件里唯一一个硬编码的硬件常量:每个 Vector 核的 UB 预算按 use_simt 取 216 KB 或 248 KB。注意这是库自己写下的口径,不是我们从 官方文档核到的容量(第 19 章引的 Atlas A2 口径是 192 KB,两者不是同一款芯片、 也不是同一套预留策略)。RoPE 与 Rand 就是靠这个值反推 buffer 大小的(§6)。

最后是 PDL 开关——它是本章唯一一处 CUDA 专属能力:

# .../TileKernels/tile_kernels/config.py:105-112(另有 96-103 的 reset)
def set_pdl(use_pdl: bool) -> None: ...
def get_pdl() -> bool: ...

use_pdl 默认 False(config.py:7),用户可显式打开。PDL 是 NVIDIA 的 Programmatic Dependent Launch:允许后一个 kernel 在前一个 kernel 还没完全结束时 就开始加载(T.pdl_sync() / T.pdl_trigger(),定义见 _vendor/tilelang-v0.1.15/tilelang/cuda/language/pdl.py:10-21)。它只存在于 CUDA 方言里:tilelang.language 通过 from tilelang.cuda.language import * 把它带进来 (_vendor/tilelang-v0.1.15/tilelang/language/__init__.py:11),而 tilelang.ascend.language 只建立在 tilelang.language.common 之上 (.../tilelang/ascend/language/__init__.py:5,49-56),因此昇腾分支根本没有 pdl_sync。 这就是 get_pdl() 只会出现在 _cuda.py 的分派参数里、不会出现在 _asc.py 里的原因。

3.2 从 topk_gate 入口逐行读

下面是整个文件,只有 43 行,但三层结构的三条规则全在里面:

# .../TileKernels/tile_kernels/moe/topk_gate_kernel.py:1-43(逐行)
import torch

from tile_kernels.config import get_num_vec_cores, get_pdl, is_ascend
from tile_kernels.moe.topk_gate_cuda import get_topk_gate_kernel_cuda


def topk_gate(scores: torch.Tensor, num_topk: int) -> torch.Tensor:
    """Select the top-k experts per token from scores. ... """
    assert scores.dim() == 2 and scores.is_contiguous() and scores.dtype == torch.float32
    num_tokens, num_experts = scores.shape
    assert num_topk <= num_experts, f'num_topk ({num_topk}) must be <= num_experts ({num_experts})'
    assert num_topk <= 32, f'num_topk ({num_topk}) must be <= 32'

    topk_idx = torch.empty((num_tokens, num_topk), dtype=torch.int64, device=scores.device)
    if num_tokens == 0:
        return topk_idx

    if is_ascend():
        from tile_kernels.moe.topk_gate_asc import get_topk_gate_kernel_asc

        kernel = get_topk_gate_kernel_asc(num_experts, num_topk, get_num_vec_cores())
    else:
        kernel = get_topk_gate_kernel_cuda(num_experts, num_topk, use_pdl=get_pdl())

    kernel(scores, topk_idx)
    return topk_idx

逐行拆:

行 在做什么 为什么在这里
:1-4 导入:config 的三件套 + CUDA 工厂 CUDA 工厂在模块顶层导入;这意味着即使在 NPU 机器上 import tile_kernels 也会导入 topk_gate_cuda。它只是「定义了一个函数」,不会编译,也不会碰 CUDA 设备——但这是一个值得注意的实现选择(见坑 P2)
:26 校验 2D / contiguous / float32 后端无关。kernel 假设输入已经是连续 fp32,把转换交给调用者,避免在 kernel 里塞进一个 layout 分支
:27-29 取形状、校验 k <= E 且 k <= 32 k <= 32 是算法约束:两个后端都用「32 路」作为一次向量/一轮 warp 能覆盖的宽度(§4.5)。约束写在共享层,两个后端就不必各写一遍
:31 分配 int64 输出 输出 dtype 是 API 契约(:18 的 Returns),不是后端细节
:32-33 空输入短路 见 §2.3
:35-38 昇腾分支:函数内 import + 传三个编译期常量 import 是延迟的;get_num_vec_cores() 在这一刻求值,于是核数被烧进了编译产物
:39-40 CUDA 分支:传 num_experts, num_topk, use_pdl CUDA 版不需要核数(网格由 num_tokens 决定)
:42 同一个调用形式 两个后端返回的是同一种东西:接受 (scores, topk_idx) 的可调用对象

:42 这一行是整章最值得记的一行。上游的 kernel 从不「知道」下游有两种后端, 它只知道「有个东西接受两个张量」。这就是第 03 章讲的「接口与 schedule 解耦」 在真实库里的样子。

3.3 与第 19 章对照:TileKernels 用的是 M 树

第 19 章花了很大篇幅讲「昇腾有两棵树,写法不通用」。TileKernels 的选择非常干脆:

维度 第 19 章的 A 树(适配器) 第 19 章的 M 树(主干) TileKernels 选的
导入 import tilelang.language as T import tilelang.ascend.language as T M 树:from tilelang.ascend import language as T(topk_gate_asc.py:2)
target "ascendc" / "pto" "ascend" @tilelang.jit 默认(rand/randn_asc.py:7 里显式写 target='ascend')
目标设备 A2 / A3 Ascend 950 Ascend 950(资料库索引 明写)
UB 分配 T.alloc_ub T.alloc_shared T.alloc_shared(topk_gate_asc.py:31)
矩阵乘 T.gemm_v0 T.gemm(..., unit_flag_ctrl=...) T.gemm
同步 手写 barrier_all / set_flag 默认自动插入 默认自动(_asc.py 里几乎没有同步代码)
调度 手写为主 + TL_ASCEND_* AutoSchedule 默认开 AutoSchedule
混核 is_npu=True + vid T.MixedKernel(N, sids=2) 两者都用

为什么选 M 树?两个可核实的理由:

  • 它要 Ascend 950(资料库索引),而 950 正是 M 树的验证目标(第 19 章 §2.6)。
  • 它要自动调度。这个库里的 Ascend kernel 通篇几乎看不到同步指令——依赖全部由 AutoSchedule + InsertSync 推导(第 22 / 23 章)。手写 barrier 在几十个算子上是 不可维护的。你会在 §4.7 看到这条选择的代价:用户必须自己声明缓冲区版本数 (T.annotate_buffer_versions),因为「几份 buffer」是调度器无法替你猜的语义。

还有一条更硬的判据,可以直接从「有没有这个模块」看出来:TileKernels 的 Ascend 文件里 那句 from tilelang.ascend.language import simd as S(例如 topk_gate_asc.py:3), 只在 M 树里成立——锁定的 v0.1.15 有 tilelang/ascend/language/simd.py(57909 字节, def vintlv 在 :1052、def vdintlv 在 :1067),而 A 树 (_vendor/tilelang-ascend-v0.1.2.000/)连 tilelang/ascend/ 这个子包都没有, 跨 lane 归约/重排原语(vcmax / vintlv 等)在它的整个 tilelang/ 里一个都搜不到 (它的昇腾向量表面在 tilelang/language/ascend.py)。换句话说:没有 M 树, 这些 kernel 的写法根本无从谈起。 核对过程与命令见 本章引用 末尾的「附」一节。

3.4 同一套测试同时覆盖两个后端

这是「三层结构」最实际的收益。tests/moe/test_topk_gate.py:52-59 全部内容如下:

# .../TileKernels/tests/moe/test_topk_gate.py:52-59
@pytest.mark.parametrize('params', generate_test_params(get_test_level()), ids=make_param_id)
def test_topk_gate(params):
    scores = generate_test_data(params)
    num_topk = params['num_topk']

    topk_idx_ref = torch_stable_topk(scores, num_topk)          # tile_kernels/torch/topk.py:18
    topk_idx = tile_kernels.moe.topk_gate(scores, num_topk)     # 外层 API
    assert_equal(topk_idx, topk_idx_ref)                        # testing/numeric.py:5

这段测试里有三件值得学的事:

  1. 它只碰外层 API(tile_kernels.moe.topk_gate),所以同一份测试在 CUDA 机器上就跑 CUDA 实现、在昇腾机器上就跑昇腾实现。测试文件里没有任何 if is_ascend()。
  2. 它的判据是逐字节相等(assert_equal 先转成 uint8 视图再 torch.equal, testing/numeric.py:18-21),不是「误差小于某个数」。top-k 的索引是离散量, 没有「差不多相等」这回事;这直接推高了两个后端的实现要求——tie-break 规则必须 完全一致(§4.8)。
  3. torch/topk.py:18-21 的参考实现特意把 stride 改成 (num_topk, 1) (as_strided),因为 assert_equal 默认比对 stride(testing/numeric.py:14)。 这类「慢但严格」的参考实现,是双后端库里唯一能被两边同时信任的共同标准。

3.5 常见坑清单(外层层)

编号 坑 症状 正确做法
P1 用「能否 import torch_npu」之类的方式判断后端 装了包但没卡时误判 用设备节点/运行期能力探测;TileKernels 用 /dev/davinci_manager(config.py:26)
P2 在模块顶层 import 两个后端的 kernel 模块 在昇腾机器上导入 CUDA dialect 也可能踩到 CUDA-only 的东西 共享层顶层只 import CUDA 工厂,昇腾工厂延迟 import(topk_gate_kernel.py:4 vs :36)。见下方注释
P3 把 num_vec_cores 当成运行期值传进 kernel 每个 batch 重新编译,或静默用错核数 在工厂调用时求值,作为编译期常量传入(topk_gate_kernel.py:38)
P4 用 CUDA 的 get_num_sms() 去开昇腾向量核 只用了一半的向量核 纯向量算子用 get_num_vec_cores()(config.py:70-71)
P5 以为两份实现的参数表一致 昇腾版多一个 num_vec_cores,CUDA 版多一个 use_pdl 参数表本来就应该不同:那是各自的编译期特化旋钮
P6 在昇腾分支里传 use_pdl 报 AttributeError,因为 M 树昇腾方言没有 pdl_sync PDL 是 CUDA-only 能力(cuda/language/pdl.py)

关于 P2 的诚实说明:topk_gate_kernel.py:4 的顶层 import 之所以没有出问题, 是因为 topk_gate_cuda.py 只有「函数定义 + @tilelang.jit 装饰」,不执行任何 CUDA 操作。但同一个仓库里的 quant/per_block_cast_asc.py:7-9 特意留了一条注释 (「Re-bind below the star import」),说明共享模块的 import * 可能把错误的 T 带进后端文件里——这是这一层最隐蔽的一类 bug,写自己的库时要留意。


4. 核心对照:top-k gate 的两个后端

这一节是本章的重心。我们把 moe/topk_gate_{cuda,asc}.py 拆成同一串语义步骤, 每一步先看 CUDA 怎么写、再看 Ascend 怎么写、最后解释为什么必须不一样。

两个文件都很短(CUDA 68 行、Ascend 157 行),意思不是「昇腾实现更啰嗦」,而是 昇腾版把 CUDA 版交给编译器的那些决定(谁是并行单位、缓冲区有几份、向量有多宽) 全部显式写了出来。这是本章最想让你记住的一句话。

topk_gate 两个后端的逐项对照CUDA 版 topk_gate_cuda.pyAscend 版 topk_gate_asc.py网格T.Kernel(num_tokens, threads=32) :22T.Kernel(num_vec_cores) :30并行单位1 block = 1 token,32 线程 / warp1 Vector 核 = 1 token,64 lane / 指令一行放哪alloc_fragment(寄存器):25-27alloc_shared(UB):31归约怎么写alloc_reducer(op=min/sum) + init/update/finalizeS.vmax / S.vmin / S.vcmax / S.vcmin越界元素补齐成 -inf :37-38掩码屏蔽 :81,120非法值检测reducer 累加 + 循环外一次 assert :41-46x-x != 0 打标 + vmax 累积 + 循环外 assert剩余行网格 = token 数,天然整除if row < num_tokens 守卫 :47迭代划分无(一 block 一行)按核交错 row = w*V + core_id :46流水无T.Pipelined(..., num_stages=6) :41-45缓冲版本编译器决定annotate_buffer_versions(显式) :35显式同步无一句 S.mem_bar('VST_VLD') :151向量宽度编译器选(T.Parallel + 向量化)写死 vector_length = 64 :11int64 输出T.copy 做 int32→int64 转换 :64T.view 成 int32 视图,逐 lane 写 :34,101启动/结束T.pdl_sync() / T.pdl_trigger() :23-24,65-66无(PDL 是 CUDA-only)pass_configs关掉 warp specialization :8-10无(AutoSchedule 默认开)
同一份算法,同一套 tie-break 契约;下表的每一行差异都是「并行映射」差异,没有一行改变算法语义。行号为 corpus 快照里的行号。
  • 左列多出来的两行(PDL、pass_configs)是「CUDA 有、昇腾没有对应物」。
  • 右列多出来的四行(流水、缓冲版本、mem_bar、显式向量宽度)是「昇腾必须显式表达、CUDA 交给编译器」。
  • tie-break 契约(并列取最小下标)在两边都用「最小归约」实现:左边是 op='min' 的 reducer,右边是 S.vmin + S.vcmin。
Ascend 版 UB 流水的三个在飞迭代第 w+5 行:GM → scores_ubPIPE: MTE2;T.copy(scores[row, :], scores_ub[:num_experts])MTE2第 w 行:UB → SIMD 计算S.vld ─► vmax/vmin 归约 ─► S.vsts(out_ub[2*k], idx, one)Vector(V)第 w-1 行:out_ub → GMPIPE: MTE3;T.copy(out_ub[:num_topk*2], topk_idx_view[row, :])MTE3
num_stages = 6 让同一时刻有多个迭代同时在飞:一个正在搬进来、一个正在算、一个正在写回去。每一类数据要放几份,必须由 annotate_buffer_versions 显式声明。
  • 缓冲版本数由 T.annotate_buffer_versions 显式声明:scores_ub = num_stages(6)、out_ub = num_stages(6)、invalid_logits_ub = 1。
  • invalid_logits_ub 只有 1 份,因为它是「跨所有迭代共享的累积器」,不是流水数据;所以 T.device_assert 在循环之外执行(:155)。
  • annotations.py:19-45 的语义:1 = 退出多缓冲资格;num_versions >= 2 = 固定份数;也可只给模式(auto / iteration / counter)。
  • 调度器求的是「依赖上至少要几份」,num_stages 表达的是「我想重叠多深」——后者是性能意图,必须由写 kernel 的人给出。
两种并行模型的映射:SIMT 与 SIMD维度NVIDIA GPU(SIMT)昇腾 NPU(SIMD)你必须指定的网格尺寸 + 每 block 线程数只用网格(核)尺寸「线程」存在吗是:threads=32,线程有独立寄存器核这一层不存在;只在 T.SimtVF 里出现一条指令作用于32 个线程,各算 1 个标量64 条 lane,一次算 64 个标量一行怎么分token → block → warp → 每线程几个专家token → Vector 核 → vreg × 64 lane → 分组跨单位通信__shfl / shared / T.alloc_reducer(编译器实现)S.vcmax / S.vcmin(显式跨 lane 归约指令)代码里看得见宽度吗看不见:T.Parallel(n) 只是一个循环看得见:vector_length = 64、T.int32x64、PAT_ALL超出宽度怎么办编译器在向量化时自行决定自己分组:快路径(一组装下) / 慢路径(多组)
CUDA 的并行是「32 个独立的小计算」,SIMD 宽度由编译器在向量化时决定;昇腾的并行是「一条 64 宽度的向量指令」,宽度必须由你写出来。
  • 昇腾的 T.Kernel(N) 只给一个主索引:想用线程就进 T.SimtVF(threads=...)(见 rand/randn_asc.py:34)。
  • 「专家数超过一组」只有昇腾版需要处理(topk_gate_asc.py 的 num_groups > 1 慢路径),因为每条 SIMD 指令的宽度是硬件固定的。
  • 推论:在昇腾上,tile 大小不是性能参数而是语法参数——vector_length / max_vregs_per_group 直接决定走哪条分支。

4.1 先把算法说清楚

topk_gate(scores, num_topk) 的语义是:对每一行(一个 token 的专家打分),选出 分数最高的 num_topk 个专家的下标,返回 int64 的 [num_tokens, num_topk]。

API 契约里有两句关键的话(.../TileKernels/tile_kernels/moe/topk_gate_kernel.py:22-24):

  • Always return the smaller index when there are ties.
  • The output is always contiguous.

「并列时取字典序更小的下标」这一条决定了整个实现形态。torch.topk 的并列语义是 未定义的,所以参考实现必须换成 torch.sort(..., stable=True)(torch/topk.py:18-21)。 而在 kernel 里,这条契约把算法钉死成一件事:

for k in 0 .. num_topk-1:
    找到当前未被选中的最大值 m
    在所有「值等于 m」的下标里取最小的那个 idx        ← tie-break
    把 idx 记到输出第 k 列
    把 idx 位置的分数置为 -inf                       ← 不参与下一轮

也就是**「重复取 max,并列取最小下标」**。它的代价是 num_topk 轮串行,好处是 不需要排序、不需要辅助空间、tie-break 规则天然明确。两个后端都实现这一段, 区别只在「一轮怎么并行地做掉」。

另一个必须记住的实现细节:输入必须是有限的。两个后端都会检查 nan/inf,并且在发现时通过 T.device_assert 让整个 kernel 失败 (CUDA topk_gate_cuda.py:40-46;Ascend topk_gate_asc.py:66-71,155)。 原因也来自 tie-break 契约:如果输入里有 NaN,「最大值」和「相等」都失去定义。

4.2 CUDA 版逐段读

完整文件(.../TileKernels/tile_kernels/moe/topk_gate_cuda.py):

# :1-15  ── 导入与工厂签名
import tilelang
from tilelang import language as T

from tile_kernels.utils import align

@tilelang.jit(
    pass_configs={
        tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True,
    },
)
def get_topk_gate_kernel_cuda(num_experts: int, num_topk: int, use_pdl: bool = False):
    num_tokens = T.dynamic('num_tokens')
    num_threads = 32
    num_aligned_experts = align(num_experts, num_threads)

    @T.prim_func
    def topk_gate_kernel(
        scores: T.Tensor[(num_tokens, num_experts), T.float32],
        topk_idx: T.Tensor[(num_tokens, num_topk), T.int64],
    ):
        with T.Kernel(num_tokens, threads=num_threads) as pid:      # :22
            if use_pdl:
                T.pdl_sync()                                        # :24
            scores_fragment = T.alloc_fragment((num_aligned_experts,), T.float32)
            amax_fragment = T.alloc_fragment((1,), T.float32)
            idx_fragment = T.alloc_fragment((num_aligned_experts,), T.int32)
            idx_reducer = T.alloc_reducer((1,), T.int32, op='min')  # :28
            idx_result = T.alloc_fragment((1,), T.int32)
            nonfinite_reducer = T.alloc_reducer((1,), T.int32, op='sum')
            nonfinite_result = T.alloc_fragment((1,), T.int32)
            topk_idx_shared = T.alloc_shared((num_topk,), T.int32)  # :32

            for i in T.Parallel(num_aligned_experts):               # :34-38
                if i < num_experts:
                    scores_fragment[i] = scores[pid, i]
                else:
                    scores_fragment[i] = -T.infinity(T.float32)

            # Invalid input check, single deferred assert to keep the load loop vectorized
            T.reducer_init(nonfinite_reducer)                       # :41-46
            for i in T.Parallel(num_aligned_experts):
                if i < num_experts and not T.isfinite(scores_fragment[i]):
                    T.reducer_update(nonfinite_reducer[0], 1)
            T.finalize_reducer(nonfinite_reducer, nonfinite_result)
            T.device_assert(nonfinite_result[0] == 0, msg='Score input for topk gate must be finite')

            for i in T.Parallel(num_aligned_experts):               # :48-49
                idx_fragment[i] = i

            # Get topk via repeatedly finding max
            for k in T.unroll(num_topk):                            # :52-62
                T.reduce_max(scores_fragment, amax_fragment)
                T.reducer_init(idx_reducer)
                for i in T.Parallel(num_aligned_experts):
                    if scores_fragment[i] == amax_fragment[0]:
                        T.reducer_update(idx_reducer[0], idx_fragment[i])
                T.finalize_reducer(idx_reducer, idx_result)
                topk_idx_shared[k] = idx_result[0]
                for i in T.Parallel(num_aligned_experts):
                    if idx_fragment[i] == idx_result[0]:
                        scores_fragment[i] = -T.infinity(T.float32)

            T.copy(topk_idx_shared, topk_idx[pid, 0], disable_tma=True)   # :64
            if use_pdl:
                T.pdl_trigger()                                          # :66

    return topk_gate_kernel

逐段解释。

(a) 并行单位:T.Kernel(num_tokens, threads=32)(:22)。 网格是 num_tokens 个 block,每个 block 处理一行、只有 32 个线程(一个 warp)。 「一行一个 block」让下面的所有跨线程通信都退化成 warp 内通信, 这是后面能用 alloc_reducer 的前提。注意 num_tokens 是 T.dynamic 的 (:13),所以网格尺寸运行时才知道。

(b) 片上存储:fragment 存分数与下标,shared 存输出(:25-32)。 T.alloc_fragment 是「按线程切分的寄存器数组」(第 10 章),所以 scores_fragment 逻辑上是 32 个 fp32,物理上每个线程拿几个。 idx_fragment 同理,装的是「这个位置对应哪个专家下标」。 只有 topk_idx_shared(num_topk ≤ 32 个 int32)是 shared memory—— 因为最后一次拷贝要由全 warp 协作完成。

(c) 载入与补齐:越界位置填 -inf(:34-38)。 num_aligned_experts = align(num_experts, 32)(:15)把专家数向上对齐到 32。 为什么能这么随意地补齐?因为「并列取最小下标」只会在真实存在的专家里发生, 而 -inf 永远不可能成为最大值——除非一行的分数全是 -inf,那就被 :40-46 的 有限性检查拦住了。这两段是互相依赖的,缺一个都会让 -inf 补齐变得不安全。

(d) 有限性检查:deferred assert(:40-46)。 注意注释的原话—— 「single deferred assert to keep the load loop vectorized」。做法是: 用 T.Parallel 并行地累加「非有限值的个数」(op='sum' 的 T.alloc_reducer),最后用一个 T.device_assert 断言它为 0。 如果改成在 T.Parallel 循环体里逐个 T.device_assert,编译器就没法把这个循环 向量化/展开成规整的访存了。**「把断言折叠成一次,换取循环的规则性」**是这一层 最实用的一个模式,值得单独记住。

关于 alloc_reducer 本身:它是 v0.1.15 的一等公民, 文档字符串写得很清楚(_vendor/tilelang-v0.1.15/tilelang/language/allocate.py:296-320):

Allocate a reducer: a first-class deferred reduction epoch handle. ... The reducer lives in the virtual local.reducer scope and may only be accessed through the epoch operations ... Physical storage and the cross-thread communication plan are chosen by the compiler; the physical layout can never change how many times a logical contribution is combined.

也就是说,「这 32 个线程各自数出来的 0/1 怎么合并成一个数」不由你决定, 由编译器按当前后端选(warp shuffle、shared memory 都行)。你要写的只是 「初始化 / 每轮贡献 / 收尾」三步(reducer_init / reducer_update / finalize_reducer,定义在 .../tilelang/language/reduce_op.py:358,398,434)。

(e) 一轮 top-k(:52-62):三次并行操作 + 一个标量。 一轮的实际步骤是:

  1. T.reduce_max(scores_fragment, amax_fragment):全 warp 求最大值 (amax = absolute max 的命名沿用自量化,这里就是 max)。
  2. 用 op='min' 的 reducer 收集「所有等于最大值的下标里的最小值」—— 这一句就是 tie-break 契约的落点(:54-58)。
  3. 把选中的位置置 -inf,下一轮不再考虑(:60-62)。

第 2 步用 T.alloc_reducer(..., op='min') 而不是手写 shuffle 归约, 是这份代码最有教学价值的地方:min 与 sum 的通信量一样,但语义完全不同。 把「语义」而不是「通信模式」写出来,编译器才能在昇腾上换成完全不同的实现。

(f) 输出:int32 shared → int64 global(:64)。 T.copy(topk_idx_shared, topk_idx[pid, 0], disable_tma=True): 源是 int32、目标是 int64,T.copy 在两侧 dtype 不同时会做逐元素转换 (标量路径见 _vendor/tilelang-v0.1.15/tilelang/language/copy_op.py:122-127, 区域路径在 copy.cc 里做同样的转换)。索引非负且 < 2^31,所以转换精确。 disable_tma=True 是 CUDA 侧的关键字提示(copy_op.py:77 的 tilelang.cuda.language.copy 才带它);对这种「只搬几十个字节」的小拷贝,走 TMA(张量内存加速器)的建描述符开销 不划算,所以显式关掉。

(g) 两个开关:TL_DISABLE_WARP_SPECIALIZED(:7-11)与 PDL(:23-24,65-66)。 前者关掉 warp specialization:这个 kernel 只有 32 个线程、逻辑上就是「一个 warp 干一件事」, 再去做 producer/consumer warp 拆分只会引入无意义的同步。后者是 CUDA 专属的 Programmatic Dependent Launch,让相邻 kernel 能重叠启动(§3.1)。 顺序很讲究:pdl_sync() 在读输入之前、pdl_trigger() 在写输出之后, 这样「等上游写完」和「通知下游可读」都落在正确的边界上。

4.3 Ascend 版逐段读

昇腾版长得多,我们分三段看:编译期配置、缓冲区与流水、SIMD 主体。

(a) 编译期配置:显式的向量宽与分组(:1-23)。

# .../TileKernels/tile_kernels/moe/topk_gate_asc.py:1-23
import tilelang
from tilelang.ascend import language as T
from tilelang.ascend.language import simd as S

from tile_kernels.utils import align, ceil_div

@tilelang.jit
def get_topk_gate_kernel_asc(num_experts: int, num_topk: int, num_vec_cores: int):
    num_stages = 6
    vector_length = 64  # SIMD vector length (float32 lanes per vreg)

    # Adaptive: use only as many vregs as needed when num_experts <= 256.
    max_vregs_per_group = 6
    num_vregs_per_group = ceil_div(min(num_experts, max_vregs_per_group * vector_length), vector_length)
    experts_per_group = num_vregs_per_group * vector_length
    num_groups = ceil_div(num_experts, experts_per_group)
    num_aligned_experts = num_groups * experts_per_group

    # Round up to a multiple of vector_length for UB alignment of the index output.
    num_aligned_topk = align(num_topk, vector_length)
    num_tokens = T.dynamic('num_tokens')

四个常量决定了后面的一切:

  • vector_length = 64:一条向量寄存器(vreg)装 64 条 fp32 SIMD lane。 这是硬件的显式宽度,不是编译器猜的——第 19 章讲的「SIMD lane 才是昇腾上的 『线程』」在这里落成了代码。
  • max_vregs_per_group = 6:一组最多用 6 个 vreg,即一组最多覆盖 6 × 64 = 384 个专家。加上注释里那句「when num_experts <= 256」, 可以推出作者的实际区间:num_experts ≤ 384 时 num_groups == 1, 走快路径(:73-105);超过就走慢路径(:106-151)。
  • num_aligned_experts:把专家数向上对齐到 experts_per_group 的整数倍, 让「按组遍历」的循环上界是常量。
  • num_aligned_topk = align(num_topk, vector_length):顺带解决了 int64 视图的空间问题 (见 (c) 的 T.view)。num_topk ≤ 32 时它至少是 64,而 int64 视图需要 2 × num_topk ≤ 64 个 int32 槽,刚好放得下。

(b) 网格、缓冲与流水(:30-48)。

# :30-48
with T.Kernel(num_vec_cores) as core_id:
    scores_ub = T.alloc_shared((num_aligned_experts,), T.float32)
    out_ub = T.alloc_shared((num_aligned_topk,), T.int32)
    invalid_logits_ub = T.alloc_shared((vector_length,), T.int32)
    topk_idx_view = T.view(topk_idx, (num_tokens, num_topk * 2), T.int32)
    T.annotate_buffer_versions({scores_ub: num_stages, out_ub: num_stages, invalid_logits_ub: 1})

    with T.SimdVF():
        T.clear(out_ub)
        T.clear(invalid_logits_ub)

    for w in T.Pipelined(
        T.ceildiv(num_tokens, num_vec_cores),
        num_stages=num_stages,
        annotations={'multi_buffer_eligible': [scores_ub, out_ub]},
    ):
        row = w * num_vec_cores + core_id
        if row < num_tokens:
            T.copy(scores[row, :], scores_ub[:num_experts])
            ...

这里有五个必须逐个说清的决定:

  1. 网格是 num_vec_cores,没有 threads=(:30)。 对比 CUDA 的 T.Kernel(num_tokens, threads=32):昇腾的 grid 就是「Vector 核的编号」, 每个核处理一行;with T.Kernel(N) as bx 里的线程域是不存在的 (_vendor/tilelang-v0.1.15/tilelang/ascend/language/kernel.py:139-172 的 docstring:「There is no SIMT thread domain at this scope, so this dialect has no threads parameter ... Use T.SimtVF(threads=...) inside the body to run thread-parallel code」)。
  2. T.alloc_shared 在这里是 UB,不是 CUDA 的 shared memory。 M 树的昇腾方言故意 让 alloc_shared 指向 UB(.../tilelang/ascend/language/__init__.py:8-18 的注释: 「alloc_shared deliberately shadows the common surface: Ascend UB takes bool buffers in the requested scope」)。三个 UB 缓冲各自承担一个角色: scores_ub 放分数、out_ub 放输出、invalid_logits_ub 放跨所有迭代累计的 非法标志——这正是为什么它的版本数是 1 而另外两个是 num_stages(:35)。
  3. T.view 把 int64 输出当成 int32 来写(:34)。 T.view 的契约是 「shape 与 dtype 必须保持总位数不变、源必须紧凑且 elem_offset 为 0」 (_vendor/tilelang-v0.1.15/tilelang/language/customize.py:101-140)。 [num_tokens, num_topk] int64 → [num_tokens, num_topk*2] int32 满足这个条件。 于是「写一个 int64 下标」变成「写两个 int32 槽:低位写下标、高位保持 0」。 注意到 UB 的写入用 'ONEPT_B32'(单 lane 写,:101,139),而高位之所以是 0, 是因为 T.clear(out_ub) 在最前面把整个缓冲区清过零——这不是顺手为之, 而是 int64 视图正确性的必要条件。
  4. T.annotate_buffer_versions 显式声明版本数(:35)。 语义见 .../tilelang/ascend/language/annotations.py:19-65:值为 1 表示 「退出多缓冲资格」;≥2 表示「固定用这么多份」;也可以只给模式 ("auto" / "iteration" / "counter")。这里 scores_ub 与 out_ub 各要 6 份,invalid_logits_ub 只要 1 份。为什么必须手写? 因为这三个缓冲区 讲的是三件不同的语义:前两个是「每个迭代一份、可以重叠」的流式数据, 第三个是「整个 kernel 一份」的累积器。调度器可以从依赖图推出「有几处读写」, 但推不出「这份数据是每轮重置还是全程共享」——那必须由你告诉它。
  5. 流水结构:T.Pipelined(count, num_stages=..., annotations={'multi_buffer_eligible': [...]}) (:41-45)。 外层是「每核要处理多少个 token」的循环,num_stages=6 表示 流水深度 6 级;multi_buffer_eligible 进一步告诉调度器「这两个缓冲可以 化成多份以支撑流水」。注意工作划分是 row = w * num_vec_cores + core_id (:46)——按核交错分配 token(核 0 处理第 0、V、2V… 行),而不是 把 token 连续切给每个核。这样每次迭代搬进 UB 的都是「一整行」, 与 T.copy(scores[row, :], scores_ub[:num_experts])(:48)的连续读天然匹配。

(c) SIMD 主体:显式的向量槽位(:50-105 快路径)。

# :50-71  ── 局部变量、掩码、非有限值检测
with T.SimdVF():
    mask_32b = S.pset(32, 'PAT_ALL')
    one = S.pset(32, 'PAT_VL1')

    neg_inf = S.vdup(-T.infinity(T.float32), T.float32, mask_32b)
    int_max = S.vdup(T.max_value(T.int32), T.int32, mask_32b)

    scores_vec = S.alloc_local((num_vregs_per_group,), T.float32)
    index_vec  = S.alloc_local((num_vregs_per_group,), T.int32)
    tmp_idx_vec = S.alloc_local((num_vregs_per_group,), T.int32)

    lanes = S.vci(0, T.int32)
    zero = S.vdup(0.0, T.float32)
    zeros = S.vdup(0, T.int32)
    invalid = T.alloc_var(T.int32x64, init=S.vld(invalid_logits_ub[0]))
    for j in T.unroll(ceil_div(num_experts, vector_length), explicit=True):
        valid = S.vcmps(S.vadds(lanes, j * vector_length), num_experts, op='lt')
        logit = S.vsel(S.vld(scores_ub[j * vector_length]), zero, valid)
        nonfinite = S.vcmps(S.vsub(logit, logit), 0.0, op='ne')
        invalid = S.vmax(invalid, S.vsel(S.vdup(1, T.int32), zeros, nonfinite))
    S.vsts(invalid_logits_ub[0], S.vcmax(invalid), one)
  • S.pset(32, 'PAT_ALL') / S.pset(32, 'PAT_VL1'):谓词掩码,分别表示 「全 64 个 32 位 lane 都置位」和「只置第 1 个 lane」。SIMD 原语大多要显式带掩码, 因为硬件是 64 lane 一起算的(第 19 章 §7.5)。
  • S.vci(0, T.int32) 生成 lane 下标向量 [0,1,...,63],用它和常量比较就能造出 「有效 lane」掩码,替代了 CUDA 版里「越界填 -inf」的补齐动作—— 昇腾版靠掩码,CUDA 版靠补齐,这是两种机器上等价的两种做法。
  • nonfinite = S.vcmps(S.vsub(logit, logit), 0.0, op='ne'):用 x - x != 0 统一检测 NaN 和 ±inf。有限数相减得 0;NaN/inf 相减得 NaN, 而 NaN != 0 为真。这是比 isfinite 更省指令的写法,代价是可读性—— 读代码时要反过来推才知道它在干什么。
  • invalid 是一个 T.int32x64 向量寄存器,初值从 invalid_logits_ub 读入、 每行 S.vmax 累积、每轮写回。这就是「一个跨迭代累积的标志位」在 SIMD 上的形状: 用 1 打标、用 max 归约,最后在循环外一次性 T.device_assert (:155)——和第 4.2(d) 的 deferred assert 是完全同一个思路,只是这里 「累积」发生在 UB 里而不是 reducer 里。

接下来是快路径的主体:

# :73-105  ── num_groups == 1:一组 vreg 装下所有专家
if num_groups == 1:
    for i in T.unroll(num_vregs_per_group, explicit=True):
        scores_vec[i] = S.vld(scores_ub[i * vector_length])
        index_vec[i] = S.vci(T.int32(i * vector_length), T.int32)

    num_exp = S.vdup(T.int32(num_experts), T.int32, mask_32b)
    for i in T.unroll(num_vregs_per_group, explicit=True):
        scores_vec[i] = S.vsel(scores_vec[i], neg_inf, S.vcmp(index_vec[i], num_exp, mask_32b, 'lt'))

    acc_max = S.alloc_var(T.float32)
    acc_min_idx = S.alloc_var(T.int32)
    for k in T.serial(num_topk):
        acc_max = scores_vec[0]
        for i in T.unroll(1, num_vregs_per_group, explicit=True):
            acc_max = S.vmax(acc_max, scores_vec[i], mask_32b)
        mx = S.vdupv(S.vcmax(acc_max, mask_32b), mask_32b)

        for i in T.unroll(num_vregs_per_group, explicit=True):
            tmp_idx_vec[i] = S.vsel(index_vec[i], int_max, S.vcmp(scores_vec[i], mx, mask_32b, 'eq'))
        acc_min_idx = tmp_idx_vec[0]
        for i in T.unroll(1, num_vregs_per_group, explicit=True):
            acc_min_idx = S.vmin(acc_min_idx, tmp_idx_vec[i], mask_32b)
        idx = S.vdupv(S.vcmin(acc_min_idx, mask_32b), mask_32b)

        S.vsts(out_ub[2 * k], idx, one, 'ONEPT_B32')

        for i in T.unroll(num_vregs_per_group, explicit=True):
            scores_vec[i] = S.vsel(neg_inf, scores_vec[i], S.vcmp(index_vec[i], idx, mask_32b, 'eq'))

把这段和 CUDA 版的 :52-62 摆在一起,你会看到同一个算法的两种「一轮」:

步骤 CUDA(一个 warp,32 线程) Ascend(一个 Vector 核,64 lane × N 个 vreg)
求最大值 T.reduce_max(编译器选 shuffle 或 shared) S.vmax 把所有 vreg 折成一个,再 S.vcmax 做跨 lane 归约
取并列最小下标 op='min' 的 T.alloc_reducer 先 S.vsel 把非并列位置换成 INT_MAX,再 S.vmin + S.vcmin
写第 k 个结果 写 shared,最后统一拷贝 S.vsts(..., one, 'ONEPT_B32') 直接写单个 lane
屏蔽已选 T.Parallel + 条件赋值 S.vsel 用掩码把并列位置换成 neg_inf

S.vdupv(v, mask) 的语义是「把标量 v 广播成一个向量」;S.vcmax / S.vcmin 是 「跨 lane 归约」;S.vsel(a, b, mask) 是「mask 为真取 a、否则取 b」 (定义见 _vendor/tilelang-v0.1.15/tilelang/ascend/language/simd.py:922,969,976,1429)。 也就是说,因为硬件没有「32 个线程各自算完再交换」这回事, 「归约」在昇腾上必须由一个显式指令完成;CUDA 上它可能是一串 shuffle, 你不需要知道具体是什么。

(d) 慢路径与写回(:106-157)。 当 num_groups > 1 时,一组 vreg 装不下所有专家, 于是每一轮 k 被拆成三个阶段:Phase 1 逐组求局部最大值并累积全局最大值; Phase 2 逐组求出「等于全局最大值的 lane 里最小的下标」;Phase 3 把这个中奖 lane 在 UB 里置成 neg_inf(:114-149)。Phase 3 每次都要写回 UB,所以作者在循环末尾 插了一句 S.mem_bar('VST_VLD')(:151)——这是全文件唯一一句手写的同步, 因为「刚写进 UB 的值,下一次迭代要读回来」这条依赖,在 SIMD 指令流里必须显式保证 (第 19 章 §2.4)。

最后两行收尾:

# :153-155
T.copy(out_ub[: num_topk * 2], topk_idx_view[row, :])
...
T.device_assert(invalid_logits_ub[0] == 0, msg='topk_gate input contains nan or inf!')

:153 的拷贝搬的是 num_topk * 2 个 int32(即 num_topk 个 int64)到 int32 视图的那一行。注意 :155 的断言在 T.Pipelined 循环之外: invalid_logits_ub 只有 1 个版本、被所有迭代共享,所以在所有迭代结束后统一检查。 这也是「为什么它的版本必须是 1」的答案——它是个累积器,不是流水数据。

4.4 两个后端并排对照

(对应 图:02-topk-gate-compare 的图 2)

维度 CUDA 版 topk_gate_cuda.py Ascend 版 topk_gate_asc.py
网格 T.Kernel(num_tokens, threads=32) :22 T.Kernel(num_vec_cores) :30
并行单位 1 block / token,1 warp / block 1 Vector 核 / token,64 lane / 指令
一行的存放 T.alloc_fragment(寄存器):25-27 T.alloc_shared(UB):31
「归约」的表达 T.alloc_reducer(op='min'/'sum') + reducer_init/update/finalize :28,41-45,54-58 S.vmax/S.vmin/S.vcmax/S.vcmin :89,98-99
越界元素 补齐成 -inf :37-38 掩码屏蔽 :81,120
非法值检测 reducer 累加 + 循环外一次 T.device_assert :41-46 x-x != 0 打标 + vmax 累积 + 循环外断言 :66-71,155
网格剩余处理 网格 = token 数,天然整除 if row < num_tokens 守卫 :47
多迭代工作划分 无(一 block 一行) 按核交错:row = w*V + core_id :46
流水 无(一行一批做完) T.Pipelined(..., num_stages=6) :41-45
缓冲版本 由编译器决定 显式 T.annotate_buffer_versions :35
显式同步 无 一句 S.mem_bar('VST_VLD') :151
向量宽度 由编译器选(T.Parallel + 向量化) 写死 vector_length = 64 :11
int64 输出怎么写 T.copy 做 int32→int64 转换 :64 T.view 成 int32 视图、逐 lane 写 :34,101
启动/结束提示 T.pdl_sync() / T.pdl_trigger() :23-24,65-66 无(PDL 是 CUDA-only)
pass_configs 关掉 warp specialization :8-10 无(AutoSchedule 默认)

这张表现在可以回答本章最开始那个问题的一半了:同一份算法,两个后端的差异不是 「算法差异」,而是「并行映射差异」。上表里没有一行改变算法语义——每一行都在回答 「这块芯片上的并行单位是什么、数据放哪、什么时候同步、缓冲区有几份」。

4.5 为什么两边的并行单位根本不同

(对应 图:05-parallel-model-map 的图 5)

上半张表里最容易被当作「风格差异」的一行,其实是整个对照的根因:

NVIDIA CUDA 后端 昇腾 Ascend 后端
你必须指定的 网格尺寸 + 每 block 线程数 只用网格(核)尺寸
「线程」存在吗 是(threads=32),线程有独立寄存器和栈 在核这一层不存在;只在 T.SimtVF(threads=...) 里出现
一条指令作用于 32 个线程,每个线程 1 个标量(SIMT) 64 条 lane,一次算 64 个标量(SIMD)
每个「并行单位」拿到什么 逻辑上是一个标量:线程 t 处理元素 t 逻辑上是一个 64 元素的向量
跨单位通信 __shfl_sync、shared memory、T.alloc_reducer S.vcmax/S.vcmin/S.vcadd 等跨 lane 归约指令,或走 UB
代码里看得见宽度吗 看不见(T.Parallel(32) 就是一个循环) 看得见(vector_length = 64、T.int32x64、PAT_ALL)

把它讲成一句话:

CUDA 的并行是**「32 个独立的小计算」,SIMD 宽度是编译器在向量化时自己选的; 昇腾的并行是「一条 64 宽度的向量指令」**,你就得按这个宽度写代码。

这解释了两个后端里最刺眼的两处不同:

  • CUDA 版用 T.Parallel(num_aligned_experts) 处理「每个线程管几个专家」, 写法完全不依赖 32 这个数;换成 64 线程数的实现,源码几乎不用改。
  • Ascend 版必须显式管理「一组几个 vreg、一组覆盖多少个专家、分几组」 (:14-18)——因为每条指令一次只吃 64 个 lane,专家数超过一组能覆盖的范围 就得自己分组(:106-151 的慢路径)。这不是风格,是硬件的形状。

对读者最实用的一条推论是:在昇腾上,「tile 大小」不是个性能参数,而是个语法参数。 你在 CUDA 上写 block_N = 128 只是想让 GEMM 好看;你在昇腾上写 experts_per_group = 384 直接决定了代码走哪条分支。

4.6 为什么 Ascend 版要显式管缓冲区版本数

(对应 图:03-ascend-ub-pipeline 的图 3)

Ascend 版把「一行」拆成了 GM → UB(MTE2 搬运)→ 计算(Vector)→ UB → GM(MTE3 搬运) 四段,并且用 num_stages = 6 把它们叠成流水:当第 w+1 行正在被搬进 UB 时, 第 w 行正在被计算、第 w-1 行正在被写回。这就是 T.Pipelined 的全部意义 (第 11 章)。

但要流水,就必须回答一个问题:同时在做三件事,UB 上要放几份 scores_ub? 答案显然是「至少 3 份」——写第 w+2 行、算第 w+1 行、读第 w 行同时发生。 T.annotate_buffer_versions({scores_ub: num_stages, ...}) 就是在回答它。

为什么调度器不能自己算?看 annotations.py:19-45 的 docstring 就明白了:

When using T.Persistent with num_stages > 1, the Z3 auto-scheduler computes optimal buffer version counts for each on-chip buffer. Each mapping value may be: 1 opt out of multi-buffer eligibility ...; num_versions >= 2 use a fixed version count ...; mode select a mode while leaving the version count to the scheduler. ... A fixed count is useful when you want to guarantee double-buffering for a critical buffer regardless of the Z3 solver's choice.

也就是说,调度器确实会算,但算的是「在给定依赖下最少要几份」。而 「我想更深地重叠」是性能意图,不是依赖约束——除非你明确说出来。 再加上一点:昇腾的 UB 容量是硬上限(config.py:81-83 里库自己的口径是每核 216 KB / 248 KB),份数越多越可能撞上容量墙。所以这份文件里三件事—— num_stages、annotate_buffer_versions、multi_buffer_eligible——是互相制约的 三个旋钮,必须由写 kernel 的人一起定。

对照 CUDA 版:CUDA 版根本没有「一行一行流」的结构,一个 block 处理一行、 做完就退出,不存在缓冲区复用的时间窗口,所以从来没有这个问题。 这就是「同一份算法,Ascend 版必须显式管缓冲版本」的全部原因: 流水是昇腾侧为了掩盖搬运延迟而引入的结构,而 CUDA 侧用的是「网格并行度」掩盖它。

4.7 int64 输出在两边怎么落地

一个很具体但很有意思的差异。API 承诺输出 int64,而两块芯片的原生索引宽度都是 32 位(CUDA 侧 idx_local/idx_reducer 全是 T.int32;Ascend 侧全在 32 位 lane 上算)。 两边的解法不同:

做法 依赖的前提
CUDA :64 T.copy 从 int32 shared 拷进 int64 global,靠逐元素转换 T.copy 的 dtype 转换(copy_op.py:122-127)
Ascend :34,101,153 把 int64 张量 T.view 成 [num_tokens, 2*num_topk] int32,低位写索引、高位靠 T.clear 保持 0 T.view 的「总位数不变 + 源紧凑 + elem_offset==0」契约(customize.py:101-140)

两种做法都成立,但约束不一样:CUDA 版依赖 copy 的隐式转换; Ascend 版依赖「高位是 0」这个不变量,而它是由 T.clear(out_ub) 建立的。 如果哪天有人把 T.clear(out_ub) 删掉(比如为了省指令), 索引会变成「低位正确、高位是垃圾」的巨型数字,而且这种错误在 小 shape 的测试里可能看不出来。这是 §4.3(b) 里那句「这不是顺手为之」的分量所在。

4.8 常见坑(kernel 层)

编号 坑 为什么危险
K1 在 T.Parallel 循环体里逐个 T.device_assert 打断循环的规则性;CUDA 版特意折叠成一次 assert(:40)
K2 昇腾版忘了 T.clear(out_ub) int64 视图的高位变垃圾(§4.7)
K3 把 invalid_logits_ub 也加上多版本 它就不再是「全程一个累积器」,T.device_assert 会漏检前面迭代的坏值(:35)
K4 在昇腾分支里写 T.pdl_sync() M 树昇腾方言没有这个 API(§3.1)
K5 用 -inf 补齐昇腾的越界 lane 昇腾版靠掩码(:81,120);补 -inf 会与「非法值检测」互相干扰
K6 以为两边 tie-break 规则可以不同 测试是逐字节比对(testing/numeric.py:18-21),任何 tie 差异都会 red
K7 把 num_vec_cores 改成运行期值 会让 experts_per_group 等常量退化,慢路径与快路径的选择也随之漂移
K8 在 SIMD 段里忘了 S.mem_bar('VST_VLD') 刚写 UB 的值下一轮读回来时可能还没落地(:151)

5. 量化算子族:三种粒度,两套后端

(对应 图:04-quant-family 的图 4)

tile_kernels/quant/ 是本章第二大的一块,也是最容易「看名字以为懂了、其实没懂」的一块。 它一共只有 三种缩放粒度、两条数据方向、一个融合:

名字 缩放粒度 语义
per_token_cast 每个 token 一组(沿通道切) sf_block = (1, num_per_channels):一行里的第 j 列用 sf[i, j // num_per_channels]
per_block_cast 每个 2D 块一组 sf_block = (num_per_tokens, num_per_channels):块内共享一个尺度,主流配置是 (32, 32) / (128, 128)
per_channel_cast 每个通道组跨多个 token sf_block = (num_per_tokens, 1):一列上连续 num_per_tokens 行共享一个尺度
cast 无(逐元素) 纯 dtype 转换,可选随机舍入
cast_back / per_token_cast_back 与输入侧同构 反量化:x * sf 还原成 bf16/fp32
swiglu_forward_and_per_token_cast per-token SwiGLU 前向 + 量化融合成一个 kernel

三类粒度的差别不是「块大小」的差别,而是「尺度作用在哪个维度」的差别:

尺度沿哪个维度变化 典型用途
per-token 只随行与列块变化,块大小 1×K 激活(每个 token 的动态范围不同)
per-block 行列都变 权重与激活,MXFP4/MXFP8 类分块量化
per-channel 只随列块变化,块大小 M×1 需要跨多个 token 统计的通道尺度
量化算子族:三种粒度、共用算术、后端差异输入 x[num_tokens, hidden],bf16 / fp32选哪种粒度?per_token_castsf_block = (1, C),C ∈ {16,32,64,128}per_block_castsf_block = (Rt, C);昇腾只支持 (32,32)per_channel_castsf_block = (T, 1);昇腾只支持 T = 32get_sf_and_inv(共享算术)clamp → amax/max → 幂次化 → 打包fp8(e4m3)fp4(e2m1)int8 两两打包cast_back / per_token_cast_back反量化 → bf16 / fp32swiglu_forward_and_per_token_castSwiGLU + 量化融合BaseCastConfig(frozen dataclass)sf_block / use_packed_ue8m0 / use_e4m3_sf后端差异UE8M0 打包:CUDA 4 / 昇腾 2;use_e4m3_sf 只支持 CUDA;列主序 sf 对齐要求不同
三个 cast 的差别在 sf_block 的形状(缩放作用在哪个维度),算术与配置完全共享;三条后端差异会让 sf 张量的形状或取值不同。
  • 粒度是 sf_block 的形状,不是「块大小」:(1,C) 是 per-token、(Rt,C) 是 per-block、(T,1) 是 per-channel。
  • get_sf_and_inv 是两个后端唯一的算术权威,所以舍入与 clamp 的口径不可能不一致。
  • 融合路径省掉的是「SwiGLU 结果写回 GM、再读回来量化」这一趟往返;代价是多套 UB 缓冲。

5.1 共享的配置层:quant/common.py

三个 cast kernel 的「什么算合法、尺度怎么存」全部集中在 quant/common.py。 核心是两行:

# .../TileKernels/tile_kernels/quant/common.py:19-33
@dataclass(frozen=True)
class BaseCastConfig:
    torch_dtype: torch.dtype = torch.float8_e4m3fn
    sf_block: tuple[int, int] = (1, 1)
    use_tma_aligned_col_major_sf: bool = False
    use_packed_ue8m0: bool = False
    use_e4m3_sf: bool = False

    @property
    def with_sf(self) -> bool:
        return self.torch_dtype in [torch.float8_e4m3fn, torch.int8, torch.uint32]

sf_block 就是粒度;with_sf 就是把「要不要缩放因子」这件事折叠成三个布尔开关, 供 JIT 做特化(§2.2 的第 2 条职责)。目标格式只有两个:e4m3 与 e2m1 (common.py、config.py:34-36)。fp4 用 torch.int8 承载两个一包, 所以有一个专门的换算:

# .../TileKernels/tile_kernels/quant/common.py:174-185
def get_logical_hidden(hidden, dtype):
    """Compute hidden size when `torch.int8` is used for packing FP4"""
    return hidden if dtype != torch.int8 else hidden * 2

def get_physical_hidden(hidden, dtype):
    return hidden if dtype != torch.int8 else hidden // 2

逻辑宽度与物理宽度是两个概念:fp4 张量在 PyTorch 里只有 hidden/2 个 int8, 但算法上要按 hidden 个元素算。所有 cast kernel 的第一个动作都是 hidden = get_logical_hidden(hidden, x.dtype)(例如 per_token_cast_kernel.py:31)。 第 27 章讲的「量化产物的 layout」在这里有了具体形状。

还有一处显式的后端差异,而且它会造成两种张量布局:

# .../TileKernels/tile_kernels/quant/common.py:70-75
def get_packed_ue8m0_pack_factor() -> int:
    return 2 if is_ascend() else 4

def get_packed_ue8m0_torch_dtype() -> torch.dtype:
    return torch.int16 if is_ascend() else torch.int32

「UE8M0」是把尺度因子压成 8 位指数(只存 2^e 的 e)。 打包时 CUDA 一次塞 4 个(int32),昇腾一次塞 2 个(int16)。 两个数都是 32 位字长,但同一份权重在两种设备上的 sf 张量形状不同—— 所以「把 CUDA 上量化好的权重拿到昇腾上跑」需要重新打包。这是真实部署里必须知道的一条。

5.2 换算口径:get_sf_and_inv

所有 cast kernel 的数值核心是同一个宏(:251-282):

# .../TileKernels/tile_kernels/quant/common.py:251-282(节选)
@T.macro
def get_sf_and_inv(amax, out_config, sf_inv_dtype=T.float32):
    clamped_amax = T.max(amax, out_config.clamp_min_value)   # 防 0 → 除零
    max_value = T.max_value(out_config.dtype)                # e4m3:448 / e2m1:6
    sf = T.alloc_var(T.float32)
    if out_config.round_sf:
        # round_sf only uses ceil(log2(sf)) ...
        sf = clamped_amax * (T.float32(1) / max_value)
    else:
        sf = clamped_amax / max_value
        if out_config.use_e4m3_sf:
            sf_fp8 = T.cast(sf, T.float8_e4m3fn)
            return sf_fp8, 1 / T.cast(sf_fp8, T.float32)
        else:
            return sf, max_value / clamped_amax

    # Round into 2's power
    bits = T.reinterpret(sf, T.uint32)
    # clamp_min_value ensures sf is positive and normal before exponent extraction.
    # `(bits - 1) >> 23 + 1` gives ceil(log2).
    exp_sf = ((bits - 1) >> 23) + 1 - 127
    ...

四件事值得说:

  1. clamp_min_value 是必需的,不是保险。 全零行会让 amax = 0, 于是 sf = 0、sf_inv = ∞,输出整行变成 NaN。库给的默认下限写在 config.py:10-15:e4m3 非幂次舍入时是 1e-4,其他情况按目标格式的 最大可表示值再乘一个小的比例。clamp_min_value 还承担第二个职责—— 保证 sf 是正规格数,否则下面按位取指数的技巧就不成立 (注释原话:「ensures sf is positive and normal before exponent extraction」)。
  2. round_sf 是「把尺度取成 2 的幂」,用位运算实现 ceil(log2(...))。 这么做的动机是:x / sf 与 x * (1/sf) 在 2 的幂下结果完全一致, 于是可以把除法换成乘倒数,且不会引入额外误差(注释: 「for supported formats this reciprocal multiply preserves the same power-of-two bin as division」)。这是 MX 格式能在硬件上做快的原因之一。
  3. use_packed_ue8m0 时返回的不是浮点数,而是 uint8(exp_sf + 127)—— 也就是直接吐指数的偏置表示,后面的 kernel 会把它按位打包。
  4. use_e4m3_sf 只在 CUDA 上可用(common.py:118、cast_back_kernel.py:41、 per_token_cast_kernel.py:33 各有一处断言)。昇腾侧不支持「用 fp8 存尺度」这条路。

这一节的全部数值细节,第 27 章已经完整推导过;本节只补一件事: 这些口径被写成了两个后端共用的一个宏。两个 kernel 调用同一个 get_sf_and_inv,所以「舍入方式、clamp、幂次化」在 CUDA 与昇腾上不可能不一致。 这是三层结构在数值正确性上的价值:把「容易写错的算术」放进共享层。

5.3 三条 cast 路径在两边怎么分派

三个 *_kernel.py 的分派逻辑几乎是同一个模板。以 per_block_cast 为例:

# .../TileKernels/tile_kernels/quant/per_block_cast_kernel.py:42-61(节选)
if is_ascend():
    from tile_kernels.quant.per_block_cast_asc import get_per_block_cast_kernel_asc

    kernel = get_per_block_cast_kernel_asc(
        hidden=hidden, in_config=in_config, out_config=out_config,
        sf_only=sf_only, cast_only=(sf is not None),
        num_vec_cores=get_num_vec_cores(),
    )
else:
    kernel = get_per_block_cast_kernel_cuda(
        hidden=hidden, in_config=in_config, out_config=out_config,
        sf_only=sf_only, cast_only=(sf is not None),
        use_pdl=get_pdl(),
    )

对比 §3.2 的 topk_gate:结构完全一样(延迟 import / 展开布尔开关 / 核数 vs PDL 二选一)。三处细节值得注意:

  • in_config / out_config 是 dataclass 实例,直接当编译期常量传进 JIT 工厂。 这要求它们是不可变且可哈希的——所以 BaseCastConfig 被声明成 @dataclass(frozen=True)(common.py:19)。「让配置能进缓存键」是一个设计约束, 不是随手的选择。
  • cast_only=(sf is not None) 把「调用方已经给了尺度因子、我只需要搬数据」 这个运行期分支折叠成一个编译期布尔。于是同一个算法生成两个特化版本, 而不是在每个元素上分支。
  • sf_only 同理:只要尺度不要数据(MoE 的 aux loss 之类场景会这么用)。

同一层的形状约束在三个算子上并不相同,而且昇腾侧更紧:

约束 per-token per-block per-channel
CUDA/共享层 num_per_channels ∈ {16,32,64,128}、hidden % 64 == 0(per_token_cast_kernel.py:32) num_tokens % 128 == 0、hidden % 128 == 0(per_block_cast_kernel.py:33-34)、输出需 16 字节对齐(:67) num_per_tokens ∈ {32,128}、num_tokens % 128 == 0、hidden % 128 == 0(per_channel_cast_kernel.py:41-44)
昇腾侧额外 — 必须是 (32, 32)(per_block_cast_asc.py:22),且 block_k 取 align(gcd(512或1024, hidden), 128)(:26-27) num_per_tokens 只能是 32(per_channel_cast_asc.py:21);输入若带尺度,num_per_channels 也只能是 32(:23-24)

这些数字不是「性能偏好」,而是实现里写死的常量。所以「同一份 Python API 跑在两个 后端上」有一个隐含前提:调用方要落在两边都支持的参数子集里。库用断言把这件事 变成编译期错误,而不是运行期错答案——这是正确做法,但读者必须知道它存在。

5.4 cast_back:反量化与「不许越界的组合」

cast_back_kernel.py:26-58 是「同一个套路的反向版」:输入 (data, sf),输出 bf16/fp32。 它多出三条组合约束,都是因为反量化必须重建原尺度:

# .../TileKernels/tile_kernels/quant/cast_back_kernel.py:41-44
assert not (in_config.use_e4m3_sf and is_ascend()), 'E4M3 SF is only supported on CUDA'
assert not (x_block_size[1] == 1 and in_config.use_tma_aligned_col_major_sf), (
    'cast_back does not support col-major scaling factors when num_per_channels == 1'
)

第二条很有意思:当缩放块只有 1 列宽时,列主序布局下「同一个块」的字节会被拆散, 重建尺度需要跨行 gather,代价高。这不是硬件限制,是这套实现选择不做。 库里宁可断言失败,也不生成一个慢十倍的通用路径——这是「库」与「框架」的分工差别。

顺带一个真实的不一致:cast_back_kernel.py:46 用的是 if x.device.type == 'npu':,而不是其他地方统一的 is_ascend() (对比 cast_kernel.py:40、swiglu_forward_kernel.py:48、rand/randn_kernel.py:31)。 两者在正常使用下等价,但判据来源不同(一个看张量、一个看机器)。 写自己的库时应挑一种并保持一致;上游这处差异我们照实标注,不做「统一化」的推测。

5.5 为什么要把 SwiGLU 和量化融在一起

SwiGLU 前向的定义很短:

输入 x: [num_expanded_tokens, 2*hidden]      # 左右两半拼在一起
gate = x[:, :hidden];up = x[:, hidden:]
out  = swish(gate) * up      -> [num_expanded_tokens, hidden]

库里的实现直接做了这件事(quant/swiglu_forward_kernel.py:24-27, hidden = hidden // 2),并且在同一个 kernel 里把结果量化:

# .../TileKernels/tile_kernels/quant/swiglu_forward_kernel.py:40-45(节选)
# Swiglu forward : hidden -> hidden // 2
assert (fmt, num_per_channels) in [('e4m3', 128), ('e4m3', 32), ('bf16', None), ('fp32', None)]
out_config = get_cast_output_config(fmt, (1, num_per_channels if ... else 1), ...)

融合的动机是一条纯粹的访存账。分开做的话:

方案 A(两个 kernel):
  读 x (2H)  →  写 swiglu 结果 (H)  →  读 (H)  →  写 fp8 结果 (H/2) + sf
  总访存 ≈ 2H + H + H + H/2 = 4.5H(按 fp16 计)

方案 B(一个 kernel):
  读 x (2H)  →  写 fp8 结果 (H/2) + sf
  总访存 ≈ 2.5H

(这是本书的记账,忽略 sf 与缓存命中,只用来展示量级;它不是任何机器上的测量。) 中间那趟 H + H 的往返被整个消掉了。SwiGLU 是 memory-bound 算子,所以省下的 就是实打实的时间——这正是第 13 章「融合的意义」在真实库里的样子。

昇腾版还多带了一个 Tailored 的能力:它把 topk_weights 和 routed_scaling_factor 也一起吃进来(swiglu_forward_kernel.py:13-14 的参数、 :84 的调用),也就是说**「MoE 路由权重 × SwiGLU × 量化」三段合成一段**。 于是 swiglu_forward_asc.py:36-56 里出现了三组 UB 缓冲 (xl_ub/xr_ub/out_ub,外加 values_ub、amax_ub、inverse_ub、sf_dense_ub 以及 use_fast_certificate 分支下的第二套 _fb_ub)—— 融合的代价是片上存储压力,而这条压力的边界就是 UB 容量。

5.6 这条线接到第 27 章

把本节压缩成三句可以直接对到第 27 章的话:

  1. 口径共享:get_sf_and_inv 是两个后端唯一的算术权威, 所以「clamp / 幂次化 / 打包」没有第二种解释。
  2. 精度由格式决定:fp8 只有 e4m3、fp4 只有 e2m1, 累加一律 fp32(common.py 里 sf_value_dtype = T.float32 一类写法), 与第 27 章的结论一致。
  3. 布局是后端相关的:UE8M0 的打包因子在 CUDA 是 4、昇腾是 2, 列主序 sf 在昇腾不需要 16 字节对齐而 CUDA 需要(common.py:215-217 的注释: 「CUDA: col-major SF must be aligned to 16 bytes for TMA load; Ascend needs no alignment」)。权重跨设备搬运时必须重打包。

5.7 常见坑(量化层)

编号 坑 为什么危险
Q1 忘了 get_logical_hidden fp4 的 hidden 差一倍,shape 断言或结果错
Q2 把 CUDA 上打包的 UE8M0 sf 直接喂给昇腾 打包因子 4 vs 2,形状与取值都错(common.py:70-75)
Q3 在昇腾上开 use_e4m3_sf 断言失败(common.py:118)
Q4 在昇腾上给 per_block_cast 传 (128,128) 只支持 (32,32)(per_block_cast_asc.py:22)
Q5 想当然地认为「断言里的形状限制是性能建议」 它们是实现常量,越界直接报错
Q6 全零行没有 clamp sf=0、sf_inv=inf、输出 NaN(common.py:252-254)

6. RoPE 与 Rand:双后端不只是大算子的事

6.1 RoPE:同一个旋转,两种向量映射

RoPE(旋转位置编码)的算法只有几行:把 head 维度按「前半 / 后半」配对 (NeoX 口径)或「奇偶配对」(GPT-J 口径),每组做一次二维旋转:

out_first  = first * cos + second * (-sin)   (或共轭时取反)
out_second = first * sin + second *  cos

接口层(transform/rope_kernel.py:9-38)把 3D [tokens, heads, dim] 与 4D [batch, seqlen, heads, dim] 统一成 4D,再做后端分派(:80-115)。约束是: rot_dim ∈ {64, 128}、dtype 是 bf16/fp32、原地修改、cos_sin_cache 必须是 fp32 连续张量。同一份分派有两个后端工厂:get_rope_kernel_cuda / get_rope_kernel_asc。

两个实现的差异非常集中,可以直接对上 §4.5 的并行模型:

CUDA rope_cuda.py Ascend rope_asc.py
网格 T.Kernel(ceildiv(seqlen, tokens_per_cta), batch, threads=threads)(:59) T.Kernel(num_vec_cores)(:120)
并行宽度怎么定 由 nheads 与 half_dim 反推 threads(:40-45) 写死 SIMD_LANES = 64(:9)
一行放哪 T.alloc_local(寄存器,:62-65) T.alloc_shared(UB,:121-122)
缓冲区版本 不需要 T.annotate_buffer_versions({x_ub: num_stages, cos_sin_ub: num_stages})(:123)
与容量的关系 只受寄存器数量约束 从 UB 容量反推 tile 大小(:31-36)
两半怎么配对 两次带偏移的索引(:98-100) S.vintlv / S.vdintlv 把两半交错进一个向量(:168,176)

最后一行是昇腾实现里最「机器特有」的一步,值得展开。NeoX 口径要求把 x[..., :half] 与 x[..., half:] 配对乘加;在 CUDA 上这就是两次带不同偏移的 读(rope_cuda.py:98-100),硬件各发一次访存、每个线程各算一个标量, 「谁和谁配对」不关硬件的事。而在 SIMD 上,一次乘加只能作用于同一个 vreg 的 64 个 lane:要参与同一次 FMA 的两个元素,必须落在两个 vreg 的同一个 lane 位置上。 于是「谁和谁配对」从一个索引问题变成了一次 lane 重排:

  • 打包的 bf16 加载(load2(..., packed_bf16=True))把 128 条 bf16 lane 按 偶数 / 奇数拆成两个 vreg——vcvt(..., part=0/1) 就是 PART_EVEN / PART_ODD (_vendor/tilelang-v0.1.15/tilelang/ascend/language/simd.py:1247-1249)。 但 NeoX 要的是「前半 / 后半」,所以先 S.vintlv 重排成两半、旋转之后再 S.vdintlv 拆回偶奇交给打包写回(rope_asc.py:182-192,rotary_dim = 128 分支)。
  • GPT-J(interleaved=True)的搭档本来就是相邻的(2i, 2i+1), 所以打包路径不需要这一步;反过来,fp32 路径下 load2 天然给出前后两半 (rope_asc.py:79-81),于是需要重排的是 GPT-J 而不是 NeoX。

注意这与旋转公式本身无关:公式一行没变,变的是操作数怎么排进 lane。 这正是「同一份算法、两种并行映射」最纯粹的一个例子。

另外两处细节说明昇腾实现必须自己算容量:

# .../TileKernels/tile_kernels/transform/rope_asc.py:30-37
num_vec_cores = get_num_vec_cores()
dtype_size = dtype.bytes
bytes_per_row = nheads * rotary_dim * dtype_size + rotary_dim * 4
max_ub_size = get_max_ub_per_vector_core(use_simt=False)
num_stages = min(3, max_ub_size // bytes_per_row)
ub_block_tokens = min(SIMD_LANES, max_ub_size // (num_stages * bytes_per_row))
block_tokens = 1 << (ub_block_tokens.bit_length() - 1)
can_bulk_copy = x_stride_0 == nheads * x_stride_1 or x_stride_1 == rotary_dim

「一行要占多少字节」→「UB 能装几行」→「取 2 的幂」→「能不能整块搬」, 这条链条全部由写 kernel 的人维护。CUDA 版完全没有这一段: register 不够编译器会 spill、会报错,不需要你预先算。

正确性口径见 tests/transform/test_rope.py:36-46:调用外层 tile_kernels.transform.apply_rotary(...),与 tile_kernels/torch/rope.py 的 rotary_embedding_ref 用 calc_diff <= 1e-8 比对 (testing/numeric.py:33-38 的相对误差)。测试覆盖 3D/4D、两种配对(interleaved)、 共轭、positions 的三种模式(None / int32 / int64)、以及尾切 (query[..., -dim:])——同一份测试,两个后端,还是那句话。

6.2 Rand:一个诚实的「只有一个后端」

tile_kernels/rand/ 是本章唯一没有三件套的目录:

tile_kernels/rand/
├── randn_kernel.py   ← 外层:统一 API
├── randn_asc.py      ← 只有 Ascend 实现
└── seed.py           ← host 侧的种子管理

它甚至在第 1 行就写明了动机:

# .../TileKernels/tile_kernels/rand/randn_kernel.py:1
# Temporary replacement for the extremely slow torch_npu.randn.

外层的行为是「在 CUDA 上直接让路」:

# .../TileKernels/tile_kernels/rand/randn_kernel.py:29-40(节选)
shape = size[0] if len(size) == 1 and isinstance(size[0], tuple) else size
device = device or get_device()
if not is_ascend():
    return torch.randn(shape, dtype=dtype, device=device)     # CUDA:交给 PyTorch

assert torch.device(device).type == 'npu', 'Ascend randn only supports NPU tensors'
assert dtype in (torch.bfloat16, torch.float32), ...
# reserve_seed runs on the host, so graph replay would reuse the captured seed.
assert not torch.npu.is_current_stream_capturing(), (
    'Ascend randn does not support NPU graph capture now: graph replay would reuse the same random seed'
)

昇腾实现本身很短(randn_asc.py:7-42),却是本章唯一一个把全部四个昇腾要点同时 出现的例子:T.Kernel(num_vec_cores)、T.annotate_buffer_versions、 T.SimtVF(threads=512)、T.Persistent(..., num_stages=...):

# .../TileKernels/tile_kernels/rand/randn_asc.py:20-40(节选)
with T.Kernel(num_vec_cores) as core_id:
    output_ub = T.alloc_shared((T.int64(B),), dtype)
    T.annotate_buffer_versions({output_ub: num_stages})

    for pid in T.Persistent([num_blocks], num_vec_cores, core_id, group_size=1, num_stages=num_stages):
        offset = T.int64(pid) * B
        with T.SimtVF(threads=threads):
            T.rng_init(seed + pid)
            for serial in T.unroll(B // tile_size, unroll_factor=unroll_factor):
                for tx in T.Parallel(tile_size):
                    output_ub[serial * tile_size + tx] = T.rng_rand_float(dist='normal')
        T.copy(output_ub, output[offset])

这一段回答了「昇腾上还能不能用 SIMT」:能,但只能在 T.SimtVF 这个子作用域里, 而生成随机数这种「每个元素独立、不需要向量归约」的工作正是它的用武之地。 T.rng_init / T.rng_rand_float 在 CUDA 与昇腾两个方言里都存在 (_vendor/tilelang-v0.1.15/tilelang/cuda/language/random.py:4-70 与 .../tilelang/ascend/language/random.py:15-38),差别只在默认序列号 (ascend/language/__init__.py:71-75 的注释说明了原因)。

还有一条必须诚实指出的限制,就写在上面的断言里:昇腾 randn 不能进 NPU 图。 原因是 reserve_seed 在 host 上执行(rand/seed.py:13-20),图捕获时会把这个值 固化下来,replay 得到的随机数就完全一样了。库选择显式失败而不是悄悄给出错误结果—— 这是库的正确做法,也提醒我们:「同一份 API」不等于「所有使用方式都同构」。 第 19 章讲过 ACLGraph 的收益与边界,这里是它在真实库里的一个反例。


7. 方法论落点:哪些是共享的,哪些必须重写

7.1 一份清单

把本章读到的所有东西归一次类。

平台无关(写一次,两个后端共用):

内容 证据
函数与参数签名、返回值契约 topk_gate_kernel.py:7-25(docstring 里明确写了 tie-break 规则)
输入校验与形状上限 topk_gate_kernel.py:26-29
输出张量分配与 dtype topk_gate_kernel.py:31
空输入短路 topk_gate_kernel.py:32-33
sf_block 等配置语义、尺度换算算术 quant/common.py:19-33,251-282
逻辑宽度 vs 物理宽度 quant/common.py:174-185
缩放因子形状与分配 quant/common.py:188-233
数学近似的接口(不是实现) moe/sqrt_softplus.py:同名函数、两个后端各一份实现
参考实现与对拍工具 tile_kernels/torch/**、tile_kernels/testing/**
测试用例本身 tests/**:只调用外层 API

必须重写(每个后端一份):

内容 topk_gate 里的证据
并行映射(网格/线程/核) T.Kernel(num_tokens, threads=32) vs T.Kernel(num_vec_cores)
「一块数据放哪一级片上存储」 alloc_fragment vs alloc_shared(UB)
归约怎么写 T.alloc_reducer(op=...) vs S.vcmax/S.vcmin
同步 隐式/PDL vs S.mem_bar + 自动 flag
缓冲版本数 编译器决定 vs T.annotate_buffer_versions
向量宽度 编译器选 vs vector_length = 64
越界处理 补 -inf vs 掩码
分块/分组策略 无 vs 快路径/慢路径
容量反推 tile 大小 不做 vs rope_asc.py:31-36

一句话总结这张对照:

共享的是「算法与契约」,重写的是「数据怎么在芯片上流动」。

上半张表里没有一行提到 CUDA 或昇腾;下半张表里也没有一行提到这个算子叫什么。 如果你在写自己的库,这两张表就是拆文件的标准。

7.2 「重写」的量比手写 CUDA / Ascend C 少在哪

先说一个反直觉的事实:TileKernels 的 Ascend 实现并不比 CUDA 实现短 (topk_gate 68 行 vs 157 行),而且它里面用的 S.* 原语,语义上已经非常接近 Ascend C 了(vld/vsts/vcmax 这些名字就是硬件指令)。所以「少写」的不是 机器级代码本身。少写的是这四类东西:

  1. Host 侧样板与类型胶水。没有 cudaMalloc / stream / kernel launch 配置, 也没有 ACL / aclrtMalloc / TPipe / TQue 那套样板。两个后端共享同一份 torch.empty 分配与同一个调用形式(第 26 章讲了 ABI 这一层怎么做的)。
  2. tiling 与容量记账。TileLang 的 layout inference 替你做 L0/L1 分块与 swizzle(第 21 章),你只在真的会影响 UB 容量的时候才手算 (rope_asc.py:31-36、swiglu_forward_asc.py 的多套 buffer)。 手写 Ascend C 时,这套账是每写一个算子都要重做一遍的。
  3. 同步的正确性。M 树默认自动插入 flag(第 22 章), topk_gate_asc.py 全文件 157 行里只有一句手写同步(:151 的 mem_bar)。 手写 Ascend C 时,set_flag/wait_flag 的配对是主要 bug 来源 (第 19 章 §2.4、§11)。
  4. 多后端并存的组织成本。加一个后端 = 加一个文件 + 外层加一个分支; 算法、契约、测试、参考实现全部复用。这是「一份算法、两个后端」真正的收益所在—— 不是省了打字,而是省了「第二份实现悄悄跑偏」的风险。

7.3 边界在哪:什么时候 TileLang 不够用

第 33 章必须回答「为什么某些部分没有用 TileLang」。在 TileKernels 里, 答案不是「某些算子用了别的语言」,而是在四个具体位置往下沉了一层:

下沉点 位置 下沉到哪 为什么
内联设备函数与 PTX quant/swiglu_forward_cuda.py:11-16(prefetch.global.L2 的 inline PTX),在 :91 用 T.import_source(...) 注入 CUDA C++ / PTX __device__ 级别的指令级控制(L2 预取提示)没有对应的 TileLang 原语
SIMD 内在函数 Ascend 侧所有 S.*(tilelang.ascend.language.simd,v0.1.15 里 simd.py 有 2000+ 行) 昇腾向量指令 想做 lane 级控制(掩码、单 lane 写、跨 lane 归约)就必须下沉
数学近似 moe/sqrt_softplus.py:9-20 的多项式系数;quant/rstd.py 的补偿 rsqrt 手写数值方法 精度目标决定了实现,与芯片无关,但两个后端需要不同的指令序列(sqrt_softplus.py:23-25 的 CUDA 版与 :29+ 的 Ascend 多项式版),所以要分开写
矩阵乘 T.gemm / T.gemm_blockscaled(M 树) 由后端 codegen 落到 Cube / MMA / WGMMA 这里不需要下沉——TileLang 已经把这条路径做好了,这是它相对手写 Ascend C 的主要优势(第 14 / 24 / 25 章)

注意第一行和第三行的性质完全不同。第一行是**「TileLang 没暴露的能力」; 第三行是「TileLang 暴露了,但同一份数学在不同芯片上要写成不同序列」**。 分清这两者,才不会误判「TileLang 能不能干这件事」。

S.* 的位置(第二行)最容易被误解。它不是「TileLang 失败了」—— tilelang.ascend.language.simd 是官方方言的一部分,在锁定版本里是一等公民。 真实的情况是:TileLang 在 Ascend 侧选择了一条「该下沉就下沉、但保持同一套 IR 与调度」 的路线(第 25 章会讲它背后的两种 codegen 路线)。所以 topk_gate_asc.py 里 T.Pipelined + S.vmax 混着写,是完全符合设计意图的用法。

7.4 与第 32、34 章的边界

  • 第 32 章给出这次开源的清单与硬件/版本边界(包括 Ascend 950 + CANN ≥ 9.2.0)。 本章不重复。
  • 第 34 章接着讲 TileKernels 的另一半:Engram 与流形超连接(mHC)。 那批算子的特点是**「不是矩阵乘」**——条件记忆查表、Sinkhorn 归一化、 多层重计算。本章的两条经验(三层结构、平台无关/平台相关的清单)会在那里再次生效。
  • 第 35–38 章讲其余组件(DeepGEMM-Ascend / FlashMLA / DeepSelect / DeepEP-Ascend), 它们的 TileLang 含量远低于本章,正好用来对照「TileLang 主线」的边界。

8. 本章小结

8.1 一页复习

  • 三层结构:*_kernel.py(校验 + 分配 + 分派)/ *_cuda.py / *_asc.py, 外加 torch/(参考实现)与 testing/(对拍工具)。 外层的每一行回答「要不要算、算什么形状、用哪块芯片」;内层的每一行回答 「这块芯片上怎么算」。
  • 后端选择:config.py:24-26 用 /dev/davinci_manager 是否存在来判定, lru_cache 缓存到进程级;核数在工厂调用时求值(get_num_vec_cores() = sms × 2), 于是成为编译期常量并进入 JIT 缓存键;PDL 是 CUDA-only, 所以它只出现在 CUDA 分支的参数里。
  • top-k gate 对照:CUDA 是「1 block / 行 + 32 线程 + T.alloc_reducer」, Ascend 是「1 Vector 核 / 行 + 64 lane + S.vcmax/S.vcmin + 显式缓冲版本」。 算法与 tie-break 契约完全相同,差异全部是并行映射。
  • 为什么并行单位不同:CUDA 的「线程」是真实的执行单位(SIMT), Ascend 在核这一层没有线程域(SIMD,64 lane/指令), 所以在昇腾上「宽度」是语法的一部分(vector_length = 64、T.int32x64)。
  • 为什么 Ascend 要显式管缓冲版本:因为 T.Pipelined 引入了 「多个迭代同时在飞」的状态,份数是性能意图,调度器只能保证下限。
  • 量化算子族:三种粒度(per-token / per-block / per-channel)由 sf_block 表达;get_sf_and_inv 是两个后端共用的算术权威; UE8M0 打包因子 CUDA 是 4、昇腾是 2,跨设备要重打包。
  • RoPE / Rand:RoPE 展示了「配对」从索引问题变成向量重排问题 (S.vintlv/S.vdintlv);Rand 展示了「同一份 API」不等于「所有用法同构」 (昇腾 randn 不能进图,库选择显式报错)。
  • 方法论:共享算法与契约,重写数据流动。TileLang 省掉的是 host 样板、 tiling 记账、同步插入与多后端组织成本,不是机器级代码本身—— 必要时它会主动下沉(inline PTX 预取、SIMD 内在函数)。

8.2 全部常见坑(汇总)

层 编号 一句话
外层 P1–P6 后端判据、延迟 import、编译期常量、核数访问器、参数表差异、CUDA-only 能力(§3.5)
kernel K1–K8 断言折叠、T.clear、缓冲版本语义、-inf vs 掩码、tie-break、mem_bar(§4.8)
量化 Q1–Q6 逻辑宽度、跨设备打包、use_e4m3_sf、形状常量、clamp(§5.7)

8.3 本章没有验证的东西(务必记住)

以下全部需要真机才能确认,本章只做了静态阅读:

  1. 任何性能结论。两个后端的快慢、num_stages = 6 是否最优、 vector_length = 64 与 max_vregs_per_group = 6 的选择——一概未验证。
  2. 数值正确性。「tie-break 完全一致」「int32→int64 转换精确」 「x-x != 0 能检出全部非有限值」都是从源码推出的论证,不是运行结果。
  3. T.view 在昇腾上的实际可用性(本章只核对了它的契约与调用方式)。
  4. 两个后端的产物是否真的语义等价——这需要一台 CUDA 机器和一台昇腾机器, 跑 tests/** 的同一组用例,比对 tile_kernels/torch/** 的参考实现。
  5. get_max_ub_per_vector_core() 的 216/248 KB 是库自述的预算口径, 我们没有在官方文档里找到完全一致的对应条目。

反过来,本章从源码结构直接读出的结论(不依赖运行)是可靠的: 文件分层、分派逻辑、API 契约、形状约束、缓冲版本号、 以及「哪些行是逐字共享、哪些行是逐后端重写」。

延伸阅读

  • 本章的引用清单、核验方式与版本锁定:见 本章引用。
  • 习题与参考答案:见 本章习题 与 参考答案。
  • 图的源描述:见 figures/(含 资料库索引 的来源表)。
  • 代码示例:见 code/(每个示例都有 README 说明运行前提与验证级别)。
  • 相关章节:第 07 章(JIT 与缓存)、第 11 章(流水线)、第 19 章(昇腾实战与两棵树)、 第 25 章(昇腾后端 codegen)、第 26 章(运行时与 ABI)、第 27 章(量化数值口径)、 第 32 章(这次开源的清单)、第 34 章(TileKernels 下半:Engram 与 mHC)。

习题

14 题 · 答案在提交后展开

先在纸上或输入框里写下你的答案,再展开参考答案对照。客观判断请自己打勾:这三颗按钮 只影响本机保存的学习进度。

E1概念难度 ★无需硬件概念题 不需要硬件

正文 §2.2 把 TileKernels 的源码切成三层。判断下面每一条应该放进哪一层, 并说明理由(如果某一层都不合适,指出它该放哪里):

  1. assert scores.dim() == 2 and scores.is_contiguous()
  2. if is_ascend(): 分支里 import 昇腾工厂函数
  3. T.alloc_reducer((1,), T.int32, op='min')
  4. torch.empty((num_tokens, num_topk), dtype=torch.int64, device=scores.device)
  5. T.annotate_buffer_versions({scores_ub: 6})
  6. def get_packed_ue8m0_pack_factor() -> int: return 2 if is_ascend() else 4
  7. 一段把 shared_memory_per_multiprocessor 读出来的探测函数

E2概念难度 ★无需硬件概念题 不需要硬件

正文 §3.1–§3.2 讲了运行期后端选择。回答:

  1. is_ascend() 的判据是什么?为什么它用 functools.lru_cache(maxsize=1)?
  2. 为什么昇腾分支用函数内 import,而 CUDA 分支在模块顶层 import?
  3. get_num_vec_cores() 为什么是 get_num_sms() * 2?如果纯向量算子错用了 get_num_sms(),会有什么现象?
  4. 为什么 get_num_vec_cores() 要在调用工厂函数时求值,而不是在 kernel 里 当成一个变量?
  5. get_pdl() 为什么只出现在 CUDA 分支的参数里,不出现在昇腾分支?

E3概念难度 ★★无需硬件概念题 不需要硬件

第 19 章讲了昇腾的「两棵树」(适配器 A 树 / 主干 M 树)。本章的 TileKernels:

  1. 用的是哪一棵?给出文件与行号作为证据(至少两处)。
  2. 正文 §3.3 给了两个「为什么选它」的理由。说出这两条。
  3. 如果你把 A 树的写法(例如 T.alloc_L1、T.gemm_v0、is_npu=True)搬进 topk_gate_asc.py,会出现什么后果?为什么?
  4. 一个陷阱题:为什么 topk_gate_asc.py 里没有一句 barrier_all 或 set_flag, 而正文 §4.3 却说它有一句手写同步?它在哪一行、保护什么?

E4代码阅读难度 ★无需硬件代码阅读题 不需要硬件

下面是某个 kernel 的片段(已做删减):

with T.Kernel(num_vec_cores) as core_id:
    scores_ub = T.alloc_shared((num_aligned_experts,), T.float32)
    invalid_logits_ub = T.alloc_shared((vector_length,), T.int32)
    T.annotate_buffer_versions({scores_ub: 6, invalid_logits_ub: 1})
    for w in T.Pipelined(T.ceildiv(num_tokens, num_vec_cores), num_stages=6,
                         annotations={'multi_buffer_eligible': [scores_ub]}):
        row = w * num_vec_cores + core_id
        if row < num_tokens:
            T.copy(scores[row, :], scores_ub[:num_experts])
  1. 这是哪个后端的写法?列出至少三条判据。
  2. alloc_shared 在这里分配的是哪一级存储?为什么不是寄存器?
  3. row = w * num_vec_cores + core_id 这种划分方式,与「把 token 连续切给每个核」 相比,有什么好处?
  4. if row < num_tokens: 这个守卫在 CUDA 版里有没有对应物?为什么?
  5. 如果有人把 T.annotate_buffer_versions 整行删掉,代码能不能编译?会不会出错?

E5代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

下面是 Ascend 版里处理 int64 输出的三行:

topk_idx_view = T.view(topk_idx, (num_tokens, num_topk * 2), T.int32)
...
with T.SimdVF():
    T.clear(out_ub)
...
S.vsts(out_ub[2 * k], idx, one, 'ONEPT_B32')
...
T.copy(out_ub[: num_topk * 2], topk_idx_view[row, :])
  1. 这四行合起来在做什么?把「写一个 int64」拆成了什么?
  2. T.view 的契约是什么(写三条)?为什么这里能满足?
  3. T.clear(out_ub) 为什么是必需的?删掉它会怎样?为什么这种错误可能在小 shape 的测试里看不出来?
  4. CUDA 版是怎么做同一件事的?两种做法的前提有什么不同?

E6代码阅读难度 ★★无需硬件代码阅读题 不需要硬件

下面是 CUDA 版的非有限值检查:

T.reducer_init(nonfinite_reducer)                       # op='sum'
for i in T.Parallel(num_aligned_experts):
    if i < num_experts and not T.isfinite(scores_fragment[i]):
        T.reducer_update(nonfinite_reducer[0], 1)
T.finalize_reducer(nonfinite_reducer, nonfinite_result)
T.device_assert(nonfinite_result[0] == 0, msg='Score input for topk gate must be finite')
  1. 注释里写「single deferred assert to keep the load loop vectorized」。 如果改成在 T.Parallel 循环体里逐个 T.device_assert(is_finite, ...), 会失去什么?
  2. 为什么这里需要检查有限性?只用一句话答,但要指出它和「越界填 -inf」的依赖关系。
  3. 昇腾版没有用 reducer,它是怎么等价地做这件事的?给出关键原语名。

E7概念难度 ★★无需硬件概念题 不需要硬件

正文 §4.5 说「CUDA 的并行是 32 个独立的小计算,昇腾的并行是一条 64 宽度的向量指令」。

  1. 用两句话解释这句话分别指什么。
  2. 在昇腾的 T.Kernel(num_vec_cores) 里写 threads=32 会发生什么?为什么?
  3. 昇腾侧如果要真的用「线程」,应该用什么?给出一个本章出现过的例子(文件 + 行号)。
  4. topk_gate_asc.py 里 vector_length = 64、max_vregs_per_group = 6 这两个常量是怎么分工的?num_groups > 1 时会发生什么?

E8概念难度 ★★无需硬件概念题 不需要硬件

正文 §4.6 解释了 T.annotate_buffer_versions。

  1. scores_ub 与 invalid_logits_ub 的版本数分别是多少?各自的理由是什么?
  2. 用一句话说明「调度器会算版本数」和「你必须写版本数」这两件事不矛盾。
  3. 如果给 invalid_logits_ub 也写上 num_stages(6 份),会有什么后果?
  4. CUDA 版为什么完全不需要这一套?(提示:从「一行一批做完」这个结构想。)

E9概念难度 ★无需硬件概念题 不需要硬件

给出下面三个 sf_block,分别说出它属于 per-token / per-block / per-channel 里的哪一种, 并说明缩放尺度沿哪个维度变化:

  1. sf_block = (1, 128)
  2. sf_block = (32, 32)
  3. sf_block = (128, 1)

再回答:per_block_cast 在昇腾上只支持哪一种 sf_block?这一限制体现在哪一行?


E10概念难度 ★★无需硬件概念题 不需要硬件
  1. UE8M0 的打包因子在 CUDA 与昇腾上分别是多少?对应的 torch dtype 是什么?
  2. 把在 CUDA 上量化并打包好的权重直接搬到昇腾上使用,会出什么问题?
  3. 除了打包因子,正文 §5.6 还列了另外两条后端差异(一个是不支持的选项, 一个是对齐要求)。分别说出它们,并说明为什么它们不会让两个后端的数值口径 产生分歧。

E11概念难度 ★★无需硬件概念题 不需要硬件

SwiGLU 前向 + per-token 量化的融合(§5.5)。

  1. 输入是 [num_expanded_tokens, 2*hidden],输出是 [num_expanded_tokens, hidden]。 解释这个 2 倍关系从哪来。
  2. 正文给了一个「方案 A(两个 kernel)4.5H vs 方案 B(一个 kernel)2.5H」的记账 (以 fp16 计、忽略 sf 与缓存)。用同样的口径,把量化目标改成 fp4(每个元素 0.5 字节)重算一遍,给出 A 与 B 的访存比。
  3. 融合带来的代价是什么?(提示:看 swiglu_forward_asc.py 里的缓冲区数量。)
  4. 为什么说「SwiGLU 是 memory-bound 算子,所以省下的就是实打实的时间」? 这句话依赖什么前提?如果 SwiGLU 变成 compute-bound,结论还成立吗?

E12动手难度 ★★无需硬件动手题 不需要硬件

rand/randn_kernel.py:24-51 是本章唯一「只有一个后端」的例子。

  1. 在 CUDA 路径上,它做了什么?为什么这样是合理的?
  2. 昇腾路径上有哪一条断言是「正确性优先于功能」的典型?解释它挡住了什么错误。
  3. 既然 T.rng_init / T.rng_rand_float 在两个方言里都存在 (_vendor/tilelang-v0.1.15/tilelang/cuda/language/random.py 与 .../tilelang/ascend/language/random.py),为什么这个库还是不给 CUDA 写一个 自己的 randn kernel?给出你认为最合理的解释(可以从三层结构与维护成本角度谈)。

E13动手难度 ★★★无需硬件动手题 需要 Python,不需要硬件

用本章示例 code/03-map-and-diff/map_and_diff.py 做一次机械核对。

  1. 先不看输出,对下面两个算子各预测一下:哪些结构性标记会「只在一边」?
    • moe/moe_topk_gate_forward(CUDA 版有 T.alloc_shared 与 warp 归约; Ascend 版有 T.Persistent、T.SimtVF、T.annotate_buffer_versions)
    • quant/per_token_cast
  2. 运行脚本,把你的预测与实际输出逐条对照,写出猜错的项与原因。
  3. 再针对 rand/randn 回答:为什么它不会出现在脚本第 2 步的对照表里?
  4. 脚本第 3 步的输出是「21/21 命中」。说明这一步能证明什么、不能证明什么。

E14代码阅读难度 ★★★无需硬件代码阅读题 不需要硬件

transform/rope_asc.py 的 rotary_dim = 128 分支里有这么一段(:182-192):

first, second = load2(x_ub, row, head, 0, head, half_dim, True)
if dtype == T.bfloat16:
    first, second = (first, second) if interleaved else S.vintlv(first, second)
elif interleaved:
    first, second = S.vdintlv(first, second)
first, second = rotate(first, second, cosine, sine, neg_sine)
if dtype == T.bfloat16:
    first, second = (first, second) if interleaved else S.vdintlv(first, second)
elif interleaved:
    first, second = S.vintlv(first, second)
  1. 这两处重排为什么是「一次合、一次拆」的对称结构?为什么必须对称?
  2. CUDA 版(rope_cuda.py:89-100)做同一件事用的是什么写法?为什么它不需要这一步?
  3. 为什么在 dtype == T.bfloat16 时重排的是 NeoX(interleaved=False)一侧, 而在 fp32 时重排的是 GPT-J(interleaved=True)一侧? (提示:S.vcvt(..., part=0/1) 的语义是 PART_EVEN / PART_ODD, 见 _vendor/tilelang-v0.1.15/tilelang/ascend/language/simd.py:1247-1249。)
  4. 用一句话说明:这一步和旋转公式本身有关系吗?

章节自测

8 题 · 提交后本地评分

单选题。提交后立即给出分数与逐题解析;重做会清空选择,但会保留上一次的成绩记录。

  1. 1. TileKernels 里 `*_kernel.py` 这一层(外层)不应该出现下面哪一类代码?

  2. 2. `is_ascend()` 用什么作为「这是昇腾机器」的判据?

  3. 3. 关于 `topk_gate` 两个后端的并行单位,下面哪一句是对的?

  4. 4. 为什么昇腾版的 `invalid_logits_ub` 在 `T.annotate_buffer_versions` 里写的是 1 而不是 `num_stages`?

  5. 5. Ascend 版用 `T.view` 把 int64 输出当成 int32 视图来写。如果删掉它前面的 `T.clear(out_ub)`,最可能发生什么?

  6. 6. `T.alloc_reducer((1,), T.int32, op='min')` 这种写法,与手写 warp shuffle 相比,最重要的区别是什么?

  7. 7. 关于 UE8M0 缩放因子的打包,下面哪一句是对的?

  8. 8. RoPE 的 Ascend 实现里出现 `S.vintlv` / `S.vdintlv` 这一对重排指令,根本原因是什么?

本章代码

9 个文件

正文里提到的每个示例都能在这里直接阅读(带语法高亮),也可以整章浏览。

浏览本章全部代码 →

本章引用

源码链接指向锁定 tag

本章写到的每个源码位置与外部资料都在下面列出:源码链接指向锁定 tag 的 GitHub 文件, 资料链接指向原文。查看全部资料索引 →

展开引用清单

第 33 章 参考资料

本章所有结论都基于下表的本地资料或锁定版本源码。版本以 版本锁定 为准:TileLang v0.1.15 (commit a35f8ddf45eba16c21211ec8822d56ce5363036f)、TileLang-Ascend TileLang-ascend-v0.1.2.000-release(commit 89a9364e47a341fffdd65ed94786b1d4f9b6a78c)。 本章正文没有引用 A 树(适配器),只把第 19 章的 A 树结论当作对照背景。

写作环境:macOS (Apple Silicon)、Python 3.11;没有 NVIDIA GPU,也没有昇腾 NPU。 因此本章没有任何本机实测的性能或正确性结论。所有 kernel 示例的最高验证级别是 仅静态核对(python3 -m py_compile 通过 + 逐个在源码里比对 API 名与签名)。

本章的引用分三类:

类别 记号前缀 核验状态口径
TileLang 锁定源码 S… 已核验(2026-10-01):在 _vendor/tilelang-v0.1.15/ 工作树上用 rg -n / sed 定位并逐行阅读,行号与文件内容一致
TileKernels 语料快照 T… 已核验(2026-10-01):corpus 完整快照,逐文件阅读
官方发布说明与 corpus 元数据 R… 已核验(2026-10-01):本地全文

「已核验」不是运行验证。任何「跑起来对不对 / 快不快」的结论本章都没有做。 code/03-map-and-diff/ 的第 3 步对 21 个 API 名做了机械存在性核对 (输出 21/21 命中;缺:无),但它只证明「名字存在」。


一、本章示例代码(code/)

id 文件 用途 验证级别
C1 code/01-topk-gate-minimal/topk_gate_cuda_min.py §4.2 的最小可读 CUDA 实现(改写自 T3) 仅静态核对(py_compile + API 比对);未运行
C2 code/01-topk-gate-minimal/topk_gate_asc_min.py §4.3 的最小可读 Ascend 实现(改写自 T4) 仅静态核对;未运行
C3 code/02-call-the-library/call_topk_gate.py §3.4 的端到端调用 + 对拍(仿 T20) 仅静态核对;未运行
C4 code/02-call-the-library/call_quant_and_rope.py §5、§6 的端到端调用 仅静态核对;未运行
C5 code/03-map-and-diff/map_and_diff.py 三层结构清点 + 结构化对照 + API 存在性核对 CPU 逻辑验证:在 macOS / Python 3.11 上实际运行过,输出见该目录 README

二、TileKernels 语料快照(TileKernels/)

快照信息:git clone --depth 1,commit 66258df6175d(2026-09-30, HEAD 摘要 Merge pull request #34 from deepseek-ai/v2.0.0),见 资料库索引(R2)。 这是一个 commit 快照,不是 tag;如果上游发布新版本,行号可能漂移。

2.1 库定位与结构

id 引用位置 用途 核验状态
T0 资料库索引(定位、News、Features、Requirements) §1.1:库的定位、2026-09-30 昇腾支持、TileLang ≥ 0.1.15、Ascend 950 + CANN ≥ 9.2.0 已核验(2026-10-01):逐行读
T1 资料库索引(Testing)、:73-89(Project Structure) §2.1:测试方式与目录结构 已核验(2026-10-01)
T2 tile_kernels/__init__.py:1-39、tile_kernels/moe/__init__.py:1-4、tile_kernels/quant/__init__.py:1-11、tile_kernels/transform/__init__.py:1、tile_kernels/rand/__init__.py:1-4 §2.1:包对外暴露的算子清单 已核验(2026-10-01)

2.2 运行期配置与后端分派

id 引用位置 用途 核验状态
T3 tile_kernels/config.py:1-134 §3.1:is_ascend()(:24-26)、get_device()(:29-31)、get_device_num_sms()(:39-46)、核数访问器(:62-71)、get_max_smem_per_sm(:74-77)、get_max_ub_per_vector_core(:80-83)、PDL(:7,105-112)、clamp 默认值(:10-15) 已核验(2026-10-01):逐行读
T4 tile_kernels/utils.py:1-38 align / ceil_div / dtype_to_str 等共享小工具 已核验(2026-10-01)

2.3 top-k gate 家族(本章核心对照样本)

id 引用位置 用途 核验状态
T5 tile_kernels/moe/topk_gate_kernel.py:1-43(全文件) §3.2:外层分派逐行读;tie-break 契约在 :22-24 已核验(2026-10-01):逐行读
T6 tile_kernels/moe/topk_gate_cuda.py:1-68(全文件) §4.2:CUDA 版逐段(T.Kernel :22、fragment :25-27、reducer :28,41-46,54-58、T.copy :64、PDL :23-24,65-66、pass_configs :7-11) 已核验(2026-10-01):逐行读
T7 tile_kernels/moe/topk_gate_asc.py:1-157(全文件) §4.3:Ascend 版逐段(配置 :9-23、网格与缓冲 :30-39、流水 :41-48、SIMD 主体 :50-105、慢路径 :106-151、mem_bar :151、收尾 :153-155) 已核验(2026-10-01):逐行读
T8 tile_kernels/moe/moe_topk_gate_forward_kernel.py:116-149;..._cuda.py:8-13,15-21,78-91,205-250;..._asc.py:9-13,69-99,123-124,179-278 §3.2、§4.4 的第二个样本:同一套分派模板、warp_reduce_sum 宏、T.SimtVF/T.Persistent 组合 已核验(2026-10-01):分派段与关键段逐行读;未逐行读完全部 293/281 行
T9 tile_kernels/moe/moe_topk_gate_backward_kernel.py:9-40;..._cuda.py:1-5;..._asc.py:1-7 §3.3:反向算子沿用同一套三层与两棵树约定 已核验(2026-10-01):头部与文件头读
T10 tile_kernels/moe/normalize_weight_kernel.py:8-37;normalize_weight_asc.py:1-48 §4.5 补充样本:Ascend 侧也可以在 T.SimtVF 里用 fragment + T.reduce_sum 已核验(2026-10-01):全文件读
T11 tile_kernels/moe/sqrt_softplus.py:1-40 §2.2、§7.3:同一数学近似、两个后端各一份实现(CUDA 用 T.log1p/T.exp,Ascend 用 9 阶多项式 + Horner) 已核验(2026-10-01):前 40 行;多项式系数表在 :10-20

2.4 量化算子族

id 引用位置 用途 核验状态
T12 tile_kernels/quant/common.py:19-61(配置 dataclass)、:64-75(sf 类型与 UE8M0 打包因子)、:78-123(输入配置)、:126-171(输出配置与 clamp)、:174-203(逻辑/物理宽度、sf 形状)、:206-248(sf 分配与 epilogue)、:251-316(get_sf_and_inv / load_sf / store_sf) §5.1、§5.2、§5.6:粒度配置、打包因子后端差异、列主序对齐差异、尺度与倒数 已核验(2026-10-01):逐段读
T13 tile_kernels/quant/per_token_cast_kernel.py:24-96,99-142,144-219;per_token_cast_cuda.py:28-33,96-105;per_token_cast_asc.py(头部与 T.Kernel(num_vec_cores) / Persistent / S.* 段) §5.3:per-token 的约束(num_per_channels ∈ {16,32,64,128}、hidden % 64 == 0)与分派 已核验(2026-10-01):外层与 CUDA 头部逐行读;Ascend 侧读了 T.Kernel/Persistent/S.* 的关键段,未逐行读完全部 370 行
T14 tile_kernels/quant/per_block_cast_kernel.py:30-98,101-134;per_block_cast_asc.py:12-42 §5.3:per-block 的对齐/整除约束(:33-34、:67)与昇腾只支持 (32,32)(:22)、block_k 取 align(gcd(512或1024, hidden), 128)(:26-27) 已核验(2026-10-01):外层逐行读;Ascend 前 80 行逐行读
T15 tile_kernels/quant/per_channel_cast_kernel.py:10-67;per_channel_cast_asc.py:13-90 §5.3:per-channel 的 num_per_tokens ∈ {32,128}、昇腾只支持 32 已核验(2026-10-01):外层逐行读;Ascend 前 90 行逐行读
T16 tile_kernels/quant/cast_kernel.py:8-59;cast_back_kernel.py:9-64 §5.4、§6:逐元素 cast 与反量化;use_e4m3_sf 仅 CUDA(common.py:118、cast_back_kernel.py:41);x.device.type == 'npu' 与 is_ascend() 并存(cast_back_kernel.py:46 vs cast_kernel.py:40) 已核验(2026-10-01):两个文件逐行读
T17 tile_kernels/quant/swiglu_forward_kernel.py:11-141;swiglu_forward_cuda.py:11-24,80-90,91;swiglu_forward_asc.py:12,42,45,260-311 §5.5:融合语义(hidden = hidden // 2 :26-27)、允许的 (fmt, num_per_channels) 组合(:41)、CUDA 的 PREFETCH_SOURCE + T.import_source(:11-16、:91)、Ascend 的多套 UB 缓冲(:285-311) 已核验(2026-10-01):外层逐行读;CUDA/Ascend 读关键段,未逐行读完 323/395 行
T18 tile_kernels/quant/types.py:1-3;tile_kernels/quant/rstd.py:1-98 QuantTensor = tuple[Tensor, Tensor];Ascend 的补偿式 rsqrt 已核验(2026-10-01):types.py 全文;rstd.py 全文

2.5 RoPE 与 Rand

id 引用位置 用途 核验状态
T19 tile_kernels/transform/rope_kernel.py:9-115(全文件) §6.1:统一的 3D/4D 处理、rot_dim ∈ {64,128}、原地修改、后端分派 已核验(2026-10-01):逐行读
T20 tile_kernels/transform/rope_cuda.py:40-45,48-132(全文件) §6.1、§7:线程数由 nheads/half_dim 反推(:40-45)、寄存器里的两半(:62-65)、两种配对(:89-100)、T.ieee_fmaf(:110-114) 已核验(2026-10-01):逐行读
T21 tile_kernels/transform/rope_asc.py:9,30-37,45-110,112-200(全文件) §6.1、§7、习题 E14:SIMD_LANES = 64(:9)、由 UB 容量反推 tile(:30-37)、S.vintlv/S.vdintlv 重排(:168,176,rotary_dim = 128 分支在 :182-192) 已核验(2026-10-01):逐行读
T22 tile_kernels/rand/randn_kernel.py:1-55(全文件) §6.2:CUDA 让路给 torch.randn(:31-32)、昇腾专用的 dtype 与图捕获断言(:34-39)、block/stage 配置(:11-21) 已核验(2026-10-01):逐行读
T23 tile_kernels/rand/randn_asc.py:7-42(全文件) §6.2:T.Kernel(num_vec_cores)、T.annotate_buffer_versions、T.SimtVF(threads=512)、T.Persistent、T.rng_init / T.rng_rand_float(dist='normal') 已核验(2026-10-01):逐行读
T24 tile_kernels/rand/seed.py:13-20 §6.2:reserve_seed 在 host 上执行,因此不能进图 已核验(2026-10-01):逐行读

2.6 参考实现与测试工具

id 引用位置 用途 核验状态
T25 tile_kernels/torch/__init__.py:1-25;tile_kernels/torch/topk.py:9-21 §2.1、§3.4:参考实现清单;stable_topk 的 stride 处理 已核验(2026-10-01):逐行读
T26 tile_kernels/testing/numeric.py:5-38(assert_equal / calc_diff)、:41-74(check_bias / count_bytes) §3.4、§4.8:逐字节对拍与相对误差口径 已核验(2026-10-01):逐行读
T27 tile_kernels/testing/generator.py:12-56(测试档位与形状生成) §3.3:TK_TEST_LEVEL 三档;测试档位决定形状覆盖 已核验(2026-10-01):前 60 行
T28 tests/moe/test_topk_gate.py:18-29,32-59,62-88 §3.4:参数网格(num_experts ∈ {1,35,1025}、num_topk ∈ {1,9,32})、只调外层 API、assert_equal 对拍 已核验(2026-10-01):逐行读
T29 tests/transform/test_rope.py:15-46,49-95,98-140 §6.1:RoPE 的覆盖范围与 calc_diff <= 1e-8 门限 已核验(2026-10-01):逐行读
T30 tests/rand/test_randn.py:17-40,43-69 §6.2:set_seed → 同种子同结果;形状与分布判据 已核验(2026-10-01):逐行读
T31 tests/conftest.py:1-30 §3:xdist worker 的设备绑定、benchmark 目录约定 已核验(2026-10-01):前 30 行

三、TileLang 锁定源码引用(_vendor/tilelang-v0.1.15/)

id 引用位置 用途 核验方式与状态
S1 tilelang/language/__init__.py:1-32 §3.1:tilelang.language 是 CUDA 方言的再导出(:11),所以 PDL 只在默认命名空间里 已核验(2026-10-01):逐行读
S2 tilelang/language/allocate.py:49,67,81,96-103 §2、§4:alloc_shared / alloc_local / alloc_fragment / alloc_var 的 scope 已核验(2026-10-01):逐行读
S3 tilelang/language/allocate.py:292-330 §4.2:alloc_reducer 的语义与限制(「物理存储与跨线程通信方案由编译器选择」) 已核验(2026-10-01):逐行读 docstring
S4 tilelang/language/reduce_op.py:358,398,434 §4.2:reducer_init / reducer_update / finalize_reducer 三步 已核验(2026-10-01)
S5 tilelang/language/reduce_op.py:479,495,511 §4.4:warp_reduce_sum/max/min 已核验(2026-10-01)
S6 tilelang/language/customize.py:101-140 §4.7:T.view 的契约(总位数不变、elem_offset == 0、紧凑或显式 strides) 已核验(2026-10-01):逐行读
S7 tilelang/language/copy_op.py:59-72,77,122-127 §4.2:T.copy 的签名;dtype 不同时的逐元素转换;disable_tma 属于 CUDA 方言 已核验(2026-10-01)
S8 tilelang/language/loop.py:82,112,189,230 §4.3:T.Persistent / T.Pipelined(含 annotations 参数)/ T.serial / T.unroll 已核验(2026-10-01):读 Pipelined 全 docstring
S9 tilelang/language/kernel.py:431;tilelang/language/proxy.py:256-257;tilelang/language/symbolics.py:13 §2.5、§4:通用 T.Kernel、T.Tensor / T.StridedTensor 代理、T.dynamic 已核验(2026-10-01)
S10 tilelang/language/common.py:178 §7.3:T.import_source 的定义位置(CUDA 侧内联设备函数的入口) 已核验(2026-10-01)
S11 tilelang/cuda/language/pdl.py:4-21 §3.1:PDL 的 pdl_trigger / pdl_sync 只在 CUDA 方言里 已核验(2026-10-01):全文
S12 tilelang/cuda/language/random.py:4-70;tilelang/ascend/language/random.py:15-38 §6.2:两个方言都有 rng_init / rng_rand / rng_rand_float;昇腾版只改了默认序列号 已核验(2026-10-01)
S13 tilelang/cuda/language/print.py:8;tilelang/ascend/debug.py(device_assert) §4.2:两个方言都导出 device_assert 已核验(2026-10-01)
S14 tilelang/ascend/language/__init__.py:5,8-18,49-56,69-75 §3.3、§4.3:昇腾方言建立在 language.common 之上;alloc_shared 故意遮蔽为 UB;Kernel/MixedKernel/get_thread_binding 来自 ascend/language/kernel.py;device_assert 与 rng_* 的导出 已核验(2026-10-01):逐行读
S15 tilelang/ascend/language/kernel.py:139-172(Kernel)、:176-...(MixedKernel) §4.5:昇腾 T.Kernel(N) 只接受一维网格、没有 threads=;线程要用 T.SimtVF 已核验(2026-10-01):逐行读 docstring
S16 tilelang/ascend/language/frame.py:107,179 §4.3、§6.2:T.SimtVF(threads=128) 与 T.SimdVF(latency=0) 的签名 已核验(2026-10-01)
S17 tilelang/ascend/language/annotations.py:19-65(annotate_buffer_versions)、:107-162(annotate_manual_multi_buffer)、:165-188(annotate_unlimit_memory) §4.6:版本数语义(1 退出多缓冲 / 固定份数 / 只给模式),以及「Z3 在 T.Persistent + num_stages > 1 时会自己算」 已核验(2026-10-01):逐行读 docstring
S18 tilelang/ascend/language/simd.py:457(vsts)、:922(vdup)、:933(vdupv)、:969(vcmax)、:976(vcmin)、:1019(vci)、:1052(vintlv)、:1067(vdintlv)、:1247-1249(vcvt 的 part=PART_EVEN/PART_ODD)、:1429(vsel)、:1486(mem_bar) §4.3、§6.1、习题 E14:本章用到的全部 SIMD 原语 已核验(2026-10-01):逐个 rg -n 定位并读签名/docstring
S19 tilelang/transform/pass_config.py:73,83,189,239,335 §4.2、§5.3:TL_DISABLE_WARP_SPECIALIZED(:73)、TL_ENABLE_FAST_MATH(:83)、TL_ENABLE_LOWER_LDGSTG_PREDICATED(:189)、TL_DISABLE_THREAD_STORAGE_SYNC(:239)、TL_DISABLE_OUT_OF_BOUND_WARNING(:335) 已核验(2026-10-01):逐个定位
S20 tilelang/transform/pass_config.py:76(TL_ENABLE_AUTO_SCHEDULE) §3.3:昇腾自动调度默认开启(与第 19 章 S15 同一条) 已核验(2026-10-01)

四、官方发布说明与 corpus 元数据

id 本地路径 上游链接 用途 核验状态
R1 DeepSeek 开源昇腾基础组件(官方发布说明) 第 12 行 见该文件头部转录说明 §1.1:官方对 TileKernels 的定位(「数据处理所需的常规向量计算和访存算子」)与「性能接近硬件上限」的自述 已核验(2026-10-01):本地全文(29 行)
R2 资料库索引 — 本章快照的 commit 与日期(TileKernels 66258df6175d,2026-09-30) 已核验(2026-10-01)
R3 资料库索引(条目 github-deepseek-tilekernels,约 :1495-1505) https://github.com/deepseek-ai/TileKernels 语料的 trust: primary 标注与用途说明(「本教程第 33、34 章的主线」) 已核验(2026-10-01)
R4 TileKernels/README.md(GitHub 渲染版) https://github.com/deepseek-ai/TileKernels T0/T1 的上游出处 未核验:本机未访问网络核对 GitHub 页面与快照是否一致

关于性能数字的说明

本章不提供任何实测数字。 原因见开头的验证边界。正文里出现的「数量级」只有三类:

数字 来源 条件
num_stages = 6、vector_length = 64、max_vregs_per_group = 6、SIMD_LANES = 64、num_threads = 32、num_topk ≤ 32 T6 / T7 / T21 等源码常量 源码里的常量,不是调优结果、不是实测最优
每核 UB 预算 216 KB(use_simt=True)/ 248 KB(use_simt=False) T3(config.py:81-83) 库自述的预算口径;我们没有在官方文档里找到逐字对应的条目(见「待补资料」第 5 条)
「方案 A 4.5H vs 方案 B 2.5H」(以及习题里 fp4 的 5H : 3H) 本书推导 纯整数记账,忽略 sf、缓存与 TMA 一次搬多块;不是任何机器上的测量
rot_dim ∈ {64,128}、num_per_channels ∈ {16,32,64,128}、num_per_tokens ∈ {32,128}、(32,32)、hidden % 64 == 0、num_tokens % 128 == 0 T13–T17 的断言 实现里写死的常量,不是性能建议

「性能接近硬件上限」「已用于内部训练与推理」(T0)是厂商自述。 本章引用它只是为了说明「这不是玩具库」,不作为可复现的结论。


待补资料

  1. Ascend 950 的硬件规格页缺失。 本章多处提到「Vector 核」「SIMD 宽度 64」 「UB 容量」,但 corpus 里没有 Ascend 950 的官方规格页(容量、算力、带宽、 每核 lane 宽度)。SIMD_LANES = 64 是从 rope_asc.py:9 的常量与 simd.py 里 T.float32x64 / T.int32x64 的类型宽度推出来的, 没有官方文档逐字确认「dav-3510 的向量宽度就是 64 条 fp32 lane」。 建议补抓昇腾 950 的产品规格与 CANN 的 Ascend C API 文档。
  2. 两后端的实测等价性完全未验证。 本章多次断言「两个后端实现同一个算法」, 依据是源码逐行对照与共用的参考实现(T25/T26)。 真正的验证需要一台 CUDA 机器 + 一台昇腾机器,跑同一组 tests/** 用例并与 tile_kernels/torch/** 对拍。本章没有这个条件。
  3. T.view 在昇腾上的实际行为只核对了契约。 §4.7 的结论来自 customize.py:101-140 的 docstring 与调用方式;没有在设备上验证 「int64 → int32 视图 + 单 lane 写高位」这条路真的成立。
  4. corpus 快照不是 tag。 TileKernels 快照是 commit 66258df6175d (HEAD 摘要 Merge pull request #34 from deepseek-ai/v2.0.0), 而 pip install tile-kernels 装到的是发布版本,两者可能不同。 本章所有行号都对应快照;引用具体行号时请注意这一点。 建议后续在 corpus 的 资料库索引 里为 TileKernels 补一个 tag 或 release 快照。
  5. get_max_ub_per_vector_core() 的 216/248 KB 缺少官方对应条目。 第 19 章引的 Atlas A2 口径是 UB 192 KB(不同产品、不同预留策略), 与这里的 216/248 KB 不是一回事。需要一份 Ascend 950 的 UB 规格 或 CANN 里对应的查询接口文档,才能判断这两个数是怎么来的。
  6. T.annotate_buffer_versions 缺省时的实际行为未验证。 习题 E4 第 5 问的答案(「大概率仍能编译,但可能丢掉累积器语义」) 来自 annotations.py:19-45 的文档,没有做过「删掉这一行再编译」的实验。 建议在有设备的机器上做一次对照实验(编译 + 数值对拍),把结论补成实测。
  7. tilelang/ascend/language/simd.py 只读了本章用到的原语。 该文件 2000+ 行,本章逐个核对了 vld/vsts/vdup/vdupv/vcmax/vcmin/vci/ vintlv/vdintlv/vcvt/vsel/pset/vadds/vcmps/vsub/vmax/vmin/mem_bar 等, 没有系统梳理它的全部原语与语义分类。第 25 章若要展开,需要完整过一遍。

关于 check-content.mjs 的说明

本章写完后运行了 node scripts/tutorial/check-content.mjs 33-tilekernels-two-backends, 输出见交付回报。它校验的是结构与格式:图 JSON 的合法性与 id 一致性、 source-figures/<slug>/ 里的每个原图都有对应 spec、自测题的数量(5–8) 与字段完整性(每题恰好 4 个选项、answer 在 0–3、explain 非空)。 它不检查内容正确性、引用可达性或行号准确性。 本章的行号准确性由上面各表逐条人工核验,其中「API 名字是否存在」 由 code/03-map-and-diff/map_and_diff.py 第 3 步做了机械复核(21/21 命中)。


附:一条被外部校验工具误报的引用,以及它带来的额外结论

并行写作的另一章在跑 tools/check_book.py 时,把本章的一处引用报成 「_vendor/tilelang-v0.1.15/tilelang/ascend/language/simd.py 不存在」。 这是一次误报,但排查过程本身给本章补了一条有价值的结论,所以记在这里。

误报的真实原因:行号后缀写错,不是文件不存在

tools/check_book.py 用 LINE_SUFFIX_RE = re.compile(r":\d+(?:[-,]\d+)*$") 剥掉行号后缀再去查文件。本章 参考答案 的习题 E14 里写的是 `...simd.py:1052,1067+`——末尾多了一个 +,正则匹配不到, 于是整串(含 :1052,1067+)被当成路径去查,自然「不存在」。 这是行号写法的错误,不是引用目标的问题。 该处已改为分别标注 :1052 与 :1067。 修复后 python3 tools/check_book.py 的输出是 checked 2858 citations, 0 broken。

顺带核实:这个文件在锁定版本里确实存在

在该 commit(a35f8ddf45eba16c21211ec8822d56ce5363036f,即 版本锁定 锁定的 v0.1.15)的工作树上:

$ ls -l tilelang/ascend/language/simd.py
-rw-r--r--  1 huandu  staff  57909 Oct  1 00:28 tilelang/ascend/language/simd.py
$ shasum -a 256 tilelang/ascend/language/simd.py
89483579ddde5e36a352a853720fc02ba2b06badeab9eca74b31bcde6a04b57f
$ sed -n '1052p;1067p' tilelang/ascend/language/simd.py
def vintlv(src0, src1):
def vdintlv(src0, src1):
$ git log -1 --format='%H %s'
a35f8ddf45eba16c21211ec8822d56ce5363036f [Release] Bump version into 0.1.15 (#3309)

也就是说,TileKernels 的 Ascend kernel 里那句 from tilelang.ascend.language import simd as S(例如 topk_gate_asc.py:3) 在锁定的 v0.1.15 主干里是有对应的——它正是本章 §3.3 所说「TileKernels 选了 M 树」 的直接证据:如果只有 A 树可用,这些 S.* 原语根本无从谈起。

这条对照值得单独记下来:S.* 是 M 树专属

同一份检查也确认了 A 树(_vendor/tilelang-ascend-v0.1.2.000/)没有这个表面:

$ ls tilelang/                      # A 树:没有 ascend/ 这个子包
__init__.py  _ffi_api.py  autotuner  cache  carver  common  contrib  engine  env.py
intrinsics  jit  language  layout  libinfo.py  math  primitives  profiler  quantize
testing  tools  transform  utils  version.py

$ find . -name 'simd*.py'           # A 树:没有任何 simd 模块
(无输出)

$ rg -c "vcmax|vintlv" tilelang/     # A 树:跨 lane 归约/重排原语一个都没有
(无输出;A 树的昇腾向量表面在 tilelang/language/ascend.py 与 ascend_tile.py)

所以下面的判断是有据可查的,而不是推测:

结论 证据
from tilelang.ascend.language import simd as S 只在 M 树成立 M 树有 tilelang/ascend/language/simd.py;A 树连 tilelang/ascend/ 都没有
S.vcmax / S.vcmin / S.vintlv / S.vdintlv 是 M 树方言的一等公民 _vendor/tilelang-v0.1.15/tilelang/ascend/language/simd.py:969,976,1052,1067
A 树的昇腾 API 与 M 树不通用(第 19 章 §2.6 的结论)在本章同样成立 A 树用 T.alloc_ub(tilelang/language/allocate.py:156)、无 simd 表面;M 树用 T.alloc_shared + S.*

这条对照对读者的实际意义:如果你想照抄 TileKernels 的 Ascend kernel, 你需要的不是 A 树,而是主干 ≥ v0.1.15 的昇腾方言(tilelang.ascend.language)。 这也是为什么本章 §1.1 特意指出「TileKernels 要求的 TileLang ≥ 0.1.15」 与本书锁定的主干版本恰好一致。

关于「本地快照 vs 上游发布」的一句话提醒

另一条容易被误判的情况是:TileKernels 的语料快照是一个 commit (66258df6175d,HEAD 摘要 Merge pull request #34 from deepseek-ai/v2.0.0), 不是 tag。若上游之后改动文件行数,本章的行号会漂移;届时按 code/03-map-and-diff/map_and_diff.py 的机械核对重新标定即可。

本章共 12 个二级小节;右上角目录在桌面端会跟随滚动高亮。