Chapter 7: Custom Kernels with Pallas

JAX study notes · extra Advanced chapter: writing custom kernels by hand with Pallas. Prerequisites: Basics 00–07, Advanced 00–06. Environment: Python 3.14 · JAX / jaxlib 0.12.0.1 · CPU only.

Every kernel on this machine was actually run in interpret mode and cross-checked against a NumPy float64 reference with assert. The GPU/TPU parts are explanation only; code that can't run on this machine is marked "not executed on this machine".

⚠️ jax.experimental.pallas lives in the experimental namespace and is not guaranteed to be stable across versions. Every API used in this chapter was first confirmed with a probe on 0.12.0.1. After upgrading JAX, rerun the probes in §0 first.

Learning goals

  1. Explain where XLA's automatic fusion stops, where Pallas sits in the JAX stack, and how it relates to Triton, Mosaic (TPU) and Mosaic GPU.
  2. Learn the Pallas programming model: pl.pallas_call, reading and writing Refs (x_ref[...]), out_shape, grid, BlockSpec(block_shape, index_map), pl.program_id / pl.num_programs.
  3. Run and debug kernels on a CPU-only machine with interpret mode; know the difference between interpret=True and pltpu.InterpretParams(), and that both only check correctness and say nothing about performance.
  4. Write common advanced kernels: a tiled matmul that accumulates along K (initialized with @pl.when), numerically stable row-wise softmax / LayerNorm, masking when shapes don't divide evenly, reductions across the grid, and online logsumexp (the core trick of FlashAttention).
  5. Test how pallas_call composes with jit, vmap and grad: the first two just work, while grad needs a custom_vjp wrapper with a backward kernel written in Pallas, verified with check_grads.
  6. Know the hard constraints on GPU/TPU (TPU block shapes aligned to 8×128), compiler_params, and how to approach tuning (block size, arithmetic intensity, the HBM→VMEM/SMEM memory hierarchy).

Contents

SectionContentRuns on this machine
§0Probes: 0.12 import paths and how to use interpret✅
§1Why Pallas: the limits of fusion and its place in the JAX stack✅
§2Programming model: Ref, out_shape, grid, BlockSpec, program_id✅
§3Interpret mode in detail (CPU)✅
§4Advanced kernels: accumulating matmul, softmax/LayerNorm, masking, reductions, online logsumexp✅
§5Composing with jit / vmap / grad✅
§6GPU/TPU notes (block sizes, compiler_params, tuning)⚠️ lowering and arithmetic only; kernels not executed on this machine
§7Practical rules checklist—

After that come the hands-on exercises (4), the quiz (6 questions), the chapter summary and the cheat sheet.

How this connects to earlier chapters

  • Fusion and kernel boundaries: Advanced 02 Performance §2.6 counted fusions in optimized HLO and showed that softmax gets split into several kernels at the "reduce → broadcast" point. This chapter doesn't count fusions again; instead it answers the follow-up question: where XLA splits things apart, how do you write your own kernel to put them back together?
  • Custom primitives and C++ FFI: Advanced 05 Interop and custom ops covered the rules of a Primitive and jax.ffi. Pallas is a third route: the kernel is written in JAX-style Python, traced and lowered by JAX itself, with no separate C++ compilation. §1 has a table comparing the three.
  • custom_vjp: Advanced 04 Advanced autodiff §4.6; for the basics see Basics 03 grad.
  • vmap's batch dimension: Basics 04 vmap.
  • jit and static arguments: Basics 02 JIT.

⚠️ Environment note: the jaxlib 0.12.0.1 CPU backend has a known bug: when a jit closure captures a constant matrix and computes W @ x followed by a sum, it silently returns wrong results (symptoms and workaround in "Known issues in this environment" in 00 Overview, root cause in Advanced 02 §2.3). So in this chapter, weights and data are always passed in as function arguments (Pallas itself also doesn't allow a kernel to capture array constants; see §2.1), and key numbers are cross-checked against NumPy float64.

# Shared setup: imports, version check, cross-checking and timing helpers
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__, "| backend", jax.default_backend(), jax.devices())
assert jax.__version__.startswith("0.12")
assert jax.default_backend() == "cpu", "This chapter runs on CPU in interpret mode (on Colab, use a CPU runtime)"

def f64(a):
    """Convert to NumPy float64, for high-precision reference implementations"""
    return np.asarray(a, dtype=np.float64)

def max_err(a, ref):
    """Max absolute error (both converted to float64 before comparing)"""
    return float(np.max(np.abs(f64(a) - f64(ref))))

def bench(f, *args, n=5):
    """Average time (ms): warm up once, then loop n times. Timings are only printed, never asserted"""
    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():
    """When the TPU simulator reports an error through io_callback, it first logs a full traceback; silence it while demonstrating ❌ code"""
    lg = logging.getLogger("jax._src.callback")
    old = lg.disabled
    lg.disabled = True
    try:
        yield
    finally:
        lg.disabled = old

rng = np.random.default_rng(0)   # only for a little NumPy-side data; JAX randomness always uses jax.random.key
Python 3.14.7 | JAX 0.12.0.1 | backend cpu [CpuDevice(id=0)]

§0 Probes: confirming the Pallas API in 0.12

Instead of writing code from memory, let's confirm things one at a time:

  1. Import paths: jax.experimental.pallas (usual alias pl), plus the backend submodules tpu, triton and mosaic_gpu;
  2. The full signature of pallas_call, especially what the interpret argument accepts;
  3. Whether some names common in older tutorials (pl.load, pl.store, pltpu.TPUCompilerParams) still exist in 0.12.
# Probe 1: import paths and key names
from jax.experimental import pallas as pl
from jax.experimental.pallas import tpu as pltpu            # TPU (Mosaic) specific: VMEM, CompilerParams, InterpretParams...
from jax.experimental.pallas import triton as pltriton      # GPU · Triton backend
from jax.experimental.pallas import mosaic_gpu as plgpu     # GPU · Mosaic GPU backend

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")

# Names from older tutorials: gone in 0.12
gone = {"pl.load": hasattr(pl, "load"), "pl.store": hasattr(pl, "store"),
        "pl.InterpretParams": hasattr(pl, "InterpretParams"),
        "pltpu.TPUCompilerParams": hasattr(pltpu, "TPUCompilerParams")}
print("Names that no longer exist:", gone)
assert not any(gone.values())

sig = inspect.signature(pl.pallas_call)
print("pallas_call parameters:", list(sig.parameters))
print("interpret default:", 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)
Names that no longer exist: {'pl.load': False, 'pl.store': False, 'pl.InterpretParams': False, 'pltpu.TPUCompilerParams': False}
pallas_call parameters: ['kernel', 'out_shape', 'grid_spec', 'grid', 'in_specs', 'out_specs', 'scratch_shapes', 'input_output_aliases', 'debug', 'interpret', 'name', 'compiler_params', 'cost_estimate', 'metadata']
interpret default: False
# Probe 2: what does the interpret argument actually accept? All three forms tested on 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.]

This confirms that in 0.12.0.1 the interpret argument takes three kinds of values, and all of them run on CPU:

FormMeaning
interpret=TrueGeneric interpreter: turns the whole pallas_call into "a jax.jit program that scans over the grid", and runs the kernel body as an ordinary JAX function. This chapter mostly uses this one.
interpret=pltpu.InterpretParams(...)TPU simulator: simulates the TPU's HBM/VMEM, DMA and semaphores on CPU, and can also detect TPU-only errors such as data races and non-consecutive revisits of output blocks (§4.4). Note that it lives in the pltpu submodule; it is not pl.InterpretParams.
interpret=plgpu.InterpretGPUParams(...)The Mosaic GPU simulator, used the same way.

The default value of interpret is False. The ❌ cell below shows that on CPU, leaving out interpret fails right away.

# ❌ Wrong: no interpret on the CPU backend — the CPU has no Pallas compilation backend
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.
# ✅ Correct: use one global switch on this machine; on GPU/TPU just change it to 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.]
# ❌ Wrong: reading/writing Refs with pl.load / pl.store as in older tutorials — both were removed from the pl namespace in 0.12
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'
# ✅ Correct: read and write Refs with NumPy-style indexing; for a dynamic slice use 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)]   # first half
    o_ref[4:] = x_ref[4:] + y_ref[4:]                              # second half: static slices work too
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 Why Pallas

1.1 The limits of XLA fusion

⚪ Optional: skim this if you've already covered the fusion boundaries in Advanced 02 §2.6; come back when you need to decide which cases are worth a hand-written kernel

