<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://technicomplabs.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://technicomplabs.io/" rel="alternate" type="text/html" /><updated>2026-08-08T15:57:41-04:00</updated><id>https://technicomplabs.io/feed.xml</id><title type="html">Technicomp Labs</title><subtitle>Deep dives on systems performance, LLM inference, and applied machine learning — with real data from the lab.</subtitle><author><name>Paul D. Martin, Ph.D.</name></author><entry><title type="html">Finding the Wall: How to Actually Test LLM Inference Performance</title><link href="https://technicomplabs.io/posts/2026/08/finding-the-wall/" rel="alternate" type="text/html" title="Finding the Wall: How to Actually Test LLM Inference Performance" /><published>2026-08-08T00:00:00-04:00</published><updated>2026-08-08T00:00:00-04:00</updated><id>https://technicomplabs.io/posts/2026/08/finding-the-wall-llm-inference-performance</id><content type="html" xml:base="https://technicomplabs.io/posts/2026/08/finding-the-wall/"><![CDATA[<p>Most “how fast is my local LLM” numbers are vibes. Someone changes a flag, the tokens/second wiggles, and a conclusion gets posted. Then someone else can’t reproduce it, because the number never came from a model of the machine — it came from a single run with an unstated microbatch and a background compile eating three cores.</p>

<p>This is the method I actually use, demonstrated end to end on one machine. The rule underneath it: <strong>establish the physical ceiling, predict what the software should reach, then change one variable at a time and explain every number — and keep the hypotheses that got refuted, because they save more time than the ones that held.</strong></p>

<p>The machine is <strong>Galactus</strong>: an AMD EPYC 7713 (64 cores / 128 threads, Zen 3), 2 TB of DDR4-2933 across 8 channels, and 4 × AMD Radeon Pro V620, running llama.cpp and ROCm in a container on Proxmox. The workload is hybrid Mixture-of-Experts inference — routed experts held in system RAM, the dense path (attention, shared experts, KV cache) on the GPUs. The headline model is <strong>GLM-5.2</strong> (753 B parameters, 435 GiB at Q4), with <strong>DeepSeek-V4-Flash</strong> along for the speculative-decode section.</p>

<p>By the end, GLM-5.2 prefill went from 37.6 to 119.4 tokens/second and decode from 5.2 to 7.1 — and, more importantly, both ceilings are explained rather than stumbled into.</p>

<h2 id="step-1-start-at-the-wall-not-the-model">Step 1: Start at the wall, not the model</h2>

<p>For CPU-resident MoE, decode speed is set by one thing: how fast the active experts can be read out of DRAM every token. So the first measurement isn’t a model benchmark at all. It’s memory bandwidth.</p>

<p>I run <a href="https://www.cs.virginia.edu/stream/">STREAM</a> as a thread sweep, then apply the <strong>read-for-ownership (RFO) correction</strong>. STREAM undercounts write traffic, because an ordinary store first has to read the cache line it’s about to overwrite — traffic STREAM never counts. The fix is to scale the write-heavy kernels: Scale ×1.5, Add and Triad ×4/3. Copy needs no correction <em>if</em> the compiler turned it into non-temporal stores.</p>

<p>There’s a built-in sanity check: uncorrected-Copy plus the RFO correction must not exceed the theoretical ceiling. On Galactus it would have — proof that Copy used non-temporal stores and shouldn’t be corrected. With that resolved, all four kernels converge:</p>

<p><strong>~152 GB/s</strong>, which is 81% of the 187.7 GB/s theoretical peak for 8-channel DDR4-2933.</p>

<p>Two things already fall out of this one number. Bandwidth <em>saturates at 16 threads</em> and declines past it — so decode has no reason to want all 64 cores. And 81% of theoretical is a healthy platform; if it had come back at 50%, the story would’ve been “fix your memory topology,” not “tune llama.cpp.”</p>

<h2 id="step-2-predict-decode-before-you-measure-it">Step 2: Predict decode before you measure it</h2>

<p>Now I use the wall to predict the model, with a two-term model of a decode step:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>time_per_token ≈ C + (bytes_read_per_token ÷ bandwidth)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">C</code> is a fixed GPU-side cost you measure once. <code class="language-plaintext highlighter-rouge">bytes_read_per_token</code> is the active-expert footprint at your quant. For GLM-5.2, <code class="language-plaintext highlighter-rouge">C ≈ 90 ms</code> and the experts read ≈ 13.8 GB/token, so:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>90 ms + (13.8 GB ÷ 152 GB/s) ≈ 90 + 91 = 181 ms  →  ~5.5 t/s
</code></pre></div></div>

