Layer streaming: fine-tune a model that does not fit in your card (BETA, v0.72.0; NF4 v0.72.2; breadth v0.72.3; preference losses v0.72.4; external validation and the NF4 gradient repair v0.73.0)

The usual answer to "this model does not fit" is quantize harder, rent a bigger GPU, or give up. Layer streaming is a fourth answer: never load the frozen base at all. Soup keeps it in CPU RAM (or on NVMe) and copies it into VRAM one decoder layer at a time, so peak VRAM is bounded by the size of one layer instead of the whole model.

Quantizing that streamed base to NF4 shrinks it about fourfold, and that is what turns the idea into a useful one:

Llama-3.1-8B fine-tunes on a 4 GB laptop GPU at 119.6 tok/s in 3.32 GB — on a card that cannot hold even a quarter of the model.

This ships BETA, and both the scope and the claim below are narrow on purpose.

If you would rather watch it than read it: the 90-second run shows the pre-flight panel (a 3.60 GB base store pinned in RAM across 32 layers, two 113 MB VRAM buffers) and then the measurement card settling at 3.32 GB and 119.6 tok/s, stopping short of the 4 GB line. The mechanism is written up in a preprint, summarised on the paper page.

v0.72.3 widened it. The first three releases were deliberately narrow: Llama and Qwen only, batch size 1, no gradient accumulation, no resume, RAM only. All of that is lifted. Ten architectures are supported as of v0.74.0, batches and accumulation work, --resume works, a pre-flight predicts peak VRAM and refuses a run that will not fit, and an NVMe overflow tier handles a base too big even for RAM. See Scaling a streaming run for the sizing rules, the batch-versus-accumulation measurement and the disk tier.

v0.72.4 added alignment. Streaming used to mean supervised fine-tuning only. dpo, orpo, simpo and kto now run against a streamed base, and DPO's reference model is the same streamed base with its adapters switched off, so it costs no extra weights. See Preference losses over streaming.

Read this first if you already trained with streaming

Two defects in this feature's history produced runs that looked completely healthy and were not. Both are narrow, both have a one-line test, and neither affects a run outside its stated scope.

If you streamed a 32B or larger model in NF4

Above roughly 165 MB per decoder layer, a streamed NF4 run produced silently wrong gradients. In practice that means a 32B or a 72B: 8B sits at 105 MB per layer and 14B at 132, both below the boundary with margin, and both verified exact over a 50-backward soak. Filed as issue #331.

The forward stayed bit-exact throughout, and the loss matched a resident reference to every digit, so nothing in a training log could show it. The cause is buffer aliasing rather than a race: bitsandbytes keeps the packed 4-bit weight outside PyTorch's save_for_backward mechanism, so gradient checkpointing cannot recompute it, and the backward reads a streaming slot that has already been refilled with a different layer. bf16 was never affected, at any size.

It was present in v0.72.0 through v0.72.4, and it is repaired in v0.73.0. The repair keeps streamed NF4 weights out of that code path entirely, and it is gated on two real models against a control that reproduced the defect in the same process: 256 of 256 gradient tensors exact at 32B, and 320 of 320 at 72B, the size where the defect was worst. It costs about 3% of peak VRAM and 4% of throughput. If you streamed a 32B or larger model in NF4 on any earlier version, re-run it on v0.73.0; if you streamed 8B or smaller, there is nothing to do. The full diagnosis, the threshold measurement and the repair's cost.

If you trained with streaming on v0.72.0

That adapter is inert. Re-run it on v0.72.1.

The streaming wrapper held the real decoder layer as a child named inner, so every adapter tensor was written to disk under a key carrying an extra .inner. segment:

base_model.model.model.layers.0.inner.self_attn.q_proj.lora_A.weight
                               ^^^^^^

Loading that file into an ordinary model matches nothing. PEFT reports missing keys as a UserWarning rather than an error, so soup merge, soup serve, soup chat and PeftModel.from_pretrained all completed normally and handed back the untuned base model. Nothing failed, and nothing looked wrong.

Check a file you already have:

