第 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 的探针。
学习目标¶
- 说清楚 XLA 自动融合的边界在哪里,Pallas 在 JAX 栈里处于什么位置,以及它和 Triton、Mosaic(TPU)、Mosaic GPU 的关系。
- 掌握 Pallas 编程模型:
pl.pallas_call、Ref 读写(x_ref[...])、out_shape、grid、BlockSpec(block_shape, index_map)、pl.program_id/pl.num_programs。 - 在只有 CPU 的机器上用 interpret 模式运行和调试 kernel,知道
interpret=True与pltpu.InterpretParams()的区别,也知道它们只能验证正确性、没有性能意义。 - 能写出常见的进阶 kernel:沿 K 维累加的分块 matmul(
@pl.when初始化)、数值稳定的按行 softmax / LayerNorm、形状不能整除时的掩码处理、跨 grid 的归约,以及在线 logsumexp(FlashAttention 的核心技巧)。 - 实测
pallas_call与jit、vmap、grad的组合方式:前两者直接可用,grad需要用custom_vjp包一层,再用 Pallas 写反向 kernel,最后用check_grads验证。 - 了解 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 | ✅ |
| §3 | interpret 模式详解(CPU) | ✅ |
| §4 | 进阶 kernel:累加 matmul、softmax/LayerNorm、掩码、归约、在线 logsumexp | ✅ |
| §5 | 与 jit / vmap / grad 的组合 | ✅ |
| §6 | GPU/TPU 注意事项(块大小、compiler_params、调优) | ⚠️ 只做 lowering 与算术,kernel 未在本机执行 |
| §7 | 实战规则清单 | — |
之后依次是:动手练习(4 道)、测验(6 道)、本章小结、速查表。
与已学章节的关系¶
- 融合与内核边界:../进阶/02_JAX代码性能优化.ipynb §2.6 在优化后的 HLO 里数过 fusion,也看到了 softmax 会在「归约→广播」处被切成多个内核。本章不再重复数 fusion,而是接着回答:XLA 切开的地方,怎样自己写一个内核把它们合起来?
- 自定义原语与 C++ FFI:../进阶/05_互操作性与自定义操作.ipynb 讲了
Primitive的各条规则和jax.ffi。Pallas 是第三条路:kernel 用 JAX 风格的 Python 写,由 JAX 自己追踪和 lowering,不需要另外编译 C++。§1 有一张三者的对照表。 - custom_vjp:../进阶/04_进阶自动微分.ipynb §4.6;入门见 ../03_自动微分grad.ipynb。
- vmap 的批处理维度:../04_自动向量化vmap.ipynb。
- jit 与静态参数:../02_JIT编译.ipynb。
⚠️ 环境注意: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
§0 探针:确认 0.12 的 Pallas API¶
先别凭记忆写代码,逐项确认一下:
- 导入路径:
jax.experimental.pallas(惯用别名pl),以及各后端子模块tpu、triton、mosaic_gpu; pallas_call的完整签名,尤其是interpret参数接受什么;- 一些旧教程里常见的名字(
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)
# 探针 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))
跑下来可以确认:在 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)
# ✅ 正确写法:本机统一用一个全局开关;换到 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)
# ❌ 错误写法:照旧教程用 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)
# ✅ 正确写法:直接对 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)
§1 为什么需要 Pallas¶
1.1 XLA 融合的边界¶
⚪ 选读:已学过进阶/02 §2.6 的融合边界可略读;想判断哪些场景值得手写内核时再看
进阶/02 §2.6 的结论可以概括成三句:
- 一串逐元素操作很容易被 XLA 合成一个内核,省掉中间结果的主存往返;
- 归约后接广播(softmax 的
max/sum)、optimization_barrier、某些控制流会形成内核边界; dot/conv这类重算子通常交给库(cuBLAS、YNNPACK、TPU 的 MXU 内核)完成,XLA 只能在它们的输入或输出端附带一些逐元素操作。
问题在于,XLA 的融合是启发式的,用户不能指定「这块数据在片上存储里停留多久」「循环按什么顺序走」「哪个中间量不写回主存」。有几类计算恰好卡在这些边界上:
| 场景 | XLA 默认做法的问题 | 手写内核能做什么 |
|---|---|---|
Attention:softmax(QKᵀ)V | QKᵀ 是一个 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 + XLA | Pallas | 自定义原语 + C++ FFI(进阶/05) | |
|---|---|---|---|
| 用什么写 | NumPy 风格 | JAX 风格 Python + Ref | C++(或 CUDA) |
| 谁来编译 | XLA | Mosaic / 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
🔍 补充¶
⚪ 选读:想了解 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 返回结果(像普通函数那样),而不是写进输出 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)
# ✅ 正确写法:写进 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)
# ❌ 错误写法: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)
# ✅ 正确写法:数组一律作为输入传进来(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)
# 让每个 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)
# ❌ 错误写法: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 模式下被夹到最后一块,结果悄悄出错
# ✅ 正确写法: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]))
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)
这个版本的缺点是: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)] # 字典序
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 在非整除长度上仍然正确")
结论:逐元素计算可以不管边界;一旦块内有归约(求和、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 ← 块越多,解释循环越长(只打印,不作结论)")
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)
# ❌ 错误写法:用 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)
# ✅ 正确写法:用 @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(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 # 输出缓冲区在块之间被复用,上一个块的结果被累加了进来
# ✅ 正确写法: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}")
第二行结果很有意思:即使未初始化的内存恰好是 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
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 不减最大值 —— 输入较大时 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()
# ✅ 正确写法:减去行最大值后,平移 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)))
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)
# ❌ 错误写法:方差用 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()
# ✅ 正确写法:两遍法(先中心化)
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)
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()
# ✅ 正确写法: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)
4.4 跨 grid 的归约¶
🟢 必读
把「按行求和」做成两维 grid:i 遍历行块,j 遍历列块并累加。输出块 (br,) 的 index_map 是 (i, j) -> (i,)。与 matmul 一样,有三条规则:
- 归约维(
j)放在 grid 的最后; j == 0时初始化;- 列数不能整除时,对列做掩码。
在 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)
# ❌ 错误写法:把归约维放在 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)
# ✅ 正确写法:归约维放最后,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 上,输出块位于 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)
🔍 补充¶
⚪ 选读:写在线归约、选择掩码填充值时
- 掩码的单位元要和归约匹配:求和用 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 最后的另一个原因。
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 函数
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
# ✅ 正确写法:块大小声明为静态参数(或者用 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)
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)
# ✅ 正确写法: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)
为什么直接 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 规则(对 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)通过")
# 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)
§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 包装后的函数 | ❌ TypeError | custom_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 实际计算的地方) | 标量/控制 | |
|---|---|---|---|
| TPU | HBM | VMEM(向量内存,16~128 MiB,见 §6.3 的硬件表) | SMEM(标量内存) |
| GPU | HBM(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
补充两点(未在本机验证):
- 一维块有另一套规则(官方文档:等于数组长度,或是 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 成功(未执行)")
各参数的含义:
- TPU
pltpu.CompilerParamsdimension_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"))
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
读表: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 通用思路,未在本机执行):
- 先用屋脊模型估算:算出 kernel 的算术强度,判断它是带宽受限还是计算受限。带宽受限时,优化重点是少搬数据(融合、复用),而不是少算。
- 块大小:在 VMEM/SMEM 预算内尽量大。TPU 上满足 8×128 的倍数,并对齐 MXU(v6e 的 MXU 是 256 列,v4/v5e/v5p 是 128 列;上表没有打印这一列,可读
info.mxu_column_size,上面已 assert);GPU 上取 2 的幂,并兼顾占用率(occupancy)。 - grid 顺序与复用:让相邻的 program 共享输入块,可以省掉 DMA。归约维放最后;
parallel维放前面,方便多核切分。 - 精度:输入用 bf16,累加用 float32 scratch,最后一步再转回 bf16(§4.1′)。
- 给 XLA 提示:
cost_estimate=pl.CostEstimate(flops=..., transcendentals=..., bytes_accessed=...)能帮助 XLA 安排 kernel 周围的调度。 - 原地更新:
input_output_aliases={0: 0}让输出复用输入缓冲区,省掉一次分配(KV cache 更新这类场景常用)。 - 测量:用
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]))
§7 实战规则清单¶
先判断要不要写 kernel
- 先用
jnp写参考实现,看 HLO 和 profile(进阶/02)。只有当 XLA 的融合边界确实浪费了带宽(attention、长行归约、ragged/量化计算)时,才考虑 Pallas。 - 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 自检通过")
# ✍️ 作答区:练习 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 自检通过")
# ✍️ 作答区:练习 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 自检通过")
# ✍️ 作答区:练习 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 自检通过")
测验¶
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)。
本章小结¶
- 定位:XLA 的融合是启发式的,在「归约→广播」、重算子和 ragged 计算处会留下内核边界(进阶/02 §2.6)。Pallas 让你用 JAX 风格的 Python 手写内核:它在 jaxpr 中是
pallas_call原语,在 TPU 上由 Mosaic 编译,在 GPU 上由 Triton 或 Mosaic GPU 编译,在 CPU 上只能 interpret。与 C++ FFI(进阶/05)相比,它不需要额外的编译工具链,jit/vmap也可以直接用。 - 编程模型:kernel 只读写 Ref;
grid决定执行多少个 program;BlockSpec(block_shape, index_map)决定每个 program 搬哪一块,index_map返回块下标;pl.program_id/pl.num_programs用来定位;@pl.when做条件副作用。 - interpret 模式(0.12 实测):
interpret=True是通用解释器;interpret=pltpu.InterpretParams()是 TPU 模拟器(注意它在pltpu里);plgpu.InterpretGPUParams()是 Mosaic GPU 模拟器。三者都只能验证正确性。越界部分会被填成 NaN,未初始化的输出块也是 NaN。 - 进阶 kernel 的三条铁律:累加器先初始化;归约维放 grid 最后;块内有归约就必须掩码(用
jnp.where填单位元,matmul 的 A、B 两边都要掩码)。数值稳定的写法(减最大值、两遍法方差、在线 logsumexp)在 kernel 里同样适用。 - 变换:
jit、vmap直接可用(vmap 会给 grid 加一维)。pallas_call有自动 JVP 规则,但没有部分求值和转置规则(线性化阶段即失败),所以grad需要custom_vjp加上 Pallas 反向 kernel,并用check_grads(modes=["rev"])验证;直接jvp只在简单情形下可用。 - 硬件:TPU 的块要满足 8×128 规则(本机可以交叉 lowering 验证);
compiler_params是后端专属的;调优的核心是在 VMEM/SMEM 预算内选尽量大的块,让算术强度越过屋脊点,并安排好 grid 顺序以复用数据。
速查表¶
| API(JAX 0.12) | 作用 |
|---|---|
from jax.experimental import pallas as pl | Pallas 主模块 |
from jax.experimental.pallas import tpu as pltpu | TPU(Mosaic)专用:VMEM、SMEM、CompilerParams、InterpretParams |
from jax.experimental.pallas import triton as pltriton / mosaic_gpu as plgpu | GPU 的两个后端 |
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[...] += v | Ref 的读 / 写 / 读-改-写 |
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=True | CPU 上的通用解释器(只验证正确性) |
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 正确。不需要掩码:逐元素计算中,越界部分在写出时会被丢弃。")
练习 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}")
练习 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)
练习 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 都通过")