<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Damen Knight</title>
    <link>https://damenknight.com/</link>
    <description>Astrophotography, Tech Stuff, More</description>
    <language>en-us</language>
    <copyright>© 2026 Damen Knight. All rights reserved.</copyright>
    <atom:link href="https://damenknight.com/feed.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Sat, 05 Sep 2026 15:47:21 GMT</lastBuildDate>
    <item>
      <title>What Your LLM Benchmark Is Actually Measuring</title>
      <link>https://damenknight.com/what-your-llm-benchmark-is-actually-measuring/</link>
      <guid isPermaLink="true">https://damenknight.com/what-your-llm-benchmark-is-actually-measuring/</guid>
      <pubDate>Sat, 05 Sep 2026 15:47:21 GMT</pubDate>
      <category>AI</category>
      <category>Projects</category>
      <category>Homelab</category>
      <description>Identical settings can still steer you toward the wrong model. I traced a benchmark ranking reversal to unfinished answers and grading errors.</description>
      <content:encoded><![CDATA[<p>A benchmark can steer you toward the wrong model even when you test every model with exactly the same settings.</p>
<p>On a 200-question sample from <a href="https://github.com/openai/grade-school-math">GSM8K</a>, a benchmark of grade-school math word problems, my
zero-shot test made Llama look much better than Granite: 68.5% versus 33.5%. After
giving both models more room to answer and using an AI-assisted audit to check what they
actually said, Granite came out ahead on that sample, 93.5% to 88.0%. I’d been cutting off its answers
and then misreading some of the ones it finished.</p>
<p>A shared token limit can cut off a wordier model, and a shared grader can reject answers
in a format it doesn’t recognize, sometimes enough to decide the winner. Carry that
ranking into an application with different requirements, and you can reject a model
before testing the conditions where it might work better.</p>
<p>If you really need an answer inside that limit and in that exact format, those are
useful failures to measure. But I’d been reading the score as “how good is this model at
math?” without checking how much of it meant “did my setup let it finish, and did my
code understand the answer?” That’s a fairly expensive assumption to build a model
recommendation on, and checking it changed mine.</p>
<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;"><li style="margin-bottom: 0.35rem;"><a href="#token-budget" style="font-size: 0.9375rem; text-decoration: none;">I was cutting off the answer</a></li>
<li style="margin-bottom: 0.35rem;"><a href="#answer-extraction" style="font-size: 0.9375rem; text-decoration: none;">And sometimes I wasn't reading it properly</a></li>
<li style="margin-bottom: 0.35rem;"><a href="#reasoning" style="font-size: 0.9375rem; text-decoration: none;">“Let it think” is a setting to test</a></li>
<li style="margin-bottom: 0.35rem;"><a href="#grader-bias" style="font-size: 0.9375rem; text-decoration: none;">My grader had a preference for model families</a></li>
<li style="margin-bottom: 0.35rem;"><a href="#public-harness" style="font-size: 0.9375rem; text-decoration: none;">A public harness can make the same distinction</a></li>
<li style="margin-bottom: 0.35rem;"><a href="#grammar" style="font-size: 0.9375rem; text-decoration: none;">Fine, I'll make it write the right format</a></li>
<li style="margin-bottom: 0.35rem;"><a href="#choosing-models" style="font-size: 0.9375rem; text-decoration: none;">What can you trust a comparison to tell you?</a></li>
<li style="margin-bottom: 0.35rem;"><a href="#methodology" style="font-size: 0.9375rem; text-decoration: none;">Scope and methodology</a></li></ul></div>
<h2 id="token-budget" style="scroll-margin-top: 110px;">I was cutting off the answer</h2>
<p>These runs used <a href="https://github.com/EleutherAI/lm-evaluation-harness">lm-evaluation-harness</a>, or lm-eval, a public evaluation toolkit, on
HOMER, my Strix Halo miniPC. The two models were granite4-h-small and llama31-8b, and
the chat adapter in my installed lm-eval version, 0.4.9.1, gave them a default 256-token
generation limit. Both models got the same allowance, but I hadn’t checked what that
allowance did to each model under each prompt.</p>
<p>The choice of prompt recipe changed which model came out ahead, so I focused the
follow-up on two recipes supplied by the toolkit. The zero-shot recipe asks the model to
“think step by step” without showing any worked examples, and 123 of Granite’s 200
responses were unfinished before resolving the answer. The Llama-style eight-shot recipe
shows eight worked examples, and only four responses were unfinished. The much shorter answers suggest those
examples also helped it get to the point, though I haven’t isolated that effect from
the recipes’ other differences.</p>
<p>I replayed the saved prompts on HOMER with a 1,024-token allowance, keeping the same
Q4_K_M weights, llama.cpp binary, and request settings apart from the cap. I also used
Codex to audit the answers in both sets of responses, accepting equivalent numeric
notation and rejecting numbers that happened to match before the model had actually
answered. Since this is an AI-assisted audit rather than independent human grading, I’ve
kept the original extractor scores alongside it.</p>
<p>Each cell below covers the same 200 questions, and the column numbers are the output
token limits. “Extractor” is the toolkit’s automatic score. “Audited” is the score from that answer review, which checked whether the response
gave the requested answer against the benchmark reference. It didn’t require every
reasoning step to be sound, and the reviewer can make mistakes too.</p>
<div role="region" tabindex="0" aria-label="I was cutting off the answer table" style="overflow-x: auto; max-width: 100%; margin: 1.5rem 0;"><table style="min-width: 560px; margin: 0;">
<thead>
<tr>
<th>model and recipe</th>
<th>original 256, extractor</th>
<th>original 256, audited</th>
<th>rerun 1,024, extractor</th>
<th>rerun 1,024, audited</th>
</tr>
</thead>
<tbody>
<tr>
<td>Granite, zero-shot</td>
<td>33.5%</td>
<td>38.5%</td>
<td>75.0%</td>
<td>93.5%</td>
</tr>
<tr>
<td>Granite, eight-shot</td>
<td>86.5%</td>
<td>89.5%</td>
<td>89.0%</td>
<td>92.5%</td>
</tr>
<tr>
<td>Llama, zero-shot</td>
<td>68.5%</td>
<td>74.5%</td>
<td>78.0%</td>
<td>88.0%</td>
</tr>
<tr>
<td>Llama, eight-shot</td>
<td>77.5%</td>
<td>80.5%</td>
<td>82.5%</td>
<td>85.0%</td>
</tr>
</tbody>
</table></div>
<p>Read down the original extractor column, and the recipe decides which model wins: Llama
leads in zero-shot, while Granite leads in eight-shot. Read down the final audited
column, and Granite leads in both. Its own gap between recipes has also shrunk from 53
points to one, though I wouldn’t read anything into that one-point difference on this
sample.</p>
<p>The control reruns didn’t reproduce every original response exactly, so I can’t
attribute every changed answer to extra tokens. But 50 of Granite’s longer zero-shot
responses <em>do</em> start with the entire unfinished original, byte for byte, and then
continue to a correct answer. For those questions, I can see the answer arriving after
the point where I’d previously stopped it.</p>
<p>At 1,024 tokens, none of Granite’s responses hit the limit. Its zero-shot answers
averaged 326.47 generated tokens, compared with 110.91 for the eight-shot answers. Llama
still hit the limit on six zero-shot questions and three eight-shot questions. More room
helped, but it didn’t make this an unlimited-budget test.</p>
<h2 id="answer-extraction" style="scroll-margin-top: 110px;">And sometimes I wasn’t reading it properly</h2>
<p>The longer run still leaves a fourteen-point gap between Granite’s two recipes under
automatic scoring, 75.0% versus 89.0%, so the token limit doesn’t explain the whole
result. The next problem was how the toolkit read the answers.</p>
<p>The flexible extractor takes the last matching number, so when one archived Granite
answer correctly said a wire made <strong>8 pieces</strong>, then mentioned that each was <strong>6
inches</strong> long, the grader took 6. Another gave <strong>26.00</strong>, which failed comparison with
<strong>26</strong>. It could also award a pass when an unfinished response stopped at step <strong>4</strong>, on
a question whose answer happened to be four.</p>
<p>This particular decimal problem wasn’t even waiting for me to discover it. <a href="https://github.com/EleutherAI/lm-evaluation-harness/issues/3214">huaanrui reported it in lm-eval issue #3214</a> in August 2025,
including the same Rory sandwich question that appears in my sample. The issue is
closed, but <strong>as of this writing, September 5, 2026, the decimal bug still reproduces on
upstream <code>main</code></strong>. I checked its actual extractor and scoring code: <code>$29.</code> passes
against <code>29</code>, while <code>$29.00.</code> fails. It removes the dollar sign and trailing period,
then compares <code>29.00</code> with <code>29</code> as strings. The same happens with <code>26.00</code> versus <code>26</code>.
That’s the behavior at <a href="https://github.com/EleutherAI/lm-evaluation-harness/blob/b954108c9baaaa934b4ad842033b31a97ee30816/lm_eval/api/metrics.py#L234">commit <code>b954108</code></a>,
using the shipped GSM8K task settings.</p>
<p>Once the audit reads the requested answer instead, Granite’s zero-shot rerun goes from
75.0% to 93.5%. The eight-shot rerun goes from 89.0% to 92.5%. The grading error was
much more expensive for one recipe than the other.</p>
<p><a href="https://arxiv.org/abs/2310.11324">Sclar, Choi, Tsvetkov, and Suhr’s FormatSpread work</a>
had already established that prompt formats can affect models differently enough to
reverse comparisons. Their experiments preserve meaning more carefully than my shipped
recipes, which change examples, wording, and stop sequences together. <a href="https://arxiv.org/abs/2509.01790">Hua and colleagues’ <em>Flaw or Artifact?</em></a> supplies another
reason to investigate: much of their measured prompt sensitivity shrank when they
replaced heuristic scoring with a model judge.</p>
<p>Those papers prompted me to check how much of my own spread survived when the model
could finish and the answer got read correctly, and for these two recipes on Granite,
very little did. If your application needs an answer inside 256 tokens, Granite’s
unfinished responses are real failures. If you can give it more room, my original
zero-shot comparison would have sent you toward Llama on evidence that doesn’t survive
the change.</p>
<p>Consistent settings tell you which model worked better under those settings. Before you
build around that recommendation, check whether it survives the settings your
application will actually use. I’d skipped that part.</p>
<h2 id="reasoning" style="scroll-margin-top: 110px;">“Let it think” is a setting to test</h2>
<p>The budget problem also showed up in my own 95-task suite, which compares the cost of
getting correct answers on this box. This is a different workload from GSM8K, so its
percentages aren’t comparable to the ones above. It runs each task twice: once asking
for an immediate answer, called terse mode, and once allowing the model to work through
the problem, called free mode. Both have a 4,096-token generation cap, and these three
models show why the choice matters:</p>
<div role="region" tabindex="0" aria-label="“Let it think” is a setting to test table" style="overflow-x: auto; max-width: 100%; margin: 1.5rem 0;"><table style="min-width: 560px; margin: 0;">
<thead>
<tr>
<th>model</th>
<th>answer immediately</th>
<th>work through the problem</th>
<th>change, points</th>
</tr>
</thead>
<tbody>
<tr>
<td>mistral-small-24b</td>
<td>57.9%</td>
<td>98.9%</td>
<td>+41.1</td>
</tr>
<tr>
<td>devstral-2-123b</td>
<td>62.1%</td>
<td>98.9%</td>
<td>+36.8</td>
</tr>
<tr>
<td>gemma-4-12b</td>
<td>78.9%</td>
<td>52.6%</td>
<td>−26.3</td>
</tr>
</tbody>
</table></div>
<p>On these tasks, Mistral scores much better when I let it work through the problem. Gemma’s score
falls, partly because it runs out of tokens. Models that already reason can use up the
whole allowance continuing to reason, leaving my harness waiting for an answer.</p>
<p>If you need a finished answer within that allowance, running out of tokens is a real
failure. Your application doesn’t get much use out of “hang on, I’m still thinking.”
These runs don’t establish what the model could solve with more room, though. You lose
that distinction when all you get is an accuracy score.</p>
<p>I also tested reasoning on and off, using <code>--reasoning off</code> on three Gemma 4 sizes.
Within each pair, the weights and tasks stayed the same. These scores combine both
prompting modes, so they are not directly comparable to the separate mode scores above.</p>
<div role="region" tabindex="0" aria-label="“Let it think” is a setting to test table" style="overflow-x: auto; max-width: 100%; margin: 1.5rem 0;"><table style="min-width: 560px; margin: 0;">
<thead>
<tr>
<th>model</th>
<th>reasoning on</th>
<th>reasoning off</th>
<th>change, points</th>
</tr>
</thead>
<tbody>
<tr>
<td>gemma-4-26b-a4b</td>
<td>62.1%</td>
<td>83.7%</td>
<td>+21.6</td>
</tr>
<tr>
<td>gemma-4-12b</td>
<td>65.8%</td>
<td>84.2%</td>
<td>+18.4</td>
</tr>
<tr>
<td>gemma-4-31b</td>
<td>89.5%</td>
<td>86.8%</td>
<td>−2.6</td>
</tr>
</tbody>
</table></div>
<p>Turning the headline feature <em>off</em> helped the two smaller models substantially, and
after the 26B result, “turn reasoning off” looks like excellent advice for that model on
this workload. It’s a short hop from there to telling Gemma users generally to do it,
except the 31B model got slightly worse. I’d want more evidence before making much of
that small decline, but it gives me no reason to recommend turning reasoning off across
the family.</p>
<p>If your application disables reasoning or imposes a tighter output budget, check whether
a recommendation based on reasoning-enabled tests still holds. You can change the
outcome without touching the model file.</p>
<p><a href="https://aclanthology.org/2026.findings-acl.1285/">Srivastava and colleagues’ LLMThinkBench study</a> also finds that longer
reasoning can accompany lower accuracy on basic math, and that restricting the token
budget can cause large drops. My Gemma rows show what that tuning question looks like
within one family on this workload.</p>
<h2 id="grader-bias" style="scroll-margin-top: 110px;">My grader had a preference for model families</h2>
<p>Within that same custom suite, the grader caused a separate problem. Prompts and
reasoning flags are at least things I’d expect to tune, but checking whether a list of
numbers is correct felt like a rather less exciting part of the project. I wrote it, so
surely I could trust it to recognize a list of numbers… right??</p>
<p>Mine objected to this:</p>
<pre><code>Expected: [33, 15]
Received: [33,15]
</code></pre>
<p>I collapsed runs of whitespace but didn’t remove the spaces around separators, so all
the numbers were right, and my code was objecting to the spaces.</p>
<p>Fixing spacing rescued 30 answers for qwen3-30b-thinking and 23 for qwen3-4b-thinking.
Their instruct counterparts recovered 2 and 7 respectively. gemma-3-4b recovered none.</p>
<p>The thinking variants tended to write compact lists. I was comparing instruct and
thinking models using a grader that disproportionately rejected the thinking models’
formatting. After re-grading the stored outputs, the comparison looked like this:</p>
<div role="region" tabindex="0" aria-label="My grader had a preference for model families table" style="overflow-x: auto; max-width: 100%; margin: 1.5rem 0;"><table style="min-width: 560px; margin: 0;">
<thead>
<tr>
<th>thinking minus instruct, free mode</th>
<th>original grading</th>
<th>corrected grading</th>
</tr>
</thead>
<tbody>
<tr>
<td>Qwen3-4B</td>
<td>+7.4 points</td>
<td>+9.5 points</td>
</tr>
<tr>
<td>Qwen3-30B</td>
<td>−20.0 points</td>
<td>−3.2 points</td>
</tr>
</tbody>
</table></div>
<p>“Reasoning training costs twenty accuracy points at 30B” looked like a result worth
investigating, and most of it turned out to be my grader objecting to <code>[33,15]</code>. Naming
a unit caught a different set of models: llama31-8b answered <code>221 units.</code> when the
expected string was <code>221</code>, and correcting numeric extraction moved its overall score
from 45.3% to 60.0%, while phi-4 moved from 58.9% to 73.2%. The spacing fix had rescued
no answers for either of them.</p>
<p>Each formatting convention caught a different set of models. In the Qwen comparison, the
error followed the very distinction I was trying to measure, so even the
instruct-versus-thinking comparison within a family was affected. Adding more test
questions won’t fix a rule like that, but they will give you a more precise estimate of
how often each model writes a list without spaces.</p>
<h2 id="public-harness" style="scroll-margin-top: 110px;">A public harness can make the same distinction</h2>
<p>“Damen, maybe your grader is just bad” is a fair objection, but back in lm-eval, there’s
another comparison you can make without generating a single new answer. For its
five-shot <code>gsm8k</code> task, a separate recipe from the two in the opening, lm-eval reports
two readings of the generated answers. <code>strict-match</code> looks for a number after <code>####</code>,
the convention demonstrated in the examples, while <code>flexible-extract</code> takes the last
matching number in the response. The model doesn’t even have to run again, because both
filters grade the output it already produced.</p>
<div role="region" tabindex="0" aria-label="A public harness can make the same distinction table" style="overflow-x: auto; max-width: 100%; margin: 1.5rem 0;"><table style="min-width: 560px; margin: 0;">
<thead>
<tr>
<th>model</th>
<th>flexible-extract</th>
<th>strict-match</th>
<th>gap, points</th>
</tr>
</thead>
<tbody>
<tr>
<td>llama31-8b</td>
<td>79.5%</td>
<td>19.0%</td>
<td>60.5</td>
</tr>
<tr>
<td>phi-4</td>
<td>72.5%</td>
<td>13.0%</td>
<td>59.5</td>
</tr>
<tr>
<td>granite4-h-small</td>
<td>82.0%</td>
<td>34.5%</td>
<td>47.5</td>
</tr>
<tr>
<td>qwen25-coder-32b</td>
<td>73.0%</td>
<td>44.5%</td>
<td>28.5</td>
</tr>
<tr>
<td>gemma-3-12b</td>
<td>85.0%</td>
<td>79.5%</td>
<td>5.5</td>
</tr>
<tr>
<td>qwen3-4b-instruct</td>
<td>75.5%</td>
<td>72.5%</td>
<td>3.0</td>
</tr>
</tbody>
</table></div>
<p>Four of the six models change position, with Llama moving from second-last to third.
phi-4’s score changes by almost sixty points, while qwen3-4b-instruct barely moves in
accuracy at all.</p>
<p>In the logged responses, phi-4 emits <code>####</code> only 13% of the time, while gemma-3-12b does
so 88% of the time. Requiring that marker accounts for much of their difference in
sensitivity to the grading rule.</p>
<p>If your application needs that exact format, you absolutely care whether the model
supplies it. But if you’re choosing a model for arithmetic, how much of its apparent
struggle is with writing <code>####</code>? Check the flexible extractor’s passes, too. Finding the
right number somewhere convenient isn’t enough to establish that the model answered the
question correctly.</p>
<p>I’m glad the harness prints both. The trouble starts when one column becomes “GSM8K
accuracy” with everything else stripped off, and you carry that number into a decision
about which model to use.</p>
<p><a href="https://arxiv.org/abs/2510.14773">Jo and colleagues</a> examine this in <em>Finding Answers
in Thought Matters</em>. They show extraction methods changing model rankings and inspect
the output conventions responsible. They also examine incomplete reasoning caused by
repetition, which is relevant to the unfinished responses I discuss below.</p>
<h2 id="grammar" style="scroll-margin-top: 110px;">Fine, I’ll make it write the right format</h2>
<p>That leaves an obvious question: could you make the model produce the format you need in
the first place? Back on the custom suite, I tried <a href="https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md">llama.cpp’s grammars</a>, which restrict
the text a model is allowed to generate. If I made the model write the format my strict
grader expected, would those answers start passing without another patch to the grader?</p>
<p><a href="https://huggingface.co/blog/evaluation-structured-outputs">Will Kurt, Remi Louf, and Clémentine Fourrier</a> had already tested
structured generation as a way to reduce prompt sensitivity and ranking changes on GSM8K
and GPQA. Their GSM8K constraint allowed reasoning before the answer, a detail that
becomes rather relevant to my attempt below.</p>
<p>I used llama.cpp grammars to constrain answer shapes: brackets and separators for a
list, for example, without supplying the values or the expected list length. For the
formatting tasks, the generator read the expected answer to select separators and
character classes, so it did use answer-key information about the format. Control and
constrained runs used the same prompts on the same loaded server. These are 84
terse-mode tasks, excluding the code-execution tasks. Here, strict grading requires an
exact final-line match, while tolerant grading accepts variations in spacing,
punctuation, and surrounding text. Neither score includes a separate answer audit
like the one in the opening.</p>
<div role="region" tabindex="0" aria-label="Fine, I’ll make it write the right format table" style="overflow-x: auto; max-width: 100%; margin: 1.5rem 0;"><table style="min-width: 560px; margin: 0;">
<thead>
<tr>
<th>model</th>
<th>unconstrained, strict grading</th>
<th>unconstrained, tolerant grading</th>
<th>constrained, strict grading</th>
</tr>
</thead>
<tbody>
<tr>
<td>phi-4</td>
<td>40.5%</td>
<td>57.1%</td>
<td>57.1%</td>
</tr>
<tr>
<td>llama31-8b</td>
<td>23.8%</td>
<td>39.3%</td>
<td>39.3%</td>
</tr>
<tr>
<td>qwen25-coder-32b</td>
<td>54.8%</td>
<td>57.1%</td>
<td>57.1%</td>
</tr>
<tr>
<td>granite4-h-small</td>
<td>52.4%</td>
<td>53.6%</td>
<td>54.8%</td>
</tr>
<tr>
<td>qwen3-4b-instruct</td>
<td>41.7%</td>
<td>41.7%</td>
<td>41.7%</td>
</tr>
</tbody>
</table></div>
<p>For phi-4, Llama, and Qwen Coder, the successful task sets matched exactly between
tolerant grading of the original output and strict grading of the constrained output.
That supports the explanation that these particular failures were avoidable formatting
failures. But I’ve changed generation too, and some models now solve different tasks.</p>
<p>qwen3-4b-instruct, for example, keeps the same total but swaps two task verdicts. For
phi-4-reasoning, tolerant control and strict constrained scores both come out at 45.2%,
but each condition solves 15 tasks the other fails. Look only at the totals, and you’ll
miss that entirely.</p>
<p>Then I applied the grammar from the first generated token to models whose templates open
a thinking block. qwen3-4b-thinking went from 98.8% under tolerant control grading to
0.0% under strict constrained grading. qwen3.6-35b-a3b went from 86.9% to 0.0%.</p>
<p>My grammar excluded the characters needed to close the thinking block, and neither model
emitted a closing tag in any constrained response. I’d restricted the output before they
could get to the part I wanted to restrict. Some kept going as long strings of digits
and commas until the budget ran out.</p>
<p>And yes, I ran this before reading the relevant work. <a href="https://arxiv.org/abs/2408.02442">Tam and colleagues</a> had already shown that format restrictions
can degrade reasoning, and <a href="https://arxiv.org/abs/2604.03616">The Format Tax</a> finds that
separating reasoning from formatting recovers much of the loss in its tests. Reading
first would have been a cheaper way to find that out. I haven’t run that separation
here, so what I’ve established is where my particular constraint setup fails, and I
can’t recommend it to someone using those thinking models, even though it recovered
correct answers for the bare-answering ones.</p>
<h2 id="choosing-models" style="scroll-margin-top: 110px;">What can you trust a comparison to tell you?</h2>
<p>The useful comparison depends on how much of your application you’re willing to change
to accommodate a model. If you need a drop-in replacement behind an existing API, the
prompt, output schema, and budget are real requirements. “It works great if you change
all of those” may be true and still be no help to you. Testing the models under those
fixed conditions answers the question you actually have.</p>
<p>If you can adapt the application, give each model a comparable tuning budget, and check
the selected setup on tasks you didn’t use for tuning. Pick a winner before doing that, and you risk
selecting whichever model happened to like your first attempt at a prompt.</p>
<p>If you’re making a broader claim about model ability, show results across plausible
configurations, including the ones that reverse your preferred ranking.</p>
<p>The lm-eval maintainers’ <a href="https://arxiv.org/abs/2405.14782"><em>Lessons from the Trenches</em></a>
is a good reference for this: inspect outputs, document the setup, and be careful about
comparisons across implementations. Here’s how I’d apply that advice to the failures I
found:</p>
<ul>
<li><strong>Keep the responses and their termination information.</strong> Inspect examples of wrong,
  correct, and unfinished output. In my custom suite, all 15 free-mode passes for
  phi-4-reasoning were truncated responses: the correct number survived at the end of
  a repetition loop. If the requirement is a completed answer, those are failures.
  An answer-string score alone concealed that distinction.</li>
<li><strong>Try the settings likely to change in actual use.</strong> Prompt mode, reasoning switch,
  generation budget, and output constraints belong next to the model name. Check
  each candidate. A good setting for one size does not establish a family default.</li>
<li><strong>Grade the stored output more than one defensible way.</strong> On the five-shot GSM8K
  task, the two extractor scores already provide a useful first check. Investigate
  the disagreements before interpreting either column as ability.</li>
<li><strong>Check whether the decision survives.</strong> If a reasonable configuration change
  reverses the winner, report the reversal. If it changes a score but leaves the
  decision intact, report that too. Your readers can use a result with known limits.</li>
</ul>
<p>And use a public harness where it covers the workload. Seriously. I set out to test
models and ended up maintaining rules about how to read commas and units. Public tooling
still needs checking, as GSM8K demonstrates, but at least I can compare notes with other
people using the same implementation.</p>
<p>If picking another shipped recipe changes the winner, you want to find that in the
writeup, before you’ve built an application around the recommendation.</p>
<h2 id="methodology" style="scroll-margin-top: 110px;">Scope and methodology</h2>
<p>These are local runs on quantized open weights through a chat endpoint on the Strix Halo
box. The GSM8K sample is 200 questions, not the full benchmark. The budget follow-up
covers two models and two untuned shipped recipes. The separate extractor comparison
uses the five-shot <code>gsm8k</code> task. These runs do not establish definitive model rankings
or optimized scores.</p>
<p>The follow-up completed 800 requests at 1,024 tokens and 80 predetermined controls at
256, with temperature zero, seed 1234, and four concurrent requests. Codex reviewed the
original and new answers, without blinding, and ambiguous or unresolved answers count as
incorrect. The new logs retain the termination metadata that the original logs omitted.
The 80 predetermined controls, replayed at the original 256-token limit, reproduced 78
pass/fail verdicts and 40 response strings exactly. After 679 requests, the thermal
guard interrupted the run. I had left the automatic fan curve running instead of pinning
the benchmark fans. The remaining 201 requests ran with full fans and unchanged
inference settings. No throughput comparison is being made from this run.</p>
<p>My custom suite has 95 tasks run in two modes. The prompt and reasoning-switch results
shown here use one run per condition, temperature zero, and a 4,096-token cap.
Percentages and point changes are rounded separately from the underlying counts. That
cap is part of the result, especially where reasoning fails to finish. I retained
historical re-grading evidence for eight complete runs. The other runs were measured
after the comparator fixes. The grammar experiment covers 84 terse tasks per model, and
tests one constraint design.</p>
<p>I checked the tables against the stored results. The custom suite retained output tails,
which support answer-extraction checks but not a complete audit of every generation.
Generated source for the code-execution slice was not retained and cannot be re-graded.</p>]]></content:encoded>
    </item>
    <item>
      <title>Register Mapping the Ampinvt TEL-48502M100 solar inverter</title>
      <link>https://damenknight.com/inverter-register-map/</link>
      <guid isPermaLink="true">https://damenknight.com/inverter-register-map/</guid>
      <pubDate>Sun, 26 Jul 2026 12:00:00 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <description>The Ampinvt TEL-48502M100 doesn’t publish a Modbus register map, and as far as I can tell nobody else has built one either, so I scanned the full address space…</description>
      <content:encoded><![CDATA[<p>TL;DR: I turned a <a href="https://us.ampinvt.com/products/5kw-48vdc-to-120vac-single-phase-hybrid-solar-inverter">5kW Ampinvt TEL-48502M100 solar inverter</a> from a box with an LCD on it in a shed into something I can poll, log, safely write setpoints to, and trust to route my homelab rack off real battery charge instead of the voltage guesswork it ships with. Getting there needed a Modbus register map, which the vendor doesn’t publish and which as far as I can tell nobody else ever has - it’s not SRNE, it’s not MUST, and the vendor’s own published spec is for a completely different product line - so I scanned the whole 16-bit address space and value-matched registers against photographs of the LCD until the map fell out. Then I had to go do the entire thing AGAIN over Bluetooth, because the one number I actually needed - how full is this battery, really - is the one number the inverter cannot tell you.</p>
<p>If you’re shopping for a hybrid inverter you can actually drive from a script, this one qualifies now. And if you already own one, everything below is the map, the traps, and the working setup, so you don’t have to go find them yourself.</p>
<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#shape" style="font-size: 0.9375rem; text-decoration: none;">What the system looks like</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-bus" style="font-size: 0.9375rem; text-decoration: none;">Talking to the inverter</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#method" style="font-size: 0.9375rem; text-decoration: none;">Building the map</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#stale-cache" style="font-size: 0.9375rem; text-decoration: none;">Don’t trust the 0x20xx block</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#derived" style="font-size: 0.9375rem; text-decoration: none;">Deriving what the map doesn’t have</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#writes" style="font-size: 0.9375rem; text-decoration: none;">Writing setpoints safely</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#coulomb" style="font-size: 0.9375rem; text-decoration: none;">Measuring charge state: coulomb counting</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#ble" style="font-size: 0.9375rem; text-decoration: none;">Ground truth: BLE into the BMS</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#fusion" style="font-size: 0.9375rem; text-decoration: none;">Fusing the two signals</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#guard" style="font-size: 0.9375rem; text-decoration: none;">Closing the loop: routing the load</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#deploy" style="font-size: 0.9375rem; text-decoration: none;">How it’s deployed</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#register-map" style="font-size: 0.9375rem; text-decoration: none;">The full register map</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#caveats" style="font-size: 0.9375rem; text-decoration: none;">Limits worth knowing before you start</a></li>
</ul>
</div>

<h2 id="shape">What the system looks like</h2>
<p>The setup: solar array, two Eco-Worthy 48V/100Ah LFP packs in parallel (~10kWh, 200Ah at 48V), one Ampinvt TEL-48502M100 inverter/charger, and the rack it powers. All of it lives in the same shed in my back yard. The inverter has an LCD with numbers on it, which is handy right up until you want those numbers from indoors, or in a graph, or from ten minutes ago.</p>
<p>What it grew into is three processes on one small VM, I’ll swap it to a container later probably, talking over a local MQTT broker:</p>
<pre><code>┌──────────────┐  Modbus-RTU   ┌──────────┐   MQTT    ┌──────────┐   REST   ┌───────────┐
│  inverter    │──────────────▶│  daemon  │──────────▶│   shim   │─────────▶│ dashboard │
│   (CH340T)   │ 9600 8N1 s1   │ pyserial │  topics   │ FastAPI  │ /status  │   rich    │
└──────────────┘               └──────────┘           └──────────┘          └───────────┘
                                     ▲                     ▲
┌──────────────┐  BLE / GATT   ┌──────────┐              MQTT
│  BMS packs   │──────────────▶│   ble    │──────────────▶ broker network
│ (JBD 0xFF00) │  notify 0xFF01│  poller  │  derived      (HA / Grafana)
└──────────────┘               └──────────┘  topics
</code></pre>
<ul>
<li><code>daemon</code> owns the serial port. Polls registers, decodes scale and sentinels, publishes per-field MQTT topics, integrates the energy ledger and the coulomb counter, and is the only process allowed to write setpoints.</li>
<li><code>ble poller</code> owns the Bluetooth adapter. Reads each battery pack’s BMS, publishes per-pack telemetry, and runs the load-routing guard.</li>
<li><code>shim</code> subscribes to everything, caches the latest values, and serves them as JSON over HTTP for the dashboard and anything else that doesn’t speak MQTT.</li>
</ul>
<p>Splitting the two pollers isn’t just for giggles. BLE is slow and flaky next to RS-485, and I didn’t want a Bluetooth timeout to be able to stall inverter polling, so they’re separate processes under separate units. The poller doesn’t write to the daemon’s state either - it subscribes to what the daemon publishes and puts its own derived topics back on the bus.</p>
<h2 id="the-bus">Talking to the inverter</h2>
<p>The inverter has a USB-B port with an internal CH340T behind it, and it speaks bog-standard Modbus-RTU: function <code>0x03</code> to read holding registers, <code>0x06</code> to write one, 9600 8N1, slave address 1. That part takes ten minutes to establish.</p>
<p>Making it <u>reliable</u> took a lot longer, and comes down to three things the firmware cares about.</p>
<p>Chunk reads at 32 registers. Anything much over 32 registers times out. Not an error, not an exception response, just silence until the timeout expires. 88 fails, 32 is comfortable.</p>
<p>Leave ~50ms between requests. The firmware ignores follow-up reads that arrive too fast, and it does it silently. Enforce it inside the client instead of trusting callers, or someone eventually writes a tight loop and spends an afternoon wondering where half their reads went.</p>
<p>Pin the line flags off. <code>rtscts</code>, <code>dsrdtr</code> and <code>xonxoff</code> all explicitly <code>False</code> - the CH340 drops reads if pyserial so much as toggles RTS on open.</p>
<p>Each of those fails <u>partially</u> rather than cleanly, which is what made them expensive to find. If you’re pointing a stock Modbus library at this device and getting intermittent reads, it’s one of the three.</p>
<p>Above that, <code>read_blocks()</code> groups a sparse address set into ≤32-register runs and returns how many runs succeeded, so the caller can tell “the bus is down” from “here’s a snapshot” instead of an empty reading that looks like the real thing. One subtlety in the watchdog: a hung serial read blocks the poll thread <u>while holding the lock</u>, so the watchdog needs a way to close the FD without taking that lock, or it blocks on the thing it’s supposed to be rescuing.</p>
<h2 id="method">Building the map</h2>
<p>I scanned <code>0x0000</code> through <code>0xFFFF</code>. Everything that responds lives in <code>0x0400</code> to <code>0x21xx</code>, in three clusters:</p>
<table>
<thead>
<tr>
<th>Region</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0x0500</code>-<code>0x0510</code></td>
<td>Live electrical readings (battery, PV)</td>
</tr>
<tr>
<td><code>0x1000</code>-<code>0x110f</code></td>
<td>Settings: charge curve, currents, priority/charge modes, SoC thresholds</td>
</tr>
<tr>
<td><code>0x2000</code>-<code>0x212c</code></td>
<td>Status/snapshot cluster - treat with suspicion, see below</td>
</tr>
</tbody>
</table>
<p>There is nothing above <code>0x30FF</code>. The SRNE family keeps daily PV/grid/load energy counters up at <code>0xF0xx</code>; this box has no equivalent I could find, which quietly decides what you can and can’t measure for the rest of the project.</p>
<p>Finding addresses that respond is easy. Working out what they <u>mean</u> is the whole job, and only two approaches ever proved anything.</p>
<p>For settings: photograph the LCD. Walk every parameter screen <code>[01]</code> through <code>[63]</code>, photograph each one, and value-match against a register dump. If LCD parameter <code>[09]</code> reads 58.4V and <code>0x1007</code> holds raw 146, you have the address and the scale from a single photo.</p>
<p>That’s also how the <em>weirdest</em> convention on this device falls out. Most values are raw × 0.1, but <u>charge-curve setpoints are stored per-12V-cell at 0.1V resolution</u>, so on a 48V (4×12V) config the scale is ×0.4:</p>
<pre><code>raw 146 → 14.6 V/cell → 58.4 V system
</code></pre>
<p>Assume ×0.1 like everything else, get 14.6V, conclude it isn’t a voltage and move on. The photo is what breaks the tie.</p>
<p>For live values: the delta test. Change exactly one physical condition and watch which raw register <u>moves</u>. This is the only thing that counts as an identification.</p>
<p>The delta test is also what settles sign conventions, which on this device are not what you’d guess: <code>battery_current</code> is <em>negative when charging</em> (current into the pack), positive when discharging. Confirmed against an LCD showing an unsigned “12A” with the PV→Battery arrow lit while the register read -12.3A. A backwards sign in a control rule does the catastrophic opposite of what you intended, so this one lives in a comment, in the docs, and in the field definition itself.</p>
<p>The map is a list of frozen dataclasses - address, name, scale, unit, signed, sentinel, notes - and the decode is about as boring as it should be:</p>
<pre><code class="language-python">def decode(field: Field, raw: int) -> float | int | None:
    """Apply scale + sentinel handling. Returns None if raw is a sentinel."""
    sentinels = {field.sentinel} if field.sentinel is not None else DEFAULT_SENTINELS
    if raw in sentinels:
        return None
    if field.signed and raw >= 0x8000:
        raw = raw - 0x10000
    return raw * field.scale if field.scale != 1.0 else raw
</code></pre>
<p>Raw <code>4096</code> and <code>0xFFFF</code> are the device’s “no value” sentinels and decode to <code>None</code>, which propagates as an empty MQTT payload instead of a plausible-looking zero. A zero meaning “no reading” and a zero meaning “no current” look identical on a dashboard, and only one of them should ruin your evening.</p>
<p>The <code>notes</code> field is the one that earns its keep. It carries the evidence for every identification: which LCD photo it matched, which delta test moved it, and what I had it confidently mislabeled as beforehand. It’s why I can still trust a field I confirmed two months ago and haven’t thought about since.</p>
<h2 id="stale-cache">Don’t trust the 0x20xx block</h2>
<p>This is the most useful thing to know before you point anything at this inverter. The <code>0x2000</code>-<code>0x212c</code> region responds to reads and looks <u>fantastic</u> - a value that reads 8 when the load is light, one that reads 640W, a VA/W pair sitting adjacent at <code>0x2030</code>/<code>0x2031</code> exactly how other OEMs lay out apparent and active power, a battery SoC at <code>0x2049</code> that matched the app on the nose first time I looked.</p>
<p>Almost none of it is live. The numbers are plausible, stale, and in some cases just firmware defaults that never change. Every address in the known-dead list at the bottom of this post came out of that block, and I misidentified three of them before I worked out what was going on.</p>
<p>Two exceptions, and they’re the useful ones. <code>0x203c</code> and <code>0x203d</code> are live, and they’re the only place this inverter tells you where your load is actually running:</p>
<table>
<thead>
<tr>
<th></th>
<th>on mains</th>
<th>on battery</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0x203c</code></td>
<td>3588.7</td>
<td>191.9</td>
</tr>
<tr>
<td><code>0x203d</code></td>
<td>212.8</td>
<td>8.0</td>
</tr>
</tbody>
</table>
<p>Across 771 settled samples - 610 on grid, 161 on battery, spanning three real transfers - the two value sets are completely disjoint. They lag the actual transfer by one poll, so allow a sample either side of a changeover before reading them. They hold those constants while battery power swings -1447→+1324 W and PV runs 0→1825 W, so they report state, not magnitude: compare against the constants, don’t read the number as a measurement.</p>
<p>Also worth knowing: <code>0x0509</code> is <em>PV power in watts</em>, not a DC bus voltage. It reads ~154 at 1540W, which makes a 48V→120V inverter look like it’s reporting a ~154V internal bus, and I had it mislabeled that way for weeks. It tracks <code>pv_voltage × pv_current</code> at r=1.000 over 118 distinct values and reads 0 whenever PV current is 0. There is no DC-bus-voltage register on this device.</p>
<p>If you want to check a candidate register yourself, the quick version is: log it every 20-30 seconds through a window where the real quantity is swinging hard - dawn recharge is ideal - then count how many distinct values it took. Live registers took 70-108 distinct values across 180 samples for me. The dead ones took one to four. There’s no gray zone, and it takes one morning.</p>
<p>A couple of cautions on that. A low count alone isn’t proof, because <code>0x203c</code> above only ever takes two values and is perfectly live - if a register describes a state rather than a quantity, ask how many states the machine has. And a register that only changes when the inverter changes mode will look dead unless your window contains a mode change, which is how I had <code>0x204b</code> pegged as the operating-state enum for two months before testing it across a real transfer.</p>
<p>Whatever you disprove, keep it written down with the evidence attached instead of deleting it:</p>
<pre><code class="language-python">Field(0x2127, "cand_pv_power_0x2127",    scale=10.0, unit="W",
      notes="STALE - frozen at 1700W across the 2026-05-25 capture "
            "(read 1700W at dawn with PV≈0). Use derived "
            "pv_voltage × pv_current instead."),
</code></pre>
<p>That note has stopped me re-investigating <code>0x2127</code> at least twice, because 1700 is <u>such</u> a believable number for PV power.</p>
<h2 id="derived">Deriving what the map doesn’t have</h2>
<p>The direct consequence of the stale-cache block: <u>the only trustworthy power numbers on this inverter are derived, not read.</u></p>
<pre><code>pv_power      = pv_voltage      × pv_current
battery_power = battery_voltage × battery_current    # signed, negative = charging
load_balance  = pv_power + battery_power             # what the load is drawing
</code></pre>
<p>Everything downstream - grid offset, charge, discharge, the per-day ledger priced against my time-of-use rates - comes off those two products. Nothing reads a “power” register, because every power register on this device is a fiction.</p>
<p>There’s a second consequence: <em>total grid usage is not measurable from this inverter.</em> No AC-input power or energy register exists and the likely-looking candidates are all stale. The only honest grid quantity available is grid→battery charging, <code>max(0, battery_charge - PV)</code>. True household grid draw needs an external CT clamp, and the software says so instead of inventing a self-sufficiency percentage.</p>
<h2 id="writes">Writing setpoints safely</h2>
<p>Reading is free. Writing to a reverse-engineered address on a device rated for 5kW, with a lithium bank hanging off it, deserves more care - so every write goes through five gates. Three of them are boring and I’ll do them quickly:</p>
<ul>
<li>An allow-list. A field is unwritable until someone registers it explicitly, with bounds and a note saying where those bounds came from.</li>
<li>Engineering-unit bounds. The API and CLI take volts, amps and percent, and conversion to raw happens once via the field’s own scale, so nobody hand-computes a ×0.4 at midnight.</li>
<li>An audit log. Every attempt appends to JSONL with timestamp, initiator and before/after values, <code>fsync</code>‘d, because an audit line that only reached the page cache is not evidence. An unclean reboot right after a setpoint change is the moment you want to know what was written.</li>
</ul>
<p>The other two are worth more words.</p>
<p>Cross-field rules are where the domain knowledge lives. <code>float_voltage ≤ boost_voltage</code>, so the bank is never parked at absorption. <code>mains_to_battery_v > battery_to_mains_v</code>, or the transfer thrashes. And a minimum band between the transfer and handback thresholds:</p>
<pre><code class="language-python"># A grid→battery handback threshold this close to the battery→grid threshold is
# crossed by IR rebound alone when the load drops away, so the transfer never
# settles. Measured 2026-07-11: the two sat 0.4V apart (one raw step) and the
# rack flipped ~95×/hour.
MIN_TRANSFER_BAND_V = 1.0
</code></pre>
<p>That 0.4V is smaller than the bank’s IR rebound when the load lifts, so grid takes the load, the voltage springs back over the handback point, and the load gets handed straight back. Ninety-five relay flips an hour is audible, which is a diagnostic technique I don’t recommend but which is how I first noticed.</p>
<p>Read-back verification is the gate I’d keep if you made me give up the other four, and it’s specific to reverse-engineered maps: <em>Modbus <code>0x06</code> echoes the frame back whether or not the address is real.</em> The inverter will happily “accept” a write to a register it doesn’t own, with a well-formed echo and a valid CRC, and the value simply doesn’t stick. The echo proves nothing, so every write reads itself back:</p>
<pre><code class="language-python">readback = self.read_holding(address, 1)[0]
if readback != value:
    raise ModbusError(
        f"write verify failed at 0x{address:04x}: "
        f"wrote {value} (0x{value:04x}), read back {readback} (0x{readback:04x})"
    )
</code></pre>
<h2 id="coulomb">Measuring charge state: coulomb counting</h2>
<p>Everything else in this system is shaped by one physical fact: <em>LFP has no voltage curve to speak of.</em> From roughly 20% to 90% state of charge the cell just sits on a plateau. I measured it under a full-rack ~26A draw and the bank held at <u>51.8V, dead flat, for nine straight minutes</u> while true per-pack SoC fell from 44% to 42%. Two percent of the bank drained and the terminal voltage did not move!</p>
<p>Which makes any voltage-derived SoC fiction across the entire range you care about, and the inverter’s single SoC register is voltage-derived. The only honest way to get the number is to integrate current - amp-hours in, amp-hours out, against a known capacity. That’s <code>soc.py</code>.</p>
<pre><code class="language-python">def _integrate_locked(self, battery_current: float, gap_s: float) -> None:
    # negative current = charging (into pack); positive = discharging.
    amp_hours = -(battery_current + self.parasitic_a) * (gap_s / SECONDS_PER_HOUR)
    if amp_hours >= 0:  # charging: derate by coulombic efficiency
        self._charge_ah += amp_hours * self.charge_efficiency
    else:               # discharging: full coulombs leave
        self._charge_ah += amp_hours
</code></pre>
<p>A raw integrator drifts forever, so it needs propping up.</p>
<p>Anchor it on physics. When the bank reaches float voltage (≥56.4V) <u>and</u> current has tapered into the CV-phase tail (<5A) <u>and</u> both hold for 10 minutes, the bank is full by definition, so snap to 100%. Standard LFP recalibration, fires on every real full charge.</p>
<p>Make that anchor condition something you continuously observe. If samples stop - daemon restart, inverter dropout - the “at full” hold clock has to reset, or an outage that happens to bridge one high-voltage sample counts the entire blind period as “held at full” and fires the anchor on a half-empty bank the moment samples resume. Took me a while to work out why the counter kept jumping to 100% after restarts. Same applies to the integration itself: gaps longer than <code>max_gap</code> get dropped rather than integrated against stale current. I’d rather under-count than invent amp-hours.</p>
<p>Meter what you’d otherwise clamp. The count clamps at capacity, obviously, but a bank that <u>keeps absorbing charge</u> after you’ve declared it 100% was never full. So instead of throwing that away:</p>
<pre><code class="language-python"># The clamp would bin the evidence that we were never actually full.
if self._charge_ah > self.capacity_ah:
    self._overflow_ah += self._charge_ah - self.capacity_ah
</code></pre>
<p>That meter paid for itself almost immediately. It caught the bank absorbing 51.2Ah over 16 hours past a verified anchor, then 65.3Ah over 21.4 hours on the next cycle. A healthy 200Ah bank cannot do that. Two cycles, one division:</p>
<pre><code>51.2 Ah / 16.0 h  = 3.20 A
65.3 Ah / 21.4 h  = 3.05 A
</code></pre>
<p>Roughly 3.1A that the <code>battery_current</code> register never sees, because it’s the inverter’s own DC-bus self-consumption - about 160W of overhead sitting upstream of the shunt. Left unmetered it was inflating SoC by 40-50Ah a day, a quarter of the bank, which explains a lot of the dusk deaths that happened at a comfortable-looking “23%”. It’s a config constant now, derating charge and inflating discharge during integration. 
The estimator also refuses to claim a number it hasn’t earned: while it’s carrying unexplained overflow it caps itself at 99.5%, and only a real float-and-taper anchor clears the books.</p>
<p>It publishes <code>battery_soc_last_full</code> so the dashboard can show <u>when</u> the count was last anchored, and “never” means it’s still running on its initial seed and you shouldn’t believe it yet. A bare percentage looks equally confident whether it was anchored an hour ago or never, which is how you end up trusting one that’s been drifting since Tuesday.</p>
<h2 id="ble">Ground truth: BLE into the BMS</h2>
<p>Counting coulombs at the bank level has one blind spot. A single measurement sees two parallel packs as one, and on the flat part of the LFP curve two packs at identical terminal voltage can be holding quite different amounts of charge. The weaker one’s BMS cuts out first and takes the rack with it, and none of that is visible from any register on the Modbus bus.</p>
<p>The packs know, though. Each has a BMS, and the BMS talks Bluetooth, because that’s how the phone app works. So: go around the inverter entirely. A TP-Link UB500 dongle (<code>2357:0604</code>, RTL8761BU) went straight into the Proxmox host and got passed through to the monitoring VM. The one gotcha is a spectacularly unhelpful error: the VM is minimal and has no <code>linux-firmware</code>, so <code>hci0</code> refused to come up with <code>rtl8761bu_fw.bin not found, error -2</code>. Rather than drag a multi-gigabyte firmware package onto a small VM, drop in the two files it actually wants - <code>rtl8761bu_fw.bin</code> (44 KB) and <code>rtl8761bu_config.bin</code> (6 bytes, yes really, six) - reload <code>btusb</code>, and you have a controller.</p>
<p>A GATT dump confirmed these are JBD/Xiaoxiang boards, the most common Chinese LFP BMS family and well enough documented by the community to work from. Dump the table, don’t trust the brand on the label - Eco-Worthy is a case around somebody else’s board, and whose board it is turns out to be the only question that matters.</p>
<p>After Modbus-with-caveats it’s a genuinely pleasant protocol. Service <code>0xFF00</code>, write commands to <code>0xFF02</code>, replies arrive as notifications on <code>0xFF01</code>. Same frame shape both directions:</p>
<pre><code>DD A5 <cmd> <len> <payload...> <chk_hi> <chk_lo> 77
</code></pre>
<p>Two commands cover everything worth having: <code>DDA50300FFFD77</code> for basic info and <code>DDA50400FFFC77</code> for per-cell millivolts. The checksum is <code>0x10000 - sum(cmd, len)</code>, and <code>len</code> is 0 for reads.</p>
<p>Replies arrive chunked across MTU-sized notifications, so you buffer and ask “is that a whole frame yet” after each one. Check the declared length byte and not just the trailing <code>0x77</code>, or the first payload containing a stray <code>0x77</code> will hand you a truncated frame that decodes perfectly and means nothing:</p>
<pre><code class="language-python">def frame_complete(buf: bytes) -> bool:
    if len(buf) < 7 or buf[0] != 0xDD:
        return False
    payload_len = buf[3]
    return len(buf) >= 4 + payload_len + 3 and buf[-1] == 0x77
</code></pre>
<p>The basic-info decode is the prize. SoC, true pack current, residual capacity, cycles, protection flags, FET state and temperatures, all out of one 0x03 reply:</p>
<pre><code class="language-python">def decode_basic(frame: bytes) -> dict:
    d = _validate(frame, 0x03)
    ntc = d[22]
    temps = [
        (struct.unpack(">H", d[23 + 2*i:25 + 2*i])[0] - 2731) / 10.0
        for i in range(ntc)
    ]
    return {
        "voltage":    struct.unpack(">H", d[0:2])[0] / 100.0,
        "current":    struct.unpack(">h", d[2:4])[0] / 100.0,   # signed, +charge
        "resid_ah":   struct.unpack(">H", d[4:6])[0] / 100.0,
        "nominal_ah": struct.unpack(">H", d[6:8])[0] / 100.0,
        "cycles":     struct.unpack(">H", d[8:10])[0],
        "protection": struct.unpack(">H", d[16:18])[0],
        "soc_pct":    d[19],
        "fet":        d[20],
        "ncell":      d[21],
        "temps_c":    temps,
    }
</code></pre>
<p>Temperatures are decikelvin, hence the 2731. SoC is a single byte at offset 19. Cell voltages come back from <code>0x04</code> as a big-endian u16 of millivolts per cell, which gets you cell spread for free - a useful early warning that a pack is coming apart internally.</p>
<p>One trap when you bolt this onto something that already exists: <u>the BMS says positive is charging, the inverter says negative is charging.</u> Same current, same bank, opposite signs. Rather than rely on remembering that at every call site, the reading object carries an explicit <code>current_inverter_convention()</code> that re-signs it, and the convention is written on the dataclass where it can’t be missed.</p>
<p>The frame decoders are pure functions with <code>bleak</code> deferred into the one async call that needs it, so the whole protocol layer unit-tests against captured frames on a machine with no Bluetooth stack at all.</p>
<h2 id="fusion">Fusing the two signals</h2>
<p>Per-pack telemetry gets you two things neither source produces alone.</p>
<p>The imbalance floor is <code>min(pack_socs)</code>, not the average. The weak pack is the one that cuts out, so the weak pack is the number the system runs on. It publishes the spread too and warns above 10 points, because two packs on the same bus drifting apart is something you want to hear about early rather than at dusk.</p>
<p>The parasitic cross-check falls out for free. The BMS measures true current at the pack terminals; the inverter register can’t see the inverter’s own bus draw. Subtract one from the other and there it is. First time I ran it the register said -12.7A “charging” while the BMS said -10.0A at the terminals, and that 2.7A gap is the same self-consumption I’d fitted weeks earlier from overflow accumulation - completely different data, agreeing to within half an amp. I was quite pleased with that one!</p>
<p>It runs continuously now and warns if the implied value drifts more than 1.5A from the configured constant, so a change in the inverter’s idle draw shows up as a log line rather than as mysterious SoC drift three weeks later. Gated on <code>|current| ≥ 3A</code>, because at rest you’re dividing noise by noise.</p>
<h2 id="guard">Closing the loop: routing the load</h2>
<p>With a trustworthy charge number, the last piece is acting on it: keeping the load on the battery while there’s charge, and moving it to grid before the bank gets low enough for a pack to cut out.</p>
<p>The obvious approach is to set the inverter’s own thresholds and let its routing logic get on with it. I spent weeks on that. It does not work on this hardware, and not for want of trying values:</p>
<table>
<thead>
<tr>
<th>Lever</th>
<th>Why it doesn’t close the loop</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>battery_to_mains_v</code></td>
<td>Voltage-gated on a chemistry with no voltage slope. It can force the load to grid, but never hands it back.</td>
</tr>
<tr>
<td><code>mains_to_battery_v</code></td>
<td>Firmware-floored (must exceed <code>battery_uv_recovery_v</code>), and its return is a lagged level trigger of about a minute. Set it above the float ceiling and it means “never switch back,” which parks the load on grid and halves PV harvest, since the MPPT curtails once the bank is full with nowhere to send power.</td>
</tr>
<tr>
<td><code>switch_to_mains_soc</code></td>
<td>Only governs when the BMS is actively talking to the inverter. When it isn’t, the parameter is inert and the box silently falls back to the voltage threshold.</td>
</tr>
<tr>
<td><code>switch_to_inverter_soc</code></td>
<td>Measured inert on this firmware at 60, 90 and 100. There’s no SoC hysteresis pair; the load hands back the instant SoC clears the single threshold.</td>
</tr>
</tbody>
</table>
<p>So instead of nudging thresholds and hoping, drive the load source <u>directly</u>, from the signal that knows the charge state, using the control that moves the load immediately: <code>supply_priority_mode</code>, the UTI/SBU/SOL enum at <code>0x1102</code>.</p>
<p>The decision itself is a pure function, which makes it trivial to test every path:</p>
<pre><code class="language-python">def decide(state, soc_pct, soc_age_s, cfg) -> str:
    """Next state, given the current one and the freshest SoC we have."""
    trusted = soc_pct is not None and soc_age_s <= cfg.stale_seconds

    if not trusted:
        # Cannot see the battery. Never switch back to it; if we were already on
        # mains, stay there. On first run with no reading, assume the worst.
        return GRID if state in (None, GRID) else state

    if soc_pct <= cfg.floor_pct:
        return GRID
    if soc_pct >= cfg.resume_pct:
        return BATTERY
    # Between the rails: hold.
    return state or BATTERY
</code></pre>
<p>Weakest-pack SoC at or below the floor (35%) writes mode 1, UTI: load runs on mains, battery stops draining, PV keeps refilling it. At or above resume (55%) it writes mode 2, SBU, and the load goes back to solar and battery. The config refuses to start if <code>resume ≤ floor</code>, because a guard with no hysteresis will just sit there flapping the relay all evening.</p>
<p>What makes it safe to leave running unattended is that every failure mode resolves toward grid. Grid is the backstop; the battery is the thing I can destroy. Stale SoC, dead BLE, first run with no reading - all route to mains and stay there, and it will never switch <u>back</u> to a battery it can’t currently measure. It writes through the same SafeWriter as everything else, so an automated actor gets no privileged bypass, and it reads back the live mode first, so a landed write goes quiet while a <u>rejected</u> one keeps retrying in the audit log instead of just sitting there doing nothing.</p>
<p>The switch moves the load in about <u>15 seconds</u>, against the minute-long lag the voltage levers gave me. In service it cycles battery→grid around 35% and back around 55% through each dusk.</p>
<p>There are two easy ways to get the verification wrong, and I found both of them:</p>
<p>Working out which source is carrying the load needs an energy balance, not the charge sign. In daylight the bank charges whether the load is on grid or on battery, because PV surplus spills in either direction, so <code>battery_current < 0</code> tells you precisely nothing. I burned about three hours on this and confidently reported a “hysteresis works” result that was entirely an artifact of it. What does discriminate, at any hour:</p>
<pre><code>load + parasitic ≈ 475 W       # measured at dawn: PV ≈ 0, bank supplies everything
implied_mains = bank_charge_W + 475 - PV_W
                                # ≈0 ⇒ on battery;  >250 ⇒ on grid
</code></pre>
<p>The software coulomb counter is not in the routing path, and that was a deliberate demotion. The guard gates on <code>min(pack_socs)</code> from the BMSes and nothing else. My estimator is a model integrating a register that’s missing 3.1A, leaning on a float anchor that might not have fired in days; each JBD pack meanwhile does its own hardware coulomb count against its own shunt. Where those disagree the hardware wins, and it took a bad night to accept that - the estimator once read 33% while both packs sat at ~76%, and dragged the guard into a pointless grid transfer at dusk on a three-quarters-full bank. It’s a fine dashboard number. It has no business steering a contactor.</p>
<h2 id="deploy">How it’s deployed</h2>
<p>Three systemd units on a Rocky 9 VM with the CH340 and the BT dongle passed through: <code>solar-monitor-daemon</code> owns the serial port and everything derived from it, <code>solar-monitor-ble</code> owns the Bluetooth adapter and the routing guard, and <code>solar-monitor-shim</code> turns MQTT into REST for the dashboard. The BLE unit runs as root because BlueZ’s D-Bus policy denies <code>org.bluez</code> to everyone else. Both pollers are <code>Type=notify</code> with a systemd watchdog, and the daemon carries its own on top for the hung-serial-read case.</p>
<p>Couple of things worth stealing. A udev rule pins the inverter to <code>/dev/solar-inverter</code> and kills USB autosuspend for the CH340, because a suspended adapter looks just like a dead inverter and I only wanted to have that particular afternoon once. And the BLE poller must <u>not</u> inherit the shared <code>mqtt.client_id</code> - two clients with the same id collide on the broker, kick each other in a reconnect storm, and take the daemon’s publishing down along with the poller’s.</p>
<p>Config is one YAML file; the only values you’d have to think about are <code>capacity_ah</code> (must match the real bank or the percentage is meaningless), <code>parasitic_a</code>, the float-anchor pair <code>full_voltage</code>/<code>taper_current_a</code>, and the guard’s <code>floor_pct</code>/<code>resume_pct</code>.</p>
<h2 id="register-map">The full register map</h2>
<p>Everything below came off the live device. Bus is Modbus-RTU, function <code>0x03</code>/<code>0x06</code>, 9600 8N1, slave 1, ≤32-register block reads, ≥50ms between requests. Raw <code>4096</code> (<code>0x1000</code>) and <code>0xFFFF</code> are “no value” sentinels.</p>
<p>Live readings - these track reality and cross-check against the LCD:</p>
<table>
<thead>
<tr>
<th>Addr</th>
<th>Field</th>
<th>Scale</th>
<th>Unit</th>
<th>How confirmed</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0x0500</code></td>
<td>battery_voltage</td>
<td>0.1</td>
<td>V</td>
<td>raw 533 → 53.3V, matches LCD exactly</td>
</tr>
<tr>
<td><code>0x0501</code></td>
<td>battery_current</td>
<td>0.1</td>
<td>A signed</td>
<td>-12.3A while PV→battery arrow lit; negative = charging</td>
</tr>
<tr>
<td><code>0x0502</code></td>
<td>battery_soc</td>
<td>1</td>
<td>%</td>
<td>the only SoC the inverter exposes; where it comes from varies, see limits</td>
</tr>
<tr>
<td><code>0x0507</code></td>
<td>pv_voltage</td>
<td>0.1</td>
<td>V</td>
<td>matches LCD PV reading</td>
</tr>
<tr>
<td><code>0x0508</code></td>
<td>pv_current</td>
<td>0.1</td>
<td>A</td>
<td>live PV input current (manual max 22A)</td>
</tr>
<tr>
<td><code>0x0509</code></td>
<td>pv_power_reported</td>
<td>1</td>
<td>W</td>
<td>= pv_v × pv_i, r=1.000 over 118 distinct values</td>
</tr>
<tr>
<td><code>0x0510</code></td>
<td><u>unidentified</u></td>
<td>?</td>
<td>W?</td>
<td>live, loosely tracks PV, not proportional. Open</td>
</tr>
<tr>
<td><code>0x1104</code></td>
<td>ac_output_voltage</td>
<td>0.1</td>
<td>V</td>
<td>raw 1200 → 120.0V</td>
</tr>
<tr>
<td><code>0x212c</code></td>
<td>ac_input_voltage</td>
<td>0.1</td>
<td>V</td>
<td>raw 1150 → 115.0V; never moves, treat as dead</td>
</tr>
<tr>
<td><code>0x203c</code></td>
<td>mains_state_0x203c</td>
<td>n/a</td>
<td>state</td>
<td>3588.7 on mains / 191.9 on battery - tracks load source</td>
</tr>
<tr>
<td><code>0x203d</code></td>
<td>mains_state_0x203d</td>
<td>n/a</td>
<td>state</td>
<td>212.8 on mains / 8.0 on battery - moves with <code>0x203c</code></td>
</tr>
</tbody>
</table>
<p>Settings - every row value-matched against a photograph of the corresponding LCD parameter. Charge-curve voltages use the ×0.4 per-12V-cell scale:</p>
<table>
<thead>
<tr>
<th>Addr</th>
<th>Field</th>
<th>LCD</th>
<th>Scale</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>0x1102</code></td>
<td>supply_priority_mode</td>
<td><code>[01]</code></td>
<td>1</td>
<td>1=UTI grid-first, 2=SBU battery-first, 3=SOL PV-first</td>
</tr>
<tr>
<td><code>0x110f</code></td>
<td>charging_mode</td>
<td><code>[06]</code></td>
<td>1</td>
<td>2=SNU (PV+mains hybrid); OSO/CSO/CUB codes not fully decoded</td>
</tr>
<tr>
<td><code>0x1003</code></td>
<td>max_charge_current</td>
<td><code>[07]</code></td>
<td>0.1</td>
<td>A, hardware ceiling 100A</td>
</tr>
<tr>
<td><code>0x1007</code></td>
<td>boost_voltage</td>
<td><code>[09]</code></td>
<td>0.4</td>
<td>absorption; raw 146 → 58.4V</td>
</tr>
<tr>
<td><code>0x1008</code></td>
<td>float_voltage</td>
<td><code>[11]</code></td>
<td>0.4</td>
<td>should be ≤ boost</td>
</tr>
<tr>
<td><code>0x1009</code></td>
<td>battery_recharge_v</td>
<td><code>[37]</code></td>
<td>0.4</td>
<td>recharge recovery point</td>
</tr>
<tr>
<td><code>0x100a</code></td>
<td>battery_uv_recovery_v</td>
<td><code>[35]</code></td>
<td>0.4</td>
<td>the register that actually ends a grid stint</td>
</tr>
<tr>
<td><code>0x100c</code></td>
<td>over_discharge_v</td>
<td><code>[12]</code></td>
<td>0.4</td>
<td>over-discharge cutoff</td>
</tr>
<tr>
<td><code>0x100d</code></td>
<td>battery_uv_alarm_v</td>
<td><code>[14]</code></td>
<td>0.4</td>
<td>under-voltage alarm</td>
</tr>
<tr>
<td><code>0x1010</code></td>
<td>ac_output_rated_v</td>
<td><code>[38]</code></td>
<td>1</td>
<td>V</td>
</tr>
<tr>
<td><code>0x1012</code></td>
<td>ac_output_frequency</td>
<td><code>[02]</code></td>
<td>1</td>
<td>Hz</td>
</tr>
<tr>
<td><code>0x1018</code></td>
<td>battery_to_mains_v</td>
<td><code>[04]</code></td>
<td>0.4</td>
<td>switch load to grid below this</td>
</tr>
<tr>
<td><code>0x1019</code></td>
<td>mains_to_battery_v</td>
<td><code>[05]</code></td>
<td>0.4</td>
<td>switch back above this; above float = never</td>
</tr>
<tr>
<td><code>0x101b</code></td>
<td>cutoff_charge_soc</td>
<td><code>[60]</code></td>
<td>1</td>
<td>%</td>
</tr>
<tr>
<td><code>0x101c</code></td>
<td>discharge_alarm_soc</td>
<td><code>[58]</code></td>
<td>1</td>
<td>% - alarm only, does not transfer load</td>
</tr>
<tr>
<td><code>0x101d</code></td>
<td>switch_to_mains_soc</td>
<td><code>[61]</code></td>
<td>1</td>
<td>% - only acts when BMS comms are live</td>
</tr>
<tr>
<td><code>0x101e</code></td>
<td>switch_to_inverter_soc</td>
<td><code>[62]</code></td>
<td>1</td>
<td>% - measured inert on this firmware</td>
</tr>
<tr>
<td><code>0x1023</code></td>
<td>rs485_address</td>
<td><code>[30]</code></td>
<td>1</td>
<td>slave id</td>
</tr>
<tr>
<td><code>0x1024</code></td>
<td>equalization_interval_d</td>
<td><code>[20]</code></td>
<td>1</td>
<td>days</td>
</tr>
<tr>
<td><code>0x1103</code></td>
<td>ac_charge_current</td>
<td><code>[28]</code></td>
<td>0.1</td>
<td>A - DC-side, not AC draw. See limits</td>
</tr>
<tr>
<td><code>0x202d</code></td>
<td>mppt_temperature</td>
<td>-</td>
<td>1</td>
<td>°C, matched LCD 55°C; needs a thermal delta to fully confirm</td>
</tr>
</tbody>
</table>
<p>Known-dead, published so nobody re-investigates them: <code>0x203f</code> (looks like load %), <code>0x204c</code> and <code>0x2031</code> (look like load watts), <code>0x2030</code> (looks like VA), <code>0x2127</code> and <code>0x2125</code> (look like PV), <code>0x2049</code> and <code>0x2034</code> (look like SoC), <code>0x111f</code> (looks like a second SoC - reads 40% on a near-empty bank), <code>0x203e</code> (looks like AC-in volts), <code>0x2121</code>, <code>0x2123</code>, <code>0x2037</code>, <code>0x2044</code>, <code>0x204f</code>. All frozen across long captures, including across real grid↔battery load transfers.</p>
<p><code>0x204b</code> is dead, and it’s the one I’d most expected to be alive. An operating-state enum observed at 5, with an SRNE analog, plausibly “on grid / on battery / standby” - the one bit of routing visibility this map otherwise lacks. It sits at 5 across real grid↔battery transfers in both directions, so whatever it is, it isn’t that. The load-source signal is at <code>0x203c</code>/<code>0x203d</code> instead.</p>
<p>Still open: <code>0x2116</code> and <code>0x2041</code>, candidate temperatures. They stay flat for a legitimate reason - temperatures move slowly and nothing thermal happened while I was polling - so they need a real thermal swing before anyone can say anything about them. And <code>0x0510</code>, which is unambiguously live (142 distinct values) but whose best correlate is battery power at r=-0.876 with a ±40% ratio spread. A ±40% spread is what made me throw out <code>0x203c</code> the first time, and I’d rather not repeat that particular mistake, so it stays unnamed until PV and charge power drift far enough apart to tell them apart.</p>
<h2 id="caveats">Limits worth knowing before you start</h2>
<ul>
<li><code>ac_charge_current</code> <code>[28]</code> is DC-side, not AC draw. The manual calls it “Maximum AC charging current.” It’s the DC current into the battery, so 15A DC is roughly 7.5A AC at the wall, and it does <u>not</u> cap total AC draw - the inverter still pulls whatever the load needs on top of it. Worth knowing before you use it as a budget for anything.</li>
<li>The coulomb estimator has one open issue. Its float anchor needs the bank to reach 56.4V with tapered current, and under the SoC-guard regime the bank cycles roughly 35-80% and rarely gets there, so <code>last_full</code> can sit at “never” and the count free-drifts. It’s decoupled from routing and the dashboard flags the low confidence, but the energy ledger leans on it. On the list.</li>
<li>Which lever governs depends on whether BMS comms are live. If the battery is talking to the inverter, <code>switch_to_mains_soc</code> is the real governor and the voltage thresholds do nothing - I held <code>battery_to_mains_v</code> a full volt above the bank for three minutes and the load never moved. With no BMS link it falls back to voltage, which on LFP means it fires far too late. Check which regime you’re in before tuning anything, because the same setpoint does opposite amounts of nothing in each.</li>
<li>The BLE side is JBD-generic. If your pack answers on service <code>0xFF00</code>, the decoder above should work as-is - but dump the GATT table and confirm before you trust the badge on the case.</li>
</ul>
<p>The code that drives all this isn’t public - it’s a personal thing held together with optimism and systemd units, and it assumes my exact bank and my exact power rates. But the map <u>is</u> all up there, dead registers included. If you’ve got the same box, that should be enough to skip most of what I went through.</p>]]></content:encoded>
    </item>
    <item>
      <title>Everything I Tried on the Strix Halo That Didn&#x27;t Work (and Why)</title>
      <link>https://damenknight.com/strix-halo-what-didnt-work/</link>
      <guid isPermaLink="true">https://damenknight.com/strix-halo-what-didnt-work/</guid>
      <pubDate>Wed, 15 Jul 2026 12:00:00 GMT</pubDate>
      <category>AI</category>
      <category>Projects</category>
      <category>Homelab</category>
      <description>A running list of everything on the Strix Halo box that didn’t work, sorted by how it failed rather than how embarrassing it was (there was no shortage of…</description>
      <content:encoded><![CDATA[<p>I’ve been doing a lot of writeups about the Strix Halo and various tunings and wins I’ve managed to eke out of it, because it’s a relatively low-cost and accessible system and it’s just fun. That said, obviously there is a HUGE pile of things I tried on the Strix Halo box that <u>didn’t</u> pan out and I think there’s some interesting learnings in that too.</p>
<p>So here we go, a list of failures sorted by <em>how</em> they failed, because on this hardware the how is the useful part.</p>
<p>The failures modes fall into three buckets:</p>
<ul>
<li>Type 1, it ran and was <em>slower or worse</em>.</li>
<li>Type 2, it silently did <em>nothing</em>, which is the failure mode that ate the most days.</li>
<li>Type 3, it worked and I <em>measured it wrong</em>.</li>
</ul>
<p>Reminder, because it drives everything below: this box is not bandwidth-starved in the abstract, it is bandwidth-<em>pinned</em>. I measured decode reading memory at ~95% of the read ceiling (242.7 of 256 GB/s). We cannot stream faster. So every real win has to move <em>fewer bytes</em>, and most of what follows is me learning that (over and over again) the hard way.</p>
<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#ran-worse" style="font-size: 0.9375rem; text-decoration: none;">Type 1: it ran, and it was worse</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#silent-noop" style="font-size: 0.9375rem; text-decoration: none;">Type 2: it silently did nothing</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#read-wrong" style="font-size: 0.9375rem; text-decoration: none;">Type 3: it worked, I read it wrong</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#pattern" style="font-size: 0.9375rem; text-decoration: none;">The pattern</a></li>
</ul>
</div>

<h2 id="ran-worse">Type 1: it ran, and it was worse</h2>
<p>The first entry is a bit of a cheat, it never even got to run, but it’s the wall the whole project is pinned against, so it goes first.</p>
<h3>Memory and fabric overclock: architecturally locked</h3>
<p>The obvious first move on a bandwidth-pinned box is to raise the memory clock. You can’t. Infinity Fabric on this part is rate-matched to the memory clock, there’s no independent FCLK knob, and memory frequency is SKU-binned at 8000 (AMD ships 8533 as separate part numbers). I went as far as the AMI/AGESA BIOS unlock that <a href="https://winraid.level1techs.com/t/request-unlock-bios-uefi-for-amd-395-strix-halo/111395">others have tried on 395 boxes</a>; it fails, and recovery needs a hardware SPI programmer. The clocks were already pinned at max, not thermally throttled. Revive this only with different silicon, an 8533 SKU or an LPDDR6 next-gen part. On this box it’s closed.</p>
<h3>rocWMMA flash-attention regresses past 32k</h3>
<p>The <a href="https://kyuz0.github.io/amd-strix-halo-toolboxes/">community-tuned ROCm numbers</a> looked great, so I chased a ROCm + rocWMMA flash-attention build. It helps at short context and, by <a href="https://strixhalo.wiki/AI/llamacpp-with-ROCm">community guidance</a> and my own A/B against RADV, gives ground back at depth past ~32k, which is exactly the range this whole project cares about. The strixhalo.wiki maintainers now say it outright: as of ROCm 7.0.2+ the rocWMMA path shouldn’t be used with upstream llama.cpp, it’s slower than the plain ROCm/HIP path as context grows. RADV/Vulkan was the faster path at depth in my runs, so the ROCm detour was a net loss for the long-context case. The tuned 13.3 tok/s-at-130k number floating around comes from a <a href="https://github.com/lhl/llama.cpp/tree/rocm-wmma-tune">patched branch</a> (<a href="https://github.com/lhl/strix-halo-testing">lhl’s Strix Halo benchmarking</a>), not a stock build, which is worth knowing before you go replicate it.</p>
<h3>MALL residency is too small to matter</h3>
<p>gfx1151 has a 32MB L3/MALL that is genuinely strong when your working set fits, 3.4-9.6x DRAM bandwidth. A 35B MoE’s working set does not fit in 32MB. Measured, cache-residency bought about 1.1x on decode, and it’s conditional and it costs you recall to arrange. The cache is real and the win is not, for this model. This could be fun to revisit with smaller models, however! I <em>really</em> wanted to find a way to make this one work out.</p>
<h3>KV-cache quant tax</h3>
<p>q4_0 KV quant <em>hurts</em> prefill at mid context on this box (131k: 393 tok/s vs f16’s 473, a 0.83x loss), because the dequant overhead outweighs the bandwidth it saves at that depth. It flips to a win only at extreme context (256k: q4_0 240 vs f16 223, 1.08x). And once you’re running the sliding-window recipe, quant <em>loses</em> again (256k: q4_0 767 vs SWA-f16 855, 0.90x), because the window already bounded the KV to a size where there’s no bandwidth pressure left for quant to feed on. SWA and KV-quant are substitutes, not complements, you pick the bigger lever, you don’t stack them. I spent… a while trying to stack them.</p>
<h2 id="silent-noop">Type 2: it silently did nothing</h2>
<p>This is the long one, and the pile that cost me the most days, because a silent no-op is the only failure that doesn’t have the decency to announce itself. The run finishes, nothing errors, and the numbers come back looking exactly as plausible as a real result. You can burn a whole experiment on one and not know until you go digging.</p>
<h3>The “content-aware” sparse mask was never content-aware</h3>
<p>The tell, in hindsight, is embarrassingly clean. I had a <code>tau</code> threshold knob on the selector, and sweeping it across its entire range (0.01 to 0.99) moved neither prefill speed nor perplexity by a hair. At a 16k window every setting landed on ~753 tok/s and PPL 1.3512; at a 2k window every setting landed on ~903 tok/s. The absolute numbers move with the window, but <code>tau</code> itself was inert at both. A knob that changes nothing isn’t tuning anything, so my content selector was selecting no content. The reason is a softmax over all blocks peaked so hard on recent tokens that far-back salient blocks never clear any threshold worth setting, which quietly collapsed the whole thing into a plain sink-plus-sliding-window. I chased that for a week before I caught it, and the full autopsy (the mechanism, the max-pool swap that changed nothing, the DeltaNet layers that were actually carrying the needle) is <a href="https://damenknight.com/strix-halo-256k-context/">in the 256k post</a>. The Type 2 lesson is small and I won’t forget it again: if a feature has an off switch, run it. If “off” looks identical to “on,” it was never on.</p>
<h3>The ggml step() bug: the mask that never masked</h3>
<p>This one lived underneath the last one, and it came first: before my mask was even a lazy sliding window, it was nothing at all. It leaned on ggml’s <code>step()</code>, and <code>step(0)</code> disagrees with itself across backends: 0 on CPU, 1 on Vulkan. On the GPU path that meant my mask never dropped a tile, flash-attention never skipped anything, and I ran a whole recall curve that was silently dense. I chased it down, filed it as <a href="https://github.com/ggml-org/llama.cpp/issues/25027">issue #25027</a> (accepted and fixed upstream), and <a href="https://damenknight.com/ggml-step-vulkan-step0-bug/">wrote the whole saga up separately</a>, so I’ll spare you the forensics here. It earns a mention because it’s the purest Type 2 specimen I have: my code was correct, the framework disagreed with itself at exactly one input value, and the only symptom was “the optimization does nothing,” with no error anywhere.</p>
<h3>The additive-mask hack was O(N^2) in bookkeeping</h3>
<p>This one ran, and ran correctly, which is exactly why it lands in this bucket: the mask masked, the outputs were right, and the speedup it was supposed to buy quietly never showed up. The tell was that it kept eroding the deeper I went. A sliding window applied as an additive -inf mask still makes flash-attention walk every query-tile / key-tile pair and pay a per-tile check on the all-masked tiles it then throws away: O(N) in real attention work, O(N^2) in tile bookkeeping, and at depth the bookkeeping is most of the bill. The <a href="https://damenknight.com/strix-halo-256k-context/">256k post has the ladder</a>; the lesson for this list is that “it’s masked” and “it’s cheap” are separate claims, and a mask riding on top of a dense kernel only ever buys you the first one. The actual win needed native sliding-window attention with a bounded rolling KV cache (flat 850-890 tok/s from 32k to 1M, a 32x range that sags 4.8%), which is O(N) for real. The mask was a scaffold that looked like the answer.</p>
<h3>Env-gated patches silently no-op on the wrong binary</h3>
<p>This is the one I’d carve into the case. HOMER has two <code>llama-server</code> binaries: the system one at <code>/usr/local/bin</code> (no patches) and my <code>build-vk/bin</code> build (the SWA and per-layer-quant patches). My patches are environment-gated, so on the unpatched binary they don’t error, they just quietly do nothing, <code>n_swa</code> stays 0 and the model runs dense. I ran a windowing sweep against the system binary once and got a clean, plausible, completely dense curve, and nothing in the output said a word. The only defense is behavioral: an out-of-window needle <em>must</em> miss, and if it doesn’t, either you’re on the wrong binary or the flag never took. I hit this same wall on the 256k work too, which is why “it ran” stopped counting as “it ran patched” for me. Every grid now starts with a needle the feature is supposed to drop, and if it survives, I throw the grid out before I read a single number.</p>
<h3>Kernel-level knobs that measured to nothing</h3>
<p>A cluster of small ones, grouped because they share a shape: I turned them on, measured, and found no signal. Transparent hugepages, a measured no-op (the GART already uses large pages). <code>amd_iommu=off</code>, neutral, because <code>iommu=pt</code> overrides it and there was near-zero headroom anyway. <a href="https://llm-tracker.info/_TOORG/Strix-Halo">Borrowing gfx1100 kernels</a> via <code>HSA_OVERRIDE_GFX_VERSION=11.0.0</code> looked incredible on a GEMM microbench (5.6x) and produced no end-to-end LLM win I could measure. Microbenchmark speedups that don’t survive contact with a real forward pass are their own kind of silent nothing.</p>
<h2 id="read-wrong">Type 3: it worked, I read it wrong</h2>
<h3>The NIAH “5/5” that was measuring thinking budget</h3>
<p>qwen3.6 is a reasoning model, and for a while I measured needle-in-a-haystack recall as “did it answer within the <code>-n</code> token budget,” not “could it retrieve.” Those aren’t the same thing. A too-short budget makes the model look like it failed to recall when it just ran out of room to think, and a generous one flatters recall for reasons that have nothing to do with the retrieval mechanism I thought I was testing. Several of my early clean 5/5 numbers were contaminated by this, including a “win4096 5/5 but +74% PPL” that I’d been treating as a real recall win. I now measure recall with greedy decode and a stop token, deterministic and exact, and I re-ran the suspect numbers.</p>
<h3>The temp-0 degeneracy attractor that fooled me three times</h3>
<p>The single most expensive misread of the project. At temp-0 greedy decode, deep-context generation on this model falls into a degeneracy attractor, it starts emitting bare “3” or single-character garbage, and it does this non-monotonically: a keep-budget sweep goes hit / miss / hit / miss with no pattern, and the baseline itself flips run to run. I read this as “eviction is dropping the needle” at least three separate times before pinning it. It is not an eviction failure, the scorer keeps the needle at global rank #1; it is a <em>decode-side</em> collapse from attention-sink deprivation, and bumping temperature to 0.3 partially stabilizes it. I burned days attributing a generation-side attractor to my byte-reduction code because the symptom (wrong answer at depth) looked identical to the thing I was afraid of.</p>
<h3>The NPU that was the right answer to the wrong model</h3>
<p>Not a mistake so much as a scope misread. The NPU’s flat-prefill win is real, I measured it, on a <em>dense</em> proxy model (Qwen3-8B), where dense attention collapses with context and the NPU’s steady prefill overtakes the iGPU around 16-32k. My actual hero model is an MoE whose iGPU prefill is robust and defers that collapse past the range I care about, so the crossover never appears and the NPU stops being a lever. The measurement was right; treating it as a general win for <em>this</em> model was the error. <u>But turn that around, because it’s not a dead end so much as an open door I walked past</u>. On a <em>dense</em> long-context model the crossover is real: the NPU genuinely overtakes the iGPU on prefill past ~16-32k, and the only reason I didn’t chase it further is that my hero model defers the collapse the NPU was going to rescue me from. The stack is already standing on HOMER. So if you’re running a dense model at depth on one of these boxes, this is the thread I’d pull, and I’d love to hear where it lands, because I think there’s a real win sitting there that I just wasn’t the right person to find.</p>
<h2 id="pattern">The pattern</h2>
<p>The Strix box is pinned at the bandwidth wall, so saving bytes is the <em>only</em> thing that helps, and I spent most of these dead-ends discovering that a thing I thought was saving bytes wasn’t doing anything at all.</p>
<ul>
<li>A mask that masked nothing.</li>
<li>A patch on the wrong binary.</li>
<li>A kernel knob that microbenched beautifully and moved no real tokens.</li>
</ul>
<p>The dangerous failures aren’t the slow ones, they’re the ones where the run completes, the numbers look plausible, and you’re one behavioral check away from finding out you measured a no-op. The single habit that came out of all of this: before trusting any result, prove the thing you think you turned on is the thing that ran. An out-of-window needle has to miss. Or you’re measuring dense and calling it a win.</p>]]></content:encoded>
    </item>
    <item>
      <title>Do the wins stack? MTP on top of sliding-window attention</title>
      <link>https://damenknight.com/mtp-swa-stack/</link>
      <guid isPermaLink="true">https://damenknight.com/mtp-swa-stack/</guid>
      <pubDate>Sat, 04 Jul 2026 12:00:00 GMT</pubDate>
      <category>AI</category>
      <category>Projects</category>
      <category>Homelab</category>
      <description>Two decode-speed wins from this series, MTP and the sliding-window recipe, attack different costs, so they should just stack. They didn’t even get the chance -…</description>
      <content:encoded><![CDATA[<p>TL;DR: Yes! With a few very large asterisks, you CAN stack MTP and SWA. You need a patch for llama.cpp (below), and the payoff depends entirely on what you’re generating: on predictable output the stack is spectacular (+46% decode at 123K), but on open-ended generation MTP still loses money even with SWA under it.</p>
<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#setup" style="font-size: 0.9375rem; text-decoration: none;">Where the last post left off</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#crash" style="font-size: 0.9375rem; text-decoration: none;">The crash</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#rebase" style="font-size: 0.9375rem; text-decoration: none;">Llama.cpp moves FAST</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#fix" style="font-size: 0.9375rem; text-decoration: none;">The actual fix</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#best-case" style="font-size: 0.9375rem; text-decoration: none;">Best case: super-additive at 123K</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#mechanism" style="font-size: 0.9375rem; text-decoration: none;">Why SWA lifts acceptance</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#worst-case" style="font-size: 0.9375rem; text-decoration: none;">Forcing the worst case</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#rule" style="font-size: 0.9375rem; text-decoration: none;">The rule</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#patch" style="font-size: 0.9375rem; text-decoration: none;">Getting the patch</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#caveats" style="font-size: 0.9375rem; text-decoration: none;">Caveats</a></li>
</ul>
</div>

<h2 id="setup">Where the last post left off</h2>
<p>In <a href="https://damenknight.com/strix-halo-256k-context/">the 256K post</a> I got usable long context out of my Strix Halo box by marking the hybrid’s ten full-attention layers sliding-window, keeping a few of them full as a far-recall backstop. Bounded KV, flat prefill, decode that holds up at depth. That post deliberately left one thread on the table: whether the <em>other</em> decode lever still works on top of it.  I mean one win is cool but two is better, right?</p>
<p>That other lever is MTP - multi-token prediction, llama.cpp’s self-speculative decode - which I previously <a href="https://damenknight.com/mtp-speculative-decoding-strix-halo/">got working on this box</a>, then <a href="https://damenknight.com/mtp-mistakes/">gave bad advice about</a>, then apologized for. The short version of those two posts: MTP drafts a few tokens ahead with a lightweight head and verifies them in one pass, the payoff is governed by the <a href="https://arxiv.org/abs/2211.17192">Leviathan et al.</a> formula, and whether it helps depends on the acceptance rate α, the draft depth γ, and the platform cost ratio c. On this box, with the right <code>--spec-draft-n-max</code>, it’s a real win.</p>
<p>So: SWA bounds the KV and keeps decode fast at depth. MTP amortizes the weight reads. They attack different costs so they should stack, you’d think. You can see where this is going.</p>
<p>(A confession: if you applied the full fork patch from the 256K post, or read its diff unusually closely, you may have noticed a small MTP fix sitting in there that the post never mentioned. That was this. It shipped quietly because it belonged to a post I hadn’t written yet - this one - and because I was too lazy to rip it out just for that post lol)</p>
<h2 id="crash">The crash</h2>
<p>They did not stack. They didn’t even <em>underperform</em> together, which would have at least been interesting data. Turning both on aborted at load:</p>
<pre><code>GGML_ASSERT(hparams.swa_type == LLAMA_SWA_TYPE_NONE
            && "Use llama_kv_cache_iswa for SWA") failed
  src/llama-graph.cpp:2704, via llama_model_qwen35moe::build_arch_graph
</code></pre>
<p>Well that is annoying. Here I finally remembered to do the smart thing: google before hand-patching. The search turned up issue <a href="https://github.com/ggml-org/llama.cpp/issues/23322">#23322</a> (people combining SWA and MTP on the Qwen3.6 family, hitting a different problem) and a pair of upstream iSWA crash fixes (<a href="https://github.com/ggml-org/llama.cpp/pull/24294">#24294</a>, <a href="https://github.com/ggml-org/llama.cpp/pull/23131">#23131</a>) that my fork’s base predated. Which raised an awkward possibility: maybe upstream had already fixed my crash while I wasn’t looking.</p>
<h2 id="rebase">Llama.cpp moves FAST</h2>
<p>My fork was 638 commits behind master… not ideal.  There was no way to find out whether the crash was already fixed except to pay the debt, so I rebased the whole patch set onto current upstream - through an API migration that had renamed or restructured basically everything my patches touched. Two of my patches turned out to be obsolete and got dropped outright: the original env-gated sparse-mask hack (superseded by the SWA recipe it grew into) and my old MTP graph routing (upstream had refactored MTP properly in <a href="https://github.com/ggml-org/llama.cpp/pull/23643">#23643</a>).</p>
<p>The answer, after all that: <em>no</em>. Current upstream, same crash. The two upstream iSWA fixes were real but covered different holes. This one was still my problem, boo.</p>
<h2 id="fix">The actual fix</h2>
<p>The bug is a divergence between two graphs that are supposed to agree. When the SWA recipe is on, the <em>main</em> graph correctly routes attention through the iSWA hybrid cache. But MTP builds its own little sub-graph for the draft head, and that sub-graph built its attention input with the non-iSWA builder - the one that opens with the assert above. The main graph and the MTP graph disagreed about what kind of cache the model was running on, and the assert is what disagreement looks like.</p>
<p>The fix mirrors the main graph: when <code>swa_type != NONE</code>, build the MTP sub-graph’s attention input through the iSWA builder too. The MTP layer’s index isn’t marked as a sliding-window layer, so it lands on the full-attention sub-cache - the draft head attends the full KV, which is what you want from the thing whose guesses get verified. The fix is tiny and the non-SWA path is byte-identical to before.</p>
<h2 id="best-case">Best case: super-additive at 123K</h2>
<p>With both levers finally running in the same process, the 2x2 at ~123K context (predictable log-continuation generation, greedy, q4_1 KV; full config in the caveats):</p>
<table>
<thead>
<tr>
<th>@123K, predictable gen</th>
<th>prefill t/s</th>
<th>decode t/s</th>
<th>vs dense</th>
<th>acceptance</th>
</tr>
</thead>
<tbody>
<tr>
<td>dense</td>
<td>388.0</td>
<td>37.09</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>SWA only</td>
<td>550.3</td>
<td>42.15</td>
<td>+14%</td>
<td>-</td>
</tr>
<tr>
<td>dense + MTP</td>
<td>361.4</td>
<td>41.04</td>
<td>+11%</td>
<td>0.805</td>
</tr>
<tr>
<td>SWA + MTP</td>
<td>469.1</td>
<td>54.14</td>
<td>+46%</td>
<td>0.961</td>
</tr>
</tbody>
</table>
<p>Separately the levers buy +14% and +11%. <em>Together they buy +46%</em>, well past their sum, and the acceptance column says why. Under SWA, the draft head’s guesses went from being accepted 80% of the time to 96% of the time. SWA isn’t just contributing its own speedup next to MTP’s - it’s making MTP <em>better at its job</em>.</p>
<p>(Prefill note: MTP costs you some prefill wherever it runs, 7-15% in this 2x2 - the draft head is extra graph. SWA-only is the prefill champion. This post is about decode.)</p>
<h2 id="mechanism">Why SWA lifts acceptance</h2>
<p>The <a href="https://damenknight.com/mtp-mistakes/">mistakes post</a> was all about the denominator of the Leviathan formula - the cost ratio c that the hardware sets, the thing I’d ignored while staring at acceptance. This result is the numerator’s revenge: same box, same c, and the speedup moved anyway, because SWA moved α.</p>
<p>The mechanism is almost embarrassingly simple once you see it. The draft head is small and shallow: its whole job is guessing what the big model will say next. Bound the big model’s effective context with a sliding window and you’ve lowered its next-token entropy - it has less history to condition on, so it becomes more predictable. A more predictable target is an easier target to draft for.</p>
<p>The depth behavior fits: at 20K context, dense acceptance is already ~0.88 (short contexts are easy to draft for), and MTP+SWA (70.3 t/s) just tracks MTP-only (73.8) - no lift, but no harm either. The lift only appears where the dense model’s conditioning gets long and the window’s pruning of it starts to matter.</p>
<p>It also matches something the long-context speculative-decoding literature already knows from the other direction. <a href="https://arxiv.org/abs/2408.11049">MagicDec</a> (<a href="https://www.together.ai/blog/speculative-decoding-for-high-throughput-long-context-inference">Together AI’s writeup</a>) attacks long-context decode by giving the <em>draft</em> a StreamingLLM-style window while the target stays full-attention, and reports that a windowed draft holds high acceptance out to 100K: windowed and full-context distributions agree on most tokens. Mine is the mirror image. Here the <em>target</em> is the windowed one, because bounding its KV is the whole point of the SWA recipe, and the acceptance gain lands on the stock MTP head as a side effect.</p>
<p>The lift showed up everywhere I measured, in the same direction. Not in the same size though, and that’s where the good news stops.</p>
<h2 id="worst-case">Forcing the worst case</h2>
<p>That 0.96 acceptance came from continuing a repetitive maintenance log - about the most predictable generation task that exists. Of course the draft head aced it! This series has burned me enough times that an exciting best-case number now triggers a panic reflex instead of a victory lap: go find the workload that hates it.</p>
<p>The test: a diverse ~79K context (combinatorial prose, nothing repeating) and a genuinely open-ended analytical generation task, with generation diversity measured on both sides so I could confirm the two configs were doing comparably hard work rather than one of them quietly degenerating into an easy loop. Same 2x2:</p>
<table>
<thead>
<tr>
<th>@79K, open-ended gen</th>
<th>decode t/s</th>
<th>acceptance</th>
<th>MTP’s delta vs its own baseline</th>
</tr>
</thead>
<tbody>
<tr>
<td>dense</td>
<td>41.13</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>dense + MTP</td>
<td>36.77</td>
<td>0.510</td>
<td>-11%, net-negative</td>
</tr>
<tr>
<td>SWA only</td>
<td>44.66</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>SWA + MTP</td>
<td>43.51</td>
<td>0.549</td>
<td>-2.6%, still negative</td>
</tr>
</tbody>
</table>
<p><em>On hard generation, MTP does not pay off</em>. Not dense, and <em>not with SWA either</em>. The acceptance lift is still there (0.51 to 0.55, same direction as always) but it’s small, and 0.55 is nowhere near the ~0.8 acceptance this box needs before the draft-and-verify overhead nets out ahead. SWA shrank MTP’s loss from -11% to -2.6%. Shrinking a loss is nice but not exactly much of a win.</p>
<p>SWA lifts MTP acceptance everywhere - the mechanism is real - but the <em>size</em> of the lift tracks how predictable the generation already was: 0.80 to 0.96 on the log, 0.51 to 0.55 on the analysis. SWA lowers the predictability threshold at which MTP breaks even. It does not make MTP universal, because nothing does - the acceptance gate never goes away, and your workload is what sets it.</p>
<p>Meanwhile, look at the SWA-only rows in both tables. +14% at 123K, +8.6% at 79K, no acceptance column, no conditions. That’s the lever that doesn’t care what you’re generating.</p>
<h2 id="rule">The rule</h2>
<ul>
<li>Take the SWA recipe’s decode win always. It’s bounded-KV physics, generation-agnostic, and it’s the reason the 256K post exists.</li>
<li>Add MTP on top only when the generation is predictable or structured: code, formatted continuation, extraction, log-like output. There it’s spectacular: +46% at 123K.</li>
<li>Leave MTP off for open-ended, creative, or analytical generation, even with SWA. It was net-negative dense and it’s still net-negative stacked; SWA just made it hurt less.</li>
<li>If you only remember one thing: acceptance is workload, cost ratio is hardware, and you need both on your side. </li>
</ul>
<h2 id="patch">Getting the patch</h2>
<p>As confessed at the top: if you’re running <a href="https://damenknight.com/files/395aimax-fork-full.patch">the full fork patch</a> from the 256K post, you’ve had this fix all along. It’s also available standalone as <a href="https://damenknight.com/files/mtp-iswa.patch">mtp-iswa.patch</a>; it applies clean to current upstream.</p>
<p>I’m not sending this one upstream, at least for now, because stock Qwen3.6 GGUFs don’t set <code>swa_type</code> on this arch, so a vanilla model never hits this assert. You need a downstream SWA config, like mine, to reach the path. That makes it a latent-inconsistency fix - the MTP sub-graph diverges from the main graph in exactly the handling the assert guards - rather than a crash any stock user is living with today, and “apply my SWA patch first” is a weak reproduction story for a maintainer to review against. </p>
<p>If you’re stacking SWA on this family some other way and hit the assert, the patch is right there. And if anyone feels like carrying it through the upstream contribution process, PLEASE be my guest - the diff is small, the reasoning is in this post, and I’d be happy to see it land. (Related upstream: <a href="https://github.com/ggml-org/llama.cpp/issues/23322">#23322</a> is the same feature combination but a different problem - a runtime acceptance issue, not this build crash.)</p>
<h2 id="caveats">Caveats</h2>
<ul>
<li>One model, one architecture. Everything here is Qwen3.6-35B-A3B, the <code>qwen35moe</code> hybrid (10 full-attention GQA + 30 Gated-DeltaNet layers), UD-Q4_K_M with the bundled MTP head. The crash and fix are specific to that arch’s MTP graph. The acceptance-lift mechanism (a bounded context lowers the target’s entropy, which makes drafting easier) has no model-specific step in the argument, but I’ve measured it exactly once, on one family. Treat the direction as an argument and the magnitudes as this box, this model.</li>
<li>Config, for reproducibility: Vulkan/RADV, <code>--dynsparse-swa 16384 --dynsparse-swa-full 27,31,35,39</code>, q4_1 KV, <code>-ub 512</code>, greedy decode, <code>--spec-type draft-mtp</code>. The SWA flags are from my fork (patches shipped with the 256K post); everything else is stock.</li>
<li>The worst-case comparison is the diversity-matched one. I actually ran two hard tasks at 79K, and in the other one the two configs went different directions off the same prompt: the SWA run kept continuing the document it was given (word-diversity 0.29, i.e. it settled into something predictable) while the dense run veered off into meta-reasoning about the task (0.68). Different generations means their acceptance numbers aren’t comparable, so that run doesn’t get to be the headline - though for what it’s worth, it showed the same shape: the moment the generation turned predictable, MTP+SWA won big (+32%). The -2.6% comes from the task where both configs measured equally diverse (~0.72). Raw numbers for everything in this post: <a href="https://damenknight.com/files/qwen3.6-mtp-swa-fix.json">the result JSON</a>.</li>
<li>Best case and worst case are different context depths (123K vs 79K), because they’re built from different source text. The dense baselines bracket them consistently, but don’t read the +46%-vs-(-2.6%) pair as a single controlled variable flip; it’s two points on the same curve.</li>
<li>Single-stream, batch-1, as always in this series. Greedy decode, so MTP output is byte-identical to MTP-off and the comparison is a pure speed test.</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>I got a usable 256K-token context on my Strix box</title>
      <link>https://damenknight.com/strix-halo-256k-context/</link>
      <guid isPermaLink="true">https://damenknight.com/strix-halo-256k-context/</guid>
      <pubDate>Thu, 02 Jul 2026 12:00:00 GMT</pubDate>
      <category>AI</category>
      <category>Projects</category>
      <category>Homelab</category>
      <description>A usable million-token context on my Strix Halo box turned out to be a trap - prefill takes 4.6 hours and the tricks that make it fast make it useless. What I…</description>
      <content:encoded><![CDATA[<p>A usable million tokens in Qwen 3.6 on my Strix host is just not actually realistic. Prefill takes too long, and the hacks necessary to make it fast make it useless.  What I DID get, however, is an actually-usable 256K token context which is itself a nice win: ingested in about ten and a half minutes, answering at 36 tokens/s, retrieval intact, KV cache under a gigabyte.  This is the difference between the pleasant local chatbot I’d already tuned this box into and something that can sit under a real agent. </p>
<p>The trick is giving the hybrid model’s attention layers a bounded sliding window, so prefill goes flat (~855 tokens/s from 32K to 1M) and the KV cache stops growing. Then, because a windowed layer can only retrieve what falls inside its window, you keep 4 of the 10 attention layers un-windowed. I was wrong four times on the way here, and the week I lost to a one-character cross-backend bug is <a href="https://damenknight.com/ggml-step-vulkan-step0-bug/">its own post</a>.</p>
<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#the-lever" style="font-size: 0.9375rem; text-decoration: none;">A tuned box that is not yet a useful agent</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#free-win" style="font-size: 0.9375rem; text-decoration: none;">The free win hiding in llama.cpp</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#attempt-1" style="font-size: 0.9375rem; text-decoration: none;">Attempt 1: the dumb fast version</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#why-dynamic" style="font-size: 0.9375rem; text-decoration: none;">Why the dumb version doesn’t work</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#attempt-2" style="font-size: 0.9375rem; text-decoration: none;">Attempt 2: it retrieves, and it’s slower</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#payoff" style="font-size: 0.9375rem; text-decoration: none;">What it does, and what it turned out to be</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#millionk" style="font-size: 0.9375rem; text-decoration: none;">The moonshot: a million tokens, prefilled but not usable</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#usable" style="font-size: 0.9375rem; text-decoration: none;">Recall, measured properly: the four-layer backstop</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#prior-art" style="font-size: 0.9375rem; text-decoration: none;">Where this sits, and what’s actually mine</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#takeaways" style="font-size: 0.9375rem; text-decoration: none;">Takeaways</a></li>
</ul>
</div>

<h2 id="the-lever">A tuned box that is not yet a useful agent</h2>
<p>This didn’t start as a sparse-attention project. It started as dissatisfaction with a box I’d already tuned. If you’ve been following along, you’ve watched me get Qwen running well on my Strix Halo mini-PC and then squeeze it from every direction I could find: quant ladders, speculative decoding, a thermal daemon so it could sustain load. Decode sits in the high 30s to mid 40s of tokens per second and short prompts turn around instantly. A nice local chatbot.</p>
<p>I don’t want a chatbot, though. I want a <em>local agent</em>, and agents eat context. So of course context is where the box fell over. A dense 256K-token prefill (the model’s full trained window) took about 20 minutes, per query, before the first token of the answer. A million tokens took 4 hours and 37 minutes. That’s not an estimate, I ran it once early on and watched it crawl. “Supports 256K context” was technically true and practically a lie. So the question stopped being “can I make it faster” and became: can I make a much larger context window usable on this box? I aimed at a frankly silly number, a million tokens. The intro already spoiled how that ends.</p>
<p>The box is HOMER, the same Strix Halo mini-PC this whole series is built on (<a href="https://damenknight.com/running-frontier-coding-model-mini-pc/">introduced here</a>, <a href="https://damenknight.com/strix-halo-vs-a30-vs-frontier/">benchmarked against real hardware here</a>). What matters for this post: a unified-memory APU has <em>frontier-class memory and laptop-class compute</em>. 128 GB of UMA will hold a multi-million-token KV cache without flinching. It just can’t feed the math fast enough (decode on these models is <a href="https://arxiv.org/abs/2402.16363">memory-bandwidth-bound</a>, not compute-bound - that’s the <a href="https://dl.acm.org/doi/10.1145/1498765.1498785">roofline</a> at work).</p>
<p>The model is Qwen3.6-35B-A3B at Q4_K_M, and it stays the model. I’m not swapping down to something sub-quadratic I’d like less. It’s a hybrid: of its 40 layers, 10 are full-attention GQA and the other 30 are Gated-DeltaNet (linear attention). That detail matters later, because only those 10 full-attention layers grow a KV cache, and only those 10 are somewhere attention sparsity can buy anything. It runs coherent on Vulkan/RADV llama.cpp (b9204), which I confirmed before trusting any throughput numbers.</p>
<p>The earlier tuning work also told me what kind of solution could work at all. I’d measured the box’s real memory bandwidth with a little HIP microbench: the read path hits about 242.7 GB/s out of a theoretical 256. The bus is maxed. No clever kernel is going to stream faster, and the memory overclock door is welded shut on this SKU. So the bandwidth-regime thesis from the rest of this series collapses to one rule here:</p>
<p><em>Every remaining win has to move fewer bytes.</em></p>
<p>Anything whose only benefit is more FLOPS is DOA since I’m not compute-starved at decode and I can’t go faster on the bus. What survives are tricks that skip reading some of the data. At long context, the biggest pile of skippable bytes is the KV cache during prefill, and the tool for skipping it is sparse attention.</p>
<h2 id="free-win">The free win hiding in llama.cpp</h2>
<p>llama.cpp’s Vulkan FlashAttention has a mask-aware tile-skip optimization (<a href="https://github.com/ggml-org/llama.cpp/pull/19281">PR #19281</a>, later hardened by a bounds fix in <a href="https://github.com/ggml-org/llama.cpp/pull/20296">#20296</a>): before it loads a tile of K and V, it checks whether that tile’s slice of the attention mask is entirely <code>-inf</code>. If it is, the query can’t attend there anyway, so the shader continues without loading the tile. No K read, no V read, no compute.</p>
<p>So I didn’t need a sparse-attention <em>kernel</em>, just a sparse-attention <em>mask</em>. Mark the blocks I don’t care about as <code>-inf</code> and the existing FlashAttention skips the memory traffic for me, on hardware it already runs on. All that’s left to build is the decision of what to drop.</p>
<p>I wasn’t the first one here either. DeepSeek’s own sparse attention (their <a href="https://arxiv.org/abs/2512.02556">DSA</a>, from the V3.2 work) landed upstream as <a href="https://github.com/ggml-org/llama.cpp/pull/23346">PR #23346</a>, doing this same thing: fill a mask with <code>-inf</code>, unmask the selected positions, add it to the causal mask, hand it to the stock FlashAttention. The maintainers’ guidance on sparse attention was that it should reuse FA and the mask-skip rather than add a new op, so the route was sanctioned. (One AMD landmine worth flagging once: the <code>argsort</code> kernel hits a shared-memory assert on AMD GPUs, filed against top-k sampling in <a href="https://github.com/ggml-org/llama.cpp/issues/24177">#24177</a>, and it’s the same kernel an in-graph top-k block-selection would call. I pick blocks with a plain threshold instead. I wanted a threshold anyway.)</p>
<h2 id="attempt-1">Attempt 1: the dumb fast version</h2>
<p>Before trying to get too fancy, I built the dumbest possible mask to prove the skip even fires: keep a few “sink” tokens at the very start, keep a recent window of the last N tokens, drop everything in between. This is the classic <a href="https://arxiv.org/abs/2309.17453">StreamingLLM</a> shape. Purely position-based, no knowledge of the content, a handful of lines in <code>set_input_kq_mask_impl</code>.</p>
<p>On the hero model at a 131K-token prefill, baseline throughput is 473 t/s. With a 256-token sink and a 4096-token window it’s 945 t/s, so 2.0x. At 262K it’s 223 -> 890, which is 4.0x. The speedup grows with depth, since the O(n²) attention term it’s killing is a bigger slice of prefill the deeper you go. In wall-clock terms a cold 256K prefill drops from about 19.6 minutes toward 5.</p>
<p>Two side measurements worth keeping. The window is a gentle knob: across windows from 1K to 32K at 131K context, throughput only slides from ~1008 to ~702 t/s, so a bigger, safer window is cheap. And there’s an Amdahl ceiling: at 131K the speedup caps around 2.1x even with a tiny window, because the MoE FFNs and those 30 DeltaNet layers are about half of prefill and sparse attention doesn’t touch them. At 256K attention is a larger fraction of the work, so the ceiling rises toward 4x.</p>
<p><em><strong>The catch is that this is lossy</strong></em>. A fixed window drops the middle of your context. I ran a needle-in-a-haystack test (a planted string, <code>TURQUOISE-7731</code>, at a known depth) and got a depressingly clean result: you retrieve the needle if and only if it falls inside the window. Put it 20K tokens deep with a 16K window and it’s gone. The text stays fluent either way (sparsity doesn’t make the model babble), and a window big enough to never miss is a window big enough to give back the speedup. I also tried a dilated variant, keeping every Nth block on top of the window. It catches a deep needle if and only if the needle lands on the stride grid. Better than a pure window, still a dice roll. Position-based masks are a dead end for retrieval.</p>
<h2 id="why-dynamic">Why the dumb version doesn’t work</h2>
<p>The fix <em>has</em> to be content-aware: keep the blocks that matter, wherever they sit, not the blocks that happen to be recent. The cheap version of that would be a static calibrated pattern, i.e. figure out once, offline, which columns are “heavy hitters” for this model and always keep those. Before building anything dynamic I measured whether static could work, with a little capture tool built on llama.cpp’s eval-callback, watching the 10 full-attention layers.</p>
<p>The obvious cheap proxy, key magnitude, is dead on this model. The Qwen3 line <a href="https://arxiv.org/abs/2505.09388">QK-normalizes</a> its keys (an RMSNorm on Q and K before the dot product, a trick that goes back to <a href="https://arxiv.org/abs/2302.05442">ViT-22B</a>), and per-position key norm came back flat: max over median 1.0x. Actual attention mass (run the softmax with FA off and watch where the weight goes) is another matter, max-over-median 47x. The weight piles onto the sink, onto a recent local window, and onto about 6.6% of columns scattered through the middle that are content-salient. The planted needle showed up as one of those heavy columns, so “keep the high-attention columns” preserves retrieval by construction.</p>
<p>Then I moved the needle (token ~350 to ~541) and re-measured, and the heavy mid-context cluster moved with it. The salient columns follow the content. A static calibration learned on one prompt can’t generalize to the next, because the important columns are wherever this particular document put them. So selection has to be dynamic: per-prompt, in the graph, from the actual keys.</p>
<h2 id="attempt-2">Attempt 2: it retrieves, and it’s slower</h2>
<p>Attempt 2 does its scoring dynamically, inside the compute graph right before the FlashAttention call. Pool the queries and keys down to one representative vector per block, score block against block, softmax over the key-blocks, threshold to a keep/drop decision, and OR that with “always keep the sink and the recent window.” Turn the drops into an additive <code>-inf</code> block mask and hand it to the stock FlashAttention. Same tile-skip as attempt 1, but the kept set follows the content.</p>
<p>Retrieval-wise it delivered. The first cut scored blocks off a single strided token and retrieved erratically (2 of 5 needle depths), but mean-pooled block representatives unioned with the sink+window fixed that: 5 out of 5 needle depths, at two different thresholds. The needle’s block gets kept wherever it lands.</p>
<p>......... aaaaaaaaand it was slower than doing nothing. 0.79x. At 131K, baseline 473 t/s became 372.  What the heck?</p>
<p>The mechanism is strictly subtractive: I’m dropping blocks, FlashAttention should read <em>less</em>. Chasing down how dropping work produced a slowdown ate the better part of a week and became a post of its own. The short version: a one-character cross-backend bug in ggml. The <code>step</code> function I built my keep/drop mask on returns a different value at exactly zero on the Vulkan backend than on the CPU (<code>step(0)</code> is 1 there, 0 on the CPU), so my “drop this block” decision quietly evaluated to “keep” for every block, and FlashAttention never skipped a thing. <a href="https://damenknight.com/ggml-step-vulkan-step0-bug/">The full debugging war story, and the upstream fix, are here.</a></p>
<h2 id="payoff">What it does, and what it turned out to be</h2>
<p>With the mask finally dropping the blocks I told it to (huzzah!), the code delivered the number I’d been chasing: at a 131K prefill, baseline 473 t/s became 880, a clean 1.86x, with the needle still retrieved at 5 of 5 depths. I started writing the blog post and getting ready to accept the recognition of my genius.</p>
<p>Then I measured it properly, and it just became two more things I’d been wrong about.  Blarg.</p>
<p>First, the 1.86x was graded against the wrong quality bar. The 880 t/s came at a 4096-token window, and the only perplexity I’d checked was at a 32K context, where 4096 is still ~12% of everything. At the 131K context where the speed number lives, a 4096 window is ~3% of the prompt. So I did the boring thing I should have done first: same-context perplexity at 131K, swept across windows, on two corpora (Moby Dick for prose, the llama.cpp source tree for code).</p>
<table>
<thead>
<tr>
<th>window</th>
<th>speedup @131K</th>
<th>prose PPL</th>
<th>code PPL</th>
</tr>
</thead>
<tbody>
<tr>
<td>4096</td>
<td>1.86x</td>
<td>+74%</td>
<td>+26%</td>
</tr>
<tr>
<td>8192</td>
<td>1.75x</td>
<td>+5.8%</td>
<td>+12%</td>
</tr>
<tr>
<td>16384</td>
<td>1.59x</td>
<td>+2.2%</td>
<td>+7.5%</td>
</tr>
<tr>
<td>24576</td>
<td>1.46x</td>
<td>+1.2%</td>
<td>+5.9%</td>
</tr>
<tr>
<td>32768</td>
<td>1.36x</td>
<td>+0.8%</td>
<td>+4.8%</td>
</tr>
<tr>
<td>49152</td>
<td>1.21x</td>
<td>+0.2%</td>
<td>+3.5%</td>
</tr>
<tr>
<td>65536</td>
<td>1.10x</td>
<td>-0.1%</td>
<td>+2.5%</td>
</tr>
</tbody>
</table>
<p>The 1.86x headline is unusable at real depth. A +74% prose perplexity is not a model you’d want to talk to. Prose has a cliff-then-recovery shape, since it leans on local context and a moderate absolute window restores it. The 16K window at +2.2% is the knee, and it’s what I’d ship for prose. Code never recovers: still +2.5% at a half-context window that’s barely faster than baseline, because dropping any blocks hurts long-range structure (a matched delimiter, a scope opened a thousand lines up). For code, don’t sparsify.</p>
<p>Notice that needle-in-a-haystack passed 5/5 on every row of that table, including the +74% disaster at the top. This is one of those things I would REALLY watch out for, and is why I do so much validation/testing.  One planted token survives heavy sparsity while the model’s general grip on the context craters. This is the complaint <a href="https://arxiv.org/abs/2404.06654">RULER</a> and <a href="https://arxiv.org/abs/2502.05167">NoLiMa</a> make about single-needle retrieval, and if I’d trusted <a href="https://github.com/gkamradt/LLMTest_NeedleInAHaystack">NIAH</a> I’d have shipped the +74% config as “perfect.” Perplexity was the honest measure here. Though perplexity has a blind spot of its own, which bites me later on.</p>
<p>The second thing I was wrong about retired the word “content-aware.” Quality and speed barely moved no matter how I set the content threshold, so I ablated it directly: tau from 0.01 to 0.99 at a fixed window gave identical speed and perplexity at every value. tau was a no-op. <em>I had built a content selector that selected no content, at any setting, the entire time.</em> .... Yeah.  Egg on my face.</p>
<p>The mechanism is simple once you look. The block score is a softmax over all ~1000 key-blocks, and that softmax is sharply peaked on the recent blocks. A far-back salient block, normalized against the dominant recent ones, comes out with a probability near zero, below any threshold worth using. The keep-cliff sits between tau=0 and tau=0.0005, nowhere near the 1/1024 you’d see if the distribution were remotely flat. I wondered whether mean-pooling 128 tokens into one representative was washing out a single sharp needle key, so I swapped in max-pooling. It behaved identically, because the problem is the softmax normalization, not the pooling. The only blocks my selector wanted were the recent ones already inside the window. Net new blocks kept: zero.</p>
<p>So attempt 2 had silently collapsed back into attempt 1, a plain sink-plus-sliding-window. Every row in that table is a sliding-window result. The content-aware machinery I spent a week debugging was, once it finally ran, computing an elaborate score and then keeping the same blocks a three-line window mask would have kept.</p>
<p>Why did it retrieve, then? If the selector keeps nothing far-back, the needle’s block gets dropped on the windowed attention layers, and yet NIAH was 5/5. At the time I had an explanation I liked: only 10 of the 40 layers are full-attention and those are the only ones I masked, so the 30 unmasked Gated-DeltaNet layers, which see every token, must be carrying the needle. A tidy story that fit every data point I had. It’s also totally wrong, which makes it mistake number four, and I wouldn’t find out until I measured recall properly, two sections down.</p>
<h2 id="millionk">The moonshot: a million tokens, prefilled but not usable</h2>
<p>Even with the content-aware story dead there’s a real result here, the one I’d been chasing since I set the silly target, because the cold prefill was always the wall on this box. Dense prefill goes super-quadratic past ~131K: the KV cache for the full-attention layers outgrows the 32 MB Infinity Cache, and attention starts re-reading it from LPDDR5 at the 256 GB/s ceiling. Windowing the attention layers should bound each query’s KV slice and flatten the curve. My first cut at that was the mask, and it half-worked. Dense versus my 16K-window mask:</p>
<table>
<thead>
<tr>
<th>context</th>
<th>dense prefill</th>
<th>16K-window mask</th>
<th>speedup</th>
</tr>
</thead>
<tbody>
<tr>
<td>32K</td>
<td>39s</td>
<td>38s</td>
<td>1.0x</td>
</tr>
<tr>
<td>65K</td>
<td>97s</td>
<td>81s</td>
<td>1.2x</td>
</tr>
<tr>
<td>131K</td>
<td>277s</td>
<td>174s</td>
<td>1.6x</td>
</tr>
<tr>
<td>262K</td>
<td>~20min</td>
<td>~6min</td>
<td>3.1x</td>
</tr>
</tbody>
</table>
<p>The speedup grows with depth. At a million tokens the dense side is not a guess: that’s the 4h37m run from the intro, and fitting a curve to this ladder lands right on it (4.6-5.2h depending on the fit). The mask extrapolates to ~40 minutes there, a raw ~6.6x, but I never ran the mask at 1M, and it has two structural leftovers that made me not want to. It still allocates the full KV cache, so the memory win is zero. And llama.cpp’s FlashAttention still loops over every query-tile by key-tile pair, paying a per-tile check even on the all-<code>-inf</code> tiles it skips, so the work is O(N) but the bookkeeping stays O(N²).</p>
<p>The fix is to stop faking the window and ask for a real one. llama.cpp already ships sliding-window attention as a first-class path (Mistral, Gemma, and the hybrid lfm2 models all use it), so I marked the 10 attention layers as sliding-window through that path. They get a rolling KV cache bounded to the window: it never grows, and the kernel never touches tiles outside it. Prefill becomes genuinely linear:</p>
<table>
<thead>
<tr>
<th>context</th>
<th>dense</th>
<th>native sliding-window</th>
</tr>
</thead>
<tbody>
<tr>
<td>32K</td>
<td>837 t/s</td>
<td>893 t/s</td>
</tr>
<tr>
<td>131K</td>
<td>473 t/s</td>
<td>860 t/s</td>
</tr>
<tr>
<td>262K</td>
<td>222 t/s</td>
<td>855 t/s</td>
</tr>
<tr>
<td>512K</td>
<td>never run dense</td>
<td>853 t/s (~10 min)</td>
</tr>
<tr>
<td>1M</td>
<td>4h37m, measured*</td>
<td>851 t/s (20.5 min, measured)</td>
</tr>
</tbody>
</table>
<p>~855 tokens a second from 32K to 1M, a 4.8% drift across a 32x range, while dense falls off a cliff. Both million-token prefills are real wall-clock numbers, and the ratio is ~13x. (*The dense 1M is the earlier run from the intro: Q8_0 weights and q8_0 KV via llama-bench, which is why it’s starred rather than sitting in the same Q4_K_M ladder as the rest of the column. It also ran without a RoPE override, so it’s a throughput measurement, not a coherence-valid million.) One thing that run settled, to be fair to dense: memory was never the killer on this box. The dense 1M fit in the 128 GB and even decoded at 10.6 tokens/s once the 4.6 hours were paid. The windowed cache staying bounded at a few hundred MB, where dense’s grows linearly with context, is still the difference between a box with headroom and a box running one job, and it matters a lot more on hardware with less RAM than this.</p>
<p>Here’s the half of the moonshot I didn’t get. The quality is the same windowed quality from the last section, because it’s the same window done properly (perplexity at 131K is 1.3718 versus the mask’s 1.3512 - the 1.5% is the attention sink the mask kept and the stock path leaves out). At a million tokens that window table stops being a caveat. A 16K window is 1.6% of a 1M context, the same fraction as a 2K window at 131K, and that configuration measured +57% code and +199% prose perplexity. <em>Unusable</em>. Retrieval is somehow even worse: the next section measures it properly, and a windowed layer’s recall reach <em>is</em> its window, so nothing more than 16K tokens back is findable. On top of which, Qwen3.6’s trained window is 256K. You can stretch a model past its training with RoPE tricks (on the big MI300X box earlier in this series I validated a freq-base override out to needle retrieval at ~634K), but nothing in these runs does, so past 256K there was never a usability claim on the table anyway. The million-token run is a prefill-and-memory demo. It proves the machinery stays flat and bounded as deep as you push it, and the window is a dial rather than a wall (at 256K, a 32K window holds 699 t/s at +5.1% code perplexity, a 64K window 522 t/s at +2.5%). It is not a usable context.</p>
<p>Prefill wall down, memory wall down, usability not achieved. The useful move from there was to point the same machinery at the deepest context where usability <em>is</em> winnable, and find out where that is.</p>
<h2 id="usable">Recall, measured properly: the four-layer backstop</h2>
<p>First I had to fix my own measurement. The 5/5 retrieval results earlier in this post came from a harness I’d stopped questioning: sampled decoding, no stop token, long rambling generations scored by substring match, and a rambling generation can blunder into a substring match it didn’t earn. The corrected harness is greedy decoding, stop at end-of-generation, exact match, plus one behavioral check before trusting any grid: an out-of-window needle has to actually miss on the binary under test. That last check is not paranoia. I burned a full run measuring an unpatched binary that silently ignored the window setting and ran dense. If your feature is env-gated, “it ran” is not evidence it ran.</p>
<p>Measured that way, the sliding-window path gives a blunt answer: single-needle retrieval reach is the window. In a ~98K context with needles planted ~88K, ~49K, and ~10K tokens back, dense finds all three, a 64K window finds the two inside it and misses the 88K one, a 16K window finds only the last. The needles the window misses stay missed, which kills my DeltaNet explanation. The 30 linear-attention layers see every token, and they’re why the model stays fluent about the far context, why perplexity degrades gently under windowing instead of collapsing the way a windowed pure-attention model does. They do not do precise retrieval. On this model, precise retrieval lives in the full-attention layers, all ten of which I had just windowed.</p>
<p>The fix: don’t window all ten. I swept “keep the last K attention layers full” at a 16K window, same three needles. K=2 buys nothing, still blind past the window. K=4 brings all three needles back. K=6 matches K=4 and is just slower. There’s a critical mass somewhere between 2 and 4 layers, and 4 of the 10 is the cheapest configuration that clears it. The retrieval-heads literature says why this shape appears: <a href="https://arxiv.org/abs/2404.15574">Wu et al.</a> showed NIAH-style retrieval is done by a small set of attention heads scattered across layers, and <a href="https://arxiv.org/abs/2407.15891">RazorAttention</a> / <a href="https://arxiv.org/abs/2410.10819">DuoAttention</a> keep those heads full while windowing the rest. Keeping whole layers is the blunt version of the same idea. You keep enough layers full to contain the heads that matter.</p>
<p><em><strong>That</strong></em> is the recipe, and on my fork it graduated from env hack to a real CLI flag. Here’s the entire mechanism, a loader-time helper shared by the hybrid architectures (each one’s graph then routes through llama.cpp’s existing iSWA hybrid cache when <code>swa_type</code> is set):</p>
<pre><code class="language-cpp">// models.h (my fork) - shared by qwen35moe, qwen3next, granite-hybrid.
// Window every full-attention layer; un-window a chosen few as the recall backstop.
static inline void apply_dynsparse_swa(llama_hparams & hparams, const llama_model_params & mp) {
    // ... resolve W / backstop list from --dynsparse-swa* flags (env vars as fallback) ...
    hparams.swa_type = LLAMA_SWA_TYPE_STANDARD;
    hparams.n_swa    = swa_w;                                   // the window W
    for (uint32_t i = 0; i < n_main; ++i) {
        hparams.is_swa_impl[i] = hparams.is_recr(i) ? 0u : 1u;  // attention layers -> windowed
    }
    // ... then un-window the backstop set (--dynsparse-swa-full 27,31,35,39)
    //     and print the resolved windowed/kept-full split at load ...
}
</code></pre>
<p>Which makes the whole recipe a command line: window to 16K, keep four layers full, q4_1 KV cache.</p>
<pre><code class="language-bash">build-vk/bin/llama-server -m Qwen3.6-35B-A3B-Q4_K_M.gguf \
    --dynsparse-swa 16384 --dynsparse-swa-full 27,31,35,39 \
    -ctk q4_1 -ctv q4_1 -fa on -c 262144
</code></pre>
<p>None of this is upstream currently, so don’t expect the flag in a stock build. The whole fork is one patch on top of upstream llama.cpp commit <code>6f4f53f2b</code>: grab <a href="https://damenknight.com/files/395aimax-fork-full.patch">the patch</a>, then</p>
<pre><code class="language-bash">git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
git checkout 6f4f53f2b
git apply 395aimax-fork-full.patch
cmake -B build-vk -DGGML_VULKAN=ON && cmake --build build-vk --config Release
</code></pre>
<p>That patch also carries the measurement tooling behind this post’s numbers (the attention-capture tool and the KV-eviction harness). A smaller extract with just the SWA feature is <a href="https://damenknight.com/files/dynsparse-swa.patch">here</a> if you’d rather read than build. Run end-to-end at real depths, with the needle planted outside the window so it can only be reached through the backstop:</p>
<table>
<thead>
<tr>
<th>config</th>
<th>prefill</th>
<th>decode</th>
<th>attn-KV</th>
<th>far needle</th>
</tr>
</thead>
<tbody>
<tr>
<td>dense @131K</td>
<td>5.0 min (391 t/s)</td>
<td>38.6 t/s</td>
<td>819 MB</td>
<td>hit</td>
</tr>
<tr>
<td>recipe @131K</td>
<td>3.8 min (521 t/s)</td>
<td>44.2 t/s</td>
<td>434 MB</td>
<td>hit</td>
</tr>
<tr>
<td>recipe @192K</td>
<td>6.7 min (447 t/s)</td>
<td>40.5 t/s</td>
<td>596 MB</td>
<td>hit</td>
</tr>
<tr>
<td>recipe @256K</td>
<td>10.5 min (389 t/s)</td>
<td>36.2 t/s</td>
<td>767 MB</td>
<td>hit</td>
</tr>
</tbody>
</table>
<p><em>At 131K the recipe beats dense on all three axes at once</em> (1.89x smaller KV, 1.33x faster prefill, 1.14x faster decode - fewer bytes helps decode too). And the 256K row is the model’s entire trained context. Where the million was the stunt, this row is the everyday claim: the whole native window, ingested in ten and a half minutes at the throughput dense manages at half that depth, answering at 36 tokens/s with retrieval intact, on the “cheap” box.  FINALLY!  We did it, Reddit!</p>
<p>A single planted needle is the easiest retrieval task there is (<a href="https://arxiv.org/abs/2509.21361">Paulsen’s MECW work</a> measured effective context across task types and found single-needle by far the most flattering probe), so I graded harder ones too. Multi-fact retrieval (five codes scattered through the context, “list all five”) is where the recipe hits its first real boundary: 5/5 at 128K but 1/5 at 256K, where dense still scores 5/5, so it’s my recipe’s limit rather than the model’s. Doubling the window to 32K restores 5/5 at 256K for +8% KV and +8% prefill. Oddly, four of the five needles sit far outside even the doubled window (the deepest is 216K tokens back), so the kept-full layers were never the bottleneck. The narrow window was degrading the model’s ability to bind multiple facts together at depth, and more local context fixes it.</p>
<p>I also checked that the model <em>uses</em> a long context rather than just fishing strings out of it: plant counterfactuals that contradict strong priors (water boils at 157C, the capital of France is Oslo) and ask it to answer from the document. The recipe matches dense, 5/5 at both 128K and 256K, zero reversions to what the model knows is really true. The one task family that still needs dense is aggregation, where the answer is spread diffusely over the whole context. Windowing breaks that gather and no backstop saves it, so know which kind of question you’re serving.</p>
<p>One model is cool but I wanted to know how portable this was, so I ported the recipe to two more hybrids. On <a href="https://huggingface.co/Qwen/Qwen3-Next-80B-A3B-Instruct">Qwen3-Next-80B</a> (another DeltaNet hybrid) the whole pattern transfers at 128K: 2.07x smaller KV, 1.37x faster prefill, 1.16x faster decode, far needle retrieved, multi-fact 5/5. On <a href="https://huggingface.co/ibm-granite/granite-4.0-h-small">Granite-4.0-H-Small</a>, a Mamba2-heavy hybrid with only 4 attention layers out of 40, it also works, with the critical-mass lesson in miniature: one kept-full layer holds recall at 64K but breaks at 128K, and two of its four hold 128K at a 1.46x KV shrink. The constant that moves between models is which layers you keep full, so measure that per model rather than trusting mine. The scope boundary: this is a hybrid-model technique. A pure-attention model has no recurrent layers to keep the far context fluent, windowing it at prefill collapses quality (<a href="https://arxiv.org/abs/2512.10411">SWAA</a> documents this), and the recipe degrades into plain StreamingLLM with recall capped at the window. The hybrid’s linear layers make the windowing survivable, and the kept-full attention layers make it retrieve.</p>
<h2 id="prior-art">Where this sits, and what’s actually mine</h2>
<p>Credit where it’s due, because almost none of the algorithm here is new, and the part that was supposed to be mine didn’t pan out. Training-free, dynamic, content-aware sparse prefill is a well-trodden line, and attempt 2 was my try at joining it:</p>
<ul>
<li><a href="https://arxiv.org/abs/2407.02490">MInference</a> (Microsoft, NeurIPS 2024) defined the problem: classify each head’s sparse pattern, build the indices per prompt, run an optimized sparse prefill. It’s the anchor everyone benchmarks against.</li>
<li><a href="https://arxiv.org/abs/2502.20766">FlexPrefill</a> (ICLR 2025) does the part I leaned on hardest: per-prompt, training-free, selecting blocks by a cumulative-attention threshold rather than a fixed top-k. That’s my threshold, arrived at separately for the same reasons.</li>
<li><a href="https://arxiv.org/abs/2410.13276">SeerAttention</a> is the closest thing to my scorer: pool Q and K down to block representatives, multiply for block scores, run block-sparse attention. They <em>learn</em> a gate for it, where I threw the learning away and thresholded the raw pooled score.</li>
<li><a href="https://arxiv.org/abs/2503.16428">XAttention</a> and <a href="https://arxiv.org/abs/2502.18137">SpargeAttention</a> are training-free block-sparse cousins with different cheap importance proxies (antidiagonal sums, block self-similarity).</li>
<li>DeepSeek’s <a href="https://arxiv.org/abs/2502.11089">NSA</a> is the trained-from-scratch version of the idea, and their <a href="https://arxiv.org/abs/2512.02556">DSA</a> is the inference-time one whose llama.cpp mask idiom I copied outright.</li>
<li>My fixed sink+window is straight out of <a href="https://arxiv.org/abs/2309.17453">StreamingLLM</a>, and the attention sink it leans on is a <a href="https://arxiv.org/abs/2410.10781">studied</a> phenomenon, not folklore.</li>
<li>The keep-some-attention-full backstop has its own literature: <a href="https://arxiv.org/abs/2404.15574">retrieval heads</a> (a small, sparse set of heads does the retrieving), plus <a href="https://arxiv.org/abs/2407.15891">RazorAttention</a> / <a href="https://arxiv.org/abs/2410.10819">DuoAttention</a>, which keep those heads full and window the rest. My kept-full <em>layers</em> are the coarse version of their kept-full <em>heads</em>. <a href="https://arxiv.org/abs/2512.10411">SWAA</a> studies windowing full-attention models at prefill and finds it collapses on pure-attention models, the boundary my hybrid results sit just inside.</li>
</ul>
<p>So what’s mine? Less than I thought when I started writing. I set out to join that club, training-free content-aware block selection, and on this model and these corpora I couldn’t, because there was no cheap salient subset for a selector to find. The quality need is broad: code wants ~25% of the context back before it’s within 5% of dense, prose wants a moderate absolute window. With no handful of heavy blocks to keep, my selector reduced to a sliding window, and the window is StreamingLLM. What’s left:</p>
<ol>
<li>It runs on a “cheap” consumer iGPU, through Vulkan, with no new kernel. The papers above ship custom CUDA. Here the win was noticing the box already had the machinery, first a mask riding the stock FlashAttention tile-skip, then marking the attention layers sliding-window through llama.cpp’s existing hybrid-SWA path. Nobody had ported the RazorAttention/DuoAttention idea into this stack either, and llama.cpp’s knob is per-layer, so the layer-level version is what’s implementable today. I implemented it.</li>
<li>The measured recipe on hybrids: window the attention layers, keep a critical mass full (4 of 10 here - 2 buys nothing, 6 buys nothing more), with operating points from grading it against real tasks. 16K windows for single-fact, 32K for multi-fact, dense for aggregation. Validated on three hybrid families, scoped to hybrids on purpose.</li>
<li>The bug, which is <a href="https://damenknight.com/ggml-step-vulkan-step0-bug/">its own post</a> and the reason any of this took as long as it did.</li>
</ol>
<p>One anti-result, because these rarely get written up: pooled-QK threshold selection with a global softmax does not surface far-back salient blocks on this model. The normalization drowns them, and max-pooling doesn’t save it. If you’re building content-aware sparse attention, ablate your selector against a plain window early. Mine looked like it was working for a week and wasn’t.</p>
<h2 id="takeaways">Takeaways</h2>
<p>The most expensive lesson was the one-character bug, and it has <a href="https://damenknight.com/ggml-step-vulkan-step0-bug/">its own post</a>. Short version: when a compute graph validates on the CPU but misbehaves on a GPU backend, suspect a per-op semantic mismatch before anything clever about scheduling or memory. The bug only hid the work, though. The work itself taught me two things.</p>
<p>Ablate your own knob. If a feature has a setting that turns it off, run that setting, because if “off” looks identical to “on” then your feature was never on. One tau sweep would have caught the inert selector in an afternoon instead of an article. I believed in it for a week because I never pointed it at its own off switch.</p>
<p>And don’t trust a needle. It passed 5/5 at every window I tried, including the one at +74% perplexity, and then it turned out even those passes were partly my harness being generous. Perplexity lies in the other direction. It barely notices retrieval, which is how the recall boundary hid inside decent-looking curves. Measure both, strictly, on the content you actually serve, and pit whatever you built against the dumb baseline it’s supposed to beat. Mine turned out to <em>be</em> the dumb baseline, which I’d have known on day two if I’d run the comparison.</p>
<p>What’s left is smaller than I set out to build, and I like it more for being true. The cold prefill wall comes down the way the bandwidth thesis said it had to: stop reading bytes you don’t need, and the bytes turned out to be most of the KV cache on most of the attention layers. The linear-attention layers keep the far context fluent for free, four kept-full attention layers keep it retrievable, the million-token moonshot lands as a 20-minute prefill instead of a 4.6-hour one but nothing more, and the model’s whole native 256K context becomes something a local agent can live in, which is what I wanted from this box all along. I just had to be wrong four times to get there, and teach the GPU that zero is not a positive number along the way.</p>]]></content:encoded>
    </item>
    <item>
      <title>One character cost me a week of long context</title>
      <link>https://damenknight.com/ggml-step-vulkan-step0-bug/</link>
      <guid isPermaLink="true">https://damenknight.com/ggml-step-vulkan-step0-bug/</guid>
      <pubDate>Fri, 26 Jun 2026 12:00:00 GMT</pubDate>
      <category>AI</category>
      <category>Projects</category>
      <category>Homelab</category>
      <description>I found a one-character bug in llama.cpp: on the Vulkan backend, step(0) returns 1 instead of 0, so the “drop this block” mask I built out of step() quietly…</description>
      <content:encoded><![CDATA[<p>TL;DR: I found a bug in llama.cpp. It’s a one-character cross-backend bug in ggml: the <code>step()</code> operator returns 1 at exactly zero on the Vulkan (GPU) backend (<code>x >= 0</code>) but 0 on the CPU (<code>x > 0</code>). It’s filed (<a href="https://github.com/ggml-org/llama.cpp/issues/25027">#25027</a>), a maintainer wrote the fix (<a href="https://github.com/ggml-org/llama.cpp/pull/25036">PR #25036</a>), and it’s approved and waiting to merge. </p>
<p>I found it the hard way: building sparse-attention prefill on my “cheap” AMD Strix Halo miniPC, I hit a slowdown where there should have been a 2x speedup, and spent the better part of a week blaming the graph scheduler, the allocator, an O(n^2) re-sweep, and buffer aliasing, before realizing my “drop this block” mask, built out of <code>step()</code>, was quietly keeping every block because step(0) was wrong on the GPU, so FlashAttention read all of it anyway.</p>
<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#setup" style="font-size: 0.9375rem; text-decoration: none;">Just enough setup to make the bug land</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#symptom" style="font-size: 0.9375rem; text-decoration: none;">The symptom: drop work, get slower</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#being-wrong" style="font-size: 0.9375rem; text-decoration: none;">A week of being wrong</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-bug" style="font-size: 0.9375rem; text-decoration: none;">The actual bug</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#lesson" style="font-size: 0.9375rem; text-decoration: none;">The lesson I’ll actually remember</a></li>
</ul>
</div>

<h2 id="setup">Just enough setup to make the bug land</h2>
<p>To explain what the bug was I have to back up to what I was building… </p>
<p>I’ve been trying to make a long context <em>usable</em> on a “cheap” Strix Halo box (a Ryzen AI Max+ 395 with the gfx1151 iGPU) - yes I know, I am a crazy person. That box is bandwidth-bound so the only real approach is moving fewer bytes, and during a long prefill the way you do that is sparse attention: don’t read the parts of the KV cache that don’t matter. </p>
<p>The neat thing about doing this in llama.cpp is that you don’t have to write a kernel. The Vulkan FlashAttention already has a tile-skip (<a href="https://github.com/ggml-org/llama.cpp/pull/19281">PR #19281</a>): before it loads a tile of K and V, it checks whether that tile’s slice of the attention mask is entirely <code>-inf</code>, and if so it skips the tile, the load, and the compute. The entire feature reduces to one thing, computing a good mask. Mark a block <code>-inf</code> and its bytes never move.</p>
<p>To decide which blocks to keep I built a boolean: keep a block if it scores as content-salient <em>or</em> it falls in a recent window. And I built that OR the way you build booleans when you’re working in a tensor graph that has no <code>if</code>: out of arithmetic, with the Heaviside step function. <code>step(x)</code> is 1 for positive <code>x</code> and 0 otherwise, so <code>keep = step(content_score + in_window)</code> is a serviceable OR, and <code>step</code> of zero is supposed to be the “neither” case that evaluates to <em>drop</em>.</p>
<p>Turns out that “supposed to” in the previous sentence was doing a lot of heavy lifting.</p>
<h2 id="symptom">The symptom: drop work, get… slower???</h2>
<p>With the mask in place, the thing got <em>slower</em>. What the heck? At a 131K-token prefill, baseline throughput was 473 t/s; my sparse version did 372. That’s confusing, because the mechanism is supposed to be strictly subtractive. I’m dropping blocks. FlashAttention should be reading <em>less</em>. Dropping work and getting a slowdown means something is very wrong, and I had a mask that looked correct, output that was coherent, retrieval that passed my tests, and a number going the wrong way. </p>
<h2 id="being-wrong">A week of being wrong</h2>
<p>None of these were dumb guesses. Every one had a real mechanism behind it, got measured, and got fixed, and not one of them was the problem (RIP me, I guess, but will be useful later!). The graveyard, in order:</p>
<p><em>Wrong theory #1: a CPU fallback.</em> My block-scoring step pooled the keys with <code>ggml_pool_1d</code>, and the Vulkan backend has no 1D pooling kernel, only 2D. So every micro-batch, that op silently fell back to the CPU, with a full GPU->CPU->GPU round trip and a sync each time. That’s real, and I confirmed it three ways (source, a decomposition that showed a fixed per-ubatch tax independent of how much I dropped, and a scheduler trace). I rewrote it as a <code>ggml_pool_2d</code> over the token axis. It helped: 0.79x became 0.94x. Still a slowdown. </p>
<p><em>Wrong theory #2: an O(n^2) re-sweep.</em> The graph is stateless per micro-batch, so each of the ~256 prefill ubatches re-pools <em>all</em> of the keys accumulated so far. Sum that over the prefill and you get an O(n^2) re-sweep of K, the same order as FlashAttention’s own read. I built a model that fit all my data points and it was beautiful. The fix is to amortize: cache each block’s representative <em>once</em>, when its tokens are written to the KV cache, and read the cache instead of re-pooling. A persistent per-layer rep buffer parallel to the KV cache, incremental updates on the write path, the whole thing. It compiled, it retrieved correctly, and it bought <em>zero</em> speed. The beautiful model was wrong.</p>
<p><em>Wrong theory #3: FlashAttention isn’t skipping at all.</em> By now I’d stopped trusting my assumptions and turned on the Vulkan performance logger. It settled it: FlashAttention was dominating the trace, reading the <em>full</em> K, every layer, the entire time. The sparse machinery I’d been agonizing over wasn’t even in the top 25 ops. The keep-nothing case (an all-<code>-inf</code> mask, which should be the maximum possible skip) ran at 390 t/s, <em>slower</em> than baseline. If the skip were firing, that case would be 2x. It wasn’t firing. At all. For any version. Every “sparse” run I’d done was running dense attention plus the overhead of computing a mask nobody used.</p>
<p>That reframed the hunt. The mask was being <em>computed and added</em>, and I could prove it was logically <code>-inf</code>, because a keep-nothing mask makes the model attend over nothing and produce NaNs. But FlashAttention wouldn’t tile-skip it. Meanwhile a simpler fixed-window mask, the one I’d built first and fed in as a plain input tensor, skipped fine. Same kernel, same model, one mask skips and one doesn’t.</p>
<p><em>Wrong theory #4: the graph scheduler / buffer aliasing.</em> I narrowed it hard. A diagnostic that forced the mask to all-<code>-inf</code> through a <em>short</em> op chain skipped cleanly (>2x, the win was provably there). My <em>long</em> dynamic chain produced a mask that matched the short one in shape, stride, type, and contiguity, and as far as I could tell held the same <code>-inf</code> values, and yet FlashAttention skipped one and not the other. The only difference was the <em>graph that produced the tensor</em>. From there it really looked like a scheduler or graph-allocator bug: a missing barrier between my long producer chain and the FlashAttention consumer, or a buffer getting aliased and overwritten before FA’s skip pre-pass read it. I had a whole “the deeply-derived tensor lands in a reused buffer” story, complete with a clean-buffer workaround that happened to make it skip. It was, again, plausible, and again not the cause.</p>
<p>The pattern across all of it: I spent days on re-sweep math, op fusion, mask layout, sync barriers, and buffer lifetimes. Every one of those was a sophisticated guess about <em>systems behavior</em>, and every one was wrong, because the bug wasn’t in the systems. It was in arithmetic.</p>
<h2 id="the-bug">The actual bug</h2>
<p>What all my workarounds had in common, the reason “force the mask through a short chain” or “drop into a freshly-filled buffer” made it skip, is that they all bypassed the <em>math</em> that built the keep/drop decision. I’d been treating that math as incidental, when it was the whole bug.</p>
<p>Recall the OR: <code>keep = step(content_score + in_window)</code>, where <code>step</code> is 1 for positive and 0 otherwise. The intent is “keep this block if it’s content-salient <em>or</em> it’s in the window.” The “neither” case, score zero and not in the window, is supposed to land on <code>step(0)</code> and evaluate to <em>drop</em>.</p>
<p><code>step(0)</code> does not return 0 on the Vulkan backend. It returns 1.</p>
<p>Here are the two implementations, which are supposed to compute the same function:</p>
<pre><code>// CPU reference: ggml/src/ggml-cpu/vec.h, ggml_vec_step_f32
y[i] = (x[i] > 0.f) ? 1.f : 0.f;     // step(0) = 0

// Vulkan: ggml/src/ggml-vulkan/vulkan-shaders/unary.comp, op_step
return x >= 0.0f ? 1.0f : 0.0f;      // step(0) = 1
</code></pre>
<p><code>></code> on the CPU, <code>>=</code> on the GPU. At exactly zero they disagree by the maximum possible amount. And boolean-indicator math sits on exactly-zero constantly, because that’s what “this condition is false” <em>is</em>. So on the GPU, my “neither” case, the blocks I most wanted to drop, evaluated to <code>step(0) = 1 = keep</code>. Every block was kept. The mask was effectively all-zeros. FlashAttention dutifully read every tile, because I’d told it to, and I’d spent a week blaming the scheduler for honoring my own broken arithmetic.</p>
<p>It survives undetected because you only hit it at an input of <em>exactly</em> 0.0, and the upstream test for this op never does. <code>test-backend-ops</code> initializes its inputs with uniform random floats in [-150, 150], which never land precisely on zero, so the STEP test passes green while the op is wrong. I forced the input to exactly 0 in a one-line edit and reran it:</p>
<pre><code>[STEP] ERR = 1.000000000 > 0.000000100   STEP(type=f16,...,v=0): FAIL
[STEP] ERR = 1.000000000 > 0.000000100   STEP(type=f32,...,v=0): FAIL
0/4 tests passed   Backend Vulkan0: FAIL
</code></pre>
<p>ERR of exactly 1.0: CPU says 0, Vulkan says 1. Both precisions. The fix is one character, <code>>=</code> to <code>></code>, to match the CPU reference. My local workaround, until that lands, is to bias the OR by 0.5 so the boundary is never <em>at</em> zero: <code>step((a + b) - 0.5)</code>, which is robust no matter which way <code>step(0)</code> falls. I filed it upstream with the repro and the one-line fix: <a href="https://github.com/ggml-org/llama.cpp/issues/25027">ggml-org/llama.cpp#25027</a>. A Vulkan-backend maintainer picked it up and wrote the actual patch, <a href="https://github.com/ggml-org/llama.cpp/pull/25036">PR #25036</a>, <code>>=</code> to <code>></code>, approved by two maintainers and waiting to merge as I write this. </p>
<p>“step(0) is off by one” sounds like a corner case, and for most llama.cpp users it is one: <code>step</code> barely appears in mainstream model graphs, so nobody’s chat session was silently wrong because of this. The cost is narrower and meaner than that. If you build boolean or gating logic out of <code>step</code> on the Vulkan backend, the way I did, your graph silently disagrees with the identical graph on the CPU, at exactly the input boolean logic lands on constantly: the false case, where the indicators sum to zero. Nothing crashes and nothing warns. It just quietly does the opposite of what you wrote, on one backend only.</p>
<p>That is also what fixing it means. Once the patch lands, <code>step</code> on Vulkan agrees with the CPU, I drop the <code>-0.5</code> hack, and the mask works without a workaround. The broader win is quieter than a speedup: ggml has one fewer place where the same graph returns two different answers on two backends. A portable op that isn’t actually portable is worse than no op at all, because you trust it. Now it matches the CPU, like it always should have.</p>
<p>The thing that still stings: every fix I built in the graveyard above was <em>correct</em>. The pool_2d rewrite, the rep cache, the clean mask construction, they were all better, and they all paid off the <em>instant</em> the mask started dropping blocks. I just built them to fix problems that were downstream of a one-character lie about whether zero is positive.</p>
<p>Exactly how much each of those buys, now that the skip actually fires, is a writeup of its own. I’ll dig into the pool_1d-to-pool_2d fix, the amortized rep cache, and the rest of the speed work in a follow-up post. This one’s about the bug.</p>
<h2 id="lesson">The lesson I’ll actually remember</h2>
<p>If a compute graph validates fine on the CPU and misbehaves only on a GPU backend, suspect a per-op semantic mismatch <em>first</em>, before scheduling, fusion, barriers, or memory. Backends are supposed to compute the same function and almost always do, which is exactly why the rare place they don’t is so expensive: every instinct sends you to the systems layer, where the behavior is, instead of the arithmetic, where the bug is. The tell, in hindsight, was that all my workarounds worked by <em>avoiding the math</em>, and I read that as “the systems path is fragile” when it meant “the math is wrong.” A week, for <code>>=</code> versus <code>></code>.</p>
<p>It generalizes past this one shader. The same class of thing, the same op computing subtly different numbers depending on where it runs, is exactly what the determinism people keep finding (<a href="https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/">Thinking Machines’ writeup</a> on batch-variance is the recent canonical one), and it’s why the boring discipline pays: when you build boolean logic out of arithmetic, the boundary value is a real input, not an edge case, and it’s the one a random test generator never produces on its own. The whole thing was avoidable, and I’d have caught it on day one if I’d doubted the easy part instead of the hard part.</p>]]></content:encoded>
    </item>
    <item>
      <title>No more melting my Strix</title>
      <link>https://damenknight.com/strix-thermal-fan-daemon/</link>
      <guid isPermaLink="true">https://damenknight.com/strix-thermal-fan-daemon/</guid>
      <pubDate>Fri, 19 Jun 2026 12:00:00 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <category>AI</category>
      <description>I’ve hard-crashed my Strix Halo miniPC on thermals three separate times across these posts, every time because the fix was manual and I forgot to apply it. So…</description>
      <content:encoded><![CDATA[<p>TL;DR: 
I’ve had my 395 AI system hard crash 3+ times while working on posts for this blog due to thermals. The fix was always the same - cap power and pin the fans before a heavy run - and so was the failure: the fix was manual, it wasn’t always obvious which runs would push the box hard enough to need it, so I’d skip it or forget and melt the box. So I finally wrote a ~100-line daemon that watches the die temperature and ramps the fans itself. Bonus, it basically never requires actually maxing the fans, so it’s way quieter now too!</p>
<p>The daemon is the boring part, a poll loop and four numbers. The rest of this is how I ended up writing it, and why the off-the-shelf tools were no help.</p>
<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#tradition" style="font-size: 0.9375rem; text-decoration: none;">A tradition of cooking this box</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#driving-fans" style="font-size: 0.9375rem; text-decoration: none;">Driving the fans</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#daemon" style="font-size: 0.9375rem; text-decoration: none;">Damen’s daemon</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#code" style="font-size: 0.9375rem; text-decoration: none;">The code</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#real-bug" style="font-size: 0.9375rem; text-decoration: none;">The real bug was man all along</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#caveats" style="font-size: 0.9375rem; text-decoration: none;">The usual caveats</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#learnings" style="font-size: 0.9375rem; text-decoration: none;">General neat learnings</a></li>
</ul>
</div>

<h2 id="tradition">A tradition of cooking this box</h2>
<p>I have now lost a workload to thermals on this miniPC three separate times (four crashes, counting the sitting where I did it twice), across two (soon to be three) posts.</p>
<ol>
<li><a href="https://damenknight.com/running-frontier-coding-model-mini-pc/">The first time</a>, I learned the hard way that a UMA APU in a miniPC chassis will cook itself to a <em>hard</em> shutdown the instant CPU and GPU spike together - a 165W burst the little cooler can’t clear, and the firmware just cuts power. The fix that time was a <code>ryzenadj</code> power cap.</li>
<li><a href="https://damenknight.com/strix-halo-vs-a30-vs-frontier/">The second time</a>, I literally opened the relevant section with the words “don’t re-make the same thermal mistakes as last time,” and then re-made them - forgot to pin the fans before a multi-hour sweep and crashed the box <em>twice</em> in one sitting. After that I wrote a wrapper that refused to start a benchmark unless the fans were confirmed spinning at full tilt. Surely that was the end of it.</li>
<li>It was not the end of it, because the wrapper only helped if I remembered to route a run through it. So I kicked off a 262K-token benchmark the quick way, walked away, and it went dark at 102°C partway through the run.  This post isn’t live yet, hence the lack of a link, but it’s coming.</li>
</ol>
<p>The root problem here was clear: any system built on me <em>remembering</em> was doomed to failure.</p>
<h2 id="driving-fans">Driving the fans</h2>
<p>None of this part is my discovery - I got into Strix Halo thermals the way most people do - by finding <a href="https://strixhalo.wiki/Guides/Power-Mode-and-Fan-Control">the strixhalo.wiki fan-and-power-control guide</a> after the box started misbehaving (it first cooked itself <a href="https://damenknight.com/running-frontier-coding-model-mini-pc/">in my very first post on this machine</a>), which is where both the <code>ec_su_axb35</code> tooling and the “the standard Linux fan stack won’t bind here” situation come from. <em>The part that’s actually mine is the specific curve and the daemon that runs it, further down</em>.</p>
<p>The short version: <code>lm-sensors</code> + <code>fancontrol</code> won’t bind here, because there’s no standard hwmon PWM (pulse-width modulation, the variable signal that normally sets fan speed) channel for it to grab. The fans sit behind a vendor EC (embedded controller) exposed by a custom out-of-tree driver, <code>ec_su_axb35</code>, under its own sysfs class:</p>
<pre><code>/sys/class/ec_su_axb35/
├── apu/power_mode          # balanced | performance
├── fan1/{mode,level,rpm,rampup_curve,rampdown_curve}
├── fan2/...
├── fan3/...
└── temp1/{temp,min,max}
</code></pre>
<p>You set a fan by writing <code>fixed</code> to <code>mode</code> and a <code>level</code> of 1-5, or <code>auto</code> to hand control back to the EC. No PWM percentage, no hwmon binding. (For the record, at <code>level=5</code> the three fans read 4227 / 4321 / 1876 RPM - two big fans near 4300, one smaller one near 1900. Worth knowing, because “max” isn’t one number.)</p>
<p>The daemon hinges on one detail here: the EC’s own <code>temp1</code> is a conservative sensor that tops out around 75°C, but the temperature that actually <em>kills</em> the box - the one that read 102 at the crash - is Tctl, from <code>k10temp</code>. Tctl is the control temperature AMD’s chips report for thermal management: effectively the CPU die/junction temperature, and the number the hardware itself watches when it decides to cut power. So the daemon watches Tctl, not the EC’s sensor (and finds <code>k10temp</code> by name rather than by <code>hwmon</code> index, because those indices renumber across reboots - exactly the kind of thing that turns “works on my machine” into “works until the next reboot”).</p>
<h2 id="daemon">Damen’s daemon</h2>
<p>It’s a graded fan curve on Tctl. Below 75°C the fans go back to <code>auto</code> and the box stays quiet; from 75°C up it steps through fan levels, hitting full blast by 85°C - comfortably under the 100°C TjMax, the junction-temperature limit where the chip starts cutting power to save itself:</p>
<pre><code>CURVE=(75:2 79:3 82:4 85:5)   # Tctl °C : fan level; below 75 -> auto
HYST=3                        # downward deadband so it doesn't flap at a boundary
INTERVAL=3                    # seconds between polls
</code></pre>
<p>That’s the entire configuration. The rest is plumbing: poll Tctl, compute the target level, and if it changed, write the level to all three fans (or hand them back to <code>auto</code> when it cools off). A 3°C downward deadband stops it oscillating when the temperature parks on a band edge; warming ramps immediately, cooling waits a few degrees before stepping down. Simulating a sweep makes the hysteresis obvious:</p>
<pre><code>WARMING:  auto ... 75->lvl2  79->lvl3  82->lvl4  85->lvl5
COOLING:  lvl5 held to 82 ... 81->lvl3  75->lvl2  71->auto
</code></pre>
<p>It runs as a systemd service with <code>Restart=always</code>, and it deliberately does not restore <code>auto</code> when it exits - if the daemon dies, the fans hold where they were rather than dropping mid-load, and systemd just brings it back. Fail toward <em>cold</em>, not quiet.</p>
<p>Here it is doing exactly that. I installed the service, then drove a sustained 35B benchmark and watched its journal:</p>
<pre><code>strix-fan-daemon started; watching hwmon3/temp1_input; curve=75:2 79:3 82:4 85:5 interval=3s hyst=3C
Tctl 50C -> fans AUTO
Tctl 75C -> fans level 2
Tctl 79C -> fans level 3
Tctl 63C -> fans AUTO
</code></pre>
<p>Idle, the fans sit on <code>auto</code>. The bench drives the die temp up, and at 75C the daemon takes over: level 2, then level 3 at 79C. Level 3 turned out to be enough to hold the box in the high 70s for the rest of the run, so it never had to reach for 4 or 5. When the load stopped and the die fell back through the floor, it handed the fans back to <code>auto</code>. And the whole time, <code>power_mode</code> stayed exactly where I left it (<code>performance</code>) - the daemon only ever touches the fans, never the power profile.</p>
<h2 id="code">The code</h2>
<p>Here’s everything: the three files plus an installer. They assume the <code>ec_su_axb35</code> driver is already installed and loaded - if it isn’t, set that up from the <a href="https://strixhalo.wiki/Guides/Power-Mode-and-Fan-Control">strixhalo.wiki guide</a> first. If you have the same box you can lift these as-is; on a different EC the shape carries over even though the sysfs paths won’t.</p>
<p>The daemon itself, <code>strix-fan-daemon.sh</code>:</p>
<pre><code class="language-bash">#!/bin/bash
# strix-fan-daemon - graded Tctl-driven fan curve for the Strix Halo.
#
# Automates, as a service, what fan-inference.sh does by hand: it watches the CPU
# die temperature and ramps the fans so the box stops cooking itself when someone
# forgets to crank them before a benchmark. It touches ONLY the fans
# (fanN/{mode,level}); it never changes apu/power_mode, ryzenadj, or any power limit.
#
# The spec:
#   - graded curve (not binary)
#   - driven by CPU die temp, k10temp / Tctl
#   - full fans (level 5) at >=85C, release to auto below 75C
#   - fans only, no ryzenadj
#
# Runs as root via systemd (strix-fan-daemon.service).

set -u

EC="/sys/class/ec_su_axb35"

# ---- tunables (the whole config is these four things) ----------------------
INTERVAL=3            # seconds between Tctl polls
HYST=3               # downward deadband, in C, to stop the fans flapping at a boundary
HEARTBEAT=20         # log a heartbeat every N polls even when nothing changes

# Graded curve. Each entry "MIN_TEMP:LEVEL": at Tctl >= MIN_TEMP (and below the
# next entry) the fans are pinned to LEVEL (1-5) in fixed mode. Below the lowest
# MIN_TEMP they drop back to auto (quiet idle). power_mode is never touched. The 75
# floor and the 85->5 top are the anchors; 79/82 are the ramp in between.
CURVE=(75:2 79:3 82:4 85:5)
# ----------------------------------------------------------------------------

log() { echo "$(date '+%F %T') $*"; }

if [ "${EUID:-$(id -u)}" -ne 0 ]; then
  echo "strix-fan-daemon must run as root (it writes $EC)." >&2
  exit 1
fi

# Resolve the k10temp Tctl input by hwmon *name* (indices move across reboots).
find_tctl() {
  local h lab
  for h in /sys/class/hwmon/hwmon*; do
    [ -r "$h/name" ] || continue
    [ "$(cat "$h/name")" = "k10temp" ] || continue
    for lab in "$h"/temp*_label; do
      [ -r "$lab" ] || continue
      if [ "$(cat "$lab")" = "Tctl" ]; then echo "${lab%_label}_input"; return 0; fi
    done
    [ -r "$h/temp1_input" ] && { echo "$h/temp1_input"; return 0; }
  done
  return 1
}

# Desired fan level for a given temp (0 = auto/idle).
desired_level() {
  local t=$1 lvl=0 entry min level
  for entry in "${CURVE[@]}"; do
    min=${entry%:*}; level=${entry#*:}
    [ "$t" -ge "$min" ] && lvl=$level
  done
  echo "$lvl"
}

apply_level() {            # $1 = level (0 = auto). Fans only - never touches apu/power_mode.
  local f
  if [ "$1" -eq 0 ]; then
    for f in fan1 fan2 fan3; do echo auto > "$EC/$f/mode"; done
  else
    for f in fan1 fan2 fan3; do
      echo fixed > "$EC/$f/mode"; echo "$1" > "$EC/$f/level"
    done
  fi
}

TCTL=$(find_tctl) || { log "FATAL: could not find k10temp/Tctl in /sys/class/hwmon"; exit 1; }
log "strix-fan-daemon started; watching $TCTL; curve=${CURVE[*]} interval=${INTERVAL}s hyst=${HYST}C"

cur=-1            # last level actually applied (-1 = unknown/forces first apply)
ticks=0
while :; do
  if ! milli=$(cat "$TCTL" 2>/dev/null) || [ -z "$milli" ]; then
    log "WARN: Tctl read failed; leaving fans unchanged"; sleep "$INTERVAL"; continue
  fi
  t=$(( milli / 1000 ))

  want=$(desired_level "$t")
  if [ "$want" -lt "$cur" ]; then
    # Cooling: only step down if we'd still want a lower level HYST degrees hotter
    # (otherwise hold, to avoid flapping at a band edge).
    want_warm=$(desired_level "$(( t + HYST ))")
    [ "$want_warm" -ge "$cur" ] && want=$cur
  fi

  if [ "$want" -ne "$cur" ]; then
    apply_level "$want"
    if [ "$want" -eq 0 ]; then log "Tctl ${t}C -> fans AUTO"; else log "Tctl ${t}C -> fans level $want"; fi
    cur=$want
    ticks=0
  else
    ticks=$(( ticks + 1 ))
    if [ "$ticks" -ge "$HEARTBEAT" ]; then
      log "Tctl ${t}C (holding $( [ "$cur" -eq 0 ] && echo AUTO || echo "level $cur" ))"; ticks=0
    fi
  fi
  sleep "$INTERVAL"
done
</code></pre>
<p>The systemd unit, <code>strix-fan-daemon.service</code>. The comment is the one deliberate choice in here: if the daemon dies, do not drop the fans back to auto.</p>
<pre><code class="language-ini">[Unit]
Description=Strix Halo graded Tctl fan-curve daemon
Documentation=/blog-posts
After=multi-user.target

[Service]
Type=simple
ExecStart=/usr/local/sbin/strix-fan-daemon.sh
Restart=always
RestartSec=2
# Fail safe: if the daemon dies we do NOT restore auto (that would drop the fans
# mid-load); systemd just restarts it, and fans hold wherever they were.
Nice=-5

[Install]
WantedBy=multi-user.target
</code></pre>
<p>And the idempotent installer, <code>install.sh</code>:</p>
<pre><code class="language-bash">#!/bin/bash
# Idempotent installer for the Strix fan-curve daemon. Run as root on the system:
#   sudo ./install.sh
# Safe to re-run; it just refreshes the files and reloads the service.
set -euo pipefail

SRC="$(cd "$(dirname "$0")" && pwd)"

install -m 0755 "$SRC/strix-fan-daemon.sh"      /usr/local/sbin/strix-fan-daemon.sh
install -m 0644 "$SRC/strix-fan-daemon.service" /etc/systemd/system/strix-fan-daemon.service

systemctl daemon-reload
systemctl enable strix-fan-daemon.service
systemctl restart strix-fan-daemon.service   # restart so a re-install actually picks up script changes
sleep 1
systemctl status --no-pager strix-fan-daemon.service || true
echo
echo "Installed. Follow it live with:  journalctl -fu strix-fan-daemon"
</code></pre>
<p>Drop those three in a directory, then:</p>
<pre><code>sudo ./install.sh
journalctl -fu strix-fan-daemon      # watch it work
</code></pre>
<h2 id="real-bug">The real bug was man all along</h2>
<p>Here’s the part I’d want past-Damen to read. Every one of these three deaths already had a fix written down - <code>ryzenadj</code> after the first, fan-pinning and then a whole bench wrapper after the second. What kept failing was process: <em>a fix you have to remember to apply is a fix that eventually doesn’t get applied</em>.</p>
<p>So now, the protection runs as a service the box starts itself - watching temperature continuously rather than waiting on me!</p>
<h2 id="caveats">The usual caveats</h2>
<ul>
<li>Fans only - this does not cover the burst-spike death. The very first thermal crash wasn’t a slow cook; it was a 165W CPU+GPU transient that tripped a hard shutdown faster than any fan can respond. Fans can’t save you from that - only a power cap can. This daemon is deliberately fans-only, so for burst protection you still want the <code>ryzenadj</code> cap from the first post. What it fixes is the <em>sustained-load</em> death (the one that ate the 262K bench), which is the one that actually keeps happening to me.</li>
<li>It’s reactive. The curve engages as Tctl climbs past 75, so there’s a brief warm-up transient at the start of a heavy load before the fans spin up - unlike pinning them by hand <em>before</em> you start. Fine for sustained benchmarks; if you wanted to close that gap you’d add a load-aware floor on top of the curve.</li>
<li>One box, one EC. All of this is specific to the <code>ec_su_axb35</code> driver on this particular miniPC. The shape of the solution generalizes; the sysfs paths and fan levels may not.</li>
</ul>
<h2 id="learnings">General neat learnings</h2>
<ul>
<li>If your fans have no hwmon PWM, the standard tooling won’t help - find the vendor EC’s sysfs and drive it yourself. It’s less work than it sounds: this whole daemon is one poll loop and four config values.</li>
<li>Watch the temperature that kills the box, not the convenient one. Tctl, not the EC’s polite low-reading sensor.</li>
</ul>
<p>The least reliable component in this miniPC was always the human holding the SSH session. It’s a hundred lines of bash’s job now, and bash doesn’t forget to turn the fans on.</p>]]></content:encoded>
    </item>
    <item>
      <title>I told 3090 owners to skip MTP. I was very wrong.</title>
      <link>https://damenknight.com/mtp-mistakes/</link>
      <guid isPermaLink="true">https://damenknight.com/mtp-mistakes/</guid>
      <pubDate>Thu, 18 Jun 2026 12:00:00 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <category>AI</category>
      <description>Last time I told 3090 owners to skip MTP. Then I took my own advice and ran it on my 3090: +41%, the biggest speedup in the series, on the exact card I’d said…</description>
      <content:encoded><![CDATA[<p>In <a href="https://damenknight.com/mtp-speculative-decoding-strix-halo/">the MTP post</a> I got Multi-Token Prediction working on the Strix Halo miniPC, squeezed an 18-26% speedup out of qwen3.6 once I stopped using the default that made it 3x slower, and then handed out some advice. The win was probably specific to bandwidth-starved hardware, I said, so if you’re on a 3090 or an A100, don’t expect these numbers. I also said that if you measured MTP on a different GPU, you should publish what you got. But here’s the thing… I OWN a 3090.  Me not doing that myself was just lazy!  So I took my own advice and ran it on two more cards - an MI300X datacenter GPU and an RTX 3090 and the 3090 turned in <em>+41%</em>, the biggest speedup in the whole series, on the exact card I’d told you to skip.  Err… My bad?</p>
<p>The MI300X behaved as expected: MTP made qwen3.6 <em>slower</em> at every setting. And the part that should have tipped me off sooner is that all three machines - Strix, 3090, MI300X - accepted the draft head’s guesses about 90% of the time. Same trick, same acceptance rate, and the outcome ran from a 41% win to a loss at every setting based on nothing but which GPU it ran on. My mistake was reasoning about memory bandwidth, when bandwidth is only a proxy for the variable that actually decides this - a variable that’s been sitting in the <a href="https://arxiv.org/abs/2211.17192">original speculative-decoding paper</a> since 2023, which I’d have known if I’d googled before I started giving advice ;)</p>
<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#refresher" style="font-size: 0.9375rem; text-decoration: none;">A brief refresher</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#mi300x-slower" style="font-size: 0.9375rem; text-decoration: none;">The MI300X: slower everywhere</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#3090-wrong" style="font-size: 0.9375rem; text-decoration: none;">The 3090: where Damen was wrong</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#didnt-add-up" style="font-size: 0.9375rem; text-decoration: none;">The part that didn’t add up (until it did)</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#why-bandwidth" style="font-size: 0.9375rem; text-decoration: none;">Why: bandwidth was a proxy</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#takeaways" style="font-size: 0.9375rem; text-decoration: none;">What to take from it</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#caveats" style="font-size: 0.9375rem; text-decoration: none;">Caveats</a></li>
</ul>
</div>

<h2 id="refresher">A brief refresher</h2>
<p>Speculative decoding speeds up generation by having a small, fast predictor guess several tokens ahead, then letting the full model verify the whole batch in one forward pass. Every accepted guess is a token you got nearly for free. MTP is the self-speculative version: the model ships with a lightweight extra head trained to predict a few tokens out, so the draft and the verify come from the same model. The knob that matters is <code>--spec-draft-n-max</code>, how many tokens the head drafts before the model checks its work. On the miniPC the sweet spot was 2-4; the default of 16 was a disaster. (All of that is in the <a href="https://damenknight.com/mtp-speculative-decoding-strix-halo/">first post</a>; this one assumes it.)</p>
<p>The catch with spec decoding is that every <em>rejected</em> guess is wasted compute, both the draft’s and the verify’s. The whole thing only pays off if the guesses are accepted often enough that the saved steps outweigh the wasted work. So you check the acceptance rate. Which is where these results stop making sense.</p>
<h2 id="mi300x-slower">The MI300X: slower everywhere</h2>
<p>Same model (qwen3.6-35B-A3B), same llama.cpp, single MI300X, decode tokens/sec, higher is better. Methodology: a fixed ~80-token prompt, 256 tokens generated, 3 reps averaged with a warm-up discarded, via llama-server’s <code>/completion</code> endpoint. I swept <code>--spec-draft-n-max</code> from 1 to 16 against the MTP-off baseline, for both a Q8_0 and a Q4_K_M trunk:</p>
<table>
<thead>
<tr>
<th>n_max</th>
<th>Q8_0 tok/s</th>
<th>vs off</th>
<th>Q4_K_M tok/s</th>
<th>vs off</th>
</tr>
</thead>
<tbody>
<tr>
<td>off</td>
<td>145.4</td>
<td>-</td>
<td>143.1</td>
<td>-</td>
</tr>
<tr>
<td>1</td>
<td>124.8</td>
<td>-14%</td>
<td>135.1</td>
<td>-6%</td>
</tr>
<tr>
<td>2</td>
<td>125.3</td>
<td>-14%</td>
<td>130.7</td>
<td>-9%</td>
</tr>
<tr>
<td>3</td>
<td>139.8</td>
<td>-4%</td>
<td>126.3</td>
<td>-12%</td>
</tr>
<tr>
<td>4</td>
<td>133.2</td>
<td>-8%</td>
<td>126.6</td>
<td>-11%</td>
</tr>
<tr>
<td>8</td>
<td>124.1</td>
<td>-15%</td>
<td>118.7</td>
<td>-17%</td>
</tr>
<tr>
<td>16 (default)</td>
<td>115.7</td>
<td>-20%</td>
<td>106.5</td>
<td>-26%</td>
</tr>
</tbody>
</table>
<p>No wins there.  The least-bad config (Q8_0 at n_max=3) still loses 4%, and that one had enough run-to-run variance that I’d call it a wash at best. The default n_max=16 is a 20-26% regression, reproducing the same “the default is a trap” finding from the miniPC, just without any good configuration on the other side of it to redeem it. On this hardware there is no setting I’ve found where MTP comes out ahead.</p>
<h2 id="3090-wrong">The 3090: where Damen was wrong</h2>
<p>And now for something completely different. RTX 3090, 24 GB, CUDA build of the same llama.cpp, same qwen3.6-35B-A3B at Q4_K_M with the matching MTP head. Greedy decode so MTP-on produces byte-identical output to MTP-off (with greedy, speculative decoding is exact, so this is a controlled same-workload speed test), 256 tokens, three reps each. Decode tokens/sec:</p>
<table>
<thead>
<tr>
<th>n_max</th>
<th>tok/s</th>
<th>vs off</th>
</tr>
</thead>
<tbody>
<tr>
<td>off</td>
<td>167</td>
<td>-</td>
</tr>
<tr>
<td>1</td>
<td>217</td>
<td>+30%</td>
</tr>
<tr>
<td>2</td>
<td>236</td>
<td>+41%</td>
</tr>
<tr>
<td>3</td>
<td>233</td>
<td>+39%</td>
</tr>
<tr>
<td>4</td>
<td>234</td>
<td>+40%</td>
</tr>
<tr>
<td>8</td>
<td>170</td>
<td>+1%</td>
</tr>
<tr>
<td>16</td>
<td>128</td>
<td>-24%</td>
</tr>
</tbody>
</table>
<p>That is a real, large win, and the reason I’d bet against it is worth digging into a little because past me wasn’t REALLY just being lazy. I’d cited <a href="https://github.com/thc1006/qwen3.6-speculative-decoding-rtx3090">an early community writeup</a> that swept 19 spec-decode configs on a 3090 and found none faster than baseline, and that’s what I leaned on when I told you to skip it. In fairness to “past Damen”, that writeup wasn’t even testing the same mechanism: it swept a separate draft model plus ngram methods, not the self-speculative MTP head this post is about, and its own author was careful to call the negative result engine-specific to llama.cpp’s draft path - not a property of the card or the model - having seen vLLM’s MTP come out ahead.  Run through the draft-mtp path, MTP on this 3090 turns out to be the best result in the series. I’ll happily take being wrong in this direction.</p>
<p>The peak is +41% at n_max=2, with a comfortable plateau across 2 to 4. The acceptance rate at the peak was 90.6% (164 of 181 drafted tokens accepted, measured server-side; the throughput numbers above come from the greedy llama-cli runs, so acceptance and speed are from separate invocations of the same config rather than one combined measurement). The off baseline (167 tok/s) matches an independent llama-bench run to within noise, so the speedup is genuine and not a harness artifact. Two things jump out against the MI300X. The win is <em>bigger</em> than the miniPC’s +18-26%, on a card with almost four times the bandwidth, which is backwards from any simple bandwidth story. And the n_max=16 default tanks it here too, down 24%.</p>
<h2 id="didnt-add-up">The part that didn’t add up (until it did)</h2>
<p>The strange part was the acceptance. It was excellent everywhere: 82-93% on the MI300X, 90.6% on the 3090, similarly high on the Strix. And that same near-90% bought a 41% speedup on the 3090, +18-26% on the miniPC, and a <em>slowdown</em> on the MI300X. So “is the speculation working?” (yes, accepted everywhere) turns out to be a completely different question from “is it helping?” Acceptance tells you the head is good. It says nothing about whether the saved steps are worth the added compute, and that depends entirely on the hardware.</p>
<h2 id="why-bandwidth">Why: bandwidth was a proxy</h2>
<p>This is the part I got wrong, and it’s textbook, which somehow makes it worse. I went looking in memory bandwidth, and there <em>is</em> a bandwidth story here, but it’s a stand-in for something more precise that’s been written down since the technique was invented.</p>
<p>Speculative decoding has a closed-form speedup model, from <a href="https://arxiv.org/abs/2211.17192">Leviathan et al. (2023)</a>, the paper that introduced it. The expected wall-clock speedup is:</p>
<pre><code>       1 - α^(γ+1)
S = ──────────────────
     (1 - α)(γc + 1)
</code></pre>
<p>Three knobs. α is the acceptance rate - how often a drafted token survives verification. γ is how many tokens you draft per step (our <code>--spec-draft-n-max</code>). And c is the one I had been ignoring: the <em>cost ratio</em>, how long one draft pass takes relative to one verify pass. The catch is that it’s platform-dependent, a property of the model <em>and the hardware it runs on</em>, not the model alone.</p>
<p>That fact is… kind of the whole thing here. Acceptance only sets the <em>numerator</em> - how many tokens a good round buys you; c sets the <em>denominator</em>, what that round costs. I’d been obsessing over the numerator and ignoring the denominator the hardware quietly controls.</p>
<p>So I did the thing I should have done first: I took our measured α, γ, and speedup on each card and solved the formula backwards for the c that each result implies. At the matched setting - γ=2, acceptance ~91% on both - the cost ratio is the entire story:</p>
<table>
<thead>
<tr>
<th>Card</th>
<th>acceptance α</th>
<th>observed speedup</th>
<th>implied cost ratio c</th>
</tr>
</thead>
<tbody>
<tr>
<td>RTX 3090 (CUDA)</td>
<td>0.906</td>
<td>1.41× (+41%)</td>
<td>≈ 0.47</td>
</tr>
<tr>
<td>MI300X, Q4_K_M (ROCm)</td>
<td>0.914</td>
<td>0.91× (-9%)</td>
<td>≈ 1.0</td>
</tr>
<tr>
<td>MI300X, Q8_0 (ROCm)</td>
<td>0.904</td>
<td>0.86× (-14%)</td>
<td>≈ 1.08</td>
</tr>
</tbody>
</table>
<p>There it is. At the same acceptance the 3090’s draft costs under half a verify pass while the MI300X’s effective cost is more than a <em>whole</em> one. A draft that is cheap relative to verification (c well under 1) leaves room for the accepted tokens to net out ahead. A draft that costs as much as the verification it is trying to save (c near or above 1) can never come out ahead no matter how good the acceptance - you can read it straight off the formula, where once <code>γc + 1</code> outgrows the numerator, S drops below 1. The break-even c at ~91% acceptance is around 0.86; the 3090 sits comfortably under it, the MI300X over it. </p>
<p>So why is the MI300X’s c so much higher? Bandwidth comes back in here, not as the answer but as the thing that <em>sets</em> c. Verification batches γ+1 tokens into one forward pass, and that pass is only cheap (only about one decode step instead of γ+1 of them) if its dominant cost amortizes across the batch. Streaming the weights is a per-pass cost: load them once, verify the whole batch, done. So when decode is memory-bandwidth-bound, a γ-token verify costs about what a 1-token decode costs, the denominator’s “1” holds, and c stays small. That is the 3090. But MoE routing and attention are per-token costs - a four-token verify does four tokens’ worth of both, batched or not. On the MI300X, single-stream decode of a 3B-active MoE is not bandwidth-bound at all (it sits under 10% of the card’s 5.3 TB/s, bottlenecked on that per-token overhead instead), so verification does not amortize, the effective “1” in the denominator behaves more like γ+1, and that violation gets folded straight into c. The MI300X is just paying per-token where the 3090 pays per-pass.</p>
<p>And this is why a result that looks like a flat contradiction isn’t one. AMD has published <a href="https://rocm.blogs.amd.com/artificial-intelligence/spec_decode_mi300x/README.html">up to 3× from speculative decoding on the very same MI300X, at batch size 1</a> - the exact configuration where I measured a loss. The difference is the model. Their wins are on <em>dense</em> 34B-to-70B models, and a dense 70B at batch-1 genuinely is bandwidth-bound: streaming seventy billion parameters per token is an enormous per-pass cost that amortizes beautifully, so its c stays small and the formula pays out. qwen3.6 fires only ~3B parameters per token, so it never gets bandwidth-bound on a 5.3 TB/s card, its verify will not amortize, and its c balloons. The model’s <em>active</em> size - not the card - decides whether you are paying per-pass or per-token. (The MoE speculative-decoding literature describes the same mechanism from the other side: under speculation a γ-token verify can activate the <em>union</em> of all experts those tokens route to, inflating exactly the per-token cost that will not amortize - see e.g. <a href="https://arxiv.org/abs/2602.16052">MoE-Spec</a>.)</p>
<p>One caveat on that c column before I move on: I solved for c rather than measuring it directly, so it is an <em>effective</em> ratio. It lumps a genuinely expensive draft together with verification that simply didn’t amortize, and only a draft-pass/verify-pass timing breakdown, which I haven’t run, would separate the two. (More on the cross-backend caveats at the end.)</p>
<p><u>None of this is new theory</u>. The Leviathan formula is the founding paper, and the roofline reading of it - amortize per-pass costs, pay per-token costs in full - is well-trodden. What three boxes running the identical model show is the half that’s easy to forget: at <em>fixed acceptance</em>, the speedup is whatever the cost ratio says, and that ratio swings by more than 2× from card to card. Bandwidth was never the variable. It was a proxy for c, and the Strix is proof the proxy leaks.</p>
<h2 id="takeaways">What to take from it</h2>
<ul>
<li>The speedup has a formula, and acceptance is only one of its three terms. Whether MTP helps is set by acceptance α, draft depth γ, <em>and</em> the platform cost ratio c = draft-pass time over verify-pass time. c is the one that varies by hardware, and it is what decides whether the same ~90% acceptance turns into +41% or -14%. If you remember one thing past “tune n_max,” remember that the cost ratio, not the bandwidth, is what you are really up against.</li>
<li>Don’t trust acceptance rate as a go/no-go signal. Roughly 90% acceptance bought +41% on one card and a net loss on another, because acceptance only sets the formula’s numerator. Measure end-to-end tokens/sec against the MTP-off baseline; that is the only number that decides it.</li>
<li>The n_max=16 default is bad everywhere. It was a 3x regression on the miniPC and a 20-26% one on both the MI300X and the 3090. Wherever MTP runs, the sweet spot was 2 to 4 and the default was wrong. If you turn spec decoding on, that is the first knob to move.</li>
</ul>
<p>The card that benefitted most was the mid-range one, because its draft was cheap relative to its verify - which bandwidth only loosely predicts. It was never about small versus big. It’s the cost ratio, and that’s a thing you can measure instead of guess.</p>
<p>So: <u>if you read the last post and skipped MTP on your 3090 - sorry, go turn it on</u>.</p>
<h2 id="caveats">Caveats</h2>
<ul>
<li>Single-stream, batch-1. This is the latency case. Under heavy concurrent batching the decode regime changes (it becomes compute-bound for a different reason), and the spec-decode tradeoff would need its own measurement. These conclusions are specific to the one-request-at-a-time case.</li>
<li>The workload was CoT-heavy, which flatters MTP. All three machines were measured on a thinking-model prompt that generates long, structured chains of reasoning, and that kind of text is unusually self-predictable, which pushes acceptance toward 90% and the speedup with it. The <em>relative</em> comparison across machines holds, because they all saw the same favorable workload, but the absolute gains would shrink on less predictable output. Read +41% as the top of the range for this card, not a universal number.</li>
<li>The three rows are not a controlled cross-hardware A/B, and c lives in the software stack as much as the silicon. Strix ran Vulkan, the MI300X ROCm, the 3090 CUDA, at differing quants, so the cross-card magnitudes carry the usual cross-backend caveat; what holds up is the sign and rough size of each result plus the within-machine off-vs-on comparisons, which were measured identically per card. The Strix is the live example of the backend mattering: an open llama.cpp issue (<a href="https://github.com/ggml-org/llama.cpp/issues/23126">#23126</a>) reports its Vulkan path on unified-memory iGPUs pays a draft/target synchronization penalty that inflates c for backend reasons, not hardware ones (it’s filed for a separate draft model, not MTP, so it may not transfer). It’s also why I don’t quote a back-computed c for the Strix at all: I never captured a clean acceptance number for it, so any c would be invented.</li>
<li>One model, one architecture. qwen3.6 is a 3B-active MoE, which is part of why single-stream decode is so overhead-bound on the MI300X. A dense model would shift the compute-versus-bandwidth balance, and where it landed on each card is its own measurement.</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>When Smaller Stops Being Faster: a Quant Ladder on the MI300X</title>
      <link>https://damenknight.com/mi300x-quant-ladder/</link>
      <guid isPermaLink="true">https://damenknight.com/mi300x-quant-ladder/</guid>
      <pubDate>Fri, 12 Jun 2026 12:00:00 GMT</pubDate>
      <category>Projects</category>
      <category>AI</category>
      <description>Someone handed me an MI300X for a little while, so I walked two MoE coding models down the quant ladder - BF16 to Q4 - to answer what the Strix Halo miniPC…</description>
      <content:encoded><![CDATA[<p>For three posts I ran coding models on a “cheap” Strix Halo miniPC, and one rule held the whole way through: <em>smaller is faster</em>. I put it plainly in <a href="https://damenknight.com/mtp-speculative-decoding-strix-halo/">the MTP post</a>: since generation is bandwidth-bound, a smaller quant can mean a faster one, because a starved iGPU spends every token waiting on memory and a lighter model has fewer bytes to haul per token. Then I managed to get my hands on a very beefy modern server for a little while, with effectively infinite RAM and an MI300X to play with ;)</p>

<p>So what happens when you go the other direction, from the miniPC to the biggest GPU you can get your hands on? What happens when VRAM, bandwidth, and “what fits” all stop being the bottleneck? <strong>How much quality do you actually lose when you quantize a model, and is the speed worth it?</strong></p>

<p>The quality answer is the dull one, so let’s get it out of the way: from BF16 down to Q4_K_M I couldn’t find a difference worth reporting (on either model, whether the benchmark was code, math, instruction-following, or knowledge).</p>

<p>The speed answer is why I bothered. Most of what we learned about quantizing on the miniPC was a bandwidth artifact: a set of lessons that were only the case because that little machine was starving for memory, and they cease to be true on a card with bandwidth to spare. The cleanest case: quantizing harder used to make a model generate faster, and here it does nothing at all, Q8 and Q4 decoding at the same speed. A few of the old rules go further than flat and reverse outright, and chasing down why is most of what follows.</p>

<p>There’s one practical takeaway worth pulling right to the front (which I..... have not done here, sorry, I’m a jerk). FP8 is the format you’d actually deploy on an MI300X, and served the naive/obvious way - <code>--quantization fp8</code> and nothing else - it is <em>slower</em> than not quantizing at all, losing to plain BF16 at the low concurrency a single self-hosted endpoint actually runs at, and only inching ahead once you’re pushing 128 streams. One environment variable that, outside AMD’s and vLLM’s own posts and threads, I’ve barely seen mentioned anywhere, <code>VLLM_ROCM_USE_AITER=1</code>, swings it up to 46% the other way: a bigger lever than the entire gap between FP8 and full precision. If you serve models on AMD and that’s news, then you are welcome.</p>

<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#why-quant-ladder" style="font-size: 0.9375rem; text-decoration: none;">Why a “quant ladder” at all??</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-models" style="font-size: 0.9375rem; text-decoration: none;">The models on the ladder</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#how-served" style="font-size: 0.9375rem; text-decoration: none;">How it was served</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#quality-flat" style="font-size: 0.9375rem; text-decoration: none;">Quality: the ladder is flat</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#speed-breaks" style="font-size: 0.9375rem; text-decoration: none;">Speed: where the miniPC intuition breaks</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#when-pays-off" style="font-size: 0.9375rem; text-decoration: none;">So when does quantizing a big GPU ever pay off?</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#what-to-pick" style="font-size: 0.9375rem; text-decoration: none;">So what do you actually pick</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#caveats" style="font-size: 0.9375rem; text-decoration: none;">Caveats and fine print</a></li>
</ul>
</div>

<h2 id="why-quant-ladder">Why a “quant ladder” at all??</h2>

<p>A quick refresher: A model’s weights are stored at some numeric precision. Train and release at BF16 (16 bits per weight), then <em>quantize</em> down - Q8 (~8 bits), Q6, Q5, Q4 - to make the file smaller and, on most hardware, faster. The catch is supposed to be quality: fewer bits per weight means a coarser approximation of what the model learned, and at some point it should start writing worse code.</p>

<p>The miniPC posts established one half of this: quality is hardware-agnostic. Run the same GGUF on a Strix Halo iGPU or an A30 and you get the same benchmark score within noise, because the hardware decides <em>how fast</em>, not <em>how smart</em>. This post is the other half: hold the hardware fixed, walk <em>down the quant ladder</em>, and watch what happens to both quality and speed. BF16 sits at the top as ground truth - the precision the model was actually released at - and each rung below trades bits for bytes.</p>

<p>The MI300X is the right place to run this cleanly, for two dumb reasons: 1) I never have to offload and 2) ... I mean someone gave me access to an MI300X! On the miniPC, “what fits” is the entire story. Here, a 35B or 80B model fits in a single GPU’s 192 GiB with room to spare at <em>every</em> quant including BF16, so nothing spills, nothing streams over PCIe, and the only variable that moves is the precision.</p>

<h2 id="the-models">The models on the ladder</h2>

<p>Two models, both of which have appeared in this series, both Mixture-of-Experts (total parameters large, active-per-token small):</p>

<table>
<thead>
<tr>
<th>Model</th>
<th>Total / active</th>
<th>Role</th>
<th>Thinking?</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>qwen3.6-35B-A3B</code></td>
<td>35B / ~3B</td>
<td>the general reasoning model I landed on <a href="https://damenknight.com/strix-halo-vs-a30-vs-frontier/">in this post</a></td>
<td>yes (on/off switch)</td>
</tr>
<tr>
<td><code>qwen3-coder-next</code> (80B-A3B)</td>
<td>80B / ~3B</td>
<td>the <a href="https://damenknight.com/running-frontier-coding-model-mini-pc/">bigger coder model</a> from the same posts</td>
<td>no (instruct-only)</td>
</tr>
</tbody>
</table>

<p>Picking these two on purpose: one mid-size reasoning model with a thinking mode, one larger coder model without. If the quant curve looks the same on both, the finding generalizes past a single architecture.</p>

<h2 id="how-served">How it was served</h2>

<p>Two serving stacks, because no single one does everything I needed cleanly on ROCm:</p>

<ul>
<li><strong>GGUF K-quants via llama.cpp</strong> built for ROCm/gfx942 - this is the ladder proper (Q8_0, Q6_K, Q5_K_M, Q4_K_M), and it’s the <em>same quant format</em> I ran on Strix Halo, so the numbers are directly comparable across the whole series. Plain <code>llama-quantize</code>, no imatrix, to keep it honest and reproducible.</li>
<li><strong>vLLM</strong> (AMD’s <code>rocm/vllm-dev</code> container - bare-metal vLLM on ROCm 7.x is its own saga, container-only is the path) for two reference points the GGUF ladder can’t give me: the <strong>BF16</strong> anchor and a <strong>native FP8</strong> run, FP8 being the format you’d actually deploy in production.</li>
</ul>

<p>The one wrinkle worth flagging up front: the BF16/FP8 reference points come from vLLM, while the Q8-to-Q4 ladder comes from llama.cpp. So a BF16-vs-Q8 comparison crosses a serving-stack boundary, and I won’t lean on it. The <em>clean</em> apples-to-apples quant curve is Q8 down to Q4, all llama.cpp, all the same harness - and that’s the one I draw conclusions from. BF16 and FP8 are there as sanity anchors, not as rungs.</p>

<p>A note on what I <em>didn’t</em> run: HumanEval+. I left it on the shelf on purpose, because I’ve watched my own HumanEval+ harness score the <em>same model file</em> anywhere from 37% to 92% just by rephrasing the prompt (nested code fences in the prompt template were enough to make a weaker quant emit a fragment instead of a function). A benchmark that swings 55 points on prompt formatting isn’t measuring the model, it’s measuring my harness, so I trust Polyglot and lm_eval here and treat HumanEval+ numbers, mine or anyone’s, with suspicion.</p>

<p>The big risk going in was narrower than any of this, and specific to this box. I’d been running qwen3-coder-next on llama.cpp for months on Strix Halo and the A30, so the model and its GGUFs were known-good; what was new here was the <strong>backend, llama.cpp built for ROCm/gfx942</strong>. qwen3-coder-next isn’t a plain transformer, it runs on the Qwen3-Next architecture with a hybrid/linear-attention design whose unusual kernels have needed their own fixes in llama.cpp before, and the Vulkan and CUDA paths I already trusted were no guarantee the HIP/ROCm path would compute those layers correctly. It’s the kind of model that can load up looking fine and still quietly emit garbage. So the de-risk gate was: build for gfx942, load, run one Polyglot exercise to prove the harness could still tell a right answer from a wrong one <em>before</em> committing to multi-hour runs. It cleared, which I don’t take for granted when an odd architecture meets a backend it hasn’t been road-tested on.</p>

<h2 id="quality-flat">Quality: the ladder is flat</h2>

<p>This is the half I expected to be dull, and it obliged. That quantizing down to Q4 barely touches quality is close to received wisdom by now, so I walked the ladder to confirm it on these two models, just in case of a surprise.</p>

<p>Here’s the coding result - <a href="https://aider.chat/2024/12/21/polyglot.html">Aider Polyglot</a>, 225 multi-language exercises, pass@2 (the model gets a second shot after seeing test failures, which is how you’d actually use it):</p>

<table>
<thead>
<tr>
<th>Quant</th>
<th>qwen3.6 thinking-on</th>
<th>qwen3.6 thinking-off</th>
<th>qwen3-coder-next</th>
</tr>
</thead>
<tbody>
<tr><td>BF16 <em>(vLLM ref)</em></td><td>59.6</td><td>54.2</td><td>-</td></tr>
<tr><td>Q8_0</td><td>65.3</td><td>56.0</td><td>57.8</td></tr>
<tr><td>Q6_K</td><td>61.3</td><td>57.3</td><td>58.2</td></tr>
<tr><td>Q5_K_M</td><td>65.3</td><td>52.4</td><td>58.7</td></tr>
<tr><td>Q4_K_M</td><td>61.8</td><td>50.2</td><td>56.9</td></tr>
<tr><td>FP8 <em>(vLLM ref)</em></td><td>59.1</td><td>-</td><td>-</td></tr>
</tbody>
</table>

<p>Take a long look at the numbers and let them sink in. The qwen3-coder-next ladder spans <strong>1.8 points</strong> top to bottom - Q5 (the 5-bit quant!) scores <em>higher</em> than Q8. qwen3.6 thinking-on bounces between 61 and 65 with no downward trend; Q8 and Q5 tie at the top, Q6 dips below both. These aren’t degradation curves, they’re noise. On a 225-exercise benchmark, the run-to-run wobble is bigger than anything the quantization is doing. Down to Q4_K_M - a 4-bit approximation of the weights - there is no measurable coding-quality cost on either model.</p>

<p>And it’s not just coding. The whole point of running <a href="https://github.com/EleutherAI/lm-evaluation-harness">lm_eval</a> on top was to check quality from angles Polyglot can’t see - math, instruction-following, broad knowledge:</p>

<table>
<thead>
<tr>
<th></th>
<th>gsm8k</th>
<th>ifeval</th>
<th>mmlu_pro</th>
</tr>
</thead>
<tbody>
<tr><td>qwen3.6, Q8 to Q4</td><td>87.0 to 87.1%</td><td>83.7 to 83.0%</td><td>77.8 to 77.4%</td></tr>
<tr><td>coder-next, Q8 to Q4</td><td>89.6 to 87.8%</td><td>79.9 to 81.1%</td><td>76.2 to 76.0%</td></tr>
</tbody>
</table>

<p>Every delta from Q8 to Q4 is under one point. (One thing I watched for, since these are reasoning-capable models: if you grade a thinking model wrong, its chain-of-thought eats the answer field and gsm8k craters to single digits - a config bug that looks like a quality cliff. gsm8k landing at 87-90% is how I know the reasoning was handled correctly and these scores are real, not artifacts of a misgraded run.) Across four benchmarks and two models, going from 16 bits down to 4 costs you essentially nothing I can measure. That’s the result everyone hopes quantization gives and rarely gets to state this cleanly, so I’ll take the clean win before the speed section complicates things.</p>

<h3 id="thinking-gap">A notion that didn’t pan out: the thinking gap doesn’t widen</h3>

<p>qwen3.6 has a thinking on/off switch, and I ran both modes partly to chase a notion: maybe chain-of-thought <em>compensates</em> for quantization damage, so the gap between thinking-on and thinking-off should widen as you quantize harder (thinking would have more to fix). The first two data points teased it - the gap looked like it grew from ~9pp at Q8 to ~12pp at Q4.</p>

<p>Then I filled in the middle rungs, and the pattern evaporated. The thinking-on-minus-off gap across BF16/Q8/Q6/Q5/Q4 goes <strong>5.4 / 9.3 / 4.0 / 12.9 / 11.6</strong> points, a random walk between 4 and 13. The apparent widening was just two cherry-picked endpoints. Thinking doesn’t measurably rescue a more-quantized model, boooo. Oh well, worth a try!</p>

<h2 id="speed-breaks">Speed: where the miniPC intuition breaks</h2>

<p>This is the part that surprised me a little. Here’s <code>llama-bench</code> on qwen3.6 across the ladder - prefill (processing your prompt) and decode (generating the response), tokens/sec, higher is better:</p>

<table>
<thead>
<tr>
<th>Quant</th>
<th>File size</th>
<th>Prefill tok/s</th>
<th>Decode tok/s</th>
</tr>
</thead>
<tbody>
<tr><td>F16</td><td>66.2 GB</td><td>1,303</td><td>139</td></tr>
<tr><td>Q8_0</td><td>35.2 GB</td><td>5,473</td><td>154</td></tr>
<tr><td>Q6_K</td><td>27.2 GB</td><td>4,374</td><td>150</td></tr>
<tr><td>Q5_K_M</td><td>23.6 GB</td><td>5,446</td><td>151</td></tr>
<tr><td>Q4_K_M</td><td>20.2 GB</td><td>5,555</td><td>153</td></tr>
</tbody>
</table>

<p>The decode column is the one to watch, and it refuses to move. Halving the bytes per weight from Q8 to Q4 takes generation from 154 tok/s to 153, a gap the run-to-run noise swallows whole. Even full F16 only sags to 139. For everything the ladder does to file size, decode speed sits still.</p>

<p>Strix Halo behaved nothing like this, and this is exactly the rule I opened with coming due. There, generation is bandwidth-bound, so dropping down the quant ladder genuinely speeds it up: in <a href="https://damenknight.com/mtp-speculative-decoding-strix-halo/">the MTP post</a> this same qwen3.6 ran about 50 tok/s at Q8 and 65 at Q4 with MTP off, a clean ~30% win for nothing but going smaller. The two boxes side by side:</p>

<table>
<thead>
<tr>
<th>qwen3.6 decode, MTP off</th>
<th>Q8_0</th>
<th>Q4_K_M</th>
<th>Q8 to Q4</th>
</tr>
</thead>
<tbody>
<tr><td>Strix Halo (gfx1151, Vulkan)</td><td>~50 tok/s</td><td>~65 tok/s</td><td><strong>+30%</strong></td></tr>
<tr><td>MI300X (gfx942, llama.cpp ROCm)</td><td>154 tok/s</td><td>153 tok/s</td><td><strong>~0%</strong></td></tr>
</tbody>
</table>

<p>The same change that bought 30% on the miniPC buys nothing here. The MI300X carries so much HBM3 bandwidth that decode never waits on memory, even at Q8, and trimming the model further only frees up headroom the GPU was never short on. The “smaller model generates faster” logic I leaned on for three straight posts was really a fact about starved hardware all along, and it doesn’t survive contact with a card sitting this far below its bandwidth limit.</p>

<p>Prefill tells the other half of it. F16 prefill collapses, 1,303 tok/s against roughly 5,500 for every quant, so moving off full precision is a real ~4x win there (prefill leans on compute and bandwidth in a way decode doesn’t). Once you’re actually on the ladder, though, Q8 to Q4 prefill flattens out the same way decode does. The whole jump lives between F16 and Q8; everything below Q8 is rounding error.</p>

<p>The 80B coder-next shows the identical shape, just slower in absolute terms - F16 prefill/decode of 717/106, Q4 of 3,527/121. Same story: decode flat across quants, F16 prefill collapses, file size drops 3.3x (148 to 45 GiB) for free.</p>

<h3 id="q6-slower">One weird rung: Q6_K is slower than Q8_0</h3>

<p>Look again at the qwen3.6 table: Q6_K prefills at 4,374 tok/s, behind the larger Q8_0 at 5,473, even though its file is 8 GB smaller. coder-next repeats the trick (Q6_K 2,840 against Q8_0 3,425), so it isn’t a one-off. Q8_0 unpacks almost for free, a scale factor per block and not much else, while the K-quants carry a fussier block structure that costs real compute to dequantize on the way to the matmul. When bandwidth is the bottleneck, as on the miniPC, the smaller file still wins, because the bytes you save outweigh the compute you spend getting at them. Lift the bottleneck and the bill for that extra dequant work comes due, which is how the smaller quant ends up the slower one.</p>

<h2 id="when-pays-off">So when does quantizing a big GPU ever pay off?</h2>

<p>If decode speed is flat and quality is flat, the obvious reaction is “so don’t bother quantizing on a card like this.” That’s the wrong lesson, and it’s worth going into why, because the benchmark above is rigged against quant in a way that’s easy to miss.</p>

<p>My <code>llama-bench</code> runs are <em>batch-1</em>: one request, one stream, nobody else on the GPU. That’s the latency case, and it’s exactly the regime where quant on this hardware does nothing. Here’s the math: At batch-1, generating a token means reading the active weights once, so the speed ceiling is roughly (memory bandwidth) / (bytes of active weights). For a 3B-active MoE at Q8, that’s about 3 GB of weights against the MI300X’s 5,325 GB/s of HBM3: a ceiling near 1,700 tok/s. I measured 154. We’re under a tenth of the way to the bandwidth wall, which means single-stream decode here is bound by overhead (kernel launches, MoE routing, attention), not by hauling weights. Halve the weight bytes with a heavier quant and you’ve cut a cost that wasn’t the bottleneck, so nothing moves.</p>

<p>The miniPC lives on the far side of that line. Its ~256 GB/s of LPDDR5, roughly 21x less bandwidth than the MI300X, drops the same Q8 ceiling to about 85 tok/s, and in practice it generates around 50. That machine is pinned against the wall, so shaving bytes per token nearly doubles its ceiling and you feel every byte of it.</p>

<p>So back to the point: when <em>does</em> it pay off on a big GPU? Three real cases, none of which my batch-1 latency test can see:</p>

<ol>
<li><strong>To fit on fewer GPUs, ideally one.</strong> This is about capacity, not speed, and for a big enough model it can be the biggest practical win on the list, even if neither of my two ever triggers it: qwen3.6 is 66 GB at F16 and coder-next is 148 GiB, so both fit a single 192 GB card at full precision and I never had to quantize either just to make it fit. It bites a tier up, on models too large for one card, where halving the bytes per weight is the difference between needing two cards and one. Collapsing a model onto a single GPU sidesteps tensor-parallel entirely, no cross-GPU comms on the critical path, which is usually simpler and faster per request than splitting it across several, though a future post will hopefully demonstrate that with real numbers (the multi-GPU story on this box could be its own writeup). For the models in this post it’s moot; once you’re past what a single card holds, quantizing to fit is often the highest-value move available.</li>
<li><strong>To free memory for KV cache, which is long context and high concurrency.</strong> Weights and the KV cache share the same 192 GB. A 35B model barely dents it, but a 120B-plus model serving many users at long context becomes KV-bound, not weight-bound, and every gigabyte you don’t spend on weights is a gigabyte you can spend on more concurrent sequences or more context. Single-stream latency stays flat, but <em>aggregate</em> throughput climbs because you’re fitting more work on the card at once. One sharp caveat, though, and it’s the same bandwidth lesson again: this means quantizing the <em>weights</em> to make room for an f16 KV cache, <em>not</em> quantizing the KV cache itself. I checked that separately, and on this hardware quantizing the cache is its own “smaller is slower” pitfall: a q8_0 KV cache costs you ~27% of decode at 256K context and a q4_0 cache ~59%, because attention has to dequantize the cache on every step and the bandwidth that would make that free is exactly what this card has to spare. Quantize KV to <em>fit</em> something that otherwise wouldn’t; never quantize it expecting speed.</li>
<li><strong>For throughput at scale, a native low-precision format like FP8, not a K-quant.</strong> As you batch up requests, decode stops being overhead-bound and becomes compute-bound, the matmuls get big enough to saturate the cores. On paper FP8 wins there on raw math: the MI300X does FP8 matrix multiply at 2,615 TFLOPS against 1,307 for BF16, a clean 2x. That’s a <em>compute</em> win with nothing to do with memory, and GGUF K-quants can’t touch it because they dequantize back to fp16 before the matmul. So I measured it, and there’s a real gotcha hiding in the word “FP8,” see the next section.</li>
</ol>

<h3 id="fp8-default-trap">Measured: FP8 wastes most of its advantage by default</h3>

<p>I ran a concurrency sweep instead of asserting claim #3: the same qwen3.6-35B, a fixed 512-in/128-out workload, closed-loop at 1/8/32/64/128 concurrent requests. BF16 and FP8 on vLLM, Q4_K_M on llama.cpp. The first FP8 column is the obvious invocation, <code>--quantization fp8</code>. The second adds one environment variable, <code>VLLM_ROCM_USE_AITER=1</code>, which turns on AMD’s optimized kernels. Output tokens/sec, higher is better:</p>

<table>
<thead>
<tr>
<th>Concurrency</th>
<th>BF16</th>
<th>FP8 (default)</th>
<th>FP8 + AITER</th>
<th>Q4_K_M (llama.cpp)</th>
</tr>
</thead>
<tbody>
<tr><td>1</td><td><strong>130</strong></td><td>104</td><td>121</td><td>114</td></tr>
<tr><td>8</td><td>563</td><td>515</td><td><strong>666</strong></td><td>244 (28 reqs failed)</td></tr>
<tr><td>32</td><td>1,491</td><td>1,493</td><td><strong>1,932</strong></td><td>270 (39 failed)</td></tr>
<tr><td>64</td><td>1,997</td><td>2,135</td><td><strong>2,917</strong></td><td>255 (36 failed)</td></tr>
<tr><td>128</td><td>2,621</td><td>2,792</td><td><strong>3,637</strong></td><td>277 (7 failed)</td></tr>
</tbody>
</table>

<p><strong>Default FP8 is a trap on this hardware.</strong> The obvious invocation, <code>--quantization fp8</code> and nothing else, is <em>slower</em> than just running BF16 across the low and middle of the range a single self-hosted endpoint actually runs at: 20% slower single-stream, 9% slower at eight concurrent, dead even at 32, and a measly 7% ahead even at 128. That’s a thoroughly underwhelming showing for a format with twice BF16’s theoretical FLOPS. It matches open reports of FP8 underperforming BF16 on MI300X (<a href="https://github.com/vllm-project/vllm/issues/31475">vLLM #31475</a>): the stock FP8 path on ROCm leaves the win on the floor.</p>

<p><strong>One flag recovers most of it.</strong> Flip on AMD’s AITER kernels (<code>VLLM_ROCM_USE_AITER=1</code>) and FP8 goes from “why did I bother” to 18% faster than BF16 at eight concurrent, 30% at 32, and 46% at 64. Same weights, same precision, same vLLM, one env var, up to a 46% throughput difference. If you run an FP8 endpoint on an MI300X without AITER, you are leaving roughly a quarter of your serving capacity unused and would have been faster staying on BF16 at low load. (I also tried a pre-quantized static-scale FP8 checkpoint, Qwen’s official one, in case dynamic scaling was the culprit. It made no difference. AITER was the whole story.)</p>

<p>So what is the flag actually doing? <a href="https://rocm.blogs.amd.com/software-tools-optimization/aiter-ai-tensor-engine/README.html">AITER</a> is AMD’s pile of hand-tuned inference kernels for ROCm, a mix of assembly, Composable Kernel, and Triton; the ones that matter here are its FP8 GEMM and fused-MoE kernels. Stock vLLM on ROCm runs FP8 through generic kernels that don’t squeeze the MI300X’s FP8 throughput, and AITER swaps in kernels actually tuned for this silicon, for the matmuls and, on an MoE, the expert routing. It’s a kernel change, not a precision change, so it’s free on the quality side: you’re running the identical FP8 weights, just through math that fits the hardware (the same FP8 that lands at 59.1 Polyglot, within noise of BF16).</p>

<p>The catch, before you build on it: AITER is a moving target, not a set-and-forget switch. Coverage is uneven, the tuned kernels exist for some models and ops and not others (AMD’s own launch numbers were scoped to DeepSeek), and which ones are fast shifts version to version with the ROCm/vLLM container; and the FP8 win in particular only lands on CDNA3-class cards like this one, which have the native FP8 units to begin with. The honest read is “free speed for this model, on this container, today,” which is why I measured it on qwen3.6 instead of assuming the flag behaves the same everywhere.</p>

<p><strong>But even tuned, it’s not the 2x in my usecase.</strong> AITER FP8 tops out around +46%, not the +100% the FLOPS spec implies. <em>The reason is the model</em>: a 35B-A3B MoE only fires ~3B parameters per token, so the FP8 matmul speedup applies to a sliver of the work while routing and expert-selection overhead, which FP8 does nothing for, takes a bigger share of each token. A dense model would likely see more of the 2x. So the honest ceiling for <em>this class</em> of model is “a third to a half faster, with the right kernels,” which is still well worth having.</p>

<p><strong>And llama.cpp is a single-stream tool, not a serving stack.</strong> At one request it’s competitive (114 tok/s, ahead of default FP8). The instant you add concurrency it collapses: throughput flatlines around 270 tok/s no matter the load (vLLM scales past 3,600 with AITER, more than 10x), it starts <em>dropping</em> requests, and tail latency falls apart, mean time-to-first-token at 128 concurrent was over twelve seconds. GGUF on llama.cpp is the right tool for the single-user, fits-on-my-box case this whole series is about; put it under a production serving load and you want vLLM. (The failed-request counts wobble enough that I read them as “it fell over,” not a precise capacity number, but the throughput plateau and the latency blowup are unambiguous. This makes me sad, because honestly I kind of hate using vLLM hah.)</p>

<p>So claim #3 holds, with a big asterisk: a native format <em>does</em> win the compute regime where a K-quant can’t, but only if you turn on the kernels that make it work. The default doesn’t, and the gap between the default and the tuned path is bigger than the gap between FP8 and BF16 in the first place.</p>

<p>The thing that genuinely does not help, on any of these axes, is grabbing a bigger GGUF K-quant and expecting single-stream generation to speed up. It won’t, and as the Q6_K rung showed, it can go backwards.</p>

<h2 id="what-to-pick">So what do you actually pick</h2>

<p>If you’re running one of these models on an MI300X (or anything with comparable HBM bandwidth), the takeaways are clean:</p>

<ul>
<li><strong>Quantize to Q4_K_M without quality guilt.</strong> Two models, four benchmarks, BF16 to Q4 is flat. You’re not trading smarts for size in any way I could measure.</li>
<li><strong>Quantize for capacity, not latency.</strong> On this hardware the reason to shrink a model is to fit it on fewer GPUs (ideally one, dodging tensor-parallel) or to free memory for KV cache, not to make a single stream generate faster. Q4 gets you a 3.3x smaller model and ~4x faster prefill <em>versus F16</em>, but Q8-to-Q4 will not move your decode speed, because decode was never bandwidth-bound here. If single-stream tokens/sec is all you care about, Q8 and Q4 are a wash; pick on footprint.</li>
<li><strong>Don’t assume “smaller quant = faster” transfers from consumer hardware.</strong> It’s a bandwidth-starvation effect, and this box isn’t starved. Q6_K being slower than Q8_0 here is the canary: dequant compute can cost more than the bytes you saved.</li>
<li><strong>FP8 for footprint always, for throughput only with AITER.</strong> FP8 lands within noise of BF16 on quality (59.1 vs 59.6 Polyglot) and halves the model’s memory, reason enough on its own. For <em>speed</em>, the issue is the kernels: stock <code>--quantization fp8</code> is slower than BF16 below ~32 concurrent, but <code>VLLM_ROCM_USE_AITER=1</code> flips it to 30-46% faster under load. If you serve FP8 on MI300X, turn AITER on, it’s the difference between FP8 being a downgrade and a real win. And if you’re low-concurrency, BF16 is still faster single-stream, so don’t reach for FP8 expecting latency.</li>
</ul>

<p>The throughline for the series holds and extends: quality is hardware-agnostic <em>and</em> quant-agnostic down to Q4. What’s hardware-<em>specific</em> is the speed you get for quantizing - and on a GPU that isn’t starved for bandwidth, that speed is mostly already on the table at Q8. The interesting question was never “how smart is the smaller model” (just as smart). It’s “what does the smaller model buy you on <em>this</em> box” - and the answer flips depending on whether the box was ever hungry for bandwidth in the first place.</p>

<h2 id="caveats">Caveats and fine print</h2>

<ul>
<li><strong>TP=1 throughout.</strong> Everything here ran on a single MI300X, which is all these models need (they fit one card at every precision). Multi-GPU on this box is its own saga - 2- and 4-way tensor parallel do work once you get the boot flags and RCCL shared memory sorted, and the full multi-GPU story is a post of its own. Nothing here needed more than one GPU.</li>
<li><strong>The BF16/FP8 anchors are vLLM, the ladder is llama.cpp.</strong> As noted up top, I only draw the quant-curve conclusions from the Q8 to Q4 llama.cpp runs, which are internally consistent. Treat the BF16 and FP8 points as reference, not as rungs on the same ruler.</li>
<li><strong>Polyglot noise is real.</strong> 225 exercises is enough to rank models a tier apart but not enough to resolve sub-2-point quant differences - which is exactly why I’m calling the ladder “flat” rather than reading its wiggles as signal. The lm_eval breadth (sub-1pp everywhere) is what makes me confident it’s genuinely flat and not just Polyglot being coarse.</li>
<li><strong>This is two MoE models.</strong> Both are A3B (~3B active). A dense model, or a much larger active-parameter count, could push decode back toward bandwidth-bound even on this hardware. If you’re on different architecture, measure your own decode column before trusting the “flat” claim.</li>
</ul>]]></content:encoded>
    </item>
    <item>
      <title>Network Setup (2026 edition)</title>
      <link>https://damenknight.com/network-setup-2026/</link>
      <guid isPermaLink="true">https://damenknight.com/network-setup-2026/</guid>
      <pubDate>Sun, 07 Jun 2026 23:20:00 GMT</pubDate>
      <category>Homelab</category>
      <description>Two years ago my network was a mesh router and a pfSense miniPC on one flat /8. It&#x27;s now two buildings joined by 10G fiber, carved into VLANs, with a…</description>
      <content:encoded><![CDATA[
<p>Two years ago, <a href="https://damenknight.com/network-setup/">my entire "network setup" was a Netgear Orbi mesh and a pfSense miniPC</a> on one flat <code>10.0.0.0/8</code>, and the whole writeup fit in two lines. It has, uh, escalated. What was one router and one subnet is now two buildings joined by a 10G fiber run, carved into VLANs, with a hypervisor and a NAS doing the heavy lifting and a pile of self-hosted services hanging off the back.</p>

<p>Here's where it landed.</p>

<h2>Topology</h2>

<figure>
<a href="https://damenknight.com/images/network-diagram-2026.svg" target="_blank" rel="noopener"><img src="https://damenknight.com/images/network-diagram-2026.png" alt="Network topology: a dual-WAN pfSense firewall in the house feeds a 10G core switch, which links over fiber to a Juniper core switch in the shed that fans out to a Proxmox host, a TrueNAS box, Wi-Fi APs, and iDRAC." /></a>
<figcaption>House and shed, joined by a single lit pair of fiber. Click the diagram to enlarge.</figcaption>
</figure>

<details>
<summary>Same diagram as Mermaid source</summary>
<pre><code>flowchart TD
    classDef core fill:#1d3557,stroke:#a8dadc,color:#f1faee
    classDef infra fill:#457b9d,stroke:#a8dadc,color:#f1faee
    classDef wan fill:#2a9d8f,stroke:#1d3557,color:#f1faee
    classDef leaf fill:#f4a261,stroke:#1d3557,color:#1d3557

    ISP[/"Fiber ISP"/]:::wan
    TMO[/"T-Mobile 5G<br/>(backup)"/]:::wan

    ISP -->|2.5 GbE| PF
    TMO -.->|failover| PF

    subgraph HOUSE [" HOUSE "]
        PF["pfSense<br/>router / firewall<br/>dual-WAN"]:::core
        XG8["USW Pro XG 8<br/>core switch"]:::core
        FLEX["UniFi Flex XG<br/>leaf switch"]:::leaf
        APH["U7 Pro AP<br/>Wi-Fi 7"]:::leaf

        PF -->|10G DAC| XG8
        XG8 -->|10G| FLEX
        XG8 --- APH
        FLEX --- HV["Plex · Sonos<br/>Hue · AV"]:::leaf
    end

    XG8 ==>|"10G fiber<br/>(only house↔shed link)"| JUN

    subgraph SHED [" SHED "]
        JUN["Juniper EX4550<br/>core switch"]:::core
        PROX["Proxmox<br/>Dell R740"]:::infra
        NAS["TrueNAS SCALE<br/>Dell R730xd<br/>ZFS RAID10 (striped mirrors)"]:::infra
        APS["U7 Pro AP<br/>Wi-Fi 7"]:::leaf
        IDRAC["iDRAC<br/>out-of-band mgmt"]:::infra

        JUN --- PROX
        JUN ===|2x 10G LACP| NAS
        JUN --- APS
        JUN --- IDRAC
    end</code></pre>
</details>

<p>The house↔shed backbone runs over OS2 fiber: a 12-strand pull, with one pair lit at 10GBASE-LR today and plenty of spare strands waiting to be put to work.</p>

<h2>VLANs</h2>

<p>Everything used to live on one flat <code>/8</code> (yes, all of it, on the same broadcast domain, don't @ me). It's now carved into zones, each its own <code>/16</code> in private address space:</p>

<table>
<thead>
<tr><th>VLAN</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td>10</td><td>Management (router, switches, hypervisor, NAS, iDRAC)</td></tr>
<tr><td>20</td><td>Services (public-facing VMs)</td></tr>
<tr><td>30</td><td>Personal / Lab (workstations, dev VMs)</td></tr>
<tr><td>40</td><td>IoT (home automation, cameras, speakers, plugs)</td></tr>
<tr><td>50</td><td>Guest Wi-Fi</td></tr>
</tbody>
</table>

<p>Inter-zone traffic is default-deny. IoT in particular only gets DNS and NTP and is otherwise walled off from everything. The smart plugs do not need to phone home, and they certainly don't need to see the NAS.</p>

<h2>The router is a Ship of Theseus</h2>

<p>The firewall is still, technically, the home theater PC I bought in 2015. Eleven years of transplants later, the only original parts left are the case and (maybe) the power supply. New CPU, new board, new NICs, everything that actually does the work has been swapped out. It's still the same machine the way my grandfather's hammer is still my grandfather's hammer.</p>

<p>Current internals:</p>

<ul>
<li><strong>CPU:</strong> Intel Core i5-12400 (it started life with a Core i7-4790K)</li>
<li><strong>Board:</strong> ASUS PRIME B760M-A D4</li>
<li><strong>RAM:</strong> 64 GB</li>
<li><strong>WAN:</strong> Intel I225-V 2.5GbE (fiber) + Realtek RTL8125 2.5GbE (T-Mobile 5G failover)</li>
<li><strong>LAN:</strong> Mellanox ConnectX-4 Lx, dual SFP28 (the 10G uplink to the rest of the network)</li>
<li><strong>Disk:</strong> NVMe SSD</li>
</ul>

<p>It started life playing movies in a living room. It now routes two WAN links, terminates a 10G trunk, and runs pfSense for the whole property. Same hammer.</p>

<h2>The rest of the hardware</h2>

<ul>
<li><strong>Core switching:</strong> UniFi USW Pro XG 8 PoE (house) and a Juniper EX4550 (shed), joined by 10G fiber, with a UniFi Flex XG as a downstream leaf.</li>
<li><strong>Hypervisor:</strong> Dell R740 running Proxmox.</li>
<li><strong>Storage:</strong> Dell R730xd plus an MD1400 SAS shelf running TrueNAS SCALE. ZFS RAID10 (striped mirrors), dual-10G LACP to the switch.</li>
<li><strong>Wi-Fi:</strong> UniFi U7 Pro (Wi-Fi 7), one per building, plus an outdoor AP for the observatory gear.</li>
</ul>

<p>Not shown on the diagram, because it would turn into spaghetti: the Strix Halo node, a handful of Playwright test nodes, a couple of build hosts, and the usual rotating cast of throwaway VMs.</p>

<h2>What runs on it</h2>

<p>A mix of personal and small-business services, all self-hosted: a PeerTube instance, a couple of websites (including the one you're reading), Plex, Immich, Forgejo, Home Assistant, a PBX, authoritative DNS (PowerDNS + Kea for DHCP), and HAProxy out front doing TLS termination, plus a rotating cast of dev and AI VMs.</p>

<h2>Pain</h2>

<ul>
<li><strong><code>commit confirmed</code> is not optional.</strong> A plain <code>commit</code> on the Juniper once took down the entire network. Every change that touches the trunk now gets a rollback timer.</li>
<li><strong>sshguard cannot tell Claude Code from a brute-force bot.</strong> Point an agent at your infra and it will open a fresh SSH session for nearly every command, sshguard reads the rapid reconnects as an attack, and bans the host. Congratulations, your AI ops helper has now locked both of you out of the router. Ask me how I know.</li>
<li><strong>Lack of proper out-of-band management</strong> means you REALLY want to make sure you've got a keyboard/monitor available for when you break your firewall/router.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>MTP Speculative Decoding on Strix Halo: How I Made It 3x Slower Before I Made It Faster</title>
      <link>https://damenknight.com/mtp-speculative-decoding-strix-halo/</link>
      <guid isPermaLink="true">https://damenknight.com/mtp-speculative-decoding-strix-halo/</guid>
      <pubDate>Sat, 30 May 2026 12:00:00 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <category>AI</category>
      <description>A short follow-up on the Strix Halo miniPC: turning on MTP speculative decoding with its default settings made qwen3.6 generation 3x slower. Tuned for an A3B…</description>
      <content:encoded><![CDATA[<p>In <a href="https://damenknight.com/strix-halo-vs-a30-vs-frontier/">the last post</a> I landed on qwen3.6 as the most usable coding model I could actually run on the miniPC (a Strix Halo box: AMD Ryzen AI MAX+ 395, 128 GiB of unified memory, Vulkan). This is a MUCH shorter follow-up about squeezing more tokens/sec out of it with MTP, and about the ditch I drove into on the way.</p>

<p>The three-sentence version: turned on with its default settings, MTP made generation <em>3x slower</em>! Tuned for this model, it’s about 18-26% faster. The difference between the two is a single number.</p>

<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#what-is-mtp" style="font-size: 0.9375rem; text-decoration: none;">What the heck is MTP??</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-wrong-path" style="font-size: 0.9375rem; text-decoration: none;">The wrong path</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-knob-that-matters" style="font-size: 0.9375rem; text-decoration: none;">The knob that matters: --spec-draft-n-max</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-config-id-use" style="font-size: 0.9375rem; text-decoration: none;">The config I'd actually use</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-iq4-xs-build" style="font-size: 0.9375rem; text-decoration: none;">What about the faster IQ4_XS build?</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#strix-halo-vulkan-result" style="font-size: 0.9375rem; text-decoration: none;">This is a Strix Halo / Vulkan result</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-takeaway" style="font-size: 0.9375rem; text-decoration: none;">The takeaway</a></li>
</ul>
</div>

<h2 id="what-is-mtp">What the heck is MTP??</h2>

<p>Normally a model generates one token per forward pass: run the whole network, get one token, repeat. That’s slow. Speculative decoding speeds it up by first having a small, fast predictor - something far cheaper to run than the full model - guess the next several tokens, and then letting the full model verify that whole batch of guesses in a <em>single</em> forward pass. The trick is that checking several tokens at once costs the big model about the same as generating one. Every guess it accepts is a token you got essentially for free. The catch, of course, is that every <em>rejected</em> guess is wasted compute, both the draft’s and the verify’s. The math only pays off if the guesses are accepted often.</p>

<p>MTP (Multi-Token Prediction) is the self-speculative version: instead of running a second small “draft” model alongside the big one, the model ships with an extra lightweight head trained to predict a few tokens ahead. The draft and the verify come from the same model. llama.cpp added support in <a href="https://github.com/ggml-org/llama.cpp/pull/22673">PR #22673</a> (merged 2026-05-16, build b9180 or later), exposed as <code>--spec-type draft-mtp</code>.</p>

<p>Qwen3.6 here is a 35B-A3B Mixture-of-Experts model: 35B total parameters, but only ~3B are active per token. That “A3B” part turns out to matter a lot for whether MTP helps.</p>

<h2 id="the-wrong-path">The wrong path</h2>

<p>The first thing I did, which was the first thing and not the smart thing, was the naive run: flip MTP on, leave everything at defaults, and see what stock settings buy you. It’s what most people will reach for, and the articles all quote ~2x, so why not? Generation dropped to 18.6 tok/s, down from ~60 with MTP off. So, the opposite of 2x. Definitely didn’t seem right lol.</p>

<p>The culprit was <code>--spec-draft-n-max</code>, the number of tokens the head is allowed to draft ahead before the model checks its work. It defaults to <em>16</em>. Here’s what it gets you at a range of values (tok/s, higher is better):</p>

<table>
<thead>
<tr>
<th>MTP config (qwen3.6-35B-A3B, Unsloth Q4_K_M - early run)</th>
<th>tok/s</th>
</tr>
</thead>
<tbody>
<tr>
<td>off (baseline)</td>
<td>59.7</td>
</tr>
<tr>
<td>on, <code>--spec-draft-n-max 16</code> (the default)</td>
<td>18.6</td>
</tr>
<tr>
<td>on, <code>--spec-draft-n-max 8</code></td>
<td>27.6</td>
</tr>
<tr>
<td>on, <code>--spec-draft-n-max 4</code></td>
<td>66.9</td>
</tr>
</tbody>
</table>

<p>A caveat on these numbers: they were quick and exploratory. I killed off any stray model servers but skipped the full services-down, fans-pinned protocol I used for the recommended config below, so read the absolute baseline loosely. (It also idles at ~60 here rather than the ~65 you’ll see later because this early run used a slightly slower quant upload. Why two “Q4_K_M” files run at different speeds is its own rabbit hole, maybe a future companion post.) A 3x regression dwarfs either effect, which is the whole point.</p>

<p>So there we have it: for me, the default was a roughly 3x regression, and the line between useless and useful is narrow. n=8 is still slower than no MTP at all, and only by n=4 does it pull ahead. I almost wrote the whole thing up as “MTP doesn’t work on Strix Halo MoE” right there. Then I did the thing I should’ve led with if I’d actually wanted good numbers: went and read how MTP works.</p>

<h2 id="the-knob-that-matters">The knob that matters: --spec-draft-n-max</h2>

<p>The default of 16 is calibrated for dense, instruction-tuned models, which accept long draft runs. An A3B MoE is the opposite: only ~3B parameters fire per token, the head’s predictions get rejected sooner, and every rejected draft past the acceptance point is pure waste. The community guidance for A3B-class MoEs converges on <em>n=2 or 3</em>. Even llama.cpp’s own MTP pull request reports its best results around 3 draft tokens at roughly 75% steady-state acceptance, nowhere near the default 16, and acceptance only falls faster as you push past that.</p>

<p>The other thing worth saying plainly: the reported 2x speedup you may have seen for “Qwen3.6 + MTP” comes from other setups (the PR author quotes >2x, testing on a different stack than a power-capped Strix Halo). On this box, for the 35B-A3B MoE on Vulkan, the realistic ceiling I measured is more like 1.2x. Lower acceptance means less free speed. That’s not a failure, it’s just the honest number for this class of model on this hardware, and it’s still worth having.</p>

<p>There’s also a second knob, <code>--spec-draft-p-min</code> (the minimum probability the head needs before a draft is even attempted, default 0.75). Some guides call it the most impactful parameter. On my hardware, sweeping it from 0.5 to 0.9 stayed within sampling noise, so I left it at the default. Your mileage may vary; it’s worth a quick sweep, but n_max is the one that actually moved my numbers.</p>

<h2 id="the-config-id-use">The config I'd actually use</h2>

<p>Two setups: pick by whether you care more about disk or about the last drop of quality. Both are gfx1151 on Vulkan (see the caveat at the end if you’re on something else), same clean-bench methodology as the last post.</p>

<p><strong>The build.</strong> You need llama.cpp with <a href="https://github.com/ggml-org/llama.cpp/pull/22673">PR #22673</a>, so b9180 or later. Check with <code>llama-server --help</code> and look for <code>draft-mtp</code> in the <code>--spec-type</code> modes. Build it Vulkan-only: there’s a known foot-gun (<a href="https://github.com/ggml-org/llama.cpp/issues/23199">issue #23199</a>) where, in a dual Vulkan+ROCm build, the MTP tensors get placed on the ROCm device and MTP is silently disabled even when you asked for Vulkan. I didn’t run into this thankfully (my build is Vulkan-only), but watch out.</p>

<p><strong>Q4_K_M (smaller, faster, what I’d default to).</strong> Two files from <a href="https://huggingface.co/bartowski/Qwen_Qwen3.6-35B-A3B-GGUF">bartowski/Qwen_Qwen3.6-35B-A3B-GGUF</a>: the model (<code>Qwen_Qwen3.6-35B-A3B-Q4_K_M.gguf</code>, ~20 GiB) and the MTP head as a separate file (<code>mtp-Qwen_Qwen3.6-35B-A3B-Q4_0.gguf</code>, ~1 GiB).</p>

<pre><code>llama-server \
  --model Qwen_Qwen3.6-35B-A3B-Q4_K_M.gguf \
  --model-draft mtp-Qwen_Qwen3.6-35B-A3B-Q4_0.gguf \
  --spec-type draft-mtp \
  --spec-draft-n-max 3 \
  -ngl 999 -c 8192 -fa on --parallel 1 \
  -t 32 -tb 32 -ub 2048 \
  -ctk q8_0 -ctv q8_0 \
  --reasoning off \
  --host 0.0.0.0 --port 8089 --alias qwen3.6</code></pre>

<p>That gets ~77 tok/s, versus ~65 with the <code>--model-draft</code> and <code>--spec-type</code> lines removed. About +18%.</p>

<p><strong>Q8_0 (more disk, a hair more quality).</strong> Same flags, the Q8_0 trunk and head (<code>Qwen_Qwen3.6-35B-A3B-Q8_0.gguf</code> ~35 GiB, <code>mtp-Qwen_Qwen3.6-35B-A3B-Q8_0.gguf</code> ~2 GiB), and one important change: <code>--spec-draft-n-max 2</code> instead of 3. Expected: ~63 tok/s, versus ~50 with MTP off. About +26%.</p>

<p>The sweet spot drops to 2 at Q8 because the trunk’s own predictions are more confident, so it accepts fewer drafts from the head, and drafting a third token just wastes work. Sweep n_max (1, 2, 3, 4) on whatever model and quant combo you land on and use whichever wins.</p>

<p><strong>The head quant matters less than you’d think, and lighter is better.</strong> A Q4_0 head on the Q4_K_M trunk hit 77 tok/s; a Q8_0 head on the same trunk did 74. The bigger head’s better predictions don’t pay for the extra bandwidth they cost.</p>

<p><strong>Configs that lose, for completeness:</strong> the default <code>--spec-draft-n-max 16</code> (~19 tok/s, a 3x regression) and <code>--spec-draft-n-max 8</code> (~28 tok/s, still slower than no MTP). The default is the trap.</p>

<p>If you’d rather not juggle two files, <a href="https://huggingface.co/unsloth/Qwen3.6-35B-A3B-MTP-GGUF">unsloth/Qwen3.6-35B-A3B-MTP-GGUF</a> bundles the trunk and head into one (drop <code>--model-draft</code>, keep <code>--spec-type draft-mtp</code>). I measured ~75 tok/s at n=3, about 2 tok/s behind the bartowski split, with identical quality on Polyglot, gsm8k, and ifeval. Use whichever workflow you prefer.</p>

<h2 id="the-iq4-xs-build">What about the faster IQ4_XS build?</h2>

<p>There’s an <a href="https://huggingface.co/localweights/Qwen3.6-35B-A3B-MTP-IQ4_XS-GGUF">IQ4_XS build with the MTP head baked in</a>, and a pretty wild-sounding claim for IQ4_XS+MTP on Strix Halo was making the rounds: 90.8 tok/s average, 110.6 peak. IQ4_XS is a smaller quant than Q4_K_M (about 4.25 bits per weight versus ~4.8), and since generation is bandwidth-bound, smaller CAN mean faster, so it’s a plausible claim. I downloaded it and benched it the same way as everything above, but I was not able to reproduce it.</p>

<table>
<thead>
<tr>
<th>IQ4_XS + MTP (n=2), my box</th>
<th>tok/s</th>
</tr>
</thead>
<tbody>
<tr>
<td>q8_0 KV cache</td>
<td>~78</td>
</tr>
<tr>
<td>f16 KV cache</td>
<td>~81</td>
</tr>
<tr>
<td>the number I was chasing</td>
<td>90.8 avg / 110.6 peak</td>
</tr>
</tbody>
</table>

<p>Apples-to-apples, at the q8_0 KV cache my recommended config uses, IQ4_XS lands around 78 tok/s, a hair over the Q4_K_M + MTP setup above (~77) but inside the noise. Switching IQ4_XS to an f16 KV cache pushes it to ~81. That is a real few percent, but it is the cache talking, not the quant: f16 would lift the Q4_K_M numbers the same way. So IQ4_XS earns you a little (a smaller file), and a little more if you spend the extra memory on an f16 cache, but it is not the different league the 90+ figure implies. A raw <code>llama-bench</code> pass on the file came in at 73, so that is not where the headline comes from either.</p>

<p>My best guess for the gap to 90.8: I cap this box at 100W with <code>ryzenadj</code> for round-the-clock thermal stability (the “I melted it twice” saga from the last post). Run the chip hotter, or on a newer build, and you would probably claw some of it back. Worth a shot if you have the thermal headroom. But on a power-limited Strix Halo it is a modest step over the Q4_K_M + MTP config, not a leap, and nothing I measured got close to 90. There are some thermal upgrades I may attempt to make, and maybe I’ll revisit this with a higher cap if I do.</p>

<h2 id="strix-halo-vulkan-result">This is a Strix Halo / Vulkan result</h2>

<p>The numbers above are gfx1151 on Vulkan. On CUDA, an early community writeup for this same model found no net speedup from llama.cpp’s speculative-decoding paths: it tested <a href="https://github.com/thc1006/qwen3.6-speculative-decoding-rtx3090">19 configurations on an RTX 3090</a> and found none faster than baseline (the same author’s <a href="https://hackmd.io/ODXuOQNzSiyUITz7g9mtBw">HackMD notes</a> lay out the detail). Note that’s a llama.cpp-specific result, the same author found vLLM’s MTP faster on the same card. So the speedup here may be specific to the Vulkan path or to the unified-memory architecture. If you’re on a 3090 or an A100, don’t expect these numbers (<a href="https://damenknight.com/mtp-mistakes/">Damen's Update: This is TERRIBLE ADVICE!!!</a>), and if you measure your own, publish them: there’s a bit of public A3B-MoE spec-decode data now (mostly CUDA, plus a Strix Halo ROCm run or two), but I couldn’t find a single A3B-on-gfx1151-via-Vulkan benchmark out there, so that corner is wide open.</p>

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

<p>MTP on this hardware is a real but modest gain: ~18-26%, not the 2x you’ll see quoted for the dense model. And the default config is actively harmful on an A3B MoE. If you take one thing from this: turn <code>--spec-draft-n-max</code> down to 2 or 3 before you decide whether MTP works for you.</p>]]></content:encoded>
    </item>
    <item>
      <title>Strix Halo vs an A30 vs the Frontier: What the miniPC Can (and Can&#x27;t) Actually Do</title>
      <link>https://damenknight.com/strix-halo-vs-a30-vs-frontier/</link>
      <guid isPermaLink="true">https://damenknight.com/strix-halo-vs-a30-vs-frontier/</guid>
      <pubDate>Sat, 23 May 2026 12:00:00 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <category>AI</category>
      <description>I benchmarked my Strix Halo miniPC against an A30 GPU and frontier API models. The short version: locally-hosted qwen3.6-thinking scores 62.2% on the Aider…</description>
      <content:encoded><![CDATA[<p>A few months ago <a href="https://damenknight.com/running-frontier-coding-model-mini-pc/">I wrote about getting a Strix Halo miniPC (~$2.5K all-in) to run a frontier coding model</a>. That led to people asking me how I was measuring/testing models (I really wasn't, beyond what my anecdotal experience was).  That led to me starting to actually benchmark stuff - but then I thought... why not throw my A30 GPU in the mix while I'm at it, and really see what's what?  They even cost me (roughly) the same.</p>

<p>The short version: I didn't lie! It is roughly like Sonnet when it's a bit slow.  BUT, things have changed and now you should use qwen3.6.  Also there are some other neat findings ahead ;)</p>

<p>The longer version: quality is hardware-agnostic, which is exactly what you'd expect (run the same model on a Strix Halo APU or a datacenter A30 and you get the same benchmark score within noise, because the hardware should decide how fast, not how smart). The interesting questions were always about speed and fit, and that is where the miniPC shines!</p>

<p>Locally-hosted <code>qwen3.6-thinking</code> (a 35B-A3B Mixture-of-Experts (MoE) model, that is, 35B total parameters with only ~3B activated per token) scores 62.2% on the <a href="https://aider.chat/2024/12/21/polyglot.html">Aider Polyglot benchmark</a>, sitting right between Claude Sonnet 4 thinking (61.3%) and Claude 3.7 Sonnet thinking (64.9%), and it does that at 45 tok/s with a 65K-token context. That is Sonnet-class capability, at usable speeds, on a ~$2.5K box. It is not Opus-class (72.0%) or GPT-5-class (88.0%), but Sonnet-class coding on hardware I own, at no per-token cost and with nothing leaving my network.</p>

<p>The surprises were both about memory, not compute. First: even when a model fits comfortably in the A30's VRAM, the A30 can still lose at long context. The A30 wins at short context, but on qwen3-30b-a3b at 65K depth the miniPC outruns it by 36% (28.1 vs 20.6 tok/s), because the A30's 24 GiB runs out of room for the KV cache (the attention key/value store that grows with every token of context) while the miniPC's 128 GiB of unified memory shrugs it off (the crossover lands somewhere between 8K and 32K). qwen3-30b-a3b is only 17 GiB, so this isn't even an offload problem, it's all running on the GPU, the A30 just can't hold a big model and a big KV cache at once.</p>

<p>The bigger surprise was the hybrid cliff. For any model that doesn't fit in 24 GiB at all, the miniPC wins outright. qwen3-coder-next (80B-A3B) has to be split across GPU and CPU on the A30, and once half its layers (plus their KV) live in slow DDR4 system memory and get streamed over PCIe every token, it crawls: 3.9 tok/s at 65K against Strix Halo's 38, almost 10x. That gap, a unified-memory box versus a GPU forced into hybrid offload, is the whole reason I bought the thing.</p>

<p>The recommendation has also moved since the last post. Back then, <code>qwen3-coder-next</code> was the best local-fitting coding model I had, and I called its quality "something like Sonnet 4.5 on a slow day." That was ... close enough that I won't say I was wrong, hah (it lands about 9pp below baseline Sonnet 4), but qwen3.6 has since released, and on the same Strix Halo hardware it is straightforwardly better: higher quality, faster, and a smaller VRAM footprint. If you have been running qwen3-coder-next since my last post, switch to qwen3.6.</p>

<div class="toc" style="background-color: var(--color-bg-raised); border: 1px solid var(--color-border); border-left: 3px solid var(--color-accent); border-radius: 10px; padding: 1.5rem 2rem; margin-bottom: 2.5rem;">
<p style="font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-accent); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 0.75rem;">Contents</p>
<ul style="list-style: none; padding: 0; margin: 0;">
<li style="margin-bottom: 0.35rem; "><a href="#the-contestants" style="font-size: 0.9375rem; text-decoration: none;">The contestants</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-benchmarks" style="font-size: 0.9375rem; text-decoration: none;">The benchmarks</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#quality-strix-halo-vs-a30" style="font-size: 0.9375rem; text-decoration: none;">Quality: Strix Halo vs A30</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#quality-local-vs-the-frontier" style="font-size: 0.9375rem; text-decoration: none;">Quality: Local vs the Frontier</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#coding-specific-aider-polyglot" style="font-size: 0.875rem; text-decoration: none;">Coding-specific: Aider Polyglot</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#saturated-benchmarks-humaneval-and-lmeval" style="font-size: 0.875rem; text-decoration: none;">Saturated benchmarks: HumanEval+ and lm_eval</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#quality-at-depth-niah" style="font-size: 0.875rem; text-decoration: none;">Quality at depth: NIAH</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#speed-default-throughput" style="font-size: 0.9375rem; text-decoration: none;">Speed: default throughput</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#speed-at-long-context" style="font-size: 0.9375rem; text-decoration: none;">Speed at long context</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#gpt-oss-20b-fits-both" style="font-size: 0.875rem; text-decoration: none;">gpt-oss-20b (fits both)</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#qwen3-30b-a3b-2507-fits-both" style="font-size: 0.875rem; text-decoration: none;">qwen3-30b-a3b-2507 (fits both)</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#qwen36-thinking-strix-halo-only-sonnet-tier-model" style="font-size: 0.875rem; text-decoration: none;">qwen3.6-thinking (Strix Halo only, Sonnet-tier model)</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#qwen3-coder-next-80b-a3b-strix-halo-uma-vs-a30-hybrid-offload" style="font-size: 0.875rem; text-decoration: none;">qwen3-coder-next 80B-A3B (Strix Halo UMA vs A30 hybrid offload)</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#deepseek-coder-v2-lite-the-bonus-weird-result" style="font-size: 0.875rem; text-decoration: none;">DeepSeek-Coder-V2-Lite, the bonus weird result</a></li>
<li style="margin-bottom: 0.35rem; padding-left: 1.25rem;"><a href="#bonus-models-strix-halo-coverage-only" style="font-size: 0.875rem; text-decoration: none;">Bonus models (Strix Halo coverage only)</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#what-each-box-is-actually-best-for" style="font-size: 0.9375rem; text-decoration: none;">What each box is actually best for</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#what-id-do-differently" style="font-size: 0.9375rem; text-decoration: none;">What I'd do differently</a></li>
<li style="margin-bottom: 0.35rem; "><a href="#the-end-result" style="font-size: 0.9375rem; text-decoration: none;">The end result</a></li>
</ul>
</div>



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

<table>
<thead>
<tr>
<th>Spec</th>
<th>Strix Halo (the miniPC)</th>
<th>A30</th>
</tr>
</thead>
<tbody>
<tr>
<td>GPU/APU</td>
<td>AMD Ryzen AI MAX+ 395 (gfx1151)</td>
<td>NVIDIA A30 PCIe</td>
</tr>
<tr>
<td>Architecture</td>
<td>RDNA3.5-class iGPU, unified memory (UMA)</td>
<td>Ampere GA100, dedicated PCIe</td>
</tr>
<tr>
<td>Memory</td>
<td>128 GiB LPDDR5 (shared CPU+GPU)</td>
<td>24 GiB HBM2</td>
</tr>
<tr>
<td>Backend</td>
<td>llama.cpp Vulkan (RADV)</td>
<td>llama.cpp CUDA</td>
</tr>
<tr>
<td>TDP (effective)</td>
<td>100W (after <code>ryzenadj</code>)</td>
<td>165W</td>
</tr>
<tr>
<td>Host system</td>
<td>GMKtec NucBox EVO-X2</td>
<td>R740 / Proxmox VM (16c/64G)</td>
</tr>
<tr>
<td>Approx all-in cost</td>
<td>~$2,500</td>
<td>~$2-3K used (card only; bring your own server)</td>
</tr>
<tr>
<td>Largest model that fits</td>
<td>gpt-oss-120B (~64 GiB), qwen3-coder-next 80B-A3B (~45 GiB)</td>
<td>qwen2.5-coder-32B (Q4_K_M) at most</td>
</tr>
</tbody>
</table>

<p>The "what fits" row is the most important and most under-discussed difference, with one clarification: it means <em>fits entirely in VRAM, at full speed</em>. You can push a bigger model onto the A30 with hybrid GPU/CPU offload (I do exactly that later in the post), it just runs much slower once part of it spills out of the 24 GiB. The miniPC's unified memory has no such cliff: anything up to 128 GiB loads and runs on the iGPU at full speed, including a 120B model the A30 can't hold in VRAM at all.</p>

<p>It's worth noting what's doing the spilling on the A30 side: its host is an older Dell R740 on DDR4, so when a model overflows into system RAM, DDR4 bandwidth is the bottleneck. A newer DDR5 host with more memory channels would lift the hybrid numbers, but it would also cost more, which is sort of the point: the unified-memory miniPC sidesteps the spill entirely, for the price of a single mid-range box.</p>

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

<p>Four things, each measuring something different:</p>

<ul>
<li><strong>Aider Polyglot</strong>, 225 multi-language <a href="https://exercism.org">Exercism</a> coding exercises, the model is asked to edit existing files to make tests pass. This is the only benchmark on the list that resembles real-world agentic coding work, and it's the one frontier models actually struggle with. Not saturated.</li>
<li><strong><a href="https://github.com/evalplus/evalplus">HumanEval+</a></strong>, function-level code generation, 164 problems. Top models all score 90%+. Saturated.</li>
<li><strong><a href="https://github.com/EleutherAI/lm-evaluation-harness">lm_eval</a></strong> (<a href="https://github.com/openai/grade-school-math">gsm8k</a>, <a href="https://github.com/google-research/google-research/tree/master/instruction_following_eval">ifeval</a>, <a href="https://github.com/TIGER-AI-Lab/MMLU-Pro">mmlu_pro</a>), knowledge and instruction-following at single-prompt level. Frontier models saturate this too.</li>
<li><strong><a href="https://github.com/ggml-org/llama.cpp/tree/master/tools/llama-bench">llama-bench</a></strong>, pure throughput, no quality signal. Two numbers matter, both in tokens/sec: <strong>pp</strong> (prompt processing, how fast the model ingests your prompt) and <strong>tg</strong> (token generation, how fast it writes the reply). I report the defaults <code>pp512 / tg128</code> (a 512-token prompt, 128 generated tokens) plus depth tests for long-context behavior.</li>
</ul>

<p>I treat Polyglot as the load-bearing quality metric because (a) it actually discriminates, and (b) it's what I care about, agentic coding is what these boxes get used for in practice.  If there's a benchmark I didn't run, it is because I don't know about it or didn't think of it.</p>

<p>One reading convention for every table below: higher is better, unless I explicitly say otherwise.</p>

<h2 id="quality-strix-halo-vs-a30">Quality: Strix Halo vs A30</h2>

<p>Nobody expects a model to get smarter or dumber depending on whether it runs on AMD or NVIDIA silicon, and it doesn't. This was never really an open question. But I had both boxes and was running the benchmarks anyway, so I figured I'd confirm it and see where any real differences showed up. The answer: quality is the same within noise, with one model-specific surprise. Boring but necessary setup for everything that follows.</p>

<table>
<thead>
<tr>
<th>Model</th>
<th>Strix Halo (Vulkan)</th>
<th>A30 (CUDA)</th>
</tr>
</thead>
<tbody>
<tr>
<td>gemma-3-27b-it (HumanEval+ p@1+)</td>
<td>78.7%</td>
<td>77.4%</td>
</tr>
<tr>
<td>qwen2.5-coder-32b (HumanEval+ p@1+)</td>
<td>85.4%</td>
<td>86.6%</td>
</tr>
<tr>
<td>qwen3-30b-a3b-2507 (HumanEval+ p@1+)</td>
<td>89.0%</td>
<td>89.6%</td>
</tr>
<tr>
<td>qwen2.5-coder-32b (Polyglot)</td>
<td>25/225 (11.1%)</td>
<td>25/225 (11.1%)</td>
</tr>
<tr>
<td>qwen3.6 (Polyglot, no think)</td>
<td>121/225 (53.8%)</td>
<td>106/225 (47.1%)</td>
</tr>
</tbody>
</table>

<p>("p@1+" is pass@1 on EvalPlus's extended test set, meaning the model's first answer has to pass every test.)</p>

<p>Within noise on HumanEval+ across the board, and on the qwen2.5-coder Polyglot row. The qwen3.6 Polyglot row shows a 6.7pp cross-host gap (53.8% Strix Halo vs 47.1% A30), which is larger than I'd expect from pure sampling noise; possibly a real CUDA-vs-Vulkan difference for that specific model and harness, or a build-version skew between the two boxes. The HumanEval+ gemma/qwen2.5/qwen3-30b rows on the same model files agree exactly cross-host, so it isn't a general "the A30 produces worse logits" pattern; it's a qwen3.6-Polyglot-specific finding I'd want to dig into in a future bench.</p>

<p>So the model is mostly the model, and hardware doesn't make it dumber in any general sense. There can be model-specific cross-host quirks worth checking (this one came as a surprise to me), but for the typical case, once you've picked a model that fits, the hardware question reduces to <em>how fast</em> and <em>can it even fit</em>.</p>

<h2 id="quality-local-vs-the-frontier">Quality: Local vs the Frontier</h2>

<p>Here's where it gets fun. I'm going to split this into coding and non-coding because they behave very differently.</p>

<h3 id="coding-specific-aider-polyglot">Coding-specific: Aider Polyglot</h3>

<p>Polyglot is the benchmark where frontier models still have headroom, and the one that tracks "how good is this thing as a coding agent." Here's the comparison (Aider leaderboard scores for the API models, my results for local):</p>

<table>
<thead>
<tr>
<th>Model</th>
<th>Polyglot pass rate</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>GPT-5 (high)</strong></td>
<td><strong>88.0%</strong></td>
<td>API</td>
</tr>
<tr>
<td><strong>Gemini-2.5-Pro (32k think)</strong></td>
<td><strong>83.1%</strong></td>
<td>API</td>
</tr>
<tr>
<td>DeepSeek-V3.2 Reasoner</td>
<td>74.2%</td>
<td>API (open weight ~700B, won't fit my hardware)</td>
</tr>
<tr>
<td><strong>Claude Opus 4 (32k think)</strong></td>
<td><strong>72.0%</strong></td>
<td>API</td>
</tr>
<tr>
<td>Claude Opus 4 (no think)</td>
<td>70.7%</td>
<td>API</td>
</tr>
<tr>
<td>Claude 3.7 Sonnet (32k think)</td>
<td>64.9%</td>
<td>API</td>
</tr>
<tr>
<td><strong>qwen3.6-thinking (Strix Halo)</strong></td>
<td><strong>62.2%</strong></td>
<td><strong>local, 35B-A3B MoE</strong></td>
</tr>
<tr>
<td>Claude Sonnet 4 (32k think)</td>
<td>61.3%</td>
<td>API</td>
</tr>
<tr>
<td>Claude 3.7 Sonnet (no think)</td>
<td>60.4%</td>
<td>API</td>
</tr>
<tr>
<td>Claude Sonnet 4 (no think)</td>
<td>56.4%</td>
<td>API</td>
</tr>
<tr>
<td>qwen3.6 (Strix Halo, no think)</td>
<td>53.8%</td>
<td>local</td>
</tr>
<tr>
<td>qwen3-coder-next (Strix Halo)</td>
<td>47.6%</td>
<td>local, 80B-A3B MoE; doesn't fit on A30, see the Speed sections</td>
</tr>
<tr>
<td>qwen3.6 (A30, no think)</td>
<td>47.1%</td>
<td>local</td>
</tr>
<tr>
<td>GPT-OSS-120B (high)</td>
<td>41.8%</td>
<td>leaderboard score, API</td>
</tr>
<tr>
<td>qwen3-30b-a3b-2507 (Strix Halo)</td>
<td>30.2%</td>
<td>local</td>
</tr>
<tr>
<td>qwen3-30b-a3b-2507 (A30)</td>
<td>28.9%</td>
<td>local</td>
</tr>
<tr>
<td>GPT-OSS-20B-thinking (A30)</td>
<td>16.9%</td>
<td>local</td>
</tr>
<tr>
<td>GPT-OSS-120B (Strix Halo, Q4_K_M)</td>
<td>1.8%*</td>
<td>local, almost certainly broken locally, see footnote</td>
</tr>
</tbody>
</table>

<p><em>* The 23× gap between local gpt-oss-120B (1.8%) and the same model's API leaderboard score (41.8%) is almost certainly the <code>reasoning_effort</code> parameter not wiring through to llama.cpp's gpt-oss path: low/medium/high produce near-identical outputs within sampling noise. For a model whose top-line capability <em>is</em> its reasoning depth, a broken reasoning knob is a broken model. Full discussion in item 3 below.</em></p>

<p>(All API model scores in this table come from the <a href="https://aider.chat/docs/leaderboards/">Aider Polyglot leaderboard</a>, last updated 2025-11-20. A few newer frontier releases (Google's Gemini 3 and Anthropic's Claude Opus 4.5 / 4.7) exist but haven't been scored by the Aider team yet, so they aren't represented above. The most recent Gemini and Opus variants the leaderboard does have are Gemini 2.5 Pro 32k-think at 83.1% and Claude Opus 4 32k-think at 72.0%.)</p>

<p><strong>What this shows:</strong></p>

<ol>
<li>
<p><strong>Sonnet-class is achievable locally, in both thinking and no-think modes.</strong> My best local model (<code>qwen3.6-thinking</code>, a 35B-A3B MoE) sits right in the Claude Sonnet thinking band (62.2% vs Sonnet 4 thinking 61.3%). And on the apples-to-apples no-think comparison, qwen3.6 with thinking off (53.8%) is just 2.6pp under Claude Sonnet 4 no-think (56.4%); effectively tied within Polyglot's noise floor. So it's not just "Sonnet-class when allowed to think"; it's "Sonnet-class without needing to think." That second result was the bigger surprise.</p>
</li>
<li>
<p><strong>The recommendation has moved since my last post.</strong> Back then, <code>qwen3-coder-next</code> (80B-A3B) was the best local-fitting coding model I had, and the explicit subject of <a href="https://damenknight.com/running-frontier-coding-model-mini-pc/">the previous post</a>. <code>qwen3.6</code> didn't exist yet. Now it does, and it's straightforwardly better: 53.8% Polyglot at thinking-off (vs qwen3-coder-next's 47.6%), 62.2% at thinking-on, smaller VRAM footprint, faster throughput. If you've been running qwen3-coder-next on Strix Halo since my last post: try qwen3.6.</p>
</li>
<li>
<p><strong>The real gap is to GPT-5, Gemini 2.5 Pro, and Claude Opus.</strong> Those three are ~10-26pp ahead of my best local model. The Anthropic ladder is worth calling out specifically: qwen3.6-thinking (62.2%) is essentially tied with Sonnet 4 thinking (61.3%), but Anthropic's actual flagship is Opus, which scores 72.0%, about 10pp ahead of local. Then GPT-5 (88.0%) and Gemini 2.5 Pro thinking (83.1%) are the real top of the leaderboard. DeepSeek V3.2 Reasoner (74.2%) is the closest open-weight to that band, but at ~700B parameters it won't fit on either of my boxes.</p>
</li>
<li>
<p><strong>Local quants underperform their API counterparts catastrophically on some models.</strong> My local <code>gpt-oss-120B</code> Q4_K_M scored 1.8%; the leaderboard's <code>gpt-oss-120b (high)</code> scored 41.8%. That's a 23x gap, not a small one. Three things contribute: quantization, the <code>reasoning_effort</code> parameter <a href="#footnote-effort">doesn't actually wire through to the model on llama.cpp</a> (I verified this; low/medium/high produce near-identical outputs within sampling noise), and I used Aider's <code>whole</code> edit format vs the leaderboard's <code>diff</code>. The reasoning-effort issue is probably the biggest factor; gpt-oss is essentially a reasoning model, and if the reasoning depth knob is broken, the model is operating in something close to a "low effort" mode regardless of what you pass in.</p>
</li>
<li>
<p><strong>Thinking mode is meaningful when measured correctly.</strong> <code>qwen3.6</code> without thinking: 53.8%. With thinking: 62.2%. That's 8.4 pp of capability sitting behind a flag.</p>
</li>
</ol>

<h4>A caveat about polyglot versioning</h4>

<p>My test harness ran 225 exercises on most models, the same set as Aider's leaderboard. A few runs got 289 or 450 (multiple attempts per exercise from a config tweak); rates are still computed as <code>passed/total</code>. Edit-format matters too: I used <code>whole</code> because it's more robust to weaker models, while Aider's leaderboard uses <code>diff</code> because it gets better scores from the top models. <code>whole</code> is generally a slight handicap. Treat the comparisons as directional, not exact.</p>

<p>Methodology note: all HumanEval+ numbers in this post come from <code>evalplus.codegen</code>, the canonical scorer behind EvalPlus's published leaderboard.</p>

<h3 id="saturated-benchmarks-humaneval-and-lmeval">Saturated benchmarks: HumanEval+ and lm_eval</h3>

<p>These are the benches where frontier and local models all score in the same 85-95% range, they don't discriminate well anymore. Quick look:</p>

<p>Cross-host gsm8k + ifeval, identical Q4_K_M quantization, identical chat-completions API, 200 items each:</p>

<table>
<thead>
<tr>
<th>Model</th>
<th>Strix Halo gsm8k</th>
<th>A30 gsm8k</th>
<th>Strix Halo ifeval</th>
<th>A30 ifeval</th>
</tr>
</thead>
<tbody>
<tr>
<td>gemma-3-4b-it</td>
<td>78.5%</td>
<td>82.5%</td>
<td>69.5%</td>
<td>68.0%</td>
</tr>
<tr>
<td>qwen3-4b-2507</td>
<td>90.0%</td>
<td>90.0%</td>
<td>79.5%</td>
<td>80.5%</td>
</tr>
<tr>
<td>gemma-3-12b-it</td>
<td>92.5%</td>
<td>91.0%</td>
<td>72.0%</td>
<td>72.5%</td>
</tr>
<tr>
<td>gemma-3-27b-it</td>
<td>93.5%</td>
<td>93.5%</td>
<td>77.5%</td>
<td>76.0%</td>
</tr>
<tr>
<td>qwen3-30b-a3b-2507</td>
<td>95.5%</td>
<td>93.5%</td>
<td>79.5%</td>
<td>80.5%</td>
</tr>
<tr>
<td>qwen2.5-coder-32b</td>
<td>95.0%</td>
<td>95.0%</td>
<td>75.0%</td>
<td>75.0%</td>
</tr>
<tr>
<td>phi-4 (14B)</td>
<td>90.5%</td>
<td>91.0%</td>
<td>56.5%</td>
<td>57.0%</td>
</tr>
<tr>
<td>mistral-small-3.2-24b</td>
<td>95.0%</td>
<td>94.5%</td>
<td>76.5%</td>
<td>72.0%</td>
</tr>
<tr>
<td>qwen3.6-thinking</td>
<td><strong>96.5%</strong></td>
<td>n/a (model not on A30)</td>
<td>79.0%</td>
<td>n/a</td>
</tr>
<tr>
<td>gpt-oss-20b (reasoning off)</td>
<td>87.5%</td>
<td>n/a (different run config)</td>
<td>25.5%</td>
<td>n/a</td>
</tr>
</tbody>
</table>

<p>The cross-host rows agree within 1-2 percentage points across the board. <em>Same model, same quantization, same prompt, same score within noise</em>.</p>

<p>Notable observations:</p>

<ul>
<li><strong>qwen3.6-thinking tops gsm8k at 96.5%</strong>, better than any A30 result in this set, and in the same ~95-97% band frontier models hit on saturated math benches before the frontier moved on to <a href="https://maa.org/maa-invitational-competitions/">AIME</a> / <a href="https://epoch.ai/frontiermath">FrontierMath</a>. On a 35B-A3B MoE running on a miniPC.</li>
<li><strong>gpt-oss-20b ifeval at 25.5%</strong> is shockingly low for a model that hits 87.5% on gsm8k in the same run. This is the <code>--reasoning off</code> configuration. The other gpt-oss-20b runs in my data, <code>reasoning on</code> variants, also fall in the 25-36% ifeval band, so this isn't a reasoning-flag artifact; gpt-oss-20b just struggles with strict prompt-following regardless. Worth knowing if you were planning to deploy it for instruction-bound tasks.</li>
<li><strong>phi-4 inverted profile</strong>: 90.5% gsm8k but only 56.5% ifeval. It's a math-strong, instruction-weaker model. Useful data point for choosing models by use case.</li>
</ul>

<p>Same story as the coding benches: <em>local models are basically tied with frontier on saturated benchmarks</em>. A Qwen3-30B-A3B on a miniPC scores 95.5% on gsm8k, comfortably in the same band as any frontier model that's still being measured against gsm8k. The frontier moat still exists, but it's on real-world agentic coding (Polyglot).</p>

<h3 id="quality-at-depth-niah">Quality at depth: NIAH</h3>

<p>Throughput at 65K context is meaningless if the model can't actually <em>find</em> anything at 65K. I tested <a href="https://github.com/gkamradt/LLMTest_NeedleInAHaystack">needle-in-haystack</a> retrieval (single-needle: ask "what's the best thing to do in San Francisco?" with a sandwich-and-Dolores-Park needle planted at 10%, 50%, or 90% depth in a haystack of Paul Graham essays):</p>

<table>
<thead>
<tr>
<th>Model</th>
<th>Host</th>
<th>Pass rate (4K/16K/32K/60K × 3 depths)</th>
</tr>
</thead>
<tbody>
<tr>
<td>qwen3.6-thinking</td>
<td>Strix Halo</td>
<td><strong>100%</strong> (12/12)</td>
</tr>
<tr>
<td>qwen3-coder-next</td>
<td>Strix Halo</td>
<td><strong>100%</strong> (12/12)</td>
</tr>
<tr>
<td>qwen3-30b-a3b-2507</td>
<td>A30</td>
<td>100% (12/12)</td>
</tr>
<tr>
<td>qwen2.5-coder-32b</td>
<td>A30</td>
<td>100% (12/12)</td>
</tr>
<tr>
<td>gemma-3-27b-it</td>
<td>A30</td>
<td>100% (12/12)</td>
</tr>
<tr>
<td>phi-4</td>
<td>A30</td>
<td>100% (12/12)</td>
</tr>
<tr>
<td>mistral-small-3.2-24b</td>
<td>A30</td>
<td>100% (12/12)</td>
</tr>
<tr>
<td>llama-4-scout-17b-16e</td>
<td>A30</td>
<td>100% (12/12)</td>
</tr>
<tr>
<td>qwen3-4b-2507</td>
<td>A30</td>
<td>100% (12/12)</td>
</tr>
<tr>
<td>gpt-oss-20b</td>
<td>A30</td>
<td>91.7% (11/12)</td>
</tr>
<tr>
<td>granite-3.1-8b-instruct</td>
<td>A30</td>
<td>88.9% (failed at depth)</td>
</tr>
<tr>
<td>deepseek-coder-v2-lite</td>
<td>A30</td>
<td><strong>33.3%</strong> (4/12, all 4K passes, 1 of 3 at 16K, every 32K and 60K cell timed out at 600s), same root cause as the llama-bench MLA cliff: CUDA-on-MLA is too slow at depth to finish a single query inside any reasonable budget</td>
</tr>
</tbody>
</table>

<p><em>Top-row finding</em>: Strix Halo's qwen3.6-thinking and qwen3-coder-next both score perfect retrieval at 60K context, with response times of 1-2 min per query. The model isn't just <em>running</em> with that context, it's actually <em>using</em> it. Combined with the throughput numbers, this is what makes the miniPC a real coding-agent target rather than a benchmark curiosity.</p>

<h2 id="speed-default-throughput">Speed: default throughput</h2>

<p>Quality matters; speed matters more than people think. A 62% model running at 1 tok/s is unusable. A 50% model at 80 tok/s is a daily driver.</p>

<p>(Methodology note before the tables: every Strix Halo throughput number below was collected with no other model servers running, fans pinned to max, and free memory verified before each run. There's a bench wrapper now that refuses to start without those conditions met. I ended up writing it after melting the poor machine twice, details in <a href="#what-id-do-differently">What I'd do differently</a> below.)</p>

<p>Default <code>pp512 / tg128</code> numbers (Q4_K_M, <code>-fa 1</code>, Strix Halo on q8_0 KV / A30 on q4_0 KV, see longctx section for the protocol note). Throughput is in tokens/sec, so higher is better. The last column is the one place bigger isn't better: it's the A30/miniPC tg ratio, where above 1.0 means the A30 is faster and below 1.0 means the miniPC wins (I flag those rows inline).</p>

<table>
<thead>
<tr>
<th>Model</th>
<th>Strix Halo pp</th>
<th>A30 pp</th>
<th>Strix Halo tg</th>
<th>A30 tg</th>
<th>A30/Strix Halo tg ratio</th>
</tr>
</thead>
<tbody>
<tr>
<td>qwen3-4b-2507</td>
<td>2048</td>
<td>3934</td>
<td>75.0</td>
<td>118.9</td>
<td>1.59x</td>
</tr>
<tr>
<td>gemma-3-4b-it</td>
<td>2257</td>
<td>4431</td>
<td>74.1</td>
<td>109.5</td>
<td>1.48x</td>
</tr>
<tr>
<td>gemma-3-12b-it</td>
<td>750</td>
<td>1590</td>
<td>26.9</td>
<td>52.4</td>
<td>1.95x</td>
</tr>
<tr>
<td>phi-4 (14B)</td>
<td>652</td>
<td>1452</td>
<td>24.1</td>
<td>53.2</td>
<td>2.21x</td>
</tr>
<tr>
<td>gpt-oss-20b</td>
<td>1287</td>
<td>2805</td>
<td>80.8</td>
<td>130.2</td>
<td>1.61x</td>
</tr>
<tr>
<td>mistral-small-3.2-24b</td>
<td>267</td>
<td>905</td>
<td>15.3</td>
<td>34.5</td>
<td>2.26x</td>
</tr>
<tr>
<td>gemma-3-27b-it</td>
<td>230</td>
<td>771</td>
<td>12.6</td>
<td>28.0</td>
<td>2.23x</td>
</tr>
<tr>
<td>qwen3-30b-a3b-2507 (MoE)</td>
<td>1167</td>
<td>2274</td>
<td>87.0</td>
<td>136.2</td>
<td>1.56x</td>
</tr>
<tr>
<td>qwen2.5-coder-32b</td>
<td>186</td>
<td>633</td>
<td>11.1</td>
<td>24.1</td>
<td>2.18x</td>
</tr>
<tr>
<td>qwen3-coder-next (80B-A3B)†</td>
<td>551</td>
<td>110</td>
<td>56.4</td>
<td>12.2</td>
<td><strong>0.22x</strong> ← Strix Halo wins 4.6x</td>
</tr>
<tr>
<td>qwen3.6 (35B-A3B MoE)</td>
<td>944</td>
<td>1933</td>
<td>67.1</td>
<td>99.9</td>
<td>1.49x</td>
</tr>
</tbody>
</table>

<p>† The A30 row for qwen3-coder-next is <strong>hybrid GPU/CPU offload</strong> (22 of 49 layers on GPU, the rest on CPU/RAM). The 45 GiB Q4_K_M model can't fit fully in 24 GiB VRAM, so this is what you get if you force it onto the A30 anyway, the apples-to-apples speed cost of exceeding the VRAM ceiling on a dedicated GPU.</p>

<p>Two stories here:</p>

<p><strong>1. A30 wins at default by 2-3x.</strong> Expected, a dedicated GPU with proper VRAM and CUDA kernels should beat an APU running Vulkan. The factor is consistent across dense models in the 2.2-2.8x range.</p>

<p><strong>2. MoE narrows the gap and makes the miniPC viable.</strong> Look at qwen3-30b-a3b-2507: A30/Strix Halo ratio is just 1.56x for tg, the smallest gap in the table among the bigger models. That's because the model only activates ~3B params per token. Memory bandwidth matters more than raw compute for tg, and Strix Halo's UMA gives it surprisingly good bandwidth for active-parameter-light workloads. (The 4B models also show ratios below 2x, small models stop benefiting from the A30's compute headroom because they're already bandwidth-bound on both boxes.)</p>

<p>Compare that to the dense qwen2.5-coder-32b: 11.1 tok/s on Strix Halo vs 24.1 on A30, still a 2.18x gap but the absolute number is terrible on Strix Halo. I don't know about the rest of you, but 11 tok/s on a 32B dense model is not exactly what I'd call "usable". I'd never reach for the dense coder if a comparable-quality MoE exists.</p>

<h2 id="speed-at-long-context">Speed at long context</h2>

<p>Now the fun part.  Wait, I already said that.  Another fun part! Coding agents send long context (the codebase, the test results, previous turns), so what happens when you push the depth?</p>

<p>I ran the same <code>pp512 / tg128</code> test at depths 0 / 8K / 32K / 65K. Strix Halo is benched with q8_0 KV cache (matches how the production llama-servers are deployed). A30's previously-collected longctx sweep was at q4_0 KV; the small protocol asymmetry is mildly conservative for Strix Halo at depth (q4_0 saves a bit of KV bandwidth at the cost of dequant overhead, within MC noise on this hardware, but if anything it shaves a few percent off the Strix Halo side at deep contexts).</p>

<h3 id="gpt-oss-20b-fits-both">gpt-oss-20b (fits both)</h3>

<table>
<thead>
<tr>
<th>Depth</th>
<th>Strix Halo pp</th>
<th>A30 pp</th>
<th>Strix Halo tg</th>
<th>A30 tg</th>
<th>A30/Strix Halo tg</th>
</tr>
</thead>
<tbody>
<tr>
<td>0 (default)</td>
<td>1287</td>
<td>2805</td>
<td>80.8</td>
<td>130.2</td>
<td>1.61x</td>
</tr>
<tr>
<td>8K</td>
<td>958</td>
<td>2522</td>
<td>66.6</td>
<td>109.5</td>
<td>1.64x</td>
</tr>
<tr>
<td>32K</td>
<td>547</td>
<td>1933</td>
<td>56.9</td>
<td>77.2</td>
<td>1.36x</td>
</tr>
<tr>
<td>65K</td>
<td>338</td>
<td>1452</td>
<td>45.6</td>
<td>54.5</td>
<td><strong>1.20x</strong></td>
</tr>
</tbody>
</table>

<p>A30 tg dropped 58% from default to 65K depth (130 to 55 tok/s). Strix Halo tg dropped 44% over the same range (81 to 46 tok/s). A30 still wins on this model at every depth, but the lead shrinks dramatically as context grows, the A30/Strix Halo ratio compresses from 1.61x at default to 1.20x at 65K.</p>

<h3 id="qwen3-30b-a3b-2507-fits-both">qwen3-30b-a3b-2507 (fits both)</h3>

<table>
<thead>
<tr>
<th>Depth</th>
<th>Strix Halo pp</th>
<th>A30 pp</th>
<th>Strix Halo tg</th>
<th>A30 tg</th>
<th>A30/Strix Halo tg</th>
</tr>
</thead>
<tbody>
<tr>
<td>0 (default)</td>
<td>1167</td>
<td>2274</td>
<td>87.0</td>
<td>136.2</td>
<td>1.56x</td>
</tr>
<tr>
<td>8K</td>
<td>533</td>
<td>1746</td>
<td>62.1</td>
<td>72.5</td>
<td>1.17x</td>
</tr>
<tr>
<td>32K</td>
<td>205</td>
<td>1012</td>
<td>40.2</td>
<td>35.1</td>
<td><strong>0.87x</strong> ← Strix Halo wins</td>
</tr>
<tr>
<td>65K</td>
<td>110</td>
<td>631</td>
<td>28.1</td>
<td>20.6</td>
<td><strong>0.73x</strong> ← Strix Halo wins by 36%</td>
</tr>
</tbody>
</table>

<p><em>This is where it gets spicy.</em> A30 tg dropped <em>85%</em> from default to 65K (136 to 21 tok/s), the 24 GiB VRAM ran out of room for a meaningful KV cache at depth. Strix Halo tg dropped 68% over the same range (87 to 28 tok/s), painful but consistent. Crossover happens between 8K and 32K context. At 32K the miniPC is already faster; at 65K it's 36% faster than the dedicated GPU.</p>

<p>The model itself is 17 GiB Q4_K_M. The A30 has 24 GiB of VRAM. At 65K context the KV cache plus activations plus the model are competing for that 7 GiB headroom, and CUDA's memory management gets bottlenecked. Strix Halo's 128 GiB UMA doesn't care, there's so much memory headroom that the only constraint is compute and bandwidth, both of which degrade gracefully.</p>

<h3 id="qwen36-thinking-strix-halo-only-sonnet-tier-model">qwen3.6-thinking (Strix Halo only, Sonnet-tier model)</h3>

<p>This is the model I'd actually use for coding. The numbers are remarkable:</p>

<table>
<thead>
<tr>
<th>Depth</th>
<th>Strix Halo pp</th>
<th>Strix Halo tg</th>
</tr>
</thead>
<tbody>
<tr>
<td>0 (default)</td>
<td>944</td>
<td>67.1</td>
</tr>
<tr>
<td>8K</td>
<td>790</td>
<td>61.8</td>
</tr>
<tr>
<td>32K</td>
<td>517</td>
<td>55.6</td>
</tr>
<tr>
<td>65K</td>
<td>349</td>
<td><strong>45.5</strong></td>
</tr>
</tbody>
</table>

<p>tg drops <em>32%</em> from default to 65K depth (67.1 to 45.5 tok/s). A Sonnet-class model running locally at 45 tok/s with a 65K-token context window. That's <em>actually usable</em> for serious agentic coding, you can pack a meaningful chunk of a codebase into the context and not pay a brutal speed tax for it.</p>

<p><strong>A note on Q8_0:</strong> I also ran the no-think qwen3.6 at Q8_0 (38 GiB on disk vs Q4_K_M's 20 GiB). Polyglot moved from 53.8% to 56.9%, a ~3 pp gain. Throughput dropped from 65 tok/s to 50 tok/s at default and is similarly proportional at depth. So if you have the disk and want every last point of Polyglot, Q8_0 is a real upgrade. If you'd rather have the speed, Q4_K_M is the right call, the quality gap is small relative to the speed cost.</p>

<h3 id="qwen3-coder-next-80b-a3b-strix-halo-uma-vs-a30-hybrid-offload">qwen3-coder-next 80B-A3B (Strix Halo UMA vs A30 hybrid offload)</h3>

<p>The 80B-A3B that motivated <a href="https://damenknight.com/running-frontier-coding-model-mini-pc/">the last post</a>. At 45 GiB Q4_K_M it doesn't fit in 24 GiB VRAM, so the A30 column here is <strong>hybrid GPU/CPU offload</strong> (<code>-ngl 22</code>, 22 of 49 layers on GPU, the rest streamed from system RAM). Strix Halo's 128 GiB UMA swallows the full model and runs entirely on the iGPU:</p>

<table>
<thead>
<tr>
<th>Depth</th>
<th>Strix Halo pp</th>
<th>A30 hybrid pp</th>
<th>Strix Halo tg</th>
<th>A30 hybrid tg</th>
<th>A30/Strix Halo tg</th>
</tr>
</thead>
<tbody>
<tr>
<td>0 (default)</td>
<td>551</td>
<td>110</td>
<td>56.4</td>
<td>12.2</td>
<td><strong>0.22x</strong> ← Strix Halo wins 4.6x</td>
</tr>
<tr>
<td>8K</td>
<td>500</td>
<td>109</td>
<td>52.5</td>
<td>9.3</td>
<td><strong>0.18x</strong> ← Strix Halo wins 5.6x</td>
</tr>
<tr>
<td>32K</td>
<td>372</td>
<td>109</td>
<td>46.9</td>
<td>5.4</td>
<td><strong>0.12x</strong> ← Strix Halo wins 8.7x</td>
</tr>
<tr>
<td>65K</td>
<td>256</td>
<td>106</td>
<td>38.1</td>
<td>3.9</td>
<td><strong>0.10x</strong> ← Strix Halo wins 9.8x</td>
</tr>
</tbody>
</table>

<p>This is the clearest "wrong tool for the job" result I had. The A30 is a <em>good</em> card, it just doesn't have enough VRAM to hold the model, and PCIe bandwidth between GPU and host RAM is roughly 30x slower than the A30's own HBM2. So every token has to drag activations across that bottleneck.</p>

<p>The math: A30 hybrid tg falls from 12.2 to 3.9 tok/s (a 68% drop) over the depth sweep, while Strix Halo's UMA tg falls from 56.4 to 38.1 (only 32%). The A30 falls off twice as steeply because attention has to read the full KV cache to produce each new token, and on hybrid mode roughly half the model's layers, plus their slice of the KV cache, live in CPU RAM (DDR4, on this server). Each token's attention op pays PCIe-bandwidth overhead, and that overhead scales with context length. So 4.6× at default and <em>9.8× at 65K</em>.</p>

<p>On the Strix system the story is the other way around: the iGPU has the same bandwidth to all 128 GiB as it does to the first 16 GiB. There's no VRAM cliff to fall off because there's no VRAM/RAM distinction at all. tg drops 32% from default to 65K (56.4 to 38.1 tok/s), painful but consistent, and at 38 tok/s with 65K of context loaded it's still... not fast, but usable.</p>

<p>(I also tried to run Aider Polyglot on A30 hybrid for a quality cross-check; the harness's per-call timeout repeatedly fired against the 3.9-9.3 tok/s hybrid response rate, and I abandoned the run after 9 of 225 exercises in ~5 hours. Throughput data above is from <code>llama-bench</code> directly, which doesn't have that problem.)</p>

<h3 id="deepseek-coder-v2-lite-the-bonus-weird-result">DeepSeek-Coder-V2-Lite, the bonus weird result</h3>

<p>I benchmarked this one for completeness, expecting nothing exciting. Instead I found one of the clearest "the dedicated GPU is broken here" results in the whole sweep. DeepSeek-V2's <a href="https://arxiv.org/abs/2405.04434">Multi-head Latent Attention (MLA)</a> uses a low-rank-projected KV cache that's smaller than standard MHA but requires a different attention kernel. The CUDA implementation in llama.cpp build 9064 falls off a cliff once any KV is present:</p>

<table>
<thead>
<tr>
<th>Depth</th>
<th>Strix Halo pp (Vulkan)</th>
<th>A30 pp (CUDA)</th>
<th>Strix Halo tg</th>
<th>A30 tg</th>
<th>A30/Strix Halo tg</th>
</tr>
</thead>
<tbody>
<tr>
<td>0 (default)</td>
<td>1641</td>
<td>408</td>
<td>106.0</td>
<td>88.5</td>
<td><strong>0.83x</strong> ← Strix Halo wins</td>
</tr>
<tr>
<td>8K</td>
<td>1032</td>
<td>17</td>
<td>64.4</td>
<td>4.9</td>
<td><strong>0.08x</strong> ← Strix Halo wins 13x</td>
</tr>
<tr>
<td>32K</td>
<td>484</td>
<td>wedged</td>
<td>30.8</td>
<td>wedged</td>
<td>n/a</td>
</tr>
<tr>
<td>65K</td>
<td>250</td>
<td>wedged</td>
<td>17.4</td>
<td>wedged</td>
<td>n/a</td>
</tr>
</tbody>
</table>

<p>The A30 bench actually wedged my harness, at d=32K, the CUDA kernel grinds at ~3-5 tok/s prefill, which means a single measurement of the 32K-token prefill would take 100+ minutes. I killed it after 17 minutes of no progress.</p>

<p>Strix Halo's Vulkan path handles MLA at depth normally, degrading from 106 to 17 tok/s tg is a real cliff, but it's a <em>finite</em> one and the bench actually finishes. <em>Even at d=0 Strix Halo is 4× faster on pp512 (1641 vs 408), and that's before any KV is in play.</em> The CUDA backend isn't just slow at depth on this architecture, it's just slow on this architecture.</p>

<p>This isn't a hardware issue, I don't think, it's a software bug.  Presumably to be fixed in some future llama.cpp release lol. But for anyone considering DeepSeek-V2-family models for coding <em>right now</em> the miniPC is the only sensible target. A 24 GiB A30 will load the model just fine and then be fairly unusable.</p>

<h3 id="bonus-models-strix-halo-coverage-only">Bonus models (Strix Halo coverage only)</h3>

<p>For completeness, three more models I benchmarked on Strix Halo to fill out the table:</p>

<table>
<thead>
<tr>
<th>Model</th>
<th>Default pp</th>
<th>Default tg</th>
<th>65K pp</th>
<th>65K tg</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<tr>
<td>granite-3.1-8b-instruct</td>
<td>996</td>
<td>39.5</td>
<td>(crashed)</td>
<td>(crashed)</td>
<td>Vulkan device-lost at d=65K, got d=0/8K/32K only</td>
</tr>
<tr>
<td>llama-4-scout-17b-16e</td>
<td>159</td>
<td>20.1</td>
<td>105</td>
<td>13.9</td>
<td>17B-active, 109B-total, slowest in the post but flattest depth scaling (only 31% tg drop)</td>
</tr>
</tbody>
</table>

<h2 id="what-each-box-is-actually-best-for">What each box is actually best for</h2>

<ul>
<li><strong>Strix Halo as a coding agent:</strong> qwen3.6 with thinking on when I want quality, qwen3.6 with thinking off when I want speed/quality balance. Same model file, same throughput, just flip the <code>--reasoning</code> flag.</li>
<li><strong>A30 for serving small concurrent requests:</strong> gpt-oss-20b at 130 tok/s or qwen3-30b-a3b at 136 tok/s is great for embeddings, rerank, and utility models in a stack.</li>
</ul>

<p>These are different jobs. The boxes aren't substitutes; they're complements.</p>

<h2 id="what-id-do-differently">What I'd do differently</h2>

<ol>
<li>
<p><strong>Update everything to the latest first.</strong> I spent a week chasing scores that looked too low only to realize my llama.cpp was 700 commits behind on reasoning-channel handling. Thinking models scored 0% on lm_eval because the reasoning content was consuming the entire context budget. A rebuild fixed it. This stuff moves fast, llama.cpp lands fixes weekly, so pull and rebuild to the latest before you trust a single number.</p>
</li>
<li>
<p><strong>Bench with <code>-d</code> from the start, not <code>-c</code>.</strong> The <code>-c</code> arg got removed from llama-bench in recent builds; the replacement is <code>-d <depth></code> for testing tg at a given KV depth. My first A30 long-context sweep died at parse time. Trivial fix in retrospect, but it cost me half a day.</p>
</li>
<li>
<p><strong>Don't trust HumanEval+ as a discriminator.</strong> Everything competent scores 85%+. The bench doesn't separate "okay" from "great." Polyglot is what actually matters; I should have run it first.</p>
</li>
<li>
<p><strong>Run <code>whole</code> and <code>diff</code> edit formats both.</strong> I ran everything in <code>whole</code> because it's robust for weak models. That makes the strong-model comparisons against Aider's leaderboard (which uses <code>diff</code>) slightly unfair to the local models. Doing both would have given a cleaner local-vs-API comparison.</p>
</li>
<li>
<p><strong>Treat thermals and bench cleanliness as first-class concerns.</strong> Two specific traps cost me roughly a week of redo work:</p>
<ul>
<li><strong>Don't re-make the same thermal mistakes as last time.</strong> I already worked this box's thermals out in the last post: sustained GPU load trips it unless you cap power with <code>ryzenadj</code> and pin the fans manually, because the stock fan curve is tuned for desktop bursts, not back-to-back benchmarks holding the GPU near 100% for minutes at a time. Then I forgot to actually turn any of that on before kicking off a multi-hour sweep, and crashed the box twice (no kernel log, just unreachable until a power-cycle) rediscovering a lesson I'd already written down. The fix was the one I already had on the shelf: <code>mode=fixed level=5</code> on all three fans (under <code>/sys/class/ec_su_axb35/fan*/</code>) before any sustained workload. The wrapper now refuses to start a bench unless the fans are confirmed above 3500 RPM.</li>
<li><strong>Keep other model servers cleared out the whole time, not just at the start.</strong> Any concurrent <code>llama-server</code> process <code>--mlock</code>'s its model into RAM and steals memory bandwidth from the bench. I caught this when a spot-check tg128 re-run came in 5% higher than the recorded number with everything else stopped. Five percent is small enough to miss in a single run and big enough to materially change rankings across models. The real trap is that it's easy to start clean and then let stray servers creep back in over a long session, so the fix isn't a one-time cleanup, it's re-verifying nothing else is loaded before every single run. Every Strix Halo throughput number in this post was collected that way, and the wrapper enforces it as a precondition.</li>
</ul>
<p>The meta lesson: a bench harness that <em>requires</em> you to remember the discipline will eventually run dirty. Make the harness refuse to run unless the conditions are met.</p>

<h2 id="the-end-result">The end result</h2>

<p>Use the right tool for the job.  Shocking, I know.</p>

<p>The miniPC can be a Sonnet-tier coding agent (when running the right model) that costs about $2,500 once and never sends my code anywhere. The A30 box is for smaller task-specific models that need high throughput.</p>

<p>The local-vs-frontier gap is still real on the hardest problems and on real agentic Polyglot work, but it's roughly Sonnet-class for daily-driver coding tasks, and the gap is closing. The next time someone benchmarks this, I expect the frontier-API moat to be at least a little bit smaller.</p>

<hr />

<a id="footnote-effort"></a>

<p>*Footnote: the <code>reasoning_effort</code> parameter not wiring through to llama.cpp's gpt-oss path is documented elsewhere; I verified by running effort=low/medium/high through lm_eval gsm8k and getting near-identical scores (90% / 86% / 86%), within the sampling noise band for a 200-item subset. If the flag were actually doing anything, I'd expect monotonic improvement from low to high; instead "high" is the same as "medium" and "low" comes out <em>higher</em> than both, which only makes sense if all three are effectively the same configuration plus sampling noise. A separate post about this might be coming.*</p>]]></content:encoded>
    </item>
    <item>
      <title>Running a Frontier Coding Model on an Under-$3K Mini PC</title>
      <link>https://damenknight.com/running-frontier-coding-model-mini-pc/</link>
      <guid isPermaLink="true">https://damenknight.com/running-frontier-coding-model-mini-pc/</guid>
      <pubDate>Thu, 12 Mar 2026 12:00:00 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <category>AI</category>
      <description>I got Qwen3-Coder-Next (80B MoE) running at 46 tok/s on a under-$3K mini PC. It took a full OS reinstall, a firmware downgrade, kernel parameter archaeology, a…</description>
      <content:encoded><![CDATA[<p><strong>TL;DR</strong>: I got Qwen3-Coder-Next (80B MoE) running at 46 tok/s on an under-$3K mini PC. It took a full OS reinstall, a firmware downgrade, kernel parameter archaeology, a thermal crisis, and throwing out about half the tuning advice I found online. Here's everything I learned the hard way.</p>
<h2>Why This Hardware</h2>
<p>My existing GPU setups didn't have enough VRAM to run some of the larger models I was interested in testing. Discrete GPUs with 48+ GB of VRAM are absurdly expensive, and splitting a model across multiple consumer cards comes with its own headaches and PCIe bottleneck tax. So I started looking into UMA (Unified Memory Architecture) systems - where the CPU and GPU share the same memory pool - as a significantly more affordable way to get a ton of usable memory for inference.</p>
<p>That led me to the Ryzen AI MAX+ 395. It's a weird chip - a laptop/mini-PC APU with 32 Zen 5 cores, a 40-CU RDNA 3.5 iGPU, and support for up to 128 GB of LPDDR5 unified memory. Since the CPU and GPU share the same pool, the GPU can address all 128 GB without PCIe bottlenecks. For LLM inference, where model weights need to stream through the compute units every single token, that's a huge deal.</p>
<p>The theoretical memory bandwidth is 256 GB/s (LPDDR5X-8000 on a 256-bit bus). In practice I measured around 212-215 GB/s - about 82% efficiency. That's slower than an M4 Max (~546 GB/s) but faster than trying to cram a 70B model across two consumer GPUs and eating the PCIe tax.</p>
<p>The GMKtec NucBox EVO-X2 packages this chip into a mini PC chassis for under $3K with 128 GB RAM - though with the way LPDDR5 prices have been going lately, check current pricing before you get too excited. There are a few other options with this chip: Framework makes a Desktop, ASUS has the ROG Flow Z13 tablet, and Minisforum has the EliteMini AI Max. The GMKtec was the best price-to-performance option I found at the time, but it's worth shopping around.</p>
<h2>The OS: Rocky Linux 9.7</h2>
<p>I'm running Rocky Linux 9.7 - enterprise stability, good package ecosystem, SELinux actually works properly. Any RHEL 9 derivative should work similarly.</p>
<h2>The Three Things That Must Be Right</h2>
<p>After the base OS was clean, I hit a wall. A really frustrating wall. Getting this hardware working properly requires <strong>three specific things to be correct</strong> - the right kernel, the right firmware, and thermal power limits that won't let the system cook itself to death. I'm going to cover all three here because skipping any one of them will ruin your day.</p>
<h3>1. Kernel 6.18.4 or newer</h3>
<p>The KFD (Kernel Fusion Driver) in older kernels has a page table bug specific to gfx1151. Any GPU tensor allocation triggers "Memory access fault: Page not present" errors. This was fixed upstream in kernel 6.18.4. Rocky 9's stock kernel is 6.12, which is too old.</p>
<p>I tried AMD's <code>amdgpu-dkms</code> package first (which backports the amdgpu driver to older kernels), but the DKMS version is pre-6.18 and doesn't include the KFD fix. No combination of kernel parameters - <code>HSA_ENABLE_SDMA=0</code>, <code>amd_iommu=off</code>, <code>amdgpu.noretry=0</code>, <code>amdgpu.cwsr_enable=0</code> - works around it. Trust me, I tried them all. You need the actual kernel fix.</p>
<p>The solution: ELRepo's <code>kernel-ml</code> package, which provides mainline kernels packaged for RHEL/Rocky. I installed 6.19.6 and it just worked.</p>
<pre><code class="language-bash">sudo dnf install -y elrepo-release
sudo rpm --import https://www.elrepo.org/RPM-GPG-KEY-elrepo.org
sudo dnf --enablerepo=elrepo-kernel install -y kernel-ml
</code></pre>
<h3>2. MES firmware version 0x80</h3>
<p>Even with kernel 6.19.6, I was still getting page faults. Cool. The second half of the puzzle is the MES (Micro Engine Scheduler) firmware. Rocky's <code>linux-firmware-20260130</code> package ships MES version 0x83, which is known to cause ROCm page faults on Strix Halo. The upstream linux-firmware repository explicitly reverted it with the commit message: "MES FW 0x83 is reported to cause ROCm page faults."</p>
<p>Rocky hadn't picked up the revert yet, and AMD's own <code>amdgpu-dkms-firmware</code> package <em>also</em> ships 0x83. So the fix is manual:</p>
<pre><code class="language-bash"># Download good firmware (version 0x80) from upstream revert commit
curl -sL -o /tmp/gc_11_5_1_mes1.bin \
  "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/amdgpu/gc_11_5_1_mes1.bin?id=c092c7487eb7c3d58697f490ff605bc38f4cc947"
curl -sL -o /tmp/gc_11_5_1_mes_2.bin \
  "https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/amdgpu/gc_11_5_1_mes_2.bin?id=c092c7487eb7c3d58697f490ff605bc38f4cc947"

# Install to updates dir (takes priority over base firmware)
sudo cp /tmp/gc_11_5_1_mes1.bin /lib/firmware/updates/amdgpu/
sudo cp /tmp/gc_11_5_1_mes_2.bin /lib/firmware/updates/amdgpu/

# Rebuild initramfs and reboot
sudo dracut --force /boot/initramfs-$(uname -r).img $(uname -r)
sudo reboot
</code></pre>
<p>Verify after reboot:</p>
<pre><code class="language-bash">sudo cat /sys/kernel/debug/dri/0/amdgpu_firmware_info | grep MES
# Good: firmware version: 0x00000080
# Bad:  firmware version: 0x00000083
</code></pre>
<p>Once both pieces were in place, PyTorch passed all validation checks: tensor operations, all data types (fp32, fp16, bf16, int8), 4 GiB memory allocation, and ~1.05 TFLOPS on a 4096x4096 FP32 matmul. Finally.</p>
<p><strong>Lesson learned the hard way:</strong> Pin your firmware. I added <code>exclude=linux-firmware* amdgpu-dkms-firmware*</code> to <code>/etc/dnf/dnf.conf</code> to prevent package updates from sneaking MES 0x83 back in. Ask me how I know.</p>
<h3>3. Thermal Power Limits</h3>
<p><strong>This one might be the most important of the three, so don't skip it.</strong></p>
<p>While setting up a PyTorch benchmarking suite, the system started dying on me. At first I figured "oh weird, the host crashed" - but when I went to check on it, it wasn't just locked up. It was fully powered off. That's... not normal. Then it happened again. And again. Full hard power-off events with no warning, no logs, nothing.</p>
<p>I set up thermal monitoring logging every 5 seconds and caught the cause:</p>
<pre><code>19:00:07  Tctl=71°C   pwr=92W    ← normal inference
19:00:12  Tctl=91°C   pwr=165W   ← torch.compile spike
19:00:22  Tctl=93°C   pwr=164W   ← approaching TjMax (100°C)
19:00:27  Tctl=61°C   pwr=30W    ← thermal shutdown
</code></pre>
<p><code>torch.compile</code> triggers Triton/Inductor kernel compilation that simultaneously hammers all 32 CPU cores <em>and</em> the GPU. On a UMA APU where everything shares one thermal envelope in a mini PC chassis, that produces a 165W power spike - way past the 120W PPT Fast limit and far more than the little cooler can handle. The firmware thermal protection kicks in and just kills power. No graceful shutdown, just off.</p>
<p>Normal LLM inference is totally fine - 73-75W, 76-80°C, perfectly stable all day long. But the moment you hit a mixed CPU+GPU burst workload, you're rolling the dice. And it's not just <code>torch.compile</code> - anything that pegs the CPU and GPU simultaneously in this chassis can trigger it. I lost count of how many times the system just cut out on me before I got this sorted.</p>
<p>The fix is <strong>ryzenadj</strong>, a tool that lets you adjust AMD mobile power limits from Linux:</p>
<pre><code class="language-bash">sudo ryzenadj --fast-limit=100000 --tctl-temp=88
</code></pre>
<p>This caps burst power to 100W and sets the thermal target to 88°C, giving 12°C of headroom before TjMax. <strong>Do this immediately after your first boot, before you run anything heavy.</strong> I created a systemd service to persist these limits across reboots so they're always active. The GMKtec ships with BIOS 1.12 / EC 1.10 (the latest available), so there's no firmware fix coming - you've gotta manage this in software.</p>
<p>Other thermal improvements people recommend but I haven't tried yet: replacing the stock thermal paste with PTM7950 phase-change material, and the <code>ec_su_axb35</code> kernel module for Linux fan control. Maybe I'll get to those at some point.</p>
<h2>Understanding Unified Memory (It's Unintuitive)</h2>
<p>The BIOS has a "UMA Frame Buffer Size" setting that defaults to 64 GB. Your instinct says "big number = more GPU memory = good." Yeah, your instinct is wrong here.</p>
<p>On a traditional discrete GPU, VRAM is physically separate from system RAM. On Strix Halo, there's only one pool of LPDDR5. The BIOS carveout <em>reserves</em> a chunk of that pool as dedicated VRAM - the OS can't see it, can't use it for anything else, and the GPU doesn't even need it because it can access system RAM at the same speed through GTT (Graphics Translation Table).</p>
<p>The optimal configuration is:</p>
<ul>
<li><strong>BIOS VRAM: 2 GB</strong> (the minimum on the GMKtec's current BIOS 1.12 - you'll see guides online saying to set this to 512 MB, but that was only possible on earlier BIOS versions. 2 GB is as low as it goes now.)</li>
<li><strong>GTT: 124 GB</strong> (dynamically mapped, shared between CPU and GPU)</li>
</ul>
<p>This gives you ~124 GB usable for both CPU and GPU workloads, instead of 64 GB locked to GPU + 64 GB for CPU.</p>
<p>The kernel parameters to make this work:</p>
<pre><code>amdgpu.gttsize=126976          # 124 GiB GTT
ttm.pages_limit=29360128       # Allow TTM to manage 112 GiB of pages
ttm.page_pool_size=29360128    # Matching pool size
amdgpu.no_system_mem_limit=1   # Disable SVM resident memory cap
amd_iommu=off                  # Fully disable IOMMU (~4% bandwidth gain)
</code></pre>
<p>The <code>ttm.pages_limit</code> parameter is particularly sneaky. Without it, you can set GTT to 124 GB and the kernel will report 124 GB, but HIP/ROCm applications will only see ~62 GiB. The TTM subsystem has its own page limit that must match. And it has to be set at boot - runtime changes don't take effect. That one took a while to figure out.</p>
<p>On Rocky 9, updating kernel parameters has its own gotcha: editing <code>/etc/default/grub</code> and running <code>grub2-mkconfig</code> <strong>doesn't work</strong>. Rocky 9 uses BLS (Boot Loader Specification) entries, which have their own options line. Use <code>grubby</code> instead:</p>
<pre><code class="language-bash">grubby --update-kernel=DEFAULT --args="amd_iommu=off amdgpu.gttsize=126976 ttm.pages_limit=29360128 ttm.page_pool_size=29360128 amdgpu.no_system_mem_limit=1"
</code></pre>
<h2>Building and Running llama.cpp</h2>
<p>Ok, with the hardware finally cooperating, I built llama.cpp. I started with ROCm/HIP since that's what everyone recommends for AMD GPUs:</p>
<pre><code class="language-bash">cmake -B build \
  -DGGML_HIP=ON -DAMDGPU_TARGETS=gfx1151 \
  -DGGML_NATIVE=OFF -DCMAKE_C_FLAGS='-march=znver4' -DCMAKE_CXX_FLAGS='-march=znver4' \
  -DGGML_AVX512=ON -DGGML_AVX512_VBMI=ON -DGGML_AVX512_VNNI=ON -DGGML_AVX512_BF16=ON \
  -DGGML_HIP_ROCWMMA_FATTN=ON -DGGML_CUDA_FA_ALL_QUANTS=ON \
  -DGGML_LTO=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
</code></pre>
<p>A few build notes:</p>
<ul>
<li><code>-DGGML_NATIVE=OFF</code> with explicit <code>-march=znver4</code> is required because GCC 11 on Rocky 9 emits VNNI instructions that the system's binutils can't assemble. Specifying znver4 explicitly avoids the problematic auto-detection.</li>
<li>The AVX512 flags enable SIMD for CPU-side tensor ops. Zen 5 has full AVX-512 support.</li>
<li><code>GGML_HIP_ROCWMMA_FATTN</code> enables wave matrix multiply for flash attention.</li>
</ul>
<p><strong>Critical for APUs:</strong> You must set <code>GGML_CUDA_ENABLE_UNIFIED_MEMORY=1</code> before running. Without it, llama.cpp tries to allocate in the 2 GB dedicated VRAM carveout and fails for any model larger than 2 GB. With it, allocations go through the full GTT pool. Don't skip this or you'll be very confused.</p>
<h3>The Model</h3>
<p>I'm running <strong>Qwen3-Coder-Next Q4_K_M</strong> - an 80B parameter Mixture-of-Experts model with 3B active parameters, purpose-built for coding agents. At Q4_K_M quantization it's about 46 GiB across 4 GGUF shards, fitting comfortably in 128 GB with room for a 65K token context window.</p>
<p>The Mixture-of-Experts architecture is what makes this hardware viable. An 80B MoE model only needs to stream the active expert weights each token - roughly 3B parameters - not the full 80B. Dense 70B models? They crawl at 5-7 tok/s on this hardware. This 80B MoE? 46 tok/s. Same memory, same bandwidth - the model architecture makes all the difference.</p>
<p>This model scored #1 on SWE-rebench Pass@5 at 64.6%, beating Claude Opus 4.6 (58.3%). Running it locally at interactive speeds on a sub-$3K box (give or take, depending on what RAM prices are doing this week) is... pretty nuts.</p>
<h3>Runtime Configuration</h3>
<p>I run llama-server as a systemd service with these flags:</p>
<pre><code>-fa on              # Flash attention (smaller KV cache, faster attention)
--parallel 1        # Single slot - all memory for one user
-t 32 -tb 32       # All 32 CPU cores
-ub 2048            # Large ubatch for GPU utilization during prompt processing
-ctk q8_0 -ctv q8_0  # Quantized KV cache (~2x smaller than f16, minimal quality loss)
--mlock             # Pin model in RAM
-c 65536            # 65K context window
</code></pre>
<p>Two things I learned about GPU power modes: <code>profile_peak</code> sounds good but actually causes thermal throttling on an integrated GPU sharing the SoC thermal envelope. Generation dropped from 37.9 to 26.9 tok/s. Ouch. Use <code>high</code> instead - it clocks up aggressively but lets the thermal controller do its job.</p>

<h2>Tuning: What the Internet Got Wrong</h2>
<p>With the system stable, I went through every tuning recommendation I could find - a comprehensive "definitive guide" document and the <a href="https://strixhalo.wiki/AI/llamacpp-performance">strixhalo.wiki llama.cpp performance page</a>. I benchmarked each claim individually. A lot of them were wrong, at least for this hardware.</p>
<h3>Things that didn't matter</h3>
<p><strong><code>--no-mmap</code> vs <code>--mlock</code></strong>: Identical performance. pp=219.5/tg=37.7 vs pp=218.7/tg=38.0. On a UMA APU where GPU memory <em>is</em> system memory, both approaches effectively do the same thing. Pick whichever you prefer.</p>
<p><strong><code>-b 256</code> batch size</strong>: Slightly <em>worse</em> than the default <code>-ub 2048</code>. The claimed jump from 70 to 591 tok/s was for Qwen3-30B-A3B, a much smaller model with different memory access patterns. Don't copy batch size settings across models.</p>
<p><strong><code>ROCBLAS_USE_HIPBLASLT=1</code></strong>: No measurable effect on gfx1151 with this model. The "mandatory" claim may apply to other GPU architectures.</p>
<h3>Things that helped a little</h3>
<p><strong><code>amd_iommu=off</code></strong>: Real. Generation speed went from 38.0 to 39.4 tok/s - a 3.7% improvement. Not the claimed 6%, but free performance. I also bumped GTT from 112 GiB to 124 GiB in the same change.</p>
<h3>The big discovery: Vulkan beats ROCm</h3>
<p>Then I built llama.cpp with Vulkan instead of HIP, just to see what would happen:</p>
<pre><code class="language-bash">cmake .. -DGGML_VULKAN=ON -DCMAKE_BUILD_TYPE=Release
</code></pre>
<p>The results were... not subtle:</p>
<table>
<thead>
<tr>
<th>Context</th>
<th>Vulkan pp (tok/s)</th>
<th>Vulkan tg (tok/s)</th>
<th>HIP pp (tok/s)</th>
<th>HIP tg (tok/s)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Default (512)</td>
<td>548</td>
<td>45.9</td>
<td>336</td>
<td>40.8</td>
</tr>
<tr>
<td>32K</td>
<td>394</td>
<td>36.8</td>
<td>91</td>
<td>29.7</td>
</tr>
<tr>
<td>65K</td>
<td>305</td>
<td>32.2</td>
<td>54</td>
<td>23.5</td>
</tr>
<tr>
<td>100K</td>
<td>213</td>
<td>28.2</td>
<td>36</td>
<td>18.7</td>
</tr>
</tbody>
</table>
<p>Vulkan with RADV (Mesa's open-source Vulkan driver) was 63% faster at prompt processing and 12% faster at generation at default context. The gap <em>widens</em> with context length - at 100K tokens, Vulkan is nearly 6x faster at prompt processing and 51% faster at generation.</p>
<p>This directly contradicts the common advice that "ROCm is better for long-context work." That may be true on datacenter GPUs (MI300X) or older desktop GPUs (gfx1100), but on gfx1151, the HIP compute kernels are known to run 2-6x slower than expected. Vulkan's cooperative matrix support through RADV doesn't have the same problem.</p>
<p>The guides also recommended AMDVLK (AMD's proprietary Vulkan driver) over RADV for 10-15% better performance. I investigated and found that AMD <strong>discontinued AMDVLK in September 2025</strong>, going all-in on RADV. The strixhalo.wiki's own benchmarks actually show RADV beating AMDVLK even before they killed it. Just use RADV.</p>
<p>One nice bonus: the Vulkan build doesn't need the <code>GGML_CUDA_ENABLE_UNIFIED_MEMORY=1</code> environment variable. That's a HIP/ROCm-specific workaround.</p>
<h2>The Boring But Important Stuff</h2>
<p>A handful of other things that aren't exciting but tripped me up:</p>
<p><strong>DNF firmware pinning</strong>: Added <code>exclude=linux-firmware* amdgpu-dkms-firmware*</code> to <code>/etc/dnf/dnf.conf</code>. Without this, a routine <code>dnf update</code> can reintroduce MES 0x83 and break GPU compute.</p>
<p><strong>EPEL rocminfo conflict</strong>: EPEL ships rocminfo 5.4.4 which conflicts with the ROCm 7.2 version from AMD's repo. Fixed with <code>dnf config-manager --save --setopt=epel.excludepkgs=rocminfo</code>.</p>
<p><strong>SELinux and systemd</strong>: The llama-server binary must live in <code>/usr/local/bin</code> (not <code>~/</code>) for SELinux to allow systemd to execute it. Run <code>restorecon -v</code> after copying.</p>
<p><strong>WiFi</strong>: The MediaTek MT7925 (Wi-Fi 7) works with WPA2 networks but fails on WPA2/WPA3 mixed-mode SSIDs. Suspected <code>mt7925e</code> driver bug. If your router broadcasts both, you may need a WPA2-only SSID.</p>
<p><strong>GPU performance mode</strong>: Set via udev rule to persist across reboots:</p>
<pre><code class="language-bash">echo 'ACTION=="add", SUBSYSTEM=="drm", KERNEL=="card0", ATTR{device/power_dpm_force_performance_level}="high"' \
  | sudo tee /etc/udev/rules.d/99-gpu-perf.rules
</code></pre>
<h2>What I'd Do Differently</h2>
<p>If I was setting this up again from scratch:</p>
<ol>
<li>
<p><strong>Start with Vulkan, not ROCm/HIP.</strong> I spent way too much time optimizing the HIP build before discovering Vulkan was faster at everything. Just build llama.cpp with <code>-DGGML_VULKAN=ON</code> from the start.</p>
</li>
<li>
<p><strong>Install ELRepo kernel immediately.</strong> Don't waste time trying to make the stock 6.12 kernel work with DKMS. It can't. I tried.</p>
</li>
<li>
<p><strong>Check MES firmware before debugging anything else.</strong> If <code>rocminfo</code> hangs or GPU compute produces page faults, check MES version first. It's the most common cause and the least obvious one.</p>
</li>
<li>
<p><strong>Set BIOS VRAM to minimum and maximize GTT from day one.</strong> The default 64 GB carveout wastes half your memory for no reason.</p>
</li>
<li>
<p><strong>Install ryzenadj before you do literally anything else.</strong> Seriously. The thermal shutdowns caught me completely off guard and happened repeatedly. The stock power limits on this chassis are not safe for sustained workloads. Cap power <em>first</em>, then start playing with models.</p>
</li>
</ol>
<h2>The End Result</h2>
<p>My final configuration:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Setting</th>
</tr>
</thead>
<tbody>
<tr>
<td>OS</td>
<td>Rocky Linux 9.7, kernel 6.19.6 (ELRepo)</td>
</tr>
<tr>
<td>GPU driver</td>
<td>Mesa RADV 25.0.7 (Vulkan)</td>
</tr>
<tr>
<td>MES firmware</td>
<td>0x80 (manually installed)</td>
</tr>
<tr>
<td>BIOS VRAM</td>
<td>2 GB (minimum)</td>
</tr>
<tr>
<td>GTT</td>
<td>124 GiB</td>
</tr>
<tr>
<td>IOMMU</td>
<td>Fully disabled</td>
</tr>
<tr>
<td>Power limits</td>
<td>100W burst / 88°C target (ryzenadj)</td>
</tr>
<tr>
<td>llama.cpp</td>
<td>Vulkan build, flash attention, q8_0 KV cache</td>
</tr>
<tr>
<td>Model</td>
<td>Qwen3-Coder-Next Q4_K_M (80B MoE, 46 GiB)</td>
</tr>
<tr>
<td>Context</td>
<td>65K tokens</td>
</tr>
</tbody>
</table>
<p>Performance:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Speed</th>
</tr>
</thead>
<tbody>
<tr>
<td>Token generation (short context)</td>
<td>45.9 tok/s</td>
</tr>
<tr>
<td>Token generation (32K context)</td>
<td>36.8 tok/s</td>
</tr>
<tr>
<td>Token generation (65K context)</td>
<td>32.2 tok/s</td>
</tr>
<tr>
<td>Token generation (100K context)</td>
<td>28.2 tok/s</td>
</tr>
<tr>
<td>Prompt processing (short context)</td>
<td>548 tok/s</td>
</tr>
</tbody>
</table>
<p>For a mini PC that cost me under $3K - though good luck getting that price if LPDDR5 keeps doing what it's been doing - running a frontier-class 80B coding model entirely locally, with 65K context and no API costs? I'm pretty happy with that.</p>
<hr />
<p><em>Tested on: GMKtec NucBox EVO-X2, AMD Ryzen AI MAX+ 395, 128 GB LPDDR5, Rocky Linux 9.7, kernel 6.19.6, llama.cpp build f90bd1dd8, Mesa RADV 25.0.7. March 2026.</em></p>]]></content:encoded>
    </item>
    <item>
      <title>Rescuing &quot;Unsupported&quot; Enterprise SSDs with Custom MegaRAID Tools</title>
      <link>https://damenknight.com/rescuing-unsupported-enterprise-ssds-megaraid-tools/</link>
      <guid isPermaLink="true">https://damenknight.com/rescuing-unsupported-enterprise-ssds-megaraid-tools/</guid>
      <pubDate>Sat, 31 Jan 2026 09:00:00 GMT</pubDate>
      <category>Homelab</category>
      <description>I picked up a pair of refurbished Samsung PM1643a SSDs for my Dell R740 Proxmox server, but my PERC H330 showed them as &quot;Unsupported&quot; with 0 KB size. They had…</description>
      <content:encoded><![CDATA[<p>I picked up a pair of refurbished Samsung PM1643a SSDs (3.84TB each) for my Dell R740 Proxmox server. Great deal on enterprise drives, right? Except when I installed them, my PERC H330 controller showed them as "UGUnsp" (Unconfigured Good Unsupported) with a size of... 0 KB.</p>

<p>The drives had been pulled from an enterprise storage array (likely Hitachi or EMC) and were formatted with 520-byte sectors instead of the standard 512. Those extra 8 bytes per sector are used for T10-DIF data integrity protection - useful in big SANs, useless for my homelab.</p>

<p>The usual fixes wouldn't work. <code>sg_format</code>? Can't see the drives. Samsung DC Toolkit? Nope. Perccli format/erase commands? "Operation not allowed." The controller refused to expose them to Linux at all - no <code>/dev/sd*</code>, no <code>/dev/sg*</code>. My only option seemed to be flashing the H330 to IT-mode, which meant unacceptable downtime.</p>

<p>Then I noticed something: <code>smartctl -d megaraid,4 -i /dev/sda</code> could actually talk to the drives via MegaRAID passthrough. The controller wouldn't expose them, but it would relay SCSI commands to them. That was my way in.</p>

<p>With some help from Claude Code, I dug into smartctl's source code and reverse-engineered the MegaRAID IOCTL interface. The result is a set of small C tools that send SCSI FORMAT UNIT and MODE SELECT commands directly through the MegaRAID passthrough - no HBA flash required, no downtime.</p>

<p>Both drives are now happily running at 512-byte sectors, showing their full 3.49 TiB each, and working perfectly as JBOD in Proxmox.</p>

<p>I've open-sourced the tools in case anyone else runs into this: <a href="https://github.com/filthyrake/megaraid_format_tools">github.com/filthyrake/megaraid_format_tools</a></p>]]></content:encoded>
    </item>
    <item>
      <title>Vlog??</title>
      <link>https://damenknight.com/vlog/</link>
      <guid isPermaLink="true">https://damenknight.com/vlog/</guid>
      <pubDate>Wed, 03 Dec 2025 21:07:35 GMT</pubDate>
      <category>Uncategorized</category>
      <description>Once upon a time I had a youtube channel. I mean I still do but most of my videos are now private and I stopped posting. I dont really want to reactivate my…</description>
      <content:encoded><![CDATA[
<p>Once upon a time I had a youtube channel.  I mean I still do but most of my videos are now private and I stopped posting.  I dont really want to reactivate my channel and be a YouTuber again, but I also dont want to just toss all that stuff out, so I’m standing up <a href="https://vlog.damenknight.com">vlog.damenknight.com</a> and migrating MOST of my old YouTube content over.</p>



<p>Now I DO want to create at least some content still – I clearly enjoy it – but doing it here instead of on YT will hopefully let me keep it a bit more chill and maybe more consistent.  Head on over, check it out as I get things migrated, and keep your eyes out in the future for all NEW content!  More car stuff, more astro stuff, more tech, you name it!</p>
]]></content:encoded>
    </item>
    <item>
      <title>Astrophotography Datasets Site</title>
      <link>https://damenknight.com/astrophotography-datasets-site/</link>
      <guid isPermaLink="true">https://damenknight.com/astrophotography-datasets-site/</guid>
      <pubDate>Mon, 05 May 2025 00:13:28 GMT</pubDate>
      <category>Astrophotography</category>
      <description>For a while now, I’ve been sharing my astrophotography datasets on a really basic mid-90’s-looking site I threw together. It was ugly, it was hard to use, it…</description>
      <content:encoded><![CDATA[
<p>For a while now, I’ve been sharing my astrophotography datasets on a really basic mid-90’s-looking site I threw together.  It was ugly, it was hard to use, it sucked.  But it served its purpose and I was ok with it.  I had bigger plans though.</p>



<p>You see, there aren’t a ton of sites that share astrophotography datasets – especially not for free.  Many people sell theirs, and the best free options have historically been places like NASA and the ESA.  I absolutely love those free resources, but I wanted something for the rest of us and with more variety.</p>



<p>So I’ve spent the past… many many many months working hard to build something new.  This was a pain, since I am not a web guy or a software guy and this involved both – and I’m far far from done still – but it is in a place where I’m happy to talk about it and start showing it off.</p>



<p>If you havent yet, head over to check it out: <a href="https://datasets.miscellaneousnerdery.com">Miscellaneous Datasets</a>.  It is fairly filterable by what kind of data you want.  All datasets can be downloaded in all the major formats.  Right now it is limited to data I’ve personally captured, but I’m hard at work getting more contributors on board.  My dream is that someday this will be the largest free non-government dataset resource in the world.</p>



<p></p>
]]></content:encoded>
    </item>
    <item>
      <title>Warewulf Home Lab Setup</title>
      <link>https://damenknight.com/warewulf-home-lab-setup/</link>
      <guid isPermaLink="true">https://damenknight.com/warewulf-home-lab-setup/</guid>
      <pubDate>Tue, 25 Mar 2025 19:13:18 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <description>This cluster is something I setup to learn more about HPC. My initial project is using the cluster to do astrophotography image…</description>
      <content:encoded><![CDATA[
<figure><img decoding="async" width="1920" height="1440" src="https://damenknight.com/images/image-2-1920x1440.jpg" alt=""   /></figure>



<figure><img decoding="async" width="846" height="545" src="https://damenknight.com/images/image-1.png" alt=""   /></figure>



<details><summary>Control Node</summary>
<p>VM running on PowerEdge R730<br>Rocky Linux 9.5<br>Warewulf 6.4</p>



<p></p>
</details>



<details><summary>Compute Nodes</summary>
<p>1x MeLE Quieter 3C<br>* Intel N100<br>* 16GB RAM<br><br>3x MeLE Quieter 4C<br>* Intel N100<br>* 16GB RAM</p>
</details>



<details><summary>Connectivity</summary>
<p>TrendNet Switch</p>
</details>



<p>This cluster is something I setup to learn more about HPC.  My initial project is using the cluster to do astrophotography image pre-processing/calibrating/integrating.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Obligatory HomeLab Writeup</title>
      <link>https://damenknight.com/obligatory-homelab-writeup/</link>
      <guid isPermaLink="true">https://damenknight.com/obligatory-homelab-writeup/</guid>
      <pubDate>Sat, 15 Mar 2025 16:41:12 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <description>Don’t judge my local fire-hazard. I’ll get this out of the way first: You do not want rack servers in your home. They’re *really* loud. I am just a crazy…</description>
      <content:encoded><![CDATA[
<figure><img decoding="async" width="810" height="1080" src="https://damenknight.com/images/image-810x1080.jpg" alt=""   /></figure>



<p class="has-text-align-center has-small-font-size">Don’t judge my local fire-hazard. </p>



<p>I’ll get this out of the way first: You do not want rack servers in your home.  They’re *<strong>really</strong>* loud.  I am just a crazy person.</p>



<p></p>



<p>Ok, now that that’s out of the way, let’s talk about what we’ve got.  Starting with the items actually installed in the rack from the bottom and working our way up:</p>



<details><summary>Synology RS2421+</summary>
<p><strong>Hardware:</strong><br>12x 18TB Seagate Exos HDDs (SHR)<br>2x 1TB SSD Cache (RAID 1)<br>32GB RAM<br>10Gbe NIC<br><br><strong>Services:</strong><br>DHCP<br>Temporary Web Hosting<br>NAS</p>



<p></p>



<p></p>
</details>



<details><summary>Dell PowerEdge R730</summary>
<p><strong>Hardware:</strong><br>2x 1TB SAS SSDs (RAID 1)<br>6x 1TB SAS SSDs (RAID 10)<br>768GB RAM<br>2x Xeon E5-2699(v4)<br>2x 10Gbe NIC<br>2x 1Gbe NIC<br>1x nVidia Tesla P4<br>1x nVidia Tesla T4<br><br><strong>Services:</strong><br>Proxmox<br>Ollama<br>Prod Web Hosting<br>Media Management<br>Frigate<br>WareWulf Cluster Management</p>
</details>



<details><summary>Dell PowerEdge R730xd</summary>
<p><strong>Hardware:</strong><br>2x 1TB SATA SSDs (ZFS Mirror)<br>12x 16TB Seagate Exos SATA HDDs (ZFS Striped Mirror)<br>768GB RAM<br>2x Xeon E5-2680(v3)<br>2x 10Gbe NIC<br>2x 1Gbe NIC<br>1x LSI SAS9305-16e HBA (attached to the PowerVault MD1400)<br><br><strong>Services:</strong><br>TrueNas<br>NAS<br>Immich<br>Minio<br>Tailscale<br>Uptime-Kuma<br>Postgres</p>



<p></p>
</details>



<details><summary>Dell PowerVault MD1400</summary>
<p><strong>Hardware</strong>:<br>6x 18TB Seagate Exos SAS HDDs (ZFS Striped Mirror)</p>
</details>



<details><summary>Juniper EX4550-32T-AFI</summary>
<p>10Gbe Managed Switch</p>
</details>



<details><summary>CyberPower CPS1215RM </summary>
<p>It’s a PDU, what do you want?</p>
</details>



<p>On top of the rack things get a little spicier</p>



<figure><img decoding="async" width="1920" height="1440" src="https://damenknight.com/images/image-1-1920x1440.jpg" alt=""   /></figure>



<details><summary>WareWulf Cluster</summary>
<p><strong>Hardware</strong>:<br>1x MeLE Quieter 3C MiniPC<br>3x MeLE Quieter 4C MiniPC<br>1x TRENDnet Switch (connects to 1Gbe NIC in the PowerEdge R730 for the WareWulf Controller)<br><br><strong>Services:</strong><br>Test environment for HPC<br><br><a href="https://damenknight.com/warewulf-home-lab-setup/">Details here.</a></p>
</details>



<details><summary>Plex Server</summary>
<p><strong>Hardware:</strong><br>BeeLink EQ12 MiniPC<br><br><strong>Services:</strong><br>Plex</p>
</details>



<details><summary>Home Assistant</summary>
<p><strong>Hardware:</strong><br>1x Home Assistant Green<br><br><strong>Services:</strong><br>Home Assistant</p>
</details>



<p>Moving beyond the rack entirely we have have the router/firewall</p>



<details><summary>Custom SFF PC</summary>
<p><strong>Hardware:</strong><br>1x Core i5-12400<br>64GB RAM<br>1x 2.5Gbe NIC (to internet)<br>1x 10Gbe NIC (to LAN)<br><br><strong>Services:</strong><br>PFSense<br>DNS<br>pfBlockerNG<br></p>
</details>



<p>Finally we have the ridiculous power distribution/UPS setup.  I wont go into too much detail here, but there are 2x 1500VA APC UPSes, and everything with redundant power is split between the two.  Those two UPSes are themselves plugged into a Jackery 5000 power station.</p>



<p>The rack itself is just a VEVOR 12U open frame rack.</p>



<p>I left out a few things – I’ve got a raspberry pi 5 running an allsky camera in my backyard (<a href="https://allsky.miscellaneousnerdery.com/">https://allsky.miscellaneousnerdery.com/</a>), there’s a sonos node, a smart things node, a philips node, a govee node, etc etc etc.  I may go into detail on those at some point, if I ever build out a full network map to share – it has gotten a lot more complicated since my last post.</p>



<p>In my next post I’ll go into the various services I’m running in more detail, along with how they’re all setup and what I’m using them for.  </p>



<p></p>
]]></content:encoded>
    </item>
    <item>
      <title>Mounting my miniPC and Powerbox</title>
      <link>https://damenknight.com/mounting-my-minipc-and-powerbox/</link>
      <guid isPermaLink="true">https://damenknight.com/mounting-my-minipc-and-powerbox/</guid>
      <pubDate>Mon, 29 Jan 2024 14:43:16 GMT</pubDate>
      <category>Astrophotography</category>
      <description>I recently got a Pegasus Pocket PowerBox Advanced for my astro setup, and while I was at it I moved my miniPC up to attach on the telescope instead of onto my…</description>
      <content:encoded><![CDATA[
<p>I recently got a Pegasus Pocket PowerBox Advanced for my astro setup, and while I was at it I moved my miniPC up to attach on the telescope instead of onto my mount.  </p>



<p>I did this using the accessories from BuckEye Stargazer.  Unfortunately, there are not a ton of guides or instructions available for how everything goes together so I went ahead and filmed a quick video going over the process.</p>



<figure><div>
<iframe loading="lazy" title="Mounting the Pegasus Astro Pocket Powerbox Advance (and miniPC) with the Buckeye Stargazer adapters" width="500" height="281" src="https://www.youtube.com/embed/wqQ-__FTluE?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div></figure>
]]></content:encoded>
    </item>
    <item>
      <title>Achieving better telescope balance</title>
      <link>https://damenknight.com/achieving-better-telescope-balance/</link>
      <guid isPermaLink="true">https://damenknight.com/achieving-better-telescope-balance/</guid>
      <pubDate>Thu, 25 Jan 2024 17:25:34 GMT</pubDate>
      <category>Astrophotography</category>
      <description>On the astrophotography Discord server I hang out on I’ve seen lots and lots of folks struggle with balancing their telescope. I think a big part of the reason…</description>
      <content:encoded><![CDATA[
<figure><div>
<iframe loading="lazy" title="Your telescope is out of balance and you dont even know it!" width="500" height="281" src="https://www.youtube.com/embed/dFjvplKKrKI?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div></figure>



<p>On the astrophotography Discord server I hang out on I’ve seen lots and lots of folks struggle with balancing their telescope.  I think a big part of the reason why this is so difficult is that a lot of the descriptions of what good balance is are vague or non-existent.  So I went ahead and made a quick video to help show what “good” balance looks like.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Network Setup</title>
      <link>https://damenknight.com/network-setup/</link>
      <guid isPermaLink="true">https://damenknight.com/network-setup/</guid>
      <pubDate>Mon, 22 Jan 2024 18:39:44 GMT</pubDate>
      <category>Homelab</category>
      <category>Projects</category>
      <description>WiFi: NetGear Orbi RBRE960 (AP mode, 3AP Mesh)Router: Custom MiniPC pfSense Router (10GBit LAN, 2.5GBit WAN)</description>
      <content:encoded><![CDATA[
<figure><img decoding="async" width="844" height="934" src="https://damenknight.com/images/network-diagram.png" alt=""   /></figure>



<p><strong>WiFi:</strong> NetGear Orbi RBRE960 (AP mode, 3AP Mesh)<br><strong>Router:</strong> Custom MiniPC pfSense Router (10GBit LAN, 2.5GBit WAN)<br></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
