harness · the other half
Austin LangChain / AIMUG · September 2, 2026

Your harness is the other half of the model

One GH200 now serves text, vision, speech and a safety model. The interesting part is not that it fits. It is what had to stop being true first.

Colin McNamara · Field CTO at AHEAD · organizer, AIMUG
Every number here was measured on one machine and checked against a primary source.
follow along
The claim

One card. Four jobs. A router in front.

A single HBM3 pool of 97,871 MiB divided into four segments: Qwen3.8-27B-FP8 using 59,278 MiB, whisper-large-v3 using 7,660 MiB, Qwen3Guard-Gen-4B using 11,758 MiB, and 19,053 MiB free. 78,818 MiB used in total. Text, speech and a safety classifier, three services on one card with room for a fourth.

Claude Code drives the text tier over its own native protocol. None of this was affordable twelve hours earlier, and the hardware did not change. A constraint I had accepted without re-testing did. Here is how it is wired.

What it actually looks like

Two machines, and one link that does the work

Two panels. On the left, a laptop running Claude Code speaking the Anthropic messages API, and OpenCode and Codex speaking the OpenAI chat API, all feeding a semantic router on port 8899 that reads four signals: complexity, conversation, context and jailbreak. The router sends a request to a local Ollama llama3.1 8B only when it is trivial, the opening turn, and under 256 tokens; everything else goes to the GH200. On the right, a GH200 running three services: Qwen3.8-27B-FP8 at 58.7 GB handling text and vision with a 262k context, whisper-large-v3 at 7.7 GB, and Qwen3Guard-Gen-4B at 11.7 GB, using 78 GB of a 95.6 GB HBM3 pool, with a heavy NVLink-C2C arrow at 447 GB/s over 10 links joining HBM to 573 GB of Grace memory. An amber dashed arrow runs from the guard model back into the router, labelled safety signal, measured 87 percent, not yet wired. A grey dashed line shows speech bypassing the router entirely, because it has no audio endpoint.

Do not absorb it all now. Every box gets its own slide later. A turn goes local only if all three signals agree: trivial, opening turn, under 256 tokens. Everything else crosses to Athena. One of these signals is unlike the others. The jailbreak route does not pick a model at all, it takes the tools away, so an injection arrives as inert text. Free and blunt, sitting next to a semantic signal that needed calibrating to 0.07 for 75 percent accuracy. Speech stays outside the router deliberately: the string "transcription" appears zero times in its source.

The machine

One GH200, sitting in a rack

NVIDIA GH200: a Hopper GPU with 96 GB of HBM3 at 4,023 GB/s and a 72-core Grace CPU with 480 GB of LPDDR5X at 384 GB/s, joined by NVLink-C2C at 450 GB/s per direction and 900 GB/s bidirectional, presented as one shared virtual address space across two cache-coherent NUMA tiers.

One address space across two memory tiers. For everything in this talk the workload never leaves the left half, so you can forget the Grace side exists. This is a story about software.

Groundwork · 1 of 5

Every request runs in two completely different phases

Five slides of foundations, about four minutes. If you serve models for a living, this is review and you can rest. If you mostly call APIs, this is the part that makes everything after it land. I would rather over-explain than have half the room politely lost.

Phase one, prefill, reads the whole prompt in one parallel pass and produces the first token. Phase two, decode, writes the answer one token at a time, each one needing the one before it, so it cannot be done in parallel. Same model and same hardware, completely different bottlenecks.

Almost everything surprising about serving a model lives in the gap between those two phases. Hold on to this, the whole talk hangs off it.

Groundwork · 2 of 5

To produce one token, the GPU reads the entire model

Not part of it. All of it. Every weight, out of memory, for every single token. Then it does it again for the next one.

Three identical rows, each showing 28.7 GiB of model weights being read in full to produce a single token. Three tokens means three complete reads of the model. HBM bandwidth of about 4,000 GB/s divided by 28.7 GiB per token gives a ceiling near 130 tokens per second; measured was 63.1.

So single-stream generation is not limited by how fast the GPU can do maths. It is limited by how fast it can move bytes. The tensor cores, the expensive part you actually bought the card for, sit mostly idle.

Prefill is the opposite: one pass over a huge batch of tokens, math units saturated. Same silicon, opposite bottleneck.

