docs / specification / xpu
The kernel reference interpreter
The reference interpreter runs a @Kernel’s source on the host and produces
the answer every backend is compared against. It reads the same syntax tree
the kernel lowering reads, but none of the lowering’s code. Its values carry
their own width and signedness, and its arithmetic follows the host compiler.
A backend that extends with the wrong sign, picks the wrong compare or drops
a lane therefore disagrees with it.
#include "cajeta/xpu/reference/KernelInterpreter.h"
namespace ref = cajeta::xpu::reference;
ref::Launch l;
l.grid[0] = 4;
l.block[0] = 64;
ref::run(kernel, {ref::Arg::buffer(y), ref::Arg::buffer(x), ref::Arg::f32(2.5f)}, l);
A buffer argument is host memory that the interpreter reads and writes in
place. Its element type is the parameter’s KernelBuffer<T>. A scalar
argument is passed as its bits.
Execution model
- Workgroups run one after another, in x, then y, then z order.
- The survey resolves names that need the compiler’s type registries, such as
enum constants and
@Devicehelpers, before anything runs. Those registries are thread-local, so a work-item’s thread never looks them up. - Within a workgroup, each work-item runs until it reaches a rendezvous or
returns. A rendezvous is
Barrier.workgroup,Barrier.waveor a wave collective. Once every live member of the rendezvous’s scope has arrived, the collective is computed and they all continue. - A cooperative matrix is the wave’s object, so a tile operation that reads
memory (
load,fromWords, the epilogue verbs) is a wave collective: it reads once every lane of the wave has arrived, so what the lanes stored just before it, with no barrier between, is there, as on a wave that runs in lockstep. A kernel that uses a tile is a wave kernel and needs the width. - Scheduling is deterministic, so a run is reproducible.
- A kernel with no rendezvous runs its work-items in sequence on one thread.
- Memory is sequentially consistent, so
Barrier.workgroupMemoryandBarrier.deviceMemoryorder nothing. - Each
Shared<T>declaration has one array per workgroup, which every work-item of that workgroup binds. ASwizzled<T, S>array is aSharedarray: its XOR permutation of physical slots is an involution the program never observes, so logical indexais elementa. ABlockPadded<T, Block, Pad>array is the same with the backend’s layout: elementalives at slota + (a / Block) · Pad, the declared size counts slots, and the bound is on the slot, so a logical index that lands past the end is out of bounds. - A work-item’s linear index is
x + y·dimX + z·dimX·dimY. - Lane and wave come from the linear index and the wave width: the lane is
linear % Wand the wave islinear / W. Lanes that have returned are inactive. - The wave width is the launch’s
waveWidth. When that is 0, the width comes from the kernel’s@Wave(width = N). A kernel that uses a wave operation with neither is refused, because its answer depends on the width.
Arithmetic
Arithmetic follows the host compiler’s rules (BinaryOpExpression).
- An unsuffixed integer literal is
int32, orint64if it is pastint32or has anLsuffix. Next to a typed operand, a bare literal takes that operand’s type. - Integer operands extend to the wider width, each by its own signedness.
+,-and*wrap. /,%and the bitwise operators are unsigned when either operand is unsigned.- A comparison is signed when either operand is signed.
>>follows the signedness of the shifted operand alone.>>>is always logical.float32work is done in float32, one rounding per operation, and nothing is contracted into a fused multiply-add.float16andbfloat16are computed in float32 and rounded back, to nearest even.- An
fsuffix makes a literalfloat32. Any other float literal isfloat64. !=on floats is true when either operand is NaN.- A conversion from float to integer truncates toward zero.
c ? a : bevaluates the condition and then ONE arm, as the kernel lowering branches; the untaken arm never reads.
Built-ins
These are every built-in the interpreter defines.
| Built-in | Reference semantics |
|---|---|
KernelThread.x/y/z | the work-item’s index in its workgroup |
KernelThread.globalIdX/Y/Z | Workgroup.x * dimX + KernelThread.x, per axis |
Workgroup.x/y/z, dimX/Y/Z | the workgroup’s index; the block’s size |
Workgroup.reduce(op, v) | every work-item of the workgroup waits; float32 per wave in the xor-butterfly order then the partials from wave 0 up; int32 and int64 exact, a wrapped sum or the signed max or min |
Workgroup.scan(op, v) | every work-item waits; each receives op over the work-items before it in linear order, exactly, and the first the identity (0, the type’s minimum for Max, its maximum for Min); int32 and int64 |
Barrier.workgroup | every work-item of the workgroup arrives at this barrier before any continues |
Barrier.wave | every live lane of the wave arrives before any continues |
Barrier.workgroupMemory, deviceMemory | no effect |
Wave.width, laneId, isFirstLane | W; linear % W; laneId == 0 |
Wave.shuffleSync(v, src) | v from lane src, which must be active |
Wave.shuffleSyncF32(v, src) | the same on a float32; the bits travel unchanged |
Wave.shuffleXorSyncF32(v, m), shuffleUpSyncF32(v, d), shuffleDownSyncF32(v, d) | the xor, up and down forms on a float32; the bits travel unchanged |
Wave.shuffleXorSync(v, m) | v from lane laneId ^ m, which must be active |
Wave.shuffleUpSync(v, d), shuffleDownSync(v, d) | v from lane laneId - d or laneId + d; a lane past the wave’s edge reads itself |
Wave.rotate(v, d) | v from lane (laneId + d) % W |
Wave.shuffleSyncI64, shuffleXorSyncI64, shuffleUpSyncI64, shuffleDownSyncI64 | the uint32 forms on an int64; both halves of the source lane’s value |
Quad.broadcast(v, i) | v from lane (laneId & ~3) + (i & 3), which must be active |
Quad.swapHorizontal(v), swapVertical(v), swapDiagonal(v) | v from lane laneId ^ 1, ^ 2, ^ 3, which must be active |
Quad.all(p), any(p) | the AND or OR of p over the four lanes of the quad, every one of which must be active |
Wave.ballotSync(p) | bit i is set when active lane i passes p |
Wave.reduceSum/Max/Min/And/Or/Xor | uint32, over the active lanes in lane order; sums wrap |
Wave.reduceSumF32, reduceMaxF32 | an xor butterfly over the full width, partners at distance 1, 2, 4 and up in that order; inactive lanes contribute 0 or -inf. nvptx, cpu and a Vulkan virtual wave sum in this order; amdgpu and native SPIR-V use the device’s own reduce, whose order is the driver’s. RADV and amdgpu sum in this order too (measured, XpuNativeFloatReduceOrderTests). Another vendor’s Vulkan driver is unmeasured and carries a stated bound |
Wave.reduceSumF32Segmented(v, seg), reduceMaxF32Segmented | the same butterfly stopped at seg: partners at distance 1 up to seg/2, so each aligned span of seg lanes reduces on its own and every lane of a span holds the span’s result. seg is uniform across the wave and a power of two, and is clamped to W |
Wave.reduceSumI64, reduceMaxI64, reduceMinI64 | int64, over the active lanes in lane order; the sum wraps, the maximum and minimum are signed |
Wave.prefixSum, prefixProduct | exclusive scan in lane order, uint32 |
Wave.prefixSumI64 | exclusive sum in lane order, int64, wrapped; lane 0 receives 0 |
Group.width, laneId, rowId | W; linear % W; Workgroup.x, as int32 |
Group.reduce(op, v) | Wave.reduceSumF32 for GroupOp.Add, Wave.reduceMaxF32 for GroupOp.Max |
Group.reduceSegmented(seg, op, v) | the segmented form of the same |
Cajeta.bitsToF32, f32ToBits, bitsToF64, f64ToBits | the same bits, reinterpreted |
Bits.count, reverse, rotateLeft, rotateRight | on uint32, or on uint64 when the argument is a uint64; a rotate takes its amount modulo the width |
Bits.trailingZeros, leadingZeros | on uint32 or uint64 as above; of 0 the width (32 or 64) |
Bits.permute(lo, hi, sel) | byte i of the result is byte sel.nibble[i] & 7 of the eight bytes {lo, hi}, lo holding bytes 0 to 3 |
AsyncCopy.copy(dst, d0, src, s0, n) | dst[d0 + i] = src[s0 + i] for i < n, landed at once by every work-item of the group; commit and wait do nothing, and the Barrier.workgroup that publishes the tile is the kernel’s own |
for (T k : Group.stripe(n)) | k runs from laneId while below n, stepping by W |
Group.mac(acc, a, b) | a.dotSum(b, acc): acc plus the int8 products, wrapping in int32; a lane’s own, not a wave operation |
for (idx, e : buf.range(n)) | idx runs from globalIdX while below n, stepping by the grid’s width in work-items; e is a copy of buf[idx] |
Math.min, max, abs, floor, ceil, trunc, fma | exact, in the argument’s float type |
Math.sqrt | correctly rounded in the argument’s float type |
Math.round | rounds half away from zero, and returns a float |
Math.exp, exp2, log, log2, log10, sin, cos, tan, asin, acos, atan, atan2, pow, rsqrt | computed in float64, then rounded once to the argument’s type. A backend’s device library is compared within a stated bound. |
buf.vload<N>(i), buf.vstore(i, v) | lanes i .. i+N-1; on a KernelBuffer or a Shared array |
buf.atomicAdd/Sub/Min/Max/And/Or/Xor/Exchange/CompareExchange | a load, the operation and a store, in work-item order; returns the old value. Integer min and max follow the element’s signedness; float min and max are minnum and maxnum. |
v.asUnsigned(), asSigned() | the same bits with the element’s signedness flipped |
v.asWords(), asBytes() | 8-bit lanes regrouped into int32, little-endian, and back |
v.widenLo(), widenHi() | the low or high half of the lanes, each extended by its own signedness to twice the width |
v.narrow(w) | the lanes of v, then those of w, each truncated to half the width |
v.toF32(), toF16(), toBF16(), toF64(), toI32() | lane conversions, rounding to nearest even into the 16-bit floats; toI32 truncates toward zero |
v.toI8() | each integer lane truncated to its low byte, keeping the element’s signedness |
v.bitcastF32(), bitcastI32() | 32-bit lanes reinterpreted |
v.lut4(t) | lane i is t[v[i] & 15] |
v.dotSum(a, acc) | acc + Σi v[i]·a[i], with v extended by its own signedness and a sign-extended, wrapping in int32 |
v.dot(w), dot(w, acc) | float: products summed in lane order. 4 × 8-bit integers: both operands take the receiver’s signedness. |
w.dotAccum(a, acc) | acc[j] + Σk w[4j+k]·a[4j+k], with w extended by its own signedness and a always sign-extended, wrapping in int32 |
a call to a @Device helper | a new frame. Arguments are converted to the parameter types; buffers, Shared arrays and tiles pass by reference, so a tile the helper writes is the caller’s, and a tile of another shape than the parameter’s is refused. The result is converted to the return type. A recursive call is refused. A helper that names backend arms (@TargetIntrinsic, @TargetAsm) runs its BODY, the portable arm, which is how the corpus checks an arm against it; one with arms and no body is refused by name. |
CooperativeMatrix splat, load, store | element (r, c) is src[off + r·stride + c] for row layout 0, or src[off + c·stride + r] for column layout 1 |
CooperativeMatrix.fromWords(w0, w1, w2, w3) | the 16x16 int8 operand tile from the wave: lane L supplies column L mod 16 of a B tile (row L mod 16 of an A tile) as sixteen little-endian bytes across its four words, k-value k in byte k & 3 of word k >> 2. Lanes that share a column must agree |
t.elements(), get(i), set(i, v), row(i), col(i) | per-element access with the reference’s own lane-to-element map: lane L of a W-wide wave holds the rows·cols / W elements whose row-major index is i·W + L, so row(i) is (i·W + L) / cols and col(i) is (i·W + L) % cols. set is a wave collective: every lane’s (i, v) lands in the wave’s one tile. A tile that does not divide by the wave is refused; an element index past elements() is undefined behaviour. Each backend has its own map, so a kernel that indexes through row(i) / col(i) agrees with every backend and one that assumes a map does not |
acc.scaledAccumInto(facc, rowF, colF) | facc[r][c] += (rowF[r] · colF[c]) · acc[r][c], each product and the sum rounded once in float32, acc converted to float32 first |
acc.scaledAccumInto2(facc, rowF, colF, rowG, colG) | the same with rowG[r] · colG[c] added to the term before it is accumulated |
facc.rank1Accum(rowF, colF) | facc[r][c] += rowF[r] · colF[c] |
acc.scaledAccumI32(iacc, colS) | iacc[r][c] += acc[r][c] · colS[c], wrapping at 32 bits; an operand outside 24 bits is undefined behaviour, because the native multiply is the 24-bit one |
| a row factor | a Shared vector, or a slice xs[i] of one, read at [r] |
| a column factor | a Shared vector or slice read at [c], or a WaveVector: ofSlice(src, base, stride) reads src[base + c · stride], broadcast(v) is v for every column, ofLane(x) is lane c’s x, which every lane that shares the column (c, c + N, …) must agree on |
a WaveVector<T, N> local | the factory’s value, bound once |
CooperativeMatrix.mma(a, b) | this[r][c] += Σk a[r][k]·b[k][c], in k order. A float tile accumulates in float32, rounding once per product and once per sum. An integer tile accumulates exactly and wraps. |
A tile is a wave-uniform value. Each work-item holds its own copy, and every copy is computed the same way from the same arguments.
What is refused, and what fails
Before any work-item runs, refusals(kernel) lists every construct the
interpreter cannot define. Each entry names the construct and its line. If the
list is not empty, run throws XPU-REF01 with the first entry, so the kernel
is never run partially. Today that list includes:
- textures, images and samplers;
- value types;
KernelThread.clock(), which has no reference value by nature;- a helper with backend arms and no body (no portable arm).
The conformance corpus adds built-ins here as its kernels need them.
While running, undefined behaviour that the interpreter can see fails the run
with XPU-REF02. The message names the operation and its line:
- an index out of bounds;
- a read of a
Sharedelement that no work-item wrote; - a shift by the width or more;
- integer division by zero, or
MIN / -1; - a float that converts to an integer out of range;
- a
Barrier.workgroupthat some work-items return without reaching; - lanes of one wave that reach different wave operations;
- a shuffle from an inactive lane.
The conformance corpus
The corpus is real launches, recorded as a backend runs them, then replayed through the interpreter.
- Run a program, such as cajeta-llm’s tests, with
CAJETA_XPU_RECORD=<dir>. The runtime records the firstCAJETA_XPU_RECORD_PER_KERNELlaunches of each kernel (default 2). For each one it writeslaunch.json, giving the kernel, backend, grid, block, wave width and arguments, plus each allocation an argument points into, before (a<i>.in) and after (a<i>.out). An allocation is written once, however many arguments point into it, so aliasing survives the replay. The runtime skips a launch on a deferred stream, a Vulkan launch, and one whose allocations come to more thanCAJETA_XPU_RECORD_MAX_BYTES(default 64 MiB). Each skip goes in<dir>/skipped.tsvwith its reason. - Compile the same program with
CAJETA_XPU_CONFORMANCE=<dir>, and optionallyCAJETA_XPU_CONFORMANCE_HELD=<file>. The compiler replays every recording against its own kernels, writes<dir>/conformance.tsvand stops before generating code. It exits 1 when any launch fails.
Each launch gets one of these outcomes:
| Outcome | Meaning | Fails the run |
|---|---|---|
pass | every element agrees | no |
fail | an element disagrees; the detail names the parameter, the element and both values | yes |
held | it disagrees, and the held list tracks it | no |
stale | the held list holds it, but it agrees now | yes |
refused | the interpreter has no semantics for something it uses | no |
undefined | the interpreter met undefined behaviour, or the recording is damaged | yes |
missing | no kernel of that name is in this build | no |
The held list has one tab-separated line per kernel and backend.
<kernel> <backend> held <note> holds a known disagreement, and
<kernel> <backend> ulps=N <why> states a float bound in units in the last
place, and <kernel> <backend> rel=X <why> states one as a fraction of the
buffer’s scale. The two can be joined as ulps=N,rel=X, and an element inside
either passes. A backend of * matches every backend. On an integer buffer
ulps=N is a step bound: the two elements’ distance as integers of the
buffer’s width, for a quantized output whose float scale the backend reduced
in another order.
The scale is the largest finite reference magnitude the launch wrote, over the whole buffer when it wrote nothing. An element the launch left as it was (a fill pattern, a row it did not reach) does not set it, and nor does a sentinel, a magnitude at or above a quarter of the type’s largest value (an attention kernel’s -3e38 for an empty partition). Both still have to match; they only do not make a relative bound vacuous.
With CAJETA_XPU_CONFORMANCE_DUMP set, a failed launch leaves the reference’s
bytes of each allocation a parameter reads beside the backend’s, as a<i>.ref.
A relative bound is for results whose error scales with the buffer rather than
with the element: a @FastMath kernel, which the backend may reassociate,
contract and flush to zero, and a function such as sin near one of its
zeros. There a count of ulps is huge for an error the buffer cannot see, and a
ulp bound wide enough to admit it would also admit a real defect elsewhere. A
float failure reports its first element, its worst distance in both measures,
and how many elements fall outside the bound.
Comparing against it
Integers compare bit for bit unless a step bound is stated. Floats compare
within a stated count of units in the last place, using ulpDistance, or
within a stated fraction of the buffer’s scale, and each comparison states its
bound and the reason for it.
The interpreter shares the front end with the code under test, so a front-end defect can agree with itself. cajeta-llm’s host oracles stay in that repository’s CI as the independent check on that path.
Testing the instrument
CAJETA_XPU_FAULT=dot-activation-sign restores the single-flag dot lowering
that cajeta 3ba58bda fixed. XpuReferenceInterpreterTests sets it, checks
that the cpu backend is caught disagreeing, and checks that the two agree
once it is unset. The variable exists only for that test.
See also
specs/archive/xpu-kernel-independence-spec.md§2.2 and §7.2.test/xpu/XpuReferenceInterpreterTests.cpp.