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_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

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 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 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_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 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.

yaml
training:
  stream_layers: true
  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 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:

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.

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: sft only. 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, 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, 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.