第 7 章 用 Pallas 写自定义内核

JAX 学习笔记 · 进阶补充章节:用 Pallas 手写自定义内核。前置:入门课 00–07、进阶课 00–06。 运行环境:Python 3.14 · JAX / jaxlib 0.12.0.1 · 仅 CPU。

本机上的所有 kernel 都在 interpret 模式下实际跑过,并用 assert 和 NumPy float64 参考实现对拍。GPU/TPU 部分只做讲解,本机跑不了的代码都标了「未在本机执行」。

⚠️ jax.experimental.pallas 在 experimental 命名空间下,不保证跨版本稳定。本章用到的每个 API 都先在 0.12.0.1 上用探针确认过。升级 JAX 之后,先重跑一遍 §0 的探针。

学习目标

  1. 说清楚 XLA 自动融合的边界在哪里,Pallas 在 JAX 栈里处于什么位置,以及它和 Triton、Mosaic(TPU)、Mosaic GPU 的关系。
  2. 掌握 Pallas 编程模型:pl.pallas_call、Ref 读写(x_ref[...])、out_shape、grid、BlockSpec(block_shape, index_map)、pl.program_id / pl.num_programs。
  3. 在只有 CPU 的机器上用 interpret 模式运行和调试 kernel,知道 interpret=True 与 pltpu.InterpretParams() 的区别,也知道它们只能验证正确性、没有性能意义。
  4. 能写出常见的进阶 kernel:沿 K 维累加的分块 matmul(@pl.when 初始化)、数值稳定的按行 softmax / LayerNorm、形状不能整除时的掩码处理、跨 grid 的归约,以及在线 logsumexp(FlashAttention 的核心技巧)。
  5. 实测 pallas_call 与 jit、vmap、grad 的组合方式:前两者直接可用,grad 需要用 custom_vjp 包一层,再用 Pallas 写反向 kernel,最后用 check_grads 验证。
  6. 了解 GPU/TPU 上的硬性约束(TPU 块形状 8×128 对齐)、compiler_params 和调优思路(块大小、算术强度、HBM→VMEM/SMEM 的内存层级)。

目录

节内容本机可运行
§0探针:0.12 的导入路径与 interpret 用法✅
§1为什么需要 Pallas:融合的边界与 JAX 栈中的位置✅
§2编程模型:Ref、out_shape、grid、BlockSpec、program_id✅
§3interpret 模式详解(CPU)✅
§4进阶 kernel:累加 matmul、softmax/LayerNorm、掩码、归约、在线 logsumexp✅
§5与 jit / vmap / grad 的组合✅
§6GPU/TPU 注意事项(块大小、compiler_params、调优)⚠️ 只做 lowering 与算术,kernel 未在本机执行
§7实战规则清单—

之后依次是:动手练习(4 道)、测验(6 道)、本章小结、速查表。

与已学章节的关系

⚠️ 环境注意:jaxlib 0.12.0.1 的 CPU 后端有一个已知 bug:jit 闭包捕获的常量矩阵做 W @ x 再求和时会悄悄算错(现象与规避见 00 目录与导读「本环境已知问题」,根因见 进阶/02 §2.3)。所以本章的权重和数据都作为函数参数传入(Pallas 本身也不允许 kernel 捕获数组常量,见 §2.1),关键数值都用 NumPy float64 对拍。

# 公共准备:导入、版本检查、对拍与计时工具
import sys, time, re, logging, functools, inspect, contextlib
import numpy as np
import jax
import jax.numpy as jnp
from jax import lax

print("Python", sys.version.split()[0], "| JAX", jax.__version__, "| 后端", jax.default_backend(), jax.devices())
assert jax.__version__.startswith("0.12")
assert jax.default_backend() == "cpu", "本章在 CPU 上用 interpret 模式运行(在 Colab 上请使用 CPU 运行时)"

def f64(a):
    """转成 NumPy float64,用来做高精度参考实现"""
    return np.asarray(a, dtype=np.float64)

def max_err(a, ref):
    """最大绝对误差(都转成 float64 后再比较)"""
    return float(np.max(np.abs(f64(a) - f64(ref))))

def bench(f, *args, n=5):
    """平均耗时(毫秒):先预热一次,再循环 n 次。计时结论只打印,不做 assert"""
    jax.block_until_ready(f(*args))
    t0 = time.perf_counter()
    for _ in range(n):
        out = f(*args)
    jax.block_until_ready(out)
    return (time.perf_counter() - t0) / n * 1e3

@contextlib.contextmanager
def quiet_callback_logs():
    """TPU 模拟器内部用 io_callback 报错时,会先往日志里打一整段 traceback;演示 ❌ 写法时把它静音"""
    lg = logging.getLogger("jax._src.callback")
    old = lg.disabled
    lg.disabled = True
    try:
        yield
    finally:
        lg.disabled = old

rng = np.random.default_rng(0)   # 只用于少量 NumPy 侧数据;JAX 随机数一律用 jax.random.key
Python 3.14.7 | JAX 0.12.0.1 | 后端 cpu [CpuDevice(id=0)]

§0 探针:确认 0.12 的 Pallas API

先别凭记忆写代码,逐项确认一下:

  1. 导入路径:jax.experimental.pallas(惯用别名 pl),以及各后端子模块 tpu、triton、mosaic_gpu;
  2. pallas_call 的完整签名,尤其是 interpret 参数接受什么;
  3. 一些旧教程里常见的名字(pl.load、pl.store、pltpu.TPUCompilerParams)在 0.12 中是否还存在。
# 探针 1:导入路径与关键名字
from jax.experimental import pallas as pl
from jax.experimental.pallas import tpu as pltpu            # TPU(Mosaic)专用:VMEM、CompilerParams、InterpretParams……
from jax.experimental.pallas import triton as pltriton      # GPU · Triton 后端
from jax.experimental.pallas import mosaic_gpu as plgpu     # GPU · Mosaic GPU 后端

must_have = ["pallas_call", "BlockSpec", "GridSpec", "program_id", "num_programs", "when",
             "cdiv", "ds", "CompilerParams", "CostEstimate", "debug_print", "Squeezed"]
for name in must_have:
    assert hasattr(pl, name), name
assert hasattr(pltpu, "InterpretParams") and hasattr(pltpu, "CompilerParams") and hasattr(pltpu, "VMEM")
assert hasattr(plgpu, "InterpretGPUParams") and hasattr(pltriton, "CompilerParams")

# 旧教程里的名字:0.12 中已不存在
gone = {"pl.load": hasattr(pl, "load"), "pl.store": hasattr(pl, "store"),
        "pl.InterpretParams": hasattr(pl, "InterpretParams"),
        "pltpu.TPUCompilerParams": hasattr(pltpu, "TPUCompilerParams")}
print("已不存在的名字:", gone)
assert not any(gone.values())

sig = inspect.signature(pl.pallas_call)
print("pallas_call 参数:", list(sig.parameters))
print("interpret 默认值:", sig.parameters["interpret"].default)
assert sig.parameters["interpret"].default is False
assert {"out_shape", "grid", "in_specs", "out_specs", "scratch_shapes", "compiler_params",
        "input_output_aliases", "cost_estimate", "interpret", "name"} <= set(sig.parameters)
已不存在的名字: {'pl.load': False, 'pl.store': False, 'pl.InterpretParams': False, 'pltpu.TPUCompilerParams': False}
pallas_call 参数: ['kernel', 'out_shape', 'grid_spec', 'grid', 'in_specs', 'out_specs', 'scratch_shapes', 'input_output_aliases', 'debug', 'interpret', 'name', 'compiler_params', 'cost_estimate', 'metadata']
interpret 默认值: False
# 探针 2:interpret 参数到底接受什么?三种写法都在 CPU 上实测
def add_kernel(x_ref, y_ref, o_ref):
    o_ref[...] = x_ref[...] + y_ref[...]

x = jnp.arange(8, dtype=jnp.float32)
y = jnp.full((8,), 10.0, jnp.float32)
sds = jax.ShapeDtypeStruct(x.shape, x.dtype)

results = {}
for label, interp in [("interpret=True", True),
                      ("interpret=pltpu.InterpretParams()", pltpu.InterpretParams()),
                      ("interpret=plgpu.InterpretGPUParams()", plgpu.InterpretGPUParams())]:
    out = pl.pallas_call(add_kernel, out_shape=sds, interpret=interp)(x, y)
    results[label] = np.asarray(out)
    print(f"{label:40s} -> {results[label]}")
    np.testing.assert_array_equal(results[label], f64(x) + f64(y))
interpret=True                           -> [10. 11. 12. 13. 14. 15. 16. 17.]
interpret=pltpu.InterpretParams()        -> [10. 11. 12. 13. 14. 15. 16. 17.]
interpret=plgpu.InterpretGPUParams()     -> [10. 11. 12. 13. 14. 15. 16. 17.]

跑下来可以确认:在 0.12.0.1 里,interpret 参数有三种取值,都能在 CPU 上运行:

写法含义
interpret=True通用解释器:把整个 pallas_call 变成「对 grid 做 scan 的 jax.jit 程序」,kernel 体被当作普通 JAX 函数执行。本章主要用它。
interpret=pltpu.InterpretParams(...)TPU 模拟器:在 CPU 上模拟 TPU 的 HBM/VMEM、DMA、信号量,还能检测数据竞争、输出块的非连续重访(§4.4)等 TPU 才有的错误。注意它在 pltpu 子模块里,不是 pl.InterpretParams。
interpret=plgpu.InterpretGPUParams(...)Mosaic GPU 的模拟器,用法相同。

interpret 的默认值是 False。下面的 ❌ 单元演示:在 CPU 上不加 interpret 会直接报错。

# ❌ 错误写法:在 CPU 后端上不开 interpret —— CPU 没有 Pallas 的编译后端
try:
    pl.pallas_call(add_kernel, out_shape=sds)(x, y)
    err = None
except Exception as e:
    err = e
    print(type(e).__name__, ":", e)
assert isinstance(err, ValueError) and "Only interpret mode is supported on CPU" in str(err)
ValueError : Only interpret mode is supported on CPU backend.
# ✅ 正确写法:本机统一用一个全局开关;换到 GPU/TPU 上时只需改成 False
INTERPRET = True
out = pl.pallas_call(add_kernel, out_shape=sds, interpret=INTERPRET)(x, y)
np.testing.assert_array_equal(out, f64(x) + f64(y))
print(out)
[10. 11. 12. 13. 14. 15. 16. 17.]
# ❌ 错误写法:照旧教程用 pl.load / pl.store 读写 Ref —— 0.12 中这两个函数已从 pl 命名空间移除
def add_kernel_old(x_ref, y_ref, o_ref):
    pl.store(o_ref, ..., pl.load(x_ref, ...) + pl.load(y_ref, ...))
try:
    pl.pallas_call(add_kernel_old, out_shape=sds, interpret=INTERPRET)(x, y)
    err = None
except AttributeError as e:
    err = e
    print(type(e).__name__, ":", e)
assert isinstance(err, AttributeError)
AttributeError : module 'jax.experimental.pallas' has no attribute 'store'
# ✅ 正确写法:直接对 Ref 做 NumPy 风格的索引读写;要取一段动态切片就用 pl.ds(start, size)
def add_kernel_halves(x_ref, y_ref, o_ref):
    o_ref[pl.ds(0, 4)] = x_ref[pl.ds(0, 4)] + y_ref[pl.ds(0, 4)]   # 前半段
    o_ref[4:] = x_ref[4:] + y_ref[4:]                              # 后半段:静态切片也可以
out = pl.pallas_call(add_kernel_halves, out_shape=sds, interpret=INTERPRET)(x, y)
np.testing.assert_array_equal(out, f64(x) + f64(y))
print(out)
[10. 11. 12. 13. 14. 15. 16. 17.]

§1 为什么需要 Pallas

1.1 XLA 融合的边界

⚪ 选读:已学过进阶/02 §2.6 的融合边界可略读;想判断哪些场景值得手写内核时再看

进阶/02 §2.6 的结论可以概括成三句:

  1. 一串逐元素操作很容易被 XLA 合成一个内核,省掉中间结果的主存往返;
  2. 归约后接广播(softmax 的 max/sum)、optimization_barrier、某些控制流会形成内核边界;
  3. dot/conv 这类重算子通常交给库(cuBLAS、YNNPACK、TPU 的 MXU 内核)完成,XLA 只能在它们的输入或输出端附带一些逐元素操作。

问题在于,XLA 的融合是启发式的,用户不能指定「这块数据在片上存储里停留多久」「循环按什么顺序走」「哪个中间量不写回主存」。有几类计算恰好卡在这些边界上:

场景XLA 默认做法的问题手写内核能做什么
Attention:softmax(QKᵀ)VQKᵀ 是一个 L×L 矩阵,要完整写回 HBM 再读回来分块 + 在线 softmax(FlashAttention):L×L 矩阵从不落地(§4.5 用 logsumexp 演示核心技巧)
长行 softmax / LayerNorm归约与广播被切成多个内核一个块装下一行(或在线归约),只读一次、只写一次
稀疏、分组、ragged 计算(MoE、分组 matmul)只能 pad 成稠密形状,或者拆成多次调用用 index_map 和标量预取决定每个块读哪里
量化 matmul、自定义精度先反量化成大张量再乘在块内反量化,直接喂给矩阵单元

1.2 Pallas 是什么、处在什么位置

🟢 必读

Pallas 是 JAX 的内核语言:用 JAX 风格的 Python 描述「一个块怎么算」,再用 grid 和 BlockSpec 描述「整个数组怎么切块」。在 JAX 栈中它的位置如下:

用户代码(jnp / lax)
   │ 追踪(tracing)
   ▼
 jaxpr ──── 其中一个方程是 pallas_call 原语(里面嵌着 kernel 自己的 jaxpr)
   │ lowering
   ▼
StableHLO ── 普通操作交给 XLA 融合、调度;
   │         pallas_call 被单独交给后端内核编译器,结果以 custom_call 的形式嵌进 HLO:
   │           · TPU       → Mosaic(tpu_custom_call)
   │           · GPU       → Triton(pl.triton 子模块)或 Mosaic GPU(pl.mosaic_gpu 子模块,面向 Hopper 及以后的架构)
   │           · CPU       → 没有编译后端,只能 interpret(变成普通 HLO 的 while 循环)
   ▼
设备可执行代码
  • Triton:OpenAI 开源的 GPU 内核语言和编译器。Pallas 的 Triton 后端把 kernel lowering 成 Triton IR,由 Triton 编译器完成后面的工作,可移植性好。
  • Mosaic(TPU):Google 为 TPU 写的内核编译器,Pallas 是它在 JAX 里的主要前端。pltpu 子模块里的 VMEM、SMEM、DMA、信号量等都是 Mosaic 的概念。
  • Mosaic GPU:JAX 团队自研的 GPU 后端,直接暴露 TMA、warpgroup 矩阵指令(wgmma)等 Hopper/Blackwell 特性,上限更高,写法也更底层。当前官方 GPU 文档主要以它为例;本机(0.12.0.1)两个 GPU 子模块都能导入。

同一份 Pallas kernel 不一定能跨后端通用:grid + BlockSpec 这一层是公共的,但块大小约束、内存空间、同步原语都是后端特有的(见 §6)。

1.3 三条「自定义操作」路线的对照

⚪ 选读:需要在 jnp、Pallas、C++ FFI 之间做技术选型时

jnp + XLAPallas自定义原语 + C++ FFI(进阶/05)
用什么写NumPy 风格JAX 风格 Python + RefC++(或 CUDA)
谁来编译XLAMosaic / Triton / Mosaic GPU你自己的编译器
控制粒度交给编译器块、循环顺序、片上存储完全自己控制
CPU 上运行✅仅 interpret(只验证正确性)✅(5.10 节)
jit / vmap✅✅(§5 实测)需要自己写 batching 规则或 vmap_method
grad✅❌,需要 custom_vjp❌,需要 custom_vjp 或 JVP/转置规则

