跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 28 章编译期、后端与芯片34 分钟14 习题8 自测题

算子设计方法论与性能模型

把前面的经验上升成方法论:先算(性能模型、roofline)、再定 tile、再定流水,最后才写代码;配一个完整的融合算子案例与一份反模式清单。

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

学习目标

读完本章,你应当能够:

  1. 拿到一个没见过的新算子,按固定顺序走完「先算再写」的五步——建立性能模型 → 决定 tile 尺寸与存储位置 → 决定流水结构 → 决定是否需要特殊指令 / 特殊布局 → 最后才写代码——并在每一步给出可检验的判据,而不是「试试看」。
  2. 把 roofline 用对:算出 (flops, bytes)、算术强度与机器平衡点,说清它给出的 是下界而不是预测;并当场指出至少六种常见误读把它们带偏了多少。
  3. 说清 tile 级解析性能模型在做什么:TileSight 的三层结构(intra-tile / inter-tile / cross-device),以及 TileLang 自带的 carver 里 traffic、片上容量、寄存器、 驻留块数、波数 这五个量是怎么算出来、怎么被用来排序的;并用 tilelang.tools.Analyzer 的实际实现说明一个工具的输出边界在哪里, 它在什么情况下会静默地给出半个答案。
  4. 拿到一份 tile 配置,能指出它被哪一条约束卡住(片上容量 / 寄存器 / 指令形状 / 并行度),并给出「先粗算再微调」的下一步。
  5. 对 fusion、split、特殊指令三类决定给出判据而不是结论,并说明同样的判据在 NVIDIA 与昇腾上为什么会得出不同答案。
  6. 独立完成一个融合算子的完整设计循环:预算表 → 第一版 → 性能诊断 → 第二版。

前置知识

  • 第 2 章:(flops, bytes) 记账法、算术强度、机器平衡点、roofline 是下界。 本章 2 节是它的「进阶用法 + 常见误读」版。
  • 第 11 章:T.Pipelined 的 stage/order、多缓冲的容量约束、 「num_stages ≥ 1 + ceil(latency / compute)」这条判据;以及 3.9 节对 carver 的 初步介绍。本章 3.2 节会把 carver 的 cost model 拆到公式一级。
  • 第 12 章:lower_trace / pass_diff,以及 Nsight Compute 与 msProf 的口径。 本章给出的所有「诊断」都是这一步的输入。
  • 第 13 章:RMSNorm 与 RoPE 的实现与融合;第 27 章:fp8 量化与 block scaling。 本章第 6 节的案例把它们拼在一起。
  • 第 14、15 章:GEMM 与 attention 的性能账本与变体家族。
  • 第 21 章:layout 推导与布局冲突的代价;第 23 章:昇腾的 latency 估计与 SMT 调度。

本章的验证边界(务必先读):写作环境是 macOS (Apple Silicon),没有 NVIDIA GPU,也没有昇腾 NPU。因此:

  • 本章给出的所有数字都只有三类来源:公式推导(code/ 下的七个脚本,纯标准库, 可在任意机器复算)、厂商公开文档、锁定版本源码里的常量。
  • 没有任何一条性能数字来自本机实测。
  • 凡涉及具体 kernel 的行为,本章只写「应该怎么验证」,不写「实测结果是多少」。

1. 问题的由来:为什么必须「先算再写」

设计流程里的判据与回退分支否是≈ 1> 1否是否② 定 tile 尺寸与存储位置四条约束都过吗?缩小 tile 或换分块轴回到 ②,不要改算法③ 定流水结构搬运延迟 / 计算周期不加流水:num_stages 保持 1加多缓冲:份数 = 1 + ceil(延迟/周期)④ 特殊指令 / 特殊布局默认降级路径够快吗?引入特殊路径,并写下它的新约束⑥ 与模型对账:离下界近吗?回到 ①–④ 里最可疑的那一步
每一步都有一个可检验的判据;任何一步不过就回上一步,而不是继续往下写代码。
  • ① 建模的判据是「AI 与平衡点比」:如果算子的 AI 比平衡点低两个数量级,调参空间本来就没有。
  • ⑥ 的判据是「离下界近吗」;不近就回到 ①–④ 里最可疑的那一步,交付时把口径、判据与边界条件一起写下来。
  • 图里的回退箭头从 ⑥ 指回 ②:任何一步不过都回到上一步,而不是继续往下写代码。
新算子的设计流程:代码是第五步1. ① 建模:算出 (flops, bytes)算术强度 · 访存下界 · 机器平衡点,判 memory-bound 还是 compute-bound2. ② 定 tile 尺寸与存储位置片上容量 / 寄存器 / 指令形状 / 并行度四条约束的交集3. ③ 定流水结构哪些迭代可以重叠 · 需要几份缓冲4. ④ 判断是否需要特殊指令 / 特殊布局张量核 · TMA · 异步 · swizzle · fractal · dual_copy5. ⑤ 写代码①–④ 的结论变成 tile 尺寸、scope、num_stages 与注解6. ⑥ 测:与模型对账差多少 · 差在哪一条约束上,回到最可疑的那一步
拿到一个新算子的固定顺序——建模 → 定 tile → 定流水 → 定特殊路径 → 写代码 → 对账;顺序不能换,先定 tile 再定流水。
  • 流程里最容易跳过、也最贵的两步是 ① 建模与 ② 定 tile:跳过建模会花三天调参,跳过定 tile 会写出编不过或掉到 1 block/SM 的配置。
  • ② 与 ③ 的顺序特别重要:「能加多少 stage 是 tile 尺寸的函数」,为流水优化过的 block_K 如果放不进片上,前面的工作全部作废。

1.1 三种几乎人人都会踩的失败

一个没有经历过系统方法训练的算子作者,最常见的三种失败长这样:

失败一:调参调在了一个不存在最优解的空间里。 第 13 章的账本已经说明,RMSNorm 这类算子的算术强度在 1 FLOP/Byte 量级,比任何一条 roof 的平衡点都低两个数量级。 在这类算子上调 block_K、调 num_stages,能拿到的收益上限是百分之几; 而真正决定性能的是访存量——把三次访存合成一次,就是 3 倍的差距。 code/01-op-ledger 的 --sweep 把这件事量化了:同一个 sin(cos(x)), 融合前后分别是 14.905 µs 与 29.810 µs 的下界(H200 标定值口径)。

失败二:tile 尺寸是在编译失败的报错里学会的。 block_M=256, block_N=256, block_K=64, num_stages=4 在 Hopper 上要 256 KB 的 shared memory,而单 block 上限是 227 KB——这不是「慢一点」,是编译或启动阶段直接失败 (code/04-tile-size-decision 复现了这条边界)。同一份配置的累加器还要 256 个 寄存器/线程,已经越过 255 的硬上限。这两条都是写代码之前就能算出来的。

失败三:把一个带宽问题当成算力问题。 一个 kernel 实测只有峰值带宽的 30%, 于是去查张量核利用率、去换 T.gemm 的 policy。真实原因是每个 block 只搬 256 字节, 描述符与调度开销远大于载荷。这也是 code/06-case-norm-rope-quant/ 里第一版设计失败的方式。

三种失败的共同点是:它们都不是「写错了代码」,而是「想错了问题」。

1.2 设计流程:代码是最后一步

本章把前面二十几章的实操经验收敛成一条固定的流程(图 1):

算子的设计流程:六步对账1. ① 建模(flops, bytes) → AI → 平衡点 → 判类:memory-bound 还是 compute-bound2. ② 定 tile四条约束(片上容量 / 寄存器 / 指令形状 / 并行度)→ tile 尺寸 + 存储位置3. ③ 定流水搬运延迟与计算周期的比 → 要不要流水、要几份缓冲4. ④ 定特殊路径默认降级路径够不够快 → 需不需要张量核 / TMA / swizzle / fractal / dual_copy5. ⑤ 写代码①–④的结论变成 tile 尺寸、scope、num_stages 与注解6. ⑥ 对账实测与模型差多少、差在哪一条约束上 → 回到最可疑的那一步
拿到一个新算子后的固定顺序——建模 → 定 tile → 定流水 → 定特殊路径 → 写代码 → 对账;最后一步把结论送回最可疑的那条约束上。
  • 这条阶梯只画主路径;带判据与回退分支的完整流程图见本章图 1。

后面五节一节一步。请特别注意 ② 与 ③ 的顺序:先定 tile,再定流水。 第 11 章 4.5 节已经说明「能加多少 stage 是 tile 尺寸的函数」,反过来也一样—— 一个为流水优化过的 block_K 如果放不进片上,前面的工作全部作废。


2. 第一步:把一个算子变成 (flops, bytes)

roofline 的八种常见误读误读在案例算子上把答案带偏M1 用 datasheet 峰值compute time 低估 1.36×M2 用张量核 roof 给逐元素算子定价平衡点 12.72 → 161.97(12.7×)M3 按输入 dtype 声明 roof同一算子三个上限 306 / 162 / 12.7M4 用一个 stage 的 AI 代表融合后的算子AI 2.79 被读成 1.40(2.0×)M5 超越函数按 1 FLOP 记账softmax 行 0.8k → 1.7k cycles(2.08×)M6 只数 payload 不数查表查表从 6.2% 变成 79.8%(总流量 4.65×)M7 在模型没有抓手的尺度上读数字一个 decode step 的 SOL 是 0.003 µsM8 把 >105% 的效率当好性能那是公式错误,不是性能
每一条偏差都由同一个脚本在同一台机器、同一个算子上量化;它们的共同后果是把判类或效率读数带偏。
  • 案例算子 = QK-Norm + RoPE + fp8 量化,189.2 MFLOP / 67.76 MB,AI = 2.79 FLOP/Byte。
  • 「没有抓手」指 sol_time 与实测时间都低于延迟下限,启动开销占主导,模型对这一行的读数没有意义。
H200 标定后的四条 roof 与平衡点tensor_core.fp8306.67 FLOP/Byte(1380.75 TFLOPS)tensor_core.bf16168.78 FLOP/Byte(759.94 TFLOPS)tensor_core.fp16161.97 FLOP/Byte(729.26 TFLOPS)cuda_core.fp3212.72 FLOP/Byte(57.27 TFLOPS)
同一个算子只有一个真实的位置(它的 AI),但有若干条可能的 roof;把 AI 落在哪条 roof 下面,是设计者要声明的判断。
  • 带宽 = 4800 GB/s × 0.9380 = 4502.4 GB/s;平衡点 = effective 峰值算力 / 带宽。
  • 案例算子(QK-Norm + RoPE + fp8 量化)AI = 2.79 FLOP/Byte,远低于任何一条平衡点——它是极端的 memory-bound。
  • roof 必须由设计者声明:它说的是「最优实现会用哪个单元」,永远不从运行中的 kernel 推断。

2.1 账本模板

第 2 章给了四条记账规则。做设计时把它变成一张固定的表格,逐行填:

项                     flops          bytes
读入  A, B              -             Σ 每个输入对象一次
写出  C                 -             Σ 每个输出对象一次
中间量                  -             0(规则 1:不单独计)
逐元素算术              1 FLOP / 次     -
矩阵乘                  2 FLOP / 乘加   -
超越函数                1 FLOP / 次     -(这是账本口径,不是硬件成本)
特殊输出(scale 等)     -             按真实字节数计

最后一行是最容易漏的。在 code/06-case-norm-rope-quant/ 的案例里,每一行都要写一个 fp32 的量化 scale, 它只有 0.655 MB,占理想流量的 1%;但如果你的设计让它在每个 head 都重算一遍, 它就会变成一个新的大头。漏掉一行不会让账本错得很离谱,但会让后面的判据建立在 错误的基数上。

code/01-op-ledger/op_ledger.py 就是这张表:它把每个 term 的 flops 与 bytes 分别打印, 再给总数、算术强度、四条 roof 的平衡点与 SOL time = max(memory, compute)。

2.2 roofline 的正确用法

roofline 只有一个公式和一条判据:

