This document describes the canonical coding style for writing pl.function
kernels in PyPTO-Lib.
import pypto.language as plpl is the only accepted module alias.
A PyPTO-Lib kernel can be written in two parallel forms — module-level
@pl.jit functions or a @pl.program class. Both lower through the same
compiler pipeline. Pick one per kernel and do not mix them: a @pl.program
method calling a @pl.jit kernel (or the reverse) is discouraged.
Either way, signatures look the same — tensor params are
pl.Tensor[[shape...], dtype], outputs are wrapped in pl.Out[...], scalars
are pl.Scalar[dtype] — and the function is written as an opaque function:
the frontend does not draw the InCore / Orchestration boundary explicitly.
Each compute region is wrapped in with pl.at(level=pl.Level.CORE_GROUP, ...)
and the compiler lowers that region to InCore; code outside any pl.at block
stays in orchestration (host / AICPU control flow). See pl.at scopes below.
The form used by most DeepSeek-V4 kernels: plain module-level functions.
@pl.jit decorates the top-level function the harness compiles and runs — the
boundary the golden test invokes; its pl.Out params are the kernel outputs.
@pl.jit.inline marks a reusable sub-kernel that is inlined into each
caller rather than compiled as its own entry. Write the real compute once in an
inline function, then call it from a thin @pl.jit entry. An inline function
must return a value — the parser requires every inline call expression to
have a result; when the kernel writes in place, returning the pl.Out tensor
is idiomatic.
@pl.jit.inline
def expert_routed(recv_x, ..., recv_y): # the real compute
for local_i in pl.parallel(N_LOCAL_EXPERTS):
for nb_idx in pl.spmd(..., name_hint="exp_gate_up"):
... # matmul / dequant / SwiGLU
return recv_y # inline call must return a value
@pl.jit # compilation entry
def expert_routed_test(
recv_x: pl.Tensor[[N_LOCAL_EXPERTS, RECV_MAX, D], pl.INT8],
...,
recv_y: pl.Out[pl.Tensor[[N_LOCAL_EXPERTS, RECV_MAX, D], pl.BF16]],
):
expert_routed(recv_x, ..., recv_y) # call the inline sub-kernel
return recv_yA class groups related kernels as methods; type= on each method selects how
it lowers.
@pl.program
class Qwen3Decode:
@pl.function(type=pl.FunctionType.Opaque)
def qwen3_decode(self, hidden_states: pl.Tensor[..., pl.BF16], ...):
# orchestration code (loops, tensor allocation)
for b0 in pl.parallel(0, batch_padded, BATCH_TILE):
with pl.at(level=pl.Level.CORE_GROUP, name_hint="rmsnorm"):
# InCore region — vector / cube / mte ops
...
with pl.at(level=pl.Level.CORE_GROUP, name_hint="q_proj"):
...
return outpl.FunctionType |
Role |
|---|---|
Opaque |
Self-contained compute kernel; the frontend draws the InCore boundary from its pl.at blocks (the example above). |
Orchestration |
Top-level entry that sequences other methods (host / AICPU control flow, cross-rank dispatch). |
InCore |
A single InCore region authored directly — pl.spmd / pl.parallel / pl.pipeline and scalar loops, no surrounding pl.at. |
Every tensor the golden test compares must declare an explicit direction on
the orchestration entry — the @pl.jit entry, its @pl.jit.host driver, or
the @pl.function(type=Opaque) / Orchestration method. A plain pl.Tensor is
treated as In: the runtime skips its device→host copy-back, so the tensor
reads back as all-zeros on the host and golden silently fails. The direction
is decided by the tensor's TensorSpec:
TensorSpec |
meaning | annotation |
|---|---|---|
is_output=True, no init_value |
pure output (write-only) | pl.Out[pl.Tensor[...]] |
is_output=True and init_value |
inout — read-modify-write (e.g. a paged KV cache the kernel reads history from and appends to; recurrent state) | pl.InOut[pl.Tensor[...]] |
Annotate the entry only. @pl.jit.inline sub-kernels keep bare pl.Tensor:
they are spliced at the call site before SSA conversion, so a parameter is
already an in-place alias of the caller's variable and the direction tag carries
no information — a pl.Out / pl.InOut wrapper on an inline param is stripped
and raises a DeprecationWarning. Entry pl.InOut paired with a bare-pl.Tensor
inline is the correct combination.
@pl.jit
def attention_csa_test(
x_hc: pl.Tensor[[T, HC_MULT, D], pl.BF16], # In — plain
kv_cache: pl.InOut[pl.Tensor[[BLOCK_NUM, ...], pl.BF16]], # read old tokens + append new
x_out: pl.Out[pl.Tensor[[T, D], pl.BF16]], # pure output
):
attention_csa(x_hc, ..., kv_cache, x_out) # inline params stay bare pl.Tensor
return x_out| Parameter | Required | Purpose |
|---|---|---|
level=pl.Level.CORE_GROUP |
yes | Lowering target. CORE_GROUP is the only level used in pypto-lib. |
name_hint="..." |
recommended | Stable label for the region. Appears in generated kernel filenames and profiling traces; aids per-region debugging. |
optimizations=[...] |
optional | Per-region codegen passes (see below). |
pl.at blocks may nest: an outer pl.at defining the InCore scope, with
inner pl.at blocks (each with its own name_hint) splitting it into named
sub-kernels.
optimizations=[...] attaches per-region codegen passes to a pl.at block
(or a pl.spmd loop — same kwarg). The one in common use:
-
pl.split(pl.SplitMode...)— split the region in half so the cube and vector units ping-pong on the two halves (cube on one half while vec runs the epilogue on the other). It applies only to a mixed cube + vector region (§6); a pure-cube or pure-vector region has nothing to ping-pong. The mode picks the axis:pl.SplitMode.NONE— the default; no split.pl.SplitMode.UP_DOWN— split vertically (rows / height halved).pl.SplitMode.LEFT_RIGHT— split horizontally (cols / width halved).
Reach for it when a region's unified buffer (UB) would otherwise exceed the per-core limit — typically a wide FP32 vector epilogue stacked on a matmul accumulator — since splitting also keeps the accumulator on-chip instead of spilling to a GM scratch round-trip.
# split form on a mixed region whose FP32 epilogue would blow the UB budget
for ob in pl.spmd(N_BLOCKS, name_hint="gate_up_silu",
optimizations=[pl.split(pl.SplitMode.UP_DOWN)]):
...Run on the vector unit, inside an InCore region (a pl.at block or a
pl.spmd body — see §5). Vector ops are the standard tools for the cast /
activation / norm epilogue around a matmul, and for small standalone
reductions.
Tensor-tensor binary: pl.add, pl.sub, pl.mul, pl.div,
pl.maximum, pl.minimum. Tensor-scalar variants (second operand is a
Python int/float or pl.Scalar) suffix with s: pl.adds, pl.subs,
pl.muls, pl.divs, pl.maxs, pl.mins. Unary: pl.neg, pl.abs,
pl.exp, pl.log, pl.sqrt, pl.recip, pl.rsqrt. Activations:
pl.relu, pl.lrelu, pl.prelu. Type conversion: pl.cast(x, target_type=...).
Binary ops broadcast over compatible shapes; prefer pl.recip + pl.mul
over pl.div on hot paths.
silu_x = pl.mul(x, pl.recip(pl.add(pl.exp(pl.neg(x)), one))) # x * sigmoid(x)
out_bf16 = pl.cast(acc_fp32, target_type=pl.BF16)
scaled = pl.muls(scores, attn_scale) # scalar mulFor comparison / select / bit-twiddling — pl.cmp, pl.cmps, pl.sel,
pl.sels, pl.and_, pl.or_, pl.xor, pl.not_, pl.shl, pl.shr —
see existing kernels.
Row reductions (along the last axis, return [..., 1]): pl.row_max,
pl.row_min, pl.row_sum. Column reductions (return [1, ...]):
pl.col_max, pl.col_min, pl.col_sum. Pair with broadcast ops below for
the typical RMSNorm / softmax patterns:
sq_sum = pl.row_sum(pl.mul(x, x)) # [B, 1]
inv_rms = pl.rsqrt(pl.add(pl.mul(sq_sum, HIDDEN_INV), EPS))Apply a column to each row: pl.row_expand_add, pl.row_expand_sub,
pl.row_expand_mul, pl.row_expand_div. Apply a row to each column:
pl.col_expand_sub, pl.col_expand_mul, pl.col_expand_div. Each maps
to a single hardware broadcast op — use them rather than reshaping a
vector and relying on elementwise broadcast.
# RMSNorm body: normed[i, j] = x[i, j] * inv_rms[i] * gamma[j]
normed = pl.col_expand_mul(pl.row_expand_mul(x, inv_rms), gamma)pl.full(shape, dtype=..., value=...) allocates a scalar-filled
tensor/tile (typical use: zero-init a partial accumulator before a
reduction). pl.fillpad(x, pad_value=...) rewrites the padded tail of a
valid_shape slice with a sentinel — most often pl.PadValue.min to
mask out invalid positions before a softmax row_max. There is also an
in-place pl.fillpad_inplace.
partial_sq = pl.full([1, BATCH_TILE], dtype=pl.FP32, value=0.0)
scores = pl.fillpad(scores_valid, pad_value=pl.PadValue.min) # -inf in tailpl.set_validshape(tile, valid_rows, valid_cols) re-marks the valid
region of an already-computed tile. Where valid_shape= on a
pl.slice (§4) is a load-time marker on data coming from GM,
set_validshape annotates a tile produced on chip — typically when the
valid row/col count is only known at runtime (a pl.read of a dynamic
count). The returned view has the same nominal shape; downstream ops
(reductions, fillpad) then operate on the valid region only. It is the
standard partner of fillpad: set the valid extent, then mask the tail.
valid_rows = pl.min(RECV_TILE, n_rows - t0) # runtime count
gated_valid = pl.set_validshape(gated, valid_rows, INTER_TILE)
# softmax tail-masking idiom: set extent, then fill the pad with -inf
scores = pl.fillpad(pl.set_validshape(weighted, 1, valid_len),
pad_value=pl.PadValue.min)Top-k is built from two primitives that operate on a single row
([1, N]):
pl.sort32(values, idx_init)sorts each contiguous 32-element run in descending order, carrying the indices along.idx_initis a[1, N]UINT32 index ramp (pl.arange(0, [1, N], dtype=pl.UINT32)). The result is[1, 2*N]of interleaved(value, index)pairs.pl.mrgsort(sorted, block_len=B)4-way-merges adjacent sorted blocks (format stays interleaved pairs).block_lenis the input run length, counted in interleaved-array positions — twice the element count.sort32leaves runs of 32 elements (64 positions), so each merge grows the run ×4 andblock_lensteps ×4 per stage until a single run remains:64 → 256sorts 512 elements,64 → 256 → 1024sorts 2048.
Both require row count == 1 — an ISA constraint on mrgsort; for a
multi-row tile, loop the rows with pl.range. After sorting, slice the
leading 2*k pairs and pl.gather the odd lanes (the indices) for the top-k
index list.
score_row = score_flat[t : t + 1, :] # [1, N], N = 512
idx_init = pl.arange(0, [1, N], dtype=pl.UINT32)
s = pl.sort32(score_row, idx_init) # [1, 2N], 32-runs sorted
s = pl.mrgsort(s, block_len=64) # 4-way merge of the 64-position runs
s = pl.mrgsort(s, block_len=256) # one sorted run
topk_pairs = s[:, 0 : 2 * K] # leading k (value, index) pairs
topk_idxs = pl.gather(topk_pairs, mask_pattern=pl.tile.MaskPattern.P1010,
output_dtype=pl.INT32) # odd lanes = indicesA full runnable kernel is in examples/advanced/topk.py.
Two flavours.
Mask form — de-interleave / re-interleave even and odd lanes (the RoPE
idiom). pl.gather(tile, mask_pattern=...) selects alternate lanes of a
[H, W] tile, returning [H, W/2]; pl.tensor.scatter(src, mask_pattern=..., dst=buf) writes them back into the matching lanes of dst.
pl.tile.MaskPattern.P0101 picks the even lanes (0, 2, 4, …), P1010 the odd
lanes (1, 3, 5, …). The optional output_dtype= casts on the way out.
even = pl.gather(rope_slice, mask_pattern=pl.tile.MaskPattern.P0101) # [H, W/2]
odd = pl.gather(rope_slice, mask_pattern=pl.tile.MaskPattern.P1010)
rot_even = pl.sub(pl.col_expand_mul(even, cos_b), pl.col_expand_mul(odd, sin_b))
rot_odd = pl.add(pl.col_expand_mul(even, sin_b), pl.col_expand_mul(odd, cos_b))
buf = pl.full([H, W], dtype=pl.FP32, value=0.0)
buf = pl.tensor.scatter(rot_even, mask_pattern=pl.tile.MaskPattern.P0101, dst=buf)
buf = pl.tensor.scatter(rot_odd, mask_pattern=pl.tile.MaskPattern.P1010, dst=buf)Index form — batched per-row gather by an index tile.
pl.gather(src, dim=-1, index=idx) gathers along the last axis: for a
[B, W] source and a [B, K] INT32 index it returns [B, K], where row i
picks src[i, idx[i, :]]. The index must be a real tensor (e.g. from
pl.create_tensor), not a pl.full tile — a tile index is rejected.
gathered = pl.gather(local_scores, dim=-1, index=topk_idx_tile) # [B, K]Matrix multiply primitives. They run on the cube unit, inside an InCore
region (a pl.at block or a pl.spmd body — see §5), and produce FP32
results by default (out_dtype may override).
Plain matmul. a_trans / b_trans transpose the corresponding operand
without a separate pl.transpose. out_dtype overrides the default FP32
accumulator dtype when set.
out = pl.matmul(tile_a, tile_b)
out = pl.matmul(tile_a, tile_b, out_dtype=pl.FP32, b_trans=True)Fused multiply-accumulate: acc += lhs @ rhs. Use this inside a K-loop to
keep the partial sum on chip. The first iteration uses pl.matmul,
subsequent iterations use pl.matmul_acc. The acc destination is
allocated outside pl.at (see §4):
acc = pl.create_tensor([M, N], dtype=pl.FP32)
with pl.at(level=pl.Level.CORE_GROUP, name_hint="kproj"):
for kb in pl.pipeline(0, K_BLOCKS, stage=2):
k0 = kb * K_STEP
tile_a = pl.slice(a, [M, K_STEP], [m0, k0])
tile_b = pl.slice(b, [K_STEP, N], [k0, n0])
if kb == 0:
acc = pl.matmul(tile_a, tile_b)
else:
acc = pl.matmul_acc(acc, tile_a, tile_b)Matmul fused with a bias add: lhs @ rhs + bias. Cheaper than a separate
pl.add epilogue when the bias is broadcast over the M axis.
Batched variants for stacked matmul (shape [B, M, K] @ [B, K, N]); same
arg shape as the non-batched forms.
Vector-matrix specializations (1-row left operand). Prefer over pl.matmul
when M is 1 — the cube schedules the smaller form more efficiently.
MTE primitives manipulate tensor views and stage data without explicit
load/store. The compiler decides where the actual TLOAD/TSTORE land based
on where each pl.slice / pl.assemble sits relative to pl.at.
create_tensor lives outside pl.at. Use it when multiple pl.at
regions cooperate to fill one intermediate tensor — the tensor is allocated
once in orchestration, then each region writes its piece via assemble. If
the result of a single pl.at flows directly to its caller without further
assembly, no create_tensor is needed.
# Multi-stage assembly: q_proj is built by per-tile assembles
q_proj = pl.create_tensor([batch_padded, q_hidden], dtype=pl.BF16)
for q0 in pl.parallel(0, q_hidden, Q_OUT_STEP):
with pl.at(level=pl.Level.CORE_GROUP, name_hint="q_proj"):
...
q_proj = pl.assemble(q_proj, q_acc, [b0, q0])pl.slice(tensor, sizes, offsets, valid_shape=...) takes a sub-region;
pl.assemble(dst, src, offsets) writes a sub-region back. When these cross
the pl.at boundary they lower to InCore TLOAD / TSTORE — the tensor
descriptor (offset / shape / stride) is passed as an InCore argument and
the data movement is generated by the compiler. Argument order on slice
is (sizes, offsets).
tile = pl.slice(hidden_states, [BATCH_TILE, K_STEP], [b0, k0])
...
q_proj = pl.assemble(q_proj, q_acc, [b0, q0])The shorthand subscript forms are equivalent and often clearer:
tile = hidden_states[b0 : b0 + BATCH_TILE, k0 : k0 + K_STEP] # slice
q_proj[b0 : b0 + BATCH_TILE, q0 : q0 + Q_OUT_STEP] = q_acc # assemblevalid_shape=[real_h, real_w] on a slice marks a padded load: the slice
has nominal size sizes but only the leading valid_shape rows/cols
carry real data, and the compiler zero-pads the tail. Use this for
dynamic batch / sequence length when the kernel works on a fixed
BATCH_TILE but the caller may pass fewer valid rows.
Logical reshape (view-only). Total element count must match. Legal both
inside and outside pl.at.
flat = pl.reshape(q_chunk, [BATCH_TILE * H, D])PyPTO has four loop constructs. The choice between them is semantic — it tells the compiler what scheduling and codegen are valid.
Each construct has a fixed placement relative to pl.at:
| Construct | Outside pl.at (orchestration) |
Inside pl.at (InCore) |
|---|---|---|
pl.range |
yes | yes |
pl.parallel |
yes | no |
pl.pipeline |
no | yes |
pl.spmd |
yes (body is implicitly InCore) | no |
pl.parallel distributes iterations across cores, so it must sit in
orchestration. pl.pipeline software-pipelines stages within a single
InCore region, so it must sit inside pl.at. pl.range is a plain
sequential loop and is legal in either place. pl.spmd is a parallel SPMD
loop that bundles its own InCore region — see below.
pl.range, pl.parallel, and pl.pipeline share the same positional-arg
shape, mirroring Python's range:
pl.<loop>(stop)
pl.<loop>(start, stop)
pl.<loop>(start, stop, step)
Each argument may be either a Python int or a pl.Scalar.
Iterations execute in strict order. Loop-carried dependencies are allowed.
for kb in pl.range(HIDDEN_BLOCKS): # range [0, HIDDEN_BLOCKS)
for kb in pl.range(1, hidden_blocks): # range [1, hidden_blocks)
for k0 in pl.range(0, HIDDEN, K_STEP): # start, stop, stepTo carry state across iterations, pass init_values= and unpack the loop
variable as a (idx, (state...)) tuple:
for i, (acc,) in pl.range(N, init_values=(zero,)):
acc = pl.add(acc, x[i])Iterations are guaranteed independent — the compiler may split, reorder, or
schedule them across cores. Same arg shape and init_values support as
pl.range.
for b in pl.parallel(BATCH): # short form: extent only
for b0 in pl.parallel(0, batch_padded, BATCH_TILE): # start, stop, stepSequential like pl.range, but the compiler software-pipelines successive
iterations across compute and memory units. stage=N (required keyword) is
the pipeline depth — the loop body is replicated stage times for
ping-pong buffering; the outer trip count advances in strides of
stage * step and a tail dispatch covers the remainder when the trip count
is not divisible by stage. Typical values are 2 or 4.
for kb in pl.pipeline(HIDDEN_BLOCKS, stage=2):
for kb in pl.pipeline(2, HIDDEN_BLOCKS, stage=2): # start at 2
for kb in pl.pipeline(0, input_proj_k_blocks, stage=4):init_values= is supported, with the same (idx, (state...)) unpacking as
pl.range. Use pl.pipeline for the inner reduction loop of a matmul (the
K loop) — each iteration loads a new tile of the left/right operand and
accumulates into the same output.
pl.spmd(core_num) dispatches core_num blocks in parallel; iteration
starts at 0 and steps by 1 (only the block count is positional, not
start/stop/step). Two forms:
Loop form — body is auto-outlined into a synthetic InCore function;
the iteration variable binds the per-block index (equivalent to
pl.tile.get_block_idx()). No surrounding pl.at is needed or allowed:
for ob0 in pl.spmd(Q_SPMD_BLOCKS, name_hint="q_proj"):
# implicit InCore region — vector / cube / mte ops here
for ob in pl.range(ob0 * 4, (ob0 + 1) * 4):
q0 = ob * Q_OUT_STEP
...Context-manager form — body must be a single call to a pre-defined InCore kernel:
with pl.spmd(4):
out = self.kernel(a, b, out)Keyword args:
| Kwarg | Default | Purpose |
|---|---|---|
sync_start |
False |
If True, all blocks start execution simultaneously. |
name_hint |
"" |
Stable label for the outlined function. |
A single pl.at region can contain both cube (matmul) and vector (cast,
add, row_sum, …) ops. The compiler assigns each op to the appropriate unit
and pipelines cube/vec where possible. This is the standard pattern for a
projection with cast/residual epilogue:
q_proj = pl.create_tensor([batch_padded, hidden], dtype=pl.BF16)
for q0 in pl.parallel(0, hidden, Q_OUT_STEP):
q_acc = pl.create_tensor([BATCH_TILE, Q_OUT_STEP], dtype=pl.FP32)
with pl.at(level=pl.Level.CORE_GROUP, name_hint="q_proj"):
for kb in pl.pipeline(0, input_proj_k_blocks, stage=2):
k0 = kb * INPUT_PROJ_K_STEP
tile_a = pl.slice(normed_tile,
[BATCH_TILE, INPUT_PROJ_K_STEP], [0, k0]) # vec source
tile_b = pl.slice(wq,
[INPUT_PROJ_K_STEP, Q_OUT_STEP], [k0, q0]) # cube right
if kb == 0:
q_acc = pl.matmul(tile_a, tile_b) # cube
else:
q_acc = pl.matmul_acc(q_acc, tile_a, tile_b) # cube
q_bf16 = pl.cast(q_acc, target_type=pl.BF16) # vector
q_proj = pl.assemble(q_proj, q_bf16, [b0, q0]) # mteLarger fused regions (RMSNorm + projection + residual) follow the same
shape: a pl.pipeline matmul reduction, then the vector epilogue, then
assemble back to GM.
@pl.jit / @pl.jit.inline kernels support dynamic batch (B) and sequence
(S) dimensions via pl.dynamic symbolic dims — a single kernel can serve both
decode and prefill. Almost every rule below traces back to one constraint: the
JIT SSA renamer rewrites local Scalar references but not DynVar references
embedded in IR type annotations. So DynVars must stay in annotations, and any
concrete shape math must go through named locals.
pl.dynamic("name") creates a DynVar (a Scalar subclass) for a symbolic
dimension. Declare them as module-level constants, alongside the static
constants you still need for tiling, golden, and test loops:
B_DYN = pl.dynamic("B_DYN")
S_DYN = pl.dynamic("S_DYN")
T_DYN = pl.dynamic("T_DYN") # T = B * S, for kernels on a flat token dim
B = DECODE_BATCH # static upper bound for golden / tilingUse DynVars exclusively in pl.Tensor[[...]] parameter annotations. In the
body, capture each dynamic dim into a local Scalar with pl.tensor.dim() and
use the locals everywhere:
@pl.jit.inline
def compressor(x: pl.Tensor[[B_DYN, S_DYN, D], pl.BF16], ...):
b_dim = pl.tensor.dim(x, 0) # ✅ local Scalar — renamer tracks it
s_dim = pl.tensor.dim(x, 1)
x_flat = pl.reshape(x, [b_dim * s_dim, D])
# ❌ pl.reshape(x, [B_DYN * S_DYN, D]) — DynVar math in body → SSA failureShape annotations (pl.create_tensor, pl.reshape) must hold single Scalar
variables, not composites — extract to a named local first:
chunk_s = BATCH_CHUNK_0 * s_dim # ✅ compute first
scratch = pl.create_tensor([chunk_s, OUT_DIM], dtype=pl.FP32)
# ❌ pl.create_tensor([BATCH_CHUNK_0 * s_dim, OUT_DIM], ...)When an inlined function writes through a reshaped view of a pl.Out tensor,
the data is already in the output buffer — skip the reshape-back at the end
(return y, not pl.reshape(y_flat, ...)). A trailing reshape-back carries a
dynamic-shape SSA var that breaks the runtime tensor mapping when the inline is
nested inside another @pl.jit.inline.
In the @pl.jit wrapper, both annotate with DynVars and call
bind_dynamic() for every dynamic dim, so the DynDim cascade propagates
through inline dependencies:
@pl.jit
def compressor_test(x: pl.Tensor[[B_DYN, S_DYN, D], pl.BF16], ...):
x.bind_dynamic(0, B_DYN)
x.bind_dynamic(1, S_DYN)All four loop constructs accept dynamic bounds. pl.spmd accepts a single
Scalar or a composite dynamic expression (b_dim * HEAD_DIM // HEAD_TILE)
as the block count. When an SPMD loop folds several dims into one, place the
dynamic dim outermost so every // and % divides by a compile-time
constant — otherwise the hot loop needs a runtime division:
BLOCKS_PER_OUTER = HEAD_COUNT * (D // D_CHUNK) # compile-time
for block in pl.spmd(t_dim * BLOCKS_PER_OUTER, name_hint="..."):
t = block // BLOCKS_PER_OUTER # ÷ constant
local = block % BLOCKS_PER_OUTER
...Keep tiling constants (pipeline depth, tile sizes, spmd block factors) static —
they shape the generated IR and cannot depend on runtime dims. Runtime Scalar
comparisons in conditionals (if runtime_val + s_dim < THRESHOLD) just work.
Golden and test code runs on concrete torch tensors (no JIT). For a unified
decode+prefill kernel, parameterize build_tensor_specs(B, S), derive T from
the actual tensor shape, and iterate modes via a --mode arg:
def build_tensor_specs(B, S):
return [TensorSpec("x", [B * S, D], torch.bfloat16, ...)]
MODES = {"decode": (DECODE_BATCH, DECODE_SEQ),
"prefill": (PREFILL_BATCH, PREFILL_SEQ)}A single product DynVar T_DYN lets one @pl.jit.inline serve both modes: the
caller reshapes [B, S, ...] → [T, ...] before the call and back after, and
the old mode-specific wrapper is deleted.
| Do | Don't |
|---|---|
pl.Tensor[[B_DYN, S_DYN, ...]] in annotations |
B_DYN * S_DYN in annotations or body |
pl.tensor.dim(x, 0) → local Scalar |
DynVar arithmetic in the body |
| Compute composite to a local, then use it | pl.create_tensor([C * s_dim, ...]) |
Skip reshape-back on pl.Out in nested inline |
trailing pl.reshape(y_flat, [dyn, ...]) |
Annotate and bind_dynamic() at @pl.jit |
annotate only |
pl.spmd(b_dim * STATIC), dynamic dim outermost |
dynamic dim innermost (% t_dim in hot loop) |
| Static tiling constants | tiling that depends on runtime dims |
build_tensor_specs(B, S) + --mode loop |
hardcode B/S inside build_tensor_specs |
Give a named constant to a tiling parameter — the tile / step size — but
not to a derived block count (K_BLOCKS, Q_BLOCKS, N_TILES, …). Inline
the block-count expression (dim // TILE) at the loop header instead, so the
trip count and stride are visible right where the loop is — which is what you
need to reason about parallelism and pipelining.
# ❌ named block count hides the trip count behind a constant
Q_BLOCKS = Q_HIDDEN // Q_OUT_STEP
for q in pl.spmd(Q_BLOCKS, name_hint="q_proj"):
# ✅ tile size named; block count inlined at the loop
Q_OUT_STEP = 128 # tiling parameter
for q in pl.spmd(Q_HIDDEN // Q_OUT_STEP, name_hint="q_proj"):A comment states what a non-obvious line or block does, tersely — or there
is no comment. Do not explain why the code is written a certain way; the
one exception is a pointer to an unresolved issue / workaround (a filed
pypto#NNNN / ptoas#NNNN constraint), where the issue reference is the
comment. Do not write structural narration — no # Stage 1: / # Stage 2:,
# Loop A:, # Bridge: step labels; the loop and scope structure is already
visible from the code.
# ❌ structural narration / rationale
# Stage 1: quant the activation so the cube can run int8 for speed
x_i8 = pl.cast(pl.mul(x, inv_scale), pl.INT8, mode="rint")
# ✅ no comment — the code is self-evident
x_i8 = pl.cast(pl.mul(x, inv_scale), pl.INT8, mode="rint")
# ✅ the allowed exception — an unresolved-issue workaround
# pad to 32: ptoas rejects cube tiles whose cols aren't a multiple of 16
w_pad = pl.slice(w, [K, 32], [0, 0], valid_shape=[K, MIX_HC])Place pl.create_tensor and pl.reshape calls immediately before the
first pl.spmd / pl.parallel / pl.range / pl.at block that
consumes the result — do not hoist them to the top of a function or let
them drift far from the consuming loop. Co-locating an allocation with its
consumer makes the data-flow between orchestration and InCore easy to
trace without scrolling.
# ❌ hoisted far from first use
kv_proj = pl.create_tensor([T, OUT_DIM], dtype=pl.FP32)
score_proj = pl.create_tensor([T, OUT_DIM], dtype=pl.FP32)
kv_flat = pl.reshape(kv, [T, HEAD_DIM])
... # many lines of unrelated code
for idx in pl.spmd(T * OUT_DIM // (B_TILE * OUT_TILE), name_hint="kv_score_proj"):
kv_proj[...] = ...
# ✅ declared right above the consuming loop
kv_proj = pl.create_tensor([T, OUT_DIM], dtype=pl.FP32)
score_proj = pl.create_tensor([T, OUT_DIM], dtype=pl.FP32)
kv_flat = pl.reshape(kv, [T, HEAD_DIM])
for idx in pl.spmd(T * OUT_DIM // (B_TILE * OUT_TILE), name_hint="kv_score_proj"):
kv_proj[...] = ...The same principle applies to inner-loop scratch tensors: allocate inside
the loop body (or directly above the inner pl.at) rather than at the
top of the outer loop.