bash
python -c "from safetensors.torch import load_file; \
print([k for k in load_file('adapter_model.safetensors') if '.inner.' in k][:3])"

A non-empty list means the adapter is affected.

What was not affected matters just as much. The training itself was correct: the streamed run's numerics are unchanged and v0.72.0's bit-exactness results still stand. Only the saved file was wrong. There is no way to restore a broken file's association with its base model beyond renaming keys, so re-running is the reliable path. On v0.72.1 a streamed adapter saves byte-for-byte in the same layout as an ordinary LoRA run, portable to any tool that has never heard of layer streaming.

v0.72.1 also closed a second hole in the same area: the guard refusing --resume for a streaming run only tested that one flag, so --hf-resume reached resume_from around it. That combination used to appear to work by accident, and once adapters save canonically it would instead have matched nothing and continued with a freshly initialised adapter, silently. Both flags were refused until v0.72.3 fixed the load side and unblocked them; see Scaling a streaming run.

Both defects above share a shape, and it is worth naming rather than smoothing over. v0.72.0 passed every correctness gate it set itself and shipped a clean throughput table, and no test in that release saved an adapter and loaded it back. #331 passed every gate too, on hardware where the reference it needed could not exist. In each case the run exited 0 and the loss curve looked healthy. A green suite measures what it was pointed at.

What is actually new here

Layer streaming itself is not new, and fine-tuning on a small card is not new. An early draft of this page claimed no published system does this; that claim was fact-checked against the sources and cut, because it is false. What is true is narrower:

  • For inference, streaming a small card is well established. AirLLM runs 70B-class models through a 4 GB GPU, and its own documentation states that layer sharding is not compatible with gradient propagation. Hugging Face's disk_offload is documented as big-model inference and runs under no_grad.
  • For training, the published small-card result already exists: LSP-Offload (arXiv:2406.10181) fine-tunes 1.3B on a 4 GB laptop GPU. It gets there by compressing the optimisation into a learned sparse subspace, not by streaming weights.

So the honest headline is not "nobody has done this". It is up to 8B on a 4 GB card, by streaming the base rather than approximating the optimisation, with the forward pass verified bit-exact against a resident run at the same precision — and packaged as two config keys in a general-purpose CLI rather than a research prototype.

Turn it on

It is a config key, not a CLI flag:

yaml
training:
  stream_layers: true          # enable layer streaming
  quantization: 4bit           # NF4: ~4x smaller store, so 8B fits a 4 GB card
  stream_source: auto          # RAM when the base fits, NVMe disk when it does not
  stream_buffers: 2            # double-buffering; range [2, 8], default 2
  # stream_pin: false          # v0.74.0: force the pageable store, and print what it costs

  batch_size: 4                # any concrete value; the pre-flight sizes it for you

quantization accepts none (bf16) or 4bit (NF4) and nothing else. NF4 is what you want unless you have a specific reason to stream bf16: it shrinks the RAM store about fourfold, and a smaller store is far more likely to page-lock, which is worth more than the arithmetic (see below).

bash
# There is no --stream-layers flag. You train exactly as before:
soup train --config soup.yaml

The one value batch_size cannot take is auto. The auto-batch probe sizes a resident model, which a streaming run never loads, so it is refused by name. Set a concrete number instead: as of v0.72.3 a pre-flight predicts peak VRAM for that exact batch and sequence length and refuses the run before any GPU work if it will not fit. Reach for batch_size before gradient_accumulation_steps, because at the same effective batch a real batch measured about 2.52x faster than accumulating. Why, and how to size it.

How it works

The base model is built on PyTorch's meta device, so its weights never occupy VRAM. Only the embeddings, the final norm, an untied LM head and the LoRA adapters with their gradients and optimizer state get real storage on the card, and those are small. The checkpoint is rewritten once into one safetensors shard per decoder layer, and that store is held in CPU RAM, page-locked when the machine allows it, or read from an NVMe disk tier when the base is too large for RAM.