正确的用法有且只有三条:

  1. 用它判类,不要用它预测。 它给出的是「再好也不可能快过这个数」。 真实实现一定更慢(第 2 章 2.5 节)。
  2. roof 必须由设计者声明,而且声明的是「最优实现会用哪个单元」。 TileOPs 的 roofline 文档把这条写成了规范:roof key「state the unit an optimal implementation would use, declared by the op author in code. It is never inferred from the running kernel」 (TileOPs/docs/design/roofline.md 第 63–70 行)。
  3. 换任何一台机器、任何一个 shape,都要重算。 AI 随规模变化(第 14 章 1.2 节), 标定后的峰值与带宽也随芯片变化。

用 H200 的标定 profile(R2:TileOPs/src/tileops/perf/profiles/h200.yaml, effective = theoretical × calibration,定义见 R1 §5.1)算出四条 roof 的平衡点:

roof theoretical calibration effective 平衡点
tensor_core.fp8 1979.0 TFLOPS 0.6977 1380.75 TFLOPS 306.67
tensor_core.bf16 989.5 TFLOPS 0.7680 759.94 TFLOPS 168.78
tensor_core.fp16 989.5 TFLOPS 0.7370 729.26 TFLOPS 161.97
cuda_core.fp32 67.0 TFLOPS 0.8548 57.27 TFLOPS 12.72

带宽:4800 GB/s × 0.9380 = 4502.4 GB/s(R2:15-18)。

2.3 roofline 的八种常见误读

下面每一条都由 code/02-roofline-misuse/roofline_misuse.py 在同一台机器、 同一个算子上量化。该算子就是本章第 6 节的案例:QK-Norm + RoPE + fp8 量化, 189.2 MFLOP / 67.76 MB,AI = 2.79 FLOP/Byte。

M1:把 datasheet 峰值当成可达到的峰值。 H200 的 fp16 张量核 datasheet 是 989.5 TFLOPS,标定后是 729.26 TFLOPS(持续值只有 0.6684,受 700 W 限制; burst 值 0.7370)。用 datasheet 算,compute time 被低估 1.36×。 这不是「厂商虚标」:同一个 h200.yaml 里,HBM 的标定值是 0.9380, 而纯 copy 混合只有 0.8183——标定是按访问类型分别测的。

M2:用张量核的 roof 给逐元素算子定价。 一个 norm/RoPE/quantize 的 kernel 跑在 CUDA core 上,不是张量核上。用 tensor_core.fp16 定价,平衡点从 12.72 变成 161.97(12.7×)。在当前这个算子上两种读法都得「memory-bound」,所以看起来很无害; 但只要逐元素的工作量再重一点(长归约、激活链),这个 12.7 倍的偏移就足以让判类翻转, 把你送去优化错误的东西。

M3:按输入 dtype 声明 roof。 一个 fp8 的 attention,它的 fp8 只是存储格式; 它的 QK/PV 收缩可能跑在 fp16 或 bf16 上,softmax 一定跑在 CUDA core 上。 同一份代码,三个上限:306.67 / 161.97 / 12.72。TileSight 论文的摘要正是从这个缺口 开始的:现有工具只能给「coarse roofline bounds」(R4)。

M4:用一个 stage 的 AI 代表融合后的整算子。 同一个算子,写成两个 kernel 是 134.87 MB(AI 1.40),融合成一个 kernel 是 67.76 MB(AI 2.79)。 拿拆分版的 AI 去评判融合版,会低估它的算术强度 2.0 倍,进而把后面每一个判据 都往「memory-bound」的方向偏。账本是按调用定义的:输入各读一次、输出各写一次、 中间量不计——调用次数一变,AI 就变。

M5:把超越函数按 1 FLOP 记账。 账本口径如此(第 2 章规则 2),但硬件不是。 本 corpus 里唯一一份可以引用的 ops/cycle 表在昇腾侧 (_vendor/tilelang-v0.1.15/src/ascend/transform/estimate_latency.h:57-68):

mul / add / sub / min_max / cmp / logic     51 ops/cycle
div / mod                                   13 ops/cycle
special_func(rsqrt / exp 等)               8 ops/cycle

一个超越函数是乘法的 6.38 倍成本。把它代进一个 8192 宽的 softmax 行 (每 score 5 个操作、其中 1 个是 exp):不称重是 0.8k cycles,按 pipe 称重是 1.7k cycles,2.08×。TileOPs 的文档把这条写得很直白:这个度量 「does not certify an SFU-bound kernel as at its limit」(roofline.md:34)。 注意:NVIDIA 侧在本 corpus 里没有等价的 ops/cycle 表,所以本章不给 NV 侧的倍数。

M6:只数 payload,不数查表。 案例算子里,norm 权重与 RoPE 的 cos/sin 表合计 4.19 MB,占理想流量的 6.2%——看起来可以忽略。但设计一旦把它们按「每行读一次」 来写,这 4.19 MB 会变成 251.66 MB,占实际流量的 79.8%(第 6 节)。 小数据不是问题,被反复读的小数据才是问题。

M7:在模型没有抓手的尺度上读数字。 同一个 kernel body,在 4096 token 的 prefill shape 上 SOL 是 15.051 µs,在一个 decode step(1 token、40 行 128 元素)上 SOL 是 0.003 µs。TileOPs 把这种行标成 latency-bound:「sol_time and measured time both below the latency floor: launch overhead dominates and the model has no traction」(roofline.md:40-41)。当整个算子的 SOL 只有几百纳秒时, 「效率」是错的指标——该看的是发射次数和最小能填满设备的网格。

M8:把 >100% 的效率当成好 kernel。 TileOPs 的判读分档是 「≥80% 视为到顶、>105% 视为 anomaly」,并且每一行隐含的速率都会与 roof 的 理论上限比较;越界在物理上不可能,所以那是公式错误,不是性能 (roofline.md:156-163)。最常见的成因就是少算了一行字节——比如忘了 scale 张量 (案例里 0.655 MB)。

2.4 多层 roofline:拐点在哪里

roofline 还有一个不常被提到但很有用的用法:用平衡点反推「从多大规模开始, 调 tile 才有意义」。

第 14 章 1.2 节已经做过一次:fp16 方阵 GEMM 的 AI ≈ 2M/(3b) 随边长线性增长, 代入平衡点就能解出「M 大于多少才跨过平衡点」。同一招可以套在本章的算子上。 code/01-op-ledger 的 --sweep 给出了一整张翻转表:

算子 AI 在 tensor_core.fp16 下的判类 在 cuda_core.fp32 下的判类
gemm 4096³ fp16 1365.33 compute compute
gemm 512³ fp16 170.67 compute compute
gemm 128³ fp16 42.67 memory compute
attention 32×4096×4096 d128 2068.12 compute compute
attention 32×1×8192 d128 1.01 memory memory
rmsnorm 8192×4096 1.00 memory memory
元素链 n=16.8M(融合) 0.50 memory memory
案例算子 3.10 memory memory

第三行就是 M2 讲的那件事在一个真实形状上发生:gemm 128³ 在张量核 roof 下是 memory-bound,在 CUDA-core roof 下是 compute-bound。它的真实身份取决于你打算 用哪个单元实现它——这正是「roof 要由设计者声明」的含义。


3. 第二步:tile 级解析模型

手算的账本粒度是「整个算子」。要往下走一步,就必须能回答「这个 tile 配置会怎样」。 这一类模型统称 tile 级解析性能模型。本节讲四种,并说清各自能答什么。

四种性能模型的定位层 1:手工账本(本章第 2 节)层 2:tile 级解析模型层 3:编译器内建估计器层 4:测量手工账本(flops, bytes) → AI → 平衡点成本:几分钟 · 粒度:整个算子误差:一个数量级以内TileSightintra-tile / inter-tile / cross-devicecarver-rollertraffic + smem + regs → waves粒度:tile 与 schedule来源只有摘要,无源码tilelang.tools.AnalyzerNV,静态EstimateLatency + Z3昇腾,cycle 级Nsight Compute / msProf测量唯一能给出真实时间的层对账:模型差在哪一条约束上
从成本最低、粒度最粗的手工账本,到唯一能给出真实时间的测量层,四个层次回答不同的问题,也会在不同的地方给出错误答案。
  • 手工账本能答「是不是 memory-bound、下界是多少」,答不了具体 tile 配置与绝对时间;出错时长这样:用错 roof。
  • carver 能挡掉不可行配置并给排序,但在 block_K 上没有分辨率、会大量并列;Analyzer 只统计 tl.gemm 与跨 global 的 tl.copy,sm_90/sm_100 上 estimated_time 会静默退化成访存时间。
  • 昇腾的 EstimateLatency + Z3 能算每条 pipe 的 latency 与 II,但换芯片要重新标定常量。

3.1 TileSight:把 tile 从编程原语提升为分析原语

TileSight 的自我定位写在它的论文摘要里( TileSight: A First-Principles Tile-Centric Analytic…):

programmers still rely on coarse roofline bounds, opaque ML predictors, or post-hoc profilers to understand kernel execution. … We present TileSight, a tile-centric performance-modeling tool that elevates the tile from a programming primitive to an analysis primitive.

它的结构分三层,三层共用 tile 这个抽象:

层 建模对象 机制
intra-tile(核内) 计算与访存的流水重叠 把工作表达成跨 network / memory / compute 三条 pipeline 的资源向量
inter-tile(核间) cache 层级 调度有依赖和有序关系的动作以暴露合法重叠;从 tile 复用距离推断多级 cache 命中率
cross-device(跨卡) 节点间通信 把远程张量访问映射成放置(placement),再用 alpha-beta 阶段代价路由

论文自报的数字:在 A100 / H200 / B200 / B6000 上,单卡 kernel 延迟预测的 pooled MAPE 是 12.35%;L2 命中率预测在所有 GPU 上与实测相差约 1 个百分点; 最多 32 卡时,融合的分布式 kernel 是 16.18% wMAPE,端到端 vLLM serving 是 13.52% wMAPE;在优化任务上,它选出的 tile 配置与厂商/专家基线有竞争力。

证据边界(必须说清):本 corpus 里 TileSight 只有论文摘要 (TileSight: A First-Principles Tile-Centric Analytic… 共 12 行)与仓库 README 的转录 (tile-ai/TileSight,73 行)。 没有源码,也没有全文。 所以本章只能引用它的分层结构与自报误差, 不能引用它的具体公式。它的 README 摘要里能确认的另有两点: (a)模型跑在 CPU 上,CUDA/HIP 探针单独测量目标 GPU; (b)入口形如 sight.analyze(program, H200_SXM().set_to_microbench(), options=...), 结果里可以取 result.launches["main"].kernel_body_s。 缺料已在 本章引用 的「待补资料」里登记。

它解决的问题是什么:如果你已经有 tile 形状、数据移动和软件流水的描述, 你可以在不写代码的情况下问「换一个 tile 会怎样」。这正是本章第 4、6 节 手推预算表的自动化版本。

3.2 carver:TileLang 自带的 tile 配置推荐器

