Scaling a streaming run (v0.72.3)

Layer streaming proved a mechanism: the frozen base never loads resident, so peak VRAM is bounded by one decoder layer instead of the whole model, and NF4 shrank that store far enough to put Llama-3.1-8B on a 4 GB laptop GPU.

It was also, on purpose, barely usable for planning a real run. Llama and Qwen only. Batch size 1. No gradient accumulation. No resume. RAM or nothing.

v0.72.3 lifts all of it, and each capability was gated against a streamed-versus-resident bit-exactness reference before it was written rather than after.

Which models stream

Ten model_type values are on the allowlist. Six of them arrived in v0.72.3 and the tenth in v0.74.0:

model_typeSinceNotes
llamav0.72.0the 8B row on the layer streaming page is this one
qwen2v0.72.0every throughput row on these pages is Llama or Qwen
qwen3v0.72.0
mistralv0.72.3
gemmav0.72.3
gemma2v0.72.3
gemma3_textv0.72.3the text-only config. See the warning below
phiv0.72.3
phi3v0.72.3fuses Q, K and V into one qkv_proj, so there is no q_proj to find
qwen4_expv0.74.0float32 tiny-model parity only. See the caveat below

Four aliases route onto families already on the list rather than counting as new ones: qwen3_5, qwen3_5_text, qwen3_5_moe and qwen3_5_moe_text all reach the qwen3 streamer, and qwen4_exp_text reaches qwen4_exp. Upstream states why each alias needs its own control: mapping a model type by name alone is not enough to establish that its decoder graph is safe.

The v0.74.0 additions carry weaker evidence than the original nine, and the difference is stated rather than smoothed over. Only the original nine are verified bit-exact in both bf16 and NF4. Qwen3.5's dense and MoE decoder paths are verified against resident controls on CPU; the MoE path also has live streamed training on Qwen3.5-35B-A3B with NF4 and MoE LoRA on a 3072-token dataset, and that run has no resident control at all, because no available machine can hold 35B resident. Qwen4-Exp has an exact float32 tiny-model parity gate, including its external N-gram table, and real-checkpoint plus NF4 validation are still pending. Its N-gram access is governed by training.stream_ngram_source (auto, ram, disk).

Each of the six added in v0.72.3 was verified bit-exact against the same checkpoint loaded resident, under both bf16 and NF4. Phi-3 is the interesting one: streaming has to substitute weights into a layer whose attention projection is fused, and it comes out bit-identical anyway.

gemma3 is not gemma3_text. A real google/gemma-3-* checkpoint reports model_type: gemma3, which is the vision-capable wrapper, and it is refused. Streaming a multimodal wrapper as if it were a causal LM is exactly the failure an allowlist exists to prevent, so the refusal is deliberate rather than an oversight.

There is no throughput measurement for any family beyond Llama and Qwen, so none is claimed — not for the six added in v0.72.3, and not for Qwen3.5 or Qwen4-Exp either. Every tok/s figure published for layer streaming is Llama or Qwen. What was established for the newer families is correctness, not speed.

Batch size beats gradient accumulation

Both were refused before v0.72.3 and both work now. They are not interchangeable, and the release measured the difference instead of reasoning about it.

Measured on Qwen2.5-0.5B in bf16, sequence length 256, pinned RAM store, 50 steps after 10 warm-up:

batch_sizegradient_accumulation_stepsThroughputPeak VRAM
11556.6 tok/s0.842 GB
14540.1 tok/s0.846 GB
411378.0 tok/s2.28 GB

At the same effective batch of 4, raising batch_size measured about 2.52x faster than accumulating.

The reason is structural, not incidental. A real batch amortises each layer's load over more tokens. Accumulation does not: every micro-batch re-reads the entire base, so the layer reads per 1000 tokens stayed flat across accumulation 1, 2 and 4. Accumulation is not useless, though. It holds peak VRAM almost exactly flat (0.842 GB to 0.846 GB) where batch 4 cost 2.28 GB, which is the whole point of it.

The rule: raise batch_size while the VRAM budget allows, then accumulate for the rest. Soup prints that advice at run start when it sees you accumulating.

The VRAM pre-flight

