the path · 0/15
start the path

the kernel path · stage 1 · Pallas fundamentals · lesson 03 of 3

The scalar world

A kernel that decides has to keep the number it decides on somewhere, and a vector register is the wrong place.

the goal Write kernels whose block selection and control flow come from data: place scalars in SMEMScalar memory: lengths, flags, and indices live here, feeding control flow without ever entering the vector datapath.taught in /l/tpu →, prefetch the index arrays a schedule depends on, and say precisely which work a branch can skip and which it cannot.

mastery work · this chapter0/2
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
§ 01

The memory that answers questions

SMEMScalar memory: lengths, flags, and indices live here, feeding control flow without ever entering the vector datapath.taught in /l/tpu → is small, low-latency, randomly addressable, and reads or writes 32 bits per instruction. Set that against the 4 KiB granularity of a VMEMThe TPU’s software-managed vector scratchpad, about 128 MiB. Blocks must be staged here before compute touches them; what is resident is what your schedule staged.taught in /l/tpu → transaction and the trade is clear: SMEM moves almost nothing per instruction and has no alignment requirement to satisfy, which is exactly the shape of a single sequence length or loop bound. The reference's rule of thumb is short. Any data used to perform control-flow decisions should be placed in SMEM.

The chapter at /l/tpu puts the scalar core beside the MXUThe systolic matmul array: 128x128 on v5e, 256x256 on v6e. Matmuls only; everything else is the VPU’s job.taught in /l/tpu → and the VPUThe vector unit for elementwise work, organized as (8, 128) lanes; the origin of the tiling lattice every layer above obeys.taught in /l/tpu → and explains why the usual two-block diagram is incomplete. This page is the programming model that unit exposes: what you may put in front of it, when it runs relative to everything else, and what it is allowed to decide.

§ 02

Prefetch is an ordering guarantee, not a copy

Every index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → so far took grid coordinates and nothing else, which is what lets the pipeline evaluate it before any real data exists. PrefetchScalarGridSpec widens the input to that function, and the widening is the whole feature. With num_scalar_prefetch=n, the first n arguments are placed in SMEMScalar memory: lengths, flags, and indices live here, feeding control flow without ever entering the vector datapath.taught in /l/tpu → with no BlockSpec of their own, and every subsequent spec's index map receives those SMEM refs after the grid indices.

The reason this needs an API rather than an ordinary read is scheduling, not syntax. The index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → runs in order to decide which copy to issue. If the value it reads were itself staged by the pipeline, the decision would depend on a transfer that the decision was supposed to schedule. Prefetch resolves that by landing the scalars before the pipeline's first step exists, so by the time any index map runs, the numbers are already there.

Three orderings follow from that and they are easy to transpose. The index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → takes grid indices first, then prefetch refs. The kernel body takes prefetch refs first, then inputs, then outputs, then scratch. The caller passes prefetch arguments before the real ones.

the three signatures, schematic, from the scalar prefetch guide
def index_map(*grid_indices, *prefetch_refs):
    ...

def kernel(*prefetch_refs, *input_refs, *output_refs, *scratch_refs):
    ...

kernel = pl.pallas_call(...)
result = kernel(*prefetch_args, *input_args)
§ 03

The prefetch map

Once an index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → can read data, a schedule becomes something you compute. The pattern the sparse guide builds is a prefetch map: an array with one entry per grid position holding the index of the next non-zero block, computed in ordinary JAX outside the kernel and passed in as a prefetch argument. The index map then returns that entry instead of a coordinate derived from the grid.