下面用代码确认这张图:pallas_call 在 jaxpr 里是一个原语;interpret 模式下它在 HLO 里只是普通的 while 循环;同一个 kernel 也能在 CPU 上只做 lowering,交叉生成 TPU 代码(得到 tpu_custom_call,但不执行)。

# 1) jaxpr 里的 pallas_call 原语
add_call = pl.pallas_call(add_kernel, out_shape=sds, interpret=INTERPRET)
cj = jax.make_jaxpr(add_call)(x, y)
prims = [str(e.primitive) for e in cj.jaxpr.eqns]
print("外层 jaxpr 的原语:", prims)
inner = cj.jaxpr.eqns[0].params["jaxpr"]          # pallas_call 里嵌着 kernel 自己的 jaxpr
print("kernel 的 jaxpr:\n", inner)
assert prims == ["pallas_call"]
assert all("Ref" in str(v.aval) for v in inner.invars)   # kernel 的参数全是 Ref

# 2) interpret 模式:编译后的 HLO 里只有普通操作和 while 循环,没有 custom-call
hlo_interp = jax.jit(add_call).lower(x, y).compile().as_text()
print("interpret 模式 HLO 中 custom-call 的数量:", hlo_interp.count("custom-call"))
assert "custom-call" not in hlo_interp

# 3) 交叉 lowering 到 TPU(不执行!只看生成的 StableHLO)
add_tpu = pl.pallas_call(add_kernel, out_shape=jax.ShapeDtypeStruct((8, 128), jnp.float32))  # 不开 interpret
tpu_text = jax.jit(add_tpu).trace(jax.ShapeDtypeStruct((8, 128), jnp.float32),
                                  jax.ShapeDtypeStruct((8, 128), jnp.float32)).lower(lowering_platforms=("tpu",)).as_text()
line = [l.strip() for l in tpu_text.splitlines() if "tpu_custom_call" in l][0]
print("TPU lowering 结果:", line[:110], "...")
assert "tpu_custom_call" in tpu_text
外层 jaxpr 的原语: ['pallas_call']
kernel 的 jaxpr:
 { lambda ; a:Ref<default>{f32[8]} b:Ref<default>{f32[8]} c:Ref<default>{f32[8]}. let
    d:f32[8] <- a[...]
    e:f32[8] <- b[...]
    f:f32[8] = add d e
    c[...] <- f
  in () }