During the step, each decoder layer is copied into one of N pre-allocated VRAM buffers on a dedicated CUDA stream, so the load of layer i+1 overlaps the compute of layer i. The pooled buffer is substituted into the unmodified decoder layer, which means the same kernels run on the same weight bytes.

Each layer is read twice per step — once in the forward pass and once when the backward pass recomputes it — because dL/dx = Wᵀ · dL/dy needs the weights to push gradient down to the layers below. That is physics, not an implementation detail, and it is why streaming costs time rather than being free.

Correctness is not part of the trade. A streamed forward pass was verified bit-exact against a resident one, and a 100-step streamed loss curve matched resident exactly.

Treat "bit-exact" as two claims, not one, because they can come apart: the forward (the logits) and the backward (every LoRA gradient tensor) are measured independently. On the development box both hold. On borrowed H100 hardware, where a resident reference for a real model can actually exist, the forward is exact up to 72B while the backward turned out to be wrong above roughly 165 MB per NF4 layer, which is the defect described above. Whenever this page or any other says a run is bit-exact, it means both halves at the size stated, and nowhere else. The full ledger.

Since v0.75.0 there is a third qualifier, and it is the card. Streamed NF4 is not bit-exact against resident NF4 on Blackwell. On an RTX 5070 (sm_120) the two CUDA-only exactness tests fail at 4.9e-4 in fp16 and 3.9e-3 in bf16, while the quantization: none parametrisations of the same tests pass. That split is the useful part of the report: layer streaming itself is still exact there, and the divergence is in the bitsandbytes NF4 path on that architecture. It is not the defect v0.73.0 repaired, which had a megabytes-per-layer boundary rather than a card-architecture one, and unlike that one this is not fixed: the root cause is not established, and no mechanism is claimed here because upstream claims none. CI cannot see it, because CI has no GPU runner and these tests skip on every hosted platform; it took the first Blackwell card the project has had access to.

So the full form of the claim is four-part: which half (forward or backward), which quantisation, which size, and now which card. Everything measured and published on this site was measured on Ampere or Hopper.

Measured numbers

Measured on the development box: RTX 3050 Laptop 4 GB, Windows 11, 16.9 GB RAM, LoRA, batch 1, gradient checkpointing on, PagedAdamW8bit, 50 steps after 10 warm-up.

ModelQuantSeqThroughputGPU utilPeak VRAMRAM store
Llama-3.1-8B-InstructNF4512119.6 tok/s100%3.32 GB3.60 GB pinned
Qwen2.5-3BNF4512264.2 tok/s100%1.76 GB1.43 GB pinned
Qwen2.5-3Bbf16512143.1 tok/s79.3%2.15 GB5.55 GB pageable
Qwen2.5-1.5Bbf16512525.0 tok/s96.8%1.82 GBpinned
Qwen2.5-1.5Bbf161024487.6 tok/s96.7%2.96 GBpinned
Qwen2.5-0.5Bbf16512978.6 tok/s91.4%1.47 GBpinned

Read the 8B rate as a pre-repair figure. Every row above was measured on v0.72.2, before the NF4 gradient repair that shipped in v0.73.0. That repair cost 4.8% throughput at 32B, and nobody has re-run the 8B laptop configuration on the repaired code. A server card cannot stand in for the measurement either, which the H100 replication showed by coming back no faster rather than faster, so throughput here does not carry across machines. The nearest independent evidence is a median 113.00 tok/s in the same 3.32 GB peak on an H100, five runs, which is a cross-hardware reproduction rather than a re-measurement.

For scale, 1M training tokens is about 2.3 hours at 8B on that card. That is arithmetic from the measured rate, not a separate measurement.

The honest cost: 1.43x slower than resident training, measured at 0.5B. That is the only apples-to-apples comparison available on this box, because 1.5B and above cannot run resident here at all.

Why NF4 also made the 3B row faster

The same model went from 143.1 to 264.2 tok/s, and the reason is not that 4-bit arithmetic is faster. A 1.43 GB store fits under this machine's page-locked memory ceiling where a 5.55 GB one did not. Pinned memory is what lets the host-to-device copy run asynchronously, so the layer loads hide behind compute again, and utilisation goes from 79.3% to 100%.