The conclusions of Advanced 02 §2.6 boil down to three points:

  1. A chain of elementwise operations is easily merged by XLA into one kernel, saving round trips of intermediate results through main memory;
  2. A reduction followed by a broadcast (softmax's max/sum), optimization_barrier and some control flow create kernel boundaries;
  3. Heavy ops like dot/conv are usually handed to libraries (cuBLAS, YNNPACK, the TPU's MXU kernels), and XLA can only attach some elementwise ops on their input or output side.

The problem is that XLA's fusion is heuristic. You can't tell it "keep this data in on-chip memory for this long", "walk the loops in this order" or "never write this intermediate back to main memory". Several kinds of computation land right on these boundaries:

ScenarioProblem with XLA's defaultWhat a hand-written kernel can do
Attention: softmax(QKᵀ)VQKᵀ is an L×L matrix that has to be written back to HBM in full and read againTiling + online softmax (FlashAttention): the L×L matrix is never materialized (§4.5 shows the core trick with logsumexp)
Long-row softmax / LayerNormReduction and broadcast get split into several kernelsOne block holds a whole row (or uses an online reduction): read once, write once
Sparse, grouped, ragged computation (MoE, grouped matmul)Either pad to a dense shape or split into several callsUse index_map and scalar prefetch to decide where each block reads from
Quantized matmul, custom precisionDequantize into a big tensor first, then multiplyDequantize inside the block and feed the matrix unit directly

1.2 What Pallas is and where it sits

🟢 Must-read

Pallas is JAX's kernel language: you describe "how one block is computed" in JAX-style Python, then describe "how the whole array is cut into blocks" with grid and BlockSpec. Its place in the JAX stack:

User code (jnp / lax)
   │ tracing
   ▼
 jaxpr ──── one of its equations is the pallas_call primitive (with the kernel's own jaxpr nested inside)
   │ lowering
   ▼
StableHLO ── ordinary ops are fused and scheduled by XLA;
   │         pallas_call is handed separately to a backend kernel compiler, and the result is embedded in the HLO as a custom_call:
   │           · TPU       → Mosaic (tpu_custom_call)
   │           · GPU       → Triton (pl.triton submodule) or Mosaic GPU (pl.mosaic_gpu submodule, for Hopper and later)
   │           · CPU       → no compilation backend, interpret only (becomes an ordinary HLO while loop)
   ▼
Device executable
  • Triton: OpenAI's open-source GPU kernel language and compiler. Pallas's Triton backend lowers the kernel to Triton IR and lets the Triton compiler do the rest; it's quite portable.
  • Mosaic (TPU): Google's kernel compiler for TPU, and Pallas is its main frontend in JAX. VMEM, SMEM, DMA, semaphores and so on in the pltpu submodule are all Mosaic concepts.
  • Mosaic GPU: a GPU backend built by the JAX team. It directly exposes Hopper/Blackwell features such as TMA and warpgroup matrix instructions (wgmma); it has a higher ceiling and is lower level to write. The current official GPU docs mainly use it for examples; on this machine (0.12.0.1) both GPU submodules can be imported.

The same Pallas kernel isn't necessarily portable across backends: the grid + BlockSpec layer is shared, but block-size constraints, memory spaces and synchronization primitives are backend-specific (see §6).

1.3 Comparing three routes to "custom operations"

⚪ Optional: when you need to choose between jnp, Pallas and C++ FFI

jnp + XLAPallasCustom primitive + C++ FFI (Advanced 05)
Written inNumPy styleJAX-style Python + RefsC++ (or CUDA)
Compiled byXLAMosaic / Triton / Mosaic GPUYour own compiler
Level of controlLeft to the compilerBlocks, loop order, on-chip memoryFull control
Runs on CPU✅Interpret only (correctness checks only)✅ (Section 5.10)
jit / vmap✅✅ (tested in §5)You write a batching rule or vmap_method
grad✅❌, needs custom_vjp❌, needs custom_vjp or JVP/transpose rules

Now let's confirm this picture with code: pallas_call is a primitive in the jaxpr; in interpret mode it's just an ordinary while loop in the HLO; and the same kernel can also be lowered only on CPU, cross-generating TPU code (you get a tpu_custom_call, but it doesn't run).

# 1) The pallas_call primitive in the jaxpr
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("Primitives in the outer jaxpr:", prims)
inner = cj.jaxpr.eqns[0].params["jaxpr"]          # pallas_call has the kernel's own jaxpr nested inside
print("The kernel's jaxpr:\n", inner)
assert prims == ["pallas_call"]
assert all("Ref" in str(v.aval) for v in inner.invars)   # all of the kernel's arguments are Refs

# 2) Interpret mode: the compiled HLO has only ordinary ops and a while loop, no custom-call
hlo_interp = jax.jit(add_call).lower(x, y).compile().as_text()
print("Number of custom-calls in the interpret-mode HLO:", hlo_interp.count("custom-call"))
assert "custom-call" not in hlo_interp

# 3) Cross-lowering to TPU (not executed! we only look at the generated StableHLO)
add_tpu = pl.pallas_call(add_kernel, out_shape=jax.ShapeDtypeStruct((8, 128), jnp.float32))  # interpret off
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 result:", line[:110], "...")
assert "tpu_custom_call" in tpu_text
Primitives in the outer jaxpr: ['pallas_call']
The kernel's 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 () }
Number of custom-calls in the interpret-mode HLO: 0
TPU lowering result: %0 = stablehlo.custom_call @tpu_custom_call(%arg0, %arg1) {backend_config = "{\22custom_call_config\22: {\22bo ...

🔍 Extra

⚪ Optional: if you want to know the limits of interpret mode and cross-lowering

  • Interpret mode is "slow but reliable" because it runs the kernel body as-is as a JAX function, and the grid becomes a while loop (compiled by jax.jit). So Pallas kernels on CPU say nothing about performance: with a single block the time is about the same as jnp (in §3.3 a single block was measured as even slightly faster, which is timing noise), and with many blocks it gets much slower (in §3.3, 64 blocks measured roughly two orders of magnitude slower).
  • Cross-lowering (.trace(...).lower(lowering_platforms=("tpu",))) only goes as far as Mosaic's frontend checks and serialization; it never calls the backend of the TPU compiler. It can catch errors like invalid block shapes on this machine (§6.1), but not VMEM overflows or performance problems. GPU cross-lowering fails on this machine with No supported GPU devices found; it needs abstract devices, which we won't go into here.

§2 Programming model

2.1 Kernels, Refs and out_shape

🟢 Must-read

pl.pallas_call(kernel, out_shape, ...) returns an ordinary JAX callable. The kernel's conventions are:

  • Arguments: first a Ref for each input, then a Ref for each output (followed by scratch Refs if there are any), in the same order as in_specs/out_specs;
  • A Ref is a mutable view of a block of memory: x_ref[...] reads the whole block, o_ref[...] = v writes it, o_ref[...] += v does read-modify-write, and slices like x_ref[0, :] and x_ref[pl.ds(i, n)] work too;
  • The return value must be None: results can only be written into output Refs;
  • out_shape declares the output's shape and dtype with jax.ShapeDtypeStruct (or a pytree of them), and Pallas allocates the output buffers from it.

Without a grid, the whole array is handed to the kernel as one block, and the kernel runs once.

# Multiple outputs: out_shape is a tuple, and the kernel takes one more output Ref
def sum_diff_kernel(x_ref, y_ref, s_ref, d_ref):
    x, y = x_ref[...], y_ref[...]          # read them first to avoid reading twice
    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("Multi-output kernel is correct:", s.shape, d.shape)
Multi-output kernel is correct: (4, 6) (4, 6)
# ❌ Wrong: the kernel returns its result (like an ordinary function) instead of writing it into the output 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__, ": … the kernel function", re.search(r"should return None.*", str(e)).group(0))
assert err is not None and "should return None" in str(err)
ValueError : … the kernel function should return None. It returns a PyTree: PyTreeDef(*)
# ✅ Correct: write into o_ref; the function itself returns nothing
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)
# ❌ Wrong: the kernel captures a JAX array (say, a weight) through a closure
W_bias = jnp.arange(6, dtype=jnp.float32)
def bias_kernel_bad(x_ref, o_ref):
    o_ref[...] = x_ref[...] + W_bias          # W_bias is an external array constant
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__, ": … the kernel function", re.search(r"captures constants.*", str(e)).group(0))
assert err is not None and "captures constants" in str(err)
ValueError : … the kernel function captures constants [f32[6]]. You should pass them as inputs.
# ✅ Correct: always pass arrays in as inputs (Python scalars may be captured by closure; they're treated as compile-time constants)
scale = 0.5                                   # Python float: allowed
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("✅ Weights passed in as an argument; result is correct")
✅ Weights passed in as an argument; result is correct

This restriction lines up nicely with the workaround for this environment's CPU bug: weights and data always go in as arguments.

2.2 grid, BlockSpec and pl.program_id

🟢 Must-read

Real arrays often don't fit in on-chip memory, so they're cut into blocks. Pallas describes the cut with two things:

  • grid=(g0, g1, ...): how many times the kernel runs (a multidimensional loop). Each run is called a program; pl.program_id(axis) gives its index along dimension axis, and pl.num_programs(axis) gives the total count along that dimension;
  • BlockSpec(block_shape, index_map): for each input/output, the block's shape and which block program (i, j, ...) takes. index_map receives the grid indices and returns block indices (not element offsets); the actual offset is block index × block_shape.

An array of shape (256, 512) cut into (64, 128) blocks gives grid=(4, 4), and program (i, j) handles elements [64i:64i+64, 128j:128j+128]:

def add2d(x, y, bm=64, bn=128):
    """2-D elementwise addition: each program handles one (bm, bn) block"""
    M, N = x.shape
    spec = pl.BlockSpec((bm, bn), lambda i, j: (i, j))     # program (i, j) takes block (i, j)
    return pl.pallas_call(add_kernel,                        # same kernel as in §0: it only cares about "one block"
                          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("Tiled addition with grid=(4, 4) is correct, output shape", Z.shape)
Tiled addition with grid=(4, 4) is correct, output shape (256, 512)
# Have each program write its own (program_id(0), program_id(1)) into the block it owns, to see the grid → block mapping
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)   # tens digit is i, ones digit is 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]]
# ❌ Wrong: index_map returns an "element offset" instead of a "block index" — no error, silently wrong results
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,))],    # wrong: i*16 is an element offset
                     out_specs=pl.BlockSpec((16,), lambda i: (i,)),
                     interpret=INTERPRET)(xs)
wrong = ~np.isclose(f64(bad), 2 * f64(xs))
print("Wrong elements:", int(wrong.sum()), "/ 64; outputs 16–19:", np.asarray(bad[16:20]))
assert wrong.sum() > 0      # in interpret mode an out-of-range block index is clamped to the last block, so the result is quietly wrong
Wrong elements: 32 / 64; outputs 16–19: [ 96.  98. 100. 102.]
# ✅ Correct: index_map returns block indices, and Pallas multiplies by block_shape to get the offset
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 A first matmul: blocks don't have to be square