interpret 模式 HLO 中 custom-call 的数量: 0
TPU lowering 结果: %0 = stablehlo.custom_call @tpu_custom_call(%arg0, %arg1) {backend_config = "{\22custom_call_config\22: {\22bo ...

🔍 补充

⚪ 选读:想了解 interpret 模式与交叉 lowering 的能力边界时

  • interpret 模式之所以「慢但可靠」,是因为它把 kernel 体原样当作 JAX 函数执行,grid 变成一个 while 循环(由 jax.jit 编译)。所以 CPU 上的 Pallas kernel 没有性能意义:只有一个块时耗时与 jnp 相当(§3.3 实测单块甚至略快,属于计时噪声),块一多就慢得多(§3.3 实测 64 块时慢两个数量级左右)。
  • 交叉 lowering(.trace(...).lower(lowering_platforms=("tpu",)))只走到 Mosaic 的前端检查和序列化为止,不会调用 TPU 编译器的后端。它能在本机发现块形状不合规这类错误(§6.1),但发现不了 VMEM 超限、性能问题。GPU 的交叉 lowering 在本机会报 No supported GPU devices found,需要抽象设备才能做,这里不展开。

§2 编程模型

2.1 kernel、Ref 与 out_shape

🟢 必读

pl.pallas_call(kernel, out_shape, ...) 返回一个普通的 JAX 可调用对象。kernel 的约定是:

  • 参数:先是每个输入的 Ref,然后是每个输出的 Ref(有 scratch 时再接 scratch Ref),顺序与 in_specs/out_specs 一致;
  • Ref 是一块可变的内存视图:x_ref[...] 读出整块,o_ref[...] = v 写入,o_ref[...] += v 读-改-写,也支持切片 x_ref[0, :]、x_ref[pl.ds(i, n)];
  • 返回值必须是 None:结果只能写进输出 Ref;
  • out_shape 用 jax.ShapeDtypeStruct(或它们组成的 pytree)声明输出的形状和 dtype,Pallas 据此分配输出缓冲区。

不写 grid 时,整个数组作为一个块交给 kernel 执行一次。

# 多输出:out_shape 是一个元组,kernel 就多接一个输出 Ref
def sum_diff_kernel(x_ref, y_ref, s_ref, d_ref):
    x, y = x_ref[...], y_ref[...]          # 先读出来,避免重复读
    s_ref[...] = x + y
    d_ref[...] = x - y

kx, ky = jax.random.split(jax.random.key(0))
a = jax.random.normal(kx, (4, 6))
b = jax.random.normal(ky, (4, 6))
s, d = pl.pallas_call(sum_diff_kernel,
                      out_shape=(jax.ShapeDtypeStruct(a.shape, a.dtype),
                                 jax.ShapeDtypeStruct(a.shape, a.dtype)),
                      interpret=INTERPRET)(a, b)
np.testing.assert_allclose(f64(s), f64(a) + f64(b), rtol=1e-6)
np.testing.assert_allclose(f64(d), f64(a) - f64(b), rtol=1e-6)
print("多输出 kernel 正确:", s.shape, d.shape)
多输出 kernel 正确: (4, 6) (4, 6)
# ❌ 错误写法:kernel 返回结果(像普通函数那样),而不是写进输出 Ref
def double_kernel_bad(x_ref, o_ref):
    return x_ref[...] * 2
try:
    pl.pallas_call(double_kernel_bad, out_shape=jax.ShapeDtypeStruct(a.shape, a.dtype), interpret=INTERPRET)(a)
    err = None
except ValueError as e:
    err = e
    print(type(e).__name__, ": … kernel 函数", re.search(r"should return None.*", str(e)).group(0))
assert err is not None and "should return None" in str(err)
ValueError : … kernel 函数 should return None. It returns a PyTree: PyTreeDef(*)
# ✅ 正确写法:写进 o_ref,函数本身不返回任何东西
def double_kernel(x_ref, o_ref):
    o_ref[...] = x_ref[...] * 2
out = pl.pallas_call(double_kernel, out_shape=jax.ShapeDtypeStruct(a.shape, a.dtype), interpret=INTERPRET)(a)
np.testing.assert_allclose(f64(out), 2 * f64(a), rtol=1e-7)
print("✅", out.shape)
✅ (4, 6)
# ❌ 错误写法:kernel 通过闭包捕获一个 JAX 数组(比如权重)
W_bias = jnp.arange(6, dtype=jnp.float32)
def bias_kernel_bad(x_ref, o_ref):
    o_ref[...] = x_ref[...] + W_bias          # W_bias 是外部的数组常量
try:
    pl.pallas_call(bias_kernel_bad, out_shape=jax.ShapeDtypeStruct(a.shape, a.dtype), interpret=INTERPRET)(a)
    err = None
except ValueError as e:
    err = e
    print(type(e).__name__, ": … kernel 函数", re.search(r"captures constants.*", str(e)).group(0))
assert err is not None and "captures constants" in str(err)
ValueError : … kernel 函数 captures constants [f32[6]]. You should pass them as inputs.
# ✅ 正确写法:数组一律作为输入传进来(Python 标量可以闭包捕获,它会被当成编译期常数)
scale = 0.5                                   # Python float:允许
def bias_kernel(x_ref, w_ref, o_ref):
    o_ref[...] = x_ref[...] * scale + w_ref[...]
out = pl.pallas_call(bias_kernel, out_shape=jax.ShapeDtypeStruct(a.shape, a.dtype), interpret=INTERPRET)(a, W_bias)
np.testing.assert_allclose(f64(out), f64(a) * 0.5 + f64(W_bias), rtol=1e-6)
print("✅ 权重作为参数传入,结果正确")
✅ 权重作为参数传入,结果正确

这条限制和本环境的 CPU bug 规避规则正好一致:权重和数据一律走参数。

2.2 grid、BlockSpec 与 pl.program_id

🟢 必读

真实的数组往往放不进片上存储,要切成块。Pallas 用两样东西描述切法:

  • grid=(g0, g1, ...):kernel 要执行的次数(一个多维循环)。每次执行称为一个 program,pl.program_id(axis) 取得它在第 axis 维的下标,pl.num_programs(axis) 取得这一维的总数;
  • BlockSpec(block_shape, index_map):对每个输入/输出说明块的形状,以及第 (i, j, ...) 个 program 取第几个块。index_map 接收 grid 下标,返回块下标(不是元素偏移),实际偏移是 块下标 × block_shape。

形状为 (256, 512) 的数组用 (64, 128) 的块切开,grid=(4, 4),第 (i, j) 个 program 处理元素 [64i:64i+64, 128j:128j+128]:

def add2d(x, y, bm=64, bn=128):
    """二维逐元素加法:每个 program 处理一个 (bm, bn) 块"""
    M, N = x.shape
    spec = pl.BlockSpec((bm, bn), lambda i, j: (i, j))     # 第 (i, j) 个 program 取第 (i, j) 个块
    return pl.pallas_call(add_kernel,                        # kernel 与 §0 相同:它只关心“一个块”
                          out_shape=jax.ShapeDtypeStruct(x.shape, x.dtype),
                          grid=(M // bm, N // bn),
                          in_specs=[spec, spec], out_specs=spec,
                          interpret=INTERPRET)(x, y)

kx, ky = jax.random.split(jax.random.key(1))
X = jax.random.normal(kx, (256, 512))
Y = jax.random.normal(ky, (256, 512))
Z = add2d(X, Y)
np.testing.assert_allclose(f64(Z), f64(X) + f64(Y), rtol=1e-6)
print("grid=(4, 4) 的分块加法正确,输出形状", Z.shape)
grid=(4, 4) 的分块加法正确,输出形状 (256, 512)
# 让每个 program 把自己的 (program_id(0), program_id(1)) 写进它负责的块,直观看到 grid → 块的映射
def pid_kernel(o_ref):
    i, j = pl.program_id(0), pl.program_id(1)
    o_ref[...] = jnp.full(o_ref.shape, 10 * i + j, o_ref.dtype)   # 十位是 i,个位是 j

pid_map = pl.pallas_call(pid_kernel, out_shape=jax.ShapeDtypeStruct((4, 6), jnp.int32),
                         grid=(2, 3), out_specs=pl.BlockSpec((2, 2), lambda i, j: (i, j)),
                         interpret=INTERPRET)()
print(pid_map)
expected = np.kron(np.array([[0, 1, 2], [10, 11, 12]]), np.ones((2, 2), int))
np.testing.assert_array_equal(pid_map, expected)
[[ 0  0  1  1  2  2]
 [ 0  0  1  1  2  2]
 [10 10 11 11 12 12]
 [10 10 11 11 12 12]]
# ❌ 错误写法:index_map 返回“元素偏移”而不是“块下标”—— 不报错,静默算错
xs = jnp.arange(64, dtype=jnp.float32)
bad = pl.pallas_call(double_kernel, out_shape=jax.ShapeDtypeStruct(xs.shape, xs.dtype), grid=(4,),
                     in_specs=[pl.BlockSpec((16,), lambda i: (i * 16,))],    # 错:i*16 是元素偏移
                     out_specs=pl.BlockSpec((16,), lambda i: (i,)),
                     interpret=INTERPRET)(xs)
wrong = ~np.isclose(f64(bad), 2 * f64(xs))
print("错误元素个数:", int(wrong.sum()), "/ 64;第 16~19 个输出:", np.asarray(bad[16:20]))
assert wrong.sum() > 0      # 越界的块下标在 interpret 模式下被夹到最后一块,结果悄悄出错
错误元素个数: 32 / 64;第 16~19 个输出: [ 96.  98. 100. 102.]
# ✅ 正确写法:index_map 返回块下标,偏移由 Pallas 乘上 block_shape
good = pl.pallas_call(double_kernel, out_shape=jax.ShapeDtypeStruct(xs.shape, xs.dtype), grid=(4,),
                      in_specs=[pl.BlockSpec((16,), lambda i: (i,))],
                      out_specs=pl.BlockSpec((16,), lambda i: (i,)),
                      interpret=INTERPRET)(xs)
np.testing.assert_array_equal(good, 2 * f64(xs))
print("✅", np.asarray(good[16:20]))
✅ [32. 34. 36. 38.]

2.3 第一个 matmul:块形状不必是方的

⚪ 选读:4.1 的铺垫;熟悉「满维」BlockSpec 写法后可直接看 4.1

BlockSpec 允许一个维度取整个数组(「满维」)。最简单的分块 matmul:每个 program 负责输出的一个 (bm, bn) 块,读入 A 的 bm 行(整个 K 维)和 B 的 bn 列(整个 K 维):

$$C_{[i,j]} = A_{[i,:]}\; B_{[:,j]}$$

jnp.dot 的 preferred_element_type=jnp.float32 要求矩阵单元用 float32 累加,输入是 bf16 时这一点尤其重要(§4.1)。

def matmul_rowcol(a, b, bm=64, bn=128):
    """不沿 K 维切块的 matmul:A 取 (bm, K) 行块,B 取 (K, bn) 列块"""
    M, K = a.shape
    _, N = b.shape
    def kernel(a_ref, b_ref, o_ref):
        o_ref[...] = jnp.dot(a_ref[...], b_ref[...], preferred_element_type=jnp.float32)
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct((M, N), jnp.float32),
                          grid=(M // bm, N // bn),
                          in_specs=[pl.BlockSpec((bm, K), lambda i, j: (i, 0)),   # 第 i 个行块,K 维只有一块
                                    pl.BlockSpec((K, bn), lambda i, j: (0, j))],  # 第 j 个列块
                          out_specs=pl.BlockSpec((bm, bn), lambda i, j: (i, j)),
                          interpret=INTERPRET)(a, b)

ka, kb = jax.random.split(jax.random.key(2))
A = jax.random.normal(ka, (256, 384))
B = jax.random.normal(kb, (384, 512))
C_rc = matmul_rowcol(A, B)
ref_AB = f64(A) @ f64(B)                       # NumPy float64 参考
print("最大绝对误差:", max_err(C_rc, ref_AB))
np.testing.assert_allclose(f64(C_rc), ref_AB, rtol=1e-4, atol=1e-4)
最大绝对误差: 6.962321596404308e-05

这个版本的缺点是:K 很大时,(bm, K) 和 (K, bn) 两块放不进片上存储。§4.1 会把 K 维也切开,并在多个 program 之间累加。


§3 interpret 模式详解(CPU)

3.1 grid 按字典序顺序执行

⚪ 选读:kernel 依赖 grid 执行顺序、或要移植到 GPU 时

interpret=True 把 grid 变成一个循环,按字典序(最后一维变化最快)逐个执行 program。TPU 的单个核心上也是这样顺序循环(megacore 芯片会把 parallel 维分给多个核心,见 §4.4);GPU 上各 program 是并行的线程块,没有顺序保证。所以只依赖「顺序」才正确的 kernel,在 interpret 模式下能通过,在 GPU 上却可能出错。

pltpu.InterpretParams(grid_point_recorder=...) 可以记录模拟器访问 grid 的顺序。回调的实际签名是 (token, grid_point, core_id) -> token,要把收到的 token 原样返回。这一点是从 0.12 源码里查到的,文档字符串只写了后两个参数。

visit_order = []
def recorder(token, grid_point, core_id):
    visit_order.append(tuple(int(c) for c in grid_point))
    return token                               # 必须把 token 原样返回,否则报 "Mismatched number of outputs"

def copy_kernel(x_ref, o_ref):
    o_ref[...] = x_ref[...]

spec = pl.BlockSpec((8, 128), lambda i, j: (i, j))
xg = jax.random.normal(jax.random.key(3), (16, 384))
yg = pl.pallas_call(copy_kernel, out_shape=jax.ShapeDtypeStruct(xg.shape, xg.dtype), grid=(2, 3),
                    in_specs=[spec], out_specs=spec,
                    interpret=pltpu.InterpretParams(grid_point_recorder=recorder))(xg)
jax.block_until_ready(yg)
print("访问顺序:", visit_order)
np.testing.assert_array_equal(yg, xg)
assert visit_order == [(i, j) for i in range(2) for j in range(3)]     # 字典序
访问顺序: [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]

3.2 越界的块:读到的是「垃圾」,interpret 模式下是 NaN

🟢 必读

数组长度不能被块大小整除时,用 pl.cdiv 向上取整得到 grid,最后一个块会有一部分落在数组外面。官方文档的规定是:越界部分读入时被填充、写出时被丢弃,填充值未定义,应当视为垃圾。interpret 模式用 NaN 填充浮点数(这样错误更容易暴露),但这只是实现细节,不能依赖。

x100 = jnp.arange(1, 101, dtype=jnp.float32)          # 长度 100,块大小 32 → 4 个块,最后一块只有 4 个有效元素
peek = pl.pallas_call(copy_kernel, out_shape=jax.ShapeDtypeStruct((4, 32), jnp.float32), grid=(pl.cdiv(100, 32),),
                      in_specs=[pl.BlockSpec((32,), lambda i: (i,))],
                      out_specs=pl.BlockSpec((None, 32), lambda i: (i, 0)),   # None:这一维被挤掉(squeeze),kernel 看到的是 (32,)
                      interpret=INTERPRET)(x100)
tail = np.asarray(peek[3])
print("第 4 个块读到的内容:", tail[:8], "...")
assert np.array_equal(tail[:4], [97, 98, 99, 100]) and np.isnan(tail[4:]).all()

# 但逐元素 kernel 不受影响:越界部分写出时被丢弃
dbl = pl.pallas_call(double_kernel, out_shape=jax.ShapeDtypeStruct(x100.shape, x100.dtype), grid=(pl.cdiv(100, 32),),
                     in_specs=[pl.BlockSpec((32,), lambda i: (i,))], out_specs=pl.BlockSpec((32,), lambda i: (i,)),
                     interpret=INTERPRET)(x100)
np.testing.assert_array_equal(dbl, 2 * f64(x100))
print("逐元素 kernel 在非整除长度上仍然正确")
第 4 个块读到的内容: [ 97.  98.  99. 100.  nan  nan  nan  nan] ...
逐元素 kernel 在非整除长度上仍然正确

结论:逐元素计算可以不管边界;一旦块内有归约(求和、max、matmul 的 K 维),垃圾值就会混进结果,必须掩码(§4.3)。

3.3 interpret 模式没有性能意义

⚪ 选读:想确认 CPU 上的计时为什么没有参考价值时

interpret 模式把每个 program 当成一次普通的 JAX 计算来执行,没有片上存储,也没有流水线。它的耗时只反映「解释开销」,和真实硬件上的性能毫无关系。下面的计时只打印,不做任何结论:

kx, ky = jax.random.split(jax.random.key(4))
Xb = jax.random.normal(kx, (1024, 1024))
Yb = jax.random.normal(ky, (1024, 1024))
add_jnp = jax.jit(lambda x, y: x + y)
add_pl_1 = jax.jit(functools.partial(add2d, bm=1024, bn=1024))   # 1 个块
add_pl_64 = jax.jit(functools.partial(add2d, bm=128, bn=128))    # 64 个块
np.testing.assert_allclose(f64(add_pl_64(Xb, Yb)), f64(Xb) + f64(Yb), rtol=1e-6)
print(f"jnp + XLA            : {bench(add_jnp, Xb, Yb):8.3f} ms")
print(f"Pallas interpret 1 块 : {bench(add_pl_1, Xb, Yb):8.3f} ms")
print(f"Pallas interpret 64 块: {bench(add_pl_64, Xb, Yb):8.3f} ms   ← 块越多,解释循环越长(只打印,不作结论)")
jnp + XLA            :    0.075 ms
Pallas interpret 1 块 :    0.081 ms
Pallas interpret 64 块:   17.810 ms   ← 块越多,解释循环越长(只打印,不作结论)

3.4 TPU 模拟器能多查出哪些问题

⚪ 选读:准备上 TPU、想在 CPU 上提前查出 TPU 语义错误时

pltpu.InterpretParams 比 interpret=True 慢,但会模拟 TPU 的内存语义,能查出几类在 interpret=True 下悄悄通过、到了 TPU 上才出错的 bug:

参数作用
(默认开启)输出块重访检查同一个输出块被不连续的 program 写入时报错(§4.4 实测)
uninitialized_memory="nan"(默认)/ "zero"未初始化的输出块 / scratch 用什么值填充
detect_races=True检测多个核心之间的数据竞争
out_of_bounds_reads="raise"(默认)越界读时报错(不包括 BlockSpec 产生的边界块填充)
num_cores_or_threads模拟多核(megacore)并行
grid_point_recorder记录 grid 的访问顺序(§3.1)

⚠️ 文档要求:模拟器内部抛出异常后,同一进程里再次使用前要调用 pltpu.reset_tpu_interpret_mode_state()。

🔍 补充

⚪ 选读:调试 kernel、安排两种解释器的分工时

  • 两种模式的分工可以这样记:interpret=True 验证数学(kernel 体算得对不对),InterpretParams 验证 TPU 语义(块怎么搬、何时写回、多核会不会冲突)。上 TPU 之前两种都跑一遍,成本很低。
  • 调试 kernel 时,可以在 kernel 里用 pl.debug_print("pid={}", pl.program_id(0)) 打印。interpret 模式下实测可用(每个 program 打印一行);真实硬件上各后端对能打印的值和格式串各有限制,并不与 jax.debug.print 完全相同,用之前查对应后端的文档。

§4 进阶 kernel

4.1 带累加器的分块 matmul

🟢 必读

把 K 维也切开,grid 变成三维 (i, j, k):

$$C_{[i,j]} = \sum_{k} A_{[i,k]}\, B_{[k,j]}$$

输出块的 index_map 是 (i, j, k) -> (i, j),与 k 无关。也就是说,同一个输出块会被连续的 K 个 program 反复访问,可以直接拿输出 Ref 当累加器:

  • k == 0 时先清零:用 @pl.when(pl.program_id(2) == 0) 装饰一个无参函数,它只在条件为真时执行;
  • 每一步 o_ref[...] += A块 @ B块。

归约维必须放在 grid 的最后一维,这样同一输出块的访问才是连续的(§4.4 会演示违反这条规则的后果)。

def matmul_acc(a, b, bm=64, bk=128, bn=128):
    """沿 K 维累加的分块 matmul,输出 float32,直接用 o_ref 当累加器"""
    M, K = a.shape
    _, N = b.shape
    def kernel(a_ref, b_ref, o_ref):
        @pl.when(pl.program_id(2) == 0)            # 每个输出块的第一个 K 步:清零
        def _init():
            o_ref[...] = jnp.zeros_like(o_ref)
        o_ref[...] += jnp.dot(a_ref[...], b_ref[...], preferred_element_type=jnp.float32)
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct((M, N), jnp.float32),
                          grid=(M // bm, N // bn, K // bk),       # 归约维 k 放最后
                          in_specs=[pl.BlockSpec((bm, bk), lambda i, j, k: (i, k)),
                                    pl.BlockSpec((bk, bn), lambda i, j, k: (k, j))],
                          out_specs=pl.BlockSpec((bm, bn), lambda i, j, k: (i, j)),
                          interpret=INTERPRET)(a, b)

C_acc = matmul_acc(A, B)                    # A: (256, 384), B: (384, 512) → grid (4, 4, 3)
print("最大绝对误差(对 float64):", max_err(C_acc, ref_AB))
np.testing.assert_allclose(f64(C_acc), ref_AB, rtol=1e-4, atol=1e-4)
最大绝对误差(对 float64): 2.7661546042168084e-05
# ❌ 错误写法:用 Python 的 if 判断 program_id —— 它是追踪期的 tracer,不能转成 bool
def mm_kernel_pyif(a_ref, b_ref, o_ref):
    if pl.program_id(2) == 0:
        o_ref[...] = jnp.zeros_like(o_ref)
    o_ref[...] += jnp.dot(a_ref[...], b_ref[...], preferred_element_type=jnp.float32)
try:
    pl.pallas_call(mm_kernel_pyif, out_shape=jax.ShapeDtypeStruct((256, 512), jnp.float32), grid=(4, 4, 3),
                   in_specs=[pl.BlockSpec((64, 128), lambda i, j, k: (i, k)),
                             pl.BlockSpec((128, 128), lambda i, j, k: (k, j))],
                   out_specs=pl.BlockSpec((64, 128), lambda i, j, k: (i, j)), interpret=INTERPRET)(A, B)
    err = None
except jax.errors.TracerBoolConversionError as e:
    err = e
    print(type(e).__name__, ":", str(e).splitlines()[0])
assert isinstance(err, jax.errors.TracerBoolConversionError)
TracerBoolConversionError : Attempted boolean conversion of traced array with shape bool[].
# ✅ 正确写法:用 @pl.when(条件) 装饰一个无参函数(matmul_acc 里就是这么写的)
def mm_kernel_when(a_ref, b_ref, o_ref):
    @pl.when(pl.program_id(2) == 0)
    def _init():
        o_ref[...] = jnp.zeros_like(o_ref)
    o_ref[...] += jnp.dot(a_ref[...], b_ref[...], preferred_element_type=jnp.float32)
C_when = pl.pallas_call(mm_kernel_when, out_shape=jax.ShapeDtypeStruct((256, 512), jnp.float32), grid=(4, 4, 3),
                        in_specs=[pl.BlockSpec((64, 128), lambda i, j, k: (i, k)),
                                  pl.BlockSpec((128, 128), lambda i, j, k: (k, j))],
                        out_specs=pl.BlockSpec((64, 128), lambda i, j, k: (i, j)), interpret=INTERPRET)(A, B)
np.testing.assert_allclose(f64(C_when), ref_AB, rtol=1e-4, atol=1e-4)
print("✅ pl.when 版本正确,最大误差", max_err(C_when, ref_AB))
✅ pl.when 版本正确,最大误差 2.7661546042168084e-05

@pl.when(cond) 对应 lax.cond,只是没有返回值,专门用于「条件满足时对 Ref 做副作用」。

下面是第二种常见错误:忘记初始化累加器。

# ❌ 错误写法:忘记在 k == 0 时清零,直接 +=
def mm_kernel_noinit(a_ref, b_ref, o_ref):
    o_ref[...] += jnp.dot(a_ref[...], b_ref[...], preferred_element_type=jnp.float32)
mm_noinit = lambda interp: pl.pallas_call(
    mm_kernel_noinit, out_shape=jax.ShapeDtypeStruct((256, 512), jnp.float32), grid=(4, 4, 3),
    in_specs=[pl.BlockSpec((64, 128), lambda i, j, k: (i, k)), pl.BlockSpec((128, 128), lambda i, j, k: (k, j))],
    out_specs=pl.BlockSpec((64, 128), lambda i, j, k: (i, j)), interpret=interp)(A, B)
try:
    r_nan = mm_noinit(INTERPRET)                                          # 未初始化内存 = NaN
    r_zero = mm_noinit(pltpu.InterpretParams(uninitialized_memory="zero")) # 即使初始内存恰好是 0……
    print("interpret=True:NaN 比例 =", float(np.isnan(f64(r_nan)).mean()))
    print("TPU 模拟器(未初始化内存填 0):最大误差 =", max_err(r_zero, ref_AB))
except Exception as e:
    print(type(e).__name__, e)
    raise
assert np.isnan(f64(r_nan)).all()
assert max_err(r_zero, ref_AB) > 1.0     # 输出缓冲区在块之间被复用,上一个块的结果被累加了进来
interpret=True:NaN 比例 = 1.0
TPU 模拟器(未初始化内存填 0):最大误差 = 295.86875920583236
# ✅ 正确写法:k == 0 时显式清零(matmul_acc)。在两种解释器、两种未初始化填充值下都正确
for label, interp in [("interpret=True", INTERPRET), ("InterpretParams()", pltpu.InterpretParams()),
                      ('InterpretParams(uninitialized_memory="zero")', pltpu.InterpretParams(uninitialized_memory="zero"))]:
    C_ok = pl.pallas_call(mm_kernel_when, out_shape=jax.ShapeDtypeStruct((256, 512), jnp.float32), grid=(4, 4, 3),
                          in_specs=[pl.BlockSpec((64, 128), lambda i, j, k: (i, k)),
                                    pl.BlockSpec((128, 128), lambda i, j, k: (k, j))],
                          out_specs=pl.BlockSpec((64, 128), lambda i, j, k: (i, j)), interpret=interp)(A, B)
    np.testing.assert_allclose(f64(C_ok), ref_AB, rtol=1e-4, atol=1e-4)
    print(f"✅ {label:46s} 最大误差 {max_err(C_ok, ref_AB):.2e}")
✅ interpret=True                                 最大误差 2.77e-05
✅ InterpretParams()                              最大误差 2.77e-05
✅ InterpretParams(uninitialized_memory="zero")   最大误差 2.77e-05

第二行结果很有意思:即使未初始化的内存恰好是 0,结果也是错的。TPU 模拟器忠实再现了真实 TPU 的行为:输出块在 VMEM 里的缓冲区会在不同的输出块之间复用,并不会每次从 HBM 读回或清零。所以上一个 (i, j) 块的累加结果会带进下一个块。累加器必须显式初始化,不能指望「内存默认是 0」。

4.1′ 用 scratch 累加器:bf16 输入、float32 累加

⚪ 选读:输入输出是 bf16、需要 float32 累加时

实际训练中,输入和输出常常是 bf16。如果直接拿 bf16 的输出块当累加器,每一步都会舍入一次,误差会不断积累。标准做法是:

  • 用 scratch_shapes=[pltpu.VMEM((bm, bn), jnp.float32)] 申请一块 float32 的片上暂存区(kernel 多接一个 scratch Ref);
  • 最后一个 K 步(k == pl.num_programs(2) - 1)才把累加结果转成 bf16 写入输出。

pltpu.VMEM 在 interpret 模式下也能用(已验证)。

def matmul_bf16(a, b, bm=64, bk=128, bn=128):
    """bf16 输入/输出,float32 scratch 累加"""
    M, K = a.shape
    _, N = b.shape
    def kernel(a_ref, b_ref, o_ref, acc_ref):         # 最后一个参数是 scratch
        k = pl.program_id(2)
        @pl.when(k == 0)
        def _init():
            acc_ref[...] = jnp.zeros_like(acc_ref)
        acc_ref[...] += jnp.dot(a_ref[...], b_ref[...], preferred_element_type=jnp.float32)
        @pl.when(k == pl.num_programs(2) - 1)        # 最后一个 K 步:写出
        def _store():
            o_ref[...] = acc_ref[...].astype(o_ref.dtype)
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct((M, N), a.dtype),
                          grid=(M // bm, N // bn, K // bk),
                          in_specs=[pl.BlockSpec((bm, bk), lambda i, j, k: (i, k)),
                                    pl.BlockSpec((bk, bn), lambda i, j, k: (k, j))],
                          out_specs=pl.BlockSpec((bm, bn), lambda i, j, k: (i, j)),
                          scratch_shapes=[pltpu.VMEM((bm, bn), jnp.float32)],
                          interpret=INTERPRET)(a, b)

A16, B16 = A.astype(jnp.bfloat16), B.astype(jnp.bfloat16)
C16 = matmul_bf16(A16, B16)
ref16 = f64(A16) @ f64(B16)                # 参考:同样的 bf16 输入,float64 计算
rel = np.max(np.abs(f64(C16) - ref16) / (np.abs(ref16) + 1.0))
print("输出 dtype:", C16.dtype, "| 最大相对误差:", rel, "(bf16 的舍入单位约为 2^-8 ≈ 3.9e-3)")
assert C16.dtype == jnp.bfloat16
assert rel < 2 ** -7
输出 dtype: bfloat16 | 最大相对误差: 0.0037682344278978106 (bf16 的舍入单位约为 2^-8 ≈ 3.9e-3)

4.2 按行的 softmax 与 LayerNorm(数值稳定写法)

⚪ 选读:要手写 softmax / LayerNorm 这类按行归约的 kernel 时

进阶/02 §2.6 实测过:XLA 会把手写 softmax 在「归约 → 广播」处切成多个内核。在 Pallas 里,只要一个块装得下完整的一行(BlockSpec((br, C), lambda i: (i, 0))),max、exp、sum、除法就全部在同一个 kernel 里完成,每个元素只从主存读一次、写一次。

数值稳定性的两条老规矩,在 kernel 里同样适用:

  • softmax:先减去每行最大值,再算 exp;
  • LayerNorm:方差用两遍法 $\mathrm{mean}((x-\mu)^2)$,不要用 $E[x^2]-E[x]^2$(数值很大时会发生灾难性抵消)。

行数不能被 br 整除时不需要掩码:越界的行是垃圾,但每行独立计算,垃圾行的结果写出时会被丢弃。

def compute_dtype(dt):
    """块内计算精度:bf16/f16 提升到 float32;float32、float64 保持不变(直接写 astype(float32) 会把 float64 降精度)"""
    return jnp.promote_types(dt, jnp.float32)

def softmax_kernel(x_ref, o_ref):
    x = x_ref[...].astype(compute_dtype(x_ref.dtype))
    m = jnp.max(x, axis=-1, keepdims=True)          # 数值稳定:先减去行最大值
    e = jnp.exp(x - m)
    o_ref[...] = (e / jnp.sum(e, axis=-1, keepdims=True)).astype(o_ref.dtype)

def row_call(kernel, x, *extra, br=8):
    """按行分块调用:每个块是 br 行 × 整行;extra 是按列广播的参数(形状 (1, C),所有块共用)"""
    R, Cc = x.shape
    row_spec = pl.BlockSpec((br, Cc), lambda i: (i, 0))
    vec_spec = pl.BlockSpec((1, Cc), lambda i: (0, 0))      # 每个 program 都取同一块(第 0 块)
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct(x.shape, x.dtype),
                          grid=(pl.cdiv(R, br),),
                          in_specs=[row_spec] + [vec_spec] * len(extra), out_specs=row_spec,
                          interpret=INTERPRET)(x, *extra)

def softmax_ref64(x):
    x = f64(x); e = np.exp(x - x.max(-1, keepdims=True)); return e / e.sum(-1, keepdims=True)

xs_sm = jax.random.normal(jax.random.key(5), (50, 256)) * 3       # 50 行:不能被 8 整除
sm = row_call(softmax_kernel, xs_sm)
print("softmax 最大误差:", max_err(sm, softmax_ref64(xs_sm)))
np.testing.assert_allclose(f64(sm), softmax_ref64(xs_sm), rtol=1e-5, atol=1e-7)
np.testing.assert_allclose(f64(sm).sum(-1), 1.0, rtol=1e-5)
softmax 最大误差: 1.1184438952760445e-07
# ❌ 错误写法:softmax 不减最大值 —— 输入较大时 exp 上溢为 inf,inf/inf = NaN
def softmax_kernel_naive(x_ref, o_ref):
    e = jnp.exp(x_ref[...])
    o_ref[...] = e / jnp.sum(e, axis=-1, keepdims=True)
big = xs_sm + 100.0                                     # softmax 对平移不变,正确答案不变
try:
    sm_bad = row_call(softmax_kernel_naive, big)
    print("NaN 比例:", float(np.isnan(f64(sm_bad)).mean()))
except Exception as e:
    print(type(e).__name__, e); raise
assert np.isnan(f64(sm_bad)).any()
NaN 比例: 1.0
# ✅ 正确写法:减去行最大值后,平移 100 的结果与原结果一致
sm_big = row_call(softmax_kernel, big)
np.testing.assert_allclose(f64(sm_big), softmax_ref64(xs_sm), rtol=1e-5, atol=1e-7)
print("✅ 平移 100 后结果不变,最大误差", max_err(sm_big, softmax_ref64(xs_sm)))
✅ 平移 100 后结果不变,最大误差 1.0066334936720267e-06
EPS = 1e-5
def layernorm_kernel(x_ref, g_ref, b_ref, o_ref):
    x = x_ref[...].astype(compute_dtype(x_ref.dtype))
    mu = jnp.mean(x, axis=-1, keepdims=True)
    xc = x - mu
    var = jnp.mean(xc * xc, axis=-1, keepdims=True)        # 两遍法:先中心化,再求平方的均值
    o_ref[...] = (xc * lax.rsqrt(var + EPS) * g_ref[...] + b_ref[...]).astype(o_ref.dtype)

def layernorm_ref64(x, g, b):
    x = f64(x); mu = x.mean(-1, keepdims=True); var = ((x - mu) ** 2).mean(-1, keepdims=True)
    return (x - mu) / np.sqrt(var + EPS) * f64(g) + f64(b)

kx, kg, kb = jax.random.split(jax.random.key(6), 3)
xln = jax.random.normal(kx, (50, 256))
gamma = 1.0 + 0.1 * jax.random.normal(kg, (1, 256))        # 权重作为参数传入
beta = 0.1 * jax.random.normal(kb, (1, 256))
ln = row_call(layernorm_kernel, xln, gamma, beta)
print("LayerNorm 最大误差:", max_err(ln, layernorm_ref64(xln, gamma, beta)))
np.testing.assert_allclose(f64(ln), layernorm_ref64(xln, gamma, beta), rtol=1e-4, atol=1e-5)
LayerNorm 最大误差: 5.030126644101074e-07
# ❌ 错误写法:方差用 E[x²] − E[x]²。数据带有很大的偏移(这里是 1e4)时,float32 发生灾难性抵消
def layernorm_kernel_onepass(x_ref, g_ref, b_ref, o_ref):
    x = x_ref[...]
    mu = jnp.mean(x, axis=-1, keepdims=True)
    var = jnp.mean(x * x, axis=-1, keepdims=True) - mu * mu          # 1e8 量级的两个数相减
    o_ref[...] = (x - mu) * lax.rsqrt(var + EPS) * g_ref[...] + b_ref[...]
x_off = xln + 1e4
ref_off = layernorm_ref64(f64(xln) + 1e4, gamma, beta)          # float64 参考
try:
    ln_bad = row_call(layernorm_kernel_onepass, x_off, gamma, beta)
    err_bad = np.nanmax(np.abs(f64(ln_bad) - ref_off)) if not np.isnan(f64(ln_bad)).all() else np.inf
    print("一遍法最大误差:", err_bad, "| NaN 个数:", int(np.isnan(f64(ln_bad)).sum()))
except Exception as e:
    print(type(e).__name__, e); raise
assert err_bad > 0.1 or np.isnan(f64(ln_bad)).any()
一遍法最大误差: 1548.096477828638 | NaN 个数: 4864
# ✅ 正确写法:两遍法(先中心化)
ln_good = row_call(layernorm_kernel, x_off, gamma, beta)
err_good = max_err(ln_good, ref_off)
print("两遍法最大误差:", err_good)
assert err_good < 5e-3     # 剩余误差来自 1e4 + x 本身在 float32 里的表示误差(约 1e-3 的 ulp)
两遍法最大误差: 0.002409623281878326

4.3 带掩码的边界处理

🟢 必读

§3.2 说过,越界部分读到的是垃圾。只要块内有归约,就要先用掩码把越界部分换成该归约的单位元(求和用 0,求最大值用 -inf),再参与计算。掩码用全局下标构造:

idx = pl.program_id(axis) * block + lax.broadcasted_iota(jnp.int32, block_shape, dim)
v = jnp.where(idx < n, x_ref[...], 0.0)

注意要用 jnp.where(选择),不能用乘法:NaN * 0 仍然是 NaN。

先看分块 matmul 在 M、N、K 都不能整除时的情况:

  • M、N 方向的越界只影响输出里越界的那部分行和列,写出时会被丢弃,不用管;
  • K 方向的越界会被求和进每一个有效输出,必须掩码,而且 A 和 B 都要掩码(只掩一边会得到 0 × NaN = NaN)。
def matmul_masked(a, b, bm=64, bk=64, bn=64, mask_a=True, mask_b=True):
    """任意形状的分块 matmul:grid 用 cdiv 向上取整,K 方向用掩码"""
    M, K = a.shape
    _, N = b.shape
    def kernel(a_ref, b_ref, o_ref):
        k = pl.program_id(2)
        @pl.when(k == 0)
        def _init():
            o_ref[...] = jnp.zeros_like(o_ref)
        a_blk, b_blk = a_ref[...], b_ref[...]
        if mask_a:   # 这里的 if 判断的是 Python 布尔值(构建期常量),不是 tracer,可以用
            ka = k * bk + lax.broadcasted_iota(jnp.int32, a_blk.shape, 1)   # A 块的列 = 全局 K 下标
            a_blk = jnp.where(ka < K, a_blk, 0.0)
        if mask_b:
            kb_ = k * bk + lax.broadcasted_iota(jnp.int32, b_blk.shape, 0)  # B 块的行 = 全局 K 下标
            b_blk = jnp.where(kb_ < K, b_blk, 0.0)
        o_ref[...] += jnp.dot(a_blk, b_blk, preferred_element_type=jnp.float32)
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct((M, N), jnp.float32),
                          grid=(pl.cdiv(M, bm), pl.cdiv(N, bn), pl.cdiv(K, bk)),
                          in_specs=[pl.BlockSpec((bm, bk), lambda i, j, k: (i, k)),
                                    pl.BlockSpec((bk, bn), lambda i, j, k: (k, j))],
                          out_specs=pl.BlockSpec((bm, bn), lambda i, j, k: (i, j)),
                          interpret=INTERPRET)(a, b)

