<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://remwes0608.github.io/ai-agents-field-notes/feed.xml" rel="self" type="application/atom+xml" /><link href="https://remwes0608.github.io/ai-agents-field-notes/" rel="alternate" type="text/html" /><updated>2026-09-12T16:28:54+00:00</updated><id>https://remwes0608.github.io/ai-agents-field-notes/feed.xml</id><title type="html">AI Agents Field Notes</title><subtitle>Inference tuning, context discipline, and the numbers behind both.</subtitle><author><name>Remigiusz Weska</name></author><entry><title type="html">Cutting an agent’s turn from 150k to 10k tokens</title><link href="https://remwes0608.github.io/ai-agents-field-notes/posts/context-before-the-runtime/" rel="alternate" type="text/html" title="Cutting an agent’s turn from 150k to 10k tokens" /><published>2026-09-12T00:00:00+00:00</published><updated>2026-09-12T00:00:00+00:00</updated><id>https://remwes0608.github.io/ai-agents-field-notes/posts/context-before-the-runtime</id><content type="html" xml:base="https://remwes0608.github.io/ai-agents-field-notes/posts/context-before-the-runtime/"><![CDATA[<p><strong>The obvious way to make a slow agent faster is to make the hardware faster. The cheaper way is to
stop handing it work that did not need to exist.</strong></p>

<p>A turn’s cost is roughly <em>tokens processed</em> × <em>cost per token</em>. Halving the context halves the work
whatever the hardware does, and it is the only one of the two factors you fully control. So before
touching the runtime I went after the tokens: context per turn came down from roughly <strong>150k to
roughly 10k</strong>, with no loss in answer quality.</p>

<p>No single change did that. It was a set of small cuts, and this article shows one of them in full
— <strong>about 35k tokens of tool schemas that almost nothing ever called</strong> — because it is the one with
a clean measurement and a simple fix. The shape of it repeats everywhere else.</p>

<p>This is the half that came first. The runtime tuning that followed is
<a href="/ai-agents-field-notes/posts/dgx-spark-performance/">a separate article</a>, and cutting the context was worth more
than all six of its stages on the number a person actually feels.</p>

<p>Everything below runs on an <strong>NVIDIA DGX Spark (GB10)</strong> serving a 35B sparse MoE through llama.cpp;
the machine and the workload are <a href="/ai-agents-field-notes/about/">on the About page</a>. What matters for this article is the
ratio: a turn reads <strong>9–17 prompt tokens for every token it generates</strong>. Expect different absolute
numbers on other hardware — the shape is what transfers.</p>

<hr />

<h2 id="a-turn-has-two-waits-and-only-one-of-them-is-visible">A turn has two waits, and only one of them is visible</h2>

<p>Both halves of a turn’s cost move with context length, and they move differently.</p>

<p>Generation slows gradually:</p>

<svg viewBox="0 0 700 270" width="100%" role="img" aria-label="Decode throughput against context size: 69 tokens per second at zero context falling to 36 at 150,000.">
  <line x1="60" y1="30" x2="60" y2="230" stroke="#bbb" />
  <line x1="60" y1="230" x2="670" y2="230" stroke="#bbb" />
  <line x1="60" y1="230.0" x2="670" y2="230.0" stroke="#eee" />
  <text x="54" y="234.0" text-anchor="end" font-size="11" fill="#777">0</text>
  <line x1="60" y1="180.0" x2="670" y2="180.0" stroke="#eee" />
  <text x="54" y="184.0" text-anchor="end" font-size="11" fill="#777">20</text>
  <line x1="60" y1="130.0" x2="670" y2="130.0" stroke="#eee" />
  <text x="54" y="134.0" text-anchor="end" font-size="11" fill="#777">40</text>
  <line x1="60" y1="80.0" x2="670" y2="80.0" stroke="#eee" />
  <text x="54" y="84.0" text-anchor="end" font-size="11" fill="#777">60</text>
  <line x1="60" y1="30.0" x2="670" y2="30.0" stroke="#eee" />
  <text x="54" y="34.0" text-anchor="end" font-size="11" fill="#777">80</text>
  <text x="60.0" y="248" text-anchor="middle" font-size="11" fill="#777">0</text>
  <text x="125.5" y="248" text-anchor="middle" font-size="11" fill="#777">16k</text>
  <text x="322.1" y="248" text-anchor="middle" font-size="11" fill="#777">64k</text>
  <text x="660.0" y="248" text-anchor="middle" font-size="11" fill="#777">150k</text>
  <text x="365" y="262" text-anchor="middle" font-size="11" fill="#777">context (tokens)</text>
  <text x="16" y="130" text-anchor="middle" font-size="11" fill="#777" transform="rotate(-90 16 130)">decode t/s</text>
  <polyline points="60.0,56.6 76.4,60.6 125.5,72.7 322.1,106.2 660.0,140.7" fill="none" stroke="#2c6fad" stroke-width="2.5" />
  <circle cx="60.0" cy="56.6" r="3.5" fill="#2c6fad" />
  <text x="67.0" y="49.6" font-size="11" fill="#333">69</text>
  <circle cx="76.4" cy="60.6" r="3.5" fill="#2c6fad" />
  <text x="83.4" y="53.6" font-size="11" fill="#333">68</text>
  <circle cx="125.5" cy="72.7" r="3.5" fill="#2c6fad" />
  <text x="132.5" y="65.7" font-size="11" fill="#333">63</text>
  <circle cx="322.1" cy="106.2" r="3.5" fill="#2c6fad" />
  <text x="329.1" y="99.2" font-size="11" fill="#333">50</text>
  <circle cx="660.0" cy="140.7" r="3.5" fill="#2c6fad" />
  <text x="667.0" y="133.7" font-size="11" fill="#333">36</text>
</svg>

<p><em>Decode rate against context depth. <code class="language-plaintext highlighter-rouge">llama-bench</code>, Qwen3.6-35B-A3B at <code class="language-plaintext highlighter-rouge">f16</code> KV, <code class="language-plaintext highlighter-rouge">tg128</code>, <code class="language-plaintext highlighter-rouge">-r 2</code>,
±0.50 or better.</em></p>

<p>Time before the first token does not slow gradually. It runs away, because the whole prompt has to
be read before anything can be written, and the rate at which it is read drops as it gets longer:</p>

<svg viewBox="0 0 700 270" width="100%" role="img" aria-label="Time before the first token against context size: under two seconds at 4,000 tokens, 90 seconds at 150,000. A five-second patience line is crossed at about 11,000 tokens.">
  <rect x="60" y="30" width="610" height="189.5" fill="#d94f4f" opacity="0.05" />
  <line x1="60" y1="30" x2="60" y2="230" stroke="#bbb" />
  <line x1="60" y1="230" x2="670" y2="230" stroke="#bbb" />
  <line x1="60" y1="230.0" x2="670" y2="230.0" stroke="#eee" />
  <text x="54" y="234.0" text-anchor="end" font-size="11" fill="#777">0</text>
  <line x1="60" y1="166.8" x2="670" y2="166.8" stroke="#eee" />
  <text x="54" y="170.8" text-anchor="end" font-size="11" fill="#777">30 s</text>
  <line x1="60" y1="103.7" x2="670" y2="103.7" stroke="#eee" />
  <text x="54" y="107.7" text-anchor="end" font-size="11" fill="#777">60 s</text>
  <line x1="60" y1="40.5" x2="670" y2="40.5" stroke="#eee" />
  <text x="54" y="44.5" text-anchor="end" font-size="11" fill="#777">90 s</text>
  <text x="60.0" y="248" text-anchor="middle" font-size="11" fill="#777">0</text>
  <text x="125.5" y="248" text-anchor="middle" font-size="11" fill="#777">16k</text>
  <text x="322.1" y="248" text-anchor="middle" font-size="11" fill="#777">64k</text>
  <text x="660.0" y="248" text-anchor="middle" font-size="11" fill="#777">150k</text>
  <text x="365" y="262" text-anchor="middle" font-size="11" fill="#777">context (tokens)</text>
  <text x="16" y="130" text-anchor="middle" font-size="11" fill="#777" transform="rotate(-90 16 130)">before first token</text>
  <line x1="106.0" y1="30" x2="106.0" y2="230" stroke="#d94f4f" stroke-width="1.5" stroke-dasharray="5 4" />
  <text x="114.0" y="44" font-size="11.5" fill="#d94f4f" font-weight="600">~11k tokens</text>
  <text x="114.0" y="58" font-size="11" fill="#d94f4f">patience runs out here</text>
  <line x1="60" y1="219.5" x2="670" y2="219.5" stroke="#d94f4f" stroke-width="1.5" stroke-dasharray="5 4" />
  <text x="64" y="213.5" font-size="11" fill="#d94f4f">5 s</text>
  <polyline points="60.0,230.0 76.4,226.4 125.5,214.9 322.1,161.5 660.0,40.7" fill="none" stroke="#2c6fad" stroke-width="2.5" />
  <circle cx="76.4" cy="226.4" r="3.5" fill="#2c6fad" />
  <circle cx="125.5" cy="214.9" r="3.5" fill="#2c6fad" /><text x="133.5" y="206.9" font-size="11" fill="#333">7</text>
  <circle cx="322.1" cy="161.5" r="3.5" fill="#2c6fad" /><text x="330.1" y="153.5" font-size="11" fill="#333">33</text>
  <circle cx="660.0" cy="40.7" r="3.5" fill="#2c6fad" /><text x="668.0" y="32.7" font-size="11" fill="#333">90</text>
  <circle cx="106.0" cy="219.5" r="4" fill="#d94f4f" />
</svg>

<p><em>The same model and settings, prompt-processing rate turned into wall-clock. Measured <code class="language-plaintext highlighter-rouge">pp</code> rates:
2,389 t/s at 4k, 2,288 at 16k, 1,997 at 64k, 1,647 at 150k, ±20 or better.</em></p>

<p>Over the same range decode loses about <strong>half</strong> its rate. Time to first token goes from under two
seconds to <strong>more than a minute and a half</strong> — better than fifty times worse. One of these is a
curve; the other is a cliff.</p>

<hr />

<h2 id="five-seconds-is-not-folklore-here-it-is-the-reporting-interval">Five seconds is not folklore here, it is the reporting interval</h2>

<p>The agent buys patience by showing its work: its Discord presence updates as it picks a tool, calls
it, reads the result back. A person will wait a long time while something is visibly happening, and
gives up quickly when nothing is. <strong>Cancellations come from silence rather than from slowness.</strong></p>

<p>That is what makes the second curve worse than it looks. Decode is reportable — tokens are
arriving, rounds are completing, the presence has something to say and keeps saying it. Prefill is
dead air. No token exists yet, no tool has been called, nothing has happened that can be described,
and the only honest thing to display is a spinner.</p>