<p>Measured: <strong>5.53 t/s</strong>. I ran the same model against two more configurations (a VRAM-filled “fitter” setup and a CPU-only denominator); it predicted <strong>6.2 and 3.9</strong>, and measured <strong>6.01 and 3.87</strong>. When the model and the machine agree to within a few percent across three configs, you’re not guessing anymore — you know decode is bandwidth-bound, and you know roughly what any change <em>can</em> buy before you run it. No amount of CPU tuning will move a wall made of DRAM.</p>

<h2 id="step-3-the-iterative-loop--one-variable-at-a-time">Step 3: The iterative loop — one variable at a time</h2>

<p>With the ceiling known, the loop is simple to state and easy to botch:</p>

<ol>
  <li><strong>Baseline</strong>, measured correctly.</li>
  <li><strong>Change one variable</strong>, and <strong>sweep it</strong> — read the shape, not just the peak.</li>
  <li><strong>Next variable</strong>, carrying forward the previous winner <em>only once you know why it won.</em></li>
</ol>

<p>The thread sweep shows why you sweep instead of guessing “more is better”:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Threads</th>
      <th style="text-align: right">GLM-5.2 decode (t/s)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">24–32</td>
      <td style="text-align: right">~5.5 (peak)</td>
    </tr>
    <tr>
      <td style="text-align: right">96</td>
      <td style="text-align: right">2.76</td>
    </tr>
    <tr>
      <td style="text-align: right">128</td>
      <td style="text-align: right">1.29</td>
    </tr>
  </tbody>
</table>

<p>Decode peaks around half the physical cores and <em>collapses</em> once you schedule onto SMT siblings. Prefill, meanwhile, keeps climbing with threads — a different curve for the same knob, which is exactly why you don’t reuse a decode-optimal setting for a prefill test.</p>

<h3 id="the-war-story-how-one-default-poisoned-a-day-of-results">The war story: how one default poisoned a day of results</h3>

<p>Then the microbatch sweep, which is where I got humbled:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">n_ubatch</th>
      <th style="text-align: right">GLM-5.2 pp8192 (t/s)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">512</td>
      <td style="text-align: right">25.90</td>
    </tr>
    <tr>
      <td style="text-align: right">1024</td>
      <td style="text-align: right">41.68</td>
    </tr>
    <tr>
      <td style="text-align: right">2048</td>
      <td style="text-align: right">62.64</td>
    </tr>
    <tr>
      <td style="text-align: right">4096</td>
      <td style="text-align: right">84.62</td>
    </tr>
    <tr>
      <td style="text-align: right">8192</td>
      <td style="text-align: right">104.97</td>
    </tr>
  </tbody>
</table>

<p>That’s a <strong>4× swing</strong> on a single knob. And here’s the trap: <code class="language-plaintext highlighter-rouge">llama-bench</code>’s <code class="language-plaintext highlighter-rouge">-p</code> flag <em>silently clamps</em> <code class="language-plaintext highlighter-rouge">n_ubatch</code> to the prompt length. Every “op_offload” prefill number I’d taken that morning had a <code class="language-plaintext highlighter-rouge">-p 512</code> inherited from an old script — so they’d all secretly run at ub 512, not the ub 8192 I thought I was testing. A day of numbers, quietly invalidated by one default. Set <code class="language-plaintext highlighter-rouge">-ub</code> explicitly, every single time, and never trust a prefill number until you’ve confirmed the effective microbatch.</p>

<h2 id="step-4-the-dependency-tree-of-changes">Step 4: The dependency tree of changes</h2>

<p>The reason that microbatch bug was so costly is that changes aren’t independent — they sit in a tree, and one setting can silently confound everything downstream. Before I trust a result, I place the variable in that tree:</p>