TileLang v0.1.15 里有一个可以 from tilelang import carver 用起来的框架 (_vendor/tilelang-v0.1.15/tilelang/carver/README.md)。它做三件事:

  1. 建模硬件。 TileDevice 挂着一组能力参数 (_vendor/tilelang-v0.1.15/tilelang/carver/arch/arch_base.py:6-27)。 CUDA 设备模型的实现是 _vendor/tilelang-v0.1.15/tilelang/carver/arch/cuda.py:139-167, 其中几个数值得记住:

    smem_cap        = cuda_driver.get_shared_memory_per_block()   # 运行时查询
    compute_max_core= device.multi_processor_count                # 运行时查询
    reg_cap         = 65536                                       # :157
    max_smem_usage  = 2 * smem_cap                                # :158
    sm_partition    = 4                                           # :159
    transaction_size= [32, 128]                                   # 字节,:162
    bandwidth       = [750, 12080]                                # MB/s,:167
    

    注意 bandwidth 那两行的注释:「will be used for recommend basic tile size」, 并且明确写着只有设备之间的比例才有意义 (_vendor/tilelang-v0.1.15/tilelang/carver/arch/cuda.py:163-166,见 S3)。 它不是一块 HBM 的可用带宽。

  2. 枚举 tile 结构。 MatmulTemplate / GeneralReductionTemplate / ElementwiseTemplate / GEMVTemplate / FlashAttentionTemplate / ConvTemplate 把「循环该怎么分块」参数化 (_vendor/tilelang-v0.1.15/tilelang/carver/template/)。 调用 recommend_hints(topk=20) 拿到 top-k 建议。

  3. 按 cost model 排序。 核心在 _vendor/tilelang-v0.1.15/tilelang/carver/roller/policy/default.py。把它拆成公式:

    traffic(:398-432)。对每个节点的每个输入张量,按传播后的区域算 coalesced_tensor_shape(region, full_shape, 读事务元素数) × 每元素字节 (roller/policy/common.py:41-52)。对普通 GEMM,这个量最终等于 —— code/03-carver-cost-model --self-test 就是在核对这一点,误差为 0。

    片上容量(:452-495)。用一个 best-fit 分配器 (roller/bestfit.py:22-61,32 字节对齐)把每个节点要缓存张量的 footprint 拼起来,得到 smem_cost。

    寄存器(:564):

    reg_usage = 2 * max over nodes of ( prod(tile) * dtype_bits / 32 )
    

    这是一个块级的累加器占用量代理:prod(tile) 是输出块的元素数, 乘上位宽再乘 2。位宽取哪个 dtype 取决于 node.get_dtype() 在真实输入上返回 什么(累加 dtype 还是输入 dtype)——本章的复算取 fp32 累加,并把这一点登记为 假设,见 资料库索引 的「已知局限」。

    驻留与波数(:556-574):

    if smem_cost > smem_cap or reg_usage > reg_cap: 该候选直接丢弃
    block_per_SM = min(max_smem_usage // smem_cost,
                       reg_cap        // reg_usage,
                       sm_partition)
    num_wave     = ceil(grid_size / (block_per_SM * compute_max_core))
    

    排序键(:111-112):

    priority = (traffic + 1) * num_wave
    

    也就是「搬得少、波数少」的候选优先。搜索本身是最优先遍历 (dfs_smem_tile,:96-134),已访问 tile 数量上限 2000。

code/03-carver-cost-model/carver_cost_model.py 把这套算术在 CPU 上复算了一遍。 在 4096³ fp16 / fp32 累加、132 SM、227 KB shared 的设定下:

candidates visited : 729   valid : 154
rejected on regs     :  122
rejected on smem     :  453
model priority values in use : 48
ties at the top     : 8

三件事直接读出来:

  • 寄存器比 shared memory 更早成为瓶颈:122 个候选被 reg_usage > reg_cap 丢掉。
  • 模型对 block_K 没有分辨率:128×256×16 与 128×256×128 的 traffic 与 priority 完全相同。所以「先定 M/N,再定 K」不只是经验,是这套 cost model 的结构。
  • 模型会给出大量并列:154 个可行候选只产出 48 个不同的 priority,前 8 名并列。 第 11 章 3.9 节说「并列就是该去测量的信号」,这里给出了它的量级。

carver 的定位是「排序」,不是「定案」。 它挡掉明显不可行的候选、给出一个起点集合; 它不替代测量。还有一个现实约束:carver 在没有 GPU 的机器上用不起来。 CUDA.__init__ 会调用 tvm.runtime.cuda(0) 并在没有设备时抛 RuntimeError("Cannot find cuda device 0.") (_vendor/tilelang-v0.1.15/tilelang/carver/arch/cuda.py:144-146), 而 auto_infer_current_arch() 在没有 CUDA / MPS 时会退到 CPU 模型 (.../carver/arch/__init__.py:47-56),CPU 模型只填了平台名 (.../carver/arch/cpu.py:12-19),没有 smem_cap。这就是本章用复算脚本而不是 直接调用 carver 的原因。

顺带一提,carver 不只是离线工具: _vendor/tilelang-v0.1.15/tilelang/jit/adapter/wrapper.py:403 会在需要 设置 L2 persistence 时调用 tilelang.carver.arch.driver.get_persisting_l2_cache_max_size。

3.3 tilelang.tools.Analyzer:一个必须知道边界的工具

v0.1.15 自带一个从 TIR 直接估 FLOPs 与访存的工具 (_vendor/tilelang-v0.1.15/tilelang/tools/Analyzer.py)。用法是:

from tilelang.carver.arch import CUDA
from tilelang.tools import Analyzer

tir = kernel.get_tir(block_M=128, block_N=128, block_K=32)
result = Analyzer.analysis(tir, CUDA("cuda"))
# result.total_flops / total_global_bytes / expected_tflops / estimated_time

它的实现很短,因此边界也很清楚。逐条读源码:

  • total_flops 只统计 tl.gemm / tl.tileop.gemm (Analyzer.py:90-106,148-151)。逐元素算术、T.reduce_*、T.Parallel 里的运算 一律计 0。

  • total_global_bytes 只统计跨 global 边界的 tl.copy / tl.tileop.copy (:58-88)。直接读写 global buffer 的语句不在内。

  • 算力表只有三个条目(:10):

    ARCH_CONFIGS = {"80": (128, 1.41, 2, 108),
                    "86": (128, 1.70, 2, 84),
                    "89": (128, 2.52, 2, 128)}
    

    get_peak_tflops 用 device.compute_capability[:2] 查表,查不到就返回 None (:187-195),而 calculate 在 peak_tflops is None 时把 estimated_time 静默退化成只有访存时间(:201-204)。 换句话说:在 Hopper(90)和 Blackwell(100/103)上,这个工具不会报错, 它只会给你半个答案。

  • 那三个条目的第三个字段是 flops_per_cycle = 2,也就是每 cycle 两次浮点运算—— 这是 CUDA core 的 FMA 口径,不是张量核口径。官方文档自己写了: 「Treat expected_tflops and estimated_time as comparison aids, not device-specific benchmark results」 (_vendor/tilelang-v0.1.15/docs/tools/analyzer.md:110-112)。

  • expected_bandwidth_GBps 直接来自 carver 的设备模型 (Analyzer.py:198:self.device.bandwidth[1] / 1000)。用 CUDA 模型 (.../carver/arch/cuda.py:167)代进去就是 12.08 GB/s——这个数只有设备间比较的意义, 拿它去除字节数得到的绝对时间没有物理含义。

官方文档的「Limitations」一节 (_vendor/tilelang-v0.1.15/docs/tools/analyzer.md:98-112)把这些都写清楚了。 本章要在它之上再加一句方法论:一个工具的输出边界,是你在用它之前就该读的, 不是在被它的数字误导之后才去读的。 判据很简单——先问「它统计了哪些指令」, 再问「它的峰值表覆盖了我的 compute capability 吗」,最后问 「它的带宽和我的机器是同一个量纲吗」。

对这个工具的正确用法是:只要 GEMM-only 的 FLOPs 与跨 global 的字节数, 拿它们去和手算的账本对账(code/01-op-ledger/ 的 --case gemm 就是同一个数)。 不要用它去估任何一个融合算子的时间。

3.4 昇腾侧:指令级延迟模型 + SMT 求解

昇腾侧不是「流量 × 波数」的启发式。官方 pass 序列是 (_vendor/tilelang-v0.1.15/tilelang/ascend/pipeline.py:103-116):

mod = ascend_transform.EstimateLatency()(mod)
mod = ascend_transform.AutoSchedule()(mod)
mod = ascend_transform.AssignCore()(mod)
mod = ascend_transform.PrepareMultiBuffer()(mod)
mod = ascend_transform.ResolveCore()(mod)
mod = ascend_transform.InsertSync()(mod)
mod = ascend_transform.MaterializeMultiBuffer()(mod)

EstimateLatency 的模型是一条按物理 pipe 计费的规则。注释直接写在源码里 (_vendor/tilelang-v0.1.15/src/ascend/transform/estimate_latency.cc:326-340,见 S29):

1. Memory latency: walk read/write regions, classify (src,dst) scope
   pair to a physical path, charge `base + bytes/bandwidth`.
2. Compute latency:
     Cube   -> (M*K*N) / cube_throughput_<dtype> + base
     Vector -> max(op-count via OperationCounter, bytes / valu_bandwidth)
     Scalar -> one cycle per atomic task
3. II by pipe: take the max of the bandwidth- and throughput-limited
   initiation intervals for each active physical resource.
4. Total latency = memory_latency + compute_latency
   under sequential assumption; pipeline overlap is handled by the
   scheduler (Z3) via the II we emit.

两处细节值得单独指出,因为它们直接决定你写的 tile 能不能被正确估价:

  • Cube 的 ops 不是 M×N×K。 CubeOperations (estimate_latency.cc:692-707)是 2 * align(M,16) * align(N,16) * align(K, k_group),其中 k_group = 256 / bits (fp16/bf16 为 16;fp32 在 HF32 模式下为 8,否则为 1)。乘加的 2 已经含在内, M 和 N 都按 16 对齐。对齐是要花钱的:一个 N=200 的 tile 按 208 计费。
  • 总延迟是 memory_latency + compute_latency(:941), 重叠由 Z3 通过 II 处理。第 23 章 5.1 节提醒过:这个顺序假设让总延迟偏小, 而偏小的方向是危险的方向。

常量表在 _vendor/tilelang-v0.1.15/src/ascend/transform/estimate_latency.h:27-158, 其中与 tile 尺寸关系最大的 几个:

cube_throughput_fp16 = 8192 ops/cycle      cube_base_latency  = 8
cube_throughput_bf16 = 8192                cube_unit_m/k/n    = 16
cube_throughput_fp32 = 512                 cube_throughput_fp8 = 16384
aic_mte2_to_l1_bandwidth   = 100 B/cycle   mte2_gm_to_l1_base_latency = 190
mte2_gm_to_l1_min_ii       = 64 cycles     l1_to_l0a/b_bandwidth = 256

code/07-ascend-latency-model/ascend_pipe_model.py 用这些常量做了本章最有用的一次 推导:把 Cube 的 II 与 GM→L1 的 MTE2 的 II 相比,K 与两个因子 2 会消掉,得到一条 纯形状判据:

方形 tile 解出来是 边长 ≥ 164。脚本里的实际扫掠与之吻合:

M=N Cube II MTE2 II binding
64 16 128 MTE2
128 64 128 MTE2
192 144 128 Cube
256 256 164 Cube
512 1024 328 Cube

而官方昇腾 950 的 GEMM+ReLU 例子用的正是 block_M=256, block_N=224, block_K=128 (_vendor/tilelang-v0.1.15/tilelang/ascend/README.md:74)。代入模型:

Cube II 1792   MTE2 II 1229   MTE1 II 256   -> binding Cube (latency/period = 1.004)

模型没有针对这个例子做任何拟合,它落在 Cube 侧。这是自洽性的一个旁证—— 不是验证(见下方「模型的边界」)。

3.5 四种模型的定位

模型 粒度 输入 输出 什么时候用 什么时候会给出错误答案
手算账本(code/01-op-ledger/) 整个算子 shape、dtype、算法 AI、下界 决定「这个算子值不值得优化、往哪个方向优化」 roof 声明错(M2/M3)
TileSight(R4、R5) tile 与 schedule program(tile、数据移动、流水) kernel body 延迟、cache 命中率 已有几个候选方案,要在不写代码的前提下排序 论文自报 12.35% MAPE;本 corpus 只有摘要,无源码
carver-roller(S1–S20) tile 配置 PrimFunc + 设备模型 top-k 候选与排序 不知道从哪个 tile 开始试 模型无分辨率的维度上给并列(block_K)
Analyzer(S21–S27) 整个 TIR PrimFunc + 设备模型 FLOPs、跨 global 字节数 给 GEMM 对账 sm_90/100 无算力条目 ⇒ 只给访存时间;非 T.gemm/T.copy 一律不计
昇腾 EstimateLatency+Z3(S28–S34) 语句 / 任务 TIR + 标定常量 每任务 latency、II、开始时间 预测调度结果、判断哪条 pipe 是瓶颈 换芯片要重标常量;模型偏小方向的误差