ka, kb = jax.random.split(jax.random.key(7))
Ao = jax.random.normal(ka, (100, 200))        # M=100, K=200, N=130:都不能被 64 整除
Bo = jax.random.normal(kb, (200, 130))
ref_o = f64(Ao) @ f64(Bo)
# ❌ 错误写法:K 方向不掩码,或者只掩码 A —— NaN 污染所有输出
for label, ma, mb in [("不掩码", False, False), ("只掩码 A", True, False)]:
    try:
        r = matmul_masked(Ao, Bo, mask_a=ma, mask_b=mb)
        print(f"{label:8s}:NaN 比例 = {float(np.isnan(f64(r)).mean()):.2f}")
    except Exception as e:
        print(type(e).__name__, e); raise
    assert np.isnan(f64(r)).all()
不掩码     :NaN 比例 = 1.00
只掩码 A   :NaN 比例 = 1.00
# ✅ 正确写法:A 的列和 B 的行都用 jnp.where 掩码
C_o = matmul_masked(Ao, Bo)
print("形状", C_o.shape, "| 最大误差:", max_err(C_o, ref_o))
np.testing.assert_allclose(f64(C_o), ref_o, rtol=1e-4, atol=1e-4)
形状 (100, 130) | 最大误差: 1.2101076086068474e-05

4.4 跨 grid 的归约

🟢 必读

把「按行求和」做成两维 grid:i 遍历行块,j 遍历列块并累加。输出块 (br,) 的 index_map 是 (i, j) -> (i,)。与 matmul 一样,有三条规则:

  1. 归约维(j)放在 grid 的最后;
  2. j == 0 时初始化;
  3. 列数不能整除时,对列做掩码。

在 TPU 上还应把维度语义告诉编译器:compiler_params=pltpu.CompilerParams(dimension_semantics=("parallel", "arbitrary"))。parallel 维可以分给多个核心,arbitrary 维必须在一个核心上顺序执行(§6.2)。在 interpret 模式下也能传这个参数,实际会被接受。

def rowsum(x, br=32, bc=128, reduce_last=True, interpret=INTERPRET):
    """按行求和:x (R, C) -> (R,);reduce_last=False 用来演示错误的 grid 顺序"""
    R, Cc = x.shape
    red_axis = 1 if reduce_last else 0
    def kernel(x_ref, o_ref):
        j = pl.program_id(red_axis)
        @pl.when(j == 0)
        def _init():
            o_ref[...] = jnp.zeros_like(o_ref)
        col = j * bc + lax.broadcasted_iota(jnp.int32, x_ref.shape, 1)
        o_ref[...] += jnp.sum(jnp.where(col < Cc, x_ref[...], 0.0), axis=1)   # 掩码后再求和
    if reduce_last:
        grid, xmap, omap = (pl.cdiv(R, br), pl.cdiv(Cc, bc)), (lambda i, j: (i, j)), (lambda i, j: (i,))
    else:   # 错误顺序:归约维放在最前面
        grid, xmap, omap = (pl.cdiv(Cc, bc), pl.cdiv(R, br)), (lambda j, i: (i, j)), (lambda j, i: (i,))
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct((R,), jnp.float32), grid=grid,
                          in_specs=[pl.BlockSpec((br, bc), xmap)], out_specs=pl.BlockSpec((br,), omap),
                          compiler_params=pltpu.CompilerParams(dimension_semantics=("parallel", "arbitrary")) if reduce_last else None,
                          interpret=interpret)(x)

xr = jax.random.normal(jax.random.key(8), (96, 300))     # 300 列不能被 128 整除
rs = rowsum(xr)
print("行求和最大误差:", max_err(rs, f64(xr).sum(1)))
np.testing.assert_allclose(f64(rs), f64(xr).sum(1), rtol=1e-5, atol=1e-4)
行求和最大误差: 4.580535460263491e-06
# ❌ 错误写法:把归约维放在 grid 的第一维 —— 同一个输出块被“不连续”地访问
r_true = rowsum(xr, reduce_last=False, interpret=True)     # 通用解释器:碰巧算对,掩盖了问题
print("interpret=True 下误差:", max_err(r_true, f64(xr).sum(1)), "← 碰巧正确")
assert max_err(r_true, f64(xr).sum(1)) < 1e-3
try:
    with quiet_callback_logs():
        jax.block_until_ready(rowsum(xr, reduce_last=False, interpret=pltpu.InterpretParams()))
    err = None
except Exception as e:
    err = e
    msg = [l for l in str(e).splitlines() if "Revisited" in l][0]
    print("TPU 模拟器:", type(e).__name__, "→", msg.strip())
finally:
    pltpu.reset_tpu_interpret_mode_state()                  # 模拟器抛异常后必须重置
assert err is not None and "Revisited block" in str(err)
interpret=True 下误差: 4.580535460263491e-06 ← 碰巧正确
TPU 模拟器: JaxRuntimeError → RuntimeError: Revisited block (slice(0, 32, 1),) of output 0 in iteration (1, 0). The block was previously visited in iterations (0, 0) through (0, 0) .
# ✅ 正确写法:归约维放最后,TPU 模拟器也能通过
rs_tpu = rowsum(xr, reduce_last=True, interpret=pltpu.InterpretParams())
np.testing.assert_allclose(f64(rs_tpu), f64(xr).sum(1), rtol=1e-5, atol=1e-4)
print("✅ TPU 模拟器下误差:", max_err(rs_tpu, f64(xr).sum(1)))
✅ TPU 模拟器下误差: 4.580535460263491e-06