⚪ Optional: groundwork for 4.1; once you're comfortable with "full-dimension" BlockSpecs you can jump straight to 4.1

A BlockSpec can take an entire dimension of the array (a "full dimension"). The simplest tiled matmul: each program owns one (bm, bn) block of the output, and reads bm rows of A (all of K) and bn columns of B (all of K):

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

preferred_element_type=jnp.float32 in jnp.dot asks the matrix unit to accumulate in float32, which matters especially when the inputs are bf16 (§4.1).

def matmul_rowcol(a, b, bm=64, bn=128):
    """A matmul that doesn't tile along K: A takes (bm, K) row blocks, B takes (K, bn) column blocks"""
    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)),   # row block i; K has only one block
                                    pl.BlockSpec((K, bn), lambda i, j: (0, j))],  # column block 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 reference
print("Max absolute error:", max_err(C_rc, ref_AB))
np.testing.assert_allclose(f64(C_rc), ref_AB, rtol=1e-4, atol=1e-4)
Max absolute error: 6.962321596404308e-05

The drawback of this version: when K is large, the (bm, K) and (K, bn) blocks don't fit in on-chip memory. §4.1 cuts the K dimension too, and accumulates across several programs.


§3 Interpret mode in detail (CPU)

3.1 The grid runs in order, lexicographically

⚪ Optional: when a kernel depends on grid execution order, or you plan to port it to GPU

interpret=True turns the grid into a loop and runs the programs one by one in lexicographic order (the last dimension changes fastest). A single TPU core loops in order the same way (megacore chips split parallel dimensions across cores; see §4.4). On GPU, programs are thread blocks that run in parallel, with no ordering guarantee. So a kernel that is only correct because of "order" can pass in interpret mode and still break on GPU.

pltpu.InterpretParams(grid_point_recorder=...) can record the order in which the simulator visits the grid. The callback's actual signature is (token, grid_point, core_id) -> token, and it must return the token it receives unchanged. This was found in the 0.12 source code; the docstring only mentions the last two parameters.

visit_order = []
def recorder(token, grid_point, core_id):
    visit_order.append(tuple(int(c) for c in grid_point))
    return token                               # must return the token unchanged, or you get "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:", visit_order)
np.testing.assert_array_equal(yg, xg)
assert visit_order == [(i, j) for i in range(2) for j in range(3)]     # lexicographic order
Visit order: [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2)]

3.2 Out-of-bounds blocks: you read "garbage", which is NaN in interpret mode

🟢 Must-read

When the array length isn't divisible by the block size, you get the grid by rounding up with pl.cdiv, and part of the last block falls outside the array. The official docs say: the out-of-bounds part is padded on read and discarded on write; the padding value is undefined and should be treated as garbage. Interpret mode pads floats with NaN (which makes mistakes easier to spot), but that's an implementation detail you can't rely on.

x100 = jnp.arange(1, 101, dtype=jnp.float32)          # length 100, block size 32 → 4 blocks; the last block has only 4 valid elements
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: this dim is squeezed out; the kernel sees (32,)
                      interpret=INTERPRET)(x100)
tail = np.asarray(peek[3])
print("What the 4th block read:", tail[:8], "...")
assert np.array_equal(tail[:4], [97, 98, 99, 100]) and np.isnan(tail[4:]).all()

# But an elementwise kernel is unaffected: the out-of-bounds part is discarded on write
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("The elementwise kernel is still correct on a non-divisible length")
What the 4th block read: [ 97.  98.  99. 100.  nan  nan  nan  nan] ...
The elementwise kernel is still correct on a non-divisible length

Takeaway: elementwise computations can ignore the boundary; as soon as there's a reduction inside the block (a sum, a max, the K dimension of a matmul), garbage values leak into the result and you must mask (§4.3).

3.3 Interpret mode says nothing about performance

⚪ Optional: if you want to confirm why timings on CPU are meaningless

Interpret mode runs each program as an ordinary JAX computation, with no on-chip memory and no pipelining. Its timings only reflect "interpretation overhead" and have nothing to do with performance on real hardware. The timings below are printed only, and we draw no conclusions from them:

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 block
add_pl_64 = jax.jit(functools.partial(add2d, bm=128, bn=128))    # 64 blocks
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 block   : {bench(add_pl_1, Xb, Yb):8.3f} ms")
print(f"Pallas interpret, 64 blocks : {bench(add_pl_64, Xb, Yb):8.3f} ms   ← more blocks, longer interpretation loop (printed only, no conclusion)")
jnp + XLA                   :    0.092 ms
Pallas interpret, 1 block   :    0.113 ms
Pallas interpret, 64 blocks :   22.429 ms   ← more blocks, longer interpretation loop (printed only, no conclusion)

3.4 What extra problems the TPU simulator catches

⚪ Optional: when you're getting ready for TPU and want to catch TPU semantics errors on CPU ahead of time

pltpu.InterpretParams is slower than interpret=True, but it simulates TPU memory semantics and can catch several kinds of bugs that quietly pass under interpret=True and only fail on a TPU:

ParameterWhat it does
(on by default) output-block revisit checkErrors when the same output block is written by non-consecutive programs (tested in §4.4)
uninitialized_memory="nan" (default) / "zero"What value fills uninitialized output blocks / scratch
detect_races=TrueDetects data races between cores
out_of_bounds_reads="raise" (default)Errors on out-of-bounds reads (not counting the boundary-block padding produced by BlockSpec)
num_cores_or_threadsSimulates multi-core (megacore) parallelism
grid_point_recorderRecords the order in which the grid is visited (§3.1)

⚠️ The docs require that after the simulator raises an exception internally, you call pltpu.reset_tpu_interpret_mode_state() before using it again in the same process.

🔍 Extra

⚪ Optional: when debugging kernels, or deciding how to split work between the two interpreters

  • An easy way to remember the division of labor: interpret=True checks the math (whether the kernel body computes the right thing), and InterpretParams checks TPU semantics (how blocks are moved, when they're written back, whether cores conflict). Run both before going to a TPU; it costs very little.
  • When debugging a kernel, you can print from inside it with pl.debug_print("pid={}", pl.program_id(0)). It works in interpret mode (tested: one line per program); on real hardware each backend has its own limits on what values and format strings it can print, and it's not exactly the same as jax.debug.print, so check the docs for your backend before relying on it.

§4 Advanced kernels

4.1 Tiled matmul with an accumulator

🟢 Must-read

Cut the K dimension too, and the grid becomes three-dimensional (i, j, k):

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

The output block's index_map is (i, j, k) -> (i, j), independent of k. In other words, the same output block is visited repeatedly by K consecutive programs, so you can use the output Ref directly as the accumulator:

  • When k == 0, zero it first: decorate a no-argument function with @pl.when(pl.program_id(2) == 0), and it only runs when the condition is true;
  • On every step, o_ref[...] += A_block @ B_block.

The reduction dimension must be the last dimension of the grid, so that visits to the same output block are consecutive (§4.4 shows what happens when you break this rule).

def matmul_acc(a, b, bm=64, bk=128, bn=128):
    """Tiled matmul accumulating along K, float32 output, using o_ref directly as the accumulator"""
    M, K = a.shape
    _, N = b.shape
    def kernel(a_ref, b_ref, o_ref):
        @pl.when(pl.program_id(2) == 0)            # first K step of each output block: zero it
        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),       # reduction dim k goes last
                          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("Max absolute error (vs. float64):", max_err(C_acc, ref_AB))
np.testing.assert_allclose(f64(C_acc), ref_AB, rtol=1e-4, atol=1e-4)
Max absolute error (vs. float64): 2.7661546042168084e-05
# ❌ Wrong: testing program_id with a Python if — it's a tracer at tracing time and can't be converted to 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[].
# ✅ Correct: decorate a no-argument function with @pl.when(condition) (exactly what matmul_acc does)
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("✅ The pl.when version is correct, max error", max_err(C_when, ref_AB))
✅ The pl.when version is correct, max error 2.7661546042168084e-05

@pl.when(cond) corresponds to lax.cond without a return value; it exists specifically for "side effects on Refs when the condition holds".

Next is the second common mistake: forgetting to initialize the accumulator.

# ❌ Wrong: forgetting to zero at k == 0 and just doing +=
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)                                          # uninitialized memory = NaN
    r_zero = mm_noinit(pltpu.InterpretParams(uninitialized_memory="zero")) # even if the initial memory happens to be 0...
    print("interpret=True: fraction of NaN =", float(np.isnan(f64(r_nan)).mean()))
    print("TPU simulator (uninitialized memory filled with 0): max error =", 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     # the output buffer is reused across blocks, so the previous block's result gets accumulated in
interpret=True: fraction of NaN = 1.0
TPU simulator (uninitialized memory filled with 0): max error = 295.86875920583236
# ✅ Correct: zero explicitly at k == 0 (matmul_acc). Correct under both interpreters and both fill values for uninitialized memory
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 error {max_err(C_ok, ref_AB):.2e}")
✅ interpret=True                                 max error 2.77e-05
✅ InterpretParams()                              max error 2.77e-05
✅ InterpretParams(uninitialized_memory="zero")   max error 2.77e-05

The second line of output is interesting: even when the uninitialized memory happens to be 0, the result is still wrong. The TPU simulator faithfully reproduces how a real TPU behaves: the output block's buffer in VMEM is reused across different output blocks, and isn't read back from HBM or zeroed each time. So the accumulated result of the previous (i, j) block carries over into the next one. Accumulators must be initialized explicitly; you can't count on "memory being 0 by default".

4.1′ A scratch accumulator: bf16 inputs, float32 accumulation

⚪ Optional: when inputs and outputs are bf16 and you need float32 accumulation

In real training, inputs and outputs are often bf16. If you use the bf16 output block directly as the accumulator, every step rounds once and the error keeps piling up. The standard approach:

  • Request a float32 on-chip scratch area with scratch_shapes=[pltpu.VMEM((bm, bn), jnp.float32)] (the kernel takes one more scratch Ref);
  • Only on the last K step (k == pl.num_programs(2) - 1) convert the accumulated result to bf16 and write it to the output.

pltpu.VMEM also works in interpret mode (verified).

def matmul_bf16(a, b, bm=64, bk=128, bn=128):
    """bf16 inputs/outputs, accumulation in a float32 scratch"""
    M, K = a.shape
    _, N = b.shape
    def kernel(a_ref, b_ref, o_ref, acc_ref):         # the last argument is the 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)        # last K step: write out
        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)                # reference: the same bf16 inputs, computed in float64
