Thursday, 5 November 2026

I Like Dog Food

There is an old saying in software: eat your own dog food. If you build something, use it yourself, in anger, on the work that actually matters to you. It is the fastest way to find out whether the thing you built is real or only looks real in a demo.

As of this week Marvin is eating mine. Every Qwen request Marvin makes now goes to llmgo, my own Go and CUDA inference engine, and llama.cpp is no longer in that path. In my last post I talked about why I wanted it gone: security, a smaller audit surface, a single binary with nothing underneath it. This post is about what it took to get it into production, and what the numbers look like now that it is there.

Two cards, two jobs

llmgo has been built across two very different GPUs, and that was deliberate.

The RTX 3050 (8 GB) lives in my workstation. It is the development card. It also drives my KDE Plasma desktop, so a slice of that 8 GB is always taken and I can't get it back. The CPU behind it is a Ryzen 7 3700X, AVX2 only. The model is Qwen3.8-27B, and on this card a third of its 64 layers do not fit in VRAM, so they run on the CPU. It is not a friendly place to run a 27B model, and that is the point. If llmgo behaves here, it will behave anywhere.

The RTX 5060 Ti (16 GB) lives in the Kubernetes cluster, with the NVIDIA container toolkit providing the driver. It runs Qwen3.8-27B at UD-Q2_K_XL with its vision projector, all 64 layers on the GPU. This is where Marvin's real work happens, and llmgo now runs there as a container in exactly the slot the llama.cpp pod used to occupy.

Develop on the constrained box, deploy to the bigger one. Every win on the 3050 turns into a bigger win on the 5060 Ti.

The rule: host memory owns the model

The design decision that shaped everything else is simple to say. Host RAM holds every layer of the model, all the time. The GPU only ever gets what it needs right now. Nothing is ever copied back from the GPU; when a GPU copy is no longer needed it is just deleted, because the authoritative version is already sitting in host memory.

That rule applies to every model type, not just the text layers. The vision projector follows the same dynamic loading path, borrowing VRAM from the text layers when they are idle and handing it back when they are needed again. One mechanism, no special cases.

The 3050: ahead of llama.cpp on every number

The first GPU profile on the 3050 was humbling. The kernel doing the decode weight products was taking 263 ms per token for 40 layers, roughly twelve times slower than just reading the weights. A GPU layer was costing as much as a CPU layer, which defeats the purpose of having a GPU.

Over one long day of runs, each with the same three requests (a short prompt, a 9.6k-token chat prompt with tools, and a medium prompt), the things that moved the numbers were:

  • Prefill attention on the tensor cores, FlashAttention-style tiles instead of FP32 on the CUDA cores. The 9.6k-token prefill went from 55.4 s to 45.0 s, the biggest single step of the day.
  • An int8 tensor-core matmul for prefill, decoding the 2-bit weights to int8 in shared memory with the tile picked per weight shape at startup. The first version was slower. The fifth, with a two-stage pipeline, was the first run to beat llama.cpp's prefill.
  • A new decode kernel that works on the low-bit weights directly with int8 activations, tuned per weight shape, running at up to 166 GB/s on a card with 224 GB/s of memory bandwidth.
  • Speculative decoding with the model's own MTP layer, plus CPU kernels that decode each weight block once for both tokens, so a two-token step costs about the same as a one-token step.

Same model, same prompts, same machine, against llama.cpp:

RTX 3050: Qwen3.8-27B UD-IQ2_XXS llama.cpp llmgo first run llmgo latest Prefill, 9.6k-token promptseconds, lower is better llama.cpp 39.9 s llmgo first run 55.4 s llmgo latest 38.3 s Decode after the 9.6k prompttokens/s, higher is better llama.cpp 3.14 llmgo first run 3.08 llmgo latest 5.71 Decode, short prompttokens/s, higher is better llama.cpp 4.12 llmgo first run 3.52 llmgo latest 7.41

Then I pointed it at the bigger quant, Qwen3.8-27B UD-Q2_K_XL, the same file the 5060 Ti runs. Getting there meant fast paths for the K-quant types, which had been running on the old float-decoding kernel. Here it is beside the IQ2_XXS results:

RTX 3050: Q2_K_XL against IQ2_XXS llama.cpp (IQ2_XXS) llmgo IQ2_XXS llmgo Q2_K_XL Prefill, 9.6k-token promptseconds, lower is better llama.cpp (IQ2_XXS) 39.9 s llmgo IQ2_XXS 38.3 s llmgo Q2_K_XL 37.0 s Decode after the 9.6k prompttokens/s, higher is better llama.cpp (IQ2_XXS) 3.14 llmgo IQ2_XXS 5.71 llmgo Q2_K_XL 6.29 Decode, short prompttokens/s, higher is better llama.cpp (IQ2_XXS) 4.12 llmgo IQ2_XXS 7.41 llmgo Q2_K_XL 7.90