What this buys is not a faster loop, it is a smaller iteration space. The block-sparse matmul in that guide runs a grid of (N // blk_N, num_blocks) where the second axis walks the non-zero blocks of a compressed representation, so blocks that are entirely zero are never a grid point at all. The guide notes in passing that the grid size itself does not have to be static.

the index map reads the map instead of the grid (scalar prefetch guide)
def mask_index_map(prefetch_map, i, j, ...):
  next_nonzero_block = prefetch_map[i, j]
  return (next_nonzero_block, 0, 0)
§ 04

Kernels that decide

A sparse grid breaks an assumption the dense one gave you for free: consecutive steps no longer reliably share an output block. So the kernel has to work out for itself when a new accumulation starts and when the running total is finished. The DSD kernel does exactly that by comparing its block index against its neighbors in the prefetched array, zeroing the accumulator when the block changed and flushing to the output when it is about to change again. Both decisions read only SMEM.

Now the distinction that governs everything in this lesson. A branch in the body decides what happens to a block; it cannot decide whether the block arrives. The copy was issued by the pipeline emitter from the index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas →, before your body ran. Wrapping compute in pl.when saves the compute and pays the transfer anyway.

The index map decides what moves. A branch in the body only decides what happens to it once it has arrived.

Which gives the actual technique for skipping a block: make the index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → not ask for it. Point a step at the same block its predecessor used, and the backend skips the transfer entirely, because consecutive steps on the same slice reuse what is already resident. A prefetch map that repeats an index is a schedule that repeats no work.

the accumulator flush, from the guide's block-sparse matmul
blk_idx = pl.program_id(1)
is_start = blk_idx == 0
changed_blocks = (idxs_i_ref[blk_idx] != idxs_i_ref[jnp.maximum(blk_idx-1, 0)])
@pl.when(is_start | changed_blocks)
def _():
  accum_scratch[...] = jnp.zeros_like(accum_scratch)
accum_scratch[...] += jnp.dot(x_ref[0, :, :], y_ref[...], preferred_element_type=jnp.float32)

next_block_change = (idxs_i_ref[blk_idx] != idxs_i_ref[jnp.minimum(blk_idx+1, num_blocks)])
is_end = blk_idx == (num_blocks - 1)
@pl.when(is_end | next_block_change)
def _():
  o_ref[...] = accum_scratch[...].astype(o_ref.dtype)
§ 05

What may be data, and what may not

Ragged work is a fixed tile whose count and placement are data. Block indices can be data, through the index map. Loop bounds inside the body can be data, through a scalar in SMEM. The grid size can be data. With pltpu.emit_pipeline even the block extent can move, using pl.BoundedSlice in the block shape and pl.ds in the index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas →, so consecutive steps copy differently sized chunks.

What cannot be data is the tile the vector registers are laid out for. That is the lattice from the blockspec lesson, and no amount of prefetching relaxes it. So the ragged kernel you write is not a kernel with a variable shape; it is a kernel with a fixed shape whose schedule was computed at run time.

One cost to plan for, since the guide names it: the prefetch step runs before the main pipeline begins, so scalar arrays large enough to matter push out the start of real work. Page tables and sequence lengths are the intended size. This mechanism earns its place when the alternative schedule cannot be written at all, not when it would merely have been slightly clumsier.

dynamic block extents inside emit_pipeline (TPU pipelining guide)
def index_map(i):
    start = slices_smem[i, 0]
    size = slices_smem[i, 1] - slices_smem[i, 0]
    return (pl.ds(start, size), 0)

block_spec = pl.BlockSpec(block_shape=(pl.BoundedSlice(8), 128),
                          index_map=index_map)
§ 01

Dynamic slices and edge masks

pl.dsA dynamic slice of a ref inside the kernel: reads only the chunk, at an offset that can depend on runtime values.taught in /l/pallas →(start, size) slices a ref dynamically from inside the kernel body, at a start position computed at run time rather than fixed when the kernel is traced. Call it on a ref and you read only that chunk into a value, not the whole block. The compiler does not need to know the start position ahead of time, only that the size stays fixed and the memory space supports it, which is what separates it from ordinary Python slicing: a Python slice needs its bounds known when the kernel is traced, and pl.ds is exactly the escape hatch for when they are not.

That start position often comes from the same place a scalar comes from anywhere else in a kernel: an SMEMScalar memory: lengths, flags, and indices live here, feeding control flow without ever entering the vector datapath.taught in /l/tpu → ref holding a value read earlier in the kernel body, a per-sequence length or a computed row offset. Combine the two ideas and a kernel can walk a ragged sequence by reading its true length out of SMEM once, then using pl.dsA dynamic slice of a ref inside the kernel: reads only the chunk, at an offset that can depend on runtime values.taught in /l/pallas → with that length to read only the valid rows, never touching the padded tail at all inside the loop that matters.

This matters most at the edges of the grid itself, not just inside a single dynamic read. A grid built for a block size that does not evenly divide the array leaves a partial block on the last step: the block is still shaped for the full block size, but only part of it holds real data, and the rest holds whatever was left over from stale VMEMThe TPU’s software-managed vector scratchpad, about 128 MiB. Blocks must be staged here before compute touches them; what is resident is what your schedule staged.taught in /l/tpu → on a previous step. The fix is a mask, and it starts with an iota, generating a per-lane index inside the block and comparing it against the array's true bound to decide which lanes are real and which are leftover.

Multiply the loaded values by that mask before you use them, or select against a padding value, and the partial block behaves correctly no matter how far past the true bound the physical block extends. Do the masking after a reduction has already mixed real and stale values together, rather than before, and the mask no longer has anything valid left to select between: the damage is already folded into the sum. Get the comparison direction wrong, or compare the iota against the block's size instead of the array's true size, and the kernel reads or writes past the array's real edge without raising an error to tell you so.

None of this masking machinery is needed when the array happens to divide evenly by the block size, which is exactly the common path section one's divisibility rule already covers. The mask matters specifically for the boundary case: an array whose last dimension does not divide cleanly by the block, where the grid still has to walk in fixed-size steps, and the very last step is the one that runs past the edge.

pl.dsA dynamic slice of a ref inside the kernel: reads only the chunk, at an offset that can depend on runtime values.taught in /l/pallas → works on any ref, not only the array carrying the main algebra of the kernel. It applies just as well to a VMEMThe TPU’s software-managed vector scratchpad, about 128 MiB. Blocks must be staged here before compute touches them; what is resident is what your schedule staged.taught in /l/tpu → block the pipeline staged, to scratch memory you allocated yourself, or to a start position computed from a value you read out of SMEMScalar memory: lengths, flags, and indices live here, feeding control flow without ever entering the vector datapath.taught in /l/tpu → a few lines earlier. The pattern is the same regardless of which memory space the ref sits in; only the cost of the read or write it performs changes.

Verified in interpret mode, jax 0.4.38. Notice the iota is compared against the array's true size, not the block size.
def sum_ragged_kernel(x_ref, o_ref, *, n_valid, rows):
    i = pl.program_id(0)
    x = x_ref[...]
    idx = i * rows + jax.lax.broadcasted_iota(jnp.int32, x.shape, 0)
    x = jnp.where(idx < n_valid, x, 0.0)
    @pl.when(i == 0)
    def _():
        o_ref[...] = jnp.zeros_like(o_ref)
    o_ref[...] += jnp.sum(x, axis=0, keepdims=True)
§ 02

Scalar prefetch: schedules that read data

Every BlockSpecHow one array is carved for the grid: a block shape plus an index map saying which block each grid step sees.taught in /l/pallas → so far has an index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → that depends only on the grid coordinate: something the compiler can evaluate before it ever sees real data. PrefetchScalarGridSpec breaks that constraint. It copies scalar arrays into SMEMScalar memory: lengths, flags, and indices live here, feeding control flow without ever entering the vector datapath.taught in /l/tpu → before the pipeline starts running, and makes those SMEM values available to the index map itself, so which block gets loaded on step 5 can depend on what a specific input value actually is, not just on which step number 5 happens to be.

scalar prefetch: the scalars land before the pipeline starts, so the index maps themselves can read data
perm → SMEM [3, 0, 2, 1]before any grid step then the pipeline starts step 0 index map reads perm[0]fetches block 3 step 1 reads perm[1]fetches block 0 step 2 reads perm[2]fetches block 2 step 3 reads perm[3]fetches block 1 visible to every index map the schedule read data: this is the splash and ragged-attention mechanism

That ordering is the whole reason this works rather than deadlocking on itself. An index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → that reads a value out of a ref the pipeline has not staged yet would have nothing to read; PrefetchScalarGridSpec solves that by moving the scalar arrays into SMEMScalar memory: lengths, flags, and indices live here, feeding control flow without ever entering the vector datapath.taught in /l/tpu → before the pipeline's normal staging even begins, as a separate step that happens first. By the time step 0 of the main pipeline runs, every scalar the schedule depends on is already sitting in SMEM, not still in flight, so the index map's read of it is never racing the copy that produced it.

That is the mechanism, not a special case bolted on top of it. It is exactly what splash attention and ragged paged attention use to route each query to the right block of keys and values, where the routing decision is data (which page a token belongs to, how long a sequence really runs) rather than a static grid pattern known ahead of time. A fixed grid walk has no way to express "load whichever block this particular query needs"; a data-dependent index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → is the only way to say that at all.

The scalar arrays involved stay small on purpose. Page tables, sequence lengths, and block indices are the kind of thing that fits in SMEMScalar memory: lengths, flags, and indices live here, feeding control flow without ever entering the vector datapath.taught in /l/tpu → cheaply; the actual keys and values, the large data the routing decision points at, still move through the ordinary VMEMThe TPU’s software-managed vector scratchpad, about 128 MiB. Blocks must be staged here before compute touches them; what is resident is what your schedule staged.taught in /l/tpu → pipeline once the index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → has decided which block of them to fetch. Scalar prefetch decides where to look. It is not a replacement for the pipeline that does the looking.

The tradeoff is set-up complexity against schedule flexibility. An ordinary BlockSpecHow one array is carved for the grid: a block shape plus an index map saying which block each grid step sees.taught in /l/pallas →'s index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → is pure and needs no extra wiring around it, while PrefetchScalarGridSpec needs the scalar arrays carved out as a separate argument group before the grid spec is even built. That extra step is exactly what buys back the ability to route on data instead of on a fixed pattern known ahead of time.

None of this is free at the framework level, either. The prefetch step runs before the main pipeline begins, so scalar arrays large enough to matter still cost some setup time before the first block of the real computation starts. The pattern earns its keep specifically when the alternative is a schedule that cannot be expressed at all, not when the alternative is a marginally simpler static index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → that would have worked just as well.

Verified in interpret mode, jax 0.4.38. Notice the index map function takes the prefetched scalar ref as an extra argument, not just the grid coordinates.
def permute_kernel(perm_ref, x_ref, o_ref):
    o_ref[...] = x_ref[...]

def permute_blocks(x, perm, bm=64):
    m, d = x.shape
    grid_spec = pltpu.PrefetchScalarGridSpec(
        num_scalar_prefetch=1,
        grid=(m // bm,),
        in_specs=[pl.BlockSpec((bm, d), lambda i, perm: (perm[i], 0))],
        out_specs=pl.BlockSpec((bm, d), lambda i, perm: (i, 0)),
    )
    return pl.pallas_call(
        permute_kernel,
        grid_spec=grid_spec,
        out_shape=jax.ShapeDtypeStruct(x.shape, x.dtype),
        interpret=True,
    )(perm, x)
§ 03

Exercises

exercise Take the scratch-accumulator matmul from this guide and change the scratch dtype to bfloat16. In interpret mode, measure the max error against jnp.dot at K = 2048 for both versions, then explain the difference in one sentence about rounding events.
exercise Before running anything: for a (512, 512) array with block shape (128, 256) and index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → lambda i, j: (j, i), write down which element offsets grid step (1, 0) reads. Then verify with a kernel that writes pl.program_id values into its output.
exercise Break the lattice on purpose: pick a block shape of (12, 100) for an f32 array and read the compile error against the museum's lattice exhibit. Name the two numbers the message quotes and where each comes from.
exercise The ragged kernel on the bench ran 1.98x faster at a mean length fraction of 0.414. Predict the speedup at a fraction of 0.25 before computing it, then say which overhead keeps the measured number below the ideal 1/fraction.
exercise Print the MosaicThe MLIR dialect Pallas lowers to, and the last layer of the TPU stack you can read; only LLO below it is closed.taught in /l/mosaic → module for the scalar-prefetch permute kernel with debug=True. Find your index mapThe BlockSpec function that returns block coordinates (not element offsets) for each grid step; Pallas multiplies by the block shape to find elements.taught in /l/pallas → compiled into a transform function and confirm the prefetched scalars arrive as arguments before any ref.
before you move on

Check yourself

01 Why does scalar prefetch need an API rather than an ordinary read?

The index map runs to schedule copies before real data exists; prefetch lands the scalars before the first step so no decision depends on a transfer it was supposed to schedule.

02 What can a branch in the body never do?

Prevent a block from arriving: the copy was issued from the index map before the body ran. pl.when saves the compute and pays the transfer anyway.

assigned

Readings