rel = np.max(np.abs(f64(C16) - ref16) / (np.abs(ref16) + 1.0))
print("Output dtype:", C16.dtype, "| max relative error:", rel, "(bf16's unit roundoff is about 2^-8 ≈ 3.9e-3)")
assert C16.dtype == jnp.bfloat16
assert rel < 2 ** -7
Output dtype: bfloat16 | max relative error: 0.0037682344278978106 (bf16's unit roundoff is about 2^-8 ≈ 3.9e-3)

4.2 Row-wise softmax and LayerNorm (numerically stable)

⚪ Optional: when you hand-write row-reduction kernels like softmax / LayerNorm

Advanced 02 §2.6 measured that XLA splits a hand-written softmax into several kernels at the "reduce → broadcast" point. In Pallas, as long as one block holds an entire row (BlockSpec((br, C), lambda i: (i, 0))), the max, exp, sum and division all happen in the same kernel, and each element is read from main memory once and written once.

The two old rules of numerical stability apply inside kernels too:

  • softmax: subtract each row's max first, then take exp;
  • LayerNorm: compute the variance with the two-pass formula $\mathrm{mean}((x-\mu)^2)$, not $E[x^2]-E[x]^2$ (which suffers catastrophic cancellation for large values).

When the number of rows isn't divisible by br, you don't need a mask: the out-of-bounds rows are garbage, but each row is computed independently, and the results for garbage rows are discarded on write.

def compute_dtype(dt):
    """Compute precision inside the block: promote bf16/f16 to float32; keep float32 and float64 as they are (a blunt astype(float32) would lower float64's precision)"""
    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)          # numerically stable: subtract the row max first
    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):
    """Call a kernel over row blocks: each block is br rows × full width; extra are column-broadcast arguments (shape (1, C), shared by all blocks)"""
    R, Cc = x.shape
    row_spec = pl.BlockSpec((br, Cc), lambda i: (i, 0))
    vec_spec = pl.BlockSpec((1, Cc), lambda i: (0, 0))      # every program takes the same block (block 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 rows: not divisible by 8
sm = row_call(softmax_kernel, xs_sm)
print("softmax max error:", 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 max error: 1.1184438952760445e-07
# ❌ Wrong: softmax without subtracting the max — for large inputs exp overflows to inf, and 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 is shift-invariant, so the correct answer doesn't change
try:
    sm_bad = row_call(softmax_kernel_naive, big)
    print("Fraction of 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()
Fraction of NaN: 1.0
# ✅ Correct: after subtracting the row max, the result shifted by 100 matches the original
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("✅ Result unchanged after a shift of 100, max error", max_err(sm_big, softmax_ref64(xs_sm)))
✅ Result unchanged after a shift of 100, max error 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)        # two-pass: center first, then take the mean of the squares
    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))        # weights passed in as arguments
beta = 0.1 * jax.random.normal(kb, (1, 256))
ln = row_call(layernorm_kernel, xln, gamma, beta)
print("LayerNorm max error:", 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 max error: 5.030126644101074e-07
# ❌ Wrong: variance as E[x²] − E[x]². When the data has a large offset (1e4 here), float32 suffers catastrophic cancellation
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          # subtracting two numbers of order 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 reference
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("One-pass max error:", err_bad, "| NaN count:", 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()
One-pass max error: 1548.096477828638 | NaN count: 4864
# ✅ Correct: two-pass (center first)
ln_good = row_call(layernorm_kernel, x_off, gamma, beta)
err_good = max_err(ln_good, ref_off)
print("Two-pass max error:", err_good)
assert err_good < 5e-3     # the remaining error comes from representing 1e4 + x itself in float32 (an ulp of about 1e-3)
Two-pass max error: 0.002409623281878326

4.3 Handling boundaries with masks

🟢 Must-read

As §3.2 said, the out-of-bounds part reads garbage. Whenever there's a reduction inside a block, first use a mask to replace the out-of-bounds part with the identity element of that reduction (0 for sums, -inf for max), then compute. Build the mask from global indices:

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

Use jnp.where (selection), not multiplication: NaN * 0 is still NaN.

First, a tiled matmul where none of M, N, K divide evenly:

  • Out-of-bounds parts along M and N only affect the out-of-bounds rows and columns of the output, which are discarded on write, so you can ignore them;
  • Out-of-bounds parts along K get summed into every valid output, so you must mask, and you must mask both A and B (masking only one side gives 0 × NaN = NaN).
def matmul_masked(a, b, bm=64, bk=64, bn=64, mask_a=True, mask_b=True):
    """Tiled matmul for any shape: the grid rounds up with cdiv, and the K direction is masked"""
    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:   # this if tests a Python bool (a build-time constant), not a tracer, so it's fine
            ka = k * bk + lax.broadcasted_iota(jnp.int32, a_blk.shape, 1)   # columns of the A block = global K index
            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)  # rows of the B block = global K index
            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: none divisible by 64
Bo = jax.random.normal(kb, (200, 130))
ref_o = f64(Ao) @ f64(Bo)
# ❌ Wrong: no mask along K, or masking only A — NaN pollutes every output
for label, ma, mb in [("no mask", False, False), ("mask A only", True, False)]:
    try:
        r = matmul_masked(Ao, Bo, mask_a=ma, mask_b=mb)
        print(f"{label:8s}: fraction of NaN = {float(np.isnan(f64(r)).mean()):.2f}")
    except Exception as e:
        print(type(e).__name__, e); raise
    assert np.isnan(f64(r)).all()
no mask : fraction of NaN = 1.00
mask A only: fraction of NaN = 1.00
# ✅ Correct: mask both A's columns and B's rows with jnp.where
C_o = matmul_masked(Ao, Bo)
print("Shape", C_o.shape, "| max error:", max_err(C_o, ref_o))
np.testing.assert_allclose(f64(C_o), ref_o, rtol=1e-4, atol=1e-4)
Shape (100, 130) | max error: 1.2101076086068474e-05

4.4 Reductions across the grid

🟢 Must-read

Turn "row sums" into a 2-D grid: i walks over row blocks, and j walks over column blocks and accumulates. The output block (br,) has index_map (i, j) -> (i,). As with matmul, there are three rules:

  1. Put the reduction dimension (j) last in the grid;
  2. Initialize when j == 0;
  3. When the column count doesn't divide evenly, mask the columns.

On TPU you should also tell the compiler the dimension semantics: compiler_params=pltpu.CompilerParams(dimension_semantics=("parallel", "arbitrary")). A parallel dimension can be split across cores; an arbitrary dimension must run in order on one core (§6.2). You can pass this argument in interpret mode too, and it's actually accepted.

def rowsum(x, br=32, bc=128, reduce_last=True, interpret=INTERPRET):
    """Row sums: x (R, C) -> (R,); reduce_last=False demonstrates the wrong grid order"""
    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)   # mask, then sum
    if reduce_last:
        grid, xmap, omap = (pl.cdiv(R, br), pl.cdiv(Cc, bc)), (lambda i, j: (i, j)), (lambda i, j: (i,))
    else:   # wrong order: reduction dim first
        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 columns: not divisible by 128
rs = rowsum(xr)
print("Row-sum max error:", max_err(rs, f64(xr).sum(1)))
np.testing.assert_allclose(f64(rs), f64(xr).sum(1), rtol=1e-5, atol=1e-4)
Row-sum max error: 4.580535460263491e-06
# ❌ Wrong: the reduction dim is the first grid dim — the same output block is visited "non-consecutively"
r_true = rowsum(xr, reduce_last=False, interpret=True)     # generic interpreter: happens to be right, hiding the problem
print("Error under interpret=True:", max_err(r_true, f64(xr).sum(1)), "← right by luck")
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 simulator:", type(e).__name__, "→", msg.strip())
finally:
    pltpu.reset_tpu_interpret_mode_state()                  # must reset after the simulator raises
assert err is not None and "Revisited block" in str(err)
Error under interpret=True: 4.580535460263491e-06 ← right by luck
TPU simulator: 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) .
# ✅ Correct: reduction dim last, and the TPU simulator passes too
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("✅ Error under the TPU simulator:", max_err(rs_tpu, f64(xr).sum(1)))
✅ Error under the TPU simulator: 4.580535460263491e-06

Why non-consecutive visits go wrong: on TPU the output block lives in VMEM. Only when the output block's index changes is the old block written back to HBM and a new one swapped in, and the new block isn't read back from HBM. If the grid is (j, i), the visit order is (0,0),(0,1),(0,2),(1,0)..., so output block i gets swapped out every few steps; when its turn comes again, the previously accumulated result is gone. interpret=True reads and writes the full array every time, so it hides this problem. Run it once with InterpretParams and you'll catch it on CPU ahead of time.

4.5 Online logsumexp: the core trick of FlashAttention

⚪ Optional: when you need online reductions over very long rows, as in FlashAttention

