~/satyajit

Soup: an 8B model in 3.6 GB of VRAM, and 216 commands around it

mdjsonmcp

2026-09-13 · 36 min · llm · fine-tuning · quantization · systems · explainer

Soup is a fine-tuning CLI. Its README makes one very specific claim: fine-tune an 8B model on a 4 GB laptop GPU, no cloud, no second GPU, bit-exact against a normal run. I went looking for the seam — the rounding, the missing denominator, the benchmark that doesn't match the shipped code. I didn't find one in the headline claim. What I found instead is a project that checked its own arithmetic harder than I was set up to, including an executable proof notebook, a silently-wrong-gradients bug it found on borrowed hardware and repaired in public, and a published paper it revised specifically to retract an interpretation nobody outside the project had disputed.

A few directories over sits a much less flattering fact about the same codebase: the CLI that promises "one config, one command, no config hell" ships 216 leaf commands and a training config with 237 fields. Both of these are true of the same 181,358-line source tree, and neither cancels the other out. This is a read of the source, the tests, and the benchmark records — not the README — with numbers I recomputed myself wherever I could.

Soup's own terminal readout for a layer-streaming run: base store 3.60 GB across 32 pinned layers, VRAM buffers 2 x 113 MB = 225 MB, resident 2101 MB embeddings and adapters, LoRA applied 6,815,744 trainable of 8,030,261,248 total parameters (0.08%), gradient checkpointing handled per layer, training started. Caption: On the card: 2 x 113 MB.
Soup's own pre-flight/training readout, reproduced from the project's README (docs/assets/layer-streaming.gif — Copyright 2026 Makazhan Alpamys, Apache-2.0). Every number on it is checked below — 6,815,744 trainable params is exactly LoRA r=16 on q_proj and v_proj across 32 layers.

The memory arithmetic, from scratch

The claim is Llama-3.1-8B-Instruct, NF4-quantized, LoRA, on a card with 4 GB of VRAM. The architecture: hidden size 4096, intermediate size 14336, 32 decoder layers, grouped-query attention with 8 KV heads at head_dim 128 (so the K/V projections are 4096×1024, not 4096×4096). Every decoder layer has seven linear weights — I added them up from the config, not from anything Soup prints:

projectionshapeelements
q_proj4096 × 409616,777,216
k_proj4096 × 10244,194,304
v_proj4096 × 10244,194,304
o_proj4096 × 409616,777,216
gate_proj4096 × 1433658,720,256
up_proj4096 × 1433658,720,256
down_proj14336 × 409658,720,256
total218,103,808

Soup stores the frozen base as NF4 with double quantization, and the bytes-per-parameter constant is a real line of code, not a rule of thumb:

# src/soup_cli/utils/layer_stream.py:191-196
#: Streamed bytes per parameter under NF4 + double quant: packed ``N/2`` +
#: absmax ``N/64`` + nested absmax ``N/64/256*4`` + a 4-byte offset per weight.
NF4_BYTES_PER_PARAM = 0.5 + 1 / 64 + 4 / (64 * 256)
#: Without double quant the per-block absmax stays float32: ``N/2 + 4*N/64``.
NF4_BYTES_PER_PARAM_SINGLE = 0.5 + 4 / 64

0.5 + 1/64 + 4/(64*256) is 0.515869140625 — half a byte per parameter for the packed nibbles, plus one 8-bit absmax per 64-value block, plus a 4-byte nested absmax/offset amortized over the same 64×256-value super-block. Multiply it out:

stepbytes
NF4 packed weight (0.5 B/param)109,051,904 B109.05 MB
double-quant state (absmax + nested)3,461,120 B3.46 MB
per decoder layer112,513,024 B112.51 MB
× 32 layers3,600,416,768 B3.600 GB (3.3531 GiB)

That's the README's own "3.60 GB base store" and the GIF's "two 113 MB VRAM buffers" (112.51 rounds to 113), both landing exactly where the architecture says they should — nobody had to fudge a constant to make this reconcile. The project's paper states the same layer in a different unit, "105 MiB per layer," which is the packed weight alone (109,051,904 B ≈ 104 MiB) without the absmax overhead; both numbers are correct, they're just measuring the packed payload versus the full streamed footprint, and I couldn't find anywhere the two are explicitly reconciled in the sources — that reconciliation above is mine.

3.60 GB is the size of the whole frozen base. None of it needs to be resident at once — that's the entire point of streaming — but you don't get from "3.60 GB total" to "3.32 GB peak" for free. Here's where the rest of the budget actually goes, using Soup's own peak-VRAM formula:

peak-VRAM budget · Llama-3.1-8B NF4, LoRA, batch 1, seq 512
4.00 GB card3.54 GBmeasured peak on the card: 3.32 GB
pool · 2 buffers · 225 MB
extras · untied embed + head (bf16) · 2.10 GB
LoRA + optimizer state · 109 MB
activations (checkpointed) · 172 MB
logits (14 × vocab × seq) · 922 MB
fixed slack · 13.5 MB

The pool — the two buffers layer streaming actually adds — is 225 MB, the smallest slice of the budget. Most of the 4 GB is spent on the untied embedding/head matrix sitting fully resident in bf16 and the logits tensor, neither of which layer streaming touches.

The two streaming buffers are the smallest line item in the whole budget. Most of the 4 GB is spent on things streaming doesn't touch at all: the untied embed_tokens/lm_head pair sitting fully resident in bf16 (2.10 GB — this predates a later optimization that shares one slot between them), and the logits tensor at the sequence's last position (922 MB, 14 × vocab × seq bytes — 12 bytes for the loss path, 2 for retention). My reconstruction lands at 3.54 GB against a measured 3.32 GB; the project's own out-of-sample check of the same formula against this exact row gets 3.57 GB predicted against 3.32 GB measured — 7.5% high, which matches the formula's documented contract of never under-predicting (worst fitted-grid error 0.85%, over 10 real runs across two models). A pre-flight gate that's allowed to refuse a run only has one safe direction to be wrong in, and this one is measured to stay on it.

The mechanism, and why two buffers are enough

