Skip to content
Jared Frost
Go back

genie-ai-runtime: a Jetson edge LLM inference engine that beats llama.cpp — designing for unified memory

A field report on building genie-ai-runtime — a from-scratch CUDA inference engine for the Jetson Orin Nano Super 8 GB — and then asking the harder question the engine can’t answer on its own: on a memory-starved edge box, should the model even be a transformer?

Table of contents

Open Table of contents

0. TL;DR

This post has two halves, because the project did.

  1. The engine. genie-ai-runtime is a single-binary, single-model CUDA LLM runtime tuned for exactly one machine: the Jetson Orin Nano Super 8 GB (SM 8.7, 102 GB/s, 67 TOPS). On the home-agent workload it was built for — short prompts, one model, sharing 8 GB of unified memory with always-on voice — it runs Qwen3-4B-Q4_K_M prefill at ~38 tok/s vs llama.cpp’s ~18 tok/s on the same box: +115%. The wins are Orin-specific kernels (a tensor-core MMQ Q4_K prefill GEMM on SM 8.7), a memory-first design (INT8 KV cache, pre-allocated pools, an OOM-guard that accounts every byte before inference starts), and a persistent + in-memory prefix KV cache that makes a stable system prompt effectively free (warm-turn TTFT 444 ms, a shared-prompt re-request ~13× faster prefill).

  2. The model. The engine makes a transformer fast. But the transformer’s KV cache grows linearly with context, and on an 8 GB box that’s the thing that eventually kills you. So I benchmarked the architectures head-to-head (GeniePod/genie-claw#410) — pure transformer (Qwen) vs pure SSM (Mamba) vs attention/Mamba-2 hybrid (Falcon-H1) — across 0 → 32 k context, all Q4_K_M on llama.cpp, same Jetson. The result: quality is a matter of training, not architecture; the SSM/hybrid families win long-context speed; only pure Mamba wins on memory; and KV memory scales with layers, not the “hybrid” label — so the shallow Falcon-H1-0.5B is the genuine edge pick while the 66-layer “Deep” variant is a 2.4× memory trap that OOMs at 32 k.

Both halves are public: the runtime repo (and its path-by-path ROADMAP.md), the assistant it serves, and the benchmark issue.


1. The constraint: an 8 GB box that’s already busy

genie-claw is a local, private home assistant: wake word → speech-to-text → an LLM that turns “turn the kitchen lights down” into a tool call → text-to-speech, all on-device, nothing leaving the house. The LLM is one tenant on a Jetson Orin Nano Super 8 GB that is already running parakeet STT, TTS, a denoiser, and a Home Assistant container. The model gets the leftovers of 8 GB of unified (CPU+GPU shared) memory — and that leftover is contested and bursty: a TTS spike or an STT burst can claim it at any moment.

8 GB unified memory (≈ 7.6 GB usable) OS ~1.5 GB Qwen3-4B weights 2.32 GB STT ~0.45 KV contested headroom — voice bursts + Home Assistant
Approximate steady-state split (OS floor and STT footprint measured on-device; the rest shared and bursty). The LLM's slice is small and the headroom is contested — which is why the runtime pre-allocates and accounts every byte instead of malloc-ing mid-inference.

What “unified memory” means on the Jetson — and why it changes everything. On a discrete GPU (A100, H100, RTX 4090), the GPU has its own physical memory (HBM2e, GDDR6X) that is separate from host DRAM. You allocate buffers in host RAM, explicitly copy them to the GPU over PCIe, and the GPU’s kernels run at HBM bandwidth. Two separate allocators, two separate bandwidth pools, an explicit copy step between them.

The Jetson Orin Nano has none of that. Its CPU and GPU cores share one physical LPDDR5 pool — the same DRAM chips, the same memory bus, the same 102 GB/s bandwidth. There is no PCIe link between CPU and GPU memory. There is no cudaMemcpy(H2D) step; model weights loaded by the CPU are already accessible to CUDA kernels without a copy, because there is only one memory. This is what “unified memory” means on Jetson — not the CUDA virtual unified memory API, but a single physical DRAM pool.

This has three direct implications for an inference runtime:

  1. No copy overhead, but shared bandwidth. Weight loads, KV reads, audio buffers, OS page tables, and STT/TTS model reads all compete for the same 102 GB/s. A parakeet inference burst or a TTS flush steals from the same bus the GPU decode kernels are reading model weights from. On a discrete GPU this competition doesn’t exist — HBM and PCIe/system RAM are physically separate.

  2. One memory allocator, one budget. There is no separate “GPU memory” that can OOM independently of “CPU memory.” When a cudaMalloc fails on Jetson it means the same pool is full that the OS and STT and TTS are all using. Any runtime that treats GPU memory as a separate isolated resource will unexpectedly compete with the rest of the system.

  3. Weights are zero-copy. After the model is loaded from NVMe into memory-mapped pages, CUDA kernels can read weights directly from those pages without a host-to-device copy. The model load time (~1.3 s warm / ~30 s cold) is purely NVMe→DRAM, not NVMe→DRAM→VRAM.

The existing options don’t fit this shape:

So the thesis of genie-ai-runtime is narrow on purpose: one machine, one model, one GGUF, one shared unified-memory budget. Give up all portability and you can do things a portable runtime can’t — pin to SM 8.7, pre-allocate the KV and scratch pools from the shared pool, and account every byte across the entire system so a concurrent voice burst can’t OOM the box mid-sentence.


2. The target hardware (and the roofline that dictates strategy)

SpecOrin Nano Super 8 GB
GPU1024 CUDA cores (8 SMs × 128), 32 tensor cores, SM 8.7
Compute67 TOPS (INT8, sparse)
Memory8 GB LPDDR5, 102 GB/s, unified CPU+GPU
Ridge point0.66 OP/byte
Power7 / 10 / 15 / 25 W MAXN SUPER

The ridge point is the whole story. At ~0.66 op/byte, the two phases of inference land on opposite sides of the roofline:

That split decides where the engineering goes: prefill is where a better kernel pays off, decode is where you’ve mostly already lost to physics — so on decode you stop chasing tok/s and instead win on memory and on not prefilling at all.

The unified memory decode ceiling — what physics actually allows

On a discrete GPU with HBM2e (A100: ~2 TB/s) the decode bandwidth ceiling is high enough that kernel quality matters a lot. On Jetson LPDDR5 at 102 GB/s shared, you can calculate the hard ceiling before writing a line of code:

Qwen3-4B Q4_K_M weights: ~2.3 GB
KV cache @ 1024 ctx (INT8): ~72 MB
Total bytes streamed per decode token: ~2.37 GB

Theoretical ceiling: 102 GB/s ÷ 2.37 GB/token ≈ 43 tok/s

The engine measures ~10 tok/s in practice — ~23 % of the theoretical ceiling. The gap is not bad kernels; it’s the structure of unified memory:

This is why speculative decoding (EAGLE-3, §9) is the correct next lever — not kernel tuning. At batch=1, the decode wall is bandwidth, not compute. Verifying K draft tokens in one forward pass means one weight-read pays for up to K generated tokens: effective decode throughput can exceed the single-token bandwidth ceiling because the per-token weight cost drops by 1/K. That’s the one optimization the physics actually allows.

It also explains why the unified memory architecture makes the prefill wins matter more than they would on a discrete GPU. At 102 GB/s, the system prompt costs real latency — a 500-token system prompt is ~2.5 GB of weight reads = ~25 ms just from bandwidth. The prefix KV cache (§5) that eliminates that re-prefill on warm turns saves a wall-clock second per command, not microseconds.


3. Beating llama.cpp on the same model: the prefill story

The runtime grew through a sequence of numbered “Paths” — each an umbrella GitHub issue with per-phase PRs, each PR posting a verified on-Jetson result before merge. The full narrative is in ROADMAP.md; here’s the prefill arc that took it from “first coherent tokens” to past llama.cpp.

PathWhat it didPrefill
alpha.2first coherent tokens (per-token baseline)8.2 tok/s
Blayer-major batched prefill (read each weight once, reuse across all N tokens)15.4 tok/s (+88 %)
Dright-sized the prefill GEMM unroll (GEMM_MAX_BATCH 32 → 20)+7 % (byte-identical)
Etensor-core MMQ Q4_K prefill GEMM (mma.sync on SM 8.7)38.7 tok/s (+147 %)

(A parallel decode path, Path C — reading four packed Q4_K bytes as one uint32 on the hot GEMV kernels — lifted decode +21 %; it’s in §4, since decode is a memory story, not a prefill one.)

Net: prefill 8.2 → 38 tok/s (+363 %), and against the runtime it replaces:

genie alpha.2 8.2 llama.cpp 17.97 genie-ai-runtime +115 % vs llama.cpp 38.7 short-prompt prefill · Qwen3-4B-Q4_K_M · Orin Nano 8 GB · tok/s
Short-prompt prefill on the same hardware and model. llama.cpp pp18 = 17.97 ± 0.65; genie-ai-runtime ~38 tok/s on a chat-wrapped 33-token prompt.

One honesty caveat up front, because it matters. This is the short-prompt regime — an 18–33-token prompt, which is what a home command actually looks like, and the case genie targets. Prefill throughput rises steeply with prompt length (a bigger batch feeds the tensor cores better), so the +115% is a small-N result, not a claim that genie wins at every batch size: I tuned and measured the short prompts the product lives in, and I don’t make claims about the large-batch regime, where llama.cpp’s mature batched kernels are strong. Same short-prompt regime, same hardware and model, genie is ~2× ahead.

Why batched prefill (Path B) over-delivered

The bandwidth math under-predicted every PR in the Path B series by 3–10×. Two effects beyond raw weight bandwidth explain it:

  1. Kernel-launch overhead. Per-token prefill ran ~250 launches/token × 18 tokens ≈ 4500 launches; the layer-major batched path runs ~10 launches/layer × 36 layers ≈ 360. At ~5–10 µs/launch that’s tens of milliseconds reclaimed.
  2. L2 pressure. Per-token, each token streams the full weight set before the next starts, so the LPDDR5 controller re-fetches the same weight pages 18×. Layer-major touches each page once and keeps activations hot in L2 across the per-layer loop.

The tensor-core kernel (Path E) — the big win

Path E replaces the scalar Q4_K batched kernel’s CUDA-core FMAs with mma.sync.aligned.m16n8k16.row.col.f32.f16.f16.f32 on SM 8.7. A 16×32 FP16 staging tile is dequantized from one Q4_K block into shared memory; the 128 (d, dm) scale pairs are precomputed once per block; and 4 warps per CUDA block share one dequantized A-tile across 4 contiguous N-stripes — 32 tokens at a time vs 8 for the single-warp variant — amortizing the dequant cost 4×. ~39 registers/thread, ~407 GFLOPS aggregate on Qwen3-4B prefill shapes. The ~2.36× speedup over the scalar fallback held uniform across prompt sizes N = 33 / 88 / 235 (a 7× range).

A detail I’d flag to anyone doing this: Path E reorders float adds (the mma accumulation order differs from scalar FMAs), so it is not byte-identical to the scalar path. The bar I adopted instead was “sensibly-identical” — character-for-character the same generated text at temp=0 on the reference prompt, with drift bounded by the FP16 ULP. Insisting on byte-equality would have frozen the project on scalar kernels forever.


4. Memory-first: making every byte count

On this box, the runtime’s defining feature isn’t a kernel — it’s that memory is accounted before any inference runs. A MemoryBudget totals the model weights, the KV pool, and scratch; an OOMGuard refuses to start (cleanly) rather than crashing the Jetson when the sum won’t fit; KV and scratch are pre-allocated pools, so there is no allocation on the hot path that a concurrent voice burst could lose a race to.

Why this matters specifically on unified memory. On a discrete GPU, cudaMalloc fails independently of host memory — the GPU’s VRAM allocator and the CPU’s heap are separate, and an OOM in one doesn’t immediately affect the other. On Jetson’s unified memory, every cudaMalloc, every malloc, every OS page fault, every STT model activation all draw from the same 8 GB pool. If parakeet loads a second model for a background task, your KV cache allocation fails. If the OS buffers a large NVMe write, your scratch buffer allocation stalls. There is no isolation boundary.

The MemoryBudget accounts for this explicitly: it knows the approximate OS floor (~1.5 GB), the STT footprint (~450 MB), the TTS footprint (~100 MB), and the agent process overhead (~200 MB), and it sizes the KV pool to fit in 8 GB − fixed_overheads − weights − scratch. The pool is pre-allocated at startup — if it won’t fit, the process exits with a clean error message rather than crashing mid-inference when the first cudaMalloc on the hot path returns NULL. This is the correct design for unified memory: account the whole system budget upfront, not just the GPU “half.”

The biggest single memory lever was Path I: an INT8 KV cache (per-(layer, position, kv-head) absmax-scaled). It halves the KV body — 144 MB → 72 MB at Qwen3-4B, 1024 context (plus a ~2.25 MB scales region, ~74 MB total) — and is sensibly-identical to FP16 at temp=0 (per-element drift ~0.79 %). On unified memory, INT8 KV has two distinct wins that a discrete GPU’s separate memory accounting would only half-capture:

  1. Headroom. 70 MB freed in the shared 8 GB pool is headroom for the whole system — not just a larger KV allocation but also more room for the STT model’s activation buffers, the TTS subprocess, or a longer context window. The freed memory is genuinely available to everything.
  2. Bandwidth. Smaller KV means less LPDDR5 traffic per decode token. The KV read per token drops from ~144 KB/token (FP16, 1024 ctx) to ~72 KB/token (INT8) — a per-token bandwidth savings that directly raises the effective decode ceiling from the calculation above. At 16k context (576 MB → 288 MB INT8) the difference in theoretical ceiling is 102/(2.3+0.576) ≈ 35 tok/s vs 102/(2.3+0.288) ≈ 39 tok/s — a ~10% decode speedup from storage format alone, before any kernel change.

It’s been the default since alpha.12.

Here’s the v1.0 scorecard the engine ships with:

Workload (Qwen3-4B-Q4_K_M, 25 W MAXN SUPER)v1.0.0
Prefill (33-tok cold)38.0 tok/s (+115 % vs llama.cpp)
Decode~9.9 tok/s
Cold TTFT (33-tok)877 ms
Warm-turn TTFT (Path F, 67 % prefix reuse)444 ms
KV pool @ 1024 ctx74 MB (INT8 default)
Model load — warm (pagecache hit)1.3 s (cold 30 s, NVMe-bound)

Decode did get one real kernel win — Path C, reading four packed Q4_K quants as a single uint32 load on the hot GEMV kernels (Wo, the gate/up pair, the QKV triple) and folding the residual add into the GEMV itself, for +21 % decode (7.5 → 9.1 tok/s). But that’s where decode largely stopped: it’s bandwidth-bound at 102 GB/s, there’s little kernel headroom left, and a later push (Path G) measured neutral and was paused (more in §6). So raw decode throughput is deliberately not on the brag list — at ~10 tok/s it’s competitive but not the win. The honest framing is the one the ROADMAP itself uses: the engine wins on prefill and TTFT; on decode it competes on memory and reuse, not on tok/s.


5. Making the system prompt free: persistent + prefix KV

A home agent re-sends almost the same prompt every time — a fat system prompt (tool list, device catalog, persona) plus a few new words. Prefilling that system prompt on every command is the dominant latency, and it’s pure waste. Two features kill it:

Both are correct for the obvious worry: the shared prefix sits at identical absolute positions, so RoPE is consistent, and causal attention only reads [0, pos] — stale KV beyond the new sequence is never read. It’s the same invariant the cold path already relied on.

A unified memory detail that makes the persistent cache faster than on discrete. On a discrete GPU, saving a KV cache to disk requires a cudaMemcpy(D2H) to bring KV tensors from GPU VRAM to CPU memory before writing to a file. Loading requires the reverse: read from disk into CPU RAM, then cudaMemcpy(H2D) into VRAM. On Jetson unified memory neither copy exists — the KV pool is already in memory the CPU can directly write() or read() back. The save path is a direct write(fd, kv_ptr, size) — one syscall, zero GPU-CPU copy overhead. The load path hydrates directly into the pre-allocated KV pool at CPU speed and is immediately visible to CUDA kernels. The unified architecture turns a conceptually two-copy operation into a single memcpy-to-disk, which is why the 48% TTFT reduction in Path F comes with almost no CPU overhead on the warm path.


6. The negatives I kept

The path-based process made it cheap to record dead ends, and I think the dead ends are the most useful part for anyone building on the same hardware:


7. The deeper question the engine can’t answer: which architecture?

genie-ai-runtime makes a transformer fast on this box. But step back: the transformer’s KV cache grows linearly and unboundedly with context, and on a memory-starved 8 GB board the KV cache — not the weights — is what eventually OOMs you. That’s an architecture problem, not an engine problem. So before committing the edge stack to transformers forever, I ran the matchup (genie-claw#410): pure transformer vs pure SSM vs hybrid, swept across context depth.

Setup: all Q4_K_M, on llama.cpp’s CUDA backend (so this is a fair architecture comparison, not an engine one), on the same Orin Nano 8 GB, GeniePod services stopped and page-cache dropped so the GPU was uncontended. llama-bench for throughput, llama-completion for the allocator’s own memory report. A fair same-size triad anchors it: Qwen2.5-1.5B (transformer), Mamba-1.4B (pure SSM), Falcon-H1-1.5B-Instruct (parallel attention + Mamba-2 hybrid).

7.1 Decode throughput vs context depth — the headline

tg64 @ dN = decode tok/s with N tokens already in context:

0 10 20 30 40 50 0 4k 16k 32k context depth (tokens already in cache) decode tok/s crossover ≈1.5k Mamba-1.4B (SSM) — flat Qwen2.5-1.5B (transformer) — 3.3×↓ Falcon-H1-1.5B (hybrid) — 2.2×↓
Same size (~1.5 B), same quant, same box. The transformer's decode collapses as its KV cache fills; pure Mamba's recurrent state is fixed, so decode is dead flat; the hybrid sits in between. Crossover ≈ 1.5 k tokens — beyond it the SSM wins, and at 32 k it's 2.8× the transformer.
depthQwen2.5-1.5B (transformer)Mamba-1.4B (SSM)Falcon-H1-1.5B (hybrid)
035.029.931.7
4 09621.230.329.2
16 38416.730.220.3
32 76810.630.214.4
decay3.3×flat2.2×

Prefill (pp512) tells the same story even louder: the transformer collapses 8.7× (1264 → 145 tok/s) as attention re-scans a growing context, pure Mamba is flat (~980) on its O(1)-per-step state update, and the hybrid decays 3.4×.

7.2 Memory vs context — and the surprise

The allocator’s own report, transformer vs SSM:

contextQwen2.5-1.5B KV cacheMamba-1.4B recurrent state
4 096112 MiB14.25 MiB
32 768896 MiB14.25 MiB
growth8× — linear, unbounded0 % — constant

At 32 k the transformer’s KV cache is 63× larger than Mamba’s entire state, and still climbing. This is the real edge argument for SSMs: not speed, memory that doesn’t grow.

The surprise was the hybrid. You’d expect “attention + Mamba-2” to inherit the SSM’s flat memory. It doesn’t — at least not at 1.5 B. Falcon-H1 runs attention heads and Mamba-2 heads in parallel inside every block, so it keeps full GQA attention in all 24 layers: its KV cache still grows ~linearly (768 MiB at 32 k), and the 73 MiB recurrent state adds back what little the layer count saved. Total at 32 k: 841 MiB vs the transformer’s 896 — only ~6 % less. The hybrid buys a flatter speed curve, not a smaller memory footprint at this scale.

7.3 KV memory scales with layers, not the “hybrid” label

Sweeping the Falcon-H1 family exposed the actual governing variable. KV ∝ layers × head_dim — so the “hybrid saves memory” story holds only when the hybrid is also shallow:

Mamba-1.4B 14 MiB· 48 L · flat Falcon-H1-0.5B 604· 36 L · edge pick Falcon-H1-1.5B 841· 24 L Qwen2.5-1.5B 896· 28 L · transformer Falcon-H1-1.5B-Deep 2213 · 66 L · OOM ↑ transformer @32k
Total memory at 32 k context (KV + recurrent state), ascending. The shallow Falcon-H1-0.5B (36 layers, head_dim 64) lands well under the transformer; the 66-layer "Deep" variant needs 2.4× the transformer's memory and can't even allocate its 32 k context on 8 GB. Depth, not the hybrid label, drives KV.

And the shallow 0.5B isn’t just the lightest — it’s the fastest decoder at every depth (54.5 → 21.2 tok/s, beating all four larger models even at 32 k), in a 300 MB file, at full quality. The Deep variant is the mirror image: depth buys reasoning-per-parameter and pays in KV memory.

7.4 Quality is training, not architecture

Four greedy prompts (trivia, arithmetic, instruction-following, explanation):

trivia / arithmeticinstruction-following / reasoning
Qwen2.5-1.5B-Instruct✓ (4/4)
every Falcon-H1 Instruct (0.5B → 1.5B-Deep)✓ (4/4)
Mamba-1.4B (base)✓ (then loops)✗ — incoherent, never emits EOS (2/4)

The pure Mamba fails instruction-following and loops forever — classic base-model behavior. But that’s a confound, not a verdict on SSMs: mamba-1.4b-hf is a 2023 base model; everything it’s compared against is a 2024+ instruct model. The clean read is that every instruct-tuned model — transformer and hybrid alike — scores 4/4. Quality is a function of training maturity, not of attention-vs-SSM. There simply is no well-tuned small Mamba instruct to ship today.

7.5 The ceiling on 8 GB

Going up the size ladder: Falcon-H1-3B runs and is 4/4, but OOMs trying to allocate a 32 k context (1.76 GiB weights + ~1 GB KV + compute buffer don’t fit). Falcon-H1-7B and the 7B reasoning variant won’t load at all — the ~4.2 GB weight buffer can’t be allocated contiguously on an 8 GB unified box, even with everything else stopped. The practical Falcon-H1 ceiling on this Jetson is 3B, short context.


8. The verdict

Two decisions came out of all this:

The meta-point: an inference engine and a model architecture are two different optimization surfaces. The engine got Qwen3-4B from 8 to 38 tok/s on a board it was never designed for — but no kernel can fix the fact that a transformer’s KV cache grows with every token. That ceiling is architectural, and on the edge it’s the one that actually decides what you can run.


9. What’s next

v1.0 is the floor, not the ceiling. Three threads are queued, each aimed squarely at a wall this post ran into.

Speculative decoding via EAGLE-3, trained for the home

§6 deferred speculative decoding for a concrete reason: with Qwen3-4B as the target and no good small draft model in the Qwen3 family, the speedup was bounded by a draft I didn’t have. EAGLE-3 removes that blocker — it doesn’t need a separate draft model; it trains a lightweight autoregressive draft head on top of the target’s own hidden features (multi-layer feature fusion), so the “draft” reuses the model already resident in memory. That’s the one lever that actually beats the decode bandwidth wall from §4: verify K speculated tokens in a single weight-read forward pass instead of K of them — exactly the right trade on a 102 GB/s box where decode is otherwise pinned at ~10 tok/s and the GPU is idle waiting on HBM.

The edge twist is to train the head on smart-home context. A home assistant’s traffic is a narrow distribution — a fixed system prompt, a known device catalog, a small grammar of tool calls. A draft head fit to that distribution should accept far more aggressively than a general-purpose draft, because the next token is usually highly predictable (turn, the, a {tool_name}, a device id straight out of the catalog). And because the agent runs greedy (top-k = 1) — deterministic tool calls, not creative prose — verification collapses to exact-match on the target’s argmax: the simplest, highest-acceptance regime there is. Domain-tuned head + greedy verify is the most promising route to finally moving the decode number this engine deliberately didn’t chase. The head itself will live in genie-ai-model.

Two more model targets: Falcon-H1 and Gemma 4 E2B

genie-ai-runtime is transformer-only today, and the next two models stretch it in different directions — one in architecture, one in modality — while both stay inside the same 8 GB memory discipline.

Different directions, one constraint: every new model still has to earn its place inside the 8 GB budget the voice stack already shares.


10. The process that made it work

Two habits I’d keep for any constrained-hardware project:

  1. Path-based umbrella issues. Every numbered Path was one issue with a phase table and risks up front, and every per-phase PR posted a verified on-device result before merge. It made negative results cheap to absorb without losing momentum.
  2. “Sensibly-identical,” not byte-identical. Once tensor-core kernels reorder float adds, byte-equality is gone. Adopting FP16-ULP-bounded, character-identical output as the bar is what unlocked Path E and INT8 KV.

References


Share this post:

Previous Post
Deploying VLA models on Jetson — what fits, what doesn't, and what TensorRT buys you
Next Post
genie-claw: building a private on-device AI agent for Jetson — what is limited context size, and why it's the real engineering problem