When a row is too long to fit in one block, softmax's normalizer can be updated as you read (online softmax). Keep two running quantities: the current max $m$ and the scaled sum $\ell$. For each new column block $x_{blk}$:

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

At the end, $\mathrm{logsumexp} = m + \log \ell$. FlashAttention builds on this by also multiplying $e^{x-m}$ by the $V$ block and accumulating that too, so the $L\times L$ attention matrix is never materialized.

This kernel needs two scratches (m and ell), and uses -inf as the mask fill value for the max:

def logsumexp_rows(x, br=16, bc=128):
    """Row-wise online logsumexp: x (R, C) -> (R,), tiled along C and updated online across blocks"""
    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)         # the identity element for max is -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   # long rows with large values; 1000 columns not divisible by 128
lse = logsumexp_rows(xl)
ref_lse = sp_logsumexp(f64(xl), axis=1)
print("Online logsumexp max error:", 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)
Online logsumexp max error: 3.1767704911089822e-06

🔍 Extra

⚪ Optional: when writing online reductions or choosing mask fill values

  • The mask's identity element must match the reduction: 0 for sums, -inf for max, +inf for min, 1 for products. In the online logsumexp, if you masked with 0, a row of all-negative numbers would get its max wrongly raised to 0.
  • The online algorithm's initial value m = -inf makes the first step compute exp(-inf - m_new) = 0, which is fine and produces no NaN. But that relies on every block having at least one valid element; otherwise m_new stays -inf, and -inf - (-inf) is NaN. The official docs also require every block to have at least one element in bounds, which is automatically true when you compute the grid with cdiv.
  • The scratches in 4.1′ and 4.5 persist across the grid (their contents carry over between programs), so they can only serve as accumulators within "consecutive visits to the same output block". That's another reason the reduction dimension must be last in the grid.

§5 Composing with JAX transformations

5.1 jit: works directly, but block sizes must be static

⚪ Optional: when block sizes are passed into a jit function as arguments

pallas_call is itself a primitive and can go inside any jit function. But grid and BlockSpec have to be fixed at build time, so block sizes must be Python integers, not tracers.

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) is correct")
jit(matmul_acc) is correct
# ❌ Wrong: passing block sizes to a jit function as ordinary (traced) arguments
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
# ✅ Correct: declare block sizes as static arguments (or bind them beforehand with 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("✅ Both ways of passing static block sizes are correct")
✅ Both ways of passing static block sizes are correct

5.2 vmap: works directly; the batch dim becomes a new grid dimension

⚪ Optional: when you need to batch a kernel, or want to know how vmap is implemented

Running it shows that when you vmap a pallas_call, JAX's batching rule adds a dimension at the front of the grid and inserts a squeezed dimension (Squeezed) at the front of each BlockSpec's block shape. The kernel itself doesn't change; each batch element runs the original grid once.

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("Original grid (4, 4) → grid after vmap:", gm.grid)
print("Block shape after vmap:", gm.block_mappings[0].block_shape)
assert str(eqn.primitive) == "pallas_call" and gm.grid == (3, 4, 4)

# in_axes=None: B is shared across all batch elements (not copied)
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)) is correct, shape", Cv.shape)
Original grid (4, 4) → grid after vmap: (3, 4, 4)
Block shape after vmap: (Squeezed(), Blocked(block_size=64), Blocked(block_size=128))
vmap(matmul_masked, in_axes=(0, None)) is correct, shape (3, 100, 130)

5.3 grad: not differentiable by default; use custom_vjp + a Pallas backward kernel

🟢 Must-read

The right approach: wrap the forward kernel with jax.custom_vjp (see Advanced 04 §4.6), and write the backward pass in Pallas too. Softmax's VJP only needs the forward output $y$:

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

The backward pass is also "reduce per row, then broadcast", which fits a row-tiled kernel nicely. The forward pass saves $y$ as the residual. First, here's what happens if you skip this and differentiate directly:

# ❌ Wrong: running reverse-mode autodiff directly on a function that contains 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
# ✅ Correct: custom_vjp, with both forward and backward as Pallas kernels
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                                  # residual: store only the output y

def _softmax_bwd(y, dy):
    return (softmax_bwd_pallas(y, dy),)          # return a tuple, one entry per argument of the original function

softmax_pl.defvjp(_softmax_fwd, _softmax_bwd)

w_sm = jax.random.normal(jax.random.key(14), (16, 128))    # the "weights" are passed in as an argument too
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("Max difference from autodiff of 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)

# Cross-check against a VJP derived by hand in NumPy float64
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)
Max difference from autodiff of jax.nn.softmax: 7.450580596923828e-09

Why does a direct grad fail? Let's look at which differentiation rules the pallas_call primitive has registered (jax.interpreters.ad is a semi-internal API; we only use it here to look):

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 has a JVP rule:", has_jvp, "| has a partial eval rule:", has_pe, "| has a transpose rule:", has_transpose)
assert has_jvp and not has_pe and not has_transpose

# Reverse mode already fails at the "linearize" step (before it ever reaches transposition): jax.linearize gives the same error
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 "succeeded").splitlines()[0][:80])
assert lin_err is not None and "Linearization failed" in lin_err

# The JVP rule differentiates the kernel body and produces a new kernel that "computes primal and tangent together". In simple cases it works:
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("Difference between the simple kernel's jvp and 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)

# But this rule is fragile: tangents for only some inputs, or a kernel that uses program_id, both fail on 0.12.0.1 (tested)
xa2 = jax.random.normal(jax.random.key(18), (256, 512))
fragile = {
    "jvp w.r.t. first input only": lambda: jax.jvp(lambda u: add2d(u, xa2), (xa2,), (xa2,)),
    "kernel uses 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 'succeeded'}")
assert all(v is not None for v in fails.values())
pallas_call has a JVP rule: True | has a partial eval rule: False | has a transpose rule: False
linearize: Linearization failed to produce known values for all output primals. This is typ
Difference between the simple kernel's jvp and jax.nn.softmax: 2.9802322387695312e-08
jvp w.r.t. first input only → ValueError
kernel uses program_id → AssertionError

Here's what we found: pallas_call has an automatically generated JVP rule (it differentiates the kernel body and produces a new kernel that computes primal and tangent together), but no partial eval rule and no transpose rule.

Reverse mode first has to split the JVP into "the known primal part" and "the linear tangent part", then transpose the tangent part. In pallas_call the primal and tangent are squeezed into the same kernel and can't be separated, so the primal is treated as "unknown" too. That's why the linearize step fails with Linearization failed to produce known values for all output primals and never gets to transposition. As a result, grad, vjp and linearize all fail.

Forward mode works in simple cases, but it breaks in the two situations below, so you can't rely on it: tangents for only some inputs (ValueError: safe_zip() ...); a kernel that uses program_id (the JVP rule retraces the kernel body without a grid context, giving AssertionError).

from jax.test_util import check_grads

# 1) Numerical gradient check in float32 (custom_vjp only supports reverse mode, hence modes=["rev"])
check_grads(softmax_pl, (x_sm,), order=1, modes=["rev"])
print("check_grads (float32, rev) passed")

# 2) Check again in float64: finite differences are more accurate there and catch small errors float32 can't reveal
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) passed")
check_grads (float32, rev) passed
check_grads (float64, rev) passed
# 3) Composition: jit(grad) and vmap(grad) both work; but forward mode (jvp) isn't available for a custom_vjp function
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) and vmap(grad) are both correct")

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) and vmap(grad) are both correct
jvp: TypeError : can't apply forward-mode autodiff (jvp) to a custom_vjp function.

§5 summary: which transformations work directly

TransformationApplied directly to pallas_callNotes
jit✅Block sizes and grid must be static Python integers
vmap✅The batch dim becomes the first grid dim, and Squeezed is inserted at the front of the block shape
grad / vjp❌ ValueError: Linearization failed...The primitive has no partial eval or transpose rule, so it fails at the linearize step. Wrap it in custom_vjp and write the backward pass in Pallas
jvp⚠️ works in simple casesThere's an automatically generated JVP rule, but it fails when some input tangents are zero or when program_id is used; use custom_jvp when you need reliable forward mode
jvp on a function wrapped in custom_vjp❌ TypeErrorcustom_vjp only supports reverse mode
jit(grad), vmap(grad)✅ (once wrapped in custom_vjp)vmap automatically adds a grid dim to the backward kernel too

🔍 Extra

⚪ Optional: if you care about choosing residuals, the cost of vmap, and precision issues in check_grads

  • The tangent kernel produced by the automatic JVP rule is still a Pallas kernel: primal and tangent are computed in the same kernel, with no extra round trips to main memory. It also means the operations in the kernel body must be differentiable by JAX; when the kernel uses program_id, the JVP rule retraces the kernel body without a grid context and raises AssertionError (tested on 0.12.0.1).
  • Choosing residuals: softmax's backward pass only needs $y$, so we store $y$ and not $x$. LayerNorm's backward pass usually stores $\mu$ and $1/\sigma$ (two scalars per row) rather than the whole centered matrix. This is the same "save or recompute" trade-off as in Course exercise 2 of Advanced 04. FlashAttention's backward pass saves only each row's logsumexp (§4.5) besides the inputs Q, K, V and the output O; the attention probabilities are recomputed in the backward pass, and the $L\times L$ matrix is never stored.
  • The cost of vmap: the batching rule just adds the batch dim to the grid; the block size stays the same. If the original blocks were small, adding a batch dim multiplies the number of programs. On GPU/TPU, a hand-written kernel that "natively has a batch dim" (with blocks covering several batch elements) is sometimes faster.
  • Don't hard-code precision in a kernel: softmax_kernel was first written with x.astype(jnp.float32). Everything was fine in float32, but check_grads in float64 failed (relative error about 1e-3). The reason: float64 inputs were quietly downcast to float32 inside the kernel, so finite differences saw float32 rounding noise. After switching to jnp.promote_types(dtype, jnp.float32), bf16 is still promoted to float32, while float64 stays as it is.
  • check_grads in float32 needs inputs that aren't too "sharp": for the same softmax, if you multiply the inputs by 5, the truncation error of finite differences can exceed the default tolerance (tried while writing this chapter). Checking in float64 first (with jax.enable_x64(True):) lets you tell "wrong formula" apart from "not enough precision".