<p>Five seconds of patience buys about <strong>eleven thousand tokens</strong> of context on this machine. A turn
that fits in ten thousand answers before anyone gives up on it. A turn carrying a hundred and fifty
thousand has lost the room before it begins — ninety seconds of spinner is not a slow answer, it is
an answer nobody stayed for.</p>

<p>That is the whole of the target. Not a token budget chosen for tidiness: the largest prompt that
still starts talking inside one reporting interval.</p>

<p><strong>The automated half of the workload has no reporting interval and arrives at the same discipline
anyway.</strong> Nobody watches an event-driven turn. Somebody is still waiting on the deployment and the
automated smoke-test results behind it, and that answer lands at the end of a chain of orchestrated
steps rather than at a first token — each step’s prefill compounding into one total that is
invisible until it is over.</p>

<p>And those turns share the runtime with the watched ones. A slot occupied by a
hundred-and-fifty-thousand-token prompt is a slot the next event queues behind, so an automated
turn’s context is charged twice: once to itself, and once to everything waiting on it. Human
patience and machine throughput turn out to be two arguments for the same budget.</p>

<hr />

<h2 id="the-clearest-example-the-tool-catalogue">The clearest example: the tool catalogue</h2>

<p><strong>The reduction came from a dozen places, none of them dramatic.</strong> The agent’s identity and
formatting instructions. What it is allowed to remember, and for how long. The event rules and the
signal selectors that decide what an automated turn is even shown. The conversation history policy.
Each was worth a few thousand tokens; each took a while to get right; none of them is interesting
on its own, and listing them all would be a different and worse article.</p>

<p>The tool catalogue is the one worth showing in full. It is the largest single item, it has a clean
measurement, and the fix turned out to be simple — which makes it the best illustration of the
thing they all have in common: <strong>a cost that is invisible until someone goes looking for it.</strong></p>

<p>What it was not, in any of these cases, was the conversation.</p>

<p>A single real turn, question <em>“what are you?”</em>, measured against the deployed model’s own
tokenizer:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>tokens</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>whole prompt</td>
      <td>36,670</td>
    </tr>
    <tr>
      <td>the conversation in it</td>
      <td>~1,900</td>
    </tr>
    <tr>
      <td><strong>tool declarations</strong></td>
      <td><strong>~34,800</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>95% of that prompt was schemas.</strong> The question was four words. That ratio is one prompt on one
day, not a constant — but every turn that day paid some version of it.</p>

<p>Four MCP servers had been connected over time, each one individually reasonable:</p>

<table>
  <thead>
    <tr>
      <th>server</th>
      <th>tools declared</th>
      <th>tokens</th>
      <th>tokens per tool</th>
      <th>tools ever called</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>container registry</td>
      <td>44</td>
      <td>18,119</td>
      <td>411</td>
      <td>4</td>
    </tr>
    <tr>
      <td>Kubernetes</td>
      <td>23</td>
      <td>5,699</td>
      <td>247</td>
      <td>16</td>
    </tr>
    <tr>
      <td>metrics</td>
      <td>68</td>
      <td>—</td>
      <td>—</td>
      <td>9</td>
    </tr>
    <tr>
      <td>chat (in-process)</td>
      <td>18</td>
      <td>—</td>
      <td>—</td>
      <td>8</td>
    </tr>
    <tr>
      <td><strong>total</strong></td>
      <td><strong>153</strong></td>
      <td><strong>~34,800</strong></td>
      <td> </td>
      <td><strong>37</strong></td>
    </tr>
  </tbody>
</table>

<p><em>Two servers were not reachable for direct tokenization; their share is the residual.</em></p>

<p>Against 137 recorded sessions carrying <strong>603 tool calls across 37 distinct tools</strong> — and <strong>348 of
those 603 calls were three tools</strong>. A hundred and sixteen declared tools were never called once,
and every one of them was charged on every round of every turn.</p>

<p><strong>This is the trap, and it is structural rather than careless.</strong> Connecting an MCP server is one
line of configuration, and the cost lands somewhere nobody is looking — not in a log, not in a
latency graph, but spread evenly across every future prompt. The server that cost 18,119 tokens a
turn was doing its job correctly.</p>

<p>The fix follows from the numbers: declare a small hot set and put the rest behind a lookup the
model can call when it needs something outside it. Replaying the corpus against candidate hot sets
says where to stop:</p>

<table>
  <thead>
    <tr>
      <th>declared hot set</th>
      <th>sessions needing a lookup</th>
      <th>extra rounds per session</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>96.6%</td>
      <td>2.42</td>
    </tr>
    <tr>
      <td>8</td>
      <td>40.7%</td>
      <td>0.73</td>
    </tr>
    <tr>
      <td>12</td>
      <td>19.5%</td>
      <td>0.37</td>
    </tr>
    <tr>
      <td><strong>20</strong></td>
      <td><strong>8.5%</strong></td>
      <td><strong>0.11</strong></td>
    </tr>
  </tbody>
</table>

<p>Twenty tools covers nine sessions in ten outright, and costs the tenth about a tenth of a round.
That is what runs in production now.</p>

<hr />

<h2 id="the-cache-will-not-save-you">The cache will not save you</h2>

<p>The obvious objection is that none of this should matter, because a prompt cache means the prefill
is paid once.</p>

<p>It pays within a turn and mostly not across turns. Later rounds of one turn resend a byte-identical
prefix and prefill only the newly appended tool result, which is why round one is the expensive one
and the rest are not. But <strong>automation assembles a different prompt for every event</strong>, so there is
rarely a prefix to reuse; a chat session stays warm only briefly, and the next question may have
nothing to do with the last.</p>

<p>Keeping sessions warm long enough to help means carrying one question’s context into the next,
which trades prefill time for the risk of answering the wrong thing. Most turns open cold and pay
round one in full.</p>

<p>There is a second reason to care, and it is the one that bites quietly. Prefix matching ends at the
first differing byte, so anything that varies per turn and sits near the front of the prompt
invalidates everything behind it — and a tool catalogue is exactly the kind of thing that gets
rewritten in place.</p>

<hr />

<h2 id="lessons-learned">Lessons learned</h2>

<p><strong>Cut the context before tuning the machine.</strong> Tuning first means optimising the hardware to
process tokens that did not need to exist. The order is not a preference: context reduction is
multiplicative with everything the runtime does afterwards, and nothing the runtime does afterwards
reduces the token count.</p>

<p><strong>Expect a dozen cuts, not one.</strong> The reduction came from half a dozen unrelated places, none of
them dramatic, and the largest was about a third of it. Looking for the single big win would have
found nothing.</p>

<p><strong>Measure what is in the prompt before assuming it is the conversation.</strong> In the prompt measured
here it was 95% schemas and 5% conversation, and no instrument on the box reported that. A turn’s
token count is visible; its composition is not, and only the composition tells you what to cut.</p>

<p><strong>An MCP server’s cost is not paid where you connect it.</strong> It is one configuration line, and it is
charged on every round of every turn from then on, whether or not anything calls it. Connecting is
cheap and reversible; the bill is neither.</p>

<p><strong>Declare the tools that get used and reach the rest through a lookup.</strong> Most of a declared
catalogue is never called, and what is called concentrates hard. A small hot set with a lookup
behind it covers nearly every session and costs a fraction of a round on the rare miss.</p>

<p><strong>Time to first token is the number a person feels, and it is the one that runs away.</strong> Over the
same context range decode loses half its rate while the wait before anything appears gets more than
fifty times worse. Tokens per second is the figure everyone quotes and the milder of the two.</p>

