<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Alexander Kerchum]]></title><description><![CDATA[Alexander Kerchum]]></description><link>https://blog.kerchum.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 16 Sep 2026 04:06:50 GMT</lastBuildDate><atom:link href="https://blog.kerchum.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Lessons From the Bottom of the Stack: Shipping a Quant]]></title><description><![CDATA[The SCLP compression algorithm — palette the exponents, sidecar the outliers, pack the rest — was a week or so of prototyping. The two posts before this one covered it end to end. This post is about t]]></description><link>https://blog.kerchum.dev/lessons-from-the-bottom-of-the-stack-shipping-a-quant</link><guid isPermaLink="true">https://blog.kerchum.dev/lessons-from-the-bottom-of-the-stack-shipping-a-quant</guid><category><![CDATA[AI]]></category><category><![CDATA[quantization]]></category><category><![CDATA[llamacpp]]></category><category><![CDATA[Machine Learning]]></category><dc:creator><![CDATA[Alexander Kerchum]]></dc:creator><pubDate>Fri, 05 Jun 2026 19:52:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/614f878194bb297e1aad7495/60265a4c-e8dd-4cc5-afaf-094e723d3c96.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>The SCLP compression algorithm — palette the exponents, sidecar the outliers, pack the rest — was a week or so of prototyping. The two posts before this one covered it end to end. This post is about the other two weeks: getting it to run inside llama.cpp, on real models, fast, without corrupting output or wedging the GPU. Almost none of that work was about compression. It was about the stack underneath, which is where the time actually goes.</p>
<p>It also ran against a hard wall the whole time: a single 24 GB consumer GPU. That ceiling is why so much of what follows is about <em>bytes</em> — VRAM allocation that has to be exact, MoE temp buffers you can't afford, a last-tensor upload that's a few hundred MB too small. The constraint was real enough that we priced out slotting a second card (a 16 GB Vega64 or RX 6800 from another machine) just to offload a few layers, and watched offloading one layer too many hang the entire desktop for a minute at a time. When you have no headroom, every sizing bug is fatal instead of merely wasteful. Most of the bugs below are sizing bugs.</p>
<p>Here are the lessons, each one learned the hard way.</p>
<h2>The metadata plumbing is the real work</h2>
<p>Adding a weight type to llama.cpp sounds like one enum and a decode function. It is not. A tensor's <em>size</em> is computed, checked, and assumed in a dozen places — allocation, mmap, the GGUF loader, graph planning, the VRAM budget. Every one of them assumes size is a closed-form function of shape and type (<code>ggml_nbytes</code>). An SCLP blob is none of those things: it's stored at its <em>actual</em> compressed size, which depends on how many outliers landed in the sidecar, which depends on the weights.</p>
<p>So the loader has to infer each blob's real size — its <code>disk_size</code> — from the gap between consecutive tensor offsets in the file, and thread that number through every path that previously trusted <code>ggml_nbytes</code>. That single concept (disk_size ≠ nbytes) touched four files and was the source of the worst bug in the project. Plumbing a new <em>idea</em> about size through a codebase that has one hardcoded everywhere is the actual work. The compression was the easy part.</p>
<h2>The week-long bug: a silent size mismatch</h2>
<p>SCLP4 prefill stalled. It didn't crash — it <em>stalled</em>, dropping to 0.16 t/s with the GPU pinned but making no progress. <code>llama-bench</code> hung the same way. Single token generation was fine; only the many-token path died.</p>
<p>The chain: the VRAM allocator reserves space per tensor using <code>disk_size</code>. The <em>last</em> tensor in the file has no "next offset" to subtract from, so its size fell back to <code>ggml_nbytes</code> — which is <em>smaller</em> than the compact SCLP blob. The upload was silently truncated. The GPU didn't crash; it just ground forever on bogus work.</p>
<p>Three things made this expensive to find. It only fired at M &gt; 1 (prefill), because token generation used a different code path. The symptom was a hang, not an error — nothing logged, nothing threw. And the most promising clue was a trap: profiling showed a 430,940,160-byte (~411 MB) host-to-device transfer right at the stall, and that number matched the Q6_K <code>output.weight</code> buffer <em>exactly</em>. It looked like the smoking gun — until we noticed SCLP6 issued the identical 411 MB transfer and never stalled. The fingerprint was real — that transfer genuinely happened — but it pointed at the wrong tensor: <code>output.weight</code> wasn't what stalled us.</p>
<p>What had actually broken was sizing, in the loader. The missing case was the final tensor: with no next offset available, its inferred on-disk size used the old <code>ggml_nbytes</code> fallback instead of the real file span. The decode kernel then read past the truncated upload, picked up a garbage <code>sidecar_count</code>, and scatter-wrote phantom corrections forever. The fix is one line: derive the last tensor's size from the file boundary (<code>file_size − data_offset − tensor_offset</code>) instead of falling back to <code>ggml_nbytes</code>. Finding it took several days of bisecting which tensor, which batch size, which kernel — and learning to distrust the one number that looked like an answer.</p>
<p>The takeaway: a silent size mismatch doesn't crash, it corrupts — and corruption that happens to spin the GPU looks exactly like a performance problem until you stop trusting that framing.</p>
<h2>Trust measurements over assumptions</h2>
<p>We had three plausible optimizations that <em>should</em> have worked by every rule of thumb. All three were measured, and all three were dead ends.</p>
<ul>
<li><strong>F16 decode intermediate.</strong> The idea was to decode SCLP to F16 instead of BF16 for the two-pass prefill, on the theory that more mantissa would make the GEMM happier. It measured <em>identical</em> to BF16 on RDNA3, because the GEMM is memory-bound at these shapes and the mantissa bits don't move the needle. We won't retry it.</li>
<li><strong>hipBLASLt.</strong> The newer BLAS path is supposed to be faster. On this hardware it came in at <strong>−4% on dense and −31% on the SCLP MoE GEMM</strong> — decisively slower, so we left it off.</li>
<li><strong>Fusing decode into the prefill GEMM.</strong> The obvious win was to skip the BF16 intermediate at M &gt; 1 too and decode straight into the matmul. It worked on dense models. On MoE it produced catastrophic perplexity — and untangling <em>why</em> took a detour. The first suspect was a real bug: the fused MoE path corrupted every expert bin past the first (<code>b≥1</code> tiles were garbage), traced to a wrong <code>expert_offsets</code> stride after the route-sort. Fixing the stride made the corruption vanish — and revealed the <em>actual</em> wall underneath. With routing correct, the fused result still diverged from the two-pass result by a deterministic amount, and it was pure arithmetic: the F32 accumulation order of a tensor-core tile differs from scalar sequential summation by ~1e-3 per multiply, and across 26 MoE layers that compounds. So there were two failures stacked — a fixable indexing bug hiding an unfixable ordering difference. Matching rocBLAS's exact tile order is possible but not worth a ~20% prefill win.</li>
</ul>
<p>That made three confident guesses, three measurements, and three reversals. The pattern is the lesson: on a specific GPU, with a specific BLAS, at specific shapes, your intuition about what's faster is a hypothesis, not a fact. Measure before you commit, and <em>write down the dead ends</em> so they stay dead.</p>
<p>(One smaller version of the same lesson: mixing static <code>__shared__</code> with dynamic <code>extern __shared__</code> in a HIP kernel caused a <strong>3× regression</strong> on RDNA3. Pure-dynamic shared memory fixed it. Nothing warned us; the profiler did.)</p>
<h2>Perplexity can hide collapse — smoke-test for it</h2>
<p>Post 2's mode-collapse story ("own own own own...") had a sharp edge worth isolating. That model's decode was byte-perfect across 508 million weights, and its perplexity number looked bad but not obviously broken on our OOD scale. Perplexity averages log-likelihood over a corpus; a model that has quietly collapsed into repeating one token can still post a number that looks merely <em>bad</em> rather than <em>broken</em> — especially when, as we found, OOD wikitext perplexity is inflated ~50× and you've trained yourself to read big numbers as normal.</p>
<p>The only thing that reliably caught collapse was generating a couple hundred tokens of actual chat and reading them. So that became a hard gate: no quant is "good" until it has produced 200+ coherent tokens to a real prompt. A perplexity table is necessary but not sufficient — the inexpensive qualitative check is the one that catches the failure the metric hides.</p>
<h2>Don't <code>kill -9</code> on WSL2/ROCm</h2>
<p>A practical one that cost real hours. This work ran on WSL2 with ROCm. If you <code>kill -9</code> a llama process while it's mid-GPU-kernel, the ROCm runtime doesn't clean up — the GPU wedges, and <em>everything</em> afterward drops to ~0.16 t/s (the same number as the truncation bug, which made triage briefly confusing). In our setup, the reliable recovery was <code>wsl --shutdown</code>.</p>
<p>So: bound every GPU job with <code>timeout</code> instead of reaching for <code>kill</code>, and never SIGKILL a process that's touching the device. On WSL2 the GPU isn't a resource the OS will reclaim for you. Treat a wedged GPU as a possible <em>environment</em> state, not only a code bug — we spent time hunting a regression that was really a leftover wedge from a previous <code>kill -9</code>.</p>
<p>That ~0.16 t/s reading turned out to be badly overloaded: <em>three</em> unrelated failures all produced it: the truncated last-tensor upload, a genuine <code>kill -9</code> GPU wedge, and — the one that fooled us longest — running the fused GEMV on an old model whose sidecar wasn't sorted. The fused kernel binary-searches each row's sidecar range assuming the entries are sorted by index; on an unsorted blob the search returns a bogus enormous range, and every row grinds through millions of phantom corrections — producing the same 0.16 t/s. We first diagnosed it as a wedge and reached for <code>wsl --shutdown</code> — which of course "fixed" nothing, because the next run loaded the same stale model. The lesson stacks on the truncation one: when three different bugs share a symptom, the symptom tells you almost nothing. You have to find the fingerprint that distinguishes them.</p>
<h2>What the bottom of the stack taught us</h2>
<p>Step back and the lessons rhyme:</p>
<ol>
<li><strong>A new invariant is expensive to introduce.</strong> "Size isn't <code>ggml_nbytes</code> anymore" was one sentence, four files, and the worst bug.</li>
<li><strong>Silent corruption masquerades as slowness.</strong> A truncated upload, an unsorted sidecar, and a genuine GPU wedge all produced the same 0.16 t/s — three different bugs, one symptom. Distrust the performance framing of a hang; chase the fingerprint, not the speed.</li>
<li><strong>Your speed intuitions are hypotheses.</strong> F16, hipBLASLt, fused prefill — three "obviously faster" ideas, three losses on measurement.</li>
<li><strong>The cheap eval is the one you skip and the one that catches the disaster.</strong> Byte-perfect decode and a not-even-scary perplexity both passed while the model said "own own own."</li>
<li><strong>The environment has state.</strong> On WSL2/ROCm a kill can outlive its process.</li>
</ol>
<p>None of this is about exponents or palettes. It's the tax on shipping a format into a mature inference engine on consumer hardware — and it dwarfed the algorithm that started the whole thing. SCLP does what it set out to do: on the models we tuned for, 4-bit weights that match or beat the standard integer quant on quality per byte for decode-bound workloads, running coherently on a sub-$1,000 GPU. Getting there was mostly the work in this post, not the work in the first two. That's usually how it goes.</p>
<h2>Postscript: trying it on Gemma 4 12B</h2>
<p>While we were writing this post, Google released Gemma 4 12B, so we ran SCLP on it. The hypothesis was modest: perhaps we could compress it a little further than the standard quants, but because a 12B model isn't memory-constrained on a 24 GB card — it fits comfortably even at 8-bit — the bandwidth advantage SCLP relies on probably wouldn't materialize.</p>
<p>It didn't, and the result illustrates this post's running theme cleanly enough to be worth showing in full. Every configuration below was built with an imatrix from in-domain traces; perplexity is measured on a held-out out-of-domain set (lower is better, and as Post 2 noted these OOD numbers run roughly 50× inflated — read the rankings, not the absolutes). Throughput is <code>llama-bench</code> on the RX 7900 XTX, fully offloaded.</p>
<table>
<thead>
<tr>
<th>Quant</th>
<th>Size</th>
<th>OOD PPL</th>
<th>Prefill (t/s)</th>
<th>Generation (t/s)</th>
</tr>
</thead>
<tbody><tr>
<td>Q4_K_M</td>
<td>6.87 GiB</td>
<td>55.1</td>
<td>1921</td>
<td><strong>55.7</strong></td>
</tr>
<tr>
<td>SCLP4</td>
<td>8.27 GiB</td>
<td>646.5</td>
<td>1815</td>
<td>22.5</td>
</tr>
<tr>
<td>MIXED-Q4 (SCLP6 attn+down, Q4_K gate/up)</td>
<td>9.17 GiB</td>
<td>57.9</td>
<td>1981</td>
<td>32.5</td>
</tr>
<tr>
<td>MIXED (SCLP6 attn+down, SCLP4 gate/up)</td>
<td>9.51 GiB</td>
<td>64.9</td>
<td>1817</td>
<td>23.2</td>
</tr>
<tr>
<td>SCLP6</td>
<td>10.86 GiB</td>
<td>57.7</td>
<td>1940</td>
<td>23.5</td>
</tr>
<tr>
<td>Q8_0</td>
<td>11.80 GiB</td>
<td>54.8</td>
<td>2134</td>
<td><strong>46.1</strong></td>
</tr>
<tr>
<td>SCLP8</td>
<td>12.70 GiB</td>
<td>55.0</td>
<td>1928</td>
<td>26.5</td>
</tr>
</tbody></table>
<p>Two head-to-heads tell the story. At 8 bits, Q8_0 beats SCLP8 on every axis: it is smaller (it quantizes the embeddings too, which SCLP keeps at BF16), matches perplexity, and generates 1.7× faster. At 4 bits, Q4_K_M is both smaller than SCLP4 and far more accurate — pure SCLP4 puts 4-bit precision on the attention projections, which collapses quality (the same failure mode from Post 2; the MIXED recipe, which protects attention with SCLP6, recovers most of it). SCLP did not even win on size: the imatrix sidecar plus native embeddings push every SCLP build above its standard-quant counterpart.</p>
<p>The reason is the subject of this entire post — measure, don't assume. SCLP's advantage is bandwidth: fewer bytes read per weight at generation time, which converts to speed only when bandwidth is the bottleneck. On a model that fits in VRAM with room to spare, the standard quants' INT8 dp4a GEMV simply runs faster than SCLP's decode-and-multiply kernel, and the extra arithmetic SCLP spends on palette lookups and sidecar corrections is pure overhead rather than a trade against saved memory traffic. The wins in Posts 1 and 2 came from models pushed against the VRAM ceiling — and, for the 4-bit quality-per-byte result, from a mixture-of-experts architecture. Neither condition holds here, and the numbers reflect that precisely.</p>
<p>That is not a disappointing result so much as a sharp one: it marks where SCLP belongs. For a model too large for your card, every byte trimmed from the weight read is bandwidth returned. When the model already fits, the standard quant is the better tool.</p>
<hr />
<p><em>SCLP is open source. The reference implementation (Python + HIP kernels) is at <a href="https://github.com/KerchumA222/sclp">github.com/KerchumA222/sclp</a>. The llama.cpp fork with GPU inference is at <a href="https://github.com/KerchumA222/llama.cpp">github.com/KerchumA222/llama.cpp</a>, branch <code>sclp</code>. Both target AMD RDNA3 (ROCm/HIP) — CUDA porting is straightforward but not yet done.</em></p>
]]></content:encoded></item><item><title><![CDATA[From 8 Bits to 4: Sidecar, MoE, and the imatrix Trick That Worked]]></title><description><![CDATA[Last time we cut BF16 weights in half by treating the exponent as a 16-entry palette instead of an 8-bit field. SCLP8: 7.9 GB instead of 15.0, perplexity slightly better than the original, token gener]]></description><link>https://blog.kerchum.dev/from-8-bits-to-4-sidecar-moe-and-the-imatrix-trick-that-worked</link><guid isPermaLink="true">https://blog.kerchum.dev/from-8-bits-to-4-sidecar-moe-and-the-imatrix-trick-that-worked</guid><category><![CDATA[llm]]></category><category><![CDATA[Deep Learning]]></category><category><![CDATA[quantization]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Alexander Kerchum]]></dc:creator><pubDate>Wed, 03 Jun 2026 23:35:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/614f878194bb297e1aad7495/9b686f7e-f251-4f34-b7fb-e86b1b466efb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Last time we cut BF16 weights in half by treating the exponent as a 16-entry palette instead of an 8-bit field. SCLP8: 7.9 GB instead of 15.0, perplexity slightly <em>better</em> than the original, token generation at 43 t/s on an RX 7900 XTX.</p>
<p>8 bits per weight is the easy version. Half the bytes, a comfortable palette, a sidecar so small (0.01%) you can ignore it. The real question — the one that decides whether this is a clever trick or an actual competitor to INT4 — is what happens when you keep going. 6 bits. 5. 4.</p>
<p>Everything that was comfortable at 8 bits breaks at 4. Here's the order in which it broke, and what we did about each failure.</p>
<h2>The palette runs out of room</h2>
<p>The byte budget at each tier:</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Bits</th>
<th>Palette</th>
<th>Mantissa bits</th>
</tr>
</thead>
<tbody><tr>
<td>SCLP8</td>
<td>8</td>
<td>16 entries</td>
<td>3</td>
</tr>
<tr>
<td>SCLP6</td>
<td>6</td>
<td>8 entries</td>
<td>2</td>
</tr>
<tr>
<td>SCLP5</td>
<td>5</td>
<td>4 entries</td>
<td>2</td>
</tr>
<tr>
<td>SCLP4</td>
<td>4</td>
<td>4 entries</td>
<td>1</td>
</tr>
</tbody></table>
<p>At 8 bits you spend 4 on the palette index and 4 on sign+mantissa. At 4 bits you have to fit <em>everything</em> — index, sign, mantissa — into a single nibble: 2 bits of palette index, 1 sign, 1 mantissa bit. The palette drops from 16 entries to 4.</p>
<p>Four exponent values cannot cover 99.9% of a matrix the way sixteen did. Recall from Post 1 that a typical weight matrix uses 10–16 distinct exponents. Force that down to 4 and you've either thrown away the tails (huge sidecar) or quantized the magnitude itself. Both of those bills come due below.</p>
<h2>Per-block scaling, and exactly where it breaks</h2>
<p>SCLP6 and SCLP8 carry a <strong>per-block scale</strong>: one BF16 multiplier per 32 weights. Before encoding, each block is normalized by its max-abs value; the decoder multiplies it back. This costs +6.25% (SCLP8) / +8.3% (SCLP6) and it works — it pulls the weights in a block into a tighter magnitude range so the palette spends its entries where they matter. Gemma4-31B dense and 26B MoE are both coherent with per-block scaling on.</p>
<p>We assumed the same trick would help SCLP4 most of all, since its palette is the most starved. It did the opposite.</p>
<p>Normalizing a block by its max-abs concentrates every weight's exponent up near 126–127. For a 16-entry palette that's fine — you still resolve the spread. For a <strong>4-entry</strong> palette it's fatal: all four entries collapse onto nearly the same exponent, and SCLP4 degenerates into roughly 2-bit <em>scalar</em> quantization. On instruction-tuned models the result is unmistakable. Gemma4-26B-IT, pure SCLP4 with per-block scaling, asked anything:</p>
<pre><code>own own own own own own own own own own...
</code></pre>
<p>Mode collapse. And here's the part that cost us time: the GPU decode was <strong>byte-perfect</strong>. We verified it against the CPU reference across all 508 million weights — identical. The garbage wasn't a bug. It was the honest output of a 4-entry palette that scaling had crushed into 2 bits of magnitude resolution. (Llama-3-8B base, same config, doesn't collapse — it stays incoherent-but-diverse. Collapse is an IT-model failure mode, which is its own lesson for the smoke-testing post.)</p>
<h2>Per-block palettes</h2>
<p>The fix is to stop fighting the local exponent variation and start exploiting it. Instead of one global palette plus a normalizing scale, give <strong>each 256-weight block its own 4-entry k-means palette</strong>. No scale multiply at all. A block whose weights cluster around exponent 120 gets a palette centered there; its neighbor at 124 gets its own. The same 4 entries that were useless globally become well-placed locally.</p>
<p>The overhead is tiny — 4 palette bytes per 256 weights is 1.56% — and the quality difference is not subtle. Llama-3-8B SCLP4, wikitext perplexity:</p>
<table>
<thead>
<tr>
<th>SCLP4 mode</th>
<th>PPL</th>
</tr>
</thead>
<tbody><tr>
<td>Per-block palette</td>
<td><strong>102.4</strong></td>
</tr>
<tr>
<td>Global palette</td>
<td>117.6</td>
</tr>
<tr>
<td>Per-block <em>scaling</em> (QK=256)</td>
<td>209</td>
</tr>
</tbody></table>
<p>Per-block palette is now the <em>only</em> SCLP4 mode. Per-block scaling, the thing that helps every other tier, is the thing you must not do here. SCLP5 (an extra mantissa bit over SCLP4) uses the same per-block-palette structure.</p>
<h2>MoE: don't decode what you don't route</h2>
<p>Gemma4-26B is a mixture of experts — 128 experts per layer, of which a handful are active per token. The naive integration decoded the <em>entire</em> expert tensor to BF16, then let the router pick. For 128 experts that's a 16× waste of decode work and about 1 GB of scratch VRAM per layer, every token.</p>
<p>So we wrote a <strong>fused MoE GEMV</strong>: one thread block per (row tile, active expert). It decodes only the routed experts' weights, inline, straight into the dot product — the full BF16 expert buffer never exists. With it, the mixed-precision MoE model runs at the same 55 t/s as the dense path; without it, you pay for 120 experts you never use. Prefill (many tokens) still uses the two-pass decode, since there the GEMM dominates anyway.</p>
<h2>The imatrix trick that worked (and the one that didn't)</h2>
<p>At 4 bits the sidecar — the verbatim-BF16 escape hatch for weights the palette can't represent — grows from 0.01% to several percent. That's now a real fraction of the file, which means <em>which</em> weights you rescue matters. An importance matrix (imatrix) tells you which weights see the most activation. The obvious move is to weight the palette's k-means by importance so the clustering favors important weights.</p>
<p>We tried that first. It regressed perplexity <strong>5×</strong>. Importance-weighting drags palette entries toward high-activation weights and starves everything else, and "everything else" is still most of the matrix.</p>
<p>What worked was applying imatrix to <strong>sidecar selection only</strong>, never to the palette. Two tiers: a mandatory tier (any weight whose palette distance exceeds a threshold) plus a discretionary tier (the top <em>budget</em> fraction ranked by <code>importance × distance</code>). The palette stays a clean unweighted k-means; the imatrix only decides who gets promoted to lossless storage.</p>
<p>Sweeping the budget on Gemma4 mixed-precision shows a sharp diminishing-returns point — perplexity falls off a cliff as you go from 0 to 1%, then flattens out:</p>
<table>
<thead>
<tr>
<th>Sidecar budget</th>
<th>OOD PPL</th>
</tr>
</thead>
<tbody><tr>
<td>0%</td>
<td>13,909</td>
</tr>
<tr>
<td>0.5%</td>
<td>1,506</td>
</tr>
<tr>
<td><strong>1%</strong></td>
<td><strong>940</strong></td>
</tr>
<tr>
<td>2%</td>
<td>1,026</td>
</tr>
</tbody></table>
<p>1% is the recommended default; 2% is within noise of it — a quality floor, not an improvement. One more trap, and here I'll separate what we measured from what we inherited: ideally you build the imatrix from the <strong>BF16</strong> model, not from a Q5_K_M intermediate. An already-quantized model's own rounding noise pollutes the activation statistics the imatrix records, so the importance signal you extract is partly measuring the <em>other</em> quant's errors — which is why llama.cpp quant-makers calibrate against full precision. Where a published BF16-calibrated imatrix was available off the shelf we used one. But the Gemma4 numbers in this section came from a Q5_K_M-sourced imatrix — Gemma4's BF16 is 48 GB and won't fit a 24 GB card — so we never isolated the contamination cost in a controlled same-model run. Treat the size of that effect as community wisdom, not a number from this project. What we <em>did</em> measure is the calibration <em>domain</em>: building the imatrix from in-domain agentic traces instead of wikitext cut SCLP4 sidecar PPL by 32%.</p>
<h2>Where 4 bits lands</h2>
<p>The headline comparison is against Q4_K, llama.cpp's standard 4-bit integer quant. "MIXED" throughout is one recipe: <strong>SCLP6 on the attention projections and <code>ffn_down</code>, SCLP4 on the bulk <code>ffn_gate</code>/<code>ffn_up</code>, embeddings and output kept native</strong> — the precision-where-it-matters policy from earlier in this post. The rows differ only in how the SCLP4 portion builds its palette (global vs per-block) and whether imatrix sidecar is applied; the SCLP6 attention/<code>ffn_down</code> half is the same in all three. On held-out agentic traces (OOD perplexity, the number we actually trust):</p>
<table>
<thead>
<tr>
<th>Config</th>
<th>Size</th>
<th>OOD PPL</th>
</tr>
</thead>
<tbody><tr>
<td>MIXED, <strong>global</strong> palette + 1% imatrix</td>
<td>17.0 GiB</td>
<td>26.6</td>
</tr>
<tr>
<td>MIXED, <strong>per-block</strong> palette</td>
<td>14.9 GiB</td>
<td>132.6</td>
</tr>
<tr>
<td>MIXED, per-block palette + wikitext imatrix</td>
<td>14.9 GiB</td>
<td>39.2</td>
</tr>
<tr>
<td>SCLP6 + Q4_K hybrid</td>
<td>15.8 GiB</td>
<td>290.4</td>
</tr>
</tbody></table>
<p>Per-block-palette SCLP4 beats the Q4_K hybrid by <strong>2.2×, at 0.9 GiB smaller</strong>. Global-palette SCLP4 with a generous imatrix budget wins on quality outright — but most of its extra 2 GiB is sidecar, so it's the choice only when size is unconstrained. Q4_K still wins one thing: prefill throughput, because it goes straight through rocBLAS's integer path while SCLP pays the two-pass decode tax (Post 3 has the gory details). For prefill-bound work — long-context RAG — Q4_K gate/up is the right call. For chat and agents, which are decode-bound, SCLP4 is smaller <em>and</em> more accurate.</p>
<p>One thing that surprised us: SCLP4 is the <em>smallest</em> tier but not the <em>fastest</em> at token generation. SCLP4 lands at 34 t/s, behind SCLP6's 38. Fewer bits per weight should mean less to read and faster decode — but an apples-to-apples per-byte comparison of the two decode kernels showed SCLP4's is <strong>~1.7× slower per byte</strong> than SCLP6's. The 1-bit mantissa and per-block palette pack more logical work into each byte (more unpacking, more palette indexing per weight), and at these sizes the model already fits comfortably in VRAM, so the bandwidth saving doesn't dominate. Smaller isn't automatically faster — you have to measure the kernel, not count the bits.</p>
<p>One caveat on all of these numbers: <strong>wikitext perplexity is inflated roughly 50× out of domain.</strong> A healthy Gemma4-IT scores ~50–200 on OOD wikitext that would read as catastrophic if you assumed base-model scales. Use the rankings, not the absolutes — which is exactly the kind of measurement discipline the next post is about.</p>
<h2>Where this leaves us</h2>
<p>The compression story is, by this point, basically done: SCLP4 at 4 bits/weight, per-block palettes to keep the starved palette useful, a fused MoE GEMV so you only decode what you route, and imatrix-targeted sidecar to spend your lossless budget where it counts. It beats the standard 4-bit integer quant on quality per byte for decode-bound workloads.</p>
<p>What's left is everything underneath. The algorithm was about a week of prototyping. Making it run — correctly, fast, without wedging the GPU — took the next two, and almost none of it was about compression. That's the last post: the bugs, the dead ends, and the lessons from the bottom of the stack.</p>
<hr />
<p><em>SCLP is open source. The reference implementation (Python + HIP kernels) is at <a href="https://github.com/KerchumA222/sclp">github.com/KerchumA222/sclp</a>. The llama.cpp fork with GPU inference is at <a href="https://github.com/KerchumA222/llama.cpp">github.com/KerchumA222/llama.cpp</a>, branch <code>sclp</code>.</em></p>
]]></content:encoded></item><item><title><![CDATA[LLMs Use Just 16 of 256 Exponents — So We Compressed the Rest Away]]></title><description><![CDATA[Most people compressing LLM weights are fighting the same war: squeeze 7 billion floats into less memory without wrecking the model. The standard weapons are quantization schemes — map each float to a]]></description><link>https://blog.kerchum.dev/llms-use-just-16-of-256-exponents-so-we-compressed-the-rest-away</link><guid isPermaLink="true">https://blog.kerchum.dev/llms-use-just-16-of-256-exponents-so-we-compressed-the-rest-away</guid><category><![CDATA[llm]]></category><category><![CDATA[Artificial Intelligence]]></category><category><![CDATA[GPU]]></category><category><![CDATA[quantization]]></category><dc:creator><![CDATA[Alexander Kerchum]]></dc:creator><pubDate>Fri, 29 May 2026 19:00:00 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/614f878194bb297e1aad7495/77a23f50-e02a-4ebd-a136-3cc09330933b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most people compressing LLM weights are fighting the same war: squeeze 7 billion floats into less memory without wrecking the model. The standard weapons are quantization schemes — map each float to a small integer, accept some error, move on. INT8, INT4, GPTQ, AWQ, the whole zoo. Different as they look, they all aim at the same target: the mantissa. Reduce precision, round to fewer bits, compensate with per-block scales.</p>
<p>We spent some time looking at the other field instead.</p>
<h2>The exponent is barely random</h2>
<p>A BF16 float has three fields: 1 sign bit, 8 exponent bits, 7 mantissa bits. The sign is basically a coin flip, so there's nothing to compress there. The mantissa encodes fine-grained precision within a magnitude range, and it's close to random too. The exponent is the odd one out — it determines the order of magnitude, and that's where the structure hides.</p>
<p>So we dumped the exponent distribution of every weight matrix in Llama-3-8B. A typical attention projection has 4096 × 4096 = 16 million weights, each carrying an 8-bit exponent that could in principle take any of 256 values. In practice, <strong>10 to 16 exponent values cover 99.9%+ of every matrix</strong> — 14 on OPT-125m's MLP layers, as few as 10 on some attention projections. Calling the distribution "skewed" undersells it; it behaves more like a lookup table.</p>
<p>It's not a quirk of one model either. We checked OPT-125M, Llama-3-8B, and Gemma4-26B and saw the same pattern each time. Training concentrates weights into a narrow magnitude band, and the exponent is what encodes magnitude. The remaining ~0.01% of weights with rare exponents fall into two camps: near-zero (harmless to approximate) or extreme outliers (critical to preserve exactly).</p>
<h2>The palette idea</h2>
<p>If only 16 exponents matter, you don't need 8 bits to store them — you need 4. Build a palette of the dominant exponent values, store a 4-bit index per weight, and you've cut the exponent from 8 bits to 4 with zero loss for 99.9% of weights.</p>
<p>That leaves 4 bits for everything else, so we pack the sign (1 bit) plus the top 3 mantissa bits (3 bits) into the remaining nibble. One byte per weight.</p>
<pre><code>BF16 weight (16 bits):  [sign:1][exponent:8][mantissa:7]
SCLP8 code  (8 bits):   [palette_idx:4][sign:1][mantissa_top3:3]
</code></pre>
<p>Dropping the bottom 4 mantissa bits sounds like it ought to hurt quality, but the effect turned out to be surprisingly mild. It behaves like a form of weight regularization, gently pulling values toward their magnitude's midpoint. On Llama-3-8B the truncation actually <em>lowered</em> perplexity, from 10.59 (full BF16) to 9.87 — which wasn't the result we'd bet on going in.</p>
<h2>What about the outliers?</h2>
<p>The 0.01% of weights whose exponents fall outside the top-16 palette could just be mapped to the nearest palette entry, but that introduces a magnitude error — potentially 2x or 4x off for a 1-2 step exponent mismatch. Most of those weights are tiny enough that it wouldn't matter, but a handful carry disproportionate weight for model quality.</p>
<p>So we don't approximate them at all. Any weight whose exponent isn't in the palette gets stored verbatim — full 16-bit BF16 — in a sidecar section appended to the compressed blob, and a fixup kernel scatter-writes them back into the output at decode time. The net effect is functionally lossless for every weight that matters.</p>
<p>The sidecar typically adds 0.01-0.03% overhead, and you can measure it per tensor to decide whether it's worth the bytes.</p>
<h2>The Python prototype</h2>
<p>The first implementation was pure NumPy. Encode takes a uint16 array of raw BF16 bit patterns — we never convert to float — builds the palette with k-means on the exponent histogram, and packs each weight into one byte. Decode runs the same steps in reverse. The whole thing is about 200 lines and finishes in a few seconds on a CPU.</p>
<pre><code class="language-python"># The core encoding loop (vectorized)
palette_idx = exp_to_idx[exponents]     # 4-bit palette lookup
smn = (signs &lt;&lt; 3) | mantissa_top3      # 4-bit sign+mantissa
ws_stream = (palette_idx &lt;&lt; 4) | smn    # pack into one byte
</code></pre>
<p>On top of that we wrote a <code>.sclp</code> file format, a round-trip test suite, and a converter that patches individual tensors into existing GGUF files — all before touching a single GPU kernel. Getting the algorithm right on CPU first, where you can print every intermediate value, saved weeks of debugging down the line.</p>
<h2>Getting it onto the GPU</h2>
<p>A compression scheme that can't keep up at inference speed is really just a file format. So the target was llama.cpp on an RX 7900 XTX (RDNA3, 24 GB VRAM, HIP/ROCm).</p>
<h3>Step 1: Register the type</h3>
<p>llama.cpp's type system is a set of enums and tables. We added <code>GGML_TYPE_SCLP8 = 47</code> to <code>ggml.h</code>, registered block size and type size in <code>ggml.c</code>, told the CUDA/HIP backend it can handle SCLP in <code>supports_op</code>, and taught the GGUF loader to infer compressed blob sizes from tensor offsets (since SCLP blobs are smaller than <code>ggml_nbytes</code> would predict).</p>
<p>This plumbing took longer than the compression algorithm did. Every path that touches tensor metadata — allocation, loading, mmap, graph planning — has to agree on what "size" means for a compact blob.</p>
<h3>Step 2: Two-pass decode</h3>
<p>The safe first approach is to decode the entire compressed tensor to BF16 in a GPU kernel, then hand the BF16 buffer to rocBLAS for the matrix multiply. That's two passes over the data, but the decode kernel is simple and the GEMM is battle-tested.</p>
<p>The decode kernel is self-contained: thread 0 reads the blob header (palette size, palette bytes) and broadcasts the palette to shared memory, then all threads decode 8 weights each via coalesced uint64 loads. A second kernel scatter-writes the sidecar values. Both are HIP-graph-safe, with no host-device reads.</p>
<p>This worked on the first try — Llama-3-8B loaded and generated coherent text. But two-pass leaves the headline win on the table: at token-generation time you still read 16 bits per weight (the decoded BF16 buffer) on top of the compressed blob. The bandwidth savings only show up if you never write that BF16 intermediate in the first place.</p>
<h3>Step 3: The fused GEMV</h3>
<p>Two-pass is fine for prefill, where a large matrix multiply means the GEMM dominates. But token generation computes one output row at a time, so the matrix multiply collapses to a dot product — GEMV, not GEMM — and the bottleneck becomes memory bandwidth: how fast can you read the weight matrix?</p>
<p>BF16 reads 16 bits per weight; SCLP reads 8. Decode inline — compute the float value from the palette index and mantissa bits during the dot-product accumulation, without ever writing a BF16 intermediate — and you halve the memory traffic.</p>
<p>That's the fused GEMV kernel. One warp per output row. Each thread reads 8 bytes of compressed weights, decodes them to floats using the palette in shared memory, multiplies by the activation vector (also broadcast to shared memory), and accumulates. The sidecar correction is folded in: because the encoder sorts sidecar entries by weight index, each row can binary-search its contiguous range and apply corrections inline — no atomics, no second kernel.</p>
<p>The result is <strong>43 t/s at 8 bits/weight vs 52 t/s at 16 bits/weight on Llama-3-8B.</strong> You read half the bytes but spend more ALU per byte on palette lookup, bit manipulation, and sidecar correction. The trade is a ~7 GB footprint reduction at a modest speed cost. Later optimization — K-tiling for occupancy, compilation unit splitting for register pressure — pushed throughput further on the smaller SCLP types.</p>
<p><em>Note: an earlier SCLP8 build without sidecar correction showed 66 t/s. Adding folded sidecar (binary search + correction per row) was necessary for quality but cost roughly a third of the throughput. The 43 t/s number reflects the current kernel.</em></p>
<h3>The prefill gap</h3>
<p>Prefill — processing the entire prompt in one batch — is where SCLP pays its tax. The two-pass path (decode blob → BF16 → rocBLAS GEMM) adds a full weight-matrix read before the GEMM, whereas Q8_0 goes directly through rocBLAS's INT8 path. SCLP8 prefill lands at ~2,650 t/s, against ~3,430 for Q8_0 and ~12,000 for BF16.</p>
<p>We did try fusing the decode into the GEMM, a combined decode+matmul kernel for M &gt; 1. It worked for dense models, but on MoE models the F32 accumulation order between tensor-core tiles and scalar sequential math diverges at ~1e-3 per multiplication. Through 26 MoE layers those differences compound into catastrophic perplexity. Closing the gap would mean exactly replicating rocBLAS's tile accumulation order — doable, but not worth the engineering for a 20% prefill win.</p>
<p>For now the two-pass path is the right default, since prefill isn't the bottleneck for chat and agentic workloads anyway.</p>
<h2>Where this leaves us</h2>
<p>SCLP8 on Llama-3-8B comes in at <strong>7.9 GB</strong> (vs 15.0 GB BF16), <strong>PPL 9.87</strong> (vs 10.59), and <strong>43 t/s</strong> token generation on an RX 7900 XTX. That's 2x compression on the weight streams, and the model scores <em>slightly better</em> than the original by perplexity.</p>
<p>But 8 bits per weight is the easy version. The more interesting question is what happens when you push to 6 bits, then 4 — when the palette shrinks from 16 entries to 4, per-block scaling degenerates, and the sidecar population jumps from 0.01% to 5%. That's where mixed precision, per-block palettes, and the imatrix trick that actually worked come in. Next time.</p>
<hr />
<p><em>SCLP is open source. The reference implementation (Python + HIP kernels) is at <a href="https://github.com/KerchumA222/sclp">github.com/KerchumA222/sclp</a>. The llama.cpp fork with GPU inference is at <a href="https://github.com/KerchumA222/llama.cpp">github.com/KerchumA222/llama.cpp</a>, branch <code>sclp</code>. Both target AMD RDNA3 (ROCm/HIP) — CUDA porting is straightforward but not yet done.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to move a directory from one git repo to another (or new) without losing history]]></title><description><![CDATA[Make copy of repo
git clone dirtySourceRepo newSourceRepo
OR clone from actual git repo and prevent push
git remote set-url --push origin no_push

Make sure to checkout the correct branch before the next step.

Cloning from another local directory al...]]></description><link>https://blog.kerchum.dev/how-to-move-a-directory-from-one-git-repo-to-another-or-new-without-losing-history</link><guid isPermaLink="true">https://blog.kerchum.dev/how-to-move-a-directory-from-one-git-repo-to-another-or-new-without-losing-history</guid><category><![CDATA[Git]]></category><category><![CDATA[repository]]></category><category><![CDATA[version control]]></category><category><![CDATA[Microservices]]></category><dc:creator><![CDATA[Alexander Kerchum]]></dc:creator><pubDate>Wed, 24 Nov 2021 21:41:40 GMT</pubDate><content:encoded><![CDATA[<p>Make copy of repo
<code>git clone dirtySourceRepo newSourceRepo</code></p>
<p>OR clone from actual git repo and prevent push
<code>git remote set-url --push origin no_push</code></p>
<ul>
<li><p>Make sure to checkout the correct branch before the next step.</p>
</li>
<li><p>Cloning from another local directory allows you to easily start over if you make a mistake and also prevents you from accidentally pushing upstream.</p>
</li>
</ul>
<p>use https://github.com/newren/git-filter-repo to filter down to the folder you actually care about
<code>git filter-repo --subdirect-filter /subdirectory/I/want/to/extract --to-subdirectory-filter the/new/folder/I/want/it/in</code>
This moves the subdirectory to the root of your repo. You can use different variations of this if you want to. You can omit <code>--to-subdirectory-filter</code> if you want the contents of the folder to be in the root of the repo.</p>
<p>In destination repo (which can be a new empty repo or an existing one), set a new remote to the freshly filtered source repo.
<code>git remote add source ../newSourceRepo</code></p>
<p>Make a new branch to put the stuff into
<code>git checkout -b newBranch</code></p>
<p>Then pull in the content from the source repo:
<code>git pull source master --allow-unrelated-histories</code></p>
]]></content:encoded></item></channel></rss>