Skip to the content.

Hardware portability for the in-kernel forward pass — the internal/compute HAL seam

Status: the seam is shipped and can carry two real device backends beside the pure-Go CPU reference. internal/compute (the contract) registers cpu-ref (Reference), plus cuda (Approx, //go:build cuda) and vulkan (Approx, //go:build vulkan) — each proven on actual silicon: CUDA runs the in-kernel Llama decode on this box’s RTX 4070 (argmax-exact, logit cosine 1.0 — ../../GPU.md) and Vulkan runs the full SmolLM2-135M forward pass on a real AMD Radeon RX 7600 (argmax-exact, prefill cosine 1.0 — ../benchmarks/VULKAN-AMD-RESULTS.md). The model package routes through the seam via Model.NewBackendSession(compute.Backend), and TestHALSessionMatchesLegacyCPUReference proves the cpu-ref path is byte-identical to the legacy session path on a deterministic synthetic model. The optimized legacy prefill/batch path is still the default until full adoption. cmd/modelbench -backend <name> -require-non-reference is the production gate: it fails closed on a CPU-only build (only cpu-ref registered) and passes when built with -tags cuda/-tags vulkan on a box with that device — which is exactly how the two witnesses above were captured. The original seam design came from a 19-agent audit→design→adversarial-verify→synthesis pass (CUDA / edge-NPU / dataflow-wafer / WASM lenses); two of those four lenses (CUDA, and Vulkan as the discrete-GPU case) are now built and witnessed on real hardware, not hypothetical.

Who this is for: contributors adding or reasoning about a non-CPU backend (CUDA, Vulkan, NPU, dataflow, WASM) for fak’s in-kernel forward pass. Prerequisites: familiarity with the internal/model forward pass and Go build tags. By the end you’ll understand the seven host-CPU assumptions the internal/compute HAL neutralizes, how its type contract lets a new backend be a registration rather than a fork, and where each hardware class plugs in. Vendor-facing onboarding lives in docs/vendor/neo-silicon-onboarding.md, including the minimum compiling backend example.

1. Why a seam, not a port

The in-kernel forward pass (internal/model) is correct and, on CPU, fast. But it was written as one hardware target wearing seven invisible assumptions. They are invisible because they are not config — they are baked into the types and the call sites:

# Assumption Where it lives today Hardware it shuts out
1 float32 monoculture[]float32 is the only currency; Q8 is a duplicated forward pass gated by a bool, not a dtype every op signature; q8Tensor/q8Vec; Session.Quant f16/bf16/fp8/MX/int4-native GPU/XPU/NPU/dataflow
2 host-pointer aliasingunsafe.Slice((*float32)…) reinterprets a host blob; ops pass/return host slices weights.go:96 any device with a separate address space (GPU VRAM, NPU SRAM)
3 x86 build-tag dispatch — AVX2/512 hand-asm gated by //go:build amd64 + CPUID; the only other path is slow scalar quant_amd64.{go,s}, quant_noasm.go ARM/RISC-V CPUs, every accelerator, WASM
4 synchronous return-by-value — every op computes and returns now matRows, qMatRows, the layer loop async accelerators (enqueue → fence)
5 goroutine-only parallelismparFor splits output rows across CPU workers parallel.go, prefill_attn.go intra-kernel-lane (GPU) / pinned-graph (dataflow) HW
6 row-major onlyw[o*in+i] index math everywhere; no layout descriptor all matmuls + the KV cache tiled/blocked/col-major device-native layouts
7 eager full-RAM residency + LE hostos.ReadFile the whole ~537 MB blob (SmolLM2-135M f32: 135M params × 4 B); “amd64 is little-endian” weights.go small-SRAM NPU, browser/WASM, big-endian, pre-staged device weights

Adding any non-CPU backend by editing these in place would mean re-forking the forward pass a third time (Q8 already forked it once — tokenHiddenQ/prefillBatchedQ/stepBatchQ are hand-copies of the f32 loops). That is O(formats × hardware) edits to proven, bit-exact hot loops. The seam inverts it: write the loop once against an interface; a new backend is a registration, never an edit.

Hardware-shape neutrality ledger

This is the competitive buyer view of the same table. A backend matrix can still be GPU-biased if it counts device names but leaves the model loop shaped like a host CPU. fak’s claim is narrower and fenced: the internal/compute contract names the seven host-shape assumptions, gives each a boundary or fallback, and keeps unsupported regimes FENCED rather than silently UNDEFINED in the support-maturity sense (honesty fence, matrix).

Assumption Porting tax on non-GPU / neo-silicon HAL fence Current witness
float32 monoculture Native bf16/fp8/MX/int4 hardware needs a new loop clone or lossy host expansion. Dtype + QuantSpec; dtype dispatch lives on Tensor and weight ops. cpu-ref f32/Q8 lanes and device backend parity tests exercise dtype-dispatched MatMul; broader low-precision coverage is still a scored gap.
host-pointer aliasing Device SRAM/VRAM cannot be passed as a Go []float32 without staging everything through host RAM. Opaque Tensor/Buffer, Host(t) opt-in view, and Read(t) as the explicit fence. CUDA, Vulkan, Metal, and CPU backends register behind the same contract; DeviceMemory remains cap-advertised only when true.
x86 build-tag dispatch A new accelerator becomes another build fork rather than a runtime backend. Register, Lookup, Pick, and private Tier() probing. cpu-ref, CUDA, Vulkan, and Metal are selected by registration/build tag without editing the forward loop.
synchronous return-by-value Async command queues must block at every op boundary, losing overlap and graph capture. Caps.Async, Buffer.Ready(), and host fences at Read / Argmax. The contract is fenced; production async depth is backend-specific and advertised only when implemented.
goroutine-only parallelism Device kernels must emulate row-splitting instead of using their native lanes, tiles, or graphs. Whole-op Backend methods such as MatMul, BatchedMatMul, Attention, and Argmax. CUDA/Vulkan/Metal lower whole ops behind the interface; CPU keeps goroutines private to the reference.
row-major only Tiled, blocked, sparse, or compiler-native layouts pay repack cost at every call site. Layout on Tensor; backends repack at Upload and keep layout private after that. The shipped path is row-major-first, but the layout boundary is explicit rather than an implicit host assumption.
eager full-RAM residency + LE host Small-SRAM, pre-staged, streaming, big-endian, or browser/WASM targets inherit a full host-blob requirement. WeightSource.Weight(name, want) and Upload(t, as) move residency and narrowing behind the backend. WeightSource is a shipped seam; broad vendor staging remains not-yet until backend conformance and scaffold tooling land.

Provenance: the contract and CPU/CUDA/Vulkan/Metal registrations are WITNESSED where their support rows say so. A specific future NPU, dataflow chip, PIM target, TPU, or vendor SDK remains ASPIRED until its backend passes the relevant conformance rung. This is why the industry scorecard reports hardware-shape neutrality separately from hardware breadth.

2. The type contract — assumptions neutralized in the types

internal/compute lifts all seven assumptions in the type system, even though only the CPU reference is implemented today. The point is that the contract a future GPU/NPU implements already assumes none of them.

Two cross-cutting guard rails (judge grafts):

3. The CPU reference is verbatim

The day-1 backend (cpuref.go, Class()==Reference) reproduces the model’s arithmetic exactly, so adoption is byte-identical:

Backend method reproduces (model) reduction order preserved
MatMul (F32) matRows/parMatRows fdot 8-accumulator fixed tree
MatMul (Q8_0) qMatRows qdot8scalar 4-acc per-block
BatchedMatMul (F32 / Q8_0) matMulBatch / qGemm8scalar fdot / qgemm8cell (lanes=16)
RMSNorm rmsnorm serial in-order sum-of-squares (the load-bearing one)
RoPE ropeRow+applyRopeRow non-interleaved rotate_half
Attention tokenHidden attn loop single-acc score dot, in-order ΣwV
SwiGLU / AddInPlace / AddBias the MLP/residual loops elementwise
Argmax argmaxF32 first-max
KVStore (AppendKV/Evict/Clone) KVCache single-rotation re-RoPE on evict

It is pure-Go, scalar, stdlib-only — no unsafe, no asm, no cgo, no os.Getenv — so it is also the portable floor every other target degrades to (it compiles to wasm unchanged). A real CPU backend may later expose the model’s x86 AVX kernels via Tier(); that is a private acceleration of this same reference contract, picked by the registry, not a fork of the loop. (This is now concrete on two ISAs: the model package’s accelerated Q8 lane is amd64 AVX2/AVX-512 and arm64 NEON SDOT — measured head-to-head vs llama.cpp in ../benchmarks/LLAMACPP-HEADTOHEAD-RESULTS.md (Zen5) and ../benchmarks/M3-LLAMACPP-RESULTS.md (Apple M3). Both stay bit-identical to the scalar reference — exactly the “private acceleration, not a fork” the Tier() seam describes. So assumption #3’s “ARM/RISC-V CPUs” gap above is now closed for arm64.)

4. What day-1 buys

5. The known-open ledger (tracked deferrals, not blind spots)

Each open assumption is named with the seam that will close it. Honesty graft from the design panel: the deferrals are deliberate, not forgotten.

Open assumption Why deferred Closing seam
eager full-RAM os.ReadFile of the ~537 MB blob (SmolLM2-135M f32) CPU policy unchanged day-1 WeightSource (stream/stage per tensor)
little-endian unsafe.Slice (big-endian broken) lives inside CPU Upload only device-native repack in Upload/WeightSource
per-op host alloc (make([]float32) for q/k/v/scores) not needed to ship the CPU seam an Alloc(shape,dtype) scratch-pool cap
row-major only on CPU reference honors RowMajor a backend that honors the Layout field
bf16→f32 widening at load Dtype field now present; end-to-end narrow is future ReadAs(Dtype) + native-narrow WeightSource
synchronous return-by-value day-1 simplicity + bit-identity Caps.Async + Buffer.Ready() futures; GraphCompile record-replay
optimized model package not yet fully wired to the seam the safe first slice is a per-token HAL session path; the legacy batched/Q8 paths remain the production default fold prefillBatched, Q8, and batch decode through Backend once the per-token gate stays green
finite device capacity — OOM is a dalloc panic, Caps.DeviceMemory is a shape bool (not a size), and cuda.go discarded the totalGlobalMem it probed the seven lifts above are all hardware shape; capacity is a hardware limit — a different category, treated in its own explainer compute.DeviceCapacity (report) + FitsOnDevice, bridging to the cachemeta placement plane and an engine adapter. See hardware-limits-and-capacity.md — the eighth assumption

6. How each hardware class plugs in (and what each adversarial lens demanded)

7. Bit-identity, and the adoption diff

Preserved by construction + scoping. The CPU backend’s methods are the model functions, so no reduction is reordered and no kernel rewritten — the bytes out equal the bytes in; the only change is a method indirection. The KVStore is interface extraction only, so the kvmmu evict-vs-never-saw witness is untouched. CorrectnessClass makes the two-tier gate a typed, harness-enforced invariant so the scoping cannot rot.

The model-package adoption is now partially executable: NewBackendSession builds a HAL-owned KVStore and routes the f32 per-token path through Backend.RMSNorm, MatMul, RoPE, Attention, SwiGLU, AddInPlace, and Argmax-compatible logits. The exactness gate is TestHALSessionMatchesLegacyCPUReference: prefill, decode, and greedy generation match the legacy path byte-for-byte under cpu-ref.

What remains is the production adoption diff: collapse tokenHidden/tokenHiddenQ and the batched prefill/decode paths into one loop taking a Backend; the f32-vs-Q8 choice becomes the weight Tensor’s Dtype (resolved from Session.Quant), not a bool branch; and cmd/modelbench -backend <non-reference> -require-non-reference records real backend evidence. The existing R2/R14/oracle tests in internal/model remain the equivalence proof for the reference path — they must stay max|Δ|=0, argmax-exact. Run the suite via WSL (.\fak\test.ps1) for full verification on Windows when native WDAC policy flakes unsigned test binaries on this host.