Engineering the macuda stack
A C userspace driver, CUDA compatibility libraries and a split compilation workflow bring NVIDIA inference workloads to macOS over Thunderbolt. This technical record explains the implementation, the performance work and the validation process behind it.
The optimisation measurements below document individual development stages. For the consolidated 19 September results and active benchmark settings, see Benchmarks.
01Platform constraints and approach
The project needed a way to run llama.cpp's CUDA backend on the M4 Max test machine with an external RTX 5090. That required GPU access from macOS, a compatible runtime and a build process for both CPU and GPU code.
The initial feasibility work considered the following approaches. These assessments describe the project's tested configuration and decisions; they are not a compatibility survey of every Mac or virtualisation platform.
| Approach | Project assessment | Constraint |
|---|---|---|
| NVIDIA's macOS driver stack | Unavailable for this setup | The test system has no NVIDIA-supplied driver or CUDA runtime for its Apple Silicon host. |
| Virtual-machine PCIe passthrough | Not selected | The evaluated virtualisation configurations did not provide the required GPU passthrough. |
| QEMU with a custom VFIO extension | Not validated | The proposed path required additional extension, signing and platform work; operation on this Thunderbolt 5 setup was not established. |
| Asahi Linux on the test Mac | Not selected | The project did not establish the M4 and Thunderbolt support needed for this configuration. |
| Userspace driver through tinygrad's DriverKit extension | Implemented | The signed extension exposes PCI registers and DMA mappings. tinygrad's userspace driver provided a working firmware-initialisation reference. |
Initial tinygrad experiments demonstrated that the external GPU could run local models from the Mac, but left a substantial performance gap against llama.cpp's CUDA backend. On 13 September 2026, the project adopted a different integration strategy: preserve llama.cpp's CUDA kernels and implement the driver and runtime interfaces beneath them.
Separate application code from platform integration. The GPU kernels are compiled with NVIDIA's Linux toolchain. macuda supplies the host-side interfaces needed to execute them from a native macOS application. Performance is measured at the application level rather than inferred from identical kernel code.
02Stack overview
llama.cpp + ggml-cuda host code compiled on the Mac by clang (--cuda-host-only); device .cu compiled to
│ sm_120a cubins by Linux nvcc (Docker or SSH), embedded in the host objects
libtinycudart.a the cudart ABI ggml-cuda links against: fatbin/module loading, kernel launch,
│ memcpy, streams, events
libtinycublas.a the cuBLAS entry points ggml uses, on one tensor-core GEMM cubin (f16 wmma / TF32,
│ strided-batched)
libtinynv.a a C userspace NVIDIA driver: GSP-RM boot (firmware 570.144), RM object model over
│ RPC, MMU v3 page tables, GPFIFO channels + doorbell, QMD launch with chaining,
│ copy engine, timeline semaphores
TinyGPU.app + DriverKit dext (tinygrad's) PCI config/BAR/DMA access over a unix socket
RTX 5090 over Thunderbolt
Roughly 15,000 lines of C across the three libraries, plus 6,000 lines of offline tests. Hardware access uses tinygrad's signed system extension. Firmware initialisation uses NVIDIA's signed GSP image, which executes on the GPU.
03Hardware access through DriverKit
Hardware access on this setup is mediated by a signed system extension. tinygrad's TinyGPU is a signed DriverKit extension plus a small signed app that fronts it over a unix socket. Only the signed app carries the entitlement to open the extension, so with SIP on, that socket is the entire interface to the hardware. Its protocol defines thirteen commands; seven are used by macuda.
- MAP_BAR
- Map one of the card's memory windows. The extension returns a mapping that lives in the server process, not in ours — so every register access is a round trip over the socket.
- CFG_READ / CFG_WRITE
- PCI configuration space, eight to thirty-two bits at a time. Used at boot and almost never afterwards, because each one is a round trip through the system extension.
- MAP_SYSMEM_FD
- The server creates a POSIX shared-memory object, asks the extension to map it for DMA through Apple's IOMMU, and passes the file descriptor back. The GPU can read those pages directly, and the host process can access them through a local mapping.
- MMIO_READ / MMIO_WRITE
- Bulk register access. Writes are posted: they are copied into a socket buffer and return immediately. The performance implications are covered in §09.
- RESET
- A PCIe function-level reset. The recovery primitive: it quiesces the card in about 1.5 seconds with its memory windows intact.
Register access uses the socket; DMA buffers are shared memory. Register reads require a response from the server, while writes can be posted without waiting for completion. Command data, timelines, semaphores and firmware message queues in DMA buffers can be accessed directly by the host. This distinction is central to the submission strategy described in §09.
Two inherited constraints shape everything above. The card's BAR1 window is 256 MB over Thunderbolt with no resizable-BAR support, so this is a "small BAR" configuration: page tables must live inside that window, firmware boot structures go to host memory, and CPU-visible video memory must be reserved low. And the extension's DMA preparation accepts at most 32 scatter-gather segments per mapping, silently truncating beyond that — so pinned host memory is a pool of bounded mappings rather than an arbitrary allocator.
04libtinynv: booting a GPU from userspace
The driver contains about 13,000 lines of portable C11 with no OS dependency beyond libc, behind a backend interface with three implementations — macOS over the TinyGPU socket, Linux over sysfs, and an offline replay backend that needs no GPU at all (§11).
Booting the firmware
GPU initialisation and resource management are coordinated with GSP-RM, NVIDIA's RISC-V firmware. The host driver boots that firmware and communicates with it through shared message queues. On Blackwell the sequence is:
- Wake and identify. Read
NV_PMC_BOOT_0andNV_PMC_BOOT_42to confirm GB202. If the firmware is already resident from a previous run, clear bus-mastering, issue a function-level reset, and wait for the card's own boot firmware to report ready — a poll on a thermal scratch register, up to ten seconds. - Early memory. Read the VRAM size out of a secure scratch register, then build MMU v3 page tables inside the 256 MB BAR window.
- Stage the firmware. Three hash-checked images for release 570.144 — the GSP image itself, an FMC boot image and a bootloader — placed in host DMA memory along with the message queues, a registry, the WPR metadata structure and a 2 MB log buffer.
- Chain of trust. Blackwell boots through the FSP: a
NVDM_TYPE_COTmessage pushed word by word through a falcon mailbox, then a wait for the RISC-V core to drop its privilege lockdown. - Hand over. Wait for
GSP_INIT_DONE, set up the BAR1 block and golden context image, then allocate the device, subdevice, address space and channel group as RM objects over RPC — each one a message into a queue in shared memory, answered by firmware running on the card.
From that point the driver is an RPC client to its own GPU. Memory allocation, channel creation, control commands and even fault reporting are messages to GSP-RM. When the firmware needs the host to do something — poke a register in a particular order — it sends a CPU sequencer event and the driver executes it.
Using architecture-specific definitions
The driver uses NVIDIA's published headers for hardware and firmware definitions. NVIDIA
publishes open-gpu-kernel-modules under a dual MIT/GPL licence, and it contains every register
definition, page-table format, RM class, allocation parameter and GSP message structure the driver needs. The tree is
pinned at commit 81fe4fb — release 570.86.16, the one that added the RTX 5090 — and the build
static-asserts every structure size and field offset it relies on against those headers. A mismatched definition therefore
fails during compilation.
This caught a real defect on its first compile. The page-fault kind codes were
written from an older architecture's header, where 0xb means a compression failure. On GB202's own
header 0xb is a confidential-computing violation, the write-only violation at 0x7 does not
exist in the older list at all, and Blackwell adds physical-address variants of every access kind — which is not a
naming detail, because a fault on a physical address is not in this driver's page tables at all, so the mapping code
would have been the wrong place to start looking. The rule since: where a constant is architecture-specific, include
the chip's header, not the family's.
Diagnosing GPU faults
When an engine touches memory that is not mapped, GSP-RM sends a bare notification: something faulted, and nothing about which address or what it was doing. The obvious fix — register a fault buffer — is a boot-time act, which would have changed the boot sequence the offline replay validates.
NVIDIA's own driver does it differently, and so does this one: it asks the debugger object
(GT200_DEBUGGER) what was recorded against the channel, after the fact. Two questions, in order. First,
read all SM error states — which also answers was this an MMU fault at all, something the notification never
says. Only if that bit is set, read the MMU fault info, which returns up to four addresses with the kind and access
type for each. When the bit is clear, the SM registers are the answer instead: a kernel did something illegal
rather than touched something unmapped. Those are different bugs with different fixes, and before this they
arrived as the same sentence. It costs nothing at boot, and nothing at all until something has already gone wrong.
05Memory: page tables, apertures and a firmware reservation
The driver builds MMU v3 page tables — five levels on Blackwell — and runs its own allocators: a segregated-fit physical allocator for video memory and a TLSF allocator for virtual address space. Every device pointer it hands out is 256-byte aligned, promised rather than incidental, because ggml lays tensors out from a buffer's base assuming at least 128 and CUDA promises 256.
Where a given buffer physically lives is dictated by the small BAR:
| What | Lives in | CPU access |
|---|---|---|
| Model weights, activations, KV cache | VRAM, anywhere | None — the GPU's alone |
| Page tables, firmware boot images | VRAM below 256 MB | Register writes through the BAR window |
| GSP message queues, timelines, semaphores, the descriptor mirror | Host memory, DMA-mapped | Direct, at memory speed |
| GPFIFO ring, write pointer, doorbell page | VRAM below 256 MB + a register page | Register writes (posted) |
Validating the firmware memory reservation
The firmware places itself in a protected region at the top of video memory — WPR2 — and the driver must not hand any of that out. The original code held back a flat 64 MB, chosen because it worked.
It was wrong by 161 MB. Measured on this card, WPR2 actually spans 202.9 MB starting 224.9 MB below the top of memory. The top 161 MB of what the allocator believed it owned was inside the firmware's protected region. Nothing had ever hit it only because no run had filled the card.
The fix, landed 19 September, replaces the guess with a derivation. A header names every size the driver hands the firmware and static-asserts that their sum fits inside the 256 MB hold-back; at every open the driver reads the chip's own WPR2 base and top registers after the firmware has placed itself and refuses to start if the allocator's ceiling is above the firmware's floor. Raising the firmware heap size without raising the reservation now fails the build rather than the card.
Then it was tested by filling the card: 31 allocations of 1 GiB, 31,744 MB handed out before refusal, every piece's
first and last page written through the engine and read back intact, the firmware's own free-heap counter unchanged
across the fill, and sensors still answering afterwards. totalGlobalMem now reports 32,285 MB of the
card's 32,607 — what can actually be handed out.
06libtinycudart: CUDA runtime compatibility
The application layer never calls a driver. It calls the CUDA runtime, and specifically the compiler-generated
host stubs — __cudaRegisterFatBinary, __cudaRegisterFunction,
cudaConfigureCall, cudaSetupArgument, cudaLaunch — that clang emits when it
compiles CUDA source. That generated ABI is the real interface, and it is what this library implements: about 140
entry points, backed by the driver.
A kernel launch, end to end, is:
- At load time, ggml-cuda registers its embedded fat binary. The shim finds the ELF inside the fatbin wrapper and hands it to the driver, which loads it as a module.
- ggml registers roughly 8,000 kernels, each mapping a host stub address to a name. The shim keeps these in an open-addressing hash on the stub address, because a linear table at that size is a per-launch cost.
- A launch configures a grid and block, pushes its arguments one at a time, then launches. The shim marshals the arguments into the layout the cubin's own metadata declares, and calls the driver.
Matching kernel names across toolchains
Kernel registration must account for differences in symbol naming. The host stubs are mangled by clang on macOS; the device code is mangled by nvcc on Linux; and they disagree, in three ways:
- clang marks a
statickernel with an internal-linkage marker; nvcc does not. int64_tislong longon macOS andlongon Linux — different mangling letters for the same 64-bit type.- nvcc wraps a translation unit's static device functions in a generated namespace; clang does not. A
function-pointer template argument therefore reads
&_INTERNAL_…::f(...)on one side and&f(...)on the other.
The parameters are the same size on both sides, so the launch layout is identical — only the names differ. Rather than guessing at positions inside mangled strings, the shim demangles both sides (via LLVM's demangler, linked in from Homebrew) and matches on the demangled text with the generated namespace stripped and "long long" collapsed to "long". One rule covers all three differences.
Loading CUDA modules
Validation with the cubins used by IQ-quantised models established the following loader requirements:
- Concatenate every code section with 128-byte alignment and upload it as one allocation with a guard page — not optional, instruction prefetch faults without it.
- Bind every constant bank, not just bank 0. Bank 0 carries the parameters; the normalisation and matrix-vector kernels also use bank 4, a 26,792-byte table of pointers into an initialised data section — the quantisation grids and sign tables.
- Apply relocations before upload. Three types occur; anything else is refused rather than ignored.
- Inject driver values into constant bank 0. On Blackwell the bank must hold at least 224 entries with the shared and local memory windows at entries 188–191 and a fixed value at 223. This is not the kernel's own data, and a parameter-only kernel still faults without it.
Missing loader steps can cause hardware faults or incorrect outputs. Loader validation therefore includes both structural checks and application-level output comparisons.
07libtinycublas: one GEMM kernel, eight entry points
ggml links cuBLAS unconditionally, but with the quantised matrix-multiply path forced on, the quantised kernels
never call it. What remains are residual F16 and F32 general matrix multiplies, mostly at prompt time. So this library
is small: about eight entry points — cublasGemmEx, cublasSgemm and their strided-batched
variants — over a single hand-written tensor-core GEMM kernel (f16 WMMA and TF32), compiled once for sm_120 and
shipped as a committed cubin. A scalar fallback exists as a diagnostic.
08Split compilation for CPU and GPU
The build uses Linux nvcc for GPU code and macOS clang for CPU code. Each CUDA translation unit is
compiled in two stages:
ggml-cuda/foo.cu
├── device half → Linux nvcc (Docker on the Mac or SSH) → sm_120a cubin, packed into a fatbin
└── host half → Homebrew clang on the Mac, --cuda-host-only -nocudalib
-fcuda-include-gpubinary=<the compiled fatbin>
→ one Mach-O object with the GPU code embedded in it
Run across ggml-cuda's ~190 translation units, that produces libggml-cuda.a — every kernel ggml has,
as native macOS objects containing NVIDIA machine code. It takes about an hour. A prebuilt archive is enough to link
and run without invoking the Linux compiler. Published benchmark builds used the SSH path; the build wrapper also
supports local compilation in a Linux Docker container.
llama.cpp itself is built once as a plain CPU-only static tree — no Metal, no CUDA. The link step then replays the exact link line CMake generated for a target and inserts, ahead of the CPU backend: the backend registry object compiled with CUDA enabled, the ggml-cuda archive, the three shim libraries and LLVM's demangler. The result runs on the driver's null device — the entire host path with no GPU present, which is how launch counts and call timings are gathered without a card — until an environment variable points it at a live TinyGPU server, at which point it opens the card.
Every binary carries its driver's git commit, baked in at build time with a -dirty suffix if
the tree had uncommitted changes, printed at startup and inspectable with strings. This exists because
three separate findings on this project were diagnosed against code that was not the code running — including a
16-second image-generation result that turned out to come from an uncommitted working tree.
09Kernel submission and launch overhead
Profiling identified descriptor delivery and host-side launch preparation as major sources of submission overhead.
To launch a kernel, the driver writes a QMD — a launch descriptor — plus that kernel's constant buffer, and points the engine at it. That is about 1.5 KB per launch. A decode token issues 1,600–2,000 launches. In the original design, those bytes were written across the Thunderbolt link by the processor as individual 32-bit stores at roughly 19 ns each — about 235 MB/s of register-write bandwidth, which was most of a launch.
Posted writes can shift the apparent cost to a later wait. A register write returns after copying data into the socket buffer. The next ordering read waits for that pending work to complete, so timing the calls separately overstated read latency and understated write cost.
Descriptor delivery
- A launch builds its descriptor and constant buffer into a cached shadow in ordinary host memory. This keeps frequent small writes in local memory.
- At flush, the dirty span is copied once, sequentially into a mirror — a host allocation the engine can read, laid out at the descriptor region's own offsets, so a span copies to the place it came from.
- The descriptors reach video memory, and the compute batch that will read them is ordered behind that delivery using the cross-queue machinery that already existed. Delivery and execution use the existing ordering mechanism.
- Command buffers do not take this path and cannot. The engine fetches a command buffer in order to find the ordering primitive inside it, so that primitive cannot be what protects the buffer — it has to be present before the engine is told about it at all. Command buffers are still written by register, and they are small.
That asymmetry is why the command arena is two regions with one writer each, which was also the fix for a wrap bug that cost six hardware runs.
Chaining
Launches are chained: up to 128 descriptors link to one another so the engine walks from one to the next without the host submitting each. Chain depth 128 is measured to be the optimum — 1024 costs 14–17% and 32 costs the mixture-of-experts model 10% — because the engine cannot start a chain before the host has finished building all of it, so a deeper chain delays its own start more than it saves in seams, and a shallower one exposes more seams than it hides under the next chain's build.
Measured optimisation results
| Change | Measured effect |
|---|---|
| Descriptors through a shadow + mirror instead of register stores | Launch floor 7.26 → 1.28 µs; MoE decode 52.9 → 117.8 tok/s; dense 27B 43.4 → 61.8 |
| Descriptor region grown to 64 MB (it no longer has to fit the CPU-visible window) | 18 region wraps became 1; drain 2.99 → 0.70 ms; 27B 61.6 → 63.5 |
| Small uploads ride the command stream instead of taking their own batch | Six submissions a token became zero; +2.8% dense |
| Delta delivery + token-aligned rewind + tail release | Copy engine leaves the decode loop entirely; dense 67.8 → 70.7, MoE ~161 → ~166 |
| Inline upload cap raised to the method's real ceiling (32,764 bytes) | +2.3% MoE / +1.6% dense |
| No memory barrier on descriptors that release nothing | +3% dense, nothing on MoE |
ggml-cuda's host halves rebuilt at -O2 (they had no -O flag at all) | Host cost per launch 2.38 → 0.42 µs; MoE tg128 140 → 216; dense 71 → 74 |
Every row was validated the same way before it became a default: the operator suite at 450/450 across three chain depths, a byte-identical greedy text, and an interleaved A/B in the same minutes. One candidate was measured and rejected: dropping the five per-launch cache invalidates looks free — the operator suite still passes 450/450 — and produces a different greedy text, because a chained decode reads what the previous kernel wrote through the texture path. Only the byte-compare sees it. That knob stays a diagnostic.
Incremental descriptor updates
An initial measurement showed that of 2,435 descriptor words submitted per steady-state token, only 65 — 2.7% differ from the token before. That suggested an opportunity to reduce transfer volume. Four initial implementations exposed additional constraints:
- One envelope over the whole flush. A flush accumulates 128 launches; the first and last differing byte were 213 KB apart. The resulting transfer still covered the full span, providing no useful reduction.
- One envelope per launch. Aborted on hardware: the byte-precise bounds had no alignment guarantee and an inline upload takes whole words at a word address. Widened, then measured: of a 1,400–1,800-byte envelope only 40–240 bytes actually differ. This repeated the full-span problem at a smaller granularity.
- A separate transfer budget. Only 5 of 1,223 flushes used the path; increasing the cap did not resolve the low utilisation.
- Runs of words within a launch. Word-granular by construction. It engaged on 1,223 of 1,223 dense flushes and 938 of 938 MoE flushes — and lost: MoE 145–161 down to 89–90, a 44% regression.
Why 35% of a launch changes when 2.7% was the premise. The descriptor ring rewinds at a chain boundary, wherever the region happens to run out, so "what was last delivered at this address" was a different launch — the same kernel from another layer, with other weight pointers. The 2.7% figure compared consecutive batches within a token. Both numbers were right; the inference that one implied the other was wrong, and it survived three laps because each lap measured its own granularity and nothing measured the alignment.
The successful revision aligned descriptors across tokens: declare the descriptor region full at the first launch after a standstill, so it comes round through the existing wrap path and launch k of every token lands where launch k of the last one did. The diff becomes a launch against its own previous-token self. Predicted before the run: under 15 KB a flush. Measured: 6.6 KB, of which 4.5 KB was headers on one patch per launch — the release value every descriptor carries, which advances each token. Releasing only at the chain's tail removes that patch: 2.1–2.8 KB a flush, about 24 patches, the genuine per-token parameter changes. The copy engine left the decode loop and took the token-boundary channel switch with it.
10Profiling GPU execution and idle time
The card publishes its own utilisation counters — the same ones nvidia-smi reports. Sampled through a
decode on each model, they give a different answer for each:
| Dense 27B | Mixture 35B-A3B | |
|---|---|---|
| Memory busy | 55–59% | 15–16% |
| A kernel is executing | 85–92% | 61–64% |
| Board power | 427–436 W | 214–218 W |
| SM clock | 2,872 MHz | 2,880 MHz |
| GPU idle time per token | 1.90 ms | 2.91 ms |
The dense number was predicted before it was measured. The model moves 16.45 GB per token at roughly 1,790 GB/s, which is 9.2 ms — 57.9% of a 15.8 ms token. The card reported 55–59%. A counter over an unrelated window would have no reason to land within two points of an independently computed ratio, so that agreement validates the instrument and the cost model at once.
The two models are limited by different things and the same optimisation does not serve both. Dense is memory-bound, spending 57% of its token moving weights it cannot avoid moving. The mixture is constrained primarily by submission overhead in this measurement — it moves a sixth of the memory and draws half the power at the same clock, making GPU idle time a significant optimisation target.
Locating the token-boundary stall
Dividing 2.91 ms by 1,634 launches gives 1.8 µs each. Dividing by 20 synchronisations gives 150 µs each. Neither calculation located the actual stall. Instrumenting every stall showed that of ~4,700 in a run, ~410 are the measured ones — one per token. It is the token boundary: the point where the host samples the next token, rebuilds its computation graph, and submits again.
| Idle time per token | The token boundary alone | |
|---|---|---|
| Dense 27B | 1.90 ms | 1.72–1.77 ms |
| Mixture 35B-A3B | 2.91 ms | 1.58–1.72 ms |
On dense, the token boundary is essentially the whole of it. Two independent routes agree on the pair: a comparison against NVIDIA's own token times computed days earlier gave 2.5 ms and 3.6 ms; the card's own counters gave 1.9 and 2.9 — same order, same ratio, nothing in common between the methods.
Per-kernel profiling and host compiler optimisation
A later profiling tool used a per-kernel profile from the card's own clock: every descriptor releases a four-word report — its timeline value and the engine's clock — into a ring slot of its own in host memory. Both greedy texts stayed byte-identical under it; zero stamps were missing over 107 windows.
It found that the MoE's kernels run at native speed — 2.45 µs a launch inside a chain — and the loss was the engine idle at chain seams, because the host was building a launch in 4.1 µs against the engine's 2.45. Of that 4.1, 2.38 µs was above the runtime layer, in ggml-cuda's own dispatcher.
The build was compiling ggml-cuda's host halves with no optimisation flag at all. Rebuilt at -O2, with the device halves recovered byte-for-byte from the existing archive so nothing about the GPU code changed: the caller's time between launches fell 2.38 → 0.42 µs, chain seams 1.85 → 0.41 ms per window, and MoE decode went 140 → 216 tok/s interleaved. Every text and image stayed byte-identical.
11Development and validation
Development uses offline validation before hardware testing. The project has one physical GPU, and some failures require reconnecting it in person. Deterministic replay, structural checks and controlled scheduling reduce unnecessary hardware runs.
Record and replay
GSP initialisation is a substantial integration task and a potential source of hardware hangs. So both drivers — the C one and tinygrad's Python one — got an operation tracer at the PCI boundary, recording every config access, register access, DMA allocation and reset. A replay backend then feeds a recorded run's reads back to the C driver and asserts that its writes match.
One captured boot on real hardware becomes an offline, deterministic regression test for the entire boot path, developed on the Mac with no GPU attached. By the time the C driver first touched a real GPU it had already reproduced a known-good boot write-for-write.
Replay permits only two narrowly defined variations:
- A repeated read of the next recorded address, to accommodate polling loops with different iteration counts.
- A repeated read whose address and value the driver already holds.
The value requirement is the whole of the safety: forgiving by place alone would forgive skipping the read that saw a change. Everything else is a divergence, including a recorded read the driver never made — a read it skipped is a decision it made on information it did not gather.
Two modes, and the asymmetry between them is the point. Faithful mode makes every read the oracle makes, including re-reads of values already held; it reproduces the recording operation for operation with nothing forgiven, and it is what the first hardware runs use, so the first time the card sees this driver it sees a sequence it has already accepted. Fast mode skips those reads and must end at the same point with zero divergences. Only faithful mode can catch the driver doing too much, because fast mode can only ever under-read — and on hardware an extra read is a bus round trip at best and a side-effecting register access at worst, since several status registers clear on read. The first faithful run caught exactly that: an extra page-table read at the lowest level. Every earlier catch had been an omission, which had quietly become the only shape of bug anyone was looking for.
Replay accounting
The replay cursor moves for exactly three reasons — an operation finished, an operation was stepped over, a gap was
declared — so cursor == finished + stepped + gapped, always, checked on every move. This exists because a
path that advanced the cursor outside the accounting once hid a missing-write hole until the totals happened to be
compared by hand. Unported stages are declared, never implied: a gap names its exact first and last recorded
line and the test asserts the gap set exactly. At GSP_INIT_DONE the gap count went to zero and stayed
there.
Verifying that tests detect failures
This is the project's standing rule, and it was earned. A test file accumulated failures into a counter and
consulted it at line 117 of a 318-line function. The guard was correct when written — line 117 was the
end then. The file grew; two hundred lines of checks were appended below a guard that had already returned. The binary
printed FAIL on stdout and exited 0. The test suite reported green.
The fix is structural rather than a correction: the exit status is now the last statement of every such
function, so a later section cannot grow past it, and each prints N checks, M failed so the count is
visible rather than inferred from silence. A lint runs first in the test suite and grades every file three ways —
LOSING (assertions run after the last consult: fails the build), UNGUARDED-ACCUMULATING (correct today,
arranged to go wrong: also fails), and UNGUARDED FAIL-FAST (correct by shape: left alone). Strict mode is on
although nothing currently trips it, because the file that broke was accumulating for some time before it started
losing — and failing the build on harmless findings is how a gate gets switched off, and it takes the real findings
with it.
Limits of offline validation
The offline suite contains seventeen sections and runs in seconds without a GPU. Its coverage is substantial, but submission ordering requires separate validation on hardware.
It reaches encoding (command streams held against recorded bytes, field positions against NVIDIA's headers), arithmetic (arena recycling against a byte-by-byte ownership record), resolution (every configuration default, grepped from the code rather than written down), and structure layouts. It does not reach submission ordering, and cannot — the null device refuses before submission, and the recorded boot contains no submissions at all.
The worked example: held uploads submitted a compute batch inside a chain flush, so the timeline went backwards by exactly the length of a chain. Against that change the suite passed 17/17, the operator suite passed 450/450 at three chain depths, a 96-token text came back byte-identical, and a single-stream decode measured +3.2% twice. Every check said yes. The failure needed a second concurrent stream to fault — and a load-time driver warning that had been printing in every gate log for an hour before anyone grepped for it.
This failure class is now guarded by a runtime invariant: any compute submission inside a chain flush is a hard failure with the reason attached. The check runs during hardware execution, where the offline suite has no coverage, and makes violations explicit. The invariant had been written down in a comment four lines above the code that violated it, and read while that code was being added.
AI-assisted development and independent review
Development used concurrent AI agent sessions with defined responsibilities, a shared append-only status log and exclusive access to the physical GPU. Separate sessions covered CUDA integration, the userspace driver and the paravirtualised-guest research track.
An independent review session used a different model and a read-only repository mirror, with no GPU access. Reviews prioritised findings by severity and required file references, supporting evidence and an explicit account of what could invalidate each conclusion. Proposed experiments included a prediction and a control before hardware measurements were collected.
Release validation
The validation script checks the build, operator correctness and application output:
- A clean build from a detached worktree, so a build never runs against a tree someone is editing, with the build id verified inside each binary.
test-backend-opsvalue-checked at 450/450, at chain depths 32, 64 and 128, plus once in synchronous mode.- Both decode benchmarks at the driver's defaults.
- A temperature-0 greedy decode run three times and byte-compared. For anything speculative, the drafted output byte-identical to plain greedy.
- For build-to-build performance comparisons, an interleaving script that alternates two builds within the same minutes to control for changing load on the shared host.
12Documented corrections
The design records retain corrected findings alongside the original observations. These examples show how instrumentation, build provenance and validation affected the conclusions:
A profiling interval with a moving boundary
Two optimisations made a profiling interval collapse from 1,700 µs to 200 µs and the bimodality vanish — exactly what a fix working looks like. The token period had not moved. The stamp fired on a batch that the change had moved into the boundary itself. It happened twice in two hours. The profile now reports head-to-head, which cannot be moved by rerouting work.
Dependent measurements mistaken for confirmation
Idle time divided by synchronisation count landed inside a band "derived from the vendor residual" — two routes agreeing. They were not: the band was that same residual divided by the same count. Dividing a total by a count and noting it matches a per-unit figure from the same total is not independent confirmation.
A performance gain with a concurrency defect
A default was flipped on two single-stream measurements and a 450/450 operator pass. It corrupted memory under concurrency. Withdrawn eight minutes after the evidence arrived, fixed, and restored 33 minutes later having passed gates set before either run. The corrected gain was +2.8%.
An excluded benchmark outlier
One "off" run read 118.50 against 127–129 for every other off run of the day. Folding it in would have produced +7% instead of +2.5%. The outlier and its exclusion are documented in the measurement record.
Results tied to outdated builds
A re-verification against the tip found all three previously-published figures wrong: MoE decode was faster than reported, and an image-generation time had been captured on a pre-gate build, with a later worse reading traced to an uncommitted tree. Hence build ids in every binary.
Drain time and application latency
The teardown line's drain time is host idleness. On a GPU-bound run it is not recoverable time: an image model's drain accounting fell from 2,775 ms to 46 ms and the wall time did not move. The metric was useful for decode analysis but did not predict diffusion latency.
13Operating the card
The tested Thunderbolt setup requires coordinated hardware access. The runner enforces this sequence: read-only preflight → take the lock → one step as its own process → release, leaving the card idle warm with firmware resident and nothing submitted. Two processes on the card at once wedge it. A runner script does the preflight, takes the lock with its process id, ensures the server is up without ever replacing a live one, runs the step, echoes back the driver's own startup line — trust that line, not what you exported — flags any step that died by signal or did not have the card to itself, and releases.
The stability recipe, inherited from the earlier tinygrad work and carried into the C driver:
- Never unload the firmware at exit. The clean unload path is what poisons the next boot on this enclosure — the card drops off the Thunderbolt bus mid-init and the in-flight extension call blocks in the kernel.
- Clear bus-mastering on every exit path, including failures. A failed initialisation that booted the firmware and then threw leaves bus-mastering on with a live GSP writing its log and status queues — into DMA mappings the extension is tearing down.
- A standalone function-level reset from a fresh process is the recovery primitive, and it works: ~1.5 seconds, memory windows intact, validated repeatedly including as live recovery after a crash left the card dirty.
- Physical recovery may be required. A card that drops off the bus, or a latched IOMMU error flag, needs a physical replug — the flag does not clear on reset, only on replug, sleep-wake or reboot.
Host workloads can affect the Thunderbolt connection. On 16 September the Thunderbolt tunnel dropped and re-enumerated twice with the card idle, both times under a heavy Metal workload on the Mac's own GPU while the laptop was drawing power from the enclosure over the same cable. On Apple's own charger the same workloads did not drop it. The operating protocol therefore uses a separate laptop power adapter and coordinates jobs that map more than approximately 20 GB of host or Apple-GPU memory with the hardware scheduler.
14Current status and research directions
The 19 September benchmark batch supersedes several earlier observations in this development record. CUDA graphs were enabled in that batch, and Nemotron Nano reached 141.5 tok/s after device-to-device copies moved onto the kernel submission path. Its earlier 78% result is no longer the current baseline.
- Host turnaround between tokens. Profiling attributes approximately 1.2–1.4 ms of GPU idle time per token to logits transfer, sampling, graph preparation and construction of the first launch chain. This is a measured constraint, not a promise that all of the time can be recovered.
- Driver launch overhead. The profiled path spends approximately 1.74 µs per launch, including 1.29 µs preparing the descriptor and constant buffer. Reducing this cost remains a target for the Qwen mixture-of-experts workload, which reaches 91% of reference decode throughput in the published batch.
- Persistent descriptor replay. The experimental
TINYNV_GRAPH_RESIDENTpath retains a token's descriptors and patches changing values to reduce submissions. It is disabled by default and has not established a performance improvement in the recorded tests. This is separate from CUDA runtime graph support.
A separate research direction is a paravirtualised Linux guest driver that forwards resource-management and
memory-management requests over vsock to libtinynv on the Mac. The aim is to support NVIDIA userspace
libraries inside the guest, potentially extending application coverage to frameworks such as PyTorch and vLLM.
This remains feasibility work; those applications are not established capabilities of the current stack.