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

Your harness is the other half of the model

Seven failures on one GH200. Every one of them looked like a model problem. Not one of them was.

Colin McNamara · Field CTO at AHEAD
Every number here was measured on one machine and checked against a primary source.
follow along
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

I did the disciplined thing

A 27B model. One variable at a time. Repeat runs to confirm every number.

  • Speculative decoding on and off: 63.1 to 112.0 tok/s, a 1.78x gain for one flag
  • FP8 against BF16, with the draft head active on both
  • Separated prefill from decode by streaming and timing the first content chunk
  • Every configuration confirmed by a second identical run

Clean, repeatable, defensible. I wrote it all down and I was pleased with it.

Three days later

I upgraded the engine for an unrelated reason

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.

The uncomfortable part

I had already checked this

2 (K and V) x 4 kv_heads x 256 head_dim x 64 layers = 128 KiB / token
54.82 GiB / 128 KiB  =  ~449,000 tokens
engine reported       =   448,448 tokens     match

It matched to within a rounding error, so I moved on satisfied.

The arithmetic was correct. The premise was wrong.

I confirmed the engine was doing exactly what it claimed to be doing, and quietly mistook that for confirming it was doing the right thing. Those are different claims, and only one of them was worth my time. We were wrong together, in perfect agreement, for months.

The turn

Then I went back through my notes.

The engine version was not the only thing that had fooled me. It was the first.

Six more from the same rack, same stretch of evenings. Each one was first written down as a finding about a model.

Case one

"The model fails retrieval at 231k"

I recorded that long-context retrieval was broken: the model could not find a value buried 231,000 tokens deep. It is a thinking model, and my probe capped output at 32 tokens.

With max_tokens set to 32 the entire budget is consumed by reasoning tokens and is cut off before any answer begins, so retrieval looked broken. With a real budget the reasoning finishes and the answer 8472 is emitted exactly, every time.

I nearly filed a defect report against a model because of a number in my own harness.

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.

Case three

You are not talking to the model you think you are

One engine validates the model field in a request and 404s on a mismatch. Another ignores it entirely and answers with whatever it happens to have loaded.

client config lists:  model-a, model-b, model-c
server actually has:  model-b
you request:          model-a
you receive:          model-b, with no error of any kind

A multi-model client config is completely safe on one and actively dangerous on the other. Every benchmark you ran against "model-a" is a benchmark of model-b.

Case four

The same flag means two different things

On engine A the context flag is per sequence, giving one conversation the full 131,072 tokens. On engine B the same flag is the total KV budget divided across parallel slots, and with the default of four slots each conversation receives only 32,768.

You find out when the client overflows, and the error talks about context size, so you go and blame the model's context window.

Case five

7,178 tokens to say "ok"

The prompt was: Say exactly: ok

No reasoning flags
7,178+ tokens

runaway, and it ignored max_tokens

Reasoning off
2tokens

no thinking at all

Bounded budget
27tokens

reasoning separated out properly

Unbounded, those thoughts land in the message content, get stored in history, and refill the context every single turn. The client auto-compacts forever and the model looks incapable of a short answer.

Case six

The obvious optimization was 1.85x slower

A mixture-of-experts model splits each layer into hundreds of "expert" sub-networks and routes each token to just a handful, so total weights are enormous but only a slice is active per token. Too many to fit in GPU memory, so you park them in the 480 GB attached to the CPU. The intuitive move is then to let that CPU compute the experts whose weights sit in its own memory. No transfers, right?

Expert weights sit in 480 GB of CPU memory. Letting the 72-core CPU compute them locally gives 42.7 tokens per second. Letting the GPU read them across NVLink-C2C gives 79.0, which is 1.85x faster. The interconnect beats the weaker processor, though this would likely invert on a PCIe-attached card.

The interconnect beats the weaker processor, decisively. And this would likely invert on a PCIe-attached card, which is exactly the point: it is a property of the machine, not a rule you can carry between machines.

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 claim

Your harness is a lagging implementation of your model's architecture.

Architectures are changing structurally, not just getting bigger. Every new mechanism has to be implemented in your serving stack before you get anything like what it offers, and that implementation lands later.

Two parallel timelines. The model architecture track marks linear attention, sparse routing, and draft heads shipped in the checkpoint. The engine support track marks the same mechanisms arriving later, two implemented and one not yet. The shaded gaps between them are where you run a model your stack only partly understands.
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.

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