Skip to content

Mixed Kernels

Cube and vector working at the same time, inside one scope.

Prerequisites: Tiled matmul. Companion file: examples/advanced/03_mixed_kernel.py.

What you are building

a @ b + bias — a cube operation followed by a vector one — written so the two units overlap instead of taking turns.

Why bother

A core group pairs one cube unit with vector units. Written the obvious way, the chain occupies them one after the other:

with pl.at(level=pl.Level.CORE_GROUP, name_hint="cube_only"):
    acc = pl.matmul(a, b, out_dtype=pl.FP32)
with pl.at(level=pl.Level.CORE_GROUP, name_hint="vector_only"):
    out[:] = pl.add(acc, bias)

Two scopes, two dispatches. The vector units have nothing to do until the matmul scope has finished, and the cube unit has nothing to do afterwards. This is the form a mixed kernel replaces — and it is what examples/intermediate/01_fused_linear.py-style "fused" kernels often still are underneath: fused in name, sequential in execution.

Step 1: one scope, split

Put both operations in one scope and mark it split:

import pypto.language as pl
import torch
from pypto.runtime import RunConfig

@pl.jit
def mixed(
    a: pl.Tensor[[128, 256], pl.FP16],
    b: pl.Tensor[[256, 128], pl.FP16],
    bias: pl.Tensor[[128, 128], pl.FP32],
    out: pl.Out[pl.Tensor[[128, 128], pl.FP32]],
):
    with pl.at(
        level=pl.Level.CORE_GROUP,
        optimizations=[pl.split(pl.SplitMode.UP_DOWN)],
        name_hint="mixed",
    ):
        acc = pl.matmul(a, b, out_dtype=pl.FP32)                 # cube (AIC)
        out[:] = pl.add(acc, bias)        # vector (AIV)
    return out

torch.manual_seed(0)
a = torch.randn(128, 256, dtype=torch.float16)
b = torch.randn(256, 128, dtype=torch.float16)
bias = torch.randn(128, 128, dtype=torch.float32)
out = torch.zeros(128, 128, dtype=torch.float32)
mixed(a, b, bias, out, config=RunConfig(platform="a2a3sim"))
assert torch.allclose(out, a.float() @ b.float() + bias, rtol=1e-2, atol=1e-2)

pl.split(mode) marks the scope as mixed, and the mode names the axis along which the vector sub-region is halved. The cube sub-region stays full-sized: the split shards the vector work across the two AIV lanes, and the compiler inserts the cross-core transfers (aiv_shard at cube→vector boundaries, aic_gather on the way back) that carry results between the units. Overlap comes from cube and vector running concurrently, not from each taking half of one tile.

Mode Halves the vector sub-region along
pl.SplitMode.UP_DOWN Rows (height)
pl.SplitMode.LEFT_RIGHT Columns (width)
pl.SplitMode.NONE No split

Which one to pick follows from the vector operands' shape: halve the axis that is large enough to divide across two lanes evenly. Run the companion file with --mode left_right to compare.

Step 2: the ring spends your vector budget

The transfers the compiler inserted are not free. Every tile crossing the boundary lands in a ring buffer carved out of the consuming core's on-chip memory — UB here, since the cube feeds the vector units:

Quantity Value
Tile crossing the boundary [128, 128] FP32 = 64 KB
Default ring depth 2 slots
Ring size 2 × 64 KB = 128 KB
Vector budget 184 KB

The ring is a queue of whole tiles, so its size scales with the tile that crosses, not with the work. The default of 2 is the shallowest depth that still double-buffers: the cube can fill one slot while the vector drains the other.

pl.cross_core_slot(slot_num=N) retunes it. Deeper rings buy more overlap — the producer runs further ahead before it blocks — so raise it when the two units are poorly balanced. But the budget is tight: at slot_num=4 this kernel already fails to allocate.

with pl.at(
    level=pl.Level.CORE_GROUP,
    optimizations=[pl.split(pl.SplitMode.UP_DOWN), pl.cross_core_slot(slot_num=4)],
    name_hint="mixed",
):
Vec buffer usage (294912 bytes) exceeds platform limit (188416 bytes). The first 262144
bytes of that space are reserved by system.reserve_buffer, so tiles are allocated above
them — this is the cross-core pipe ring. Lower its depth with
optimizations=[pl.cross_core_slot(slot_num=N)] on the enclosing pl.at(...), or shrink the
tile that crosses the cube/vector boundary

Two levers when that happens: shrink the tile, or shorten the ring. Pick the largest depth that fits.

Step 3: what the compiler inserted

pl.split is the automatic path. Underneath, the cross-core dataflow is explicit operators, and you can write them yourself:

Operator Role
pl.aic_initialize_pipe / pl.aiv_initialize_pipe Set up the pipe
pl.tpush_to_aiv / pl.tpush_to_aic Push a tile to the peer core
pl.tpop_from_aic / pl.tpop_from_aiv Pop a tile the peer pushed
pl.tfree_to_aic / pl.tfree_to_aiv Release the popped slot back to the producer
pl.aiv_shard / pl.aic_gather Shard across AIV lanes, gather back on AIC
pl.split_aiv(n, mode=...) The explicit region form of the split

Every push must be paired with a pop, and every pop with a tfree. A missing tfree does not error — it leaks a ring slot, and the producer stalls once the ring fills.

The explicit form also makes cross-lane ordering yours. A boundary operator orders only the value it carries. Nothing orders a cube-lane write against a vector-lane read of the same GM buffer. Publish and fence the producer's writes, place a cross-core pl.system.syncall between those phases, then invalidate the consumer's cache before it reads; the barrier alone only synchronizes arrival. Use the soft form when the launch may have partial occupancy, and use whole-GM cache maintenance when the buffer may span multiple cache lines. The pl.split path above does not need this sequence — the compiler inserts the transfers, and the result is checked against torch. See Scopes and Placement for the rules.

Reach for the explicit form when pl.split cannot express the shape: per-lane addressing, a gather that only one lane can compute, or a region that mixes split and unsplit work. tests/st/codegen/dsl/test_split_aiv_gather_row_codegen.py is a worked example. Otherwise stay on pl.split — it inserts the same operators and gets the pairing right.

For the machine-level contract see TPUSH/TPOP; for what the pass does, ExpandMixedKernel.

Edge Cases

Fatal pitfall: the ring is sized in whole tiles at the cube/vector boundary. A tile that grows turns a working kernel into one that cannot be allocated, and the error names a byte count rather than the tile — read it as "the crossing tile is too big, or the ring too deep".

Symptom Likely cause Fix
Vec buffer usage ... exceeds platform limit The ring plus the tiles overrun the on-chip budget Lower pl.cross_core_slot(slot_num=N), or shrink the crossing tile
No speedup from pl.split One side dominates, so halving cannot overlap anything Check the work is genuinely cube-then-vector
The producer stalls after a while A popped slot was never tfreed Match every pop with a tfree
Split rejected on a scope The body mixes split and plain full-width vector ops Use the explicit pl.split_aiv region form

The same shape in a real model

examples/models/qwen3_jit/ is a @pl.jit decode path split one file per module, and its kernels/projection.py is this page's pattern at model scale — a matmul and the vector work that consumes it, inside one scope.

File Module
qwen3_decode.py The decode entry that composes the rest
config.py Shapes and dtypes the kernels are specialised on
kernels/projection.py Mixed cube + vector projection
kernels/attention.py Attention
kernels/mlp.py MLP
kernels/rmsnorm.py RMSNorm

Next

Shaping the task graph — from inside one kernel to the order between kernels.