layer streaming · 2 VRAM buffers, 32 layers
forward: layer 0 → 31Pinned host RAM3.60 GB · 32 × 112.51 MB NF4resident: layer 0→ reading layer 1PCIe Gen4×8H2D layer 1GPU2 × 112.51 MB buffersA · L0B · L1compute · dequant + matmul
peak VRAM
3.32 GB
of 4.00 GB card
host store
3.60 GB
pinned, 32 layers
H2D loads
61
of a naive 64
bandwidth used
1.60 GB/s
link: 7.77–9.38 GB/s

Every layer is read twice per step — once forward, once in the backward recomputation, because dL/dx = W^T · dL/dy still needs the frozen weight. Two buffers are enough because the prefetch for layer i±1 always targets the slot compute finished with, one step ago — that’s the whole overlap. Moving 61 × 112.53 MB in a 4.281 s step needs 1.60 GB/s on average; the link sustains 7.779.38 GB/s. The run is compute-bound, not transfer-bound — deleting every H2D byte only speeds the step up 1.4%.

The mechanism is a pinned host-RAM store holding all 32 NF4 shards, two pre-allocated VRAM buffers, and a prefetch stream that keeps one buffer loading the next layer while the compute stream works on the current one. The docstring states the whole schedule in four lines:

# src/soup_cli/utils/layer_stream_runtime.py:1-17
FORWARD   layer i: wait(i) -> prefetch(i+1) -> checkpoint(body_i)
BACKWARD  layer i: wait(i) -> prefetch(i-1) -> recompute + backward

Every layer is read twice per training step, and that's physics, not a missed optimization: the backward pass needs the frozen weight again to compute dL/dx = W^T · dL/dy down to lower layers and their adapters. torch.utils.checkpoint discards the intermediate activations after the forward pass specifically so this second read has something to recompute from — which is also, as the next section covers, exactly the assumption a real bug violated one level down the stack.

Two buffers, not one, is enforced in config validation with a comment worth quoting because it names the failure mode directly: validate_stream_buffers says "1 buffer is a scheduler bug, not a config." The pool's wait() method carries the tripwire that would catch a buffer recycled too early:

# src/soup_cli/utils/layer_stream_runtime.py:696-714
def wait(self, idx: int) -> Dict[str, Any]:
    """Block the compute stream until layer ``idx`` is resident.
 
    The ownership check is the plan-P1 tripwire: a buffer recycled while an
    autograd node still references it produces silently WRONG gradients, not
    a crash. Failing loudly here is the whole point.
    """
    slot = self.slot_for(idx)
    if self.owner[slot] != idx:
        raise RuntimeError(
            f"layer-stream scheduler bug: buffer slot {slot} holds layer "
            f"{self.owner[slot]}, but layer {idx} was requested. Raise "
            f"training.stream_buffers (currently {self.n}) or report this."
        )
    if self.is_cuda:
        torch.cuda.current_stream().wait_event(self.events[slot])
    return self.buffers[slot]

Keep that check in mind — it's the safety mechanism that worked, and it makes what happened next more interesting, not less.

Is it actually bottlenecked on the PCIe transfer?

The naive count is 64 loads per step (32 forward, 32 backward). Soup's own prefetcher skips a load when the target buffer already owns the layer it's about to need, which is why the measured count is 61, not 64. That gives a real, checkable bandwidth requirement:

quantityvaluenote
layer loads, measured61of a naive 64
bytes moved61 × 112.529 MB = 6.864 GBper step
step time512 tok ÷ 119.6 tok/s = 4.281 sRTX 3050 Laptop 4 GB
average bandwidth needed6.864 GB ÷ 4.281 s = 1.60 GB/s
link, idle pinned-copy rate7.77 GB/sPCIe Gen4×8
link, under real training load9.38 GB/ssame link, busy

1.60 GB/s against a 7.8–9.4 GB/s link is roughly 17–20% utilization — nowhere near saturated. The project didn't stop at the implied number; it instrumented the actual step with CUDA events and ran the ablation directly: deleting every host-to-device byte makes the step 1.4% faster; deleting the NF4 dequantization on top of that makes it 9.8% faster again; with both streaming-specific costs zeroed, 88.7% of the step time remains. The run is compute-bound — specifically bound by dequantizing NF4 on the fly, not by the bus — and the streamed step measures at 71.3% of this same card's own shape-matched GEMM ceiling, measured in the same session because the card's boost clock moved 442–952 MHz within one run, which makes any cross-session ceiling comparison meaningless.

step-time ablation · Llama-3.1-8B NF4, batch 1, RTX 3050 Laptop @ 960 MHz
A · baseline4.211 sB · no H2D transfers at all4.150 s-1.44%C · no NF4 dequantisation3.799 s-9.80%D · neither3.743 s-11.30%04.6 s per step
same box, separately instrumented with CUDA events · S = 512, 4.190 s step
prefetch stream · copying0.732 s · 17.5% of the stepcompute stream · blocked0.0084 s · 0.20% of the step
A · baseline step
4.211 s
121.6 tok/s
B · delete every H2D byte
−1.44%
6.864 GB moved, gone
C · delete NF4 dequant
−9.80%
the only streaming-specific cost with headroom
A, of this card's GEMM ceiling
71.3%
same session, shape-matched

Deleting every host-to-device byte — all 6.864 GB of it — makes the step 1.44% faster. That single row is what refuted three releases of “bound by host-to-device transfer,” and the lane above says why: the prefetch stream copies for 17.5% of the step, but the compute stream is blocked on a copy for 0.20%. Double buffering hides essentially all of it, so there is nothing left for more bandwidth to buy. The cost that does bite is the per-layer NF4 dequantisation, at 9.8%.

I mention the ablation methodology because the project's own paper got this wrong once and corrected itself in public, which is the best single example of the house method this site asks for working from the other side of the table. Version 2 of the paper explained the 8B configuration's matching throughput on a laptop and on an H100 by asserting the method was "bound by host-to-device transfer" — an inference, not a measurement, appearing in the abstract and six places in the body. Version 3's front matter states plainly what changed:

