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_BUFFERING on Windows, O_DIRECT on Linux, F_NOCACHE on 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_ahead layers 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, naming stream_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:

yaml
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: ./output

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

KeyValuesDefaultSinceMeaning at v0.76.0
stream_layersboolfalsev0.72.0BETA. 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_sourceauto, ram, diskautov0.72.0; disk v0.72.3auto 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_buffers2 to 82v0.72.0Pre-allocated VRAM layer buffers. 2 is double buffering
stream_read_ahead1 to 82v0.76.0Disk 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_pintrue, false, unsetunsetv0.74.0; disk tier v0.76.0false 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_overridebytesunsetv0.73.1Replaces the free-VRAM figure the pre-flight checks against
stream_vram_probeboolfalsev0.73.1Measures one real step instead of predicting it. task: sft only
stream_disk_kindnvme, ssd, hddunsetv0.73.3Overrides media detection. Applies to every stripe root too
stream_ngram_sourceauto, ram, diskautov0.74.0Where Qwen4-Exp's external N-gram table lives. A non-default value warns on a checkpoint that has none

Environment variables:

VariableMeaning
SOUP_LAYER_STREAM_CACHE_DIRThe 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_DIRWhere Soup makes the regular-file copy of Hugging Face weights (default ~/.soup/spectrum/weights/). Same containment rule
SOUP_LAYER_STREAM_STRIPE_DIRSNew 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:

bash
export SOUP_LAYER_STREAM_STRIPE_DIRS=/mnt/nvme2/soup-stripe
soup doctor --disk
soup train --config soup.yaml

On 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_kind overrides 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 shortWhat it means
layer streaming's disk tier would hold X GB of host staging ... at training.stream_read_ahead=NStaging 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 readThe 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 limitOne 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/OA 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 MThe 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.

ReaderCold 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 reader16.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:

RoundOne driveTwo drives
117.552 s10.050 s
217.431 s9.971 s
317.438 s9.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: true versus false on this tier.
  • A warm RAM-versus-disk ratio. Upstream's own surfaces disagree. The note Soup prints when auto falls back to the disk tier, and upstream's performance doc, still quote a ratio measured on the reader that #974 replaced; a later record in benchmarks/ 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_DIRECT and macOS F_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.

  • 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 memory with 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

bash
soup doctor --disk

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

See also

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.