Groundwork · 3 of 5

Why context costs you memory

When the model generates token 5,000, attention makes it look back at all 4,999 tokens before it. Recomputing those every step would be brutally quadratic.

So the server keeps a KV cache: for every token that has passed through, it stores that token's keys and values, once, and reuses them forever after.

Three snapshots of a KV cache after 10, 1,000 and 100,000 tokens. The row of cached entries grows dramatically across the three, overflowing the panel at 100,000. Keys and values are stored once per token then reused, so context length is literally a memory budget.

The win is that each token is processed once rather than once per subsequent step. The cost is that the cache grows with every token. When someone says a model "supports 262k context," what they mean is: if you can afford the cache.

Groundwork · 4 of 5

So what is actually in that cache?

Attention works by each token asking a question. That question is a query. Every earlier token offers a key, saying roughly what it is about, and a value, carrying its actual content. Models run a couple of dozen of these comparisons in parallel, and each parallel copy is called a head. Only the keys and values get cached, and they do not have to be one set per head.

Classic attention gives every one of the 24 query heads its own key and value set, 24 sets to cache, at 768 KiB per token. Grouped-query attention has many query heads share a smaller number of key and value sets, 4 sets, at 128 KiB per token. At a 262,144 token window that is 32 GiB instead of 192 GiB, twice the memory on this machine.

That is grouped-query attention, and it is the only reason a 262k window fits on this box at all. Your context length is an architecture decision, not a hardware capability.

Groundwork · 5 of 5

The two things everyone tunes

Quantization stores each weight in fewer bits. FP8 instead of BF16 halves the bytes, and since decode is bandwidth bound, fewer bytes per weight is directly fewer bytes to read per token. Speculative decoding attacks the same bottleneck from the other end:

Speculative decoding in three steps. A small fast draft model guesses three tokens. The full model checks all three in one forward pass, so the expensive part happens once. Two guesses survive and one is rejected, producing two tokens from a single read of the model.

These are the two knobs I spent that night measuring. Measuring them carefully is exactly why I thought I understood my setup.

Night one, then three days later

I upgraded the engine for an unrelated reason

First I did the disciplined thing. One variable at a time, every configuration confirmed by a second run, speculative decoding measured on and off at 63.1 to 112.0 tok/s. Clean, repeatable, and I was pleased with it.

Then a different model I wanted to try. Its architecture was not registered in the version I had. So I built a second environment alongside the first and, while it was sitting there, re-ran the identical benchmark on it.

Same model. Same quantization. Same speculative config. Same memory utilization. Same hardware. Same harness script.

Decode
+43percent

112.0 to 160.0 tok/s

KV cache pool
3.75x

419,200 to 1,574,379 tokens

The variable I never tested moved more than everything I did.

The evidence

The most important number is the one that did not move

Prefill was 6,266 tok/s before and 6,249 after. Unchanged. That is not a footnote, it is the whole attribution.

Two panels comparing prefill and decode on the same GPU. Prefill is compute bound with tensor cores saturated. Decode is bandwidth bound with tensor cores mostly idle. Across the engine upgrade prefill was unchanged while decode rose 43 percent.
Prefill and decode load the same silicon in opposite ways. Overhead-elimination work lands on one of them and not the other.

If both had moved I would be hunting a config mistake in my own setup. Because only the bandwidth-bound half moved, I can point at the serving stack. A null result did the attribution.

Root cause

A config field I had never once looked at

"full_attention_interval": 4

This model is not a uniform stack. One layer in four is ordinary full attention; the other three are not.

A strip of 64 layer segments where every fourth is highlighted. 48 layers are linear attention carrying a fixed-size running state that costs the same at token 200,000 as at token 10. Only 16 are full attention with a growing KV cache that scales with sequence length. The old engine reserved a growing cache for all 64.

This is the second way the architecture cuts your context bill. The first was sharing key and value sets across heads; this one skips three quarters of the layers entirely. The older engine did not know that. The ratio predicts 4x; I measured 3.75x. Inferred from release notes and that ratio, not from per-layer cache logs.

The failure mode

Nothing errored. Nothing warned.

  • It answered correctly
  • It passed needle retrieval at 231,000 tokens
  • It drove a working agent loop for weeks

I just had three quarters less context than I thought I did.