On 2026-08-11 we measured it directly on the laptop, and it is false at the published configuration: the streamed step runs at 71.3% of that card's same-session, shape-matched GEMM ceiling; deleting every host-to-device byte makes it 1.4% faster; and the compute stream spends 0.20% of the step blocked on a copy. [...] No measured number in version 2 changes. Every throughput, peak-VRAM, exactness and quality figure stands exactly as published; what is withdrawn is an interpretation placed on them [...] Versions 1 and 2 remain citable and are not edited.

Nobody outside the project had challenged that sentence. They measured it, found it wrong, and shipped a new DOI version whose entire content is admitting that one interpretive line was unearned — while explicitly leaving the earlier, wrong versions standing rather than quietly editing them away.

What "bit-exact" actually means

"Bit-exact" is torch.equal — IEEE bitwise identity, zero tolerance. I checked the test files directly rather than trusting the word:

# tests/test_v07200.py:1598
assert torch.equal(got, want), (got - want).abs().max().item()

There's no atol=/rtol= anywhere in the bit-exactness assertions. Two separate claims live under that one word, and the project is careful never to collapse them:

0.5B8B14B32B72B
forward (torch.equal on logits)exactexactexactexactexact
backward (every LoRA gradient tensor)exact 128/128exact 192/192wrong 62/64 pre-repair, exact 256/256 afterwrong 78/80 pre-repair, exact 320/320 after

Forward exactness holds everywhere because the forward pass never touches the aliased reference that caused the backward defect below. And 8B — the headline size — was never affected; its exactness held before, during, and after the repair.

bit-exactness ledger · torch.equal, no atol, no rtol
163.8–171.50.5B8B14B32B72Bbf16forward · torch.equal on the logitsexact at every size, in both modesbackward · every LoRA gradient tensor vs a resident referencesynthetic sweep · 48 layers, hidden 5120, only intermediate_size varies1030100300600
NF4, bit-exactNF4, disagrees with the resident referencebf16 controlhorizontal axis: MiB per layer, log scale

both tracks carry the record’s own MiB/layer figures, and they are accounted slightly differently: the real 14B row reads 132, its synthetic reconstruction 136.7. A 3.5% gap, far narrower than the bracket it sits next to.

forward, 0.5B → 72B
exact
torch.equal on the logits
backward, NF4 below the band
96 · 128 · 192
all tensors, all exact
backward, NF4 above the band
8/256 · 8/320
32B and 72B, pre-repair
bf16 at 480 and 570 MiB
exact
3.9× more bytes per layer, never affected

The sweep track is what turns “about 165 MiB” into a number: hold 48 layers and hidden 5120 fixed, vary only intermediate_size, and the flip lands between 163.8 and 171.5 MiB per NF4 layer. Its 136.7 point is a synthetic reconstruction of the real 14B’s layer shape and is exact exactly as the real 14B is; its 241.2 point reconstructs the real 32B’s and is broken exactly as the real 32B is — so the defect follows the shape, not the checkpoint. The two hollow bf16 markers out at 480 and 570 MiB are the control that stops this being a story about transfer size: they move nearly four times the bytes per layer and stay exact. For scale, the shipped CI fixtures carry a 0.01 MiB layer — three orders of magnitude to the left of this axis, which is why nothing in CI could ever have caught it.

The defect that made "backward" a real, separate claim

install_dequant_forward exists only because of a real, shipped bug, found on borrowed H100 hardware in August 2026 and repaired in v0.73.0. The docstring is the clearest description of it I've read in any codebase:

# src/soup_cli/utils/layer_stream_runtime.py:161-193 (trimmed)
def install_dequant_forward(module: Any) -> int:
    """#331 — keep a STREAMED NF4 weight out of ``bitsandbytes``' ``MatMul4Bit``.
 
    ``MatMul4Bit.forward`` stashes the packed weight and the ``quant_state`` on
    ``ctx`` as plain attributes rather than through ``save_for_backward``::
 
        ctx.state = quant_state
        ctx.tensors = (None, B)
 
    ``torch.utils.checkpoint`` discards and recomputes *saved tensors*. These are
    not saved tensors, so it cannot see them: the reference taken in the forward
    survives, it ALIASES the buffer pool, and the backward reads it after that slot
    has been refilled with a different layer. Measured on 8xH100 against a resident
    NF4 reference, that is a bit-exact forward, a healthy-looking loss curve, and
    gradients wrong on every layer but the last ``stream_buffers``.
 
    De-aliasing was measured and rejected: bnb holds the reference across the whole
    forward-to-backward span, so any copy keeps one layer alive for that span and
    costs O(model). On real 32B, peak VRAM 4 220 -> 19 720 MiB.
 
    So the weight never enters that autograd Function. It is dequantised inside the
    checkpointed region and multiplied natively [...]
    """

A bit-exact forward and a healthy-looking loss curve, with silently wrong gradients underneath — this is precisely the failure mode LayerBufferPool.wait()'s ownership tripwire was built to catch, and it slipped past it because the aliasing happened inside bitsandbytes' own autograd Function, one level below where Soup's own buffer-pool bookkeeping operates. The math checks out: 64 − 62 = 2 and 80 − 78 = 2, exactly matching stream_buffers=2 — only the last two loaded layers, still sitting in the pool, escaped the bug.

#331 · which captured weight the backward actually read
at the start of the backward · 32 decoder layers, each holding the weight reference its forward capturedlayer 0layer 3130 layers · slot refilled, the backward reads another layer’s bytes2 still in the pool · reference livegradient tensors bit-exact vs a resident NF4 reference8 / 128
stream_buffers = 2slot = layer index mod 2resident weight bytes: 2 × 241 MiB
control · as shipped
8 / 128
849.6 tok/s · 1,132 MiB peak
de-alias by cloning
128 / 128
1.06× slower · 9,070 MiB peak
stream_pin: false
128 / 128
7.41× slower · 1,129 MiB peak
v0.73.0 repair, real 32B
256 / 256
−4.8% throughput · +2.9% peak