模型的边界:code/07-ascend-latency-model/ 是教学版。真实的 EstimateAscend 还要算 Vector 的 ops/cycle、ScaleBytesByLanes 的 lanes 缩放、每个物理路径的完成延迟,以及基于 pipe mask 的 II 合成(estimate_latency.cc:750-970)。本章只保留 Cube、MTE1、MTE2 三条。第 23 章讲完整的 pass。


4. 第三步:tile 尺寸怎么定

tile 尺寸的四条约束任一条超了block_M, block_N, block_K, stages, threadsC3 指令形状(最便宜,先查)mma 16/8/16 · wgmma 64/8/16 · warpgroup=128 · 昇腾 fractal 16x16C2 寄存器压力(最容易忽略)accum/thread + 地址/索引/流水 ≤ 255/线程C1 片上容量(最硬的墙)stages×(A+B) ≤ 可用 shared(227 KB/block)或 UB/L1 容量C4 并行度(最容易被忘)grid / (blk/SM × 真实 SM 数) → 波数 + 尾波填充率四条都过 → 进入候选集合任一条不过 → 记下是哪一条,回上一步
tile 尺寸不是一个自由度,是四条约束的交集:指令形状、寄存器压力、片上容量、并行度;任一条不过就记下是哪一条,回上一步。
  • 先粗算再微调的顺序:① 从容量允许的最大 tile 开始 → ② 查指令形状是否整除 → ③ 算寄存器与驻留块数 → ④ 算波数与尾波填充率 → ⑤ 只有到这一步才动 block_K / stages。
  • 第 ⑤ 步的位置很关键:carver 复算显示 block_K 从 16 到 128 完全不改变 traffic 与 priority(前 8 名并列),先定 M/N 再定 K。
  • 片上容量超了是编译或启动失败,不是变慢;寄存器溢出到 local memory 则是性能断崖。

4.1 四条约束

tile 尺寸不是一个自由度,是四个约束的交集:

C1 片上容量。 做 级流水,需要同时保留 份输入 tile:

在 Hopper 上,单 block 的上限是 227 KB(每 SM 可配到 228 KB)。 超了是编译或启动失败,不是变慢。

C2 寄存器压力。 累加器必须在计算期间常驻寄存器:

CUDA 最佳实践文档给了这把尺子的刻度:compute capability 7.0 的 SM 有 65536 个 32 位寄存器、最多 2048 个驻留线程,也就是每线程 32 个寄存器才能到 100% occupancy, 而且寄存器分配以「每 warp 256 个」为单位向上取整 (CUDA C++ Best Practices Guide 第 3126 行)。单线程的硬上限是 255。

C3 指令形状。 张量核指令有固定形状:mma 密集 f16 是 m16n8k8 / m16n8k16 (Parallel Thread Execution ISA 第 23476–23477 行),wgmma 密集 f16 是 m64nNk16,N 从 8 到 256 (同文件 :28132),一个 warpgroup 是 4 个连续 warp 即 128 线程(:28113-28114)。 block_M 不是 64 的倍数,就用不上 wgmma 的 m64。昇腾侧的对应约束是 Cube 的 16×16 分形与各级对齐要求(UB 32 B、L1 32 B、L0A/L0B 512 B、L0C 64 B)。

C4 并行度。 输出块数除以「每 SM 驻留块数 × SM 数」就是波数。最后一波填不满, 机器就空转。第 14 章 4.1–4.2 节的 split-K / stream-K 就是为这一条准备的。

4.2 四条约束在两边的对照

约束 NVIDIA(sm_90) 昇腾
片上容量 shared 228 KB/SM、227 KB/block;CUDA 保留 1 KB、静态分配 48 KB 需 opt-in(R7) L1 / L0A / L0B / L0C / UB 五块独立,各有对齐;容量随型号,需 GetCoreMemSize 查询(R11、R12);L1 512 KB 与 UB 192 KB 都是示例条件值(R13、R14)
寄存器 65536/SM、255/线程、按每 warp 256 个取整(R8) 不走这条线;约束落在 L0/UB 容量与 MTE 带宽上
指令形状 mma 16×8×16、wgmma 64×N×16、warpgroup=128(R9) Cube 分形 16×16;对齐 UB 32 B / L1 32 B / L0A·L0B 512 B / L0C 64 B(R11)
并行度 输出块数 / (驻留块数 × SM 数) 常是 persistent 的一维固定块数(官方例子 num_blocks=32,S35),并行度由核数而不是 SM 数决定

4.3 决策流程:先粗算,再微调

code/04-tile-size-decision/tile_decision.py 把流程实现成了检查机。顺序不能换:

第 1 步:从「容量允许的最大 tile」开始。 因为 DRAM 流量 随 tile 增大而单调下降。 在 4096³ 的例子里,traffic 最优是 256×256(1107.3 MB), 其次是 128×256 / 256×128(1644.2 MB),再其次是 128×128(2181.0 MB)。

第 2 步:查指令形状是否整除。 不整除就没有意义,先改尺寸。

第 3 步:算寄存器与驻留块数。 256×256 + fp32 累加 + 512 线程 = 128 regs/thread,65536 // (128 × 512) = 1,也就是只剩 1 block/SM; 128×256 + 256 线程同样是 128 regs/thread,但每 block 一半的寄存器, 于是能驻留 2 块。这一步的输出是「谁在卡我」:

block=( 128, 256, 64)  stages=3  threads=256  instr=wgmma
  C1 shared/stage     48.0 KB -> total    144.0 KB
  C2 accumulators   128/thread ->  128 regs/thread (warp-rounded),  32768 regs/block
     occupancy: regs -> 2 blk/SM, smem -> 1, threads -> 8  => 1 blk/SM, binding=smem
  C4 grid    512 tiles over   8-warp blocks -> 4 wave(s), last wave  87.9% full

第 4 步:算波数与尾波填充率。 尾波明显不满时,先考虑 split 类变体而不是继续加大 tile。

第 5 步:只有到这一步才动 block_K 与 num_stages。 它们不影响流量 (carver 的复算已经证明 block_K 在 traffic 与 priority 上完全没有分辨率), 只影响流水与占用。第 11 章 4.5 节给出了这一维的判据 (num_stages ≥ 1 + ceil(latency / compute))。

4.4 什么时候「大 tile」对,什么时候「小 tile + 多波」对

这不是风格问题,是两条约束谁先撞到的问题:

情形 该往哪走 判据
算术强度高(大 GEMM、prefill attention) 大 tile 流量是主项,且 blocks/SM 只要 ≥ 1 就能靠 tile 内并行拉开
第一波就填不满设备(块数 < SM 数) 先加并行度,必要时 split 小块再大也填不满
属于「少量块 × 深 K」(瘦长 GEMM) split-K 第 14 章 4.1 节
最后一波明显不满 stream-K 或调整分块 第 14 章 4.2 节
逐元素 / 归约类,AI 远低于平衡点 tile 只受容量与向量宽度约束,不要为了流量去加大 tile 流量由「读几次」决定,不由 tile 尺寸决定

最后一行是本章与第 14 章最重要的区别:第 14 章那套「让 tile 越大越好」的表, 只对 compute-bound 的算子成立。 对一个 AI ≈ 3 的算子,加大 block_N 不会减少 任何一次访存,只会让寄存器先爆掉。


5. 第四步:fusion / split / 特殊指令

fusion / split 的四个问题省得很少或为负省得多超过预算还放得下会不会新工作 ≥ 省下的流量新工作 < 省下的流量要不要把这两步合成一个 kernel?① 融合能省多少字节?bytes_split − bytes_fused不要为了省流量而融去看 ②③④ 是不是别的原因② 融合后要同时活着多少片上数据?拆:容量是硬约束不是可以调的参数③ 融合会不会把 grid 缩到装不满设备?两条路:保持拆分,或融合 + 在别的轴上切split-K / split-KV④ 融合引入了什么新工作?额外输出 · 额外归约 · 额外同步拆融合按 ②③④ 决定
融合的收益只有一个来源——少搬一趟中间量,但代价有三样;所以判据是四个问题,而不是一个结论。
  • 六个真实取舍里有两行是「故意多搬字节但装得满设备」的一方胜出:瘦长 GEMM 的 split-K(多搬 67.1 MB,64 → 512 块)、decode attention 的 split-KV(多搬 2.1 MB,32 → 2048 块)。
  • 同一个判据在并行度不足时会给出与流量相反的答案——这就是判据必须写出来的原因。
NV 与昇腾的方法论差异NVIDIA(sm_90)昇腾(Ascend 950)谁来算warp / warpgroup(SIMT,同一段代码所有线程)一个 AI Core = 1 Cube + 2 Vector(两块执行体)片上容量谁决定 tileshared memory 容量 + 寄存器堆 65536/SM + 每线程 255 个上限L1 / L0A / L0B / L0C / UB 各自独立、各有对齐(L0A/L0B 512 B、L0C 64 B、UB 32 B)第一约束通常是谁寄存器(累加器)与 sharedGM→L1 的 MTE2 带宽平衡判据每线程寄存器数 ≤ 255,每 SM 驻留块数 ≥ 2M*N/(M+N) ≥ cube_throughput/mte2_bw(本例 81.92,方形 tile 边长 ≥ 164)调度怎么做数据流注解(stage/order)+ InjectSoftwarePipeline任务 + latency + II + 资源掩码 → Z3 求解 → PrepareMultiBuffer/Materialize同步异步拷贝自带等待 + barrier,用户基本不写InsertSync 统一插成对 Set/Wait flag,用户也基本不写特殊的并行手段cluster / DSM / TMA multicast / warp specializationCube 与 Vector 的核间流水 / dual_copy / SimtVF / SimdVF超低精度fp8(mma e4m3/e5m2,m16n8k16)、MX 块缩放fp8 走 Cube(16384 ops/cycle);mxfp4 走 MX MAD(32768 ops/cycle)
三条线——容量、执行体、同步——如何改变设计选择;同一个算子、同一套判据,在两边会得出不同答案。
  • 容量差异改变的是「每条数据该住在哪一块」;执行体差异让「把 epilogue 融进 GEMM」在昇腾上还多一层意思——用 Vector 的时间盖住 Cube 的时间。
  • 同步差异:昇腾是 pipe 到 pipe 的事件,加一条新数据流 = 加一对 flag,要问「这条依赖值得一次同步吗」。
  • 同一个大 tile 在两边成立的理由不一样:128x128x32 在 NV 侧是 2 blk/SM,在昇腾侧是 MTE2 II 164 > Cube II 128(GM→L1 是瓶颈)。

5.1 融合的账本:四个问题

融合的收益只有一个来源:少搬一趟中间量。省下的字节数就是中间量的大小乘 2 (原来要写一次、读一次)。但融合要付出三样代价,每一样都可能把它否掉。 所以判据是四个问题(code/05-fusion-split/fusion_split.py):

  1. 融合省多少字节? 若中间量只有几百 KB 而输入输出是几十 MB,省的可以忽略。
  2. 融合后要同时活着多少片上数据? 中间结果必须留在寄存器或片上, 不能落回 HBM——否则等于没融合。
  3. 融合会不会把 grid 缩到装不满设备? 两个各自有 10 万块的逐元素算子, 融合后可能只剩几千块。
  4. 融合引入了什么新工作? 额外的输出、额外的归约、额外的同步。

第 3、4 条在实际工程里最常被忽略。code/05-fusion-split/ 的六个取舍里,有两个是故意让搬运 更多字节的一方胜出:

decision                                        A MB      B MB  delta MB    A grid    B grid  winner
skinny GEMM 1024x1024x16384, split-K=8           4.2      71.3     -67.1        64       512  split-K=8
decode attention, sq=1, skv=128k, split-KV=64    2147.5    2149.6      -2.1        32      2048  split-KV=64

