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
Nine model_type values are on the allowlist. Six of them are new in v0.72.3:
model_type | Since | Notes |
|---|---|---|
llama | v0.72.0 | the 8B row on the layer streaming page is this one |
qwen2 | v0.72.0 | every throughput row on these pages is Llama or Qwen |
qwen3 | v0.72.0 | |
mistral | v0.72.3 | |
gemma | v0.72.3 | |
gemma2 | v0.72.3 | |
gemma3_text | v0.72.3 | the text-only config. See the warning below |
phi | v0.72.3 | |
phi3 | v0.72.3 | fuses Q, K and V into one qkv_proj, so there is no q_proj to find |
Each of the six 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 reportsmodel_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 of the six new families, so none is claimed. Every tok/s figure published for layer streaming is Llama or Qwen. What was established for the new 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_size | gradient_accumulation_steps | Throughput | Peak VRAM |
|---|---|---|---|
| 1 | 1 | 556.6 tok/s | 0.842 GB |
| 1 | 4 | 540.1 tok/s | 0.846 GB |
| 4 | 1 | 1378.0 tok/s | 2.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 6.75 TFLOPS 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, because transformers holds four things at once: the bf16 logits, the fp32 upcast, log-softmax's fp32 output, and the fp32 gradient. 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.
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-predicts any of them.
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 blocks every resume on that machine, streaming or not.
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.
training:
stream_layers: true
stream_source: auto # RAM when the base fits, NVMe when it does notstream_source | Behaviour |
|---|---|
auto (default) | RAM when the base fits it, otherwise fall back to the NVMe tier, with a printed note |
ram | insist on RAM. Refuses rather than falling back, so a run that must stay in RAM cannot quietly become a disk run |
disk | force the NVMe tier even when RAM would have fit |
NVMe 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.
Check what Soup detects on your machine:
soup doctor --diskIt 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.
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
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: ./outputThose 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
CreateProcesssearches the current directory beforePATH. - The
[mcp]extra is capped below 2.0. The MCP SDK's 2.0.0 release removed an APIsoup mcp serveround-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: sftonly. Preference losses (DPO, ORPO, SimPO, KTO) are v0.72.4.- 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, andquantizationofnoneor4bit.batch_size: autois refused: the auto-batch probe sizes a resident model, which a streaming run never loads.- Nothing above 8B has been measured, so there is no 14B or 70B claim, and the 14B-on-8 GB reference benchmark stays hardware-blocked.
See also
- Layer streaming — the mechanism, the measured numbers and the full refusal table.
- 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.