This is the shape of the whole talk. The loud failure is the easy one: the model refuses to load, you go find a newer engine, you move on with your day. The quiet failure is the expensive one.

Case two

"This model is chatty and incompetent at tools"

The model emits a tool call as plain text. The server parses that text, and which parser it uses is chosen with a flag. With no parser set the call lands in content as a string and the agent sees no tool call, so the loop silently stalls. With the correct parser it lands in tool_calls as structured data and the agent runs the tool.

Every cheap check passed while this was broken. Models endpoint: fine. Chat completion: fine. Plain prompt: fine. Only running the actual agent loop caught it.

Count them up

Every one was first filed against a model

  • looked like the model fails long-context retrieval  was my output cap
  • looked like the model is bad at tools  was a missing parser flag
  • looked like model A's numbers  was model B answering silently
  • looked like a small context window  was a flag with two meanings
  • looked like the model cannot be terse  was an unbounded reasoning budget
  • looked like a memory-placement law  was one machine's topology
  • looked like the model's context budget  was the engine not knowing its architecture

Three of the first four would have been written down as findings about the model rather than about the harness.

The turn

Then my own instruments lied to me

Two quote characters. opencode run wraps your prompt in literal double quotes before it goes on the wire. That moved my complexity classifier's margin from -0.155 to -0.085, across the routing boundary, on a scale whose entire range is about 0.2. I had been scoring that classifier against bare strings, a wire format no real client sends. Measured honestly across both, accuracy was 75 percent, not 90.

My cache-busting was cached. Laptop against GH200 on a 49,000-token prefill measured 6.0s and 6.5s. Near parity, and wrong. I had salted the end of the message, so the prefix was byte-identical and both machines served it from cache. Salting the system message instead: 169.6s against 7.1s, a 24x gap pointing the other way.

Anyone can blame their tools. Both of these were mine.

The architecture, one

It is not a 96 GB GPU. It is three tiers.

Three memory tiers by bandwidth. HBM3, 95.6 GB, at about 4,000 GB per second. Grace LPDDR5X, 573 GB, at 447 GB per second. NVMe, 879 GB, at 4.9 GB per second. A dashed marker between Grace and NVMe labels a 91x cliff. Grace is a runtime tier, NVMe is storage.

The cliff is between the second and the third, and that one ratio settles nearly every placement question on the box. vLLM agrees with it: offload paths into Grace, and no disk backend for weights at all.

The architecture, two

A cache that survives its own eviction

Recomputing a prefix from cold takes 4.55 seconds. An HBM cache hit takes 0.68 seconds. A Grace hit after real eviction takes 0.85 seconds, only 0.17 seconds behind the HBM hit and 5.4 times faster than recompute. Proven by flooding the pool with 16 distinct 53,000-token prompts, about 856,000 tokens against a 698,085-token pool, then re-requesting the first.

100 GB of Grace behind the HBM pool, proven by eviction rather than by inference. That 0.17s is the entire architecture, because it is what makes giving up HBM cheap.

The architecture, three

155.4 GiB of weights on a 95.6 GiB card

--offload-backend uva --cpu-offload-params experts   A mixture-of-experts model routes each token to a handful of its experts, so decode touches very few per token. Park those in Grace and the link traffic stays small. And how much you park is not a switch:

Decode throughput and KV pool size as more of DeepSeek-V4-Flash is offloaded to Grace. At 80 GB offloaded, 73.0 tokens per second and a 757,879-token KV pool. At 110 GB, 64.9 tokens per second and 1,873,379 tokens. At 140 GB, 58.3 tokens per second and 2,913,722 tokens. Sixty gigabytes more into Grace costs 20 percent of decode and buys 3.8 times the KV pool, and the slope flattens rather than steepening.

The cliff between HBM and Grace is a dial, not a wall. That is what 447 GB/s looks like when only 6 of 256 experts are touched per token.

The architecture, four

Both models fit. It is not free.

Qwen3.8-27B measured alone and while the other tier is busy on the same card, across three speculative decoding settings. With spec4, the production setting, 220.1 tokens per second alone and 74.2 contended, a 66.3 percent drop. With spec2, 172.9 and 58.9, a 65.9 percent drop. With speculation off, 90.9 and 43.4, a 52.2 percent drop. The smallest percentage drop belongs to the slowest configuration.