Treat the mechanism as the claim, not the multiplier. Those two rows come from different sessions and this card's boost clock varies by about 13% between sessions, so the exact factor is indicative. What is solid is the cause: pinning restores overlap, and NF4 is what makes pinning possible at this size.

The same reasoning explains the bf16 3B row's 79.3%. It is not a model-size effect: that box could not page-lock a 5.55 GB base, so the run fell back to a pageable store. Soup performs that fallback automatically and prints what it costs instead of absorbing it silently. The bf16 3B throughput is therefore a lower bound.

One number worth knowing before you size a run: at the time this table was measured, the untied embed_tokens and lm_head stayed resident and unquantised, and they account for 2.10 GB of the 8B row's 3.32 GB. Roughly two thirds of that peak was not streamed at all, which is exactly why 8B sat close to this card's ceiling.

v0.74.0 changed that, and the table above is deliberately not restated. An untied embedding and LM head are now sharded separately and reuse one vocabulary-sized device buffer instead of both staying resident, so the 8B peak has moved. Nobody has re-run the 4 GB laptop configuration on the new code, and upstream's own docs say the historical figure is not being relabelled as a new measurement. Tied embeddings keep the existing resident path and numerics. The rows above stand as what they were: v0.72.2, before both the NF4 gradient repair and this change.

Be careful how utilisation is argued in general: GPU utilisation on its own only says a kernel was resident, which is necessary for overlap but not sufficient to prove it. The evidence is the step arithmetic, not the utilisation column.

Run it yourself on a free Colab T4

Open the proof notebook in Colab and the argument runs on a card you do not have to own: a free-tier Google Colab Tesla T4 (sm_75, Turing, 15.6 GB). The notebook holds the process to 4.00 GB with torch.cuda.set_per_process_memory_fraction before anything loads, then proves the cap bites rather than assuming it, by asking for 4.29 GiB and being refused.

What the completed run printed: Meta-Llama-3.1-8B-Instruct, NF4, stream_layers: true, stream_buffers: 2, batch 1, max_length: 256, LoRA r=8, fp16 (a T4 has no bf16 hardware). Seven steps, exit 0, an adapter written with 128 of 128 tensors non-zero, and a measured peak of 2.91 GB against the pre-flight's predicted 3.02, an over-prediction of 3.8% in the direction the estimator was fitted to err.

Read the frame before you read the number:

  • No throughput is claimed, here or in the notebook. A card held under an artificial memory cap is not a benchmark. The pre-flight panel's 31-46 tok/s line is a compute bound derived from a GEMM probe on that card, not a measurement of what the run achieved.
  • Gradient exactness on Turing is not shown. An adapter with 128 non-zero tensors proves gradients flowed, not that they were right, and that distinction is exactly the shape of the NF4 defect v0.73.0 repaired. The notebook's streamed-versus-resident comparison captured no output on that session, so it is recorded as unrun rather than as a pass.
  • On a capped card the pre-flight reads the whole device. torch.cuda.mem_get_info() cannot see a per-process cap, so the panel reported 15.10 GB free while the run was held to 4.00. Nothing was harmed here, since 2.91 GB genuinely fits, but on Colab, Kaggle or a MIG slice the pre-flight is not what enforces your real budget.
  • One run, one seed, one configuration, no repeats, on a session that cannot be returned to, with the library versions not recorded.
  • The notebook installs Soup from git, not from PyPI, and it still does even though the precision fix it depends on shipped in v0.73.1. Upstream has not switched its install cell back, and the notebook's own first cell still claims the fix is unreleased. Ignore that line, expect the git install, and note that pinning it to a tag would change nothing: a git+ install resolves to the default branch whichever revision of the notebook you opened.

What it does establish is worth having on its own: the streaming path executes end to end on a pre-Ampere card, at 8B, inside a 4 GB process budget. Before this it had never been run there. The full record, including the longer list of what it does not establish.

Honest scope