为什么不连续访问会出错:在 TPU 上,输出块位于 VMEM,只有当输出块的下标发生变化时,才会把旧块写回 HBM、换上新块,而且新块并不会从 HBM 读回旧值。如果 grid 是 (j, i),访问顺序是 (0,0),(0,1),(0,2),(1,0)...,第 i 个输出块每隔几步就被换出一次;再次轮到它时,之前累加的结果已经丢了。interpret=True 每次都从完整的数组里读写,因此掩盖了这个问题。用 InterpretParams 跑一遍,就能在 CPU 上提前发现。

4.5 在线 logsumexp:FlashAttention 的核心技巧

⚪ 选读:要实现 FlashAttention 一类超长行的在线归约时

当一行太长、一个块装不下时,softmax 的归一化项可以边读边更新(online softmax)。维护两个累加量:当前最大值 $m$ 和缩放后的和 $\ell$。每读入一个新的列块 $x_{blk}$:

$$m' = \max(m, \max x_{blk}),\qquad \ell' = \ell\, e^{m-m'} + \sum e^{x_{blk}-m'}$$

最后 $\mathrm{logsumexp} = m + \log \ell$。FlashAttention 就是在此基础上,把 $e^{x-m}$ 再乘上 $V$ 块一起累加,从而让 $L\times L$ 的注意力矩阵永远不落地。

这个 kernel 需要两个 scratch(m 和 ell),并且用 -inf 作为求最大值的掩码填充值:

def logsumexp_rows(x, br=16, bc=128):
    """按行在线 logsumexp:x (R, C) -> (R,),C 方向分块、跨块在线更新"""
    R, Cc = x.shape
    def kernel(x_ref, o_ref, m_ref, l_ref):
        j = pl.program_id(1)
        @pl.when(j == 0)
        def _init():
            m_ref[...] = jnp.full_like(m_ref, -jnp.inf)
            l_ref[...] = jnp.zeros_like(l_ref)
        col = j * bc + lax.broadcasted_iota(jnp.int32, x_ref.shape, 1)
        xb = jnp.where(col < Cc, x_ref[...], -jnp.inf)         # 求最大值的单位元是 -inf
        m_old = m_ref[...]
        m_new = jnp.maximum(m_old, jnp.max(xb, axis=1))
        l_ref[...] = l_ref[...] * jnp.exp(m_old - m_new) + jnp.sum(jnp.exp(xb - m_new[:, None]), axis=1)
        m_ref[...] = m_new
        @pl.when(j == pl.num_programs(1) - 1)
        def _store():
            o_ref[...] = m_ref[...] + jnp.log(l_ref[...])
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct((R,), jnp.float32),
                          grid=(pl.cdiv(R, br), pl.cdiv(Cc, bc)),
                          in_specs=[pl.BlockSpec((br, bc), lambda i, j: (i, j))],
                          out_specs=pl.BlockSpec((br,), lambda i, j: (i,)),
                          scratch_shapes=[pltpu.VMEM((br,), jnp.float32), pltpu.VMEM((br,), jnp.float32)],
                          interpret=INTERPRET)(x)

from scipy.special import logsumexp as sp_logsumexp
xl = jax.random.normal(jax.random.key(9), (40, 1000)) * 4 + 50.0   # 行很长、数值偏大;1000 列不能被 128 整除
lse = logsumexp_rows(xl)
ref_lse = sp_logsumexp(f64(xl), axis=1)
print("在线 logsumexp 最大误差:", max_err(lse, ref_lse))
np.testing.assert_allclose(f64(lse), ref_lse, rtol=1e-5)
np.testing.assert_allclose(f64(lse), f64(jax.nn.logsumexp(xl, axis=1)), rtol=1e-5)
在线 logsumexp 最大误差: 3.1767704911089822e-06

🔍 补充

⚪ 选读:写在线归约、选择掩码填充值时

  • 掩码的单位元要和归约匹配:求和用 0,求最大值用 -inf,求最小值用 +inf,求积用 1。在线 logsumexp 里如果用 0 当掩码,当整行都是负数时最大值就会被错误地抬高到 0。
  • 在线算法的初始值 m = -inf 会让第一步出现 exp(-inf - m_new) = 0,这是合法的,不会产生 NaN。但前提是每个块里至少有一个有效元素,否则 m_new 仍是 -inf,-inf - (-inf) 就是 NaN。官方文档也规定每个块至少要有一个元素在界内,用 cdiv 计算 grid 时自然满足。
  • 4.1′ 和 4.5 中的 scratch 在 grid 上持续存在(跨 program 保留内容),所以只能在「同一个输出块的连续访问」内部当累加器使用。这正是归约维必须放在 grid 最后的另一个原因。

§5 与 JAX 变换的组合

5.1 jit:直接可用,块大小必须是静态的

⚪ 选读:块大小要作为参数传进 jit 函数时

pallas_call 本身就是一个原语,可以放进任何 jit 函数。但 grid 和 BlockSpec 在构建期就要确定,块大小必须是 Python 整数,不能是 tracer。

mm_jit = jax.jit(matmul_acc)
np.testing.assert_allclose(f64(mm_jit(A, B)), ref_AB, rtol=1e-4, atol=1e-4)
print("jit(matmul_acc) 正确")
jit(matmul_acc) 正确
# ❌ 错误写法:把块大小当作普通(被追踪的)参数传给 jit 函数
try:
    jax.jit(matmul_acc)(A, B, 64, 128, 128)
    err = None
except Exception as e:
    err = e
    print(type(e).__name__, ":", str(e).splitlines()[0][:160])