Both fit in 80,642 of 97,871 MiB, and one long-context request in flight costs the tier serving nearly all your traffic about 2.8x. I thought speculative decoding explained that, so I tested it. Turning it off does shrink the proportional drop, and leaves you 1.7x slower exactly when the card is shared. The collapse is structural: a dense model sharing the SMs loses at least half its throughput however it decodes. The MoE does not collapse the same way, but I cannot tell you by how much. Its solo rate swung 40.2 to 57.2 tok/s inside one unrestarted process, so a single sample per condition cannot resolve it.

The architecture, four

Three models, and the decode path never noticed

Solo baseline
~169tok/s

the 27B with the card nearly to itself

Three models resident
158.9tok/s

matched length, inside run-to-run variance

All three tiers working
193.7tok/s

while Whisper and the vision tier were busy

Two extra tiers and a 2.2x effective KV cache, for nothing on the decode path. The vision tier has since been retired; the 27B is itself multimodal. At matched output length, deliberately: throughput climbs to 262.6 at 800 tokens as fixed overhead amortizes, so comparing across lengths would have shown a gain that was not there.

Worst-case concurrency at a full 262k context falls 5.67x to 2.66x; real agent requests run nearer 50k.

The one you could do this weekend

I pointed Claude Code at my own GH200

It speaks the Anthropic Messages API. vLLM serves that API natively. It should have just worked. Four walls, and every one of them was the harness:

  • non-canonical field  my router's wire struct never declared context_management, which Claude Code sends on every request. I blamed vLLM for weeks. vLLM accepts it fine.
  • adaptive reasoning cannot include an effort  Claude Code sends adaptive thinking and effort: xhigh together, and Anthropic allows that pair. My router was stricter than the service it stands in for.
  • wire format openai.chat.v1  no code fix at all. Point it at an Anthropic-format backend and the lossy translation simply stops happening.
  • missing required field stop_sequence  vLLM omits a nullable field, so the router killed the stream one event from the end, after the full answer had already reached the user.

48 lines across three files, and it runs against my own card, tool loop and all. The router's original premise did not survive measurement, by the way: the laptop only wins below about 180 prompt tokens and no agent client ever sends that few. One real captured request took 157s locally against 3.0s on the GH200. I built a tier router and shipped a protocol translator.

The idea worth stealing

A backstop changes the price of every trade above it.

The vision tier was unaffordable all session. HBM was full, and dropping the text model's utilization to make room cost KV capacity outright, 1,485,965 tokens down to 698,085.

Once Grace was catching those evictions at 0.17s instead of 4.55s, the same trade became nearly free and the third model fit the same afternoon. Nothing about the machine changed. A constraint I had accepted without re-testing stopped being one.

Any plan you rejected on a constraint is worth re-testing after you relieve something underneath it.

Monday morning

What I actually changed

  • Engine version goes on the benchmark axis, not in the environment notes. I record it the way I record quantization, because it behaves like a variable.
  • Test the full loop, not the endpoint. Every cheap check passed while tool calling was silently broken. Only the real agent loop caught it.
  • One variable at a time, even when it feels slow. Two knobs I tuned partially cancel; flipping both together would have hidden that entirely.
  • Re-test your tuning peaks when either the model or the engine moves. Both shifted my optimum by more than 40 percent.
  • Checking that a system does what it says is not checking that it is right. That is the one that cost me the most.
Land it

Everything you hold fixed is a claim you are making.

A control is an assertion that something does not matter. You rarely go back and check it, because checking it is the same work as testing it, and you already decided it was background.

Somewhere in your stack there is a version number, a default, or an inherited flag you have stopped seeing.

All three of tonight's newest failures were claims I did not know I was making. That the wire format was fixed. That the flag arrived. That my salt busted the cache. That the HBM number was the ceiling.

The background of your benchmark is someone else's independent variable.

Thanks

Questions, arguments, war stories

Colin McNamara · Field CTO at AHEAD · organizer here at AIMUG.

Full write-up with every measurement, the diagrams, and the parts I got wrong along the way:

colinmcnamara.com/blog/engine-other-half-of-the-model

If you run open-weight models on your own metal, I want to hear which of these six you have already hit.

connect