§6 Notes on GPU/TPU

⚠️ None of the kernels in this section were executed on this machine (it only has a CPU). Only two things can be done here: ① cross-lower to TPU to check the block-shape rules; ② read each TPU generation's hardware parameters with pltpu.get_tpu_info_for_chip and do back-of-the-envelope arithmetic. That code actually runs and is checked with assert.

6.1 The memory hierarchy and block sizes

🟢 Must-read

Slow and largeFast and small (where the kernel actually computes)Scalar/control
TPUHBMVMEM (vector memory, 16–128 MiB; see the hardware table in §6.3)SMEM (scalar memory)
GPUHBM (GMEM)SMEM (shared memory, on the order of a hundred KiB per SM) + registers—

A BlockSpec describes exactly "which block each program moves from HBM into on-chip memory". Pallas pipelines automatically (double buffering): while block $n$ is being computed, the DMA engine is already moving block $n+1$. If two adjacent programs use the same input block, the second transfer is skipped. So the grid order determines both correctness (§4.4) and data reuse.

TPU's hard constraint: the last two dimensions of a block shape must be divisible by 8 and 128 respectively, or equal the full size of the corresponding array dimension. This comes from the TPU vector register layout of 8 (sublanes) × 128 (lanes). You can verify this rule on this machine with cross-lowering:

def tpu_lowers(block, shape=(256, 512), dtype=jnp.float32):
    """TPU lowering only (not executed); returns (succeeded, error message)"""
    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)            # note: interpret is off
    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,      # valid: multiples of 8 and 128 / full dims
         (4, 128): False, (64, 100): False, (8, 64): False}       # invalid
for blk, expect in cases.items():
    ok, msg = tpu_lowers(blk)
    print(f"block {str(blk):11s} → {'✅ lowering succeeded' if ok else '❌ ' + msg[:95]}")
    assert ok == expect, blk

# Exception: when the block equals the full array dims, the 8×128 limit doesn't apply
ok, _ = tpu_lowers((4, 100), shape=(4, 100))
print("array (4, 100), block (4, 100) (full dims) →", ok)
assert ok
block (8, 128)    → ✅ lowering succeeded
block (64, 128)   → ✅ lowering succeeded
block (256, 512)  → ✅ lowering succeeded
block (4, 128)    → ❌ The Pallas TPU lowering currently requires that the last two dimensions of your block shape are
block (64, 100)   → ❌ The Pallas TPU lowering currently requires that the last two dimensions of your block shape are
block (8, 64)     → ❌ The Pallas TPU lowering currently requires that the last two dimensions of your block shape are
array (4, 100), block (4, 100) (full dims) → True

Two more points (not verified on this machine):

  • 1-D blocks follow a different rule (official docs: equal to the array length, or a multiple of 1024, or a power of 2 no smaller than 128 × (32 / dtype bit width)). Cross-lowering 1-D blocks on this machine requires specifying a concrete TPU model (the error message suggests jax.sharding.use_abstract_mesh), which we won't go into here.
  • Narrow types like bf16 and int8 are packed along the sublane direction. On this machine a bf16 (8, 128) block passes lowering, but in practice narrow types often use more rows (for example multiples of 16 for bf16) to make full use of the registers. That's a performance recommendation, not a hard rule.

6.2 compiler_params: backend-specific parameters

⚪ Optional: when you actually configure compiler parameters on GPU/TPU

pallas_call(compiler_params=...) takes a backend-specific dataclass. Below, on this machine, we only construct the objects and only lower, to confirm the parameter names in 0.12:

# Construct each backend's CompilerParams (only checks parameter names; no kernel is executed)
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"   # you can also use the enums pltpu.PARALLEL / pltpu.ARBITRARY

# Add TPU compiler parameters to the bf16 matmul from §4.1′ and cross-lower it to TPU (lowering only, not executed)
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("bf16 matmul with dimension_semantics / vmem_limit_bytes: TPU lowering succeeded (not executed)")
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)
bf16 matmul with dimension_semantics / vmem_limit_bytes: TPU lowering succeeded (not executed)

What the parameters mean:

  • TPU pltpu.CompilerParams
    • dimension_semantics: one entry per grid dim, usually written as "parallel first, arbitrary last". Only parallel dims can be split across the TensorCores of a megacore; reduction dims must be arbitrary.
    • vmem_limit_bytes: raises the VMEM limit the compiler is allowed to use. The default scoped limit is usually well below the physical capacity (the exact number varies with chip generation and version); with very large blocks or deep buffering you need to raise it by hand.
  • GPU / Triton pltriton.CompilerParams(num_warps, num_stages): how many warps each program uses, and how many stages the software pipeline has.
  • GPU / Mosaic GPU plgpu.CompilerParams(...): max_concurrent_steps (pipeline depth), dimension_semantics and so on. Writing Mosaic GPU kernels (plgpu.BlockSpec, warpgroups, TMA) is quite different from TPU; see the official GPU docs.

A typical call on GPU looks like this (not executed on this machine):

# Not executed on this machine: tiled matmul on GPU (Triton backend)
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)
# The Triton backend requires every block dim to be a power of 2; on GPU programs run in parallel with no ordering guarantee,
# so the TPU pattern of "accumulating into the same output block along a grid dim" usually becomes a fori_loop inside the kernel on GPU.

6.3 Tuning: block size, arithmetic intensity and the VMEM budget

⚪ Optional: when tuning performance, or estimating block sizes and the VMEM budget

Estimates from the TPU hardware table that ships with 0.12 (pure arithmetic, runs on this machine; numbers come from pltpu.get_tpu_info_for_chip):

print(f"{'Model':6s}{'lanes×sublanes':>16s}{'VMEM(MiB)':>11s}{'HBM BW (GB/s)':>16s}{'bf16 TFLOP/s':>14s}{'Ridge 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        # the hardware origin of the 8×128 rule
assert infos["v6e"].mxu_column_size == 256 and all(infos[v].mxu_column_size == 128 for v in ("v4", "v5e", "v5p"))
Model   lanes×sublanes  VMEM(MiB)   HBM BW (GB/s)  bf16 TFLOP/s   Ridge 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):
    """Arithmetic intensity and VMEM usage (double-buffered) of one program of a tiled matmul"""
    flops = 2 * bm * bk * bn
    hbm_bytes = (bm * bk + bk * bn) * in_bytes          # bytes moved in from HBM per K step (ignoring output write-back)
    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 point ≈ {ridge:.0f} FLOP/B: below this arithmetic intensity you're bandwidth-bound\n")
print(f"{'block (bm,bk,bn)':18s}{'intensity FLOP/B':>16s}{'VMEM use (MiB)':>17s}{'verdict':>10s}")
for t in [(128, 128, 128), (256, 256, 256), (512, 512, 512), (1024, 512, 1024)]:
    ai, vm = tile_stats(*t)
    verdict = "compute-bound" if ai >= ridge else "bandwidth-bound"
    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          # with bf16 inputs, intensity = bm·bn/(bm+bn), independent of bk
    assert vm < v5e.vmem_capacity_bytes
v5e ridge point ≈ 240 FLOP/B: below this arithmetic intensity you're bandwidth-bound

block (bm,bk,bn)  intensity FLOP/B   VMEM use (MiB)   verdict
(128, 128, 128)                 64              0.2bandwidth-bound
(256, 256, 256)                128              1.0bandwidth-bound
(512, 512, 512)                256              4.0compute-bound
(1024, 512, 1024)              512             12.0compute-bound

Reading the table: in a bf16 matmul, a block's arithmetic intensity is $\frac{b_m b_n}{b_m+b_n}$. A 128³ block only reaches 64 FLOP/B, far below v5e's ridge point of about 240 FLOP/B, so it's held back by HBM bandwidth; a 512³ block reaches 256 and only then enters compute-bound territory, while its double-buffered VMEM usage is only about 4 MiB. That's why matmul blocks on TPU are often 512 or even larger. Bigger blocks mean fewer programs, a pipeline that's harder to fill, and more waste at the boundaries, so in the end you still have to measure.

Tuning checklist (general ideas for GPU/TPU, not executed on this machine):

  1. Estimate with the roofline model first: work out the kernel's arithmetic intensity to see whether it's bandwidth-bound or compute-bound. If it's bandwidth-bound, focus on moving less data (fusion, reuse), not computing less.
  2. Block size: as large as fits in the VMEM/SMEM budget. On TPU, use multiples of 8×128 and align with the MXU (v6e's MXU has 256 columns, v4/v5e/v5p have 128; the table above doesn't print this column, but you can read info.mxu_column_size, which is asserted above); on GPU, use powers of 2 and keep occupancy in mind.
  3. Grid order and reuse: having adjacent programs share input blocks saves DMA. Put reduction dims last; put parallel dims first so they're easy to split across cores.
  4. Precision: bf16 inputs, float32 scratch for accumulation, and convert back to bf16 only at the last step (§4.1′).
  5. Give XLA hints: cost_estimate=pl.CostEstimate(flops=..., transcendentals=..., bytes_accessed=...) helps XLA schedule things around the kernel.
  6. In-place updates: input_output_aliases={0: 0} lets the output reuse an input buffer, saving an allocation (common for things like KV cache updates).
  7. Measure: capture a trace with jax.profiler (Advanced 02 §2.1), and compare speed and correctness against the jnp reference. If XLA is already fast enough, don't write a kernel.
