docs / specification / xpu
Cajeta XPU — CPU backend + graceful degradation (bring-up plan/log)
The fourth backend, and the one that makes “run anywhere” literally true: when no
GPU/accelerator (XPU) is present, the same portable @Kernel source must execute on
the CPU. This is the AMD/Vulkan discipline applied once more — thread a CPU target
through the existing LoweringTarget seam, let the duplication reveal what the CPU
actually forks, design nothing abstract up front.
0. Decisions (locked 2026-05-30)
Four design choices, made deliberately:
-
CPU is a real vectorized backend, built first — not an emulation upgrade. The CPU target lowers
@Kernelto native host code where the wave is a SIMD register and the lane is a vector element, the grid is a threaded iteration domain, and LLVM carries it to AVX-512 / AMX / NEON / SVE. (Contrast: the existing “CPU emulation” —XpuLaunchTests.saxpyCpuEmulationEndToEnd— is not an XPU backend; it compiles a@Kernelas an ordinary host method with an explicituint32 iindex param and a serial host loop. It cannot run a portable GPU-style kernel (KernelBuffer<T>+KernelThread.globalIdX()), so it is the legacy path, separate from this backend.) -
Fall-to-CPU triggers on availability at startup, cached — no mid-run recovery. The dispatcher probes backends once at first launch, picks the best available, caches it. A GPU that is present but fails mid-run (device lost, OOM) is a hard error, not a silent CPU re-run — keeps us out of the partial-device-state swamp.
-
Bundling is explicit only. A binary embeds exactly the backends listed on the CLI (so
--xpu-backendbecomes a list; registration/dispatch hold N variants per kernel keyed by backend). Nothing auto-injected. Consequence, accepted on purpose: degradation is a build-time contract, not a runtime guarantee — a binary built--xpu-backend=vulkanshipped to a GPU-less box fails hard, because there is nothing to fall to. A must-run-anywhere artifact listscpuamong its targets (--xpu-backend=vulkan,cpu). The humane requirement: when no bundled backend is available, the dispatcher must say so precisely — “no available backend among {vulkan}; rebuild withcputo enable CPU fallback” — never crash opaquely. -
CPU is the lowest-priority backend on the same seam, and its
available()is always true. That turns “degrade to CPU” into “the priority chain reached its guaranteed terminal” — ordinary dispatch, not a special case. Probe order:CUDA → HIP → Vulkan → CPU.@Backend(...)constrains a kernel’s candidate set;CAJETA_XPU_BACKEND=cpuforces it (and is a debugging superpower — run “GPU” kernels deterministically on the CPU).
1. The variance surface — what CPU adds to the seam
GPU backends are SIMT: the hardware runs one scalar lane per work-item and supplies coordinates via intrinsics. The CPU has no hardware grid and no coordinate intrinsics — so the fork is the coordinate source and the execution model, not the body:
| Seam point | GPU (NVPTX/AMDGPU/SPIR-V) | CPU (CpuTarget) |
|---|---|---|
allocaAddressSpace | 0 / 5 / 0(Function) | 0 (flat host) |
createKernel | buffers ptr addrspace(1) + scalars | buffers ptr addrspace(0) + scalars + 9 trailing i32 coordinate params (tid.{x,y,z}, ctaid.{x,y,z}, ntid.{x,y,z}) — the grid→threads model |
threadId/workgroupId/workgroupDim | hardware sreg intrinsics | read the trailing coordinate args |
globalId | native or default | shared default ctaid*ntid + tid (works verbatim) |
materializeParam / bufferElementPtr | default (arg / GEP) | default reused — host pointers are addrspace 0, plain GEP is correct |
decorateKernel | ptx/amdgpu CC + markers | no-op (default C calling convention, external linkage for JIT/AOT lookup) |
workgroupBarrier | hardware barrier | work-item loop fission (Inc 6 ✅) — emits a @__cajeta_xpu_cpu_barrier() marker; the registration pass splits the kernel at each barrier into regions, each looped over the block, with per-block shared memory + context arrays. Unsupported shapes → XPU-N02 host-stub fallback. |
waveWidth/Shuffle/Ballot/ReduceSum | hardware wave intrinsics | first cut: width 1 (one work-item per invocation): width→1, shuffle→value, ballot→zext(pred), reduceSum→value — the honest single-lane semantics, matching the existing __cajeta_xpu_wave_* runtime stubs. True wave=SIMD-lane is a later increment (§2, Inc 5). |
What stays shared: the entire kernel-body AST walk, control flow, the operator set,
the mutable scalar-slot model, casts. The coordinate reads route through threadId
etc. and the buffer GEP through bufferElementPtr — both already seam points — so the
body is untouched. This is the same ~90% core, now spanning four backends.
2. Increments
Increment 1 — CpuTarget over the shared lowerer (Tier-0) ← in progress
src/cajeta/xpu/cpu/CpuKernelLowering.{h,cpp}—CpuTarget : LoweringTargetwith the grid→threads coordinate model above;cpu::lowerKernel(method, module)wrapper;cpu::configureHostModule(native triple + DataLayout) +createCpuTargetMachine.- Test
test/xpu/XpuCpuEmitTests.cpp— lower the portable SAXPY (KernelBuffer<float32>+KernelThread.globalIdX()) and assert: addrspace-0 buffer params, the 9 trailing coordinate params, coordinate reads come from args (nonvvm/amdgcn/spvintrinsics), plain GEP,ret void. First CPU overlap data point. - Stretch (same increment if the JIT infra is light):
XpuCpuDeviceTests.cpp— JIT-compile the lowered module, loop the host driver over the grid, verifyy[i] = a*x[i] + y0[i]. The CPU oracle the GPU backends can be diffed against.
Increment 2 — CpuBackend codegen + enum/CLI/registration (Tier-0) ✅ landed 2026-05-30
src/cajeta/xpu/cpu/CpuBackend.{h,cpp}— hostTargetMachine(moved here from CpuKernelLowering to match the other backends’ structure) +emitObject(native.o).Backend::Cpu(xpu layer) +XpuBackend::Cpu(compiler) +XpuEmit::Object;--xpu-backendis now a comma-separated list (vulkan,cpu), stored asvector<XpuBackend>withaddXpuBackend/usesXpuBackend; each selected backend uses its own default arch (a single--xpu-archcan’t serve all).CpuRegistration.{h,cpp}— the CPU registration is structurally different from the GPU ones: a CPU kernel is host code, so there is no blob to embed. It lowers each kernel into a fresh module sharing the host context, decorates the symbol (__cajeta_xpu_cpu.<name>), links it into the host module, and emits a ctor calling the new runtime hook__cajeta_xpu_register_cpu_kernel(name, fnptr)(+ a tiny name→pointer registry +__cajeta_xpu_lookup_cpu_kernelincajeta_runtime.cfor the Inc-4 dispatcher). A mid-loweringXPU-N01discards the fresh module, host untouched.- Tests
XpuCpuAotCliTests: pure-CPU registration in the.ll;vulkan,cpubundles both a SPIR-V blob registration and a CPU host-kernel registration in one module;--xpu-emit=objdrops a valid ELF object.
Increment 3 — generic CpuDriver + the launch ABI (Tier-1, serial) ✅ landed 2026-05-31
src/cajeta/xpu/cpu/CpuDriver.{h,cpp}—available()≡ true (the guaranteed terminal of the priority chain);launch(name, argv, gridX, blockX, …)resolves the kernel’s registered launcher thunk by name and runs the grid→threads loop, calling the thunk per work-item with the kernelParamsargv(shared) + that work-item’si32[9]coordinate vector. Buffers ARE host pointers (no alloc/transfer). On-CPU SAXPY green.- The launch ABI is the measured CPU launch fork. A CPU kernel can’t be called from a
bare
void*, so registration emits a uniform launcher thunk__cajeta_xpu_cpu_launch.<name>(void** argv, const i32* coord)— the CPU’skernelParamsABI, mirroringcuLaunch/hipModuleLaunch(argv[i] → &arg_i). It’s reconstructed from the kernel’sFunctionTypealone (the lastkNumCoordParamsi32s are coordinates; pointer params ⇒ buffer ptr, value params ⇒ scalar), so nothing new threads through the seam. The thunk pointer is what__cajeta_xpu_register_cpu_kernelregisters, giving the driver/dispatcher one signature for every kernel. - Serial. All parallelism — multi-core threading and true wave=SIMD-lane — is Increment 5 (decision 2026-05-31): through an opaque function-pointer call LLVM cannot auto-vectorize the work-item loop, so threading and SIMD belong together there.
Increment 4 — the runtime dispatcher + explicit multi-target bundling
The active backend is chosen once at first device touch among the bundled set (a
manifest of __cajeta_xpu_register_backend ctors the compiler emits) and the
available set (runtime probes), priority CUDA→HIP→Vulkan→CPU, honoring a
CAJETA_XPU_BACKEND force-override, then cached. Every device entry point
(__cajeta_xpu_buffer_*, __cajeta_xpu_launch, stream.sync) routes to it. Staged,
because the C runtime is the sole launch path (see findings) — every backend the
dispatcher uses must be wired in C there:
- 4.0 dispatcher core ✅ + 4.1 CPU rung ✅ (landed 2026-05-31): manifest, cached
selector, env override, precise “no available backend among {…}; rebuild with
cpu…” diagnostic, and the CPU rung (buffer = malloc/memcpy, launch = the launcher-thunk grid loop in C). CUDA (already present) refactored behind the switch. GPU-free end-to-end: a host-sourcekernel.launch()SAXPY built--xpu-backend=cpuruns on the CPU. - 4.2 HIP rung ✅ (landed 2026-05-31) —
dlopenlibamdhip64 + the HIP buffer/launch block (API-identical to CUDA, shares the module table since one device backend is active per run). Device-validated on the AMD Strix Halo: a host-source SAXPY built--xpu-backend=amdgpuruns on the GPU via HIP, and--xpu-backend=amdgpu,cpuwithCAJETA_XPU_BACKEND=cpufalls to the CPU even with the GPU present — real-hardware degrade-to-CPU. - 4.3 Vulkan rung ✅ (landed 2026-05-31) —
VulkanDriver.cppported to C (instance/ device/compute-queue/cmd-pool bring-up, host-coherent buffer table, descriptor-set compute launch), guarded by__has_include(<vulkan/vulkan.h>)anddlopen-resolved (no link dependency). The launch-ABI fork is handled by per-kernel parameter metadata (__cajeta_xpu_register_kernel_params, emitted by the Vulkan registration fromcollectKernelParamInfo) + an argv→descriptor-set translation: buffer args bind to their storage buffers, scalar args are copied into transient single-element SSBOs (freed after the dispatch). Local size is baked into the SPIR-V (kVulkanLocalSizeX = 64), so the launch dispatchesgridXwork-groups. Device-validated on RADV: host-source SAXPY--xpu-backend=vulkanruns on the GPU;vulkan,cpu+CAJETA_XPU_BACKEND=cpufalls to the CPU. All four backends now route through the one dispatcher.
Increment 5 — parallelism: multi-core threading + true wave = SIMD lane
- 5A — Multi-core threading ✅ (landed 2026-05-31). The runtime’s CPU launch
(
cajeta_xpu_launch_cpu) now fans the gridX blocks acrossmin(gridX, cores)pthread workers (pthreads, not the cooperative fiber carrier, and not C++std::thread— the launch lives in the C runtime); the calling thread runs the last slice while the others work, then joins. Each worker owns itscoord[9], and a launched CPU kernel is always barrier-free (XPU-N01) so the grid is embarrassingly parallel — race-free for any kernel correct on a GPU. Serial below a 4096-work-item threshold (fan-out costs more than a small launch saves) and with one core / one block.CAJETA_XPU_CPU_SERIAL=1forces single-threaded (deterministic debug/oracle mode + the serial benchmark baseline). Measured ~8× on 32 logical cores (a compute-bound 1M×3000-iter kernel: 9.56 s serial → 1.21 s parallel, identical result). Tests:XpuCpuDispatchTests.saxpyLargeGridParallelOnCpu(a 65536-work-item grid through the dispatcher, result matches serial exactly). - 5B — SIMD via loop-vectorization (POCL model) ✅ (landed 2026-05-31). Rather than a
from-scratch whole-function vectorizer, the kernel body is wrapped in a per-block
work-item loop and handed to LLVM’s mature, divergence-aware
LoopVectorize— the model OpenCL CPU drivers use. The per-work-item kernel (__cajeta_xpu_cpu.<name>, the shared lowerer unchanged) is markedalwaysinline; a per-block wrapper__cajeta_xpu_cpu_block.<name>(real params…, ctaid.{x,y,z}, ntid.{x,y,z})loopstid.xover[0, ntid.x)calling it; the kernel is inlined into the loop and a focused pass pipeline (mem2reg → loop-rotate → LoopVectorize → SLP → instcombine → simplifycfg,compile/Optimizer.cpp) vectorizes it. The launch ABI shifts to per-block (the thunkcajeta_xpu_launch_cpu+CpuDrivercall once per block; the wrapper owns the work-item loop), composing with the 5A pthread fan-out (threads run block ranges, each block is a vectorized loop).
- The host TargetMachine now targets the native CPU + features (
getHostCPUName+getHostCPUFeatures) so TTI exposes AVX2/AVX-512 — without it LoopVectorize leaves the masked store scalar. Tradeoff: AOT objects are tuned to this machine (a--cpu-archbaseline knob for portable binaries is a later refinement). - A general optimization pipeline landed too: cajeta ran zero IR optimization on
user code;
--opt=O0|O1|O2|O3(default O0;--release→O2,--fast→O3) runs the full per-module pipeline inemitForModule. Kernel vectorization is independent of--opt(always on). The JIT test path stays unoptimized, so existing JIT correctness tests are unaffected — but the dispatch/driver tests now run the vectorized kernel, validating its correctness. - Measured: a compute-bound 1M×3000-iter kernel went 9.56 s (scalar serial) → 3.65 s
(SIMD serial) → 0.138 s (SIMD + 32-thread parallel) — ~69× total, AVX-512
<16 x float>confirmed in the IR. Tests:XpuCpuAotCliTests.cpuBackendVectorizesBlockWrapper(divergence-free kernel → SIMD ops in the wrapper, host-robust) + the dispatch/driver suite on the vectorized kernel.
- 5C — wave-op SIMD via the LLVM Vector Function ABI ✅ (landed 2026-05-31). Wave ops are
cross-lane, so
LoopVectorizebails on them (5B left them width-1). 5C makes the wave = the SIMD vector: eachWave.*lowers to a scalar call to its__cajeta_xpu_wave_*stub, and the CPU registration pass gives that stub a Vector Function ABI variant — a small SIMD function LLVM 23’sVFDatabasereads off thevector-function-abi-variantattribute andLoopVectorizesubstitutes when it widens the per-block work-item loop. We write no vectorizer — only the per-op variant bodies (synthesized in IR, so they legalize to real SIMD on any host):reduceSum→broadcast(llvm.vector.reduce.add(v));ballotSync→broadcast(zext(bitcast <W x i1> → iW));shuffleSync→ a per-lane gatherval[src[i]].- Non-degrading by construction. The op is a scalar call with a variant attribute;
if vectorization fires it uses the variant (wave = W), if not the scalar width-1 stub runs
— always correct, never silently wrong. The mangled name uses the target-independent
_LLVM_ISA token (LoopVectorize matches on VF + parameter shape, not ISA). - Wave width W = the host’s native SIMD width (16 on AVX-512, 8 on AVX2), read from the
host TTI (
getRegisterBitWidth(FixedWidthVector)/32). The work-item loop is forced to that VF (llvm.loop.vectorize.width+.enable) so the loop VF equals the variant VF. - Divergence → masked variants. The idiomatic
if (i < n) { …reduceSum… }predicates the block; LoopVectorize then uses the masked variant (_Mv16(<W x …>, <W x i1> mask)), passing the active-lane predicate — GPU active-mask semantics (reduce/ballot over active lanes only). Tail handling is the kernel author’s job (block a multiple of W, as on GPU); tail-folding the whole loop was rejected — it scalarizes the loads (slow) where the common full-block path stays clean wide loads. Wave.width()andWave.laneId()— the queryable environment.width()takes no argument, so it cannot carry a VFABI variant (a 0-param variant is rejected by the verifier); instead it is rewritten to the constant W in a vectorized wave kernel (W is the architectural width, constant across active and inactive lanes, like a warp size).laneId()(new across the seam + all four backends — NVPTX%laneid, AMDGPUmbcnt, SPIR-VSubgroupLocalInvocationId, CPUtid.x % W) gives the lane index;isFirstLane()(=laneId() == 0, lowered for every backend) is the width-agnostic cooperation guard. The rule: never hardcode the wave width — queryWave.width()/laneId(). No width constant is exposed; the same wave-cooperative source is correct on NVIDIA (32), AMD (32/64), Vulkan (runtime), and CPU (16/8).- Tests:
XpuCpuWaveSimdTests(reduceSum/divergent-masked/ballot/shuffle/width/laneId/ isFirstLane, each width-agnostic — probes the real W then verifies against it);XpuWaveEmitTests(per-backendlaneIdintrinsics + spirv-val);XpuWaveDeviceTests(laneIdon real AMD + Vulkan hardware).
- Probe AMX / SME matmul lowering (CPU matrix engines, runtime-feature-gated).
Increment 6 — workgroup barriers via work-item loop fission (POCL-style) ✅ (landed 2026-05-31)
A @Kernel with Barrier.workgroup() can’t run as one work-item loop: a barrier means
every work-item of the block must reach it before any proceeds. POCL-style fission
(xpu/cpu/CpuBarrierFission.cpp, invoked from CpuRegistration) splits the kernel at each
barrier into regions and wraps each in its own loop over the block — and the serialized
region loops honor the barrier for free, because a per-block wrapper call runs on exactly
one pthread (5A fans blocks across threads, never the work-items of one block). This
finally unblocks shared-memory tree reductions on the CPU. Barriers were XPU-N01; the new
diagnostic for an unsupported barrier shape is XPU-N02.
- The barrier becomes a marker.
CpuTarget::workgroupBarrieremits an impure,noinline/noduplicatecall void @__cajeta_xpu_cpu_barrier()(instead of throwing) that survives lowering as a region delimiter; the pass erases every call once it has split. - The pass (operating on the per-work-item kernel cloned into the wrapper):
CloneFunctionIntothe kernel body into the wrapper, mappingtid.x→ a placeholder replaced per region with that region’s work-item-loop index,tid.y/z→ 0 (the launch ABI is 1-D),ctaid/ntid→ wrapper args. Connect entry→body soDominatorTree/LoopInfosee the CFG (the load-bearing fix — without the entry terminator, the body is unreachable and no loops are found).- Split each barrier into its own boundary block; build
LoopInfo. - Region formation is a loop-aware structured walk, not SESE/dominance: a region is a
maximal barrier-free run within one loop level; a uniform loop that contains a barrier
stays the outer scalar scaffold (its header/latch run once per iteration) and its body
region is wrapped in a work-item loop nested inside. So the tree reduction’s
for (s)loop is preserved and the halve-and-add (region B) loops over the block within it. - Context save/restore at the alloca-slot level (pre-mem2reg, the key simplification —
no SSA liveness analysis): a local that is per-work-item (a
tid.x-tainted value is stored to it) and lives across a barrier (accessed in >1 region) is widened fromTto a[ntid.x × T]array indexed by the region’s work-item index. Block-uniform locals (the loop vars, kernel params) stay scalar; region-local temps stay scalar and mem2reg promotes them. The generated work-item loops are clean counted loops, so 5B’sLoopVectorizestill widens them to SIMD. - Per-block shared memory. A
Shared<T>array lowers to one module-leveladdrspace(3)global — one instance, which would race across the blocks/threads that each call the wrapper. The pass replaces it with a wrapper-localalloca(fresh per block call),addrspacecast0→3 (a no-op the CPU backend folds), and deletes the dead global.
- Guardrails (
XPU-N02, checked before any mutation → host-stub fallback, never a miscompile): a barrier under work-item-divergent control flow (post-dominance check — the barrier must post-dominate its level’s entry); a barrier in a loop with atid-dependent trip count; a barrier in a nested loop (one level — lifted in Inc 8); dynamic-sized shared memory (lifted in Inc 10); wave ops + barriers in one kernel (the 5C forced-VF path and fission — composed in Inc 9). - Verified:
XpuCpuBarrierExecTests— two straight-line regions run the whole block; per-block shared memory staged + read cross-lane; the canonical tree reduction (in[i]=i, 256 block ⇒out[0]=32640) with a barrier inside the uniform loop; 32 blocks × 256 across pthread workers with no shared-buffer aliasing; a divergent barrier falls back cleanly. Barrier-free CPU + wave (5C) + GPU suites unchanged. - Out of scope (later):
multiple barriers / multiple regions inside one loop(Inc 7 ✅); nested uniform loops with barriers; composing wave ops with barriers; a register accumulator carried per-work-item across the uniform loop (the canonical kernel keeps it in shared memory).
Increment 7 — multiple barriers / regions inside one uniform loop ✅ (landed 2026-05-31)
Lifts the first Inc 6 scope cut: a uniform loop body may now contain more than one barrier,
splitting it into N work-item-loop regions (e.g. a ping-pong stencil read; barrier; write; barrier per iteration). The region walk already iterated multiple barriers — the loop body’s
while (cur) in the structured walk closes a region at each barrier and continues — so the
structure was right; the gap was correctness of context arrays for a per-work-item local
that crosses an in-loop barrier.
- The bug. Fission widens a per-work-item local that lives across a barrier into a
[ntid.x × T]context array, deciding “per-work-item” by tainting transitively from the work-item-index placeholder. But the taint walked SSA def-use only, and Cajeta lowersuint32 t = KernelThread.x()as a store tot’s slot followed by a load at each use — so the SSA chain breaks at the slot. A local likeint x = a[t] + a[(t+1)&255]is therefore stored from a value the taint never reached;x’s slot stayed a single scalar shared by every work-item, so the last lane’sxclobbered all others (lane 0 read the wrong value). The Inc 6 reduction never hit this — its only barrier-crossing per-work-item value wastitself (directly tainted), everything else lived in shared memory. - The fix (
CpuBarrierFission.cpp,computeTaint): make taint a fixpoint over SSA def-use and memory round-trips — a load from any alloca slot that ever receives a tainted value is itself per-work-item; re-propagate until stable. Over-approximation is safe (a wrongly-widened uniform slot just gets redundant per-lane storage, same result). Nowx’s slot is recognized as per-work-item and widened tox.slot.ctx. - Register accumulator across the loop back-edge — covered by the same fix. A per-work-item
scalar carried across a uniform loop’s iterations (
int acc = 0; for (…) { acc += tile[…]; barrier; }) was a separate Inc 6 scope cut, but it has the same root cause —accderives fromtthrought’s slot, so pre-fix it wasn’t tainted and stayed a single shared scalar. The memory-aware taint widensacctoacc.slot.ctx, and because a context array is allocated once in the wrapper entry and indexed by work-item, it persists across the outer scalar loop’s iterations for free — the accumulation carries correctly with no shared memory for the accumulator. So this cut is lifted too. - Verified:
XpuCpuBarrierExecTests.multipleBarriersInOneLoop(shared-memory ping-pong, two barriers per iteration, matches the host recurrence for every lane),localCarriedAcrossInLoopBarrier(the local-across-an-in-loop-barrier case — the regression that exposed the taint gap), andregisterAccumulatorAcrossLoopBackEdge(a per-work-item register accumulator carried across iterations);XpuCpuBarrierEmitTests.multiBarrierLoopBodySplitsAndWidensLocal(the loop body splits into ≥2 work-item loops nested in the single outer scalar loop;xis widened to a context array). The Inc 6 single-barrier reduction + all prior CPU/wave/GPU suites unchanged. - Still out of scope (after Inc 7):
nested uniform loops with barriers(Inc 8 ✅); wave ops + barriers in one kernel.
Increment 8 — nested uniform loops with barriers ✅ (landed 2026-05-31)
Lifts the one-level restriction: a barrier may sit inside a loop nested inside another loop
(e.g. a tiled/blocked iteration). The region walk was already recursive — it recurses into each
barrier-containing subloop (walk(inLoopSucc(L), L, …)), and loopHasBarrier is transitive, so
the outer loop is detected as a barrier-subloop at the top level and the inner one within it.
Each loop that (transitively) contains a barrier stays an outer scalar scaffold, nested one
inside the next, with the innermost body’s regions wrapped in work-item loops nested as deep as
the loop nest. Context arrays (allocated once in the wrapper entry, indexed by work-item) and
per-block shared memory already persist across every level, so the cross-iteration / cross-level
carry needs no extra machinery.
- The change was small: drop the
if (L->getLoopDepth() > 1) unsupported(…)guardrail, plus one defensive check in the walk — if a level starts directly on a nested barrier-loop header (no separating preheader region) enter it as a subloop rather than lettingcollectflatten it (Cajeta’s structured loops always have that preheader, so this is belt-and-suspenders for the never-miscompile contract). Every other guardrail is unchanged and still per-loop. - Guardrails intact: a barrier in a nested loop with a work-item-dependent trip count is
still rejected (
XPU-N02→ host stub), since different work-items would execute different barrier counts (GPU-undefined deadlock); likewise a barrier under divergent control flow. - Verified:
XpuCpuBarrierExecTests.nestedUniformLoopsWithBarrier(a barrier in a doubly-nested loop; both loops stay scalar scaffolds, twofor.head),nestedLoopsWithOuterLevelRegions(an outer loop holding a direct barrier-region and a nested barrier-loop — theregion·barrier·region·barrier·subloopsequence at one level), and the guardrailnestedTidDependentTripCountFallsBack(a tid-dependent inner trip count falls back, not miscompiles). All prior suites unchanged. - Still out of scope (after Inc 8):
composing wave ops with barriers(Inc 9 ✅).
Increment 9 — wave ops + barriers composed in one kernel ✅ (landed 2026-05-31)
Lifts the last barrier scope cut: a kernel may use Wave.* (5C) and Barrier.workgroup()
(Inc 6) together — the canonical two-level reduction (intra-wave SIMD reduce → shared-memory
staging → barrier → cross-wave combine) now runs on the CPU. The two mechanisms were kept
disjoint because 5C forces a vector width and attaches VFABI variants to the one work-item
loop, while fission produces many. The insight: each fission region is itself a clean counted
work-item loop, so the 5C machinery applies per region unchanged.
- The composition.
fissionBarrierKernelnow reports the back-edge branch of every region work-item loop (workItemLatches). The registration pass, after fission, runs the same 5C setup it uses for a barrier-free wave kernel — attach the SIMD variants (setupWaveVariants), rewritewidth()to the constant W, andforceLoopVectorWidth(W)on each region latch — then vectorizes and folds the substituted variants (foldWaveVariants). A wave op inside a region becomes W SIMD lanes; the barriers still delimit regions. The 5C variant/fold code was extracted into shared helpers so both paths use one implementation. The fissionXPU-N02guardrail that rejected wave+barrier is removed. - Semantics. On the CPU the wave = the W SIMD lanes of one vectorized iteration; a fission region loop vectorized at W partitions work-items [0,W),[W,2W),… — the same partition in every region, so wave membership is consistent across barriers. Forcing VF=W on every region loop is safe (the block width is uniform); only wave-bearing regions carry the inherited 5C assumption that the block size is a multiple of W (a partial tail wave would run the scalar width-1 stub in the loop’s scalar epilogue — same limitation as the barrier-free 5C path).
- Verified:
XpuCpuBarrierExecTests.waveReduceWithBarrierBlockSum(two-level block reduction,out[0]=32640— width-independent, and distinguishes a real wave reduce from the width-1 stub, which would give 1920) andwaveReduceWithBarrierMembership(in[t]=1 ⇒ out[t]==Wfor every lane against a probed width — per-wave membership, not just an associative total). All 5C wave, barrier, and GPU suites unchanged. - Barrier fission scope cuts are now fully lifted — multiple barriers per loop (Inc 7), register accumulator across the back-edge (Inc 7), nested loops (Inc 8), wave + barrier (Inc 9).
Increment 10 — dynamic (runtime-sized) shared memory + a barrier ✅ (landed 2026-05-31)
Lifts the static-shared-size requirement: a shared T[runtimeN] array (sized by a kernel
parameter, not a constant) lowers to an external unsized [0 × T] addrspace(3) global, and the
fission pass now allocas it per block from the launch’s sharedBytes: count instead of throwing
XPU-N02.
- The launch ABI carries the byte count to the CPU wrapper.
__cajeta_xpu_launchalready tooksharedBytes(cuLaunchKernel’s dynamic-shared bytes), butcajeta_xpu_launch_cpudropped it. It now threads it: the per-block coord vector gains a 10th slot (coord[9]= dynamic-shared bytes), the generated launcher thunk loads it and passes it as the per-block wrapper’s trailing param, and fission’s step 5 allocasalloca i8, i64 sharedBytes(aligned 16) for an unsized shared global andaddrspacecasts it to addrspace(3). The kernel’s typed GEPs index the byte buffer unchanged. Every wrapper gains the trailing param (uniform thunk ABI); non-dynamic kernels ignore it (DCE). The two thunk callers — the C runtimerun_sliceand the C++CpuDriver— both write the 10-slot coord. - Verified:
XpuCpuBarrierExecTests.dynamicSharedMemoryWithBarrier— the canonical tree reduction withshared int32[n](runtimen), launchedsharedBytes: [1024]over a 256 block ⇒out[0]=32640. FullXpu*suite green (137 passed / 7 GPU-skipped / 0 failed); static-shared, wave, and device launch paths unchanged (the extra coord slot is additive).
Wave-width / block-size constraint (documented — not a fixable bug)
The wave-on-CPU model (5C) maps a wave to the W SIMD lanes of a vectorized iteration, where
W is the host’s native width (16 AVX-512 / 8 AVX2 / 4 SSE). This W differs from a GPU warp
(32/64), so a wave kernel’s partitioning is platform-specific by construction — only
width-agnostic kernels (that query Wave.width() and produce a width-independent result, e.g.
the two-level reduction whose block total is the same for any W) are portable. Such kernels need
the block size to be a multiple of W to have full waves; a partial tail wave (block not a
multiple of W) runs the scalar width-1 stub in the loop’s scalar epilogue. This is inherent,
not a miscompile to fix: tail-folding wouldn’t make a non-W-multiple block cross-platform-correct
(the kernel’s own block/width already drops the partial wave), and it is automatically satisfied
by every warp-multiple block size (32/64/128/256 — all divisible by 4/8/16), i.e. by all
GPU-idiomatic launch shapes. Inherited from the barrier-free 5C path; Inc 9 adds nothing new here.
The scalar twin never runs (2026-10-05). The scalar epilogue above is one of two ways
into the scalar copy LoopVectorize leaves beside a widened loop; the other is a runtime
check the vectorizer adds when it widened under a predicate it could not prove. Every
power-of-two mask on the thread id (x & 15, x % 16, at any stride, in 32 or 64 bits)
produced one: ScalarEvolution reads the mask as a truncation, the cost model’s
consecutive-pointer analysis makes it affine under the predicate that the truncation never
wraps over the loop, and the check is “block <= mask + 1”. For x & 15 no launch passes it,
for x & 31 only a one-wave block does, and the loop that ran was the scalar twin, where
Wave.reduceSum is its own input and a tile’s wave collectives are width-1 (measured: cpu 2
for reference 929 on (x & 7) * 4; fromWords of words loaded from a buffer read each lane’s
own A element for every k; two epilogue verbs in one kernel disagreed although each agreed
alone). Two changes, both in vectorizeFunction:
- a lane mask is blinded to the stride analysis before LoopVectorize, rewritten as
x - ((x >> k) << k), an add of a recurrence and a udiv that the predicate rewriter cannot make affine, so the access is a gather with no predicate (unit-stride speculation is off for the same span,laa-speculate-unit-stride, sox * nis a gather rather than a loop versioned onn == 1); the test leverCAJETA_XPU_FAULT=keep-lane-masksleaves the mask as written; - after widening, the scalar twin of every work-item loop that holds a wave op is retired:
its preheader becomes a call to
__cajeta_xpu_cpu_scalar_twinand a return from the block, the twin is deleted, and the launcher refuses the launch by name (reason 2,Device.checkLaunchraises). A partial tail wave is therefore a refused launch now, not a width-1 result. Two twins keep running: one with no wave op in it, ordinary scalar code that is right at width 1 for a block of any size, and the twin of a block that is one work-item wide, since a one-lane wave IS the width-1 stub (a reduce is its input, a shuffle its own value, a ballot one bit), whichXpuGroupDevice.reduceAddEqualsSerialOnCpurelies on.
The parallel tag survives the pipeline (2026-10-09). Every memory access of a
work-item loop is tagged with one access group and the loop lists it in
llvm.loop.parallel_accesses, which is what lets LoopVectorize widen a forced loop whose
buffer addresses are not affine in the work-item without runtime alias checks. The passes
between that stamp and the vectorizer can make a new access out of tagged ones and drop the
tag: InstCombine merges a store issued on both arms of an if into one store of a PHI, and
the merged store carries no group. One untagged access makes the whole loop no longer
annotated parallel, LoopAccessAnalysis checks every store against every load, an epilogue
y[row] = tot against its tot + bias[row] has no computable bounds (“cannot identify
array bounds”), and the wave reduce in that loop is left scalar. The three fused qkv wave
kernels of cajeta-llm were declined on cpu by exactly this, and only because they also hold a
Vector value, since the InstCombine rounds that merge the store run only then
(xpu-kernel-adaptor 4.2.1.10). vectorizeFunction therefore re-stamps the groups right
before LoopVectorize: whatever reads or writes memory inside a loop that lists a parallel
access group, and lacks it, gets it, since the loops are parallel by construction and an
access derived from tagged accesses is as independent across work-items as they were. The
test lever CAJETA_XPU_FAULT=keep-dropped-access-groups skips the re-stamp and shows the
refusal (XpuCpuFissionUniformize.aVectorValueAndAnEpilogueBufferLoadStillLowerTheReduce).
CAJETA_XPU_CPU_DUMP_PRELV=<dir> writes each wrapper exactly as LoopVectorize sees it, after
every pass of the pipeline before it; the PREOPT dump is the module before the pipeline, and
an opt replay over it is an approximation (two passes are the compiler’s own and the LAA
option scope is not an opt flag), so a question about what the vectorizer saw is answered
by PRELV.
A uniform split inside a per-work-item guard is an arm (2026-10-09). Fission runs a
workgroup-uniform branch once, outside the work-item loops, with each side regioned on its
own (4a), and predicates a per-work-item guard around a scaffold loop as an arm selected by
a mask (4c) only when some work-item that enters the guard would miss the loop. A guard
such as if (row < limit) { if (which == 0) {loop} else if (which == 1) {loop} else {loop} },
where the inner chain is uniform by provenance and each arm holds a lane-strided loop,
passed neither test: every loop post-dominates its own split arm, so the guard stayed
ordinary control flow, the region before it collected the way around it (the epilogue after
the join) while the splits’ continuation reached the same blocks, and the uniform-loop
scaffolding declined with “a block is reached by more than one region path”. Since the
split runs outside the work-item loops, the guard around it has to be an arm too, and 4c now
asks for the arm whenever a uniform split lies inside the guard’s scope. The two iq qkv
wave kernels of cajeta-llm were declined on cpu by this
(XpuCpuFissionUniformize.aUniformSplitOfLoopsInsideAPerWorkItemGuardLowers,
aReturningArmBeforeASplitInsideAGuardLowers). Under CAJETA_XPU_DEBUG_WAVE=1 the walk
prints every region ([wave-walk] <start> -> <continuation> (pred, kind) <blocks>) and every
arm cut ([wave-cut] guard: T, F, join), and the refusal names the block reached twice and
the region that collected it.
A predicated loop’s body may hold arms (2026-10-09). A per-work-item loop whose body
branches per work-item between stores and an inner strided loop, the store tail of the Id
WMMA kernels (while (tt < nAct) { if (tb + 16 <= mEnd) { store } else { while (w < 256) ... } }), is scaffolded as a predicated loop with 4c arms inside it. Four rules changed to admit
it, each measured on the kernel and a reduced probe: the clean-exit question
(“is the only way to miss this loop to leave without doing anything?”) is asked of one trip
of the enclosing loop, never across its backedge, since otherwise every block of the body
arrives at the inner loop on a later trip and the guard gets no arm; a region cut 4c placed
does not disqualify a loop from 4b and does not make it a barrier loop for the trip-count
rule, because the body it splits still runs trip by trip under the work-item flags; a 4c arm
is a level of its own inside a loop as it is outside one; and the region walk inside such a
body sends a work-item that leaves the loop before a cut to a .leave.cut that clears its
flag and continues to the cut with the others, the last body region alone owning the loop’s
skip. What makes that sound is step 9: every region that starts inside a predicated body is
admitted on the loop’s per-work-item active flag, so a work-item that left runs no later body
region on an arm mask it never refreshed. The probe
(XpuCpuFissionUniformize.aPerWorkItemLoopWhoseArmHoldsALoopLowersAndMasksRight) checks the
output cell by cell against a host run of the per-lane program. One shape stays refused and
is on the plan: a uniform split inside a predicated loop, whose join flows into the loop’s
skip (xpu-kernel-adaptor 4.2.1.5).
Dead PHI cycles out of mem2reg (2026-10-09). A per-work-item temporary stored under one
arm of a work-item loop’s body is one alloca for every work-item, so mem2reg threads its
value around the x, y and z loop headers and any enclosing scaffold loop as loop-carried PHIs
that nothing reads, and LoopVectorize refuses a header PHI that is neither an induction nor
a reduction (“value that could not be identified as reduction is used outside the loop”).
vectorizeFunction runs ADCE right after mem2reg for every wrapper; the wrappers that hold a
Vector value never showed the defect only because their InstCombine rounds deleted the
cycles.
A value live across a region boundary takes a slot (2026-10-09). Fission demotes a value
defined before a barrier and used after it to a stack slot (step 3c), since each region is
its own work-item loop. The scaffold loops of 4b split the body at loop boundaries as well,
and a lowering temporary that crosses one, a Shared panel address computed at the top of a
uniform loop’s body and reused after an inner scaffold loop (q6kWmmaIdMwKernel), was left
as SSA across two work-item loops: “Instruction does not dominate all uses”. Once the regions
are known, any value defined in one region and used outside it takes a slot like a local
(step 6b); a tainted one gets a context array in step 7 and its accesses are redirected in
step 10. CAJETA_XPU_DEBUG_WAVE reports how many values moved.
Four more admissions, measured on the last cpu declines (2026-10-09). An acquire fence
inside a work-item loop (Barrier.deviceMemory(Acquire), the last-block pattern) is hoisted
to the loop’s predecessor by the registration, since LoopVectorize refuses a fence and one
acquire before the loop orders every lane’s later loads at least as strongly; a release fence
is not symmetric and stays (routerTopKWaveKernel, rmsnormRouterTopKKernel). The decision to
take the fission path for a wave kernel with a loop asks the kernel AND the @Device helpers
it reaches for wave ops, because a reduce kept in a helper inlined after that decision left
the kernel’s lane-strided loop un-scaffolded (the column mat-vecs). Inside a predicated
scaffold loop, the “one level out” admission takes the trip’s body after the gate and the
loop’s own exit test as the level, not the gate: a work-item whose flag is down skips the
trip, it does not miss an inner loop. And a predicated body split by any boundary, a 4c cut,
a uniform split or a scaffold subloop, gives its skip and leave to the last region only
(the body’s block set is refreshed from LoopInfo at transform time, since the uniform
transforms split latches after it was taken): q50F32WaveMatVecKernel’s uniform
while (j < 16) inside its lane-strided loop, and the uniform split inside a predicated
loop. Pinned by XpuCpuDeclaredWave.anAcquireFenceInTheBodyIsHoistedAndTheReduceWidens and
XpuCpuFissionUniformize.{aWaveOpInsideADeviceHelperStillTakesTheFissionPath, aUniformLoopInsideAPredicatedLoopLowers, aUniformSplitInsideAPredicatedLoopLowersAndMasksRight}.
A break leaves a scaffold loop (2026-10-09). The predicated scaffold sends every
exiting edge of the body to the loop’s leave, so a break was always one more such edge,
but the shape was declined before the scaffold saw it: the break reaches the code after the
loop through an empty block of its own, br <after the loop>, and the loop read as having
two exit blocks. Fission now folds a loop’s forwarding exit block into its target (step 3d)
when every predecessor is in the same loop, the block holds nothing but the branch, it is no
barrier’s boundary, and its target merges no values in phis. Both the per-work-item and the
workgroup-uniform break lower as predicated loops and answer by value
(XpuCpuFissionUniformize.{aPerWorkItemBreakLeavesTheLoopForThatWorkItem, aUniformBreakInsideAScaffoldLoopLowers}, xpu-kernel-adaptor 4.2.1.3).
A uniform slot starts at zero (2026-10-09). A workgroup-uniform local is one stack slot
for the block, written by the work-items a region admits. When a region admits nobody,
because every work-item already left through a per-work-item return (the last-block check
of a pack tail), a scaffold loop after it still runs its header once for the block and reads
a slot no work-item wrote: after mem2reg an undef phi in the loop’s exit test, so the trip
count was whatever an earlier kernel left behind. iq3xxsQ8IdGateUpGluKernel spun every cpu
worker until the watchdog, and only in the full cajeta-llm leg. Fission now zeroes every
uniform slot at the wrapper’s true entry, as step 7 zero-fills the context arrays, and such a
loop runs trips that admit nobody and ends
(XpuCpuFissionSlotInit.aUniformSlotNoWorkItemWroteIsZeroNotUndef, an IR check, since the
wrong behaviour depends on garbage; xpu-kernel-adaptor 4.2.1.12).
The global index over the pinned block is the workgroup index (2026-10-10). A kernel
whose launch sites all pass one constant block B (workgroup-reduce 6.8) is never launched
with another (the runtime refuses it, reason 4), and KernelThread.globalIdX() is
ctaid.x * ntid.x + tid.x. Since ntid.x is B and tid.x < B on every launch, fission
rewrites globalIdX() / B to the workgroup index and
globalIdX() % B to tid.x before it asks what is uniform (step 2b, with the >> and
& forms for a power of two). Read as written, the quotient was tainted by tid.x, and the
attention flash-prefill kernels of cajeta-llm (wg = globalIdX() / 256; ... while (kb0 < kEnd) with kEnd derived from wg) were refused: a barrier loop with a work-item-dependent
trip count. Without a pin only a division by Workgroup.dimX() itself is rewritten. The pin reaches
fission alone: passing it as constant ntid made a 32-wide block’s work-item loop a
constant 32 trips, which LoopVectorize declined, and a wave shuffle stayed scalar.
(XpuCpuFissionUniformize.aBarrierLoopBoundedByTheQuotientOverThePinnedBlockLowers,
xpu-kernel-adaptor 4.2.1.)
A uniform local every work-item updates in a loop is per work-item (2026-10-10). Step
9b makes a uniform read-modify-write idempotent by snapshotting the slot at the region’s
entry, and does so only when the store post-dominates that entry. A store in a loop body
or behind a uniform branch does not, so the slot stayed shared and every work-item added
its update on top of the last one’s: a kernel summing four values after a barrier read
4 (n + 1) at lane n. Step 7 now gives such a local a context array when the region’s
branches are all uniform (every work-item runs the update, each into its own copy). A
per-work-item branch (if (t == 0) { total = total + p; }, the lane-0 tail) keeps the
shared slot, which is what that pattern needs. Found when the pinned-block quotient made
the epi WMMA kernels’ replicated tile accumulator uniform and it read 32 times too large at
block 32 (XpuCpuFissionUniformize.aUniformLocalUpdatedInALoopByEveryWorkItemIsNotSummedAcrossThem).
The work-item loop also carries llvm.loop.interleave.count 1: left to its cost model
LoopVectorize interleaved some regions four times, so their vector loop needed a 4W-wide
block and every smaller block ran the scalar copy.
XpuReferenceInterpreter.aMaskedLaneIndexSumsOverTheWaveOnCpu holds the masks at one and two
waves, theScalarTwinOfAWaveKernelIsRefusedNotRun the refusal.
Docs
- This file (the log). The matrix gains a CPU column (today: emit + grid→threads
measured; wave width 1; barrier deferred). The NVIDIA∩AMD variance reckoning
(see
CajetaXPU-Variance.md) extends to four backends.
3. Findings & limitations
- Inc 1 — landed 2026-05-30.
CpuTargetover the shared lowerer; the portable SAXPY kernel (KernelBuffer<float32>+KernelThread.globalIdX()) lowers to a host function and runs correctly on the CPU over a grid.- The seam held with a single new fork: the coordinate source. The CPU reuses
materializeParam,bufferElementPtr, andglobalId’s default verbatim — the only overrides arecreateKernel(append 9 i32 coordinate params, addrspace-0 buffers) and the three coordinate reads (pull from those args). The body walk is untouched — the same ~90% core now spans four backends. - The existing “CPU emulation” is confirmed orthogonal. It compiles a
@Kernelas an ordinary host method with an explicit index param; it cannot run aKernelThread.x()- style portable kernel.CpuTargetis the actual portable path. - Wave ops are width-1 (honest single-lane), barriers raise
XPU-N01— both as designed; Inc 5/6 lift them. - Tests:
XpuCpuEmitTests(lowering shape — addrspace-0, coordinate-param model, no device intrinsics) +XpuCpuExecTests(JIT-execute SAXPY over a grid via LLJIT, verifyy[i] = a*x[i] + y0[i]— the CPU oracle). - Build gotcha (recorded): the out-of-source build means
cmake .in the source tree is a no-op for the realbuild/dir — new files needcmake -S . -B buildto re-glob (GLOB_RECURSEwithoutCONFIGURE_DEPENDS).
- The seam held with a single new fork: the coordinate source. The CPU reuses
- Inc 2 — landed 2026-05-30.
Backend::Cputhreads through the whole AOT path (enum → dispatch → Compiler → CLI), the backend selection became a multi-target list, and CPU registration links the kernel into the host module + registers its pointer.- The registration shape is the measured CPU fork. GPU backends embed a
context-independent blob +
__cajeta_xpu_register_module(name, bytes, len). The CPU can’t: its kernel is host IR in the program’s ownLLVMContext, so it must be linked (same-context fresh module →Linker::linkModules) and registered by function pointer via__cajeta_xpu_register_cpu_kernel(name, fnptr). Cross-context linking is illegal, which is why the fresh module shareshostModule.getContext(). - Multi-target works end-to-end:
--xpu-backend=vulkan,cpuputs a SPIR-V blob registration and a CPU host-kernel registration in one.ll— the bundling the explicit-only fall-to-CPU contract needs. - The
emitXpuKernelsartifact/registration loop now iterates selected backends; single-backend behavior (and--xpu-arch) is preserved, multi-backend uses per-backend default arches.
- The registration shape is the measured CPU fork. GPU backends embed a
context-independent blob +
- Inc 3 — landed 2026-05-31.
CpuDriverlaunches a registered kernel by name over a grid, serial. A portable SAXPY (KernelBuffer<float32>+KernelThread.globalIdX()) lowers, registers, and runs correctly on the CPU end-to-end through the runtime registry.- The launcher-thunk is the measured CPU launch fork. GPU drivers hand a flat
void** kernelParamstocuLaunchKernel/hipModuleLaunchKernel, which the hardware ABI unpacks. The CPU has no such ABI — calling host code needs a concrete C signature — so registration emits a per-kernel uniform unpacker__cajeta_xpu_cpu_launch.<name>(void** argv, const i32* coord)and registers it. The driver then drives one signature for every kernel. The unpacker reconstructs from the kernel’sFunctionTypealone — noKernelParamlist threaded through — so the seam stayed put: the coordinate source (Inc 1) and the registration shape (Inc 2) are still the only CPU forks; the launch fork lives entirely in the registration pass- the trivial driver, not on
LoweringTarget.
- the trivial driver, not on
argvmirrors the CUDA/HIPkernelParamsconvention exactly (argv[i] → &arg_i), so host-side launch ergonomics match the GPU device tests minus the device.- Serial; threading + SIMD are Inc 5 (the honest split — an opaque function-pointer call isn’t auto-vectorizable, so “vectorized payoff” was never reachable in Inc 3 anyway).
- Tests:
XpuCpuDriverTests(by-name launch + correct SAXPY over a grid; a second param shape —+uint32 nwith anif (i<n)guard — proving the FunctionType-driven unpacker is general; a lookup-miss returning false) + an extendedXpuCpuAotCliTestsasserting the thunk is emitted and registered. The driver test exercises the real registration ctor: it JITs the registration module, maps__cajeta_xpu_register_cpu_kernelas an absolute symbol, runsllvm.global_ctors, and lets the ctor register the thunk into the same registry the driver reads back.
- The launcher-thunk is the measured CPU launch fork. GPU drivers hand a flat
- Inc 4.0 + 4.1 — landed 2026-05-31. A compiled @Kernel program runs on the CPU with
no GPU, through the real host-source launch path — the degrade-to-CPU headline.
- The decisive architectural finding: the C runtime is the SOLE launch path for
compiled programs.
runtime/native/cajeta_runtime.cis parsed from embedded bitcode and linked into every program (CajetaModule::linkRuntime); it self-contains CUDA (its owndlopen+cuLaunchKernel). The C++CudaDriver/HipDriver/VulkanDriver/CpuDriverclasses are compiler/test-only and never linked into a user program (the runtime’s own comment says as much for CUDA). So the dispatcher — and every backend’s launch + device memory it can use — must live in C, in the runtime, not in the C++ drivers. That is why Inc 4 is staged per-backend and why the C++CpuDriver(Inc 3) is the test-side twin of the runtime’s in-C CPU launch, exactly asCudaDrivermirrors the runtime’s in-C CUDA — a deliberately duplicated pattern, not redundancy. - One cached selector, then per-backend branches.
cajeta_xpu_active_backend()picks the highest-priority backend that is both bundled and available (env-forceable), caches it, and every device entry point switches on it. The int64KernelBuffer<T>handle is backend-specific (CUDA/HIP device ptr, Vulkan buffer index, CPU host block) but coherent within a run because the backend is fixed at first device touch (locked decision #2 — a GPU lost mid-run is a hard error, not a silent CPU re-run). - The manifest closes the registry-ambiguity gap. The runtime can’t tell a cubin from
a SPIR-V blob (both register via
__cajeta_xpu_register_module), so the compiler emits a__cajeta_xpu_register_backend((int) Backend)ctor per bundled backend; thexpu::Backendenum values were already priority-aligned with the runtime ids (CUDA 0 / HIP 1 / VULKAN 2 / CPU 3), so the id is just the cast. Limitation, documented: at most one non-CPU device backend per bundle (mixednvptx,amdgpuwould collide one-blob-per-name) — a per-blob backend tag is the follow-up. - Tests:
XpuCpuDispatchTests(GPU-free) — host-source SAXPY--xpu-backend=cpuruns on the CPU (sum 4096); forcing an unbundled backend (CAJETA_XPU_BACKEND=cudawith onlycpubundled) degrades gracefully (precise diagnostic, no crash, un-launched 2048);CAJETA_XPU_BACKEND=cpuforce runs.JitTestHelpergained anxpuBackendsoption + the manifest emit; the CUDA host-launch path is behavior-preserved (full Xpu suite green). - Remaining: 4.2 (HIP) + 4.3 (Vulkan) wire those backends’ launch + memory into the C runtime so the GPU rungs of the chain are live, not just CPU + CUDA.
- The decisive architectural finding: the C runtime is the SOLE launch path for
compiled programs.
- Inc 4.2 — landed 2026-05-31. The HIP rung in the C runtime (mirrors the CUDA block —
dlopenlibamdhip64 with the canonical-ROCm path resolution,hipMalloc/hipMemcpyHtoD/hipModuleLoadData/hipModuleLaunchKernel/hipDeviceSynchronize), sharing the module table (one device backend active per run). Device-validated on the AMD box via the dispatcher:--xpu-backend=amdgpuhost-source SAXPY runs on the GPU;amdgpu,cpu+CAJETA_XPU_BACKEND=cpufalls to CPU with the GPU present. Fixed a latent deadlock the 4.0 refactor introduced —active_backend()holdsg_xpu_cuda_lockwhile selecting, so the availability probes must call the*_init_lockedvariants, not the locking*_readywrappers (it never fired before because no CUDA/HIP bundle had run the selector under the lock; the HIP device test is the first to exercise it). - Inc 4.3 — landed 2026-05-31. The Vulkan rung in the C runtime; all four backends
now route through the one dispatcher, device-validated on AMD (HIP + Vulkan) and the CPU
(GPU-free), CUDA behavior-preserved.
- The uniform launch ABI doesn’t reach Vulkan for free — the scope finding that shaped
the work:
__cajeta_xpu_launchgets a CUDA/HIPkernelParamsargv (buffer handles + raw scalar values), but Vulkan’s compute entry isvoid main()with descriptor-bound storage buffers and no params — scalars included must become single-element SSBOs. So beyond portingVulkanDriver.cppto C, the rung needed (1) per-kernel parameter metadata (__cajeta_xpu_register_kernel_params(name, count, isBuffer[], byteSize[]), emitted by the Vulkan registration ctor from the sharedcollectKernelParamInfo) and (2) an argv→descriptor-set translation in the launch: buffer args bind their storage buffers, scalar args are copied into transient SSBOs (allocated, bound, freed per dispatch). - The Vulkan buffer handle is a table index, not a pointer —
KernelBuffer<T>.deviceHandleon Vulkan is a 1-based slot in the runtime’sVkBuffer/VkDeviceMemory/mapped table; upload/download memcpy through the host-coherent mapping. The per-run-fixed backend keeps the handle’s meaning consistent (decision #2). - Baked local size (
kVulkanLocalSizeX = 64) is the one launch asymmetry — Vulkan fixes the workgroup size at SPIR-V compile time, so the launch dispatchesgridXwork-groups and the host-source block must match the bake (the device test usesblock:[64],grid:[16]for n=1024). CUDA/HIP take block dim per-launch; Vulkan can’t. - The port is
dlopen-resolved +__has_include-guarded, so a box without a Vulkan SDK header at runtime-build time compiles it out (Vulkan probes unavailable) — same graceful shape as the absent-GPU paths. - Tests:
XpuVulkanDispatchDeviceTests(RADV) —--xpu-backend=vulkanSAXPY runs on the GPU through the dispatcher;vulkan,cpu+CAJETA_XPU_BACKEND=cpufalls to CPU. Full Xpu suite: 108 passed, 0 failed.
- The uniform launch ABI doesn’t reach Vulkan for free — the scope finding that shaped
the work:
- Two compiler bugs fixed via the AOT tour — 2026-05-31. Building the first AOT
(
--emit=obj)@Kernelprogram (samples/tour/gpu/, run byrun-gpu.sh) surfaced two pre-existing compiler defects that the JIT-only test path had masked:UseInitArray— AOT global constructors never ran.Compiler::rebuildTargetMachineleftTargetOptions::UseInitArrayat itsfalsedefault, so the AsmPrinter emitted the legacy.ctorssection instead of.init_array. Modern glibc startup runs.init_array, not.ctors, so every--emit=obj/exeglobal constructor silently never fired: per-class clinit (non-foldable static initializers), theUnrecoverableExceptionvtable marker, the embedded runtime’s__attribute__((constructor))init — and the XPU kernel/backend registration. Masked because the test suite runs through the JIT (LLJIT’sinitialize()runsllvm.global_ctorsdirectly) and AOT programs so far used only lazy runtime init. Fix:opt.UseInitArray = true(one line) — what clang/llc set. The CPU tour kernel then runs as a native binary; this also un-breaks AOT static init generally.- Vulkan multi-kernel SPIR-V codegen crash.
vulkan::emitKernelRegistrationcreated one SPIR-VTargetMachineand reused it across all kernels’emitSpirv. LLVM 23’s SPIR-V backend carries codegen state (SPIRVGlobalRegistry) on the TargetMachine, so the second kernel crashed inSPIRVEmitIntrinsics::buildAssignPtr(a segfault, sometimes an abort — classic state corruption). Never hit before because every prior Vulkan test compiled a single kernel; the two-kernel tour is the first multi-kernel Vulkan module. Fix: a freshTargetMachineper kernel.
4. Runnable demo
samples/tour/gpu/ is the XPU tour — a portable @Kernel SAXPY + vecAdd program with a
run-gpu.sh that compiles for any backend and runs it (default --xpu-backend=cpu, so it
runs anywhere with no GPU). ./run-gpu.sh amdgpu,cpu / vulkan,cpu target a device with
CPU fallback; CAJETA_XPU_BACKEND=cpu ./run-gpu.sh amdgpu,cpu forces the fall-to-CPU path
on a box that has the GPU. It sits beside the stdlib/language tour in samples/tour/.
5. KernelBuffer<T> is RAII (2026-05-31)
The XPU tour exposed that KernelBuffer<T> used a manual allocate()/free() pattern with no
destructor — the drop chain freed the 16-byte handle struct but the device memory leaked
unless you remembered free(), working against the language. It also diverged from the
stdlib’s own resource-handle convention (FileReader/File/Path: acquire in the ctor,
release in ~Dtor(), idempotent close() as an escape hatch). Reworked to match:
heap KernelBuffer<T>(n)allocates device memory in the constructor;~KernelBuffer()frees it via the drop chain at scope exit.free()/allocate()are now idempotent + null- guarded escape hatches. Move-out (#buf) transfers ownership (the source’s drop entry is marked inactive, so its destructor no-ops). The tour uses the RAII form — noallocate()/free()— and runs identically on CPU, AMD (HIP), and Vulkan (RADV).- Resize isn’t a KernelBuffer concern: GPU buffers are fixed-size; a different size is just a new
heap KernelBuffer<T>(m)(the old one drops). So in-place re-allocate is a rarely-needed escape hatch, not the model.
Launch-borrow gate extended (XPU-K02). A kernel.launch(...) borrows each KernelBuffer until
KernelStream.sync(). The checker already errored on an explicit free() before sync; with a
destructor, an implicit drop before sync is the same use-after-free. Method::destroyScope
now also gates it: an owned, launch-borrowed KernelBuffer that leaves scope before a sync is
XPU-K02. Only owned locals with a live drop entry trip it — borrowed params and #-moved
buffers are skipped, and a sync clears the borrow, so there are no false positives (verified:
XpuLaunchBorrowTests.dropBeforeSyncRejected / dropAfterSyncAccepted).
Two compiler bugs fixed in passing:
- Generic-class destructor names.
~KernelBuffer()was rejected because the validator compared the declared name against the monomorphized type name (KernelBuffer<float32>) rather than the base name.KernelBuffer<T>is the first generic stdlib class with a user destructor, so nothing had exercised it. Fix: strip the<…>type arguments before comparing (CajetaLlvmVisitor.h). - Stale-stdlib gotcha (recorded). The stdlib
.cajetasources are embedded into the compiler binary at build time (cajeta::stdlib::g_files), so editingruntime/src/cajeta/**requires rebuilding the compiler (cmake --build build) before the change takes effect — testing against a stale binary mis-resolves heap overloads (a 1-arg ctor fell back to the 2-arg one, passingnas the device handle → a wild-pointer crash).
6. Backlog / known issues
Work items not part of the active increment, in priority order. Resolution policy: when
one is fixed, if the fix is a clean, self-contained change, cherry-pick it onto main;
otherwise commit it to cajeta-xpu and let it ride to main with the next branch merge.
No open items.
✅ RESOLVED 2026-05-31 — CPU kernel registry held caller-owned name keys (order-dependent SIGSEGV)
XpuCpuDriverTests.lookupMissOnUnknownKernel passed in isolation but segfaulted when run
after the suite’s two registration tests (and so in any broad Xpu*/full-suite run). Root
cause: the CPU kernel registry (__cajeta_xpu_register_cpu_kernel, cajeta_runtime.c) stored
the kernel name as the raw const char* the caller passed — for a JIT’d registration ctor
that key is the xpu.cpu.kname.<name> global living in JIT memory, which is freed when the test
tears down its LLJIT. A later lookup’s strcmp() then dereferenced the dangling key. (In a
real compiled program the kname globals live for the process, so this only bit the JIT test
harness — but the registry was wrong to assume caller lifetime.) Fix: strdup the name on
registration so the registry owns its keys (matching the env-registry in the same file).
Regression test XpuCpuDriverTests.registryKeySurvivesCallerTeardown registers a uniquely-named
kernel, tears down its JIT, then looks up — clean miss, no crash (deterministically SIGSEGVs
against the pre-fix runtime). Full Xpu* suite now green with nothing excluded.
A local assigned under a lane-divergent branch is per work-item (2026-10-10). Step 4’s
taint followed values and memory, not control. float32 keep = 0.0f; if (t % 3 == 0) { keep = 1.0f; } stores two constants, so keep took one shared slot, and after a barrier
every work-item read the last one’s value: Workgroup.scan’s compaction kernel kept
nothing on cpu. Now a slot stored inside the region a branch on a lane-varying condition
reaches before its post-dominator, and loaded outside that region where the store can
reach the load, is per work-item. A load the store cannot reach reads what came before
it: cajeta-llm’s routerTopKKernel reuses k for its wave-reduce selection loop and
again inside a later if (lane == 0), and counting that earlier load made the selection
loop’s trip count per work-item and its wave reduces scalar.
“Lane-varying” is the work-item index’s taint cut at a quotient by a non-constant value, by
a constant of at least the smallest cpu wave (8), or a shift by at least 3: a branch on
globalIdX() / Group.width() selects an arm for a whole wave, as the fused qkv kernels’
shared which always assumed. A slot loaded only inside the region keeps its single slot:
an early return’s survivors and the counters of loops inside a per-work-item guard are read
by exactly the work-items that stored them, and marking those refused barrier loops and
broke guarded splits when first tried.
(XpuCpuFissionUniformize.aLocalAssignedUnderAPerWorkItemBranchIsPerWorkItemAcrossABarrier,
int64-scan-verbs 4.2.3.)
Workgroup.scan in a loop ends each pass with a barrier (2026-10-10). A reduce in a
loop ends with a second barrier at its call site, which is harmless because its result is
the same on every lane. A scan’s result is not, so the lanes’ own use of it would follow
the loop’s last barrier, which fission cannot split across the back edge (“per-work-item
code in the latch of a barrier loop”). The lowering instead puts the barrier at the end of
every pass of the innermost loop, and before each continue.