<p><strong>Pick the budget from the workload, not from the model.</strong> Ten thousand tokens is what fits inside
one reporting interval on this hardware, and the automated half of the workload lands on the same
number for its own reason. Change the machine or the interface and the number moves. The model has
nothing to do with it.</p>]]></content><author><name>Remigiusz Weska</name></author><summary type="html"><![CDATA[Cutting an LLM agent's context from 150k to 10k tokens to get the best user experience out of an NVIDIA DGX Spark.]]></summary></entry><entry><title type="html">Tuning a 26B MoE agent on a DGX Spark to over 100 t/s</title><link href="https://remwes0608.github.io/ai-agents-field-notes/posts/dgx-spark-performance/" rel="alternate" type="text/html" title="Tuning a 26B MoE agent on a DGX Spark to over 100 t/s" /><published>2026-09-10T00:00:00+00:00</published><updated>2026-09-10T00:00:00+00:00</updated><id>https://remwes0608.github.io/ai-agents-field-notes/posts/dgx-spark-performance</id><content type="html" xml:base="https://remwes0608.github.io/ai-agents-field-notes/posts/dgx-spark-performance/"><![CDATA[<p><strong>How much can you get out of a DGX Spark before deciding it is not enough to run an agent on —
and why chasing tokens per second should not always be the goal?</strong></p>

<p>The machine and the workload are <a href="/ai-agents-field-notes/about/">on the About page</a>: a DGX Spark (GB10) with 128 GB of
unified memory and no separate VRAM, serving an infrastructure agent. <strong>Bandwidth is the only
constraint that matters here</strong>, and every number below is downstream of it.</p>

<p>It started at roughly <strong>45 t/s</strong>. It now streams <strong>over 100 t/s</strong> on the turns that make up most of
the work, spikes to <strong>138 t/s</strong> on a single prompt, and reaches roughly <strong>280 t/s</strong> in aggregate
across sixteen concurrent streams. Two phases got it there: cutting the context a turn actually
needs, then six changes to the runtime — one flag or one file at a time, each measured before the
next.</p>

<p><strong>Real context sizes and distinct prompts are what make these numbers different — and worth far
more.</strong> The figures published online for this hardware, as far as I could find them, come from one
fixed prompt at a short static context, with concurrency measured on that same prompt fired N times
at once — which measures how cheaply one request can be duplicated, not how the box handles N
different ones. Measuring the way the agent actually runs makes every number here smaller, and it
is why they predicted production.</p>

<p><strong>This is not a “add these five flags and you get 100 t/s” post</strong>, because no such answer exists.
What to change depends on what you run and what you run it for. Here it is agentic work with a mix
of small and medium contexts, where a sparse MoE in the 26–35B class fits the machine with room to
spare, and where the levers that paid were <strong>packaging, speculative decoding, cache precision and
parallel slots</strong>. Your workload may hand you a different set. What should transfer is the method:
what each number means, how to measure it, and why a figure quoted without its context length is
not a measurement. With that, you should be able to reach something similar here — or better.</p>

<p>Nothing below is a guess. Every figure comes from <code class="language-plaintext highlighter-rouge">llama-bench</code> with error bars, from the server’s
own journal under real traffic, or — for the bus and for where a pass spends its time — from a
buffer benchmark and a profiler. Each one is quoted with the metric it came from and the context
length it was taken at.</p>

<h2 id="one-configuration-three-honest-numbers">One configuration, three honest numbers</h2>

<p>Before any of the tuning, a caveat that governs how every figure here should be read — and that
explains why published benchmarks and production experience so often disagree.</p>

<p>The same server, the same model, the same flags, measured three ways:</p>

<table>
  <thead>
    <tr>
      <th>what was measured</th>
      <th>result</th>
      <th>n</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>single prompt on an idle box, with speculation</td>
      <td><strong>105–138 t/s</strong></td>
      <td>3 prompts</td>
    </tr>
    <tr>
      <td>real agent turns under 8k context</td>
      <td><strong>101.4 t/s</strong> streaming median</td>
      <td>348 samples</td>
    </tr>
    <tr>
      <td>decode at 64k context</td>
      <td><strong>55.0 t/s</strong></td>
      <td>±0.09</td>
    </tr>
  </tbody>
</table>

<p><em>Row 2 is <code class="language-plaintext highlighter-rouge">tg_3s</code>, the streaming rate — see <a href="#methodology-how-the-numbers-were-taken">Methodology</a>.
The same turns measured as a per-request average (<code class="language-plaintext highlighter-rouge">eval time</code>) give 90.7 t/s, n=59. Two metrics
of one state, not a before and after.</em></p>

<p><strong>Nothing is inconsistent here.</strong> A 2.5× spread across one configuration comes almost entirely from
<strong>context length</strong>, and that is the variable most published figures leave unstated.</p>

<p>This matters for reading anyone’s numbers, including these. A widely quoted figure for this model
on this hardware is <strong>108.78 t/s</strong> under vLLM with speculation, taken single-stream on a server
capped at 4,096 tokens of context. That is not my workload, and on its own it would not have made
the agent usable — but it is what started this, because it said the hardware had more in it than
the 45 t/s I was getting. The comparable measurement here, taken the same way, is <strong>105–138
t/s</strong>; the 64k figure of 55.0 is not a worse result, it is a different question, and it is the one
nobody publishes.</p>

<p><strong>Which number describes my agent?</strong> Mostly the middle one. Event processing and rule execution
run under 8k context, and a plain status question is a few thousand tokens. The 64k case is real —
long log investigations reach it — but it is the tail, not the day. That is why the tuning below
targets both ends and reports both.</p>

<hr />

<h2 id="methodology-how-the-numbers-were-taken">Methodology: how the numbers were taken</h2>

<p>The tuning runs in <strong>six stages</strong>, one change at a time, each measured before the next was made:</p>

<ul>
  <li><strong>Stage 1, baseline</strong> — Google’s QAT release, <code class="language-plaintext highlighter-rouge">Q4_0</code>, as shipped.</li>
  <li><strong>Stage 2, packaging</strong> — the same quantization format from a different packer.</li>
  <li><strong>Stage 3, speculation</strong> — multi-token prediction, drafting four tokens per pass.</li>
  <li><strong>Stage 4, parallelism</strong> — scaling one box to concurrent work, and what it costs.</li>
  <li><strong>Stage 5, cache precision</strong> — <code class="language-plaintext highlighter-rouge">q8_0</code> → <code class="language-plaintext highlighter-rouge">f16</code> on the KV cache.</li>
  <li><strong>Stage 6, generalisation</strong> — the same sweep against two other vendors’ models.</li>
</ul>

<p>Then one number was still missing, so the tuning stops and a profiler takes over.</p>

<p>The rest of this section is the setup behind them: where the evidence comes from, which three
throughput figures the server reports and what each of them answers, the tools, the build, and the
one unit everything is measured against.</p>

<h3 id="the-journal--where-the-evidence-comes-from">The journal — where the evidence comes from</h3>

<p>Most of the figures below are quoted from <strong>the journal</strong>. <code class="language-plaintext highlighter-rouge">llama-server</code> runs as a systemd user
unit, so everything it writes to stdout is captured by systemd’s log store and read back with
<code class="language-plaintext highlighter-rouge">journalctl</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>journalctl --user -u llm-server -f          # follow live
journalctl --user -u llm-server -S 09:29   # a window
</code></pre></div></div>

<p>At <code class="language-plaintext highlighter-rouge">-lv 3</code> the server emits a <code class="language-plaintext highlighter-rouge">print_timing</code> line for <strong>every request it completes</strong>. That is the
whole appeal: it is the server reporting on real traffic, continuously, with no benchmark harness
in the path and nothing to set up. Eight minutes of ordinary agent use produces a few hundred
labelled measurements for free.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Sep 10 09:29:38 gb10 start-llm-poc.sh[592816]: 6.35.144.849 I slot print_timing: id  1 | task 0 | n_gen =    286, tg =  94.36 t/s, tg_3s =  94.68 t/s
Sep 10 09:29:39 gb10 start-llm-poc.sh[592816]: 6.35.657.294 I slot print_timing: id  1 | task 0 | prompt eval time =    4637.24 ms / 13516 tokens (    0.34 ms per token,  2914.66 tokens per second)
Sep 10 09:29:39 gb10 start-llm-poc.sh[592816]: 6.35.657.297 I slot print_timing: id  1 | task 0 |        eval time =    3532.91 ms /   334 tokens (   10.61 ms per token,    94.26 tokens per second)
Sep 10 09:29:41 gb10 start-llm-poc.sh[592816]: 6.38.266.322 I slot print_timing: id  1 | task 112 | prompt eval time =     280.09 ms /    93 tokens (    3.01 ms per token,   332.04 tokens per second)
Sep 10 09:29:41 gb10 start-llm-poc.sh[592816]: 6.38.266.326 I slot print_timing: id  1 | task 112 |        eval time =    1730.32 ms /   156 tokens (   11.16 ms per token,    89.58 tokens per second)
</code></pre></div></div>

<p>Two fields matter when reading these. <strong><code class="language-plaintext highlighter-rouge">id</code></strong> is the slot, one of the <code class="language-plaintext highlighter-rouge">-np</code> parallel slots.
<strong><code class="language-plaintext highlighter-rouge">task</code></strong> is the request, and it ties the lines together: one task emits a <code class="language-plaintext highlighter-rouge">prompt eval</code> line and
an <code class="language-plaintext highlighter-rouge">eval time</code> line when it finishes, plus an <code class="language-plaintext highlighter-rouge">n_gen</code> line whenever the server samples a generation
still in flight.</p>

<p>The excerpt is one turn of real work — task 0 ingests a 13,516-token prompt, task 112 is the next
round of the same conversation and ingests only the 93 new tokens of tool output. Which is where
the three numbers come in.</p>

<h3 id="three-numbers-three-questions">Three numbers, three questions</h3>

<p><code class="language-plaintext highlighter-rouge">llama-server</code> at <code class="language-plaintext highlighter-rouge">-lv 3</code> reports three throughput numbers per request. They answer different
questions, and treating them as interchangeable produces wrong conclusions.</p>

<table>
  <thead>
    <tr>
      <th>metric</th>
      <th>the question it answers</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>prompt eval t/s</strong></td>
      <td>How long before the first token appears.<br /><em>Prefill — ingesting the prompt.</em></td>
    </tr>
    <tr>
      <td><strong>eval time t/s</strong></td>
      <td>Which configuration is faster.<br /><em>Decode, averaged over one whole generation, reported at the end.</em></td>
    </tr>
    <tr>
      <td><strong>n_gen <code class="language-plaintext highlighter-rouge">tg</code> / <code class="language-plaintext highlighter-rouge">tg_3s</code></strong></td>
      <td>What streaming actually feels like.<br /><em>Decode sampled during generation; <code class="language-plaintext highlighter-rouge">tg</code> running, <code class="language-plaintext highlighter-rouge">tg_3s</code> a trailing 3-second window.</em></td>
    </tr>
  </tbody>
</table>

<p>Each has a trap.</p>

<p><strong>prompt eval t/s is meaningless without its token count.</strong> The rate scales with how much there is
to process, because a small batch never amortizes the weight read. Four tasks from the same window
as the excerpt above:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>task    0  prompt  13516 tokens @  2915 t/s
task  112  prompt     93 tokens @   332 t/s
task  237  prompt     76 tokens @   328 t/s
task  638  prompt   1083 tokens @  1918 t/s
</code></pre></div></div>

<p>Those are not four speeds. They are one speed over four batch sizes. And the token counts show
something else: <strong>prompt caching means a turn pays full prefill once.</strong> Round 1 ingests 13,516
tokens; later rounds process only the new tool output.</p>

<p><strong>eval time t/s is a per-request average</strong>, folding in the ramp-up and reporting one figure however
long the generation ran. Best for comparing configurations, because each request contributes
equally.</p>

<p><strong><code class="language-plaintext highlighter-rouge">tg_3s</code> is the closest thing to perceived speed</strong>, emitted several times during a long
generation — so long rounds contribute more samples than short ones. A feature when you want to
know how it feels, a bias when you want to compare configs.</p>

<h3 id="tools">Tools</h3>

<p><strong><code class="language-plaintext highlighter-rouge">llama-bench</code> for anything a flag controls.</strong> It ships with llama.cpp: <code class="language-plaintext highlighter-rouge">-d</code> prefills to a context
depth before measuring generation, <code class="language-plaintext highlighter-rouge">-r</code> repeats and reports a standard deviation, comma-separated
values sweep a parameter, and <code class="language-plaintext highlighter-rouge">pp</code> and <code class="language-plaintext highlighter-rouge">tg</code> print as separate rows so the two can never be
conflated. It stamps the build hash on its output.</p>

<p>It cannot do speculation — there are no draft-model flags — so <strong>MTP figures come from the server
journal instead</strong>. That is the dividing line used throughout: <code class="language-plaintext highlighter-rouge">llama-bench</code> for quantization, cache
type, depth, batch and threads; the journal for MTP, the chat template, and real turns.</p>

<p><strong><code class="language-plaintext highlighter-rouge">nsys</code> for the one question a flag cannot answer</strong>, at the end: where the time goes inside a pass.
Nsight Systems traces CUDA calls and kernels against the host thread that issues them, which is what
separates “the GPU is slow” from “the GPU is idle waiting for the CPU”. Nothing above needed it;
<a href="#where-the-engines-20-points-went">the last unexplained number</a> did.</p>

<h3 id="the-build">The build</h3>

<p>Every figure here comes from one binary, and on this hardware the build configuration matters as
much as the runtime flags. Reconstructed from the CMake cache — the flags are exact, the line is
assembled from them:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cmake <span class="nt">-B</span> build <span class="se">\</span>
  <span class="nt">-DCMAKE_BUILD_TYPE</span><span class="o">=</span>Release <span class="se">\</span>
  <span class="nt">-DGGML_CUDA</span><span class="o">=</span>ON <span class="se">\</span>
  <span class="nt">-DCMAKE_CUDA_ARCHITECTURES</span><span class="o">=</span>121a-real <span class="se">\</span>
  <span class="nt">-DGGML_CUDA_FA</span><span class="o">=</span>ON <span class="nt">-DGGML_CUDA_FA_ALL_QUANTS</span><span class="o">=</span>ON <span class="se">\</span>
  <span class="nt">-DGGML_CUDA_GRAPHS</span><span class="o">=</span>ON <span class="se">\</span>
  <span class="nt">-DGGML_CPU_ARM_ARCH</span><span class="o">=</span><span class="s2">"armv9.2-a+dotprod+fp16+i8mm+bf16+sve2"</span> <span class="se">\</span>
  <span class="nt">-DGGML_CPU_REPACK</span><span class="o">=</span>ON <span class="se">\</span>
  <span class="nt">-DCMAKE_C_COMPILER</span><span class="o">=</span>gcc-14 <span class="nt">-DCMAKE_CXX_COMPILER</span><span class="o">=</span>g++-14
cmake <span class="nt">--build</span> build <span class="nt">--config</span> Release <span class="nt">-j</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>llama.cpp</td>
      <td><code class="language-plaintext highlighter-rouge">67672dc5b</code>, tag <code class="language-plaintext highlighter-rouge">b10850-1</code>, build <strong>10851</strong></td>
    </tr>
    <tr>
      <td>CUDA</td>
      <td>13.0, <code class="language-plaintext highlighter-rouge">V13.0.88</code></td>
    </tr>
    <tr>
      <td>compiler</td>
      <td>gcc/g++ 14.2.0</td>
    </tr>
    <tr>
      <td>BLAS</td>
      <td>off — CUDA handles the matmuls</td>
    </tr>
  </tbody>
</table>

<p>Three of those are specific to this machine rather than defaults.</p>

<p><strong><code class="language-plaintext highlighter-rouge">CMAKE_CUDA_ARCHITECTURES=121a-real</code></strong> is the one to get right. GB10 is compute capability 12.1;
the <code class="language-plaintext highlighter-rouge">a</code> suffix enables the architecture-specific instructions Blackwell adds, and <code class="language-plaintext highlighter-rouge">-real</code> emits
only the real target instead of also carrying PTX for JIT. Build for a generic architecture and the
fast paths are simply not compiled in — the binary works and is slower, with nothing in the logs to
say why.</p>

<p><strong><code class="language-plaintext highlighter-rouge">GGML_CPU_ARM_ARCH=armv9.2-a+…+sve2</code></strong> targets the Cortex-X925/A725 cores rather than a baseline
ARMv8, which matters for the CPU-side work around the GPU kernels.</p>

<p><strong><code class="language-plaintext highlighter-rouge">GGML_CUDA_FA_ALL_QUANTS=ON</code></strong> compiles flash-attention kernels for every quantized KV type, not
just the common ones. Every stage below runs <code class="language-plaintext highlighter-rouge">-fa 1</code>, and Stage 5 compares KV cache types directly
— without this, some of those combinations would fall back to a slower path and the comparison
would measure the build rather than the format.</p>

<p>Every benchmark below ran with <strong>all services stopped</strong> — two large-context servers resident on one
128 GB box change each other’s numbers. The one exception is the bus benchmark in the next section,
which turns out to need the opposite and says why.</p>

<h3 id="the-unit-everything-is-measured-against">The unit everything is measured against</h3>

<p>Decode reads weights across the memory bus, so the bus is the limit. Both halves of that were
measured rather than assumed.</p>

<p><strong>What the bus delivers</strong>, from a buffer benchmark with no model involved:</p>

<table>
  <thead>
    <tr>
      <th>operation</th>
      <th>GB/s</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>read-only (reduction)</td>
      <td><strong>~250</strong></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">copy</code> (read + write)</td>
      <td>~230</td>
    </tr>
    <tr>
      <td>triad (2 read + 1 write)</td>
      <td>~230</td>
    </tr>
  </tbody>
</table>

<p><em>4 GiB buffers, best of twelve runs, medians within a fraction of a percent of best. Read-only is
the figure that matters — decode reads weights rather than copying them — at roughly <strong>250 GB/s,
about 90% of the 273 GB/s spec sheet</strong>.</em></p>

<p><strong>This is the one benchmark that must not be run on an empty machine.</strong> Read-only bandwidth here
moves by around 8% with how much memory is already allocated: with nothing resident this box reads
about 240 GB/s, with tens of GiB resident about 250–260. It is not contention — the effect
reproduces against a process that only allocates and sleeps, running no kernels at all — and power
draw and SM clock are unchanged, so it is not frequency scaling either. Most likely it is how the
benchmark’s own buffers get backed once the machine is full, but that part is a hypothesis; the
effect itself reverses on demand.</p>

<p>Decode always runs with the model resident — which is what “idle box” means everywhere else in this
post: no competing services, but the weights loaded. So ~250 is what this machine really offers a
running model, and measuring the bus on a genuinely empty one understates it. Expect different
absolute numbers on other hardware; the direction and the rough size are what transfers.</p>

<p><strong>What a token costs</strong>, from the GGUF header rather than the “A4B” label:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  file total 13.26 GiB
    experts (sparse)         11.96 GiB
    attention (always)        0.58 GiB
    embeddings/output         0.39 GiB
    dense ffn (always)        0.32 GiB

  per token, with 8/128 experts routed:
    always-active           1.29 GiB   (63% of the read)
    routed experts          0.75 GiB
    PER FORWARD PASS        2.04 GiB
</code></pre></div></div>

<p>Two things fall out. The always-active path is <strong>63% of what a token reads</strong> — at batch 1 the
sparse experts are the <em>minority</em> component. And the whole model of decode becomes:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bytes per pass  =  2.04 GiB (weights)  +  KV × context
passes/second   =  achievable bandwidth / bytes per pass
throughput      =  passes/second × tokens retired per pass
</code></pre></div></div>

<p>Only the last factor can be moved. Bandwidth is not yours to tune and the weights are fixed, so
every stage below is an attempt to raise tokens-per-pass or reduce bytes-per-pass.</p>

<hr />

<h2 id="the-stages">The stages</h2>

<p>In the order they were made. Each opens with what it won or did not, then gives its configuration
and its evidence.</p>

<p>The absolute numbers below are this box on these days. On yours they will differ — quite possibly
upward, since a good deal of what made these slow was mine to fix. What should carry across is the
direction of each change and roughly what it was worth.</p>

<h3 id="stage-1--the-baseline-googlegemma-4-26b-a4b-it-qat-q4_0">Stage 1 — the baseline: <code class="language-plaintext highlighter-rouge">google/gemma-4-26B-A4B-it-qat-q4_0</code></h3>

<p><strong>Not a win — the reference.</strong> 77.92 t/s at zero context, 35.28 at 64k, and a real-turn median of
50.2 with 6% of requests below 40 t/s. Every later number is measured against these.</p>

<p>Google’s QAT release, quantized to Q4_0 by the people who trained it.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">MODEL_NAME</span><span class="o">=</span>google/gemma-4-26B-A4B-it-qat-q4_0-gguf:Q4_0

llama-server <span class="se">\</span>
  <span class="nt">-hf</span> <span class="k">${</span><span class="nv">MODEL_NAME</span><span class="k">}</span> <span class="nt">-lv</span> 3 <span class="se">\</span>
  <span class="nt">-ngl</span> 999 <span class="nt">-fa</span> 1 <span class="nt">-c</span> 200000 <span class="nt">-np</span> 1 <span class="nt">-ub</span> 2048 <span class="nt">--no-mmap</span> <span class="se">\</span>
  <span class="nt">-ctk</span> q8_0 <span class="nt">-ctv</span> q8_0 <span class="nt">--no-mmproj</span> <span class="nt">-t</span> 10 <span class="nt">--jinja</span> <span class="se">\</span>
  <span class="nt">--host</span> 0.0.0.0 <span class="nt">--port</span> 30000
</code></pre></div></div>

<p><strong>llama-bench</strong></p>

<table>
  <thead>
    <tr>
      <th>depth</th>
      <th>prefill t/s</th>
      <th>decode t/s</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>3060.05 ± 119.05</td>
      <td>77.92 ± 0.22</td>
    </tr>
    <tr>
      <td>4,096</td>
      <td>2893.03 ± 51.50</td>
      <td>71.65 ± 0.20</td>
    </tr>
    <tr>
      <td>16,384</td>
      <td>2506.60 ± 39.42</td>
      <td>58.61 ± 0.11</td>
    </tr>
    <tr>
      <td>65,536</td>
      <td>1634.02 ± 28.96</td>
      <td><strong>35.28 ± 0.04</strong></td>
    </tr>
  </tbody>
</table>

<p><em><code class="language-plaintext highlighter-rouge">-p 512 -n 128 -r 3 -fa on -ngl 999 -t 10</code>, build <code class="language-plaintext highlighter-rouge">67672dc5b (10851)</code>, idle box.</em></p>

<p><strong>Journal, real turns</strong></p>

<p>Median <strong>50.2 t/s</strong> over 650 requests, p10 43.4, and <strong>6% of requests below 40 t/s</strong>. That tail is
the part a person notices.</p>

<p>The gap between 77.92 on a synthetic prompt and 50.2 on real traffic is not a discrepancy — real
turns carry long context and reasoning-heavy output. The two numbers answer different questions,
and the rest of this article keeps them apart.</p>

<h3 id="stage-2--the-same-model-a-different-packer">Stage 2 — the same model, a different packer</h3>

<p><strong>A win for free: +8.1% at zero context, +4.4% at 64k, for changing who packed the file.</strong></p>

<p><code class="language-plaintext highlighter-rouge">unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL</code>. Same weights, same quantization format, and
<strong>every other flag identical</strong>, including <code class="language-plaintext highlighter-rouge">-ctk q8_0</code>.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">MODEL_NAME</span><span class="o">=</span>unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL
<span class="c"># flags unchanged from Stage 1</span>
</code></pre></div></div>

<p>The filename says Q4_K. The GGUF header says otherwise:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>general.name = "Gemma-4 26B-A4B IT (smart Q4_0, QAT-lossless)"
  F32     392 tensors
  Q4_0    266 tensors      &lt;- not one Q4_K tensor in the file
</code></pre></div></div>

<p><strong>It is Q4_0 throughout</strong>, with the <code class="language-plaintext highlighter-rouge">UD-…_XL</code> name carried over from unsloth’s Dynamic scheme.
Google’s own file is 265 Q4_0 tensors plus one Q6_K. The two builds use the <em>same</em> format,
differing only in which tensors stay F32 and how the QAT weights were packed — so this stage is not
a quantization change at all, it is a packing change.</p>

<p><strong>llama-bench</strong></p>

<table>
  <thead>
    <tr>
      <th>depth</th>
      <th>Stage 1 (google)</th>
      <th>Stage 2 (unsloth)</th>
      <th>gain</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>77.92</td>
      <td><strong>84.24 ± 0.19</strong></td>
      <td>+8.1%</td>
    </tr>
    <tr>
      <td>4,096</td>
      <td>71.65</td>
      <td><strong>76.91 ± 0.24</strong></td>
      <td>+7.3%</td>
    </tr>
    <tr>
      <td>16,384</td>
      <td>58.61</td>
      <td><strong>62.51 ± 0.10</strong></td>
      <td>+6.7%</td>
    </tr>
    <tr>
      <td>65,536</td>
      <td>35.28</td>
      <td><strong>36.82 ± 0.07</strong></td>
      <td>+4.4%</td>
    </tr>
  </tbody>
</table>

<p><em>Both <code class="language-plaintext highlighter-rouge">-ctk q8_0 -ctv q8_0</code>, identical flags, same build, same box.</em></p>

<p>Prefill is unchanged within noise (3060 vs 3084 at d0), and that is what identifies the cause:
this is a decode-path difference, and decode is where bytes-per-pass lives. The unsloth file is
13.26 GiB against google’s 13.43 — slightly fewer bytes to read per token.</p>

<p><strong>The gain shrinks with depth</strong>, from 8.1% to 4.4%, and that is the model working as described: at
depth the KV term grows while the weights term stays fixed, so a weights-side saving matters
proportionally less.</p>

<h3 id="stage-3--speculation-multi-token-prediction">Stage 3 — speculation: multi-token prediction</h3>

<p><strong>The largest win on real traffic: the median turn goes 50.2 → 72.7 t/s, about 1.45×, and the slow
tail disappears entirely.</strong></p>

<p>If you cannot read fewer bytes, retire more tokens per read. MTP drafts several tokens and verifies
them in one forward pass.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">MODEL_NAME</span><span class="o">=</span>unsloth/gemma-4-26B-A4B-it-qat-GGUF:UD-Q4_K_XL
<span class="nv">DRAFT_NAME</span><span class="o">=</span>unsloth/gemma-4-26B-A4B-it-qat-GGUF:Q8_0   <span class="c"># mtp-gemma-4-26B-A4B-it.gguf, ~462 MB</span>

llama-server <span class="se">\</span>
  <span class="nt">-hf</span> <span class="k">${</span><span class="nv">MODEL_NAME</span><span class="k">}</span> <span class="nt">-hfd</span> <span class="k">${</span><span class="nv">DRAFT_NAME</span><span class="k">}</span> <span class="nt">-lv</span> 3 <span class="se">\</span>
  <span class="nt">--spec-type</span> draft-mtp <span class="nt">--spec-draft-n-max</span> 4 <span class="se">\</span>
  <span class="nt">-ngl</span> 999 <span class="nt">-fa</span> 1 <span class="nt">-c</span> 200000 <span class="nt">-np</span> 1 <span class="nt">-ub</span> 2048 <span class="nt">--no-mmap</span> <span class="se">\</span>
  <span class="nt">-ctk</span> q8_0 <span class="nt">-ctv</span> q8_0 <span class="nt">--no-mmproj</span> <span class="nt">-t</span> 10 <span class="nt">--jinja</span> <span class="se">\</span>
  <span class="nt">--host</span> 0.0.0.0 <span class="nt">--port</span> 30001
</code></pre></div></div>

<p>Two flags do the work: <code class="language-plaintext highlighter-rouge">-hfd</code> fetches the draft head from the same repo as the weights, and
<code class="language-plaintext highlighter-rouge">--spec-type draft-mtp</code> turns speculation on.</p>

<p><strong><code class="language-plaintext highlighter-rouge">llama-bench</code> cannot measure this</strong> — no draft-model support — so the evidence is the journal.</p>

<p><strong>Journal, real turns</strong></p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>median</th>
      <th>p10</th>
      <th>requests under 40 t/s</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Stage 2 config</td>
      <td>50.2 t/s</td>
      <td>43.4</td>
      <td>6% of 650</td>
    </tr>
    <tr>
      <td>Stage 3 (+MTP)</td>
      <td><strong>72.7 t/s</strong></td>
      <td><strong>63.8</strong></td>
      <td><strong>0 of 35</strong></td>
    </tr>
  </tbody>
</table>

<p>Two consecutive rounds of one live turn:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>id 1 | task   0 | eval time = 3532.91 ms / 334 tokens (10.61 ms/tok, 94.26 t/s)
id 1 | task   0 | draft acceptance = 0.57108 ( 233 accepted / 408 generated), mean len = 3.28
id 1 | task 112 | eval time = 1730.32 ms / 156 tokens (11.16 ms/tok, 89.58 t/s)
id 1 | task 112 | draft acceptance = 0.54000 ( 108 accepted / 200 generated), mean len = 3.16
</code></pre></div></div>

<p><strong>~1.45× on the median turn, and the slow tail disappears entirely.</strong> The floor moving matters more
than the median — from the outside it reads as “the bot got consistent” rather than “the bot got
faster”.</p>

<p><code class="language-plaintext highlighter-rouge">draft acceptance</code> is the number to watch. It tracks how predictable the output is — around 0.9 on
tool-call JSON, around 0.5 on prose — so speculation pays most exactly where an agent spends its
tokens.</p>

<p><strong>How far speculation pays.</strong> Sweeping <code class="language-plaintext highlighter-rouge">--spec-draft-n-max</code> on a real replayed turn:</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">n-max</code></th>
      <th>decode t/s</th>
      <th>acceptance</th>
      <th>mean len</th>
      <th>passes/s</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2</td>
      <td>77.4</td>
      <td>0.872</td>
      <td>2.74</td>
      <td>28.2</td>
    </tr>
    <tr>
      <td><strong>4</strong></td>
      <td><strong>85.3</strong></td>
      <td>0.701</td>
      <td>3.78</td>
      <td>22.6</td>
    </tr>
    <tr>
      <td>6</td>
      <td>84.9</td>
      <td>0.586</td>
      <td>4.46</td>
      <td>19.0</td>
    </tr>
    <tr>
      <td>8</td>
      <td>81.6</td>
      <td>0.502</td>
      <td>4.98</td>
      <td>16.4</td>
    </tr>
    <tr>
      <td>12</td>
      <td>78.4</td>
      <td>0.411</td>
      <td>5.86</td>
      <td>13.4</td>
    </tr>
  </tbody>
</table>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>n-max  4  | draft acceptance = 0.70064 ( 220 accepted /  314 generated), mean len = 3.78
n-max 12  | draft acceptance = 0.41060 ( 248 accepted /  604 generated), mean len = 5.86
</code></pre></div></div>

<p>Read the <code class="language-plaintext highlighter-rouge">generated</code> column: drafting twelve ahead produces 604 candidates to keep 248, against 314
for 220 at four. Acceptance falls as the draft head guesses further ahead, tokens-per-pass rises,
and the pass rate falls almost exactly in step — so the product peaks at <strong>4</strong> and decays after.
The default was already optimal; the sweep bought the knowledge rather than the throughput.</p>

<p><strong>One caveat worth knowing: MTP blinds logprobs.</strong> 248 of 250 tokens return <code class="language-plaintext highlighter-rouge">logprob</code> exactly 0.0
with an empty <code class="language-plaintext highlighter-rouge">top_logprobs</code>, against 0 blanks without speculation — draft-accepted tokens are
never scored. Anything downstream consuming logprobs breaks silently.</p>

<h3 id="stage-4--parallelism-two-slots">Stage 4 — parallelism: two slots</h3>

<p><strong>A capacity win rather than a speed one.</strong> Single-stream throughput is unchanged; what the box
gains is the ability to serve concurrent work — two slots here, so chat and automation stop waiting
for each other, and up to sixteen if what you want is aggregate rather than latency. The cost is
memory: roughly 8 GB of KV per slot.</p>

<p>The agent has two entry paths that can fire together: a person’s chat turn, and a rule-triggered
automation run. At one slot they serialize, one waiting out the whole of the other.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>llama-server <span class="se">\</span>
  ... <span class="se">\</span>
  <span class="nt">-c</span> 400000 <span class="nt">-np</span> 2 <span class="se">\</span>
  ...
</code></pre></div></div>

<p><strong>The trap is <code class="language-plaintext highlighter-rouge">-c</code>.</strong> llama.cpp splits it across slots, so <code class="language-plaintext highlighter-rouge">-np 2</code> at <code class="language-plaintext highlighter-rouge">-c 200000</code> gives each slot
100,000 — and the largest real turn observed here was 90,959 tokens. <code class="language-plaintext highlighter-rouge">-c 400000</code> keeps 200,192 per
slot.</p>

<p><strong>Does an idle slot cost throughput?</strong> Single-stream, second slot empty:</p>

<table>
  <thead>
    <tr>
      <th>prompt</th>
      <th><code class="language-plaintext highlighter-rouge">-np 1</code></th>
      <th><code class="language-plaintext highlighter-rouge">-np 2</code></th>
      <th>ratio</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>prose</td>
      <td>106.8</td>
      <td>102.5</td>
      <td>0.96×</td>
    </tr>
    <tr>
      <td>json</td>
      <td>135.0</td>
      <td>142.0</td>
      <td>1.05×</td>
    </tr>
    <tr>
      <td>list</td>
      <td>115.9</td>
      <td>121.9</td>
      <td>1.05×</td>
    </tr>
  </tbody>
</table>

<p>Ratios scatter either side of 1.00 with no direction — noise, not penalty. <strong>llama.cpp batches only
slots that have work</strong>, so an idle slot does not slow the busy one down.</p>

<p><strong>It is not free, though — it is paid for in memory and in context.</strong> The slot’s KV reservation is
allocated whether or not anything is using it, about 8 GB here, and <code class="language-plaintext highlighter-rouge">-c</code> is divided rather than
shared, so each slot’s usable context is <code class="language-plaintext highlighter-rouge">-c / -np</code>. That is the real price of the second slot:
not tokens per second, but roughly 8 GB of unified memory and the obligation to double <code class="language-plaintext highlighter-rouge">-c</code> to
keep the per-slot ceiling where it was.</p>

<p><strong>What concurrency actually buys.</strong> The obvious way to measure this is to fire N copies of the
same request. That measurement is wrong, and the size of the error is the interesting part — so
both are here, N streams of the <em>same</em> prompt against N streams of <em>different</em> prompts:</p>

<table>
  <thead>
    <tr>
      <th>streams</th>
      <th>identical prompts</th>
      <th>distinct prompts</th>
      <th>per stream, distinct</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>103.5</td>
      <td>103.1</td>
      <td>103.1</td>
    </tr>
    <tr>
      <td>4</td>
      <td>236.0</td>
      <td>166.7</td>
      <td>41.7</td>
    </tr>
    <tr>
      <td>8</td>
      <td>322.0</td>
      <td>222.3</td>
      <td>27.8</td>
    </tr>
    <tr>
      <td>16</td>
      <td>411.3</td>
      <td><strong>280.2</strong></td>
      <td><strong>17.5</strong></td>
    </tr>
  </tbody>
</table>

<p><em>One server at <code class="language-plaintext highlighter-rouge">-np 16 -c 262144</code> (16,384 per slot) with <code class="language-plaintext highlighter-rouge">f16</code> KV and MTP, 300 tokens per request,
median of three runs at each point. The distinct column uses sixteen unrelated subjects — bread,
locomotives, coral, Russian novels — chosen to have nothing in common.</em></p>

<p><strong>Identical prompts overstate aggregate throughput by 47%.</strong> At one stream the two agree to within
0.4%, so this is not prompt caching; it is what happens once several sequences run together. At
temperature 0, sixteen copies of one prompt generate the same tokens in lockstep, so every forward
pass activates the <strong>same 8 of 128 experts</strong> for all sixteen sequences. The expert weights are read
once and shared across the whole batch. Real users do not do that.</p>

<p>The journal shows the difference plainly. Identical prompts, slots finishing within <em>milliseconds</em>
of each other at the same rate:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>id  1 | task 1346 | eval time = 11905.15 ms / 300 tokens (39.82 ms per token, 25.12 t/s)
id  2 | task 1347 | eval time = 11904.95 ms / 300 tokens (39.82 ms per token, 25.12 t/s)
id  3 | task 1348 | eval time = 11904.73 ms / 300 tokens (39.82 ms per token, 25.12 t/s)
id 11 | task 1356 | eval time = 11896.94 ms / 300 tokens (39.79 ms per token, 25.13 t/s)
</code></pre></div></div>

<p>Distinct prompts, the same sixteen slots — the lockstep is gone and every slot is slower:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>id  4 | task 2657 | eval time = 15137.00 ms / 300 tokens (50.63 ms per token, 19.75 t/s)
id 11 | task 2664 | eval time = 15342.81 ms / 300 tokens (51.31 ms per token, 19.49 t/s)
id  9 | task 2653 | eval time = 15998.16 ms / 300 tokens (53.51 ms per token, 18.69 t/s)
id 13 | task 2663 | eval time = 16643.44 ms / 300 tokens (55.66 ms per token, 17.97 t/s)
</code></pre></div></div>

<p>Divergent sequences route to different experts, so bytes per pass climb with the number of streams
instead of staying flat. <strong>A synthetic benchmark hides that by construction</strong>, and on a sparse MoE
it is worth a third of the headline.</p>

<p><strong>Treat 280 as an estimate rather than a measurement.</strong> The distinct-prompt run has two variables
still uncontrolled: streams that finish early leave slots idle, and MTP draft acceptance varies with
subject matter. Both push the figure down, so the honest reading is <em>somewhere between the two
columns, nearer the right-hand one</em>.</p>

<p>What does not depend on that resolution is the shape. <strong>The aggregate flattens</strong>, and <strong>the
per-stream rate collapses</strong> — 103 t/s alone, under 20 with sixteen running. Aggregate throughput is
capacity, not speed, and beyond a handful of slots it is capacity bought entirely at the expense of
the person waiting.</p>

<p><strong>For my workload the aggregate is irrelevant and the per-stream figure is not.</strong> One person waits
at a time. Two slots is the right number because it stops automation and chat blocking each other
for free; eight would trade a single turn’s speed for capacity nobody uses.</p>

<h3 id="stage-5--kv-cache-precision-q8_0--f16">Stage 5 — KV cache precision: <code class="language-plaintext highlighter-rouge">q8_0</code> → <code class="language-plaintext highlighter-rouge">f16</code></h3>

<p><strong>The largest single win in this article: +49.5% at 64k context — by moving twice the bytes.</strong></p>

<p><code class="language-plaintext highlighter-rouge">-ctk q8_0 -ctv q8_0</code> had been in the configuration from the beginning, on the obvious reasoning:
half the bytes, and it is what makes a 200K context affordable. Since the KV term dominates at
depth, halving it ought to help most exactly where help is needed.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>llama-server <span class="se">\</span>
  ... <span class="se">\</span>
  <span class="nt">-ctk</span> f16 <span class="nt">-ctv</span> f16 <span class="se">\</span>
  ...
</code></pre></div></div>

<p><strong>It does the opposite.</strong> Four cache-type combinations across four depths:</p>

<table>
  <thead>
    <tr>
      <th>depth</th>
      <th><code class="language-plaintext highlighter-rouge">f16</code>/<code class="language-plaintext highlighter-rouge">f16</code></th>
      <th><code class="language-plaintext highlighter-rouge">f16</code>/<code class="language-plaintext highlighter-rouge">q8_0</code></th>
      <th><code class="language-plaintext highlighter-rouge">q8_0</code>/<code class="language-plaintext highlighter-rouge">f16</code></th>
      <th><code class="language-plaintext highlighter-rouge">q8_0</code>/<code class="language-plaintext highlighter-rouge">q8_0</code></th>
      <th>f16 gain</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td><strong>87.92</strong></td>
      <td>85.75</td>
      <td>85.43</td>
      <td>84.00</td>
      <td>+4.7%</td>
    </tr>
    <tr>
      <td>4,096</td>
      <td><strong>78.93</strong></td>
      <td>77.68</td>
      <td>77.27</td>
      <td>76.84</td>
      <td>+2.7%</td>
    </tr>
    <tr>
      <td>16,384</td>
      <td><strong>72.65</strong></td>
      <td>67.33</td>
      <td>66.96</td>
      <td>62.21</td>
      <td><strong>+16.8%</strong></td>
    </tr>
    <tr>
      <td>65,536</td>
      <td><strong>54.96</strong></td>
      <td>43.97</td>
      <td>43.87</td>
      <td>36.77</td>
      <td><strong>+49.5%</strong></td>
    </tr>
  </tbody>
</table>

<p><em>tg128, <code class="language-plaintext highlighter-rouge">-r 3</code>, ±0.30 or better; the d65536 column is ±0.10.</em></p>

<p><strong>f16 moves ~1.9× more bytes and wins by half at depth.</strong> That is a factor of four in the wrong
direction for anything bandwidth explains. The q8_0 penalty was never bytes — it is
<strong>dequantization</strong>: every cached key and value is unpacked on every generated token, and past
roughly 10k tokens that compute cost swamps the bandwidth it saves. Below 4k the four
configurations sit within 5% of each other, which is the same statement from the other end: with an
almost-empty cache there is nothing to unpack.</p>

<p>A matched pair from the journal confirms it outside the benchmark — same task id, same
68,827-token prompt, same 200 tokens generated:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>q8_0   task 1040 | prompt eval time = 28883.32 ms / 68827 tokens (0.42 ms/tok, 2382.93 t/s)
q8_0   task 1040 |        eval time =  5668.50 ms /   200 tokens (28.48 ms/tok,   35.11 t/s)

f16    task 1040 | prompt eval time = 27666.58 ms / 68827 tokens (0.40 ms/tok, 2487.73 t/s)
f16    task 1040 |        eval time =  4020.66 ms /   200 tokens (20.20 ms/tok,   49.49 t/s)
</code></pre></div></div>

<p>Prefill within 4%, decode differing by 41%. Whatever f16 is doing, it does it during generation,
not ingestion.</p>

<p><strong>Quantizing K and V costs the same.</strong> At d65536, <code class="language-plaintext highlighter-rouge">f16</code>/<code class="language-plaintext highlighter-rouge">q8_0</code> gives 43.97 and <code class="language-plaintext highlighter-rouge">q8_0</code>/<code class="language-plaintext highlighter-rouge">f16</code> gives
43.87 — 0.2% apart on ±0.10 error bars. Either half alone costs 20%; both cost 33%. The asymmetric
configurations are the worst of both — almost all the speed given up for half the memory saved.
The common intuition that keys are more quantization-sensitive than values is a claim about
<strong>quality</strong>, and it does not transfer to the performance axis: the cost is per-KV-access
dequantization, and K and V are accessed equally.</p>

<p><strong>Journal, real traffic</strong>, splitting at the switch:</p>

<table>
  <thead>
    <tr>
      <th>context</th>
      <th>q8_0</th>
      <th>n</th>
      <th>f16</th>
      <th>n</th>
      <th>gain</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>&lt;3k</td>
      <td>99.0</td>
      <td>56</td>
      <td>89.5</td>
      <td>23</td>
      <td>−10%</td>
    </tr>
    <tr>
      <td>3–10k</td>
      <td>78.5</td>
      <td>2</td>
      <td>92.6</td>
      <td>2</td>
      <td>+18%</td>
    </tr>
    <tr>
      <td>10–40k</td>
      <td>70.8</td>
      <td>8</td>
      <td><strong>85.9</strong></td>
      <td>7</td>
      <td><strong>+21%</strong></td>
    </tr>
    <tr>
      <td>&gt;40k</td>
      <td>39.6</td>
      <td>2</td>
      <td><strong>61.3</strong></td>
      <td>4</td>
      <td><strong>+55%</strong></td>
    </tr>
  </tbody>
</table>

<p><em>Buckets outside <code class="language-plaintext highlighter-rouge">&lt;3k</code> hold single-digit samples — direction, not magnitude. The −10% at <code class="language-plaintext highlighter-rouge">&lt;3k</code> is
noise; the controlled sweep had the two level there, and there is no mechanism by which f16 is
slower on a nearly-empty cache.</em></p>

<p>On the streaming metric the same period gives median <code class="language-plaintext highlighter-rouge">tg</code> <strong>68.3 → 86.2</strong> and <code class="language-plaintext highlighter-rouge">tg_3s</code>
<strong>69.3 → 95.5</strong>, with the floor moving from <strong>31.3 to 56.8 t/s</strong>. The floor is why the agent feels
different to use.</p>

<p><strong>The cost is memory, not throughput.</strong> f16 KV is ~1.9× larger, so a 400K allocation goes from
roughly 16 GB to 31 GB. On a 128 GB box that is affordable — and the honest framing is that <strong>KV
quantization buys context length and pays for it in long-context speed</strong>, which is the opposite of
how it is usually described.</p>

<h3 id="stage-6--does-any-of-this-generalise">Stage 6 — does any of this generalise?</h3>

<p><strong>Not a win but a correction.</strong> The Stage 5 result is not Gemma-specific: GLM pays 75% where Qwen
pays nothing, and what predicts it is attention design rather than vendor.</p>

<p>A win that large invites one question: is it a fact about llama.cpp, about this hardware, or about
<strong>this model</strong>? The test: run the same cache sweep
against other vendors’ models of the same class — <code class="language-plaintext highlighter-rouge">unsloth/Qwen3.6-35B-A3B-MTP</code>, a sparse MoE at
Q4_K_XL, and <code class="language-plaintext highlighter-rouge">GLM-4.7-Flash</code> at Q4_K, a 30B-A3B MoE that llama.cpp loads under the <code class="language-plaintext highlighter-rouge">deepseek2</code>
architecture.</p>

<p><strong>llama-bench, Qwen3.6-35B-A3B</strong></p>

<table>
  <thead>
    <tr>
      <th>depth</th>
      <th><code class="language-plaintext highlighter-rouge">f16</code>/<code class="language-plaintext highlighter-rouge">f16</code></th>
      <th><code class="language-plaintext highlighter-rouge">f16</code>/<code class="language-plaintext highlighter-rouge">q8_0</code></th>
      <th><code class="language-plaintext highlighter-rouge">q8_0</code>/<code class="language-plaintext highlighter-rouge">f16</code></th>
      <th><code class="language-plaintext highlighter-rouge">q8_0</code>/<code class="language-plaintext highlighter-rouge">q8_0</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>68.03 ± 0.22</td>
      <td>67.47 ± 0.23</td>
      <td>67.34 ± 0.30</td>
      <td>67.17 ± 0.15</td>
    </tr>
    <tr>
      <td>16,384</td>
      <td>61.56 ± 0.27</td>
      <td>61.62 ± 0.16</td>
      <td>61.43 ± 0.19</td>
      <td>61.40 ± 0.14</td>
    </tr>
    <tr>
      <td>65,536</td>
      <td>48.46 ± 0.07</td>
      <td>48.69 ± 0.14</td>
      <td>48.27 ± 0.09</td>
      <td>48.34 ± 0.06</td>
    </tr>
  </tbody>
</table>

<p><strong>KV quantization costs Qwen nothing.</strong> A 0.4% spread at d65536 against ±0.06–0.14 error bars —
indistinguishable. Where gemma-4 pays <strong>+49.5%</strong> for f16, Qwen pays <strong>zero</strong>.</p>

<p>That looked like a clean answer — the Stage 5 result is Gemma-specific — until the third model.</p>

<p><strong>llama-bench, GLM-4.7-Flash (30B-A3B, <code class="language-plaintext highlighter-rouge">deepseek2</code>)</strong></p>

<table>
  <thead>
    <tr>
      <th>depth</th>
      <th><code class="language-plaintext highlighter-rouge">f16</code></th>
      <th><code class="language-plaintext highlighter-rouge">q8_0</code></th>
      <th>f16 advantage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>73.14 ± 0.12</td>
      <td>71.33 ± 0.15</td>
      <td>+2.5%</td>
    </tr>
    <tr>
      <td>16,384</td>
      <td>55.11 ± 0.13</td>
      <td>45.43 ± 0.11</td>
      <td>+21.3%</td>
    </tr>
    <tr>
      <td>65,536</td>
      <td><strong>32.96 ± 0.02</strong></td>
      <td><strong>18.81 ± 0.01</strong></td>
      <td><strong>+75.2%</strong></td>
    </tr>
  </tbody>
</table>

<p><em>Two columns rather than four: this architecture keeps one shared latent cache, so mixed <code class="language-plaintext highlighter-rouge">-ctk</code>/
<code class="language-plaintext highlighter-rouge">-ctv</code> types are rejected outright — <code class="language-plaintext highlighter-rouge">failed to create context</code>. The sweep that runs on the other
two models is not available here.</em></p>

<p><strong>GLM pays more than Gemma does.</strong> 75% at 64k against Gemma’s 49.5%, and by the same shape — nothing
at zero context, growing with depth. So “Gemma-specific” was wrong. Two of three models pay
heavily, and Qwen is the exception rather than the rule.</p>

<p><strong>The mechanism survives the correction, and it is not about vendors.</strong> What Gemma and GLM have in
common is a deliberately <em>small</em> KV cache. Gemma 4 interleaves 5:1 — 25 sliding-window layers
capped at 1,024 tokens, and only 5 global layers that grow with context, those given <strong>2 KV heads
instead of 8</strong>. Almost nothing accumulates per token.</p>

<p>GLM arrives at the same place by another route: <code class="language-plaintext highlighter-rouge">deepseek2</code>-style attention compresses keys and
values into a single low-rank latent, so what is cached per token is a fraction of a conventional
K and V. Qwen does neither and carries an ordinary, large cache.</p>

<p>A small cache is precisely the case where per-element dequantization <em>compute</em> dominates the KV
path instead of hiding beneath bandwidth. The same dequant work disappears into Qwen’s larger
cache; Gemma’s and GLM’s are too small to hide it.</p>

<p><strong>So the rule is about attention design, not about the model.</strong> Anyone applying “quantize the KV
cache, it saves bandwidth” will be right on models with a big conventional cache and badly wrong on
the ones engineered to have a small one — which is increasingly what new architectures are for. Two
models, two different mechanisms, the same 50–75% penalty.</p>

<p><strong>So give Gemma a bigger cache and the penalty should vanish.</strong> That is the obvious objection, and
it is testable. Most of the cache shape is trained in — <code class="language-plaintext highlighter-rouge">key_length</code> 512, <code class="language-plaintext highlighter-rouge">key_length_swa</code> 256, a
per-layer <code class="language-plaintext highlighter-rouge">head_count_kv</code> array, a 1,024-token window — but <code class="language-plaintext highlighter-rouge">--swa-full</code> hands the sliding-window
layers a full-size cache instead of a ring, which is as close to Qwen’s shape as this model gets.</p>

<table>
  <thead>
    <tr>
      <th>~21k context</th>
      <th><code class="language-plaintext highlighter-rouge">q8_0</code></th>
      <th><code class="language-plaintext highlighter-rouge">f16</code></th>
      <th>f16 advantage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>default, ring SWA</td>
      <td>55.97</td>
      <td><strong>60.08</strong></td>
      <td>+7.3%</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">--swa-full</code></td>
      <td><strong>40.12</strong></td>
      <td>34.49</td>
      <td>−14.0%</td>
    </tr>
  </tbody>
</table>

<p><em><code class="language-plaintext highlighter-rouge">llama-server</code> rather than <code class="language-plaintext highlighter-rouge">llama-bench</code>, which has no SWA flag: one 21k-token prompt, 200 tokens
generated, <code class="language-plaintext highlighter-rouge">-c 32768</code>, everything else held. Not comparable with the tables above — the comparison
that matters is across each row.</em></p>

<p><strong>The advantage reverses.</strong> With a full-size cache <code class="language-plaintext highlighter-rouge">q8_0</code> becomes the faster choice, which is the
mechanism stated out loud: give the kernel enough KV to read and bandwidth dominates again, so
halving the bytes wins and the dequantization hides underneath it — exactly what happens on Qwen
without anyone asking for it.</p>

<p>It is still a bad trade, which is the useful half of the result. Both configurations lose heavily
for the privilege, so the fastest cell in that table is the one you started from. You can make
quantizing pay on this architecture, but only by making everything slower first. The small cache
is the feature; f16 is what the feature costs.</p>

<p><strong>GLM has no such lever at all</strong>, and that is the sharper half. Gemma’s small cache comes from
windowing, which is an allocation decision and therefore adjustable — badly, but adjustable. GLM’s
comes from the weights: <code class="language-plaintext highlighter-rouge">deepseek2</code>-style attention projects K and V down into a low-rank latent
(<code class="language-plaintext highlighter-rouge">kv_lora_rank</code> 512 in this family), and those projections are trained at that size. There is no
ring to widen and no flag that widens it — llama.cpp does not build an SWA cache for the
<code class="language-plaintext highlighter-rouge">deepseek2</code> path at all. GLM pays its 75% and cannot opt out of it.</p>

<p>Gemma still wins the comparison outright: <strong>54.96</strong> at d65536 against Qwen’s 48.46 and GLM’s 32.96,
and <strong>87.92</strong> at zero context against 68.03 and 73.14 — from the smallest file of the three, 13.26
GiB against GLM’s 16.98.</p>

<hr />

<h2 id="the-instrument-panel-lies">The instrument panel lies</h2>

<p>Three dials mislead, and all three were nearly taken at face value.</p>

<p><strong>“GPU utilization is 100%, so there is no headroom.”</strong> <code class="language-plaintext highlighter-rouge">nvidia-smi</code> utilization reports the
fraction of time at least one kernel is <em>resident</em> — not compute performed. A kernel stalled on
DRAM reads 84% while doing very little arithmetic.</p>

<p><strong>“Power caps around 45 W, so the GPU is not fully loaded.”</strong> True, and it means the opposite of
what it looks like. Same GPU, minutes apart:</p>

<table>
  <thead>
    <tr>
      <th>phase</th>
      <th>GPU %</th>
      <th>power</th>
      <th>SM clock</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>idle</td>
      <td>0%</td>
      <td>12.2 W</td>
      <td>2405 MHz</td>
    </tr>
    <tr>
      <td><strong>prefill</strong> (47,616 tok)</td>
      <td>96%</td>
      <td><strong>70.1 W</strong></td>
      <td>2509 MHz</td>
    </tr>
    <tr>
      <td><strong>decode</strong> (600 tok)</td>
      <td>84%</td>
      <td><strong>44.8 W</strong></td>
      <td>2515 MHz</td>
    </tr>
  </tbody>
</table>

<p><em><code class="language-plaintext highlighter-rouge">nvidia-smi</code> sampled every 200 ms, medians over each request’s window. A 47,616-token prompt with
<code class="language-plaintext highlighter-rouge">max_tokens: 1</code> isolates prefill; a 5-token prompt generating 600 isolates decode.</em></p>

<p>The silicon draws <strong>1.6× more power during prefill at the same clocks</strong>. Decode is not leaving
performance unused — it is a workload that does not need the ALUs, because it is waiting on memory.
<strong>Low power during decode is the signature of a memory-bound workload</strong>, and exactly what should be
expected.</p>

<p><strong>“The profiler says the GPU is idle 90% of the time.”</strong> The worst of the three, because it arrives
wearing a profiler’s authority. <code class="language-plaintext highlighter-rouge">nsys</code> defaults to <code class="language-plaintext highlighter-rouge">--cuda-graph-trace=graph</code>, which records that a
CUDA graph was launched and does not trace the kernels inside it — and llama.cpp runs decode almost
entirely inside graphs. One server decode, captured twice, reports <strong>10% GPU busy at the default
granularity and 86% at <code class="language-plaintext highlighter-rouge">--cuda-graph-trace=node</code></strong>. The default is not wrong, it is answering a
different question, and its answer inverts the conclusion.</p>

<hr />

<h2 id="summary">Summary</h2>

<p>Six stages, one change at a time:</p>

<table>
  <thead>
    <tr>
      <th>stage</th>
      <th>change</th>
      <th>decode @ d65536</th>
      <th>note</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td><code class="language-plaintext highlighter-rouge">google:Q4_0</code> baseline</td>
      <td>~35</td>
      <td>~50 t/s median on real turns</td>
    </tr>
    <tr>
      <td>2</td>
      <td>→ <code class="language-plaintext highlighter-rouge">unsloth:UD-Q4_K_XL</code></td>
      <td>~37</td>
      <td>same Q4_0 format, better packing</td>
    </tr>
    <tr>
      <td>3</td>
      <td>+ MTP (<code class="language-plaintext highlighter-rouge">draft-mtp</code>, n-max 4)</td>
      <td>—</td>
      <td><strong>~1.5× on real turns</strong>, slow tail gone</td>
    </tr>
    <tr>
      <td>4</td>
      <td>+ <code class="language-plaintext highlighter-rouge">-np 2 -c 400000</code></td>
      <td>—</td>
      <td>capacity, not speed: ~8 GB KV per slot, scales to 16 streams</td>
    </tr>
    <tr>
      <td>5</td>
      <td><code class="language-plaintext highlighter-rouge">-ctk/-ctv</code> → <code class="language-plaintext highlighter-rouge">f16</code></td>
      <td><strong>~55</strong></td>
      <td><strong>about +50%</strong> at depth</td>
    </tr>
    <tr>
      <td>6</td>
      <td>challenge vs Qwen, GLM</td>
      <td>~48 / ~33</td>
      <td>the Stage 5 win tracks attention design, not vendor</td>
    </tr>
  </tbody>
</table>

<p><strong>Roughly 35 → 55 t/s at 64k context</strong>, plus a real-turn median that moved about 50 → 73 and a
sub-40 t/s tail that went from 6% of requests to none. Exact figures with their error bars are in
each stage above.</p>

<p>Where the machine now stands against its own limits:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bytes per pass  =  2.04 GiB + KV × context
achieved        =  2.04 GiB × 87.9 passes/s  =  193 GB/s
</code></pre></div></div>

<p>Everything on one baseline, the spec sheet, because that is the number a reader arrives with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                                              of spec
  spec sheet                    273 GB/s        100%
  what the bus actually gives  ~250 GB/s         90%
  what the engine achieves      193 GB/s         70%
</code></pre></div></div>

<p><em>87.9 is the measured zero-context decode rate of the final configuration, where the KV term is
zero and every pass retires one token, so passes per second and tokens per second are the same
number. Sizes here are <strong>GiB</strong> (2³⁰); rates are <strong>GB/s</strong> (10⁹), which is what the spec sheet
quotes.</em></p>

<p><strong>You will not get the number on the box, and roughly 70% of it is a fair expectation.</strong> Two layers
take the difference, and neither is a defect:</p>

<p><strong>About 10 points go to the machine.</strong> 273 GB/s is a peak the memory system does not sustain to a
running program, and what it does sustain depends on conditions outside the model entirely — kernel
allocation, how the buffers end up backed, how full the box is. Those conditions are also why the
figure is a range rather than a constant, which is a separate point from the shortfall itself. No
amount of engine tuning reaches any of it, and a benchmark that appears to is usually measuring
something other than the bus.</p>

<p><strong>About 20 points go to llama.cpp.</strong> This is the part that is a property of the inference engine
rather than the hardware, which makes it the only part worth attacking — and it was the largest
number left on this box, so it got profiled rather than left as a claim.</p>

<hr />

<h2 id="where-the-engines-20-points-went">Where the engine’s 20 points went</h2>

<p><strong>Of the engine’s 20 points, roughly three in four are the GPU computing more slowly than the bus
could feed it, and the rest is the GPU sitting idle between tokens.</strong></p>

<p>That split comes from Nsight Systems, profiling the same benchmark the 193 GB/s came from. It has to
be run at node graph granularity — <a href="#the-instrument-panel-lies">as above</a>, the default answers a
different question and reports the GPU as almost entirely idle.</p>

<p>The smaller share, the idle, is the easier one to understand and turned out to be the harder one to
spend. Decode alternates: the GPU computes a token, then a single CPU thread samples it, checks the
speculative draft and sets up the next pass, while the GPU waits. <strong>The core pinned at 100% during
all this is not working — it is spinning inside a CUDA wait</strong>, which looks identical to work in
<code class="language-plaintext highlighter-rouge">top</code> and is why that thread shows almost no system time. Nothing is queued behind it.</p>

<p>Two ways to reclaim that idle, both measured, neither worth having:</p>

<p><strong>A second slot does not overlap it.</strong> llama.cpp batches two slots into one forward pass rather than
running two pipelines, so the host work per pass goes up rather than moving off the critical path.
The idle barely shifts.</p>

<p><strong>Backend sampling (<code class="language-plaintext highlighter-rouge">-bs</code>) removes the largest host cost and the wall clock does not move.</strong> The
server normally copies the whole logits row to the host every pass so the CPU can sample it; the
flag samples on the GPU instead. The transfer really does disappear and the idle really does shrink
— and <code class="language-plaintext highlighter-rouge">radix_sort</code>, <code class="language-plaintext highlighter-rouge">argsort</code> and <code class="language-plaintext highlighter-rouge">cumsum</code> simply appear on the GPU instead, which now spends the
time the CPU gave up. Worse, it adds a flat <strong>450 ms to every prefill</strong>, which a profile of long
generations cannot see and a turn that runs eight to ten prefills pays eight to ten times. Reverted.</p>

<p>Which leaves the larger share, the kernels. No flag in this article reaches it, and it is not
peculiar to this box: <a href="https://github.com/ggml-org/llama.cpp/issues/27050">llama.cpp #27050</a>
profiles the same decode loop on an RTX 5090 with a different model and lands in the same place.</p>

<p><em>One caveat. The figures above are the benchmark’s. A server is idler still — it also samples,
serves HTTP, tracks slots and checks drafts — so the agent gives up a little more than this, and
none of it to the memory bus.</em></p>

<hr />

<h2 id="conclusion">Conclusion</h2>

<p>The question at the top was how much you can get out of a DGX Spark before deciding it is not
enough to run an agent on. The answer is that it is enough: <strong>45 t/s to over 100 on the turns that
matter</strong>, six changes, each one measured.</p>

<p><strong>And the agent does not run the model that won.</strong> As of these tests it runs Qwen3.6-35B-A3B — the
model Stage 6 shows losing at every context depth.</p>

<p>Gemma wins tokens per second and loses turns. It reasons about twice as much per round, hedges and
re-checks answers it already has, and on long log processing it was the model that looped. A round
it has to take back costs more than a round it finishes quickly, so the slower model finishes the
work sooner and the turns feel shorter.</p>

<p><strong>Nothing above could have predicted that.</strong> Every stage measures a rate; none of them measures how
many rounds a question takes. Single-prompt tokens per second is a real number — taken honestly
here, and taken honestly by most people who publish one — and on its own it does not decide whether
an agent is good to use.</p>

<hr />

<h2 id="lessons-learned">Lessons learned</h2>

<p><strong>Optimise the context before the machine.</strong> Cutting the context a turn actually needs, from ~150k
tokens to ~10k, was worth more than every stage below combined — though not on the number this
article is about. On decode it is about 1.8×, against about 1.6× for all six stages at 64k. On the
wait before the first token appears it is better than twenty times, and that is the one a person
feels. Where those tokens were hiding, and the curves that set the target, are
<a href="/ai-agents-field-notes/posts/context-before-the-runtime/">their own article</a>.</p>

<p>What belongs here is the order. Tuning the runtime first would have meant optimising the machine to
process tokens that did not need to exist, and no amount of tuning reduces a token count.</p>

<p><strong>Read the file, not the filename.</strong> <code class="language-plaintext highlighter-rouge">UD-Q4_K_XL</code> contains no Q4_K tensors. “26B-A4B” implies 4B
active parameters, but a forward pass also reads attention, embeddings and the dense FFN every
time — 63% of per-token bytes. Both facts come free from the GGUF header, and both change what you
would optimise.</p>

<p><strong>On a sparse MoE at batch 1, the sparse part is the minority.</strong> This inverts the intuition that
expert weights are where the money is, and it decides which quantization formats can help you at
all: anything that compresses only the experts is optimising the part that is mostly asleep.</p>

<p><strong>A headline feature can be worth nothing to you.</strong> NVFP4 is a large part of why a GB10 looks
attractive on paper, and it was one of the reasons I chose this box. For this workload it reached
none of the path a token actually reads — a capability on a spec sheet is only yours if it lands
where your workload spends its bytes.</p>

<p><strong>KV quantization buys context length, not speed — and on some architectures it costs dearly.</strong>
Gemma gives up 49.5% at 64k for a quantized cache and GLM 75%, while Qwen gives up nothing. What
predicts it is attention design rather than vendor: models engineered for a <em>small</em> KV cache pay
for quantizing it, because per-element dequantization no longer hides beneath the bandwidth it
saves. The reflex to reach for <code class="language-plaintext highlighter-rouge">-ctk q8_0</code> is right about the memory and can be badly wrong about
the time.</p>

<p><strong>Do not expect the number on the box, and do not read the shortfall as a fault.</strong> Here the bus
gives a running model about 90% of its spec sheet and the engine converts that into 70% — roughly
seventy cents on the advertised dollar, which is a fair expectation rather than a disappointment.
The two shares have different owners: what the kernel and the allocator do with memory is outside
your reach, and what the inference engine does with the bandwidth it gets is not. Knowing which is
which is the difference between tuning and wishing.</p>

<p><strong>New hardware means an immature toolchain, and the number worth finding is the one that unblocks
you.</strong> None of this worked out of the box. GB10 support in llama.cpp arrived piecemeal, and getting
a build that actually used the hardware took several attempts — the evidence is still sitting in
<a href="#the-build">the build flags</a>: <code class="language-plaintext highlighter-rouge">CMAKE_CUDA_ARCHITECTURES=121a-real</code> and an ARMv9.2 CPU target are
not defaults, and getting them wrong leaves you a binary that runs, runs slow, and says nothing in
the logs about why. MTP landed later and <code class="language-plaintext highlighter-rouge">llama-bench</code> still cannot drive
it. NVFP4 has not usefully landed at all.</p>

<p>The temptation with a toolchain that green is to wait for it. The better move was to find the rate
at which the agent became developable — 45 t/s, here — and start building on it. The ecosystem
improves on its own schedule as the user base grows, and every gain in this article arrived on an
agent that had been in daily use for weeks. Optimising a system nobody is using yet is the same
mistake as optimising context you have not cut.</p>]]></content><author><name>Remigiusz Weska</name></author><summary type="html"><![CDATA[Tuning llama.cpp on an NVIDIA DGX Spark to get 100+ tokens per second out of a 26B sparse MoE agent.]]></summary></entry></feed>