This ships BETA, and the claims stop where the measurements stop.

  • Models measured on the 4 GB card: Llama-3.1-8B under NF4, plus Qwen2.5-0.5B, 1.5B and 3B, and a live soup train on SmolLM2-135M. Nothing above 8B has been measured on this hardware, so no number on this page speaks for a larger model. Sizes above that were later measured on a borrowed 8x H100 box, up to 72B, and what those runs do and do not establish is a page of its own. Note the host side is the real gate on reproducing them: the 72B run needed a 33.74 GB pinned RAM store, which a 16.9 GB laptop cannot hold at all.
  • Ten architectures (llama, qwen2, qwen3, mistral, gemma, gemma2, gemma3_text, phi, phi3, and qwen4_exp since v0.74.0, plus the qwen3_5 dense and MoE aliases). The original nine are verified bit-exact against the same checkpoint loaded resident, under both bf16 and NF4; the v0.74.0 additions carry weaker evidence and say so. There is no throughput measurement for anything but Llama and Qwen, so no speed is claimed for the rest — every tok/s figure on this page is Llama or Qwen. Multimodal gemma3 is refused; only gemma3_text is accepted.
  • task must be one of sft, dpo, orpo, simpo, kto; backend: transformers; modality: text. The four preference losses landed in v0.72.4, and no throughput figure is claimed for any of them, because none was measured. ipo, bco and the unified task: preference dispatcher are still refused. See Preference losses over streaming.
  • quantization must be none or 4bit. Other quantisations store weights in formats that cannot be streamed into a pooled buffer.
  • batch_size cannot be auto — the auto-batch probe sizes a resident model. Any concrete value is fine, subject to the VRAM pre-flight.
  • Loading an adapter into a streamed run works as of v0.72.3, but in memory named_parameters() and state_dict() still disagree, which is the deliberate cost of a serialisation-only fix. End-to-end soup train --resume could not be demonstrated on the development box, for a reason that has nothing to do with streaming: transformers refuses torch.load below torch 2.6 under CVE-2025-32434, which blocks every resume there.
  • The RAM-versus-disk speed gap is unmeasured, and no figure is claimed for it. The disk tier's correctness is verified bit-exact against the RAM tier.
  • Plain LoRA only. DoRA, VeRA and the PiSSA / OLoRA / LoftQ initialisations all read a real base weight, which streaming keeps on the meta device at adapter-build time. use_rslora is fine.
  • Pre-Ampere cards (T4, P100, V100, GTX 16xx, RTX 20xx) stream in fp16 rather than bf16. Until this fix the store dtype was the literal "bf16 on any CUDA device", so the whole free notebook tier ran in a dtype its GPU has no units for, and it could not fail on the Ampere card every number above was measured on. fp16 is bit-exact against a resident reference of matching numerics, 0.000000e+00, in both quantisations. Two honest edges. The capability question has to be asked as is_bf16_supported(including_emulation=False), because the bare call counts software emulation and a T4 answers True to it, which made the first version of this fix a no-op on exactly the hardware it targeted. And that exactness was measured using fp16 on an Ampere card, so it establishes the plumbing and not the Turing or Pascal kernels. It shipped in v0.73.1, where it turned out not to be a streaming fix at all: the same assumption sat in fourteen places, so every task died on that hardware, not only a streamed one.
  • Numbers are Windows/WDDM and therefore systematically pessimistic versus Linux. expandable_segments:True is silently ignored on Windows, and Soup detects that rather than claiming it is active.
  • If the model already fits resident on your card, do not enable it. Streaming trades time for memory, and there is nothing to buy when memory is not the constraint.

Rejected at config load

Every refusal is deterministic and happens before a single GPU byte is touched. Where a later release lifts a limit, the refusal message names that release; the rows marked n/a are refused on a structural ground and carry no roadmap promise either way.

None of this constrains an ordinary resident run. These are the refusals a run with stream_layers: true can hit, and several of the rows below name things that are perfectly legal, and common, with streaming off.