Once batches scale, a new way to fail appears, and it is not obvious from the outside.

Streaming bounds the weights. It does nothing about activations or the logits tensor, and both scale with batch_size times data.max_length. On a model with a 151,936-token vocabulary at batch 8, the logits tensor alone measured 8.71 GB, which is 146 times the entire layer-buffer pool of 0.060 GB. Streaming the weights perfectly and then allocating an 8.71 GB logits tensor is not a win.

So soup train now predicts peak VRAM before the run and refuses one that will not fit. The panel it prints carries the budget and a forecast, here for a small model at batch 2 on the 4 GB development card:

  peak VRAM    ~0.48 GB at batch 2 x seq 256 (logits 0.35 GB)
  free VRAM    3.46 GB
  forecast     5685-8361 tok/s — a compute-bound bound, not a promise
               (from a GEMM ceiling measured on this card now @ 862 MHz)

Above those lines the panel also names the architecture and tier, the size of the layer store, the buffer pool and what stays resident. Those figures scale with the model, so the four lines above are the ones worth reading before a run: they are the only part that changes when you change batch_size or max_length.

And when it does not fit:

a streaming step is predicted to need X GB of VRAM but only Y GB is free.
Streaming bounds the WEIGHTS, not the activations or the logits — lower
training.batch_size or data.max_length, both of which scale this linearly.

The estimator had a real bug in it

The first version of the budget charged 6 bytes per logit element from first principles. The measured peak is 14. The old constant under-predicted that term by 2.33x, which on the largest term in the whole budget is the difference between a prediction and a guess.

A later measurement on different hardware took that 14 apart stage by stage, with three repeats and no spread at all, and the total held while the explanation did not. The number is 12 plus 2: the loss arithmetic costs 12, being the bf16 logits alongside three fp32 buffers of the same shape, and the remaining 2 is a retention, the cost of holding the model's output object alive across backward(). An earlier telling of this said "bf16 logits, fp32 upcast, log-softmax output and gradient, all live at once", which is right in total and wrong in detail: the upcast is freed when the loss function returns. The 12 is also stack-independent, measured identically on two very different torch and TRL versions.

The corrected estimate was fitted against ten real runs across two models with a 3.1x vocabulary contrast, at batches 1 to 8 and two sequence lengths. Worst error: 0.85%, and it never under-predicted any of them.

And then it did under-predict, at long sequence (v0.73.1)

That "never under-predicts" was the estimator's contract, and it is the reason the whole gate is trustworthy: on Windows an over-budget allocation does not raise, it spills silently into host memory. v0.73.1 measured the contract failing.

Through the real soup train on the same 4 GB laptop, with SmolLM2-135M streamed in bf16 at batch 1:

SequencePredictedReal peakRatio
43523.282 GB3.036 GB1.081x, over-predicts, safe
51203.844 GB4.118 GB0.934x, under-predicts
61444.590 GB5.830 GB0.787x, under by 21%

The ten-run fit above could not have caught this, and that is the lesson. Every one of its rows sits at sequence 256 or 512. It varies batch, so it says nothing whatever about sequence length. A control only covers the variable it varies.

The mechanism is deliberately not claimed. The obvious candidate, the quadratic term from the attention score matrix, does not settle the numbers, and a formula cannot model a term nobody has identified. So the answer shipped is not another coefficient, it is a measurement, and the fitted estimate is left in place as the default. See v0.73.1 for the probe that replaces it on request.

A second box has since said this does not reproduce there. A contributor ran the same protocol on an NVIDIA A10G with a much newer stack (torch 2.13, transformers 5.16.1) and the ratio is flat at about 1.16x over-prediction from sequence 2048 through 6144, on two models. The record publishes its leading hypothesis as a negative result with a positive control, and explicitly does not refute the reading for this 3050. Taken together the honest conclusion is that the term varies per-stack *and* per-sequence-length, which is why the answer is a measurement rather than a constant. See the v0.74.0 record.

Why refusing beats trusting the driver

On Windows, WDDM does not raise on overcommit. It pages to host memory instead. During this work a run that should have failed outright instead reached a 9.27 GB peak on a card with 4.29 GB, silently, at a throughput nobody would want. That is the case the pre-flight exists to catch: not a crash, but a run that appears to work.