两行的原因相同:64 块和 32 块都填不满 132 个 SM,而多搬 67.1 MB / 2.1 MB 换来 512 / 2048 块。判据必须写出来,就是因为它在不同的输入下会给出相反的答案。

5.2 什么时候必须拆

和 5.1 对称,拆的判据是「融合的哪一条代价被突破了」:

  • 片上容不下。 这是硬约束。典型是「把整个 attention 融进一个 kernel」—— 当 KV 长度让 N×N 的 score 矩阵放不下时,就必须回到 online softmax 的分块形态 (第 15 章 2.5 节)。
  • 并行度被压到设备以下。 此时要么拆,要么用 split 类变体把并行度买回来。
  • 需要两种完全不同的执行体。 昇腾上 Cube 与 Vector 是两块执行体; NV 上张量核与 CUDA core 也在争同一份寄存器。跨执行体的融合收益要重新算一遍。
  • 中间的数值精度必须回落到内存。 例如需要把 fp32 中间量存成 fp32 才能保证 数值正确性,那就必须写出去,融合的意义大打折扣。

5.3 特殊指令与特殊布局的判据

默认降级路径(T.copy → 普通访存、T.gemm → mma)先试。只有出现下面这类 可观测的信号时,才引入特殊路径:

信号 说明默认路径撞到了什么 可能的手段
shared memory 的 bank conflict 高 布局与访存模式不匹配 T.use_swizzle / T.annotate_layout(第 21 章)
搬运指令占比高、计算单元空转 搬运没有被重叠 TMA(Hopper)/ T.Pipelined 多缓冲(第 22、24 章)
累加器寄存器爆掉 累加必须落在同一处 Blackwell 的 T.alloc_tmem + T.tcgen05_gemm(第 24 章)
布局转换出现在热路径上 两个 op 要的布局不同 提前 T.annotate_layout 统一(第 21 章)
昇腾上 Cube 与 Vector 互相等 核间没有流水 把两者放进同一个 T.Pipelined,或用 dual_copy(第 25 章)

反过来说:没有信号就不要用。 特殊路径都会引入新的约束(对齐、形状、同步), 而这些约束会在下一个 shape 上咬人。第 14 章 2.5 节的官方 intrinsic 例子自述是 「a debug config」,就是这条规则的注脚。

5.4 昇腾与 NVIDIA 的方法论差异

同一个算子、同一套判据,在两边会得出不同答案。原因有三条,都能在锁定源码里看到。

差异一:容量的形状不一样。 NV 侧是「一个 shared + 一个寄存器堆」, tile 尺寸是「一个数放不放得下」。昇腾侧是 L1 / L0A / L0B / L0C / UB 五块独立存储, 每块有自己的容量与对齐(对齐要求:UB 32 B、L1 32 B、L0A 512 B、L0B 512 B、 L0C 64 B、BiasTable 64 B、Fixpipe 64 B——来源见 R11)。于是「tile 尺寸」在昇腾上 其实是「每条数据住在哪一块、按什么对齐」。

差异二:执行体的数量不一样。 NV 上一个 block 里的线程是一个 SIMT 整体。 昇腾上一个 AI Core 是 1 个 Cube + 2 个 Vector。这直接改变「融合」的定义: 在 NV 上把 epilogue 融进 GEMM 是省一趟 HBM; 在昇腾上还多一层意思——用 Vector 的时间盖住 Cube 的时间。 官方 GEMM+ReLU 例子就是 T.dual_copy(C_l0c, C_ub) 之后在 T.SimtVF(threads=128) 里做 ReLU,再 T.dual_copy 写回 (_vendor/tilelang-v0.1.15/tilelang/ascend/README.md:62-104,见 S35)。前面第 15 章 4.5 节也讲过 attention 上「QKᵀ 的结果必须绕一趟」。

差异三:同步的单位不一样。 NV 上是线程屏障加异步拷贝自带的等待,用户几乎不写。 昇腾上是 pipe 到 pipe 的事件(Set/Wait flag),InsertSync 统一插入成对 flag (_vendor/tilelang-v0.1.15/tilelang/ascend/pipeline.py:113,见 S34)。 所以在昇腾上加一条新数据流, 要问的问题不是「要不要加 barrier」,而是「这条依赖值不值得一次 flag 往返」。

这三条差异如何改变设计选择,可以用本章第 6 节的案例看:

决策 NV 侧的理由 昇腾侧的理由
要不要融合这三步 省 83.9 MB 的往返 同样省 83.9 MB,而且省掉两次 kernel 之间的同步
tile 取多大 让 blocks/SM 至少为 1,同时让流量接近下界 让 GM→UB 的搬运能盖住 Vector 的计算,同时 UB 装得下
需不需要特殊指令 默认 T.copy + T.gemm 先试 默认路径已经含 dual_copy / SimtVF 的编排,要先确认它没被绕过

6. 第五步:把一个融合算子从头设计一遍

本节把前面五节走一遍。所有数字来自 code/06-case-norm-rope-quant/, 可以在任意机器上复算。

案例的流量账:改一个数,流量降到 1/4.5理想下界(payload + 查表)67.76 MB(1.00×)A:一行一 block315.23 MB(4.65×)B:8 行一 block95.03 MB(1.40×)C:一 token 一 block69.86 MB(1.03×)
QK-Norm + RoPE + fp8 量化、4096 token;payload 三列完全相同,差别只在 norm 权重与 RoPE 表被重复读了多少次。
  • 每一列的分解:payload 都是 63.57 MB;lookup 分别是理想 4.19 MB、A 251.66 MB、B 31.46 MB、C 6.29 MB。
  • 下界从 15.05 µs 抬到 70.01 µs:调参救不了第一版,只有改设计(一个 block 处理几行)能救它。
  • 带宽取 H200 标定值 4502.4 GB/s;第二版没有换算法、没有换机器,只把一个 block 的行数从 1 改成 40。

6.1 问题定义

推理侧的 attention 前处理,每层每个 token 都要跑:

q, k = proj(x)                      # bf16, [tokens, heads, head_dim]
q, k = rmsnorm(q, k)                # 沿 head_dim,每个 (token, head) 一次
q, k = rope(q, k)                   # 同一 token 的所有 head 共用一张 cos/sin 表
q8, k8, scale = fp8_quant(q, k)     # 每个 (token, head) 一个 fp32 scale

参数:tokens=4096、q_heads=32、kv_heads=8(GQA)、head_dim=128、 输入 bf16、输出 fp8 e4m3。

6.2 预算表

第一步,账本(这部分与设计无关):

rows (token, head) 对    : 163840
元素数                   : 20971520
flops                    : 210.2 MFLOP
理想字节                 : 67.76 MB  (payload 63.57 + 查表 4.19)
算术强度                 : 3.102 FLOP/Byte
访存下界 @4502.4 GB/s    : 15.051 us

判类:3.102 远低于 cuda_core.fp32 的平衡点 12.72,更远低于张量核的 161.97。 这个算子是 memory-bound,而且是极端 memory-bound。结论直接出来:

不要为它引入张量核,不要为它调 num_stages。 唯一值得做的是减少它实际搬运的字节数。

第二步,tile 尺寸。 因为它是逐行的归约 + 逐元素变换,tile 的 C1/C2 只受 「一个 block 要同时活着多少行」约束,不受指令形状约束(不涉及张量核)。 所以这一节的问题变成:一个 block 处理几行?

第三步,流水。 每行的归约依赖整行的数据,所以「先读完整行再算」是天然的 两级结构。这在第 11 章的框架里就是 2 级流水:搬运本行与计算上一行重叠。 不需要更多级,因为下一行不依赖上一行的结果。

第四步,特殊指令。 这里没有任何一条 5.3 节的信号。默认路径就是答案。

6.3 第一版:一行一个 block

最直觉的写法:grid = 163840,每个 block 处理一个 (token, head) 的 128 个元素, threads=128,每个线程一个元素,在 UB/shared 里做一次树形归约求平方和, 然后读 RoPE 表、旋转、求 absmax、量化、写回。

它的预算表长这样:

design                     rows/blk    grid  waves  on-chip KB  payload MB  lookup MB  total MB  vs ideal
A: one row per block              1  163840    156         1.9       63.57     251.66    315.23     4.65x

逐项看:

  • 片上占用只有 1.9 KB——离任何容量上限都远。这一版不是被 C1 卡住的。
  • 片上放得下、形状也没有问题,所以 C1/C3 这两条最常见的约束在这里都不是问题。
  • 总共要搬 315.23 MB,是理想值的 4.65 倍。

这个数字就是这一版被否掉的全部理由。

6.4 性能诊断

诊断的关键是把超出的部分拆开:

A: one row per block
    total  315.23 MB = payload  63.57 MB + lookup  251.66 MB   (lookup is  79.8% of all traffic)
    over the ideal by  247.46 MB  -> the achievable time floor rises from 15.05 us to 70.01 us

三个结论,一个比一个具体:

  1. payload 完全正确:63.57 MB,与理想值一字不差。也就是说数据的读、写、scale 都数对了。
  2. 多出来的 247.46 MB 全是查表:norm 权重(每行读一次 q 和 k 的两份 128 元素权重) 加上 RoPE 表(每行读一次 128 个 cos 与 128 个 sin,fp32)。
  3. 因此下界从 15.05 µs 抬到 70.01 µs。 这一版的实测时间无论怎么调,都注定在 70 µs 这个量级之上——调参救不了它,只有改设计能救它。

如果手上有一台有卡的机器,这里该做的验证是:

  • 用 Analyzer 拿 GEMM-only 的对照(这一步不适用于本算子,因为它没有 T.gemm), 用第 12 章的口径拿实测时间与实测 DRAM 流量;
  • 检查实测 DRAM 流量是不是接近 315 MB。如果是,模型的诊断成立, 而且它同时说明 L2 没有兜住这些查表——163840 个 block 之间的复用距离太远;
  • 如果实测流量接近 68 MB,说明 L2 全部兜住了,那么这一版的真实问题在别处 (描述符开销、发射次数、尾部效应),诊断要换一条路走。

这正是「模型告诉你去看哪里,测量告诉你看到的是不是真相」。

6.5 第二版

改动的唯一目标是让查表被摊开。 具体做法:一个 block 处理连续的若干行, 并且让这些行落在同一个 token 的同一个 head 组内——这样 norm 权重只读一次、 RoPE 表只读一次,整组行共享。

两个候选:

B: 8 rows per block               8   20480     20         8.0       63.57      31.46     95.03     1.40x
C: one whole token per block     40    4096      4        36.2       63.57       6.29     69.86     1.03x
  • B(8 行一 block):grid 从 163840 降到 20480,查表从 251.66 MB 降到 31.46 MB, 总流量 95.03 MB,是理想值的 1.40 倍。片上只要 8 KB。
  • C(一整个 token):一个 block 处理 32 个 q head 加 8 个 kv head,共 40 行。 grid 降到 4096,查表只剩 6.29 MB,总流量 69.86 MB,只比理想值高 3%。 片上 36.2 KB,仍然远在预算之内。

选 C,理由是三条账都过:流量 1.03×、片上 36.2 KB、grid 4096(远大于 132 SM)。

6.6 结账:两版之间到底发生了什么

第一版 A 第二版 C 变化
一个 block 的行数 1 40 按 token 分组
grid 163840 4096 ÷40
片上占用 1.9 KB 36.2 KB ×19(仍宽裕)
总流量 315.23 MB 69.86 MB ÷4.5
相对理想值 4.65× 1.03× —
下界(4502.4 GB/s) 70.01 µs 15.52 µs ÷4.5

第二版没有换算法、没有换机器、没有调任何参数——只改了一个数:一个 block 处理几行。 而这个数是从 6.4 节的诊断里直接读出来的,不是试出来的。