assert err is not None
ValueError : Unsupported block dimension type: <class 'jax._src.interpreters.partial_eval.DynamicJaxprTracer'>. Allowed types: `pl.Squeezed`, `pl.Blocked`, `pl.Element`, `in
# ✅ 正确写法:块大小声明为静态参数(或者用 functools.partial 事先绑定)
mm_static = jax.jit(matmul_acc, static_argnames=("bm", "bk", "bn"))
np.testing.assert_allclose(f64(mm_static(A, B, bm=128, bk=128, bn=256)), ref_AB, rtol=1e-4, atol=1e-4)
mm_partial = jax.jit(functools.partial(matmul_acc, bm=32, bk=64, bn=128))
np.testing.assert_allclose(f64(mm_partial(A, B)), ref_AB, rtol=1e-4, atol=1e-4)
print("✅ 两种静态块大小写法都正确")
✅ 两种静态块大小写法都正确

5.2 vmap:直接可用,batch 维变成 grid 的新一维

⚪ 选读:需要对 kernel 做批处理、或想知道 vmap 的实现方式时

实际跑一下可以看到:对 pallas_call 做 vmap 时,JAX 的 batching 规则会在 grid 最前面加一维,并在每个 BlockSpec 的块形状前面插入一个被挤掉的维度(Squeezed)。kernel 本身不变,每个 batch 元素各执行一遍原来的 grid。

add_vm = jax.vmap(functools.partial(add2d, bm=64, bn=128))
Xs = jax.random.normal(jax.random.key(10), (3, 256, 512))
Ys = jax.random.normal(jax.random.key(11), (3, 256, 512))
np.testing.assert_allclose(f64(add_vm(Xs, Ys)), f64(Xs) + f64(Ys), rtol=1e-6)

eqn = jax.make_jaxpr(add_vm)(Xs, Ys).jaxpr.eqns[0]
gm = eqn.params["grid_mapping"]
print("原 grid (4, 4) → vmap 后的 grid:", gm.grid)
print("vmap 后的块形状:", gm.block_mappings[0].block_shape)
assert str(eqn.primitive) == "pallas_call" and gm.grid == (3, 4, 4)

# in_axes=None:B 在所有 batch 间共享(不复制)
As_ = jax.random.normal(jax.random.key(12), (3, 100, 200))
Cv = jax.vmap(matmul_masked, in_axes=(0, None))(As_, Bo)
np.testing.assert_allclose(f64(Cv), np.einsum("bmk,kn->bmn", f64(As_), f64(Bo)), rtol=1e-4, atol=1e-4)
print("vmap(matmul_masked, in_axes=(0, None)) 正确,形状", Cv.shape)
原 grid (4, 4) → vmap 后的 grid: (3, 4, 4)
vmap 后的块形状: (Squeezed(), Blocked(block_size=64), Blocked(block_size=128))
vmap(matmul_masked, in_axes=(0, None)) 正确,形状 (3, 100, 130)

5.3 grad:默认不可微,用 custom_vjp + Pallas 反向 kernel

🟢 必读

正确的做法是:用 jax.custom_vjp 把前向 kernel 包起来(见 进阶/04 §4.6),反向也用 Pallas 写。softmax 的 VJP 只需要前向输出 $y$:

$$\bar x = y \odot \big(\bar y - \textstyle\sum_j \bar y_j\, y_j\big)$$

反向同样是「按行归约再广播」,正好适合一个按行分块的 kernel。前向把 $y$ 作为残差(residual)保存下来。先看不这样做、直接求导会怎样:

# ❌ 错误写法:直接对含 pallas_call 的函数做反向求导
loss_direct = lambda x: jnp.sum(row_call(softmax_kernel, x) ** 2)
x_sm = jax.random.normal(jax.random.key(13), (16, 128))
try:
    jax.grad(loss_direct)(x_sm)
    err = None
except ValueError as e:
    err = e
    print("grad:", type(e).__name__, ":", str(e).splitlines()[0][:160])
assert err is not None and "Linearization failed" in str(err)
grad: ValueError : Linearization failed to produce known values for all output primals. This is typically caused by attempting to differentiate a function uses an operation that d
# ✅ 正确写法:custom_vjp,前向和反向都是 Pallas kernel
def softmax_bwd_kernel(y_ref, dy_ref, dx_ref):
    y, dy = y_ref[...], dy_ref[...]
    dx_ref[...] = y * (dy - jnp.sum(dy * y, axis=-1, keepdims=True))

def softmax_bwd_pallas(y, dy, br=8):
    R, Cc = y.shape
    spec = pl.BlockSpec((br, Cc), lambda i: (i, 0))
    return pl.pallas_call(softmax_bwd_kernel, out_shape=jax.ShapeDtypeStruct(y.shape, y.dtype),
                          grid=(pl.cdiv(R, br),), in_specs=[spec, spec], out_specs=spec,
                          interpret=INTERPRET)(y, dy)

@jax.custom_vjp
def softmax_pl(x):
    return row_call(softmax_kernel, x)

def _softmax_fwd(x):
    y = row_call(softmax_kernel, x)
    return y, y                                  # 残差:只存输出 y

def _softmax_bwd(y, dy):
    return (softmax_bwd_pallas(y, dy),)          # 返回元组,与原函数的参数一一对应

softmax_pl.defvjp(_softmax_fwd, _softmax_bwd)

w_sm = jax.random.normal(jax.random.key(14), (16, 128))    # “权重”也作为参数传入
L_pl = lambda x, w: jnp.sum(softmax_pl(x) * w)
L_ref = lambda x, w: jnp.sum(jax.nn.softmax(x) * w)
g_pl, g_ref = jax.grad(L_pl)(x_sm, w_sm), jax.grad(L_ref)(x_sm, w_sm)
print("与 jax.nn.softmax 自动微分结果的最大差:", max_err(g_pl, g_ref))
np.testing.assert_allclose(f64(g_pl), f64(g_ref), rtol=1e-5, atol=1e-7)

# NumPy float64 手推的 VJP 对拍
y64 = softmax_ref64(x_sm); w64 = f64(w_sm)
np.testing.assert_allclose(f64(g_pl), y64 * (w64 - (w64 * y64).sum(-1, keepdims=True)), rtol=1e-4, atol=1e-6)
与 jax.nn.softmax 自动微分结果的最大差: 7.450580596923828e-09

为什么直接 grad 会失败?查一下 pallas_call 原语注册了哪些微分规则(jax.interpreters.ad 是半内部 API,这里只用来观察):

from jax.interpreters import ad, partial_eval as pe
has_jvp = pl.pallas_call_p in ad.primitive_jvps
has_pe = pl.pallas_call_p in pe.custom_partial_eval_rules or pl.pallas_call_p in pe.partial_eval_jaxpr_custom_rules
has_transpose = pl.pallas_call_p in ad.primitive_transposes
print("pallas_call 有 JVP 规则:", has_jvp, "| 有部分求值规则:", has_pe, "| 有转置规则:", has_transpose)
assert has_jvp and not has_pe and not has_transpose

# 反向模式在「线性化」这一步就失败了(还没走到转置):jax.linearize 报同样的错
try:
    jax.linearize(lambda x: row_call(softmax_kernel, x), x_sm)
    lin_err = None
except ValueError as e:
    lin_err = str(e)
print("linearize:", (lin_err or "成功").splitlines()[0][:80])
assert lin_err is not None and "Linearization failed" in lin_err

# JVP 规则会对 kernel 体求导,生成一个“同时算原值和切线”的新 kernel。简单情形下它能用:
t_in = jax.random.normal(jax.random.key(17), x_sm.shape)
_, t_pl = jax.jvp(lambda x: row_call(softmax_kernel, x), (x_sm,), (t_in,))
_, t_ref = jax.jvp(jax.nn.softmax, (x_sm,), (t_in,))
print("简单 kernel 的 jvp 与 jax.nn.softmax 的差:", max_err(t_pl, t_ref))
np.testing.assert_allclose(f64(t_pl), f64(t_ref), rtol=1e-5, atol=1e-6)

# 但这个规则很脆弱:只给部分输入切线、或 kernel 里用了 program_id 时,0.12.0.1 实测都会失败
xa2 = jax.random.normal(jax.random.key(18), (256, 512))
fragile = {
    "只对第一个输入求 jvp": lambda: jax.jvp(lambda u: add2d(u, xa2), (xa2,), (xa2,)),
    "kernel 里用了 program_id": lambda: jax.jvp(lambda u: matmul_acc(u, B), (A,), (A,)),
}
fails = {}
for name, fn in fragile.items():
    try:
        jax.block_until_ready(fn()); fails[name] = None
    except Exception as e:
        fails[name] = type(e).__name__
    print(f"{name:22s} → {fails[name] or '成功'}")
assert all(v is not None for v in fails.values())
pallas_call 有 JVP 规则: True | 有部分求值规则: False | 有转置规则: False
linearize: Linearization failed to produce known values for all output primals. This is typ
简单 kernel 的 jvp 与 jax.nn.softmax 的差: 2.9802322387695312e-08
只对第一个输入求 jvp           → ValueError
kernel 里用了 program_id  → AssertionError

查下来是这样的:pallas_call 有一条自动生成的 JVP 规则(对 kernel 体求导,生成一个同时算原值和切线的新 kernel),但没有部分求值(partial eval)规则,也没有转置规则。

反向模式要先把 JVP 拆成「已知的原值部分」和「线性的切线部分」,再去转置切线部分。pallas_call 的原值和切线挤在同一个 kernel 里,拆不开,原值也被当成了「未知」。所以在线性化这一步就会报 Linearization failed to produce known values for all output primals,根本走不到转置。grad、vjp、linearize 因此都会失败。

前向模式在简单情形下能用,但遇到下面两种情况会出错,不能依赖:只给部分输入切线(报 ValueError: safe_zip() ...);kernel 里用了 program_id(JVP 规则重新追踪 kernel 体时没有 grid 上下文,报 AssertionError)。

from jax.test_util import check_grads

# 1) float32 下的数值梯度检查(custom_vjp 只支持反向模式,所以 modes=["rev"])
check_grads(softmax_pl, (x_sm,), order=1, modes=["rev"])
print("check_grads(float32,rev)通过")

# 2) 在 float64 下再查一次:有限差分更准,可以发现 float32 掩盖不了的小错误
with jax.enable_x64(True):
    x64 = jnp.asarray(rng.normal(size=(16, 128)))
    assert x64.dtype == jnp.float64
    check_grads(softmax_pl, (x64,), order=1, modes=["rev"])
    assert softmax_pl(x64).dtype == jnp.float64
print("check_grads(float64,rev)通过")
check_grads(float32,rev)通过
check_grads(float64,rev)通过
# 3) 组合:jit(grad)、vmap(grad) 都可以;但前向模式(jvp)对 custom_vjp 函数不可用
g_jit = jax.jit(jax.grad(L_pl))(x_sm, w_sm)
np.testing.assert_allclose(f64(g_jit), f64(g_ref), rtol=1e-5, atol=1e-7)

xb3 = jax.random.normal(jax.random.key(15), (3, 16, 128))
wb3 = jax.random.normal(jax.random.key(16), (3, 16, 128))
g_vm = jax.vmap(jax.grad(L_pl))(xb3, wb3)
np.testing.assert_allclose(f64(g_vm), f64(jax.vmap(jax.grad(L_ref))(xb3, wb3)), rtol=1e-5, atol=1e-7)
print("jit(grad) 与 vmap(grad) 都正确")

try:
    jax.jvp(softmax_pl, (x_sm,), (x_sm,))
    err = None
except TypeError as e:
    err = e
    print("jvp:", type(e).__name__, ":", e)
assert err is not None and "custom_vjp" in str(err)
jit(grad) 与 vmap(grad) 都正确
jvp: TypeError : can't apply forward-mode autodiff (jvp) to a custom_vjp function.

§5 小结:各变换能不能直接用

变换直接作用于 pallas_call说明
jit✅块大小、grid 必须是静态的 Python 整数
vmap✅batch 维变成 grid 的第一维,块形状前面插入 Squeezed
grad / vjp❌ ValueError: Linearization failed...原语没有部分求值和转置规则,线性化阶段就失败。用 custom_vjp 包一层,反向用 Pallas 写
jvp⚠️ 简单情形可用有自动生成的 JVP 规则,但部分输入切线为零或用到 program_id 时会失败;需要可靠的前向模式时用 custom_jvp
jvp 作用于 custom_vjp 包装后的函数❌ TypeErrorcustom_vjp 只支持反向模式
jit(grad)、vmap(grad)✅(包了 custom_vjp 之后)反向 kernel 也会被 vmap 自动加一维 grid

🔍 补充

⚪ 选读:关心残差取舍、vmap 代价和 check_grads 精度问题时

  • 自动 JVP 规则生成的切线 kernel 仍然是一个 Pallas kernel:原值和切线在同一个 kernel 里计算,没有额外的主存往返。这也说明 kernel 体里的操作必须是 JAX 能求导的;kernel 里用了 program_id 时,JVP 规则重新追踪 kernel 体会因缺少 grid 上下文而报 AssertionError(0.12.0.1 实测)。
  • 残差的取舍:softmax 反向只需要 $y$,所以存 $y$ 而不存 $x$。LayerNorm 反向通常存 $\mu$ 和 $1/\sigma$(每行两个标量),而不是中心化后的整个矩阵。这与 进阶/04 课程练习 2 里「保存还是重算」的取舍是同一个问题。FlashAttention 的反向除了输入 Q、K、V 和输出 O,只额外保存每行的 logsumexp(§4.5),注意力概率在反向时重算,$L\times L$ 矩阵从不保存。
  • vmap 的代价:batching 规则只是把 batch 维加进 grid,块大小不变。如果原来的块很小,加上 batch 维之后 program 数量会成倍增加。在 GPU/TPU 上,有时手写一个「原生带 batch 维」的 kernel(让块覆盖多个 batch 元素)会更快。
  • kernel 里别把精度写死:softmax_kernel 最初写的是 x.astype(jnp.float32),float32 下一切正常,但 float64 下的 check_grads 失败了(相对误差约 1e-3)。原因是 float64 输入在 kernel 里被悄悄降成了 float32,有限差分看到的是 float32 的舍入噪声。改成 jnp.promote_types(dtype, jnp.float32) 后,bf16 仍会提升到 float32,float64 则保持不变。
  • check_grads 在 float32 下要求输入别太「尖锐」:同一个 softmax 如果输入乘以 5,有限差分的截断误差就可能超出默认容差(编写本章时试过)。先在 float64 下检查(with jax.enable_x64(True):),能把「公式错」和「精度不够」区分开。

§6 GPU/TPU 上的注意事项

⚠️ 本节所有 kernel 都未在本机执行(本机只有 CPU)。能在本机做的只有两件事:① 交叉 lowering 到 TPU,检查块形状规则;② 用 pltpu.get_tpu_info_for_chip 读出各代 TPU 的硬件参数,做算术估算。这两部分代码会实际运行,并用 assert 验证。

6.1 内存层级与块大小

🟢 必读

慢而大快而小(kernel 实际计算的地方)标量/控制
TPUHBMVMEM(向量内存,16~128 MiB,见 §6.3 的硬件表)SMEM(标量内存)
GPUHBM(GMEM)SMEM(共享内存,每个 SM 约百 KiB 级)+ 寄存器—

BlockSpec 描述的就是「每个 program 要把哪一块从 HBM 搬进片上存储」。Pallas 会自动做流水线(双缓冲):在计算第 $n$ 块的同时,DMA 引擎已经在搬第 $n+1$ 块。如果相邻两个 program 用的是同一个输入块,第二次搬运会被省掉。因此,grid 的顺序同时决定了正确性(§4.4)和数据复用。

TPU 的硬性约束:块形状的最后两维必须分别能被 8 和 128 整除,或者等于整个数组对应维的大小。这来自 TPU 向量寄存器的 8(sublane)× 128(lane)布局。这条规则可以在本机通过交叉 lowering 验证:

def tpu_lowers(block, shape=(256, 512), dtype=jnp.float32):
    """只做 TPU lowering(不执行),返回 (是否成功, 错误信息)"""
    gi = shape[0] // block[0] if shape[0] % block[0] == 0 else 1
    gj = shape[1] // block[1] if shape[1] % block[1] == 0 else 1
    spec = pl.BlockSpec(block, lambda i, j: (i, j))
    f = pl.pallas_call(add_kernel, out_shape=jax.ShapeDtypeStruct(shape, dtype), grid=(gi, gj),
                       in_specs=[spec, spec], out_specs=spec)            # 注意:不开 interpret
    s = jax.ShapeDtypeStruct(shape, dtype)
    try:
        txt = jax.jit(f).trace(s, s).lower(lowering_platforms=("tpu",)).as_text()
        return "tpu_custom_call" in txt, ""
    except ValueError as e:
        return False, str(e).split(".")[0]

cases = {(8, 128): True, (64, 128): True, (256, 512): True,      # 合规:8 和 128 的倍数 / 满维
         (4, 128): False, (64, 100): False, (8, 64): False}       # 违规
for blk, expect in cases.items():
    ok, msg = tpu_lowers(blk)
    print(f"块 {str(blk):11s} → {'✅ lowering 成功' if ok else '❌ ' + msg[:95]}")
    assert ok == expect, blk

# 例外:块等于整个数组的维度时,不受 8×128 限制
ok, _ = tpu_lowers((4, 100), shape=(4, 100))
print("数组 (4, 100)、块 (4, 100)(满维)→", ok)
assert ok
块 (8, 128)    → ✅ lowering 成功
块 (64, 128)   → ✅ lowering 成功
块 (256, 512)  → ✅ lowering 成功
块 (4, 128)    → ❌ The Pallas TPU lowering currently requires that the last two dimensions of your block shape are
块 (64, 100)   → ❌ The Pallas TPU lowering currently requires that the last two dimensions of your block shape are
块 (8, 64)     → ❌ The Pallas TPU lowering currently requires that the last two dimensions of your block shape are
数组 (4, 100)、块 (4, 100)(满维)→ True

补充两点(未在本机验证):

  • 一维块有另一套规则(官方文档:等于数组长度,或是 1024 的倍数,或是不小于 128 × (32 / dtype 位宽) 的 2 的幂)。本机交叉 lowering 一维块时需要指定具体的 TPU 型号(报错提示要用 jax.sharding.use_abstract_mesh),这里不展开。
  • bf16、int8 这类窄类型会沿 sublane 方向打包。本机实测 bf16 的 (8, 128) 块能通过 lowering,但在实践中,窄类型常用更多的行(例如 bf16 用 16 的倍数),以充分利用寄存器。这是性能上的建议,不是硬性规则。

6.2 compiler_params:后端专属参数

⚪ 选读:真正在 GPU/TPU 上配置编译参数时

pallas_call(compiler_params=...) 接收后端专属的 dataclass。下面在本机只构造对象、只做 lowering,用来确认 0.12 中的参数名:

# 构造各后端的 CompilerParams(只检查参数名,不执行 kernel)
cp_tpu = pltpu.CompilerParams(dimension_semantics=("parallel", "parallel", "arbitrary"),
                              vmem_limit_bytes=64 * 1024 * 1024)
cp_triton = pltriton.CompilerParams(num_warps=4, num_stages=3)
cp_mgpu = plgpu.CompilerParams(max_concurrent_steps=2)
for cp in (cp_tpu, cp_triton, cp_mgpu):
    print(type(cp).__module__.split(".")[-2], "→", cp)
assert cp_tpu.dimension_semantics == ("parallel", "parallel", "arbitrary")
assert cp_triton.num_warps == 4 and cp_triton.num_stages == 3
assert str(pltpu.PARALLEL) == "GridDimensionSemantics.PARALLEL"   # 也可以用枚举 pltpu.PARALLEL / pltpu.ARBITRARY

# 把 §4.1′ 的 bf16 matmul 加上 TPU 编译参数,交叉 lowering 到 TPU(只 lowering,未执行)
def mm_bf16_kernel(a_ref, b_ref, o_ref, acc_ref):
    k = pl.program_id(2)
    @pl.when(k == 0)
    def _init():
        acc_ref[...] = jnp.zeros_like(acc_ref)
    acc_ref[...] += jnp.dot(a_ref[...], b_ref[...], preferred_element_type=jnp.float32)
    @pl.when(k == pl.num_programs(2) - 1)
    def _store():
        o_ref[...] = acc_ref[...].astype(o_ref.dtype)

Mt = Kt = Nt = 2048; bm = bk = bn = 512
mm_tpu = pl.pallas_call(mm_bf16_kernel, out_shape=jax.ShapeDtypeStruct((Mt, Nt), jnp.bfloat16),
                        grid=(Mt // bm, Nt // bn, Kt // bk),
                        in_specs=[pl.BlockSpec((bm, bk), lambda i, j, k: (i, k)),
                                  pl.BlockSpec((bk, bn), lambda i, j, k: (k, j))],
                        out_specs=pl.BlockSpec((bm, bn), lambda i, j, k: (i, j)),
                        scratch_shapes=[pltpu.VMEM((bm, bn), jnp.float32)],
                        compiler_params=cp_tpu)
sd = jax.ShapeDtypeStruct((Mt, Kt), jnp.bfloat16)
txt = jax.jit(mm_tpu).trace(sd, sd).lower(lowering_platforms=("tpu",)).as_text()
assert "tpu_custom_call" in txt
print("带 dimension_semantics / vmem_limit_bytes 的 bf16 matmul:TPU lowering 成功(未执行)")
mosaic → CompilerParams(dimension_semantics=('parallel', 'parallel', 'arbitrary'), allow_input_fusion=None, vmem_limit_bytes=67108864, collective_id=None, has_side_effects=False, flags=None, internal_scratch_in_bytes=None, serialization_format=1, disable_bounds_checks=False, disable_semaphore_checks=False, skip_device_barrier=False, allow_collective_id_without_custom_barrier=False, shape_invariant_numerics=True, use_tc_tiling_on_sc=None, needs_layout_passes=True, fuse_transposed_lhs_in_matmul=False, opt_level=None)
triton → CompilerParams(num_warps=4, num_stages=3)
mosaic_gpu → CompilerParams(approx_math=False, dimension_semantics=None, max_concurrent_steps=2, unsafe_no_auto_barriers=False, reduction_scratch_bytes=2048, profile_space=0, profile_dir='', profile_trace_scope=<TraceScope.WARPGROUP: 'warpgroup'>, profile_bounds_check=False, lowering_semantics=<LoweringSemantics.Warpgroup: 2>, skip_device_barrier=False, is_multi_process=False)
带 dimension_semantics / vmem_limit_bytes 的 bf16 matmul:TPU lowering 成功(未执行)

各参数的含义:

  • TPU pltpu.CompilerParams
    • dimension_semantics:每个 grid 维一项,通常写成「parallel 在前、arbitrary 在后」。只有 parallel 维可以分给 megacore 的多个 TensorCore;归约维必须是 arbitrary。
    • vmem_limit_bytes:提高编译器允许使用的 VMEM 上限。默认的 scoped 上限通常明显小于物理容量(具体数值随芯片代际和版本而变),块很大或缓冲很深时,需要手动调高。
  • GPU / Triton pltriton.CompilerParams(num_warps, num_stages):每个 program 用多少个 warp,软件流水线有几级。
  • GPU / Mosaic GPU plgpu.CompilerParams(...):max_concurrent_steps(流水线深度)、dimension_semantics 等。Mosaic GPU 的 kernel 写法(plgpu.BlockSpec、warpgroup、TMA)与 TPU 差别较大,见官方 GPU 文档。

GPU 上的典型调用如下(未在本机执行):

# 未在本机执行:GPU(Triton 后端)上的分块 matmul
out = pl.pallas_call(
    kernel, out_shape=jax.ShapeDtypeStruct((M, N), jnp.float16),
    grid=(pl.cdiv(M, 128), pl.cdiv(N, 128)),
    in_specs=[pl.BlockSpec((128, K), lambda i, j: (i, 0)),
              pl.BlockSpec((K, 128), lambda i, j: (0, j))],
    out_specs=pl.BlockSpec((128, 128), lambda i, j: (i, j)),
    compiler_params=pltriton.CompilerParams(num_warps=8, num_stages=3),
)(a, b)
# Triton 后端要求块的每一维是 2 的幂;GPU 上各 program 并行执行,没有顺序保证,
# 因此“沿 grid 某一维累加到同一个输出块”这种 TPU 写法在 GPU 上通常改成 kernel 内的 fori_loop。

6.3 调优思路:块大小、算术强度与 VMEM 预算

⚪ 选读:做性能调优、估算块大小和 VMEM 预算时

用 0.12 自带的 TPU 硬件表做估算(纯算术,本机可运行;数值来自 pltpu.get_tpu_info_for_chip):

print(f"{'型号':6s}{'lanes×sublanes':>16s}{'VMEM(MiB)':>11s}{'HBM 带宽(GB/s)':>16s}{'bf16 TFLOP/s':>14s}{'屋脊点 FLOP/B':>15s}")
infos = {}
for cv in [pltpu.ChipVersion.TPU_V4, pltpu.ChipVersion.TPU_V5E, pltpu.ChipVersion.TPU_V5P, pltpu.ChipVersion.TPU_V6E]:
    info = pltpu.get_tpu_info_for_chip(cv, 1)
    infos[cv.value] = info
    ridge = info.bf16_ops_per_second / info.mem_bw_bytes_per_second
    print(f"{cv.value:6s}{f'{info.num_lanes}×{info.num_sublanes}':>16s}{info.vmem_capacity_bytes / 2**20:>11.0f}"
          f"{info.mem_bw_bytes_per_second / 1e9:>16.0f}{info.bf16_ops_per_second / 1e12:>14.0f}{ridge:>15.0f}")
    assert info.num_lanes == 128 and info.num_sublanes == 8        # 8×128 规则的硬件来源
assert infos["v6e"].mxu_column_size == 256 and all(infos[v].mxu_column_size == 128 for v in ("v4", "v5e", "v5p"))
型号      lanes×sublanes  VMEM(MiB)    HBM 带宽(GB/s)  bf16 TFLOP/s     屋脊点 FLOP/B
v4               128×8         16             615           138            224
v5e              128×8        128             820           197            240
v5p              128×8         64            1230           230            187
v6e              128×8        128            1640           920            561
def tile_stats(bm, bk, bn, in_bytes=2, acc_bytes=4, out_bytes=2, buffers=2):
    """分块 matmul 一个 program 的算术强度与 VMEM 占用(双缓冲)"""
    flops = 2 * bm * bk * bn
    hbm_bytes = (bm * bk + bk * bn) * in_bytes          # 每个 K 步从 HBM 搬入的字节(忽略输出写回)
    vmem = buffers * (bm * bk + bk * bn) * in_bytes + buffers * bm * bn * out_bytes + bm * bn * acc_bytes
    return flops / hbm_bytes, vmem

v5e = infos["v5e"]
ridge = v5e.bf16_ops_per_second / v5e.mem_bw_bytes_per_second
print(f"v5e 屋脊点 ≈ {ridge:.0f} FLOP/B:算术强度低于它就是带宽受限\n")
print(f"{'块 (bm,bk,bn)':18s}{'算术强度 FLOP/B':>16s}{'VMEM 占用 (MiB)':>17s}{'判断':>10s}")
for t in [(128, 128, 128), (256, 256, 256), (512, 512, 512), (1024, 512, 1024)]:
    ai, vm = tile_stats(*t)
    verdict = "计算受限" if ai >= ridge else "带宽受限"
    print(f"{str(t):18s}{ai:>16.0f}{vm / 2**20:>17.1f}{verdict:>10s}")
    assert abs(ai - t[0] * t[2] / (t[0] + t[2])) < 1e-9          # bf16 输入时,强度 = bm·bn/(bm+bn),与 bk 无关
    assert vm < v5e.vmem_capacity_bytes
v5e 屋脊点 ≈ 240 FLOP/B:算术强度低于它就是带宽受限

块 (bm,bk,bn)           算术强度 FLOP/B    VMEM 占用 (MiB)        判断
(128, 128, 128)                 64              0.2      带宽受限
(256, 256, 256)                128              1.0      带宽受限
(512, 512, 512)                256              4.0      计算受限
(1024, 512, 1024)              512             12.0      计算受限

读表:bf16 matmul 中,一个块的算术强度是 $\frac{b_m b_n}{b_m+b_n}$。128³ 的块只有 64 FLOP/B,远低于 v5e 约 240 FLOP/B 的屋脊点,会被 HBM 带宽卡住;512³ 的块达到 256,才进入计算受限区,而它的双缓冲 VMEM 占用只有约 4 MiB。这就是 TPU 上 matmul 的块常取 512 甚至更大的原因。块越大,program 越少、流水线越难填满,边界浪费也越多,所以最终还要靠实测。

调优清单(GPU/TPU 通用思路,未在本机执行):

  1. 先用屋脊模型估算:算出 kernel 的算术强度,判断它是带宽受限还是计算受限。带宽受限时,优化重点是少搬数据(融合、复用),而不是少算。
  2. 块大小:在 VMEM/SMEM 预算内尽量大。TPU 上满足 8×128 的倍数,并对齐 MXU(v6e 的 MXU 是 256 列,v4/v5e/v5p 是 128 列;上表没有打印这一列,可读 info.mxu_column_size,上面已 assert);GPU 上取 2 的幂,并兼顾占用率(occupancy)。
  3. grid 顺序与复用:让相邻的 program 共享输入块,可以省掉 DMA。归约维放最后;parallel 维放前面,方便多核切分。
  4. 精度:输入用 bf16,累加用 float32 scratch,最后一步再转回 bf16(§4.1′)。
  5. 给 XLA 提示:cost_estimate=pl.CostEstimate(flops=..., transcendentals=..., bytes_accessed=...) 能帮助 XLA 安排 kernel 周围的调度。
  6. 原地更新:input_output_aliases={0: 0} 让输出复用输入缓冲区,省掉一次分配(KV cache 更新这类场景常用)。
  7. 测量:用 jax.profiler 抓 trace(进阶/02 §2.1),并与 jnp 参考实现比较速度和正确性。如果 XLA 已经足够快,就不要写 kernel。
# CostEstimate 与 input_output_aliases 在 interpret 模式下也可以传(这里只验证参数被接受、结果正确)
def axpy_inplace_kernel(x_ref, y_ref, o_ref):       # o 与 y 共用缓冲区:o = 2x + y
    o_ref[...] = 2.0 * x_ref[...] + y_ref[...]
xa, ya = jnp.arange(16, dtype=jnp.float32), jnp.ones(16, jnp.float32)
oa = pl.pallas_call(axpy_inplace_kernel, out_shape=jax.ShapeDtypeStruct((16,), jnp.float32),
                    input_output_aliases={1: 0},                     # 第 1 个输入 ↔ 第 0 个输出
                    cost_estimate=pl.CostEstimate(flops=32, transcendentals=0, bytes_accessed=3 * 16 * 4),
                    interpret=INTERPRET)(xa, ya)
np.testing.assert_array_equal(oa, 2 * f64(xa) + f64(ya))
print("input_output_aliases + CostEstimate:结果正确", np.asarray(oa[:4]))
input_output_aliases + CostEstimate:结果正确 [1. 3. 5. 7.]

§7 实战规则清单

先判断要不要写 kernel

  1. 先用 jnp 写参考实现,看 HLO 和 profile(进阶/02)。只有当 XLA 的融合边界确实浪费了带宽(attention、长行归约、ragged/量化计算)时,才考虑 Pallas。
  2. Pallas kernel 必须和 jnp 参考实现对拍,关键数值用 NumPy float64。

写 kernel 3. kernel 只写 Ref、返回 None;读写用 x_ref[...]、x_ref[pl.ds(i, n)],0.12 中没有 pl.load/pl.store。 4. 数组一律作为输入传入,不要闭包捕获(Pallas 会报 captures constants);Python 标量可以捕获。 5. index_map 返回的是块下标,不是元素偏移。 6. 条件分支用 @pl.when(cond),不要用 Python if 判断 program_id;用 Python if 判断构建期常量是可以的。 7. grid 用 pl.cdiv 向上取整。逐元素计算不需要掩码;块内有归约时必须用 jnp.where 填入单位元(0、-inf……),matmul 的 K 维要同时掩码 A 和 B。 8. 累加器必须在第一步显式初始化。归约维放在 grid 最后,在 TPU 上声明为 arbitrary。 9. bf16 输入用 preferred_element_type=jnp.float32 计算,用 float32 scratch 累加,最后一步再转回 bf16。 10. 数值稳定:softmax 先减最大值;方差用两遍法;超长行用在线算法。

调试与测试 11. CPU 上用 interpret=True 验证数学,再用 pltpu.InterpretParams() 验证 TPU 语义(输出块重访、未初始化内存)。模拟器抛异常后调用 pltpu.reset_tpu_interpret_mode_state()。 12. interpret 模式的计时没有任何性能意义。 13. 在本机交叉 lowering 到 TPU(.trace(...).lower(lowering_platforms=("tpu",))),提前发现块形状不合规(8×128)。

与变换组合 14. jit、vmap 可以直接用;块大小必须是静态的(static_argnames 或 functools.partial)。 15. 需要求导时用 custom_vjp 包一层,反向也写成 Pallas kernel;用 check_grads(..., modes=["rev"]) 验证,最好在 jax.enable_x64(True) 下再查一次。


动手练习

练习 1:融合的 AXPY(标量也是输入) 写一个 kernel 计算 $y = \alpha x + \beta$,其中 $x$ 的长度为 1000,块大小 128(不能整除)。$\alpha$、$\beta$ 是 JAX 标量,要求以形状 (1,) 的数组作为输入传入(不能闭包捕获),所有 program 共用同一块。用 jax.jit 包装后与 NumPy float64 对拍。 思考:这里需要掩码吗?

自检接口:axpy(x, alpha, beta):x 形状 (1000,),alpha、beta 是形状为 () 的 JAX 标量(在函数内部再整理成 (1,) 传给 kernel),返回与 x 同形状的结果;要能被 jax.jit 包装。

练习 2:按列求和 对 x 形状 (300, 256) 计算每列的和(输出 (256,))。行块 br=128、列块 bc=128。要求:① 归约维放在 grid 最后;② 对行做掩码;③ 分别用 interpret=True 和 pltpu.InterpretParams() 运行,并与 float64 对拍。

自检接口:colsum(x, interpret=...):x 形状 (300, 256),interpret 可以传 True 或 pltpu.InterpretParams(),返回形状 (256,) 的列和。

练习 3:RMSNorm kernel 实现 $\mathrm{RMSNorm}(x) = \dfrac{x}{\sqrt{\mathrm{mean}(x^2) + \epsilon}} \odot w$($\epsilon = 10^{-6}$),x 形状 (50, 384),每块 8 行,w 形状 (1, 384) 作为输入传入。与 float64 对拍,并用 jax.vmap 在形状为 (4, 50, 384) 的 batch 上验证。(进阶/05 §5.10 用 C++ FFI 实现过同一个算子,可以对比两种写法。)

自检接口:rmsnorm_pl(x, w):x 形状 (50, 384),w 形状 (1, 384),返回与 x 同形状的结果;要能被 jax.vmap(rmsnorm_pl, in_axes=(0, None)) 批处理。

练习 4:给 SiLU 补上梯度 $\mathrm{silu}(x) = x\,\sigma(x)$,导数为 $\sigma(x)\,\big(1 + x(1-\sigma(x))\big)$。用两个 Pallas kernel(前向、反向)加上 custom_vjp,实现可微的 silu_pl。输入形状 (64, 256),块 (16, 128)。用 check_grads(float32 与 float64 都要)验证,并与 jax.grad(jax.nn.silu) 的结果比较。

自检接口:silu_pl(x):输入形状 (64, 256),float32 和 float64 都支持;jax.grad / jax.vjp 可以对它求导,前向和反向都由 Pallas kernel 计算。

# ✍️ 作答区:练习 1
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 1
try:
    axpy
except NameError as e:
    print(f"⏳ 练习 1 尚未作答:{e}")
else:
    _x = jax.random.normal(jax.random.key(9001), (1000,))
    _f = jax.jit(axpy)
    for _a, _b in [(1.5, 0.25), (-3.0, 2.0)]:          # 换两组 α、β,确认它们是运行时输入
        _y = _f(_x, jnp.float32(_a), jnp.float32(_b))
        assert _y.shape == (1000,), f"输出形状应为 (1000,),实际是 {_y.shape}"
        np.testing.assert_allclose(f64(_y), _a * f64(_x) + _b, rtol=1e-6, atol=1e-6,
                                   err_msg=f"α={_a}、β={_b} 时结果与 float64 参考值不一致")
    assert "pallas_call" in str(jax.make_jaxpr(axpy)(_x, jnp.float32(1.0), jnp.float32(0.0))), \
        "axpy 中没有 pallas_call:本题要求用 Pallas kernel 实现"
    print("✅ 练习 1 自检通过")
⏳ 练习 1 尚未作答:name 'axpy' is not defined
# ✍️ 作答区:练习 2
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 2
try:
    colsum
except NameError as e:
    print(f"⏳ 练习 2 尚未作答:{e}")
else:
    _x = jax.random.normal(jax.random.key(9002), (300, 256)) + 1.0   # 加偏移:漏掉掩码或累加器初始化时误差会很明显
    _ref = f64(_x).sum(0)
    for _interp in (True, pltpu.InterpretParams()):
        _r = colsum(_x, interpret=_interp)
        assert _r.shape == (256,), f"输出形状应为 (256,),实际是 {_r.shape}"
        np.testing.assert_allclose(f64(_r), _ref, rtol=1e-5, atol=1e-3,
                                   err_msg=f"interpret={type(_interp).__name__} 时列和不正确(检查行掩码和累加器初始化)")
    assert "pallas_call" in str(jax.make_jaxpr(colsum)(_x)), "colsum 中没有 pallas_call:本题要求用 Pallas kernel 实现"
    print("✅ 练习 2 自检通过")
⏳ 练习 2 尚未作答:name 'colsum' is not defined
# ✍️ 作答区:练习 3
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 3
try:
    rmsnorm_pl
except NameError as e:
    print(f"⏳ 练习 3 尚未作答:{e}")
else:
    def _rms_ref64(x, w, eps=1e-6):
        x = f64(x)
        return x / np.sqrt((x * x).mean(-1, keepdims=True) + eps) * f64(w)
    _k1, _k2, _k3 = jax.random.split(jax.random.key(9003), 3)
    _x = 3.0 * jax.random.normal(_k1, (50, 384))
    _w = jax.random.uniform(_k2, (1, 384), minval=0.5, maxval=1.5)
    _o = rmsnorm_pl(_x, _w)
    assert _o.shape == (50, 384), f"输出形状应为 (50, 384),实际是 {_o.shape}"
    np.testing.assert_allclose(f64(_o), _rms_ref64(_x, _w), rtol=1e-5, atol=1e-6,
                               err_msg="单个输入的 RMSNorm 结果与 float64 参考值不一致(检查 ε、均值的轴、尾块)")
    _xb = jax.random.normal(_k3, (4, 50, 384))
    _ob = jax.vmap(rmsnorm_pl, in_axes=(0, None))(_xb, _w)
    np.testing.assert_allclose(f64(_ob), np.stack([_rms_ref64(t, _w) for t in _xb]), rtol=1e-5, atol=1e-6,
                               err_msg="vmap 后的 batch 结果不正确")
    assert "pallas_call" in str(jax.make_jaxpr(rmsnorm_pl)(_x, _w)), "rmsnorm_pl 中没有 pallas_call:本题要求用 Pallas kernel 实现"
    print("✅ 练习 3 自检通过")
⏳ 练习 3 尚未作答:name 'rmsnorm_pl' is not defined
# ✍️ 作答区:练习 4
# 在这里写你的代码,然后运行下面的「🧪 自检」。参考答案在本章末尾的「附录:练习参考答案」。
# 🧪 自检:练习 4
try:
    silu_pl
except NameError as e:
    print(f"⏳ 练习 4 尚未作答:{e}")
else:
    _x = 3.0 * jax.random.normal(jax.random.key(9004), (64, 256))
    _x64 = f64(_x)
    _sig = 1.0 / (1.0 + np.exp(-_x64))
    np.testing.assert_allclose(f64(silu_pl(_x)), _x64 * _sig, rtol=1e-5, atol=1e-6, err_msg="前向结果不正确")
    _ct = jax.random.normal(jax.random.key(9005), (64, 256))    # 随机余切向量,不只是 sum() 的全 1
    try:
        _dx = jax.vjp(silu_pl, _x)[1](_ct)[0]
    except Exception as e:
        raise AssertionError(f"对 silu_pl 求 VJP 失败({type(e).__name__}):检查自定义梯度规则是否定义好") from e
    np.testing.assert_allclose(f64(_dx), f64(_ct) * _sig * (1 + _x64 * (1 - _sig)), rtol=1e-5, atol=1e-5,
                               err_msg="反向(VJP)结果不正确")
    _n_pl = str(jax.make_jaxpr(jax.grad(lambda x: silu_pl(x).sum()))(_x)).count("pallas_call")
    assert _n_pl >= 2, f"求梯度的计算图里只有 {_n_pl} 个 pallas_call:前向和反向都应该由 Pallas kernel 计算"
    with jax.enable_x64(True):
        try:
            check_grads(silu_pl, (jnp.asarray(np.random.default_rng(9006).normal(size=(64, 256)) * 2),),
                        order=1, modes=["rev"])
        except AssertionError as e:
            raise AssertionError(f"float64 下 check_grads 未通过:{str(e)[:300]}") from None
    print("✅ 练习 4 自检通过")
⏳ 练习 4 尚未作答:name 'silu_pl' is not defined

测验

1. 一个 pallas_call 的 grid 是 (4, 6),某输入的 BlockSpec((32, 64), lambda i, j: (j, i))。第 (2, 5) 个 program 读到的是该输入的哪个元素区域?

答案

index_map 返回块下标 (5, 2),乘以块形状得到起点 (160, 128),所以区域是 [160:192, 128:192]。index_map 返回的永远是块下标,不是元素偏移(§2.2)。

2. 在只有 CPU 的机器上调用不带 interpret 的 pallas_call 会怎样?interpret=True 和 interpret=pltpu.InterpretParams() 有什么区别?

答案

会报 ValueError: Only interpret mode is supported on CPU backend.。interpret=True 是通用解释器,把 grid 变成 jit 编译的循环,只验证数学。pltpu.InterpretParams() 是 TPU 模拟器,额外模拟 VMEM/HBM 语义,能发现输出块的非连续重访、未初始化内存等 TPU 才会出现的错误。两者都只能验证正确性,没有性能意义(§0、§3)。

3. 长度为 1000 的向量用块大小 128 做 jnp.sum,不加掩码会得到什么?为什么逐元素的 x * 2 不需要掩码?

答案

最后一个块有 24 个越界元素,读到的是未定义的垃圾(interpret 模式下是 NaN),求和结果被污染(interpret 下得到 NaN)。逐元素计算中,越界位置的结果在写出时被丢弃,不会影响有效元素;而归约会把垃圾混进有效结果(§3.2、§4.3)。

4. 分块 matmul 的 grid 为什么写成 (M/bm, N/bn, K/bk) 而不是 (K/bk, M/bm, N/bn)?在 interpret=True 下写错会被发现吗?

答案

输出块的下标与 k 无关。只有把 k 放在最后,同一个输出块的所有访问才是连续的,输出 Ref 或 scratch 才能当累加器。在 TPU 上,输出块下标一变就会写回 HBM 并换入新块,并且不会读回旧值,非连续访问会丢失部分和。interpret=True 可能碰巧算对,pltpu.InterpretParams() 会报 Revisited block 错误(§4.4 实测)。

5. 对一个包含 pallas_call 的函数直接调用 jax.grad 会怎样?正确的做法是什么?jax.jvp 呢?

答案

pallas_call 只有一条自动生成的 JVP 规则,没有部分求值和转置规则,grad 在线性化阶段就会报 ValueError: Linearization failed...。做法是用 jax.custom_vjp 包一层,反向也用 Pallas kernel 实现,然后用 check_grads(..., modes=["rev"]) 验证。直接 jvp 在简单 kernel 上能用,但部分输入切线为零或用到 program_id 时会失败;包了 custom_vjp 之后则完全不能做 jvp(TypeError)。需要可靠的前向模式时,改用 custom_jvp(§5.3)。

6. 在 TPU 上,(256, 512) 的 float32 数组用块 (4, 128) 和 (64, 100) 各会怎样?bf16 matmul 用 128³ 和 512³ 的块,哪个更可能被带宽卡住,为什么?

答案

两种块都会在 lowering 阶段报错:最后两维必须能分别被 8 和 128 整除,或等于数组的整个维度。本机交叉 lowering 已经验证过(§6.1)。bf16 matmul 一个块的算术强度约为 $b_m b_n/(b_m+b_n)$:128³ 为 64 FLOP/B,512³ 为 256 FLOP/B。v5e 的屋脊点约为 240 FLOP/B,所以 128³ 的块是带宽受限的(§6.3)。


本章小结

  1. 定位:XLA 的融合是启发式的,在「归约→广播」、重算子和 ragged 计算处会留下内核边界(进阶/02 §2.6)。Pallas 让你用 JAX 风格的 Python 手写内核:它在 jaxpr 中是 pallas_call 原语,在 TPU 上由 Mosaic 编译,在 GPU 上由 Triton 或 Mosaic GPU 编译,在 CPU 上只能 interpret。与 C++ FFI(进阶/05)相比,它不需要额外的编译工具链,jit/vmap 也可以直接用。
  2. 编程模型:kernel 只读写 Ref;grid 决定执行多少个 program;BlockSpec(block_shape, index_map) 决定每个 program 搬哪一块,index_map 返回块下标;pl.program_id / pl.num_programs 用来定位;@pl.when 做条件副作用。
  3. interpret 模式(0.12 实测):interpret=True 是通用解释器;interpret=pltpu.InterpretParams() 是 TPU 模拟器(注意它在 pltpu 里);plgpu.InterpretGPUParams() 是 Mosaic GPU 模拟器。三者都只能验证正确性。越界部分会被填成 NaN,未初始化的输出块也是 NaN。
  4. 进阶 kernel 的三条铁律:累加器先初始化;归约维放 grid 最后;块内有归约就必须掩码(用 jnp.where 填单位元,matmul 的 A、B 两边都要掩码)。数值稳定的写法(减最大值、两遍法方差、在线 logsumexp)在 kernel 里同样适用。
  5. 变换:jit、vmap 直接可用(vmap 会给 grid 加一维)。pallas_call 有自动 JVP 规则,但没有部分求值和转置规则(线性化阶段即失败),所以 grad 需要 custom_vjp 加上 Pallas 反向 kernel,并用 check_grads(modes=["rev"]) 验证;直接 jvp 只在简单情形下可用。
  6. 硬件:TPU 的块要满足 8×128 规则(本机可以交叉 lowering 验证);compiler_params 是后端专属的;调优的核心是在 VMEM/SMEM 预算内选尽量大的块,让算术强度越过屋脊点,并安排好 grid 顺序以复用数据。

速查表

API(JAX 0.12)作用
from jax.experimental import pallas as plPallas 主模块
from jax.experimental.pallas import tpu as pltpuTPU(Mosaic)专用:VMEM、SMEM、CompilerParams、InterpretParams
from jax.experimental.pallas import triton as pltriton / mosaic_gpu as plgpuGPU 的两个后端
pl.pallas_call(kernel, out_shape, grid=, in_specs=, out_specs=, scratch_shapes=, compiler_params=, interpret=)构造 kernel 调用,返回普通可调用对象
jax.ShapeDtypeStruct(shape, dtype)声明输出形状(out_shape)
x_ref[...] / o_ref[...] = v / o_ref[...] += vRef 的读 / 写 / 读-改-写
x_ref[pl.ds(start, size)]动态切片
pl.BlockSpec(block_shape, index_map)块形状 + grid 下标 → 块下标
None 或 pl.squeezed 作为块的一维这一维大小为 1,在 kernel 里被挤掉
pl.program_id(axis) / pl.num_programs(axis)当前 program 的下标 / 该维的 program 总数
@pl.when(cond)条件为真时执行(用于初始化、最后一步写出)
pl.cdiv(a, b)向上取整除法,计算 grid 大小
lax.broadcasted_iota(jnp.int32, shape, dim) + jnp.where构造边界掩码
jnp.dot(a, b, preferred_element_type=jnp.float32)块内矩阵乘,指定累加精度
scratch_shapes=[pltpu.VMEM(shape, dtype)]片上暂存区(跨 program 保留),kernel 多接一个 Ref
interpret=TrueCPU 上的通用解释器(只验证正确性)
interpret=pltpu.InterpretParams(...)TPU 模拟器:输出块重访检查、uninitialized_memory、detect_races、grid_point_recorder
pltpu.reset_tpu_interpret_mode_state()模拟器抛异常后重置状态
pl.debug_print(fmt, *args)kernel 内打印
pltpu.CompilerParams(dimension_semantics=("parallel", "arbitrary"), vmem_limit_bytes=)TPU 编译参数
pltriton.CompilerParams(num_warps=, num_stages=)GPU(Triton)编译参数
pl.CostEstimate(flops=, transcendentals=, bytes_accessed=)给 XLA 的开销提示
input_output_aliases={in_idx: out_idx}输出复用输入缓冲区
jax.jit(f).trace(*sds).lower(lowering_platforms=("tpu",))在 CPU 上交叉 lowering 到 TPU(检查块形状,不执行)
pltpu.get_tpu_info_for_chip(pltpu.ChipVersion.TPU_V5E, 1)各代 TPU 的 VMEM、带宽、算力参数
jax.custom_vjp + f.defvjp(fwd, bwd)给 Pallas kernel 补反向模式导数
jax.test_util.check_grads(f, args, order=1, modes=["rev"])数值校验梯度
with jax.enable_x64(True):临时启用 float64(做更严格的梯度检查)

附录:练习参考答案

先在正文的「✍️ 作答区」自己写,用「🧪 自检」检查;自检通过或者实在卡住了,再来这里对照。 下面的答案按顺序运行,会覆盖你在作答区定义的同名函数。

练习 1

题目见正文「动手练习」。

# 参考答案:练习 1
def axpy(x, alpha, beta, block=128):
    n = x.shape[0]
    def kernel(x_ref, a_ref, b_ref, o_ref):
        o_ref[...] = a_ref[0] * x_ref[...] + b_ref[0]        # a_ref[0] 读出标量
    scalar_spec = pl.BlockSpec((1,), lambda i: (0,))         # 所有 program 都取第 0 块
    vec_spec = pl.BlockSpec((block,), lambda i: (i,))
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct(x.shape, x.dtype),
                          grid=(pl.cdiv(n, block),),
                          in_specs=[vec_spec, scalar_spec, scalar_spec], out_specs=vec_spec,
                          interpret=INTERPRET)(x, alpha.reshape(1), beta.reshape(1))

x_e1 = jax.random.normal(jax.random.key(100), (1000,))
alpha, beta = jnp.float32(2.5), jnp.float32(-1.0)
y_e1 = jax.jit(axpy)(x_e1, alpha, beta)
np.testing.assert_allclose(f64(y_e1), 2.5 * f64(x_e1) - 1.0, rtol=1e-6, atol=1e-6)
print("练习 1 正确。不需要掩码:逐元素计算中,越界部分在写出时会被丢弃。")
练习 1 正确。不需要掩码:逐元素计算中,越界部分在写出时会被丢弃。

练习 2

题目见正文「动手练习」。

# 参考答案:练习 2
def colsum(x, br=128, bc=128, interpret=INTERPRET):
    R, Cc = x.shape
    def kernel(x_ref, o_ref):
        i = pl.program_id(1)                                  # 归约维(行块)是 grid 的最后一维
        @pl.when(i == 0)
        def _init():
            o_ref[...] = jnp.zeros_like(o_ref)
        row = i * br + lax.broadcasted_iota(jnp.int32, x_ref.shape, 0)
        o_ref[...] += jnp.sum(jnp.where(row < R, x_ref[...], 0.0), axis=0)
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct((Cc,), jnp.float32),
                          grid=(pl.cdiv(Cc, bc), pl.cdiv(R, br)),          # (列块, 行块)
                          in_specs=[pl.BlockSpec((br, bc), lambda j, i: (i, j))],
                          out_specs=pl.BlockSpec((bc,), lambda j, i: (j,)),
                          compiler_params=pltpu.CompilerParams(dimension_semantics=("parallel", "arbitrary")),
                          interpret=interpret)(x)

x_e2 = jax.random.normal(jax.random.key(101), (300, 256))
ref_e2 = f64(x_e2).sum(0)
for interp in (True, pltpu.InterpretParams()):
    r = colsum(x_e2, interpret=interp)
    np.testing.assert_allclose(f64(r), ref_e2, rtol=1e-5, atol=1e-4)
    print(f"练习 2({type(interp).__name__})正确,最大误差 {max_err(r, ref_e2):.2e}")
练习 2(bool)正确,最大误差 5.05e-06
练习 2(InterpretParams)正确,最大误差 5.05e-06

练习 3

题目见正文「动手练习」。

# 参考答案:练习 3
RMS_EPS = 1e-6
def rmsnorm_kernel(x_ref, w_ref, o_ref):
    x = x_ref[...].astype(compute_dtype(x_ref.dtype))
    ms = jnp.mean(x * x, axis=-1, keepdims=True)
    o_ref[...] = (x * lax.rsqrt(ms + RMS_EPS) * w_ref[...]).astype(o_ref.dtype)

def rmsnorm_pl(x, w):
    return row_call(rmsnorm_kernel, x, w, br=8)               # 复用 §4.2 的按行调用工具

def rmsnorm_ref64(x, w):
    x = f64(x); return x / np.sqrt((x * x).mean(-1, keepdims=True) + RMS_EPS) * f64(w)

kx, kw = jax.random.split(jax.random.key(102))
x_e3 = jax.random.normal(kx, (50, 384))
w_e3 = 1.0 + 0.1 * jax.random.normal(kw, (1, 384))
np.testing.assert_allclose(f64(rmsnorm_pl(x_e3, w_e3)), rmsnorm_ref64(x_e3, w_e3), rtol=1e-5, atol=1e-6)

xb_e3 = jax.random.normal(jax.random.key(103), (4, 50, 384))
rb = jax.vmap(rmsnorm_pl, in_axes=(0, None))(xb_e3, w_e3)
np.testing.assert_allclose(f64(rb), np.stack([rmsnorm_ref64(t, w_e3) for t in xb_e3]), rtol=1e-5, atol=1e-6)
print("练习 3 正确:单个输入和 vmap 后的 batch 都与 float64 一致", rb.shape)
练习 3 正确:单个输入和 vmap 后的 batch 都与 float64 一致 (4, 50, 384)

练习 4

题目见正文「动手练习」。

# 参考答案:练习 4
BLK = (16, 128)
def _tiled(kernel, *args):
    """二维分块的逐元素调用(输出形状和 dtype 与第一个参数相同)"""
    R, Cc = args[0].shape
    spec = pl.BlockSpec(BLK, lambda i, j: (i, j))
    return pl.pallas_call(kernel, out_shape=jax.ShapeDtypeStruct(args[0].shape, args[0].dtype),
                          grid=(pl.cdiv(R, BLK[0]), pl.cdiv(Cc, BLK[1])),
                          in_specs=[spec] * len(args), out_specs=spec, interpret=INTERPRET)(*args)

def silu_fwd_kernel(x_ref, o_ref):
    x = x_ref[...]
    o_ref[...] = x * jax.nn.sigmoid(x)

def silu_bwd_kernel(x_ref, g_ref, dx_ref):
    x = x_ref[...]
    s = jax.nn.sigmoid(x)
    dx_ref[...] = g_ref[...] * s * (1.0 + x * (1.0 - s))

@jax.custom_vjp
def silu_pl(x):
    return _tiled(silu_fwd_kernel, x)
silu_pl.defvjp(lambda x: (_tiled(silu_fwd_kernel, x), x),       # 残差存 x(反向需要重算 sigmoid)
               lambda x, g: (_tiled(silu_bwd_kernel, x, g),))

x_e4 = jax.random.normal(jax.random.key(104), (64, 256)) * 2
np.testing.assert_allclose(f64(silu_pl(x_e4)), f64(x_e4) / (1 + np.exp(-f64(x_e4))), rtol=1e-5, atol=1e-6)
g_e4 = jax.grad(lambda x: silu_pl(x).sum())(x_e4)
np.testing.assert_allclose(f64(g_e4), f64(jax.grad(lambda x: jax.nn.silu(x).sum())(x_e4)), rtol=1e-5, atol=1e-6)
# float32 的有限差分误差会随元素个数累积,所以 float32 只检查一个块大小的子数组;完整形状放到 float64 下检查
check_grads(silu_pl, (x_e4[:16, :128] / 2,), order=1, modes=["rev"])
with jax.enable_x64(True):
    check_grads(silu_pl, (jnp.asarray(rng.normal(size=(64, 256)) * 2),), order=1, modes=["rev"])
print("练习 4 正确:前向、梯度对拍,float32 / float64 的 check_grads 都通过")
练习 4 正确:前向、梯度对拍,float32 / float64 的 check_grads 都通过