Config, on a streaming runRefused becauseLifted in
quantization other than none or 4bitother quantisations store weights in formats that cannot be streamed into a pooled buffern/a
batch_size: autothe auto-batch probe sizes a resident model, which a streaming run never loads. Set a concrete value; the pre-flight sizes itn/a
architecture outside the ten-item allowlist (*)named explicitly, with the allowlist. Multimodal gemma3 is refused on purpose: streaming a vision wrapper as a causal LM is the exact failure the allowlist exists to prevent
task: grpo or task: ppopermanent, and the message says so rather than naming a release: generation rollouts re-read every layer once per generated token, which destroys the amortisation streaming depends onn/a
task outside sft, dpo, orpo, simpo, ktothe message lists the accepted five. ipo, bco and task: preference are not among them
task: kto with batch_size: 1TRL's KL term is degenerate at batch 1. Refused when the config is read rather than minutes later, after the checkpoint has been shardedn/a
backend: unsloth / backend: mlxstreaming replaces the model-load path those backends ownn/a
lora.r < 1the streamed base is frozen, so a run with no adapter is a no-opn/a
lora.use_dora, lora.use_vera, lora.init_strategy other than randomPiSSA / OLoRA / LoftQ / DoRA / VeRA all initialise from the real base weight, which streaming keeps on the meta devicen/a
unfrozen_parameters, lisa_enabled, packing, multipack, use_fsdp2_compile, train_router_only, expand_layerseach independently rewrites or re-freezes the same layers streaming ownsn/a
stream_source / stream_buffers / stream_vram_probe / stream_vram_override / stream_disk_kind / stream_pin / stream_ngram_source set while stream_layers: falsea footgun: the knobs would silently do nothing
moe_expert_quant with stream_layers: trueadded in v0.74.0. It is applied only by the resident setup path, so it was silently ignored on a streamed run rather than doing anything
stream_pin: true on the RAM tier when the box cannot page-lock the storeadded in v0.74.0. true means insist. The refusal names the store size, not a page-lock ceiling, because the ceiling is deliberately left unprobed so a refusal can never cite a figure nobody measured. On the disk tier and on CPU there is nothing to page-lock, so it is announced and the run proceeds
stream_source: ram when the store fits available memory but store-plus-extras crosses the physical-host safety ceilingadded in v0.74.0, at pre-flight, instead of letting the kernel OOM-kill the process. auto falls back to the disk tier in the same case
a trainable lora_* parameter still on the meta device after PEFT attaches the adapteradded in v0.73.3. PEFT 0.18 creates streamed adapters on meta for Soup to materialise while PEFT 0.19 may create them as real tensors immediately, so "materialised zero" could not tell a healthy no-op from a missed adapter. The run is refused, naming the stranded parameter, instead of proceeding into a silent no-training run
a predicted peak VRAM larger than the free VRAMadded in v0.72.3, and it names training.batch_size and data.max_length as the two knobs that scale it linearly
stream_source: ram when the base does not fit in RAMram means insist, not prefer. The message offers auto, which falls back to the NVMe tier instead
the disk tier on a non-NVMe volume80 shards read twice per step on a spinning disk is a run that thrashes for hours. unknown media is refused rather than guessed

(*) One honest footnote: the architecture check is the only row here that is not a config-parse validator. It needs the model's own config, so it runs a moment later, at trainer setup. Still before any GPU work and still deterministic, but if you are counting on a pure-CPU soup train --dry-run to catch everything, that is the one that arrives late.

The generic pre-flight hardware-fit gate is skipped for streaming runs, because it models a resident run and would otherwise refuse exactly the runs streaming exists to enable. Since v0.72.3 a streaming-specific budget replaces it, and that one does refuse: it predicts peak VRAM from your batch, sequence length and vocabulary, which are the terms streaming does not bound. See Scaling a streaming run.

Full config

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

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

training:
  epochs: 3
  lr: 2e-5
  batch_size: 4                    # v0.72.3: bigger batches amortise the weight read
  gradient_accumulation_steps: 2   # v0.72.3: values above 1 are allowed
  quantization: 4bit               # NF4; 'none' streams bf16 instead
  gradient_checkpointing: true     # handled per-layer by the streamer
  stream_layers: true
  stream_source: auto              # RAM when it fits, NVMe disk when it does not
  stream_buffers: 2
  lora:
    r: 64
    alpha: 16