<ul>
  <li><strong>Some changes poison everything upstream of them.</strong> <code class="language-plaintext highlighter-rouge">-p</code> clamping <code class="language-plaintext highlighter-rouge">n_ubatch</code> is the poster child.</li>
  <li><strong>Some changes only help in one regime.</strong> <code class="language-plaintext highlighter-rouge">op_offload</code> is a net <em>loss</em> at small microbatch (the per-microbatch streaming cost dominates) and a big win at ub 8192. Test a change where it’s actually able to win.</li>
  <li><strong>Some changes touch only one phase.</strong> The scheduler patch below moves prefill and leaves decode untouched — so if decode had regressed, that’d be a bug, not a tradeoff.</li>
  <li><strong>Some paths look equivalent and aren’t.</strong> Pinning experts resident on a GPU via <code class="language-plaintext highlighter-rouge">-ot</code> placement is a <em>different code path</em> from <code class="language-plaintext highlighter-rouge">op_offload</code>, and on GLM-5.2 it measured 17% slower. “Same idea” is not “same performance.”</li>
</ul>

<p>Draw the tree, and a later measurement can’t be quietly sabotaged by an earlier flag.</p>

<h2 id="step-5-the-capstone--finding-a-llamacpp-patch-by-mechanism">Step 5: The capstone — finding a llama.cpp patch by mechanism</h2>

<p>This is the whole loop in one finding, and it’s my favorite because a <em>negative</em> result is what cracked it.</p>

<p><strong>Baseline:</strong> prefill plateaued at 104.97 t/s (ub 8192). Fast, but I didn’t know if that was the wall or an artifact. So instead of poking flags, I looked at the <em>mechanism</em> — the scheduler’s split histogram:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">GGML_SCHED_DEBUG</span><span class="o">=</span>2 llama-bench <span class="nt">-m</span> GLM-5.2-... <span class="nt">-ngl</span> 99 <span class="nt">-ot</span> <span class="s2">"exps=CPU"</span> <span class="nt">-fa</span> 1 <span class="nt">-v</span> <span class="se">\</span>
  <span class="nt">-t</span> 32 <span class="nt">-b</span> 512 <span class="nt">-ub</span> 512 <span class="nt">-p</span> 512 <span class="nt">-n</span> 0 <span class="nt">-r</span> 1 2&gt;&amp;1 | <span class="nb">grep</span> <span class="s1">'## SPLIT'</span> | <span class="nb">sort</span> | <span class="nb">uniq</span> <span class="nt">-c</span>
</code></pre></div></div>

<p>Result: <strong>731 of 1,186 GPU offload splits landed on a single card</strong> (ROCm0). Three of four GPUs were sitting idle during prefill. There’s the artifact.</p>

<p><strong>Hypothesis:</strong> distribute the offloaded expert matmuls across all four cards.</p>

<p><strong>Pre-registered null:</strong> I predicted, in writing, that distribution <em>alone</em> would do nothing — because the expert-weight copies were already asynchronous, and the thing actually serializing them was a per-split <code class="language-plaintext highlighter-rouge">synchronize</code> used to read the routing IDs. If that sync is the real bottleneck, spreading the work to four cards just gives you four streams that each still wait their turn. I built distribution-only first specifically to <em>try to refute my own hypothesis</em>: it came back <strong>105.71 vs 104.97</strong> — no change, exactly as predicted. The histogram equalized perfectly while throughput didn’t budge, which is the tell that you’ve moved a mechanism without moving the bottleneck.</p>

<p><strong>The real fix:</strong> at prefill-sized batches, essentially every expert is used by <em>some</em> token, so reading the IDs to find “which experts are active” saves no bandwidth — it only imposes the serializing sync. Skip the read at large batch, mark all experts used, and the copies can issue immediately and overlap compute across cards. Three small edits to <code class="language-plaintext highlighter-rouge">ggml/src/ggml-backend.cpp</code>:</p>

<table>
  <thead>
    <tr>
      <th>Build</th>
      <th style="text-align: right">pp8192</th>
      <th style="text-align: right">pp16384</th>
      <th style="text-align: right">pp32768</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Stock (ub 8192)</td>
      <td style="text-align: right">104.97</td>
      <td style="text-align: right">—</td>
      <td style="text-align: right">—</td>
    </tr>
    <tr>
      <td>Distribution only</td>
      <td style="text-align: right">105.71</td>
      <td style="text-align: right">—</td>
      <td style="text-align: right">—</td>
    </tr>
    <tr>
      <td><strong>Distribution + ID-read bypass</strong></td>
      <td style="text-align: right"><strong>119.36</strong></td>
      <td style="text-align: right">86.11</td>
      <td style="text-align: right">55.76</td>
    </tr>
  </tbody>