补两条这一版仍然存在的风险,用来提醒「预算表不是保证」:

  1. 40 行一 block 意味着一个 block 要索引两套 norm 权重(q 一套、k 一套) 和一张按 token 索引的 RoPE 表。如果实现里把这个索引写错,正确性会立刻崩, 而且症状是「某些 head 的输出不对」这种最难查的形态。
  2. grid 只有 4096,尾波与负载均衡变得重要。 4096 块在 132 SM 上, 每 SM 驻留 8 块时是 4 波,尾波填充 4096/(132×8)=3.88 → 最后一波约 88%。 再加大行数会把这一项继续推高收益,但同时减少并行度——这就是 5.1 节第 3 个 问题在真实案例里的形态。

7. 反模式清单

下面每一条都能在本章前面找到定量依据。把它们当成设计评审时的检查项。

  1. 先写代码再调参。 如果一个算子的 AI 比平衡点低两个数量级,调参空间本来就不存在 (第 1.1 节、code/01-op-ledger/)。先算。
  2. 用 datasheet 峰值算效率。 在 H200 上会把 compute time 低估 1.36×(M1)。
  3. 用张量核的 roof 给逐元素算子定价。 平衡点差 12.7×(M2)。
  4. 按输入 dtype 声明 roof。 fp8 是存储格式,不是计算单元(M3)。
  5. 用一个 stage 的 AI 代表整个融合算子。 会把 AI 读成一半(M4)。
  6. 把超越函数当成和乘法一样便宜。 昇腾上差 6.38 倍,softmax 行整体差 2.08 倍(M5)。
  7. 只算 payload,不算查表 / scale / 索引。 案例里查表从 6.2% 变成 79.8%(M6)。
  8. 在小到没有意义的尺度上读效率。 decode shape 的 SOL 可以只有 0.003 µs(M7)。
  9. 把 >100% 的效率当成好 kernel。 那是公式错误(M8)。
  10. 忽略 epilogue 成本。 融合的收益要减去额外输出、额外归约、额外同步 (5.1 节的第 4 个问题)。写出去再读回来的中间量,等于没融合。
  11. tile 太大导致 occupancy 崩掉。 256×256 + fp32 累加 + 512 线程 = 128 regs/线程, 只剩 1 block/SM;再大一点(256×256 累加器 = 256 regs/线程)直接越界(4.3 节)。
  12. 反过来,tile 太小导致每 block 只搬几百字节。 描述符与发射开销成为主项, 而且 L2 复用距离变远(案例的第一版)。
  13. 只在 M×N 上调,不知道 block_K 对流量没有分辨率。 carver 的复算显示 154 个可行候选里前 8 名并列(3.2 节)。先定 M/N,再定 K。
  14. 把模型的输出当成判决。 code/07-ascend-latency-model/ 里 Cube 的 8 cycle 完成开销就把 stage 公式 从 2 顶到 3,而官方例子用的是 2。模型给判据,测量给结论。
  15. 忽略同步的成本。 昇腾上是 pipe 到 pipe 的 flag 往返;给每条依赖都加一次同步, 流水就被切碎了(5.4 节)。
  16. 在需要特殊指令之前就用特殊指令。 没有 5.3 节的信号就不要引入新约束; 特殊路径的约束会在下一个 shape 上咬人。

8. 本章小结

把这一章压缩成六句话:

  1. 顺序是「建模 → tile → 流水 → 特殊路径 → 代码」。 代码是第五步, 而第 ① 步的产物(AI 与平衡点)决定后面四步值不值得做。
  2. roofline 是判类工具,不是预测工具。 它的三个输入都要自己声明: 哪些字节算进去、用哪条 roof、哪个 shape。声明错了,后面的每一步都会错。
  3. tile 级解析模型有三个层次。 手算账本告诉你「往哪个方向优化」; TileSight / carver 这一类模型告诉你「哪几个候选值得试」; 编译器内建的估计器(Analyzer、EstimateLatency+Z3)告诉你 「编译期已经替你算了什么」,而它们的边界必须逐条读源码确认。
  4. tile 尺寸是四条约束的交集:片上容量、寄存器、指令形状、并行度。 超了会直接失败或直接掉性能,所以要在写代码之前算。 顺序是先 M/N 再 block_K。
  5. fusion / split 的判据是四个问题:省多少字节、要多少片上、会不会缩掉并行度、 引入什么新工作。有四条互斥的答案,所以判据必须写出来。 同样的判据在 NV 与昇腾上会得出不同答案,因为容量形状、执行体数量与同步单位都不同。
  6. 模型和测量各管一半。 模型告诉你「差多少、差在哪一条约束上」, 测量告诉你「实际差的是不是这一条」。案例里第一版到第二版的 4.5 倍差距, 完全来自模型诊断出的「查表被重复读了 40 次」,而不是来自任何一次调参。

延伸阅读

习题

14 题 · 答案在提交后展开

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

E1概念难度 ★无需硬件概念, ,无需硬件 写出本章「先算再写」的五步流程,并解释为什么

写出本章「先算再写」的五步流程,并解释为什么 「定 tile 尺寸」必须排在「定流水结构」之前。举一个把两者顺序颠倒会出问题的具体例子。

E2概念难度 ★无需硬件概念, ,无需硬件 roofline 的三个输入分别是什么?为什么 TileOPs 的

roofline 的三个输入分别是什么?为什么 TileOPs 的 规范要求 roof key 必须由算子的作者在代码里声明,而不能从正在跑的 kernel 或者输入 dtype 推断出来?

E3计算难度 ★★无需硬件计算, ,无需硬件 用 `TileOPs/src/tileops/perf/profiles/h200.yaml` 的

用 TileOPs/src/tileops/perf/profiles/h200.yaml 的 数值,算出 cuda_core.fp32 的平衡点,并写出你的算式(theoretical、calibration、 带宽各用了哪个数)。然后回答:为什么给一个逐元素 / 归约类的算子用 tensor_core.fp16 的 roof 是错的?用本章案例算子的 AI(3.102)说明这个错误 会把平衡点带偏多少倍。

E4代码阅读难度 ★★无需硬件代码阅读, ,无需硬件 读

读 _vendor/tilelang-v0.1.15/tilelang/tools/Analyzer.py 的第 10 行与第 187–204 行。 回答:

  1. 在一个 compute capability 9.0(Hopper)的机器上调用 Analyzer.analysis(tir, CUDA("cuda")),result.expected_tflops 是什么?
  2. 此时 result.estimated_time 等于什么?它漏掉了哪一半?
  3. 为什么说这个工具「不会报错,只会给你半个答案」?
  4. 如果你在一个 sm_89 的机器上跑同一个 kernel,expected_tflops 是不是一个 可以拿来和实测 TFLOPS 比较的数?为什么?
E5代码阅读难度 ★★无需硬件代码阅读, ,无需硬件 读

读 _vendor/tilelang-v0.1.15/tilelang/carver/roller/policy/default.py:537-574, 写出 compute_tile_dict 里与 reg_usage、block_per_SM、num_wave 有关的四行公式。 然后代入 block_M=block_N=128、block_K=32、fp16 输入、fp32 累加 (smem_cap=227 KB、reg_cap=65536、sm_partition=4、SM 数=132),回答: reg_usage 与 smem_cost 各是多少?block_per_SM 的三项分别是多少, 哪一项是 binding?

E6动手难度 ★★无需硬件动手, ,无需硬件 运行

运行

python3 code/03-carver-cost-model/carver_cost_model.py --smem-cap 101376 --cores 108 --topk 8

把它与默认参数(--smem-cap 232448 --cores 132)的结果对比,回答:

  1. top-8 的 tile 有没有变化?为什么?
  2. num_wave 变化了多少?
  3. 这两个参数里,哪一个更能解释结果的差异?
E7动手难度 ★无需硬件动手, ,无需硬件 运行

运行

python3 code/01-op-ledger/op_ledger.py --case gemm --m 128 --n 128 --k 128

然后分别用 --roof tensor_core.fp16 和 --roof cuda_core.fp32 各跑一次。 写出三次输出的 bound 与平衡点,并解释「同一个算子在两张表里有两种身份」这件事, 以及它对设计决策意味着什么。

E8代码阅读难度 ★★★无需硬件代码阅读, ,无需硬件 读

读 _vendor/tilelang-v0.1.15/src/ascend/transform/estimate_latency.cc:692-707。

  1. 写出 CubeOperations 的完整公式。
  2. fp16/bf16 输入时 k_group 是多少?fp32 在 HF32 模式下呢?普通 fp32 呢?
  3. 一个 M=256, N=200, K=128 的 bf16 tile,实际按多少 ops 计费? 与 2*M*N*K 相比多付了多少百分比?
  4. 这个对齐代价在 NVIDIA 侧的 mma 形状约束里有没有对应物?
E9动手难度 ★★无需硬件动手, ,无需硬件 运行

运行

python3 code/07-ascend-latency-model/ascend_pipe_model.py
  1. 从输出里读出方形 tile 的 Cube/MTE2 翻转点在哪两个边长之间。
  2. 用本章的闭式解 算出理论翻转点,与第 1 问对比。
  3. 为什么在 M=N=128 那一行,MTE2 的 II 恰好是 128 cycles 而不是 2·(128·16 + 16·128)/100 = 82?
  4. 官方昇腾 950 的 GEMM+ReLU 例子用的是什么 tile?代入模型后 binding 是哪条 pipe?
E10概念难度 ★★无需硬件概念, ,无需硬件 写出 fusion / split 的四个判据问题。然后用

写出 fusion / split 的四个判据问题。然后用 code/05-fusion-split/ 的输出说明:为什么 split-K 与 split-KV 这两个选择会 「搬运更多字节反而胜出」?它们的共同原因是什么?

E11动手难度 ★★★无需硬件动手, ,无需硬件 运行

运行

python3 code/06-case-norm-rope-quant/case_norm_rope_quant.py --rows-per-block 16

回答:

  1. 设计 B(16 行一 block)的总流量是多少?相对理想值是多少倍?
  2. 设计 C(40 行一 block)的总流量是多少?为什么它已经无法再改善查表部分?
  3. 如果把 q_heads 改成 64(--q-heads 64),设计 C 的 rows/blk 会变成多少? 这时它的片上占用会变成多少?还放得下 192 KB 的预算吗?
E12设计难度 ★★★需要加速器设计, ,需要硬件验证 本章 6.5 节选了设计 C。

本章 6.5 节选了设计 C(一个 token 一 block)。 现在假设产品需求变成:量化必须与 norm+RoPE 分开(因为量化要复用同一个 scale 做后续的 KV cache 写入)。请设计第三版:

  1. 写出它的预算表(payload / lookup / 总流量 / 片上占用 / grid);
  2. 说明它相比设计 C 多搬运了多少字节,这些字节是不是不可避免的;
  3. 给出「在有卡机器上如何验证你的判断」的步骤,包括要测量什么、判据是什么。
E13概念难度 ★★无需硬件概念, ,无需硬件 从本章第 7 节的反模式清单里挑六条,逐条写出它的

从本章第 7 节的反模式清单里挑六条,逐条写出它的 定量依据(数字或公式),并说明如果不遵守它,你会先看到什么现象。

E14动手难度 ★★无需硬件动手, ,无需硬件 用

用

python3 code/04-tile-size-decision/tile_decision.py --diagnose \
        --block-m 128 --block-n 256 --block-k 64 --stages 3 --threads 256

回答:

  1. 这份配置被哪一条约束卡住?为什么不是寄存器?
  2. 如果保持 block_M=128, block_N=256 不变,把 stages 降到 2, blocks_per_SM 会变成多少?为什么?
  3. 如果改用 --instr mma,这份配置还合法吗?为什么?
  4. 综合第 1–3 问,给出这份配置的下一步动作(只写一个动作,并说明判据)。

章节自测

8 题 · 提交后本地评分

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

  1. 1. 在本章的设计流程里,「写代码」是第几步?

  2. 2. roofline 给出的 SOL time 是什么性质的东西?

  3. 3. 用张量核 roof 给一个逐元素(norm / RoPE / quantize)算子定价,错在哪里?

  4. 4. 关于 tile 尺寸与流量的关系,下列哪一项正确?

  5. 5. fusion / split 的四个问题里,哪一条解释了「瘦长 GEMM 反而要用 split-K」?

  6. 6. 关于 tile 尺寸的四条约束,下列哪一项正确?

  7. 7. 案例(QK-Norm + RoPE + fp8 量化)的第一版「一行一 block」为什么被否掉?

  8. 8. 把一个融合算子交给 tilelang.tools.Analyzer 去估时间,在 sm_90 / sm_100 上会发生什么?