# CostEstimate and input_output_aliases can be passed in interpret mode too (here we only check that they're accepted and the result is correct)
def axpy_inplace_kernel(x_ref, y_ref, o_ref):       # o shares its buffer with 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},                     # input 1 ↔ output 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: result is correct", np.asarray(oa[:4]))
input_output_aliases + CostEstimate: result is correct [1. 3. 5. 7.]

§7 Practical rules checklist

First decide whether you need a kernel at all

  1. Write a jnp reference first and look at the HLO and the profile (Advanced 02). Only consider Pallas when XLA's fusion boundaries really waste bandwidth (attention, long-row reductions, ragged/quantized computation).
  2. A Pallas kernel must be cross-checked against the jnp reference, with key numbers checked in NumPy float64.

Writing the kernel 3. A kernel only writes Refs and returns None; read and write with x_ref[...], x_ref[pl.ds(i, n)]. There's no pl.load/pl.store in 0.12. 4. Always pass arrays in as inputs; don't capture them in a closure (Pallas raises captures constants). Python scalars may be captured. 5. index_map returns block indices, not element offsets. 6. Use @pl.when(cond) for conditionals; don't test program_id with a Python if. A Python if on a build-time constant is fine. 7. Round the grid up with pl.cdiv. Elementwise computations need no mask; when there's a reduction inside the block you must fill in the identity element (0, -inf...) with jnp.where, and for matmul's K dim you must mask both A and B. 8. Accumulators must be initialized explicitly on the first step. Put the reduction dim last in the grid, and declare it arbitrary on TPU. 9. For bf16 inputs, compute with preferred_element_type=jnp.float32, accumulate in a float32 scratch, and convert back to bf16 only at the last step. 10. Numerical stability: subtract the max in softmax; use the two-pass variance; use online algorithms for very long rows.

Debugging and testing 11. On CPU, check the math with interpret=True, then check TPU semantics (output-block revisits, uninitialized memory) with pltpu.InterpretParams(). After the simulator raises, call pltpu.reset_tpu_interpret_mode_state(). 12. Timings in interpret mode mean nothing for performance. 13. Cross-lower to TPU on this machine (.trace(...).lower(lowering_platforms=("tpu",))) to catch invalid block shapes (8×128) early.

Composing with transformations 14. jit and vmap work directly; block sizes must be static (static_argnames or functools.partial). 15. When you need gradients, wrap with custom_vjp and write the backward pass as a Pallas kernel too; verify with check_grads(..., modes=["rev"]), ideally once more under jax.enable_x64(True).


Hands-on exercises