The throughput forecast

The panel quotes a throughput range, and where that range comes from is a deliberate design choice: a GEMM ceiling measured on your own card in that session, printed next to the SM clock. Never a compiled-in per-card constant.

That is a response to a measurement rather than a preference. This one card produced 3.5 and 7.6 TFLOPS in two different sessions at the same reported clock. A baked-in table would have been wrong about half the time, on the same hardware. Real streamed runs land at 68 to 100% of their measured ceiling, which is why the line calls itself a bound and not a promise.

Resume works now, and why it could not before

--resume and --hf-resume were refused for streaming runs. The refusal was not caution; it was covering a bug.

Adapters could not be loaded into a streamed model at all. PyTorch's load_state_dict narrows keys by child name, and the streaming wrapper holds the real decoder layer as a child, so a canonically saved checkpoint matched 0 of N tensors. PEFT reports missing keys as a warning rather than an error, so nothing failed. A resumed run simply reproduced the from-scratch loss curve exactly, which is the kind of bug that looks like a successful run.

The fix redirects canonical keys at load time, mirroring the save-side fix from v0.72.1. It is load-side only, which is worth stating precisely: v0.72.0's forward-path bit-exactness results stand without being re-run, because nothing in the forward path changed.

What was verified: the adapter round-trip now lands every tensor, the device map is preserved, decoder parameters stay on the meta device (so the run is still streaming, not quietly materialised), and loss continuity holds on the production CUDA path.

What could not be demonstrated, and why it is not streaming's fault: end-to-end soup train --resume on the development box. transformers refuses torch.load below torch 2.6 under CVE-2025-32434, and that blocked every resume on that machine, streaming or not. (Since unreachable in v0.75.0, which raised the declared floor to torch>=2.6.0 and proved it in CI, so no supported install sits below it. The streaming half was verified on the production CUDA path at the time; what was missing was a box that could run the control.)

Still open: loading an adapter into a streamed model works, but in memory named_parameters() and state_dict() still disagree. That is the deliberate cost of fixing serialisation rather than rewriting the wrapper.

The disk overflow tier

If the base does not fit in RAM either, v0.72.3 streams it from NVMe, holding nothing resident.

yaml
training:
  stream_layers: true
  batch_size: 1          # streaming refuses batch_size 'auto', which is the default
  stream_source: auto    # RAM when the base fits, NVMe when it does not
stream_sourceBehaviour
auto (default)RAM when the base fits it, otherwise fall back to the NVMe tier, with a printed note
raminsist on RAM. Refuses rather than falling back, so a run that must stay in RAM cannot quietly become a disk run
diskforce the NVMe tier even when RAM would have fit

NVMe-class only. SATA SSD, spinning disk and undetectable media are all refused. That is not fussiness: the streamer reads every shard twice per step, so an 80-layer model is 160 seeks per step on a spinning disk and the run thrashes for hours. Unknown media is refused rather than guessed, because guessing wrong costs more than stopping.

What "NVMe-class" means changed in v0.73.3, and it matters on a cloud box. Detection used to trust the kernel's rotational flag, which a paravirtual (virtio) device reports as 1 with no media hint — so a genuinely NVMe-backed cloud disk, measured at 1.5 GB/s, was classified as a spinning one and denied the tier it was built for. rotational=0 still settles it as solid state. When the flag is unreliable, the media type is now decided by a bounded direct sequential read rather than by the flag: at or above 1 GB/s the disk earns the tier, and a genuinely slow one still does not. Deliberately above SATA's practical ceiling, and an unmeasurable disk is still treated as the slow case, which is the safe direction.

training.stream_disk_kind (nvme / ssd / hdd) is the escape hatch for when even that is wrong. It prints what it overrode beside what was detected, and it carries no measured rate on purpose, so a later refusal can never cite a reading you overrode.

Check what Soup detects on your machine:

bash
soup doctor --disk

It reports NVMe, SATA SSD, HDD or Unknown, and it is opt-in because the probe costs about 9 seconds cold (about 2.4 seconds warm) on Windows. On Windows, where a machine can present several physical disks with no way to attribute the volume, Soup reports the worst one it found.