</table>

<p><strong>+13.7%</strong>, histogram equalized to 285/300/294/292 across the cards. The full patch and reproduction steps are <a href="https://github.com/pauldmartinphd/llm-performance-engineering-notebook">in the repo</a>. The point for this post isn’t the 13.7% — it’s <em>how</em> it was found: predict, measure the mechanism, and let a refuted hypothesis point at the real cause.</p>

<h2 id="step-6-decode-gains-live-in-speculation">Step 6: Decode gains live in speculation</h2>

<p>Prefill was scheduling; decode is physics. The two-term model already told me decode was pinned to the DRAM wall, and no CPU knob — threads, affinity, polling, hugepages, repacked kernels — moved it, because none of them change bytes or bandwidth. The only lever that reads <em>fewer</em> bytes per accepted token is <strong>speculative decoding</strong>.</p>

<ul>
  <li><strong>GLM-5.2</strong> gained multi-token-prediction support upstream (<code class="language-plaintext highlighter-rouge">--spec-type draft-mtp</code>). Sweeping draft depth: n=1 → 6.8, <strong>n=2 → 7.1</strong>, n=3 → 6.9 t/s. Locked at n=2: <strong>+31%</strong>, and past the 7 t/s reading-speed bar for the first time.</li>
  <li><strong>DeepSeek-V4-Flash-0731</strong> with DSpark (<code class="language-plaintext highlighter-rouge">--spec-type draft-dspark</code>, a block-5 drafter in VRAM) went further: baseline ~10.1 → <strong>14.7 t/s at n=3</strong>, about <strong>+45%</strong> — the fastest decode this machine has produced on any model.</li>
</ul>

<p>The depth curves have a shape worth internalizing. Both peak at n=2–3 and <em>regress</em> by n=5: on a top-k-of-many MoE, each drafted token activates a nearly disjoint set of experts, so deep speculation pays more expert-read bytes than its acceptance rate earns back. Same verify-tax the model predicts; you can see it in the numbers.</p>

<h2 id="step-7-record-everything--including-the-nos">Step 7: Record everything — including the “no”s</h2>

<p>Every run gets logged with its <em>full</em> configuration — date, exact flags, build, metric, value, source line. A tokens/second figure without its flags and build isn’t a result; it’s an anecdote.</p>

<p>And I log the dead ends deliberately. Over this investigation, all of these were measured and <strong>refuted</strong> for this workload: NUMA imbalance, container overhead, threadpool polling, strict CPU affinity, <code class="language-plaintext highlighter-rouge">CPU_REPACK</code>, transparent hugepages, <code class="language-plaintext highlighter-rouge">-sm row</code>, pipeline parallelism, ZenDNN, and HIP managed memory. That list is the single most useful thing the whole effort produced, because every “no” is a road the next person doesn’t have to drive down.</p>

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

<p>The specific numbers here are Galactus’s. The method isn’t:</p>

<ol>
  <li><strong>Measure the physical ceiling</strong> (STREAM + RFO).</li>
  <li><strong>Predict</strong> the workload from it (the two-term model).</li>
  <li><strong>Sweep one variable at a time</strong>, reading shapes.</li>
  <li><strong>Place each change in the dependency tree</strong> so nothing is confounded.</li>
  <li><strong>Debug by mechanism</strong>, and trust refuted hypotheses.</li>
  <li><strong>Record results and the “no”s.</strong></li>
</ol>

<p>That’s the difference between “I changed a flag and it got faster” and “I know what this machine can do, and why.” The terse, copy-pasteable version of this procedure — and every benchmark row behind these numbers — lives in the <a href="https://github.com/pauldmartinphd/llm-performance-engineering-notebook">llm-performance-engineering-notebook repo</a>.</p>]]></content><author><name>Paul D. Martin, Ph.D.</name></author><category term="performance" /><category term="llama.cpp" /><category term="moe" /><category term="llm-inference" /><category term="epyc" /><category term="benchmarking" /><summary type="html"><![CDATA[A repeatable method for finding a machine's real inference limits — demonstrated on a 64-core EPYC server running 750B-parameter MoE models, from raw memory bandwidth to a llama.cpp scheduler patch that took prefill from 105 to 119 tokens/second.]]></summary></entry></feed>