Exactly stream_buffers layers survive, because exactly that many are still in the pool when the backward starts — which is why the measured 8 / 128 here, 8 / 256 on real 32B and 8 / 320 on real 72B are all the same number, two layers, at stream_buffers = 2. Drag it to 32 and the bar does reach 128 / 128: one buffer per layer, all 7.7 GB of them resident at once, which is layer streaming deleted. That is the argument the two middle cards make quantitatively — cloning to de-alias costs 6% time but 8× peak VRAM, and turning pinning off costs 7.41× throughput. Neither is a fix; both were measured before being rejected.

The threshold is bracketed at 163.8–171.5 MiB per NF4 layer — 8B's layer (112.51 MB, i.e. 107.3 MiB) and 14B's both sit under it, never affected; 32B and 72B sit over it, affected and repaired. The repair dequantizes the weight inside the checkpointed region and calls F.linear on the dense result, so the ordinary checkpoint mechanism — which does correctly discard and recompute saved tensors — owns the weight instead of bitsandbytes' Function. It isn't a numerics change at training shapes: bitsandbytes::gemm_4bit already dispatches to the same dequantize-then-matmul fallback at every real projection shape from M=8 to M=2048; the fix just moves where the dequantized tensor gets saved.

The regression test pairs a real assertion with a control that proves the assertion can actually fail:

# tests/test_v07300.py:101-131 (trimmed)
class TestStreamedNF4AvoidsMatMul4Bit:
    """The repair, and the control that makes it mean something.
 
    ``0 calls`` on its own is equally consistent with "the counter never
    intercepted anything" — which is exactly how an earlier path control in this
    investigation was fooled. The resident control must COUNT, in the same test
    session, or the streamed assertion proves nothing.
    """
 
    def test_resident_nf4_does_reach_matmul_4bit(self, tmp_path, monkeypatch):
        """CONTROL. Without this, the assertion below is unfalsifiable."""
        ...
        assert calls["n"] > 0, (
            "the counter did not intercept bnb.matmul_4bit at all, so it cannot "
            "detect the streamed path avoiding it either"
        )
 
    def test_streamed_nf4_forward_does_not_reach_matmul_4bit(self, tmp_path, monkeypatch):
        ...
        assert calls["n"] == 0, (
            f"streamed NF4 still routed {calls['n']} call(s) through MatMul4Bit, which "
            "captures the packed weight outside save_for_backward and therefore aliases "
            "the buffer pool across the checkpoint boundary (#331)"
        )

The gated result, on real hardware: 32B, 256/256 gradient tensors exact against a repair-disabled control's 8–12/256, at +2.9% peak VRAM and −4.8% throughput; 72B, 320/320 against 8/320, at +2.6% and −3.7%. A second, independent silent defect was caught in the same v0.72.2 gate — a meta-skeleton missing an is_loaded_in_4bit marker made PEFT silently fall back to a generic adapter path, measured as a 0.9375 logit divergence against resident NF4 with byte-identical weights: no crash, no warning, a loss curve that looked perfectly healthy. Two independent ways the exactness claim could have quietly broken, both caught by the project's own gates before anyone outside noticed.

The proof notebook: what it proves, and what it explicitly doesn't

Soup ships a Colab notebook that anyone can run on a free T4 — 17 cells, no special hardware required, and it's careful about the difference between "runs" and "proves." It installs from git rather than PyPI because the bf16 pre-Ampere fix isn't released yet, and it uses that gap to make its first real point: torch.cuda.is_bf16_supported() defaults to including_emulation=True, so a T4 — Turing, no bf16 hardware at all — answers True. The notebook's own words:

Read the two lines below carefully, because they disagree, and the disagreement is the point. [...] Soup asked the permissive question [in an earlier fix], and it was a no-op on the exact hardware it targeted.

That's a bug in Soup's own first attempt at detecting this, caught only by actually running the notebook on real T4 hardware rather than reasoning about the API.

Section 3 caps the process to 4 GB via torch.cuda.set_per_process_memory_fraction, and states its own limitation up front: the cap is enforced by the allocator, not the driver, so torch.cuda.mem_get_info() — which Soup's pre-flight reads for its "free VRAM" line — still reports the whole card's real capacity inside this capped process. That's tracked as a real open issue (#347), not glossed over. The notebook proves the cap actually bites by deliberately requesting 15% over budget and catching the RuntimeError.

Section 4 is the forward bit-exactness check, on SmolLM2-135M rather than the headline 8B model, because the reference has to fit in memory next to the streamed copy — which is the whole reason the headline size can't be checked this way on a Colab GPU. Before comparing, it deliberately randomizes lora_B:

PEFT initialises lora_B to zero, so an untrained adapter contributes NOTHING and any comparison would silently be about the base model alone.

Section 5 is the headline: Llama-3.1-8B, NF4, trained under the 4 GB cap. Its weights alone are about 4.5 GB in NF4 — more than the very budget the process is capped to — and it trains anyway, because only a couple of decoder layers are ever resident. The trainer runs in-process, not via a soup train subprocess, and the notebook explains exactly why: max_memory_allocated() reports the peak of the calling process, so a subprocess "would leave us measuring nothing." It also declines to make a claim it can't support: "a T4 under an artificial cap is not a throughput benchmark," and no tok/s number is quoted anywhere in the notebook.

The closing cell states its own scope precisely, which is worth reproducing in full because "proved" and "not proved" lists are rarer than they should be in ML tooling:

Proved, on your hardware: A streamed model returns bit-identical logits to an ordinary one. An 8B model trained with a measured peak below a cap smaller than its own weights. Both on a GPU with no bf16.

Not proved: Backward exactness at this size — gradient exactness is verified up to 14B against resident references on hardware that can hold them, and a defect above that size was found, named upstream, and repaired. Speed — a T4 under an artificial cap is not a throughput benchmark, and this notebook deliberately does not quote tok/s.

The honest caveats, in the project's own words

The README carries a standing caveat on its own headline number rather than quietly re-measuring and replacing it:

(The tok/s figure was measured on v0.72.2, before the v0.73.0 correctness repair that cost −4.8% at 32B; it has not been re-run on a 4 GB card since.)

The CHANGELOG makes the same point with more teeth, and then corrects itself a second time in the same entry — the correction is left in place as a blockquote rather than silently rewritten:

The headline throughput was measured on code that has since changed. 119.6 tok/s / 3.32 GB for Llama-3.1-8B NF4 on the RTX 3050 was taken before install_dequant_forward. [...] nobody has re-run the 8B laptop configuration on the repaired code, and an 80 GB H100 cannot stand in for a 4 GB card whose throughput is bound by host-to-device transfer.

CORRECTION 2026-08-13, third occurrence of the same wrong reason. The conclusion holds, the reason given for it does not: the laptop is not transfer-bound [...] An H100 still cannot substitute, for a narrower reason — the repair's cost is paid in the per-layer NF4 dequantisation, measured at 9.8% of the step on the laptop, and that share belongs to that card's clock, GEMM ceiling and launch overhead.

There's a discarded measurement worth as much as the published ones. Before the 8B headline row, the project measured a 3B resident-NF4 baseline for comparison — and threw it out:

First attempt: 34.1 tok/s, peak VRAM 6.07 GB on a 4 GB card, 100% util, 0.63 TFLOPS. A 6.07 GB peak on a 4 GB card is a WDDM shared-host-memory spill [...] Publishing it would have produced a "streaming is 7.2x faster than resident" headline out of a number that measures Windows paging, not training. It is discarded, not reported.

That's a flattering number the project had every reason to publish, and didn't, because it measured Windows swapping to disk rather than the method. The honest consequence, stated in the same record: there is still no valid resident baseline for 8B on this card — the only size where a fair resident comparison exists at all is 0.5B, where streaming is 1.43× slower than resident. The headline claim is a capability statement ("this now runs at all"), not a speedup number, and the project's own phrasing never claims otherwise.

Soup's own benchmark card: 'MEASURED ON THIS BOX — Llama-3.1-8B-Instruct, LoRA, batch 1, seq 512, RTX 3050 Laptop, 4 GB VRAM.' A bar labelled 'peak VRAM, base streamed as NF4' reads 3.32 GB and stops well short of a marked 4.00 GB card line; a second, unlabelled bar below it is left almost empty. Chips read 119.6 tok/s, GPU utilisation 100%, and 3.60 GB RAM store, page locked. The closing line reads 'Resident, it does not fit at all.'
The project's own headline result card, the last frame of its README demo (docs/assets/layer-streaming.gif — Copyright 2026 Makazhan Alpamys, Apache-2.0). The top bar is measured; the empty one under it is not. As the paragraph above says, no valid resident 8B baseline exists on this card — the one attempt measured Windows paging and was discarded — so 'resident, it does not fit at all' is an inference from the arithmetic, which is exactly what the caveat two paragraphs up concedes about the 119.6 tok/s on the same card.

The reward-hack controller

Buried in utils/ is something I did not expect a fine-tuning CLI to ship: a closed-loop controller that mutates the KL coefficient live, mid-run, during GRPO/PPO, to counteract reward hacking as it's detected rather than only flagging it after the fact.

The plain detector underneath watches two signals, each citing a real paper: an InfoRM-style cluster-separation index (Wang et al. 2024, arXiv:2402.09345) — a falling separation between "good" and "bad" reward scores means the policy is starting to exploit the reward model — and pairwise variance across a reward-model ensemble (Coste et al. 2024, arXiv:2312.09244). Three escalating response modes sit on top: log_only (instrument, no action), kl_control (a reversible bang-bang controller with dwell/release hysteresis on β), and pid_lagrangian — a real PID-Lagrangian controller, citing Stooke et al. 2020, that treats "hacking signal at or below target" as a constraint and updates β by a clamped PID law:

# src/soup_cli/utils/reward_hack_control.py:626-655 (trimmed)
def pid_step(
    policy: PIDLagrangianPolicy, state: ControllerState, *, signal: float
) -> tuple[ControllerState, MitigationAction]:
    """Advance the PID-Lagrangian controller one step for the hacking ``signal``.
 
    ``error = signal - target``; the integral accumulates (clamped ±
    ``integral_clamp``); the multiplier β = clamp(floor..ceil, floor + Kp·error
    + Ki·∫error + Kd·Δerror). β never crosses 0.
    """
    fsignal = float(signal)
    error = fsignal - policy.signal_target
    integral = state.integral + error
    integral = max(-policy.integral_clamp, min(policy.integral_clamp, integral))
    derivative = error - state.prev_error
    control = policy.kp * error + policy.ki * integral + policy.kd * derivative
    beta = max(policy.beta_floor, min(policy.beta_ceil, policy.beta_floor + control))
    tripped = beta > policy.beta_floor
    ...

pid_lagrangian mode additionally carries a rollback-to-last-good-checkpoint escalation ladder that only exists in this mode: if the hacking signal persists past a patience window, the controller restores the last known-good checkpoint, and after a bounded number of failed recoveries it early-stops training and writes a plain-English postmortem rather than continuing to burn compute on a run it has decided is compromised.

This is wired for real, not merely defined. peft_wiring.py::_attach_reward_hack builds the callback from training.reward_hack_* config fields and attaches it to the trainer; attach_rl_callbacks calls that function from exactly two places, trainer/grpo.py and trainer/ppo.py — the two tasks where a live, gameable reward signal actually exists. If mitigation was explicitly requested and the callback can't be constructed, the code fails loud rather than silently training without the safety controller the user asked for.

What I like most here is that the project discloses its own validation gap rather than implying more coverage than it has. The module docstring names a real inconsistency from an H100 validation run directly, by issue number:

The β ladder fired in a kl_control arm but the rollback ladder "never fired in any arm": its only home was the pid_lagrangian arms, which all crashed (#342) before the rung could run.

And the mitigation log writer has a small, specific piece of self-healing worth noting on its own: if the log directory disappears mid-run (a shared temp root cleaned up by another process), it doesn't silently drop records — it recreates the directory, retries once, and warns, because, in the code's own words, "the run completes while its evidence quietly goes missing" is the one outcome this log must never produce.

The capability-probe pattern

TRL has broken Soup's preference trainers twice in the same place, and both times the fix derived from reading TRL's source turned out to be wrong, because two independent things were moving on two independent schedules: max_prompt_length was removed from the five preference configs in stages, not all at once, and ORPOConfig/CPOConfig/BCOConfig left the public trl namespace entirely at one release, becoming an ImportError rather than a rejected keyword. trainer/_trl_compat.py's docstring states the lesson learned in one line, and it's worth keeping regardless of what library you're wrapping:

a version bound derived by reading source is a hypothesis; the experiment that settles it is CONSTRUCTING THE OBJECT.

The fix asks the live class what it accepts, instead of asking trl.__version__ what it should accept:

# src/soup_cli/trainer/_trl_compat.py:96-120
def config_accepts(config_cls: type, field: str) -> bool:
    """Does this trl config class actually take ``field`` as a keyword?
 
    Asked of the class the caller is about to construct, so it stays correct
    across a namespace move as well as a version bump. Configs are dataclasses,
    so the generated ``__init__`` signature is the authoritative list of
    accepted keywords — including the ones inherited from ``TrainingArguments``.
    """
    try:
        return field in inspect.signature(config_cls).parameters
    except (TypeError, ValueError):
        return False
 
 
def prompt_length_kwargs(config_cls: type, max_prompt_length: int) -> dict[str, int]:
    """``{'max_prompt_length': N}`` iff this trl's config still accepts it.
 
    Returns an empty dict on a trl that removed the field, which is the whole
    migration: there is no replacement keyword to pass instead.
    """
    if config_accepts(config_cls, "max_prompt_length"):
        return {"max_prompt_length": max_prompt_length}
    return {}

resolve_trl_symbol does the same thing for the namespace move: it tries trl.BCOConfig first, and only falls back to trl.experimental.bco.BCOConfig on failure, so a trl that hasn't moved the symbol yet keeps taking the supported, non-experimental path with no warning. The regression test doesn't hardcode a version table either — it constructs a fake config class and checks the probe reads it:

# tests/test_trl_version_compat.py:59-66
def test_the_probe_never_reads_the_trl_version(self):
    """A class that still has the field must get the keyword even though the
    INSTALLED trl may be one that removed it — which is only possible if the
    answer comes from the class, not from ``trl.__version__``."""
    from soup_cli.trainer._trl_compat import prompt_length_kwargs
    class StillHasIt:
        def __init__(self, max_prompt_length=None):
            pass
    assert prompt_length_kwargs(StillHasIt, 7) == {"max_prompt_length": 7}

The same "derive what can be derived, pin what can't, and test the pin" discipline shows up again in the torch-floor test, for a different reason: pyproject.toml declares torch>=2.6.0 once, with a comment explaining why (TRL's preference trainers need the FSDP2 API that PyTorch 2.6.0 introduced), and doctor.py keeps a second literal copy for its own dependency report. Three tests keep the two from drifting apart; the third is purely hermetic:

# tests/test_issue636_torch_floor.py:138-159 (trimmed)
def test_doctor_torch_floor_matches_the_pyproject_declaration(self) -> None:
    # Reading installed metadata instead was tried and rejected: dist-info
    # records the install's history, so an editable checkout whose pyproject
    # moved on reports a floor nobody declared, and an uninstalled source
    # tree reports "?" which _version_ok treats as OK.
    from soup_cli.commands.doctor import EXTRA_GROUPS
    torch_rows = [
        (pkg_name, floor)
        for _, members in EXTRA_GROUPS
        for _, pkg_name, floor in members
        if pkg_name == "torch"
    ]
    assert len(torch_rows) == 1
    assert torch_rows[0][1] == _declared_torch_floor(), (
        f"doctor.py checks torch>={torch_rows[0][1]} but pyproject.toml "
        f"[train] declares torch>={_declared_torch_floor()} — doctor must "
        f"report the declared floor (#636)"
    )

The comment names a specific alternative design — read the floor from installed package metadata instead of hand-copying it — and gives the specific reason it was rejected. That's a level of "we considered the obvious fix and here's why it's wrong" that I don't see often, and it's the same shape of engineering as the capability probes above: don't trust a static claim about a dependency, verify it against the real object, and pin the one thing that genuinely can't be derived.

What's Soup's own math, and what's TRL's

It's worth being precise about which parts of this are Soup's own work. DPOTrainerWrapper is representative of the whole preference-loss family: DPO's actual loss math lives entirely inside trl.DPOTrainer/DPOConfig — Soup never reimplements it. What Soup adds around that call is real and not small: model/tokenizer loading across three backends, the quantization menu, the Qwen-family target-module resolution below, batch-size halving specifically because DPO processes paired examples, the capability-probe layer above, ReLoRA/curriculum callbacks, and a DeepSpeed empty-param-group guard. That's genuine orchestration engineering, but it's orchestration — the loss math is TRL's.

Where Soup does own the numerics:

Distillation. There's no trl.DistillTrainer; trainer/distill.py is a hand-written temperature-scaled KD kernel with three divergence options, careful about the one detail that's easy to get subtly wrong — the causal-LM shift has to match between the cross-entropy term and the KD term, or the trained-token mask lands one position off:

# src/soup_cli/trainer/distill.py:49-131 (trimmed)
def _compute_distill_term(student_logits, teacher_logits, divergence, temperature,
                           labels=None, attention_mask=None):
    # Causal-LM alignment: logits at position i predict token i+1, so the CE
    # term shifts (logits[:, :-1] vs labels[:, 1:]). The KD term must shift the
    # SAME way — otherwise the trained-token mask (labels != -100) is applied
    # one position off [...]
    if labels is not None or attention_mask is not None:
        student_logits = student_logits[:, :-1, :]
        teacher_logits = teacher_logits[:, :-1, :]
        ...
    temp = float(temperature)
    log_s = torch.log_softmax(student_logits.float() / temp, dim=-1)
    log_t = torch.log_softmax(teacher_logits.float() / temp, dim=-1)
 
    if divergence == "forward_kl":
        p_t = log_t.exp()
        per_token = (p_t * (log_t - log_s)).sum(dim=-1)
        return _masked_mean(per_token) * (temp * temp)
    ...

This one has a real, closed-form numeric test behind it — not a shape check:

# tests/test_issue719_stable_distill_divergence.py:39-53
def test_forward_kl_matches_probability_space_reference() -> None:
    student = torch.tensor([[[0.2, -0.4, 0.8]]], requires_grad=True)
    teacher = torch.tensor([[[0.5, 0.1, -0.2]]])
    temperature = 2.0
    log_student = torch.log_softmax(student / temperature, dim=-1)
    teacher_prob = torch.softmax(teacher / temperature, dim=-1)
    expected = (
        torch.nn.functional.kl_div(log_student, teacher_prob, reduction="batchmean")
        * temperature**2
    )
    actual = _compute_distill_term(student, teacher, "forward_kl", temperature=temperature)
    torch.testing.assert_close(actual, expected)

Plus parametrized gradient-finiteness tests across float32/bfloat16/float16 × reverse-KL/JS at a deliberately adversarial temperature (0.05) — the exact numerical instability the fix was for.

GRPO variants. Seven named RL objectives, including GSPO (Qwen, arXiv:2507.18071), wired by dynamically subclassing TRL's GRPOTrainer at exactly the compute_loss seam so streaming, DDP, and gradient checkpointing all keep working as TRL built them:

# src/soup_cli/utils/grpo_variants.py:278-290
if normalised == "gspo":
    # Group Sequence Policy Optimization (Qwen, arXiv:2507.18071):
    # Sequence-level importance ratio length-normalized by completion length:
    #   s_i = exp( 1/|y_i| * sum_{t=1}^{|y_i|} (log p_new - log p_old) )
    # Optimized with sequence-level surrogate clipping:
    #   loss = -min(s_i * A_i, clip(s_i, 1-eps, 1+eps) * A_i).mean()
    ...

The module is candid about the limits of its own validation: "the math is intentionally minimal — these are reference kernels for routing + unit tests. Production correctness on multi-billion-param models will be validated by the v0.53.11 smoke run on SmolLM2-135M + gsm8k." dr_grpo is named as partial today too — length normalization is implemented, the doubly-robust bias-correction term the name actually refers to is "left to v0.53.12+," and the schema gate keeps a misconfigured run from silently getting the wrong thing.

Unlearning. NPO, SimNPO, and RMU, each a small hand-written loss:

# src/soup_cli/utils/unlearn_kernels.py:59-73
def npo_loss(policy_logps, ref_logps, *, beta: float = 0.1):
    """Negative Preference Optimization loss over forget-set sequences.
 
    ``L = (2/beta) * mean( -logsigmoid(-beta * (policy_logps - ref_logps)) )``.
    Lower policy log-prob (vs the reference) => lower loss => the fact is
    being forgotten.
    """
    b = _check_beta(beta)
    ratio = policy_logps - ref_logps
    return (2.0 / b) * torch.mean(-functional.logsigmoid(-b * ratio))

And the reward-hack controller above. Everything in this section is genuinely Soup's own numerics; everything in the paragraph before it is genuinely someone else's, orchestrated well. Both are true of the same file tree.

One config, one command?

Now the other half. I walked the real command tree with typer.main.get_command rather than grepping — 77 top-level names, 216 leaf commands once every sub-group is expanded, implemented across 89 files. soup train alone declares 42 of its own typer.Option flags, independent of anything soup.yaml already sets. pyproject.toml's own description says "in one command" — literally true only if you count soup train as the one command that matters and treat the other 215 as an ecosystem around it.

soup.yaml → SoupConfig → training: TrainingConfig237 fields · 2,911 lines · 156 validators
SoupConfig root:basetaskmodalitybackenddatatrainingoutputexperiment_nameevaladvise
21 of 237 are reward_hack_* fields alone
reward_hack_detectorreward_hack_mitigationreward_hack_pid_kpreward_hack_pid_kireward_hack_beta_floorreward_hack_beta_ceilreward_hack_rollback

example fields, not exhaustive — only the reward-hack row above is a published count.

SoupConfig, the object soup.yaml actually deserializes to, is genuinely small — about ten top-level keys, one of which, training, points at TrainingConfig. That one field's schema spans lines 1023–3933 of config/schema.py — 2,911 lines, 237 annotated fields, out of 372 leaf fields across all 8 Pydantic models in the file, guarded by 156 validator methods (73 field-level, 83 model-level) and over 300 raise ValueError sites. A rank-0 LoRA config is a nice small example of how carefully those validators are written — r: 0 is a deliberate, documented full-fine-tuning switch, and the field bound closes a real historical hole:

# src/soup_cli/config/schema.py:53-69 (trimmed)
class LoraConfig(BaseModel):
    # #340 — `r: 0` is the first-class full-fine-tuning switch: no adapter is
    # applied and the base weights train directly. Before #340 a rank of 0
    # reached peft and died with "`r` should be a positive integer value", so
    # nothing that worked before changes meaning. `ge=0` closes the
    # pre-existing hole where a NEGATIVE rank parsed and failed the same way,
    # deep in peft.
    r: int = Field(
        default=64,
        ge=0,
        description=(
            "LoRA rank. 0 = full fine-tuning: no adapter, every base "
            "parameter trains (sft / embedding + transformers + text + "
            "quantization='none' only)."
        ),
    )

The config schema catching a bug before it reaches a different library's C extension is a good, small example of the validators earning their keep rather than just being ceremony.

The best single story about what that surface size costs in practice is one the project tells on itself. Pydantic's default is extra="ignore", and none of the 8 config models override it — so historically, a typo'd or version-skewed key was silently dropped and the run proceeded as if you'd never written it:

# src/soup_cli/config/loader.py:62-92 (trimmed)
def load_config(path: "Path | str") -> SoupConfig:
    """Load a soup.yaml file and return validated SoupConfig."""
    ...
    unknown_error = _report_unknown_keys(raw)
    if unknown_error is not None:
        console.print("[red bold]Config validation error:[/]\n")
        console.print(f"  [red]{for_terminal(unknown_error)}[/]")
        raise SystemExit(1)
    try:
        config = SoupConfig(**raw)
    except ValidationError as e:
        ...
    return config

The module that powers _report_unknown_keys names the exact incident that forced it to exist, by issue number:

#623 is the live case: training.stream_pin reached main two days after 0.73.3 shipped, so a user on the released wheel wrote the documented escape hatch, --dry-run reported "Config valid," the key was discarded, and the resulting OOM was investigated as a layer-streaming defect.

A user hit a real OOM and root-caused it as a training bug, when the actual cause was a version-skewed config key being silently ignored. The fix is a versioned deprecation with a self-enforcing deadline — UNKNOWN_KEY_REJECTION_VERSION = "0.75" — and the deadline has already landed: __version__ is "0.75.0" right now, so an unknown key refuses the load today (SystemExit(1) on the CLI, ValueError in the API) rather than warning. A dedicated test class pins the version and the enforced behavior together so they can't drift apart silently.

Against that, the templated path genuinely is small: soup init --template chat writes a 13-key YAML file, unvalidated, out of 372 possible fields — everything else falls to Pydantic defaults, and soup train defaults --config to exactly the path init just wrote, so the two chain with zero repeated arguments. The one thing the README's 3-line quick-start glosses over is that data.train: ./data/train.jsonl is a path the template doesn't create — you need your own dataset there first. The command that's genuinely zero-input and guaranteed to succeed on the first try is a different one, soup quickstart, which bundles a synthetic dataset and a tiny model and just runs.

Where it breaks

The README's own v0.75.0 changelog entry is the sharpest self-disclosure in the whole project, and it sits three paragraphs below the "one command" pitch in the same document:

the same soup.yaml trained a different recipe on MLX than on transformers, silently. Six training options were validated, documented, accepted — and read by nothing on that backend.

config/backend_support.py exists to catch exactly this class of gap going forward, and its own docstring is worth reading as an example of scoping down deliberately rather than a confession — it explains why the table only covers task=sft on backend=mlx today, having tried and rejected three broader inference strategies by name:

reachability over the import graph detects none of the known gaps, because every trainer's transitive closure is most of the package; reads of the trainer module alone invent gaps for fields that live in helper modules [...] and --dry-run exits before a trainer is ever constructed, so runtime tracing observes nothing. So this table is maintained by review [...] Scope today: task=sft on backend=mlx. Other pairs report nothing rather than guessing.

A separate comment, attached to the module's STATUSES constant rather than that docstring, explains why a third, more ambitious status is deliberately left unbuilt:

honoured would mean enumerating 275 declared fields against every reviewed pair — a table nobody can review honestly, and the opposite of this module's premise that a gap list is short enough to be checked.

Read in place, that's a reason for not building a comprehensive positive table, not an admission that the project's actual gap list is unreviewable — the module's whole premise is that the list it does maintain is short enough to check by hand.

The other tagline worth checking precisely is "No SSH, no config hell." It holds fully for the advertised path — a local GPU, soup train, nothing else — and it stops holding for exactly one opt-in flag combination. soup train --cloud lambda alone is plan-only: it renders a controller script and prints the command, executing nothing. Add --cloud-submit, and submit_lambda_run requires LAMBDA_SSH_KEY_NAME/LAMBDA_SSH_PRIVATE_KEY and launches the rendered script, whose own body does this:

# src/soup_cli/cloud/lambda_labs.py:196-206 (each line is a string literal —
# this is source code Soup WRITES to a generated file, not code that runs
# inside the `soup` process itself)
"def _train_and_copy(ip, private_key):",
"    common = [",
"        '-i', private_key, '-o', 'BatchMode=yes',",
"        '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=15',",
"    ]",
"    wait = subprocess.run(",
"        ['ssh', *common, f'ubuntu@{ip}',",
"         'cloud-init status --wait >/dev/null 2>&1; cat /home/ubuntu/soup.exit'],",

And train.py's own help text says so outright: "--cloud-submit ... Lambda requires a registered SSH key." That's the honest scope: the "No SSH" line holds for the local path and stops holding on one named, opt-in cloud-submission path, documented in the flag's own help text — fair to mention, not fair to overstate as the tagline being false.

One more precise gap, because "supported" turns out to mean two different things at two different layers: the MLX backend's trainer registry lists dpo and grpo as supported tasks, so soup train --backend mlx --task dpo gets past config validation and even loads real MLX models — and then .train() raises NotImplementedError, naming the real blocker (waiting on upstream mlx-lm DPO support) in plain text. Not a silent gap, but a real one: "supported" at the routing layer doesn't mean supported at the layer that actually runs your job.

The scale, plainly

A few numbers, without a verdict attached, recomputed from the git history at commit fcca8ef (1,223 commits, 205 days old):

Both things are true

I don't think this resolves into a verdict, and I don't think it's supposed to. The same codebase that gets the memory arithmetic exactly right, ships an executable proof of its own headline claim, found and repaired a silent gradient bug on borrowed hardware with a public postmortem, and revised a published, DOI'd paper specifically to retract an interpretation nobody outside the project had challenged — is also the codebase where "one config, one command, no config hell" sits in front of 216 commands and a 237-field training config, and where a typo'd config key silently trained the wrong thing for two release cycles before anyone closed the gap. Both are real. The project's own backend_support.py is, in its own words, a module reasoning openly about a table it deliberately refused to build — which is a fair description of the whole codebase, not just that one file.


Built on Soup (Makazhan Alpamys and contributors; commit fcca8ef, 2026-09-13) and its accompanying paper, Exact Layer Streaming: LoRA Fine-Tuning of an 8B Model on a 4GB Laptop GPU (v3, 13 August 2026). Every number here is recomputed from the repository's source, tests, and benchmark records, or quoted directly from them; where I couldn't verify something, I said so.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Soup: an 8B model in 3.6 GB of VRAM, and 216 commands around it", ai.thesatyajit.com, September 2026.

bibtex
@misc{ghana2026soupcli,
  author = {Satyajit Ghana},
  title  = {Soup: an 8B model in 3.6 GB of VRAM, and 216 commands around it},
  url    = {https://ai.thesatyajit.com/articles/soup-cli},
  year   = {2026}
}
share