本章代码

15 个文件

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

浏览本章全部代码 →

本章引用

源码链接指向锁定 tag

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

展开引用清单

第 28 章 参考资料

本章所有结论都基于下表的本地资料或锁定版本源码。版本以 版本锁定 为准:TileLang v0.1.15 (commit a35f8ddf45eba16c21211ec8822d56ce5363036f)、TileLang-Ascend TileLang-ascend-v0.1.2.000-release (commit 89a9364e47a341fffdd65ed94786b1d4f9b6a78c)。 文中不使用任何「版本模糊」的表述(每一次能力判断都指向具体 tag / commit); 若涉及 main 上更新的东西,会显式标注「仅 main / 未进 v0.1.15」。

写作环境:macOS (Apple Silicon)、Python 3.11;没有 NVIDIA GPU,也没有昇腾 NPU。 因此本章的所有数字都只有三类来源:

  • 模型输出——code/ 下七个纯标准库脚本,可在任意机器复算;
  • 厂商公开资料——NVIDIA / 华为昇腾官方文档,标注为「厂商公开规格,非实测」;
  • 锁定版本源码里的常量——_vendor/ 下逐行核对过的常量表与公式。

没有任何一条性能数字来自本机实测。 「已核验」的含义是:在锁定版本的工作树上 用 rg/grep -n 定位到定义行,并阅读了完整签名、注释或实现; 这不是运行验证,也不代表在硬件上验证过。


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

1.1 carver / roller:tile cost model

id 引用位置 用途 核验方式与状态
S1 _vendor/tilelang-v0.1.15/tilelang/carver/README.md carver 的自我定位、支持的模板清单(Matmul / GeneralReduction / Elementwise / GEMV / FlashAttention / Conv)、recommend_hints 用法、CUDA 设备模型的示例片段 已核验(2026-10-01):逐行读;未运行
S2 .../tilelang/carver/arch/arch_base.py:6-27 TileDevice 的能力字段:reg_cap / smem_cap / compute_max_core / warp_size / sm_partition / transaction_size / max_smem_usage / bandwidth 已核验(2026-10-01):逐行读
S3 .../tilelang/carver/arch/cuda.py:139-167 CUDA 设备模型:smem_cap 与 compute_max_core 运行时查询(:153-155)、reg_cap=65536(:157)、max_smem_usage=2*smem_cap(:158)、sm_partition=4(:159)、transaction_size=[32,128](:162)、bandwidth=[750,12080] 及其「只用于推荐 basic tile size、只有跨设备比例有意义」的注释(:163-167) 已核验(2026-10-01):逐行读
S4 .../tilelang/carver/arch/cuda.py:144-146 CUDA.__init__ 调用 tvm.runtime.cuda(0) 并在无设备时抛 RuntimeError("Cannot find cuda device 0.") —— 这是本章「无卡机器上不能用 carver」结论的直接依据 已核验(2026-10-01):逐行读
S5 .../tilelang/carver/arch/__init__.py:27-56 get_arch 与 auto_infer_current_arch:无 CUDA 且无 MPS 时退到 llvm(CPU)模型 已核验(2026-10-01):逐行读
S6 .../tilelang/carver/arch/cpu.py:12-19 CPU 设备模型只填 platform,没有 smem_cap / reg_cap 等字段 已核验(2026-10-01):逐行读
S7 .../tilelang/carver/roller/policy/default.py:398-432 _compute_memory_traffic:按传播后的区域与读/写事务粒度计算 traffic 已核验(2026-10-01):逐行读
S8 .../tilelang/carver/roller/policy/common.py:32-52 coalesced_factor 与 coalesced_tensor_shape 的完整实现 已核验(2026-10-01):逐行读,并在 code/03-carver-cost-model/ 中等价重写
S9 .../tilelang/carver/roller/policy/default.py:452-495 _compute_shared_memory_usage:best-fit 分配器把所有缓存张量的 footprint 拼成 smem_cost 已核验(2026-10-01):逐行读
S10 .../tilelang/carver/roller/bestfit.py:22-61 BestFit 分配器,32 字节对齐;code/03-carver-cost-model/ 按它等价重写 已核验(2026-10-01):逐行读
S11 .../tilelang/carver/roller/node.py:447-495 PrimFuncNode.footprint:每张要缓存的张量的字节数,32 字节向上取整 已核验(2026-10-01):逐行读
S12 .../tilelang/carver/roller/node.py:332-399 propagate / propagate_inputs / propagate_outputs:区域推导的入口(本章在 code/03-carver-cost-model/ 中把它对普通 GEMM 解析手写,并在 README 里写明这是假设) 已核验(2026-10-01):逐行读
S13 .../tilelang/carver/roller/policy/default.py:537-574 compute_tile_dict:reg_usage = 2 * max(prod(tile) * bits / 32)(:564)、smem/reg 超限即判 valid=False(:558-567)、block_per_SM 的三人 min(:568-572)、num_wave(:573) 已核验(2026-10-01):逐行读,公式在 code/03-carver-cost-model/ 中复算
S14 .../tilelang/carver/roller/policy/default.py:96-134 dfs_smem_tile 的候选枚举与最优先搜索;排序键 priority = (traffic + 1) * num_wave(:111-112);已访问 tile 上限 2000(:123) 已核验(2026-10-01):逐行读
S15 .../tilelang/carver/roller/policy/tensorcore.py:120-140, 320-335 tensor-core policy 的 _check_small_tile(阈值 32)与「smem 超静态容量就退到 shared.dyn」 已核验(2026-10-01):逐行读
S16 .../tilelang/carver/roller/hint.py:154-192 Hint 的字段:block / thread / warp / rstep / reduce_thread / vectorize / pipeline_stage / split_k_factor 已核验(2026-10-01):逐行读
S17 .../tilelang/carver/template/matmul.py:40-52 MatmulTemplate.get_hardware_aware_configs -> get_roller_hints_from_func(..., allow_gemv=True) 已核验(2026-10-01):逐行读
S18 .../tilelang/carver/utils.py:29-65 get_roller_hints_from_func:TensorCorePolicy 优先、失败或空则回退 DefaultPolicy 已核验(2026-10-01):逐行读
S19 .../tilelang/jit/adapter/wrapper.py:403(nvrtc/wrapper.py:464 同) carver 的 arch.driver.get_persisting_l2_cache_max_size 被 JIT wrapper 用于设置 L2 persistence —— carver 不只在离线使用 已核验(2026-10-01):grep -rn "carver" --include="*.py" tilelang/ | grep -v tilelang/carver/ 命中三处
S20 .../tilelang/carver/arch/driver/cuda_driver.py:22,39,106 get_cuda_device_properties / get_shared_memory_per_block / get_persisting_l2_cache_max_size —— 设备参数来自这里,因此必须有可用的 CUDA 运行时 已核验(2026-10-01):ls carver/arch/driver/ 与逐函数阅读

1.2 tilelang.tools.Analyzer

id 引用位置 用途 核验方式与状态
S21 .../tilelang/tools/Analyzer.py:10 ARCH_CONFIGS = {"80": ..., "86": ..., "89": ...}:算力表只有三个 compute capability 已核验(2026-10-01):逐行读
S22 .../tilelang/tools/Analyzer.py:58-88 _analyze_copy:只统计跨 global 边界的 tl.copy;按循环与 blockIdx.x/y 的数量放大 已核验(2026-10-01):逐行读
S23 .../tilelang/tools/Analyzer.py:90-106, 148-151 _analyze_gemm:只统计 tl.gemm / tl.tileop.gemm;FLOPs = 2*M*N*K 已核验(2026-10-01):逐行读
S24 .../tilelang/tools/Analyzer.py:172-213 calculate:get_peak_tflops(:187-195,查不到返回 None)、bandwidth_GBps = device.bandwidth[1] / 1000(:198)、estimated_time 在无算力表时退化为访存时间(:201-204) 已核验(2026-10-01):逐行读;未运行
S25 .../docs/tools/analyzer.md:93-112 官方自述的限制:只识别 T.gemm 与跨 global 的 T.copy、blockIdx.z 不计、算力表只有 8.0/8.6/8.9、expected_tflops 与 estimated_time 只作比较用 已核验(2026-10-01):逐行读
S26 .../docs/tools/index.md:11,23 工具定位:「Estimate compute and memory cost from TIR」、「Use Analyzer before benchmarking when you need a rough roofline-style estimate」 已核验(2026-10-01):逐行读
S27 .../examples/analyze/example_gemm_analyze.py、资料库索引 官方示例:构造 CUDA("cuda") 需要可见 GPU,即使分析器本身不执行 kernel 已核验(2026-10-01):逐行读;未运行

1.3 昇腾侧的延迟模型与调度

id 引用位置 用途 核验方式与状态
S28 .../src/ascend/transform/estimate_latency.h:27-158 AscendLatencyParams 常量表:Cube 基础延迟 8 与各 dtype 吞吐(:32-55)、VALU 各操作吞吐 51/13/8(:57-68)、ND2NZ 参数(:73-85)、MTE1/Fixpipe 参数(:88-101)、各物理路径带宽(:104-113)、AIV MTE 参数(:115-125)、N-strided 与 dual-destination 参数(:127-157) 已核验(2026-10-01):逐行读;分量在 code/07-ascend-latency-model/ 中复算
S29 .../src/ascend/transform/estimate_latency.cc:326-340 模型结构的官方注释:四步(memory latency / compute latency / II by pipe / total = memory + compute) 已核验(2026-10-01):逐行读
S30 .../src/ascend/transform/estimate_latency.cc:692-707 CubeOperations:2 * align(M,16) * align(N,16) * align(K, k_group),k_group = 256/bits(fp32 在 HF32 下为 8、否则 1);MX 路径的最小 M/N 为 48 已核验(2026-10-01):逐行读
S31 .../src/ascend/transform/estimate_latency.cc:910-965 Cube 的 latency 与 II;total_latency = memory_latency + compute_latency(:941);按 pipe mask 合成 II(:945-965) 已核验(2026-10-01):逐行读
S32 .../src/ascend/transform/estimate_latency.cc:750-870 逐物理路径的 memory latency 与 II;PathIICost 与 add_memory_access 已核验(2026-10-01):阅读结构,未逐项复算
S33 .../tilelang/ascend/transform/z3_scheduler.py:67-140 z3_schedule_python 的输入(latencies / iis / resource_flags / data_deps / resource_deps / owner_exclusion_deps / pipe_order_deps)与输出(start_times) 已核验(2026-10-01):逐行读
S34 .../tilelang/ascend/pipeline.py:103-116 昇腾自动调度 pass 链:EstimateLatency → AutoSchedule → AssignCore → PrepareMultiBuffer → ResolveCore → InsertSync → MaterializeMultiBuffer → LowerScheduledTIR 已核验(2026-10-01):逐行读
S35 资料库索引 官方 950 GEMM+ReLU 例子:block_M=256, block_N=224, block_K=128(:74)、num_blocks=32(:81)、num_stages=2(:90)、T.dual_copy 与 T.SimtVF(threads=128) 的 epilogue(:92-98)、与 Ascend C 的分工表(:132-152)、tilelang/ascend/language 的方言入口 已核验(2026-10-01):逐行读;未在 NPU 上运行
S36 .../tilelang/language/gemm_op.py:160-195 T.gemm 签名与 docstring(同步语义;Hopper 隐式 wgmma wait、Blackwell 隐式 mbarrier_wait_parity;各后端方言扩展 mbar / k_pack / unit_flag_ctrl) 已核验(2026-10-01):逐行读

二、corpus 资料引用(资料库索引)