On Linux the probe writes, and that is a side effect rather than just a time cost, so it is worth stating plainly: where the kernel's rotational flag is unreliable, Soup writes a small scratch file beside where the shards would go, reopens it with O_DIRECT to bypass the page cache, and times a few bounded page-aligned sequential reads, keeping the fastest one so a single cold sample cannot under-measure a fast device and wrongly refuse it. The scratch file is always removed, any failure (no O_DIRECT on that filesystem, no write permission) returns nothing so the caller stays conservative, and each read is bounded so the probe cannot grow into the 9-second cost the Windows path already carries. Media it cannot identify stays unknown, and the disk tier refuses unknown rather than guessing.

The honest part

The disk tier's correctness is verified: it is bit-exact against the RAM tier.

How much slower it is has not been measured, and no figure is claimed for it. The reason is that a like-for-like comparison is hard to construct honestly on the development hardware. safetensors memory-maps the shards, so on a machine with spare RAM the OS page cache keeps them resident between steps, which means the "disk" tier is partly a RAM tier. And at roughly 5 effective TFLOPS the NVMe read largely hides under compute anyway. A number measured there would be misleading rather than merely incomplete, so there is no number.

A config that uses all of it

yaml
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
backend: transformers

data:
  train: ./data.jsonl
  format: alpaca
  max_length: 512

training:
  epochs: 3
  lr: 2e-5
  batch_size: 4                    # raise this first; the pre-flight sizes it
  gradient_accumulation_steps: 2   # then accumulate for the rest
  quantization: 4bit               # NF4: ~4x smaller store
  gradient_checkpointing: true
  stream_layers: true
  stream_source: auto              # RAM, falling back to NVMe
  stream_buffers: 2
  lora:
    r: 64
    alpha: 16

output: ./output

Those batch and accumulation values are illustrative, not a recommendation. Whether they fit depends on your card, the model's vocabulary and max_length, and the pre-flight will tell you before the run starts rather than after.

Also in v0.72.3

Three fixes and one dependency cap that are not about capability:

  • The guard meant to refuse non-NVMe media was wired to a hardcoded constant and could never fire. It fires now.
  • Streaming weight sources are released when training ends or raises. This matters more on the disk tier, which holds one open shard handle per decoder layer.
  • Subprocess helpers resolve tools to absolute paths, because on Windows CreateProcess searches the current directory before PATH.
  • The [mcp] extra is capped below 2.0. The MCP SDK's 2.0.0 release removed an API soup mcp serve round-trips through, which broke its round-trip tests for anyone installing fresh. Support for the 2.x API is tracked separately.

What is still out of scope

Layer streaming remains BETA.

  • task must be one of sft, dpo, orpo, simpo, kto. The four preference losses landed in v0.72.4; ipo, bco and the unified task: preference dispatcher are still refused.
  • GRPO and PPO are explicitly not planned. Rollouts need generation, and generation re-reads the model per token, which destroys the amortisation streaming depends on.
  • backend: transformers, modality: text, plain LoRA, and quantization of none or 4bit.
  • batch_size: auto is refused: the auto-batch probe sizes a resident model, which a streaming run never loads.
  • Nothing above 8B has been measured on the 4 GB card, so nothing on this page speaks for a larger model there. 14B, 32B and 72B were measured later on a borrowed 8x H100 box, which also turned up a gradient defect in NF4 above roughly 165 MB per decoder layer.

Measurement records and citation

Two of this page's results are written up in the preprint rather than only logged: the peak-VRAM predictor, including the corrected 14-bytes-per-logit-element constant, and the finding that gradient accumulation is per-token I/O-neutral with a 2.52x opportunity cost against batch size. The paper page summarises both. The raw gate record for this release, failures and discarded numbers included, is in the benchmarks directory.

See also

  • Layer streaming — the mechanism, the measured numbers and the full refusal table.
  • Preference losses over streaming — DPO, ORPO, SimPO and KTO against a streamed base, and why the reference model costs no extra weights.
  • The layer streaming paper — the preprint behind these numbers, what it measured and what it explicitly does not claim.
  • Training — everything a streamed run otherwise follows unchanged.
  • Speed and memory — the knobs that matter when the model does fit resident.

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.