output: ./output

Gradient checkpointing is handled per layer by the streamer, and the Hugging Face Trainer's own is left off so layers are not recomputed twice.

That batch_size: 4 is illustrative, not a recommendation: whether it fits depends on your card, the model's vocabulary and max_length, and the pre-flight will tell you before the run starts. Sizing rules.

Troubleshooting

  • "a streaming step is predicted to need X GB of VRAM but only Y GB is free" — the v0.72.3 pre-flight. Streaming bounds the weights, not the activations or the logits, and both of those scale with batch_size times data.max_length. Lower either one. On a large-vocabulary model the logits tensor is usually the whole story.
  • "but only Y GB of RAM is free" — you asked for stream_source: ram and the base does not fit in it. ram means insist, so it refuses rather than falling back. Set stream_source: auto to use the NVMe disk tier instead, free RAM, or pick a smaller base. If you are streaming bf16, quantization: 4bit shrinks the store about fourfold and is often enough on its own.
  • "layer streaming needs NVMe or more RAM" — the base does not fit in RAM and the detected disk is not NVMe. soup doctor --disk reports what Soup detected on your machine. On a cloud box, check this one before you believe it: until v0.73.3 a paravirtual disk was read as a spinning one on the strength of a flag it defaults to. Since v0.73.3 the media type is measured when that flag is unreliable, and training.stream_disk_kind: nvme overrides it if detection is still wrong.
  • "could not page-lock the base ... falling back to a PAGEABLE RAM store" — expected on a busy machine, and it costs real throughput: pageable memory makes the host-to-device copy synchronous. Training continues, more slowly. Close other applications, or switch to quantization: 4bit so the store is small enough to pin.
  • "layer streaming does not support model_type=..." — the message prints the full allowlist. Note that a real google/gemma-3-* checkpoint reports gemma3, the vision-capable wrapper, which is refused; the text-only gemma3_text is what streams.
  • "training.stream_layers supports quantization='none' or '4bit'" — those are the only two. Other quantisations store weights in a form that cannot be streamed into a pooled buffer.
  • Slower than you expected — layer streaming trades time for memory. If the model already fits resident on your card, do not enable it.

Roadmap

Each refusal above names its release, and this is the same list from the other side:

  • v0.72.1 (shipped) — the adapter-key correctness fix above, plus the --hf-resume refusal. It took the .1 slot out of turn, which is why every later slot below moved up by one and every refusal message in the CLI was corrected to match.
  • v0.72.2 (shipped) — 4-bit (NF4) streaming, the capability jump that made 8B reachable. It also fixed a display bug where a streamed 4-bit run reported its parameter count about 6.5x too high (training was unaffected), and a startup regression that had the CLI importing PyTorch on every invocation, taking soup --help from 6.0 s back down to 1.15 s.
  • v0.72.3 (shipped) — breadth: six more architectures, batches above 1, gradient accumulation, --resume and --hf-resume, a batch- and vocabulary-aware VRAM pre-flight, and the NVMe disk overflow tier. The full page.
  • v0.72.4 (shipped) — preference losses: DPO, ORPO, SimPO and KTO against a streamed base, with DPO's reference model taken from that same stream with its adapters switched off, so it costs no extra weights. The full page.
  • v0.73.0 (shipped) — the release that came out of three days on borrowed hardware. It repaired the NF4 gradient defect that only a resident reference could have found, refused streaming under nn.DataParallel rather than quietly using one card of eight, and fixed the four preference losses whose gradient-checkpointing flag was never set. What the whole exercise measured.
  • v0.73.1 (shipped) — not a streaming release at heart, though it repaired streaming too: bf16 was assumed on every CUDA card in fourteen places, so every pre-Ampere card failed on every task. It also caught this feature's own VRAM pre-flight under-predicting at long sequence and shipped training.stream_vram_probe, which measures one real step instead of predicting it.
  • v0.73.2 (shipped) — nothing streaming-specific: it repaired the release gate that decides whether a tuned model ships at all.
  • v0.73.3 (shipped) — two streaming repairs, both contributed. A paravirtual disk reports itself as rotational with no media hint, so a genuinely NVMe-backed cloud disk measured at 1.5 GB/s was refused the disk tier, which is exactly the audience that tier exists for; when the rotational flag is unreliable the media type is now settled by a bounded direct sequential read, with a 1 GB/s floor, and training.stream_disk_kind as the manual override. And the streamed build now enforces its own postcondition: PEFT 0.18 creates streamed adapters on the meta device for Soup to materialise while PEFT 0.19 may create them as real tensors immediately, so "materialised zero" could not tell a healthy no-op from a missed adapter. The run is refused, naming the stranded parameter, rather than proceeding into a silent no-training run.
  • GRPO and PPO are explicitly not planned. Rollouts need generation, and generation re-reads the model per token.