抓取日期:2026-09-30(Asia/Shanghai),见 corpus 的 00-methodology.md。 corpus 自己定义了三级可信度(primary / vendor / community), 本章的数字只取前三类来源中的 primary 与 vendor。

2.1 性能模型与方法规范

id 本地路径 上游链接 用途 核验状态
R1 TileOPs/docs/design/roofline.md https://github.com/tile-ai/TileOPs 本章 2.2–2.3 节的全部判据来源:记数与 FLOP 约定(:43-61)、compute roof 必须由作者声明(:63-70)、不可诚实定价的行的三种处理与 latency-bound 标签(:37-41)、SFU 不保证到顶(:34)、判读分档 80% / 105% 与 physics check(:156-163)、profile 的 effective = theoretical × calibration 与包络语义(§5.1,:257-261) 已核验(2026-10-01):本地为全文,逐节读
R2 TileOPs/src/tileops/perf/profiles/h200.yaml https://github.com/tile-ai/TileOPs 本章所有 H200 数值:HBM theoretical 4800e9 / calibration 0.9380 与四种访问混合(:15-18)、cuda_core.fp32 67.0e12 × 0.8548(:20-26)、tensor_core.fp16 989.5e12 × 0.7370(含 sustained 0.6684 / burst 0.7370 与 700 W 上限的说明,:31-34)、bf16(:35-38)、tf32(:39-42)、fp8(:48-50) 已核验(2026-10-01):本地为全文,逐行读
R3 TileOPs/src/tileops/perf/profiles/h20_3e.yaml https://github.com/tile-ai/TileOPs 反例:H20-3e 的 profile 没有 cuda_core 段,因此「用哪个 roof」这件事在真实 profile 里确实会缺项 已核验(2026-10-01):本地为全文,逐行读
R4 TileSight: A First-Principles Tile-Centric Analytic… https://arxiv.org/abs/2607.22432 TileSight 的摘要全文(12 行):三层结构(intra-tile / inter-tile / cross-device)、12.35% pooled MAPE、L2 命中率约 1 个百分点、32 卡 16.18% wMAPE、vLLM 端到端 13.52% wMAPE、「coarse roofline bounds」的问题表述 已核验(2026-10-01):本地为摘要;全文缺失,见「待补资料」
R5 tile-ai/TileSight https://github.com/tile-ai/TileSight TileSight 仓库 README 的转录(73 行):MIT 许可、CPU 上建模 + CUDA/HIP 探针测量、tilesight/arch 与 tilesight/modeling 的目录分工、sight.analyze(...) 与 result.launches["main"].kernel_body_s 的示例 已核验(2026-10-01):本地为 README 快照;源码未纳入 corpus
R6 TileOPs/.claude/domain-rules/ops-design.md、docs/design/ops-design.md https://github.com/tile-ai/TileOPs 工程化落地:算子库如何把 roofline、kernel 选择与调参参数写成规范(本章只作为「方法论的工业化形态」提及,不展开) 已核验(2026-10-01):本地为全文,读小标题与规则条目

2.2 NVIDIA 硬件与指令形状

id 本地路径 上游链接 用途 核验状态
R7 Hopper Tuning Guide https://docs.nvidia.com/cuda/hopper-tuning-guide/index.html cc9.0 每 SM shared 228 KB(:50)、每 block 上限 227 KB(:51)、carveout 档位列表(:226)、CUDA 保留 1 KB 与静态 48 KB 限制(:228) 已核验(2026-10-01):本地为全文;厂商文档
R8 CUDA C++ Best Practices Guide 第 3126 行 https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html cc7.0:每 SM 65536 个 32 位寄存器、最多 2048 个驻留线程、100% occupancy 时每线程 32 个寄存器、寄存器按每 warp 256 个向上取整 已核验(2026-10-01):本地为全文;厂商文档
R9 Parallel Thread Execution ISA https://docs.nvidia.com/cuda/parallel-thread-execution/index.html mma 密集 f16 形状 m16n8k8 / m16n8k16(:23476-23477);wgmma 的 warpgroup = 4 个连续 warp(:28113-28114);wgmma 密集 f16 形状 m64nNk16、N 从 8 到 256(:28132) 已核验(2026-10-01):本地为全文;厂商文档
R10 NVIDIA Hopper Architecture In-Depth https://developer.nvidia.com/blog/nvidia-hopper-architecture-in-depth/ H100 SXM5 的 datasheet 表:FP32 60 TFLOPS、FP16 张量核 1000 TFLOPS、FP8 2000 TFLOPS(:135-143,Table 1 自述 preliminary);HBM3「over 3 TB/sec」(:334) 已核验(2026-10-01):本地为全文;厂商公开 preliminary,非实测

2.3 昇腾硬件、对齐与容量

id 本地路径 上游链接 用途 核验状态
R11 hiascend.com · canncommercial_900_programug_Ascendcopdevg_atlas_ascendc_10_0011_html 第 39–51 行 https://www.hiascend.com/document/detail/zh/canncommercial/900/programug/Ascendcopdevg/atlas_ascendc_10_0011.html 对齐表:AIV Unified Buffer 32 Byte、AIC L1 Buffer 32 Byte、L0A/L0B 512 Byte、L0C 64 Byte、BiasTable 64 Byte、Fixpipe 64 Byte;以及 L0A/L0B/L0C 的分形格式建议(FRACTAL_ZZ / FRACTAL_ZN / FRACTAL_NZ) 已核验(2026-10-01):本地为厂商文档全文,逐行读;非实测
R12 hiascend.com · canncommercial_900_programug_Ascendcopdevg_atlas_ascendc_10_0008_html https://www.hiascend.com/document/detail/zh/canncommercial/900/programug/Ascendcopdevg/atlas_ascendc_10_0008.html AI Core 的计算/存储/搬运单元、Cube 16×16、Vector=SIMD、GetCoreMemSize 运行时查询各存储单元容量、MTE1/2/3 与 Fixpipe 的通路、SetFlag/WaitFlag 语义 已核验(2026-10-01):厂商文档全文;非实测
R13 hiascend.com · canncommercial_900_programug_Ascendcopdevg_atlas_ascendc_best_practices_10_00020_html 第 18 行 https://www.hiascend.com/document/detail/zh/canncommercial/900/programug/Ascendcopdevg/atlas_ascendc_best_practices_10_00020.html 「Unified Buffer 总大小为 192K,划分为 48 个 bank,每个 bank 由 128 行组成,每行长度为 32B」。这是该文档举例的某一型号,不是「昇腾 UB 都是 192K」 已核验(2026-10-01):厂商文档全文;条件性数字
R14 hiascend.com · canncommercial_900_API_ascendcopapi_atlasascendc_api_07_0666_html https://www.hiascend.com/document/detail/zh/canncommercial/900/API/ascendcopapi/atlasascendc_api_07_0666.html 「该示例 L1 Buffer 可用大小为 512KB」——同样是示例条件,不是通用规格 已核验(2026-10-01):厂商文档全文;条件性数字

三、本章代码示例与它们对应的引用

示例 用到的引用
code/01-op-ledger/op_ledger.py 记账约定(R1:43-61)、机器参数(R2、R10)
code/02-roofline-misuse/roofline_misuse.py R1:34,40-41,63-70,156-163;R2;S28(VALU 吞吐);R4
code/03-carver-cost-model/carver_cost_model.py S2–S14
code/04-tile-size-decision/tile_decision.py R7(shared 容量)、R8(寄存器)、R9(指令形状与 warpgroup)
code/05-fusion-split/fusion_split.py 本书推导;判据由正文 5.1 节给出
code/06-case-norm-rope-quant/case_norm_rope_quant.py R2(带宽);算子结构参考本书第 13、27 章
code/07-ascend-latency-model/ascend_pipe_model.py S28、S30、S31、S35

四、待补资料

以下是本章明确缺料的地方。写者不在 corpus 内发明事实,只登记需求:

  1. TileSight 的论文全文与源码。(优先级:高)
    • 缺什么:arxiv-2607-22432 的正文与 PDF;github.com/tile-ai/TileSight 的源码树, 尤其是 tilesight/modeling/ 下的 cost / cache 模型与 tilesight/arch/ 下的 profile 定义。
    • 为什么需要:本章 3.1 节只能引用摘要里的三层结构自报误差, 无法给出任何一条公式。任务是「TileSight 这类 tile 级解析性能模型在做什么」, 目前只做到「它宣称做什么」。
    • 建议来源:arXiv PDF(https://arxiv.org/pdf/2607.22432)、 仓库 git clone --depth 1 https://github.com/tile-ai/TileSight。
    • 当前状态:本地只有 TileSight: A First-Principles Tile-Centric Analytic…(12 行摘要)与 tile-ai/TileSight(73 行 README 转录)。
  2. carver propagate 的确切语义。(优先级:中)
    • 缺什么:PrimFuncNode.propagate 对一个 reduction block 返回的区域, 是「一个 rstep 切片」还是「整条 reduction 维」。
    • 为什么需要:这决定了 _compute_memory_traffic 的 traffic 是教科书公式 (每输出 tile 读 A 的整条 K)还是它的 1/(K/block_K) 倍。
    • 怎么补:在一台有 NVIDIA GPU 的机器上实际跑一次 MatmulTemplate(...).with_arch(CUDA("nvidia/...")).recommend_hints(topk=5), 打印 hint 并对手算的 traffic 做交叉验证。
    • 当前状态:code/03-carver-cost-model/ 里把它写成显式假设(并在 README 的 「known limitations」里登记),因为另一种读法无法复现教科书公式。
  3. NVIDIA 侧的 SFU / MUFU 吞吐数字。(优先级:中)
    • 缺什么:CUDA core 与 SFU 的 ops/cycle 比值(或等价的公开吞吐表)。
    • 为什么需要:本章 2.3 节 M5 只能给出昇腾侧的 6.38 倍, NV 侧只能引用「不保证 SFU-bound 到顶」这条定性说明。
    • 建议来源:CUDA C Programming Guide 的算术指令吞吐表, 或 Nsight Compute 的 SM 吞吐 handbook。
  4. Ascend 950 的时钟频率。(优先级:低)
    • 缺什么:一条可引用的 A5 主频。
    • 为什么需要:有了它,estimate_latency.h 的 cycle 数才能换算成时间, 与 NV 侧 TFLOPS 口径的 roofline 对齐;缺它就只能做 cycle 级比较。
    • 当前状态:本章所有昇腾数字都保持 cycle 单位,不换算成秒。
  5. 昇腾 L0A/L0B/L0C 的容量。(优先级:低)
    • 缺什么:可跨型号引用的容量数字(官方要求用 GetCoreMemSize 运行时查询)。
    • 当前状态:本章沿用第 6 章 4.2 节的处理——不给数值,只给对齐约束与查询接口。

五、与其它章节的一致性说明

  • 本章第 4.2 节与图 4 引用的昇腾对齐表来自 R11 (..._atlas_ascendc_10_0011_html.md:39-51)。第 6 章 4.2 节在讲同一组约束时 引用的是 ..._atlas_ascendc_10_0008_html.md,并且把 Fixpipe Buffer 的对齐写成 「128 Byte」。R11 的表里写的是 Fixpipe Buffer 64 Byte。 本章按 R11 的原文 取值(64 Byte),并在这里登记这处差异,供终审核对;两处不影响本章的任何结论。
  • 本章第 2.2 节使用的 H200 机器参数与第 2 章 2.2 节同源(同一份 h200.yaml), 因此两章关于 LayerNorm / 元素链的访存下界可以互相复算: code/01-op-ledger --sweep 给出的 rmsnorm 8192×4096 = 29.810 µs、 元素链融合 = 14.905 µs,与第 2 章手算的 29.8 µs / 14.9 µs 一致。
  • 本章 3.2 节与第 11 章 3.9 节都讲 carver。第 11 章给的是「它有 cost model、 cost model 只排序不定案」的定位;本章把它拆到公式与行号一级,并补上了 「无卡机器上跑不起来」这一条操作性结论。两章的结论不冲突。

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