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 @Device helpers, 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.wave or 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.workgroupMemory and Barrier.deviceMemory order nothing.
  • Each Shared<T> declaration has one array per workgroup, which every work-item of that workgroup binds. A Swizzled<T, S> array is a Shared array: its XOR permutation of physical slots is an involution the program never observes, so logical index a is element a. A BlockPadded<T, Block, Pad> array is the same with the backend’s layout: element a lives at slot a + (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 % W and the wave is linear / 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, or int64 if it is past int32 or has an L suffix. 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.
  • float32 work is done in float32, one rounding per operation, and nothing is contracted into a fused multiply-add.
  • float16 and bfloat16 are computed in float32 and rounded back, to nearest even.
  • An f suffix makes a literal float32. Any other float literal is float64.
  • != on floats is true when either operand is NaN.
  • A conversion from float to integer truncates toward zero.
  • c ? a : b evaluates 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-inReference semantics
KernelThread.x/y/zthe work-item’s index in its workgroup
KernelThread.globalIdX/Y/ZWorkgroup.x * dimX + KernelThread.x, per axis
Workgroup.x/y/z, dimX/Y/Zthe 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.workgroupevery work-item of the workgroup arrives at this barrier before any continues
Barrier.waveevery live lane of the wave arrives before any continues
Barrier.workgroupMemory, deviceMemoryno effect
Wave.width, laneId, isFirstLaneW; 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, shuffleDownSyncI64the 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/Xoruint32, over the active lanes in lane order; sums wrap
Wave.reduceSumF32, reduceMaxF32an 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), reduceMaxF32Segmentedthe 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, reduceMinI64int64, over the active lanes in lane order; the sum wraps, the maximum and minimum are signed
Wave.prefixSum, prefixProductexclusive scan in lane order, uint32
Wave.prefixSumI64exclusive sum in lane order, int64, wrapped; lane 0 receives 0
Group.width, laneId, rowIdW; 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, f64ToBitsthe same bits, reinterpreted
Bits.count, reverse, rotateLeft, rotateRighton uint32, or on uint64 when the argument is a uint64; a rotate takes its amount modulo the width
Bits.trailingZeros, leadingZeroson 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, fmaexact, in the argument’s float type
Math.sqrtcorrectly rounded in the argument’s float type
Math.roundrounds half away from zero, and returns a float
Math.exp, exp2, log, log2, log10, sin, cos, tan, asin, acos, atan, atan2, pow, rsqrtcomputed 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/CompareExchangea 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 helpera 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, storeelement (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 factora Shared vector, or a slice xs[i] of one, read at [r]
a column factora 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> localthe 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 Shared element 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.workgroup that 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.

  1. Run a program, such as cajeta-llm’s tests, with CAJETA_XPU_RECORD=<dir>. The runtime records the first CAJETA_XPU_RECORD_PER_KERNEL launches of each kernel (default 2). For each one it writes launch.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 than CAJETA_XPU_RECORD_MAX_BYTES (default 64 MiB). Each skip goes in <dir>/skipped.tsv with its reason.
  2. Compile the same program with CAJETA_XPU_CONFORMANCE=<dir>, and optionally CAJETA_XPU_CONFORMANCE_HELD=<file>. The compiler replays every recording against its own kernels, writes <dir>/conformance.tsv and stops before generating code. It exits 1 when any launch fails.

Each launch gets one of these outcomes:

OutcomeMeaningFails the run
passevery element agreesno
failan element disagrees; the detail names the parameter, the element and both valuesyes
heldit disagrees, and the held list tracks itno
stalethe held list holds it, but it agrees nowyes
refusedthe interpreter has no semantics for something it usesno
undefinedthe interpreter met undefined behaviour, or the recording is damagedyes
missingno kernel of that name is in this buildno

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.

Source: docs/specification/xpu/ReferenceInterpreter.md · 14 min read · 3186 words