What is next is a measurement, not a feature. The 14B reference benchmark that was going to follow v0.72.4 did not become v0.73.1: that slot went to the pre-Ampere repair and the measured VRAM probe instead, and v0.73.2 went to the release gate. The benchmark now sits at the end of the v0.73 series rather than at its start, and it is still hardware-gated: it wants a card this project can keep rather than three borrowed days, run under three disk conditions and three sequence lengths with utilisation traces. The borrowed H100 box overtook part of it (14B, 32B and 72B were measured against resident references a 4 GB machine cannot hold) but it was a validation campaign, not that structured benchmark, and the RAM-versus-disk question it was meant to answer is still unmeasured because that box had no NVMe.

Measurement records and citation

The gate records behind every number on this page are published in full, including the checks that failed, the diagnoses that turned out wrong, and the figures that were measured and then discarded: the benchmarks directory. They are working records rather than a report assembled afterwards, so read them front to back; a passage quoted out of order may be one the same page later refutes.

The newest of them is the one from hardware nobody here owns: three days on an 8x H100 box, which is the first machine able to hold a resident reference for a model worth streaming. That record reproduces the headline row on a completely different card and stack, extends the forward check to 72B, compares the method against DeepSpeed ZeRO-3, shows a streamed model converging indistinguishably from a resident one, and contains the gradient defect it found on the way. Read the summary.

The work also has a preprint, cited by its concept DOI so a revision never strands a reference: 10.5281/zenodo.21771064. The paper page is the short version: what it measures, the correctness protocol it defends hardest, the three findings that have nothing to do with streaming, what it explicitly does not claim, and the BibTeX entry.

Shard cache

The first streaming run rewrites the checkpoint into one safetensors shard per decoder layer under ~/.soup/layer-stream/ (override with SOUP_LAYER_STREAM_CACHE_DIR), quantising as it goes when you asked for 4bit. The rewrite works one tensor at a time, so sharding a model that does not fit never requires it to fit, and the quantisation happens once, offline, not on every run.

The cache is keyed to the quantisation, the dtype, the quantisation device and a fingerprint of the source checkpoint. Switching between none and 4bit, or retraining a base in place, therefore re-shards instead of silently streaming the wrong bytes.

See also

  • Scaling a streaming run — v0.72.3: which architectures stream, how to size batch and sequence length against the VRAM pre-flight, batch versus gradient accumulation, resume, and the NVMe disk tier.
  • Preference losses over streaming — v0.72.4: DPO, ORPO, SimPO and KTO against a streamed base, why the reference model costs no extra weights, and what it costs in time instead.
  • Validation on hardware we do not own — the same mechanism on 8x H100: a resident reference at 8B through 72B, the DeepSpeed comparison, the convergence result, and the defect the exercise found.
  • Training — the training guide the streamed run otherwise follows unchanged.
  • Spectrum targeted training — the other way to train a large model small: pick the high-signal layers instead of streaming all of them.
  • LISA — full fine-tune quality without a full fine-tune, for models that do fit resident. Measured at 3B and 8B it beats full fine-tuning on held-out loss but costs more memory than LoRA, not less.

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.