Layer streaming disk tier (v0.76.0)
Layer streaming keeps the frozen base in CPU RAM when it fits and on NVMe when it does not. That second home, the disk tier, shipped in v0.72.3 and was deliberately left unmeasured. v0.76.0 rewrote it and measured it, on one machine that is not the 4 GB laptop behind the rest of these pages.
This page is the reference for that tier: what it does now, every streaming key and environment variable in one place, the refusals it added, and the measurements with every caveat attached. Layer streaming remains BETA.
What the disk tier is now
Until v0.75.2 the disk tier read each layer from the compute thread through a memory-mapped file. That held every shard handle open for the whole run, and on Windows a mapping charges the file's whole size to commit. v0.76.0 replaced it:
- A background reader thread fetches layers while the GPU computes. It parses each shard's header itself, so nothing is memory-mapped.
- Direct I/O. Each layer's data section is read as sector-aligned byte ranges that bypass the page cache (
FILE_FLAG_NO_BUFFERINGon Windows,O_DIRECTon Linux,F_NOCACHEon macOS), four ranges in parallel. A disk-tier run no longer warms the page cache for whatever runs after it. - Host staging. The reader stages
training.stream_read_aheadlayers at a time in host memory, page-locked where the box allows it. The base is still not resident, but the host now needs the staging: the pre-flight prints it (host staging read_ahead N -> X MB), and the run is refused, namingstream_read_ahead, when the staging plus the resident extras will not fit in 70% of free RAM. It is a refusal and never a silent clamp. - Optional striping of the layer cache across several NVMe drives, with one read in flight per drive. Below.
The run's ready line on this tier reads streamed from DISK (X GB on an NVMe volume) by an async reader, read_ahead=N, M MB pageable|pinned host staging. The RAM tier prints X GB pinned RAM store (Y GB page-locked).
Which tier you get is unchanged. stream_source: auto takes the RAM tier when the store fits free RAM and falls back to the disk tier when it does not; ram insists and refuses rather than falling back; disk forces the NVMe tier. The disk tier is NVMe-class only: SATA SSD, spinning disk and undetectable media are refused, and training.stream_disk_kind (nvme, ssd or hdd) overrides a wrong detection. A store that fits RAM is still faster on the RAM tier, which is why auto picks it.
A config that forces the disk tier on purpose, to try it on a base that would have fitted RAM:
base: meta-llama/Llama-3.1-8B-Instruct
task: sft
backend: transformers
data:
train: ./data.jsonl
format: alpaca
max_length: 512
training:
epochs: 1
batch_size: 4
quantization: 4bit
stream_layers: true
stream_source: disk # force the NVMe tier; auto picks it only when RAM cannot hold the store
stream_read_ahead: 3 # layers staged in host memory; 1 to 8, default 2
lora:
r: 16
output: ./outputThat file loads on the v0.76.0 binary (soup doctor --config soup.yaml). Whether it runs depends on the box: the NVMe rule and the host-RAM check are applied at trainer setup.
Every streaming knob at v0.76.0
Config keys, all under training:. A non-default value of any of them except stream_layers is refused at config load while stream_layers is false, and stream_buffers and stream_read_ahead refuse a boolean.
| Key | Values | Default | Since | Meaning at v0.76.0 |
|---|---|---|---|---|
stream_layers | bool | false | v0.72.0 | BETA. Needs backend: transformers, modality: text, a task in sft, dpo, orpo, simpo, kto, quantization of none or 4bit, a concrete batch_size, and lora.r >= 1 |
stream_source | auto, ram, disk | auto | v0.72.0; disk v0.72.3 | auto takes RAM when the store fits free-RAM headroom and the physical ceiling, else the disk tier. ram insists and refuses rather than falling back. disk forces the NVMe tier |
stream_buffers | 2 to 8 | 2 | v0.72.0 | Pre-allocated VRAM layer buffers. 2 is double buffering |
stream_read_ahead | 1 to 8 | 2 | v0.76.0 | Disk tier only. Layers the background reader stages ahead. One slot is always held by the layer being consumed, so a setting of N stages N-1 layers ahead. Each level costs one layer of host memory; the pre-flight prints the total (host staging read_ahead N -> X MB). A non-default value with stream_source: ram is refused. With several stripe drives the default is raised, see below |
stream_pin | true, false, unset | unset | v0.74.0; disk tier v0.76.0 | false forces pageable memory and prints the cost. true forces page-locking and refuses if the box cannot: on the RAM tier it names the store size, on the disk tier it names stream_read_ahead. On CPU and MPS there is nothing to page-lock, so it is announced and the run proceeds |
stream_vram_override | bytes | unset | v0.73.1 | Replaces the free-VRAM figure the pre-flight checks against |
stream_vram_probe | bool | false | v0.73.1 | Measures one real step instead of predicting it. task: sft only |
stream_disk_kind | nvme, ssd, hdd | unset | v0.73.3 | Overrides media detection. Applies to every stripe root too |
stream_ngram_source | auto, ram, disk | auto | v0.74.0 | Where Qwen4-Exp's external N-gram table lives. A non-default value warns on a checkpoint that has none |
Environment variables:
| Variable | Meaning |
|---|---|
SOUP_LAYER_STREAM_CACHE_DIR | The layer-shard cache root (default ~/.soup/layer-stream/). Must resolve under your home directory, the current directory or the temp directory, otherwise it is ignored and the default is used |
SOUP_SPECTRUM_CACHE_DIR | Where Soup makes the regular-file copy of Hugging Face weights (default ~/.soup/spectrum/weights/). Same containment rule |
SOUP_LAYER_STREAM_STRIPE_DIRS | New in v0.76.0. Extra cache folders on other NVMe drives. Rules below |
On one drive, stream_read_ahead is a host-memory knob: in upstream's record for the earlier buffered reader, depths 1, 2 and 4 were indistinguishable once run order was controlled for, and no record compares depths on the direct-I/O reader. Upstream's docs put the staging at 1.5 GB at depth 1, 2.0 GB at 2 and 2.8 GB at 4 on a 70B shape. Lower it when the host-RAM check refuses a run. With stripe drives the depth decides how many drives can read at once (below).
Striping the cache across NVMe drives
On a cold store larger than RAM the disk-tier step can be bound by the read, and then one drive is the ceiling. Set SOUP_LAYER_STREAM_STRIPE_DIRS to extra folders on other NVMe drives, separated by ; on Windows and : elsewhere:
export SOUP_LAYER_STREAM_STRIPE_DIRS=/mnt/nvme2/soup-stripe
soup doctor --disk
soup train --config soup.yamlOn Windows, in PowerShell, the same setting is $env:SOUP_LAYER_STREAM_STRIPE_DIRS = "D:\soup-stripe". The variable and its rules are read from the code, and soup doctor was run with it set; no two-drive training run was made for this page.
With N roots (the primary cache root plus the folders you list), decoder layer i is stored on root i mod N. The index, the extras and the embedding and head files stay on the primary root, and the reader keeps one layer in flight per drive. The cache is laid out this way whichever tier the run picks; only the disk tier reads the drives in parallel. The bytes are exactly the ones a single-drive cache holds, so striping changes the speed, not the result.
Every entry is validated, and a bad one refuses the run by name: a silent fallback would train at single-drive speed with nothing said. An entry must:
- be an absolute path (on Windows a drive letter or UNC share, with no
\\?\or\\.\spelling and no control characters); - already exist as a folder (Soup creates only its own folder inside it, never the root, so an unmounted drive cannot be mistaken for an empty one);
- not be a symlink or a directory junction (a genuine Windows volume mount point is accepted);
- not lie inside, or contain, the primary cache root or another entry;
- sit on a different volume from the primary root and from every other entry;
- classify as NVMe (
stream_disk_kindoverrides a wrong probe).
At most six folders are accepted. The checks that need no disk probe run when soup train or soup train --dry-run starts (only when stream_layers is true; a bad entry exits 1 with a friendly error). soup doctor lists each entry as a Stripe root row under System Resources, and soup doctor --disk also applies the NVMe rule. Changing the list re-shards the cache; unsetting it re-shards to one root and names the folder left behind. The disk pre-flight charges each drive the exact bytes it will receive, read from the source safetensors headers; when a header cannot be read, or for qwen4_exp, it splits evenly and prints approximately.
Read-ahead rises with the drives. Each drive needs a read in flight beside the slot being consumed, so the default depth becomes the number of roots plus one, capped at 8. A configured value of 2 counts as the default and is raised the same way; any other value is kept as set, with a yellow warning when it leaves drives idle. It costs one more layer of host staging per extra drive.
What Soup does inside a stripe folder. It writes only a folder of its own, named after the model plus a hash of the primary cache's path, so two primary caches sharing one stripe drive never touch each other's files. That folder is made private to your account (mode 0700 on POSIX, a protected ACL on Windows) and is checked again on every reuse and before every per-layer write. Soup never deletes anything inside a stripe folder. Two limits are upstream's own: a root swapped for a link while a shard is being written is caught at the next write, which narrows the window to a single write and does not close it, and Soup makes only its own folder private, so make the stripe root itself writable only by your own account (on a default Windows data drive other accounts can modify it).
New refusals and notices
Config-load refusals that name stream_layers are in the table on Layer streaming. The ones this tier adds at setup or run time:
| Message, in short | What it means |
|---|---|
layer streaming's disk tier would hold X GB of host staging ... at training.stream_read_ahead=N | Staging plus resident extras does not fit 70% of free RAM. Lower stream_read_ahead, free RAM, or use a smaller base |
training.stream_pin=true but this box could not ... | stream_pin: true on a CUDA target that cannot page-lock. On the disk tier this used to be announced and ignored |
SOUP_LAYER_STREAM_STRIPE_DIRS entry '<entry>': ... | A stripe root broke a rule above. The entry is shown as typed |
layer streaming's disk tier was handed N distinct layer shapes ... | More than eight different layer shapes in one shard index. A real model has one to three |
layer N's shard changed on disk since its header was read | The cache was re-sharded while a run was reading it. Re-shard and restart |
layer-stream reader has been reading layer N ... past the 300 s limit | One read was in flight for over 300 seconds, which is treated as wedged. The run stops rather than blocking |
layer streaming's staging is not sector-aligned on this box; the disk tier reads through the page cache instead of direct I/O | A yellow notice, not an error. Direct I/O is off for this run |
Re-sharding layer cache: <reason>. and Unindexed layer-cache file <path> (<n> bytes) is present; it was not named by the committed cache index. | Cache notices, printed instead of rebuilding silently |
the streamed run saved no adapter_model.safetensors ... or ... holds N LoRA tensors, but the streamed model trained M | The v0.76.0 adapter save guard, on every streamed task |
The ready line does not say whether direct I/O is on, and a filesystem that refuses direct I/O (tmpfs, for example) falls back to buffered reads and is logged, not printed.
What was measured
Everything here was measured on one machine that is not a 4 GB laptop: an RTX 5070 Laptop 8 GB (Blackwell), an i9-14900HX, 31.7 GB of RAM, two Samsung PM9B1 NVMe drives, Windows 11 Pro, torch 2.14.0, transformers 5.17.0, bitsandbytes 0.50.2. The store is a synthetic 70B-shaped NF4 store of 36.39 GB with random weights, larger than the box's RAM, run cold at batch 1 and sequence 512. The weights are random, so these are timings only: no quality claim, no real 70B checkpoint was trained, and peak VRAM was 4.38 GB, which is above a 4 GB card's limit. Units are decimal GB.
| Reader | Cold step |
|---|---|
| The previous synchronous memory-mapped reader (v0.75.2 and earlier) | 110.5 s in the slot matched against the new reader. The same reader measured 100.35 s and 92.25 s in the controls of the earlier #971 record |
| The v0.76.0 direct-I/O reader | 16.2 to 17.3 s at both run positions, with the read taking 62 to 64% of the step at 6.0 to 7.0 GB/s |
An intermediate version of the background reader without direct I/O measured 34.8 to 45.6 s in the adjacent slots; it was replaced inside the same release. The record found that every buffered read of that drive topped out at 1.2 to 2.9 GB/s whatever the request shape, while unbuffered reads of the same layers reached 3.5 to 5.65 GB/s, which is why the reader bypasses the page cache. The new reader's two run positions differ by 1.07x, where the readers it replaced differed by 1.3x to 1.6x with the position: a reader that does not use the page cache gives much the same number wherever it runs.
Two NVMe drives. The same store, striped across the laptop's two drives (decoder layer i on drive i mod 2), at the same shape:
| Round | One drive | Two drives |
|---|---|---|
| 1 | 17.552 s | 10.050 s |
| 2 | 17.431 s | 9.971 s |
| 3 | 17.438 s | 9.929 s |
A 17.4 s step fell to 9.97 s, about 1.75x in each round. That is a burst from a rested box, and the page does not quote it without that. The gate was decided by a rule committed before the striped cache existed. Its arms were three timed steps each after one warm-up, and they differ in read-ahead depth (2 for one drive, 3 for two, the feature's default for two drives). The two drives are not equivalent: one sits on CPU lanes and the other behind the chipset. Under sustained back-to-back reading, both drives settled near 2.5 GB/s each for the rest of the arms that reached that state, and a sustained-load probe measured late-window speed-ups of 1.28x and 1.41x in two rounds and reached no formal verdict; the cause of the plateau is not established. Upstream's own planning advice is roughly 1.3x to 1.4x for a long run, on drives with good cooling.
What was not measured
- A 4 GB card on the disk tier. Peak VRAM here was 4.38 GB. No disk-tier throughput exists for the card the rest of these pages were measured on.
- Pinned versus pageable staging. The 6.56x cost of turning page-locking off (32B NF4 on the H100 box, Validation on hardware we do not own) was measured on the RAM store. Upstream's docs say the same mechanism applies to the reader's staging, and that its magnitude there is not measured. Pageable staging through direct I/O has a tested sector layout and an untested speed. No throughput is claimed for
stream_pin: trueversusfalseon this tier. - A warm RAM-versus-disk ratio. Upstream's own surfaces disagree. The note Soup prints when
autofalls back to the disk tier, and upstream's performance doc, still quote a ratio measured on the reader that #974 replaced; a later record inbenchmarks/measured the reader that shipped, in the same warm case, and got a different one. That later comparison is of the read path (no byte reached the SSD). This page quotes neither. - Linux
O_DIRECTand macOSF_NOCACHE. Reviewed by readers and executed by none: the measuring box runs Windows. - Other drive pairs, and any real checkpoint. Sequence 256 and batches 2 and 3 were measured by other records on the same box (measurement records); this page quotes none of those figures.
- The cause of the throttling. Drive temperature was unreadable in the record.
A note on exactness on this card
The measuring box is a Blackwell card, where streamed NF4 and resident NF4 took different bitsandbytes paths in v0.75.0 (tracked as #776). v0.76.0 routes the streamed NF4 forward through bitsandbytes' own fused-versus-dequantized dispatch, the same kernels as resident NF4 (the backward still dequantises). The only evidence that this fixes the Blackwell inexactness is contributor-credit text and tests; benchmarks/ has no row recorded after that change and #776 is still open in the records. Nothing on this page upgrades "verified bit-exact" to the new dispatch. The correctness evidence for the new reader is separate: byte-identity gates against the previous disk source, on CPU, with one, three and four ranges, with and without direct I/O, and with pinned and pageable staging. The disk tier is not on the path of the CUDA exactness tests. The full exactness ledger.
Related fixes in the same release
- A failed page-lock no longer looks like an out-of-memory error. A store that could not be page-locked left a stale CUDA error that the next kernel launch reported as
CUDA error: out of memorywith gigabytes free. The stale error is now cleared before the pageable fallback. 14B on an 8 GB card. - The pinned store asks the host for less. It is pinned in a few power-of-two arenas rather than one tensor at a time, and packed largest tensor first when that page-locks fewer bytes. A Qwen3-8B NF4 store of 6.07 GB page-locks 6 GiB where it took 8 GiB. This is a memory change with no speed claim and no setting.
- Sharding a very large checkpoint on Windows no longer dies. At most two source handles stay open, instead of every source file's mapping (#926 in #934).
- An interrupted re-shard. A re-shard that changed the cache's content and was interrupted could, before v0.76.0, leave the old index pointing at partly rewritten files. The old index is now moved aside first and unfinished re-shards are reported. Upstream does not say whether any run reused such a cache, so as a precaution: if you interrupted a re-shard on v0.75.2 or earlier, delete that model's folder under the cache root (and any matching folder under a stripe root) before the next run. Shard cache.
soup doctor --disk
soup doctor --diskReports NVMe, SATA SSD, HDD or Unknown for the current directory's volume. It is opt-in because the probe costs about 9 seconds cold on Windows, and on Linux it can write a roughly 64 MiB scratch file (.soup-diskprobe-*) into the directory it probes (the current directory for soup doctor --disk), reads it back with O_DIRECT, and removes it, when the kernel's rotational flag is unreliable. With SOUP_LAYER_STREAM_STRIPE_DIRS set, plain soup doctor also lists each entry as a Stripe root row, and adds an issue when one is refused. This probe is unchanged from v0.75.2.
Measurement records
The working records behind this page are in the benchmarks directory, failures and discarded numbers included. Link a file's top rather than a passage: these files keep wrong-then-corrected readings in the order they happened.
gate-974-disk-tier-direct-io.md— the direct-I/O reader, warm and cold, and what was not measured.gate-971-async-nvme-source.md— the background reader this one replaced, including a run-order effect that changed its headline.gate-two-drive-striping.md— the two-drive gate, with the three earlier sequences that ended with no verdict.probe-rtx5070-two-drive-sustained.md— the sustained-load probe, which reached no verdict.gate-901-14b-on-8gb.mdandgate-1702-arena-packing.md— the page-lock fixes.
See also
- Layer streaming — the mechanism, the 4 GB laptop rows, the full refusal table.
- Scaling a streaming run — architectures, batches, the VRAM pre-flight, resume, and 14B on an 8 GB card.
- Preference losses over streaming — DPO, ORPO, SimPO and KTO against a streamed base.
- Measurement records — how to read the benchmark records.
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.