To be fair to the numbers: that run had a little more VRAM free than usual, so 44 layers fitted on the GPU instead of 39 or 40, and only 20 ran on the CPU. Per GPU layer the two files are about the same size, so the larger quant costs nothing in placement. There is no llama.cpp run of Q2_K_XL on this card yet, so the grey bars are its IQ2_XXS figures. On an 8 GB card that is also driving a desktop, a 27B model at over 6 tokens per second after a 9.6k-token prompt is a result I am happy with.

The rule I set is that no GPU kernel change is allowed to make the 3050 slower. The 3050 is an Ampere card and the 5060 Ti is Blackwell, and they do not always want the same kernels. If a change helps one and hurts the other, the kernel gets tuned or split per card rather than accepting the regression. The 3050 stays an honest benchmark.

The 5060 Ti: where the dog food got eaten

Marvin talks to its models through an LLM router, and every backend behind that router was llama.cpp. The router probes backends through /props and /health, so llmgo had to answer those the way the router expects before it could even be considered. Then it had to handle tool calling, streaming and thinking (at low reasoning effort, matching the llama.cpp config), with multiple requests isolated from each other, behind TLS and API keys.

Against the llama.cpp deployment it replaced, same card, same model:

RTX 5060 Ti: Qwen3.8-27B UD-Q2_K_XL llama.cpp llmgo Decode, 9.6k-token prompttokens/s, higher is better llama.cpp 26.5 llmgo 41.2 Prefill, 9.6k-token promptseconds, lower is better llama.cpp 10.0 s llmgo 13.2 s Prefill, ~37k-token promptseconds, lower is better llama.cpp ~45 s llmgo 54.3 s

Decode is well ahead, and holds at 31.7 to 32.4 tokens per second even at around 35k tokens of context. A big part of that is something llama.cpp leaves on the table. Qwen3.8 ships with a multi-token prediction (MTP) layer that drafts tokens ahead of the main model. llama.cpp ignores it. llmgo uses it, and in Marvin's tool loops 68 to 84 percent of the drafts are accepted, at 2.2 to 2.5 tokens per step.

Prefill on the 5060 Ti is still behind, and I am not going to pretend otherwise. The matrix multiplies are most of the remaining gap, and that is the next target. It has already been closed on the 3050, so I know it can be done.

What only daily use finds

This is the real reason to eat your own dog food. Benchmarks tell you how fast something is. Daily use tells you whether it is right.

The first time I watched Marvin run a long tool loop on llmgo, every turn took about 65 seconds, and 90 percent of that was prefill. Nothing was broken. Each turn just added 100 or so tokens (the tool call and its result) and llmgo re-read all 35,000 tokens of the conversation every time. llama.cpp keeps the previous prompt and only processes what is new. No synthetic benchmark I had written would have shown that, because benchmarks do not have conversations.

So llmgo got a prompt cache, with a twist: Qwen3.8 is a hybrid model, and its DeltaNet layers carry state that has to be checkpointed along the way, not just at the end of the prompt. The result, from a real session: ten turns in a row at 70 to 73 thousand tokens of context all hit the cache, each processing its 105 to 634 new tokens in 1.4 to 2.7 seconds.

Marvin tool loop: time per turn on the 5060 Ti before after One tool-loop turnseconds, lower is better no prompt cache ~65 s (35k context) cache hit 1.4 to 2.7 s (70k+ context)

Real use kept finding things:

  • Marvin's UI and gateway router fire two or three small title, tag and follow-up requests after every answer. Each one was pushing the big conversation off the GPU, only for the next turn to pull it straight back. Those small requests now run alongside it instead.
  • In a long session the cache grew until it squeezed the host, and one 105-token prefill took 66 seconds instead of 1.5. The cache now takes half of what is free beyond a fixed reserve.
  • When the gateway router changes the tool list mid-conversation (2, then 11, then 29 tools), nothing can be reused, because the tools sit at the very start of the prompt. llama.cpp has the same limit. Good to know it is not just me.
  • One early build produced gibberish. llmgo now checks itself at startup, running a token through the GPU and the CPU and comparing the results, and refuses to trust a layer that disagrees.

Every one of those came from Marvin doing Marvin's normal work. None of them came from a test suite.

Owning the whole path

It also closes a loop I care about. The same person writing the CUDA kernels is writing the container, the Kubernetes deployment, the router integration and the platform that consumes it. That is the continuum I keep coming back to: not developer, not ops, just the whole path from the silicon to the cluster, owned end to end. When something breaks, there is nobody else to hand it to, and that is exactly how I want it.

What's next

  • Closing the prefill gap with llama.cpp on the 5060 Ti, starting with the matrix multiplies.
  • Long-context decode. At 60k tokens, decode drops to 23.6 to 27.6 tok/s, with attention reading the cache far below what the card can do. A fix is built and waiting for its first run on the card.
  • Faster small cache hits, so adding a short tool result to a 60k-token conversation takes well under a second.
  • Gemma 4 and a CPU-only dual Xeon server that will serve the same 27B model with no GPU at all.

Back in April I was told I wouldn't be any good in AI because I didn't know GPUs. Marvin now runs on an inference engine I wrote, on kernels I tuned, faster than llama.cpp on every number on my desk and at the part you wait for while it types in the cluster. I like dog food.

No comments:

Post a Comment