Exercise 1: fused AXPY (scalars are inputs too) Write a kernel that computes $y = \alpha x + \beta$, where $x$ has length 1000 and the block size is 128 (doesn't divide evenly). $\alpha$ and $\beta$ are JAX scalars and must be passed as inputs as arrays of shape (1,) (not captured by closure), with all programs sharing the same block. Wrap it in jax.jit and cross-check against NumPy float64. Think about it: is a mask needed here?

Self-check interface: axpy(x, alpha, beta): x has shape (1000,), alpha and beta are JAX scalars of shape () (reshape them to (1,) inside the function before passing them to the kernel); returns a result with the same shape as x; must work when wrapped in jax.jit.

Exercise 2: column sums For x of shape (300, 256), compute the sum of each column (output (256,)). Row block br=128, column block bc=128. Requirements: ① put the reduction dim last in the grid; ② mask the rows; ③ run it with both interpret=True and pltpu.InterpretParams(), and cross-check against float64.

Self-check interface: colsum(x, interpret=...): x has shape (300, 256); interpret can be True or pltpu.InterpretParams(); returns the column sums with shape (256,).

Exercise 3: an RMSNorm kernel Implement $\mathrm{RMSNorm}(x) = \dfrac{x}{\sqrt{\mathrm{mean}(x^2) + \epsilon}} \odot w$ ($\epsilon = 10^{-6}$), with x of shape (50, 384), 8 rows per block, and w of shape (1, 384) passed in as an input. Cross-check against float64, and verify on a batch of shape (4, 50, 384) with jax.vmap. (Advanced 05 §5.10 implemented the same operator with C++ FFI, so you can compare the two approaches.)

Self-check interface: rmsnorm_pl(x, w): x has shape (50, 384), w has shape (1, 384); returns a result with the same shape as x; must be batchable with jax.vmap(rmsnorm_pl, in_axes=(0, None)).

Exercise 4: add a gradient to SiLU $\mathrm{silu}(x) = x\,\sigma(x)$, with derivative $\sigma(x)\,\big(1 + x(1-\sigma(x))\big)$. Implement a differentiable silu_pl with two Pallas kernels (forward, backward) plus custom_vjp. Input shape (64, 256), block (16, 128). Verify with check_grads (in both float32 and float64), and compare against jax.grad(jax.nn.silu).

Self-check interface: silu_pl(x): input shape (64, 256), supports both float32 and float64; jax.grad / jax.vjp can differentiate it, and both forward and backward are computed by Pallas kernels.

# ✍️ Your answer: Exercise 1
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 1
try:
    axpy
except NameError as e:
    print(f"⏳ Exercise 1: not answered yet: {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)]:          # try two pairs of α, β to confirm they're runtime inputs
        _y = _f(_x, jnp.float32(_a), jnp.float32(_b))
        assert _y.shape == (1000,), f"Output shape should be (1000,), got {_y.shape}"
        np.testing.assert_allclose(f64(_y), _a * f64(_x) + _b, rtol=1e-6, atol=1e-6,
                                   err_msg=f"With α={_a}, β={_b} the result doesn't match the float64 reference")
    assert "pallas_call" in str(jax.make_jaxpr(axpy)(_x, jnp.float32(1.0), jnp.float32(0.0))), \
        "No pallas_call in axpy: this exercise asks for a Pallas kernel"
    print("✅ Exercise 1: self-check passed")
⏳ Exercise 1: not answered yet: name 'axpy' is not defined
# ✍️ Your answer: Exercise 2
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 2
try:
    colsum
except NameError as e:
    print(f"⏳ Exercise 2: not answered yet: {e}")
else:
    _x = jax.random.normal(jax.random.key(9002), (300, 256)) + 1.0   # add an offset: a missing mask or accumulator init then shows up clearly
    _ref = f64(_x).sum(0)
    for _interp in (True, pltpu.InterpretParams()):
        _r = colsum(_x, interpret=_interp)
        assert _r.shape == (256,), f"Output shape should be (256,), got {_r.shape}"
        np.testing.assert_allclose(f64(_r), _ref, rtol=1e-5, atol=1e-3,
                                   err_msg=f"Column sums are wrong with interpret={type(_interp).__name__} (check the row mask and the accumulator init)")
    assert "pallas_call" in str(jax.make_jaxpr(colsum)(_x)), "No pallas_call in colsum: this exercise asks for a Pallas kernel"
    print("✅ Exercise 2: self-check passed")
⏳ Exercise 2: not answered yet: name 'colsum' is not defined
# ✍️ Your answer: Exercise 3
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 3
try:
    rmsnorm_pl
except NameError as e:
    print(f"⏳ Exercise 3: not answered yet: {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"Output shape should be (50, 384), got {_o.shape}"
    np.testing.assert_allclose(f64(_o), _rms_ref64(_x, _w), rtol=1e-5, atol=1e-6,
                               err_msg="RMSNorm on a single input doesn't match the float64 reference (check ε, the mean's axis, the tail block)")
    _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="The batch result after vmap is wrong")
    assert "pallas_call" in str(jax.make_jaxpr(rmsnorm_pl)(_x, _w)), "No pallas_call in rmsnorm_pl: this exercise asks for a Pallas kernel"
    print("✅ Exercise 3: self-check passed")
⏳ Exercise 3: not answered yet: name 'rmsnorm_pl' is not defined
# ✍️ Your answer: Exercise 4
# Write your code here, then run the 🧪 Self-check below. Reference solutions are in the appendix at the end of this chapter.
# 🧪 Self-check: Exercise 4
try:
    silu_pl
except NameError as e:
    print(f"⏳ Exercise 4: not answered yet: {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="The forward result is wrong")
    _ct = jax.random.normal(jax.random.key(9005), (64, 256))    # a random cotangent, not just the all-ones from sum()
    try:
        _dx = jax.vjp(silu_pl, _x)[1](_ct)[0]
    except Exception as e:
        raise AssertionError(f"Taking the VJP of silu_pl failed ({type(e).__name__}): check that the custom gradient rule is defined") from e
    np.testing.assert_allclose(f64(_dx), f64(_ct) * _sig * (1 + _x64 * (1 - _sig)), rtol=1e-5, atol=1e-5,
                               err_msg="The backward (VJP) result is wrong")
    _n_pl = str(jax.make_jaxpr(jax.grad(lambda x: silu_pl(x).sum()))(_x)).count("pallas_call")
    assert _n_pl >= 2, f"The gradient graph has only {_n_pl} pallas_call(s): both forward and backward should be computed by Pallas kernels"
    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"check_grads failed in float64: {str(e)[:300]}") from None
    print("✅ Exercise 4: self-check passed")
⏳ Exercise 4: not answered yet: name 'silu_pl' is not defined

Quiz

1. A pallas_call has grid (4, 6), and one input has BlockSpec((32, 64), lambda i, j: (j, i)). Which region of that input does program (2, 5) read?

Answer

index_map returns block index (5, 2); multiplying by the block shape gives the start (160, 128), so the region is [160:192, 128:192]. index_map always returns block indices, never element offsets (§2.2).

2. What happens if you call a pallas_call without interpret on a CPU-only machine? What's the difference between interpret=True and interpret=pltpu.InterpretParams()?

Answer

You get ValueError: Only interpret mode is supported on CPU backend.. interpret=True is the generic interpreter: it turns the grid into a jit-compiled loop and only checks the math. pltpu.InterpretParams() is the TPU simulator: it additionally simulates VMEM/HBM semantics and can catch TPU-only errors such as non-consecutive revisits of output blocks and uninitialized memory. Both only check correctness and say nothing about performance (§0, §3).

3. If you do jnp.sum over a vector of length 1000 with block size 128 and no mask, what do you get? Why doesn't the elementwise x * 2 need a mask?

Answer

The last block has 24 out-of-bounds elements that read undefined garbage (NaN in interpret mode), so the sum gets polluted (NaN under interpret). In an elementwise computation, results at out-of-bounds positions are discarded on write and don't affect the valid elements; a reduction, on the other hand, mixes garbage into the valid result (§3.2, §4.3).

4. Why is a tiled matmul's grid written as (M/bm, N/bn, K/bk) and not (K/bk, M/bm, N/bn)? Would the mistake be caught under interpret=True?

Answer

The output block's index doesn't depend on k. Only with k last are all visits to the same output block consecutive, so the output Ref or a scratch can serve as the accumulator. On TPU, as soon as the output block index changes, the block is written back to HBM and a new one is swapped in, without reading back old values, so non-consecutive visits lose partial sums. interpret=True may happen to compute the right answer, while pltpu.InterpretParams() reports a Revisited block error (tested in §4.4).

5. What happens if you call jax.grad directly on a function that contains pallas_call? What's the right approach? And jax.jvp?

Answer

pallas_call only has an automatically generated JVP rule, with no partial eval or transpose rule, so grad fails at the linearize step with ValueError: Linearization failed.... The fix is to wrap it with jax.custom_vjp, implement the backward pass as a Pallas kernel too, and verify with check_grads(..., modes=["rev"]). A direct jvp works for simple kernels but fails when some input tangents are zero or when program_id is used; once wrapped in custom_vjp, jvp doesn't work at all (TypeError). When you need reliable forward mode, use custom_jvp instead (§5.3).

6. On TPU, what happens with a (256, 512) float32 array using blocks (4, 128) and (64, 100)? For a bf16 matmul with 128³ vs. 512³ blocks, which is more likely to be bandwidth-bound, and why?

Answer

Both blocks fail at the lowering stage: the last two dims must be divisible by 8 and 128 respectively, or equal the full array dims. Cross-lowering on this machine has verified this (§6.1). A bf16 matmul block's arithmetic intensity is about $b_m b_n/(b_m+b_n)$: 64 FLOP/B for 128³, 256 FLOP/B for 512³. v5e's ridge point is about 240 FLOP/B, so the 128³ block is bandwidth-bound (§6.3).


Chapter summary

  1. Where it fits: XLA's fusion is heuristic and leaves kernel boundaries at "reduce → broadcast", heavy ops and ragged computation (Advanced 02 §2.6). Pallas lets you hand-write kernels in JAX-style Python: it's the pallas_call primitive in the jaxpr, compiled by Mosaic on TPU, by Triton or Mosaic GPU on GPU, and interpret-only on CPU. Compared with C++ FFI (Advanced 05), it needs no extra compiler toolchain, and jit/vmap work directly.
  2. Programming model: a kernel only reads and writes Refs; grid decides how many programs run; BlockSpec(block_shape, index_map) decides which block each program moves, with index_map returning block indices; pl.program_id / pl.num_programs tell a program where it is; @pl.when does conditional side effects.
  3. Interpret mode (tested on 0.12): interpret=True is the generic interpreter; interpret=pltpu.InterpretParams() is the TPU simulator (note that it's in pltpu); plgpu.InterpretGPUParams() is the Mosaic GPU simulator. All three only check correctness. Out-of-bounds parts are filled with NaN, and so are uninitialized output blocks.
  4. Three iron rules for advanced kernels: initialize accumulators first; put the reduction dim last in the grid; whenever there's a reduction inside a block, mask (fill in the identity element with jnp.where, and mask both A and B in a matmul). Numerically stable formulations (subtracting the max, two-pass variance, online logsumexp) apply inside kernels too.
  5. Transformations: jit and vmap work directly (vmap adds a grid dim). pallas_call has an automatic JVP rule but no partial eval or transpose rule (it fails at the linearize step), so grad needs custom_vjp plus a Pallas backward kernel, verified with check_grads(modes=["rev"]); a direct jvp only works in simple cases.
  6. Hardware: TPU blocks must follow the 8×128 rule (verifiable on this machine with cross-lowering); compiler_params is backend-specific; the heart of tuning is picking blocks as large as the VMEM/SMEM budget allows so the arithmetic intensity clears the ridge point, and ordering the grid for data reuse.

Cheat sheet

API (JAX 0.12)Purpose
from jax.experimental import pallas as plMain Pallas module
from jax.experimental.pallas import tpu as pltpuTPU (Mosaic) specific: VMEM, SMEM, CompilerParams, InterpretParams
from jax.experimental.pallas import triton as pltriton / mosaic_gpu as plgpuThe two GPU backends
pl.pallas_call(kernel, out_shape, grid=, in_specs=, out_specs=, scratch_shapes=, compiler_params=, interpret=)Build a kernel call; returns an ordinary callable
jax.ShapeDtypeStruct(shape, dtype)Declare the output shape (out_shape)
x_ref[...] / o_ref[...] = v / o_ref[...] += vRead / write / read-modify-write a Ref
x_ref[pl.ds(start, size)]Dynamic slice
pl.BlockSpec(block_shape, index_map)Block shape + grid indices → block indices
None or pl.squeezed as one block dimThat dim has size 1 and is squeezed out in the kernel
pl.program_id(axis) / pl.num_programs(axis)Index of the current program / number of programs along that dim
@pl.when(cond)Run when the condition is true (for initialization, writing out on the last step)
pl.cdiv(a, b)Ceiling division, for computing grid sizes
lax.broadcasted_iota(jnp.int32, shape, dim) + jnp.whereBuild boundary masks
jnp.dot(a, b, preferred_element_type=jnp.float32)Matrix multiply inside a block, with the accumulation precision specified
scratch_shapes=[pltpu.VMEM(shape, dtype)]On-chip scratch (persists across programs); the kernel takes one more Ref
interpret=TrueGeneric interpreter on CPU (correctness only)
interpret=pltpu.InterpretParams(...)TPU simulator: output-block revisit check, uninitialized_memory, detect_races, grid_point_recorder
pltpu.reset_tpu_interpret_mode_state()Reset state after the simulator raises
pl.debug_print(fmt, *args)Print from inside a kernel
pltpu.CompilerParams(dimension_semantics=("parallel", "arbitrary"), vmem_limit_bytes=)TPU compiler parameters
pltriton.CompilerParams(num_warps=, num_stages=)GPU (Triton) compiler parameters
pl.CostEstimate(flops=, transcendentals=, bytes_accessed=)Cost hint for XLA
input_output_aliases={in_idx: out_idx}Output reuses an input buffer
jax.jit(f).trace(*sds).lower(lowering_platforms=("tpu",))Cross-lower to TPU on CPU (checks block shapes, doesn't execute)
pltpu.get_tpu_info_for_chip(pltpu.ChipVersion.TPU_V5E, 1)VMEM, bandwidth and compute parameters of each TPU generation
jax.custom_vjp + f.defvjp(fwd, bwd)Add reverse-mode derivatives to a Pallas kernel
jax.test_util.check_grads(f, args, order=1, modes=["rev"])Numerically check gradients
with jax.enable_x64(True):Temporarily enable float64 (for stricter gradient checks)

Appendix: reference solutions

Write your own answer in the "✍️ Your answer" cell in the main text first and check it with the "🧪 Self-check"; come here to compare once the self-check passes or you're truly stuck. Running the solutions below in order overwrites any functions with the same names you defined in the answer cells.

Exercise 1

See "Hands-on exercises" in the main text for the task.

# Reference solution: Exercise 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] reads the scalar
    scalar_spec = pl.BlockSpec((1,), lambda i: (0,))         # every program takes block 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("Exercise 1 is correct. No mask needed: in an elementwise computation the out-of-bounds part is discarded on write.")
Exercise 1 is correct. No mask needed: in an elementwise computation the out-of-bounds part is discarded on write.

Exercise 2

See "Hands-on exercises" in the main text for the task.

# Reference solution: Exercise 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)                                  # the reduction dim (row blocks) is the last grid dim
        @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)),          # (column blocks, row blocks)
                          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"Exercise 2 ({type(interp).__name__}) is correct, max error {max_err(r, ref_e2):.2e}")
Exercise 2 (bool) is correct, max error 5.05e-06
Exercise 2 (InterpretParams) is correct, max error 5.05e-06

Exercise 3

See "Hands-on exercises" in the main text for the task.

# Reference solution: Exercise 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)               # reuse the row-wise helper from §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("Exercise 3 is correct: both a single input and the vmapped batch match float64", rb.shape)
Exercise 3 is correct: both a single input and the vmapped batch match float64 (4, 50, 384)

Exercise 4

See "Hands-on exercises" in the main text for the task.

# Reference solution: Exercise 4
BLK = (16, 128)
def _tiled(kernel, *args):
    """Elementwise call over 2-D blocks (output shape and dtype same as the first argument)"""
    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),       # residual is x (the backward pass recomputes 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)
# Finite-difference error in float32 grows with the number of elements, so float32 only checks a one-block subarray; the full shape is checked in 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("Exercise 4 is correct: forward and gradient cross-checked, and check_grads passes in both float32 and float64")
Exercise 4 is correct: forward and gradient cross-checked, and check_grads passes in both float32 and float64