feat(streaming): preference losses over layer streaming (v0.72.4)

DPO / ORPO / SimPO / KTO join task=sft on the layer-streaming engine.

The reference model is the SAME streamed base with adapters disabled — one set
of weights, one stream. Measured: streamed DPO peaks at 0.914x streamed SFT with
a byte-identical store and pool, where forcing a real second instance costs
+730.44 MB against 730.44 MB of weights. All four are bit-exact (0.0) against a
resident run of the same loss.

KTO is NOT reference-free: kto_trainer.py:466-476 is byte-for-byte DPO's
three-branch reference selection, so it was gated separately. It also requires
batch_size >= 2, refused at parse time rather than minutes into sharding.

grpo/ppo stay excluded permanently — rollouts re-read every layer per generated
token. The refusal deliberately names no release.

The ~390-line streaming setup moved verbatim into trainer/stream_setup.py so
five wrappers cannot drift. _STREAM_ROWS_PER_EXAMPLE is 2 for the concatenating
losses and 1 for KTO, measured not assumed: the VRAM pre-flight would otherwise
under-predict by half, and on Windows that is a silent spill, not an error.

Honest cost: the reference is free in memory, not in time — DPO reads the layer
stack 1.52x as often per step as SFT.

Also closes five holes in the release checklist itself:
- benchmarks/ was never in it, so gate records (which live under a gitignored
  .claude/) were never published. The public record behind the preprint DOI was
  about to fall a release behind; benchmarks/gate-v0.72.4-preference-losses.md
  and its index row are here, and the checklist now names the step.
- tests/test_version_sync.py asserts pyproject.toml == __init__.py. Every other
  version test in the suite is a >= floor check, so bumping one and forgetting
  the other kept CI green. Verified red-green.
- .claude/paper/ (the DOI preprint) had no "did this release change what it
  claims?" step. For v0.72.4 the answer is no: no measured number moves and its
  task: sft configs stay valid.
- The Docs section header said steps 7-12 while containing 7-13, so plan.md sat
  outside its own section.
- The README size anchor said ~238 lines against a real 426.

Notes for whoever hits these next:
- `pre-commit run --all-files` rewrites ~740 files here (ruff-format on
  pre-existing code). No pre-commit git hook is installed and CI runs only
  `ruff check`, so run it with --files on your own paths or the diff explodes.
- Measuring streamed peak VRAM across setup() charges the pre-flight's own GEMM
  probe (three 4096^3 matrices, ~100 MB) to the step. Reset the peak counter
  after setup.
- The buffer pool is freed by cycle collection, not by close(): back-to-back
  streamed runs in one process retain the previous pool (+47.65 MB measured)
  until a gc pass. Call gc.collect() between arms when measuring.
- A resident model built from a float32 fixture vs a bf16 streamed one measures
  the dtype gap, not streaming — that cost an hour chasing a 9.96e-04 "failure".
- Two concurrent pytest runs on a 4 GB card produce false CUDA failures; run the
  suite alone.

Tests: 16977 -> 17051.
This commit is contained in:
Alpamys 2026-08-03 18:38:13 +05:00
parent 43bd776b5f
commit 4fb25404f9
22 changed files with 2174 additions and 486 deletions

View File

@ -12,6 +12,47 @@ reproducing 70+ versions of notes.
## [Unreleased]
## [0.72.4] - 2026-08-03
**Added — preference losses over layer streaming: DPO, ORPO, SimPO and KTO.**
Layer streaming kept the frozen base in CPU RAM and fed it to the GPU one decoder
layer at a time, but only for `task: sft`. This release opens it to the four
preference losses. The whole risk was one thing: **DPO needs a reference model, and a
second model instance would double memory and defeat the feature entirely.**
- **The reference is the same streamed base with its adapters disabled** — one set of
weights, one stream, no second pass. Measured on an RTX 3050 4 GB with a 730 MB
model: streamed DPO peaked at **0.914x** the SFT peak, with a byte-identical RAM
store and buffer pool. Forcing a real second instance in the same harness cost
**+730.44 MB against 730.44 MB of weights** — exactly one copy. That control is what
makes the first number mean something.
- **KTO is *not* reference-free**, contrary to how it is usually described: it selects
its reference exactly the way DPO does, so it gets the same treatment and the same
memory assertion. ORPO and SimPO genuinely are reference-free.
- **Bit-exact against a resident run of the same loss**`0.0` difference for all
four, the standard every slot in this series inherits.
- **The pre-flight now knows that a paired loss is twice the rows.** DPO, ORPO and
SimPO concatenate chosen and rejected into one tensor, so a VRAM budget computed at
one row per example would have under-predicted by half — and on Windows the
consequence is not an error but a silent spill to host memory that makes the run an
order of magnitude slower.
- **KTO requires `batch_size >= 2`** (its KL term is degenerate at 1). Soup now says so
when your config is read, rather than minutes later after sharding the checkpoint.
KTO is streamable at all only because v0.72.3 lifted the batch-1 restriction.
- **`grpo` and `ppo` remain excluded permanently**, not "not yet": generation rollouts
re-read every layer once per generated token, which destroys the amortisation
streaming depends on. The refusal says so and deliberately names no release.
The streaming setup now lives in one shared place instead of being copied per trainer,
so the NF4 pre-flight, the RAM/disk tier choice and the VRAM fit refusal cannot drift
between SFT and the preference losses.
Honest costs: streaming makes the reference free in **memory**, not in **time** — DPO
traverses the layer stack three times per step against SFT's two, measured at **1.52x**
the layer reads. And the VRAM pre-flight is a sound *upper* bound for preference
losses rather than a tight estimate; see Known Limitations in the release notes.
## [0.72.3] - 2026-07-28
**Added — layer streaming breadth: more architectures, bigger batches, resume, and a

View File

@ -120,7 +120,7 @@ src/soup_cli/
templates/ - 21 built-in soup.yaml templates (YAML + manifest.json) with load_template loader (v0.39.0, +bco v0.40.0, +4 compliance v0.71.35)
ui/ - Web UI (FastAPI + HTML/JS SPA)
tests/ - Test suite (330 files, 16977 tests)
tests/ - Test suite (332 files, 17051 tests)
examples/ - Real-world config examples and datasets
```

View File

@ -52,25 +52,27 @@ infrastructure instead of improving models. Soup fixes that.
## What's New
**v0.72.3 — layer streaming grows up: more models, bigger batches, resume, and a disk
tier.** Layer streaming keeps the frozen base out of VRAM and feeds it to the GPU one
decoder layer at a time. v0.72.0.2 kept the scope deliberately tiny to prove it worked;
this release removes the training wheels.
**v0.72.4 — align on a laptop: DPO, ORPO, SimPO and KTO over layer streaming.** Layer
streaming keeps the frozen base out of VRAM and feeds it to the GPU one decoder layer at
a time. It used to support supervised fine-tuning only; now it runs the preference
losses too.
- **Six more model families** — Mistral, Gemma / Gemma 2 / Gemma 3, and Phi / Phi-3 — each
verified **bit-exact** against the same checkpoint loaded resident, in bf16 *and* NF4.
- **`batch_size` above 1, gradient accumulation, and `--resume`** all work now.
- **A pre-flight that predicts peak VRAM and refuses a run that will not fit.** Streaming
bounds the *weights*; the logits tensor is not bounded by it and scales with
`batch × seq`. On a 152k-vocab model at batch 8 that single tensor measured **8.71 GB —
146× the entire layer-buffer pool.** The prediction was fitted to ten real runs and
never under-predicts any of them.
- **A throughput forecast measured on your card, in your session**, quoted as a range
next to the SM clock it was taken at — not a number compiled into the source.
- **A disk overflow tier.** When the base will not fit in RAM, `stream_source: auto`
streams it from NVMe instead of refusing. Honest caveat: its *correctness* is verified
bit-exact against the RAM tier, but **how much slower it is has not been measured** on
the development hardware, and no figure is claimed.
- **DPO's reference model is free.** DPO needs a reference to compare against, and a
second copy of the model would double memory and defeat the whole point. Soup uses
*the same streamed base with its adapters switched off* — one set of weights, one
stream. Measured on an RTX 3050 4 GB: streamed DPO peaked at **0.914×** the
supervised-fine-tuning peak. Forcing a real second model in the same test cost
**+730 MB — exactly one copy of the weights.**
- **KTO is not reference-free**, however it is usually described: it picks its reference
the same way DPO does, so it gets the same treatment. ORPO and SimPO genuinely are.
- **Bit-exact against a normal, non-streamed run** of the same loss — `0.0` difference,
the bar every release in this series has to clear.
- **The VRAM pre-flight knows a paired loss is twice the rows**, because chosen and
rejected go through the model as one tensor.
- **Honest cost:** the reference is free in *memory*, not in *time* — DPO reads the
layer stack **1.52×** as often per step as supervised fine-tuning does.
- `grpo` / `ppo` stay excluded on purpose: generation re-reads every layer per token,
which is exactly what streaming cannot amortise.
- Still BETA.
```yaml

View File

@ -18,6 +18,7 @@ They are the evidence behind the preprint:
| [`gate-v0.72.0-layer-streaming.md`](gate-v0.72.0-layer-streaming.md) | The streaming path itself | Bit-exactness vs a resident reference; 3B bf16 trained on a 4 GB card |
| [`gate-v0.72.2-nf4.md`](gate-v0.72.2-nf4.md) | NF4 quantised streaming | Llama-3.1-8B at 119.6 tok/s in a 3.32 GB peak |
| [`gate-v0.72.3-breadth.md`](gate-v0.72.3-breadth.md) | Nine architectures, batching, accumulation, resume, disk tier | Peak-VRAM predictor at 0.85% worst-case error; accumulation is per-token I/O-neutral |
| [`gate-v0.72.4-preference-losses.md`](gate-v0.72.4-preference-losses.md) | DPO / ORPO / SimPO / KTO over the streaming engine | DPO's reference model costs no extra weights — 0.914x the SFT peak, against +730.44 MB for a real second instance |
## Hardware

View File

@ -0,0 +1,257 @@
<!--
Measurement record for Soup layer streaming, published verbatim.
These are the working gate records written while the feature was built, not a
report assembled afterwards: they contain the failures, the corrected
assumptions and the numbers that were discarded, in the order they happened.
They are the evidence behind the paper "Exact Layer Streaming: LoRA Fine-Tuning
of an 8B Model on a 4 GB Laptop GPU".
Hardware for every number below: RTX 3050 Laptop (4 GB, 4.29 GB usable),
16.9 GB host RAM, NVMe, Windows 11, unless a line states otherwise.
-->
# v0.72.4 "Preference losses" — gate results
Box: Windows 11 · RTX 3050 Laptop 4.29 GB · 16.9 GB RAM · NVMe · Python 3.10.8 ·
torch 2.5.1+cu121 · transformers 4.57.6 · peft 0.18.1 · **trl 0.19.1**.
Gates are throwaway scripts in the session scratchpad; nothing under `src/` was
written before a gate passed.
> **Version caveat, stated up front.** CI runs trl 1.9.2 / peft 0.20.0 /
> torch 2.13.0. Everything below is measured against trl **0.19.1**. The gate
> therefore also records *which TRL internals the property depends on*, so the
> shipped tests assert the property rather than the internals.
**The inherited standard** — a streamed run must be bit-exact against the
resident run of the same numerics; what changes per slot is the *reference*, not
the standard. This slot adds a second, independent standard, because bit-exactness
cannot see the failure that matters here:
> DPO needs a reference model. Implemented naively as a second model instance it
> doubles memory and defeats the entire feature. It must be **the same streamed
> base with adapters disabled** — one set of weights, one stream, no second pass.
> Gate = assert peak VRAM under DPO is within the activation delta of peak VRAM
> under SFT, **not ~2x**. A passing loss curve does not detect this; only the
> memory assertion does.
---
## Correction to the brief, established before gating
The brief says "ORPO / SimPO / KTO are reference-free and ride the engine
unchanged." **That is true for ORPO and SimPO and false for KTO.**
| loss | TRL trainer | reference model? | evidence |
|---|---|---|---|
| DPO | `DPOTrainer` | **yes** — implicit | `dpo_trainer.py:321-327`, `null_ref_context` at `:865-877` |
| KTO | `KTOTrainer` | **yes** — implicit | `kto_trainer.py:466-476`, `null_ref_context` at `:810`, used at `:926` |
| ORPO | `ORPOTrainer` | no | zero occurrences of `ref_model` in the file |
| SimPO | `CPOTrainer` | no | zero occurrences of `ref_model` in the file |
`KTOTrainer.__init__` has byte-for-byte the same three-branch shape as DPO:
```python
if ref_model: self.ref_model = ref_model
elif self.is_peft_model or args.precompute_ref_log_probs:
self.ref_model = None # <- the free path
else: self.ref_model = create_reference_model(model) # <- 2x
```
So **KTO inherits the whole trap** and was gated separately rather than waved
through as reference-free.
The free path turns on exactly one predicate: `isinstance(model, PeftModel)`.
Soup's wrappers apply PEFT manually (`get_peft_model`) before constructing the
TRL trainer, so it holds — but it is a *predicate*, not a guarantee, which is why
check A1 asserts it directly instead of assuming it.
---
## GATE 1 — DPO. **PASS 14/14**
Two scripts, because the first one's memory arm was not evidence.
### 1a — correctness battery, CPU float32, 4-layer Llama
float32 on CPU so "bit-exact" means exactly `0.0`, not "within bf16 noise".
| check | result |
|---|---|
| **A1** one model / one stream | PASS — `ref_model=None`, `is_peft_model=True`, `RamSource` constructed **exactly 1** time |
| **A3** reference ≠ policy | PASS — `max\|policyref\|` chosen **8.219452e-01**, rejected **5.105972e-01** |
| **A3-control** zero adapter ⇒ ref == policy | PASS — exactly **0.000e+00** |
| **A4** bit-exact vs **resident DPO** | PASS — `max\|loss_streamed loss_resident\| = 0.000e+00` over 16 synced adapter tensors (streamed 0.67242944, resident 0.67242944) |
| **A5** layer-0 adapter gradient ≠ 0 | PASS — 1.276013e-01; all 4 layers have non-zero adapter grad |
| **A6** determinism, same seed twice | PASS — 0.000e+00 |
**A3 is the check a loss curve cannot make.** The streamed layer substitutes base
weights through `functional_call` rather than the module's own `forward`, so
`disable_adapter()` being a no-op through that path is entirely plausible. If it
were, the reference would *be* the policy, every log-ratio would be 0, and the DPO
loss would sit at `logsigmoid(0) = 0.6931` forever — which reads as "training
slowly", not as a bug. The zero-adapter control is what proves the 8.2e-01 gap
comes from the adapter and not from some other difference between the two forwards.
### 1b — the memory assertion at a size where WEIGHTS dominate, CUDA bf16
1a's memory arm passed on a **0.30 MB** model, where a second copy of the base is
1.6% of the peak. **That is not evidence** — it would pass for an implementation
that *did* keep a second copy. Re-run at 365.2M params = **730.4 MB bf16**,
24 layers, vocab 260, seq 64, batch 1, so a second instance would dominate:
```
streamed SFT peak VRAM : 89.53 MB
streamed DPO peak VRAM : 81.87 MB (-7.66 MB, 0.914x)
CONTROL: DPO + 2nd model : 812.32 MB (+730.44 MB, 9.922x)
the base's own weights : 730.44 MB
RAM store, SFT vs DPO : 729.91 MB vs 729.91 MB
VRAM buffer pool, SFT/DPO : 60.83 MB vs 60.83 MB
```
| check | result |
|---|---|
| **B1** DPOSFT delta < the base's weight bytes | PASS 7.66 MB vs 730.44 MB, i.e. **1.0%** of one copy |
| **B2** DPO peak is not ~2x SFT | PASS — **0.914x** (a second instance would be **9.16x**) |
| **B3** CONTROL: an explicit second model DOES cost ~the weights | PASS — **+730.44 MB**, 100.0% of the weight bytes |
| **B4** one store, one pool | PASS — 729.91 MB / 60.83 MB, byte-identical to SFT |
**B3 is what gives B1/B2 teeth.** "DPO is not 2x SFT" means nothing unless the same
harness can show what 2x looks like; forcing `ref_model=` to a real second instance
moves the peak by **exactly one copy of the weights**. That is the cost the implicit
reference avoids, measured rather than argued.
DPO's peak is *below* SFT's (0.914x), which is not an anomaly: this SFT arm computes
a loss over all 64 positions while the DPO arm splits the same 64 into a 32-token
prompt and a 32-token completion, so its logits tensor is smaller. The claim being
gated is "no second copy of the weights", and a **negative** delta is the strongest
form of it.
### The I/O cost — published, not buried
`layer loads/step: SFT=46 DPO=70 over 24 layers -> 1.52x (1.92 vs 2.92 per layer)`.
Exactly the predicted ratio: SFT traverses the stack twice per step (forward +
checkpoint recompute); DPO traverses it three times (policy forward + reference
forward + recompute), so **3/2 = 1.5x**. Streaming makes the reference free in
*memory*, not in *time*. The 4-layer toy in 1a reported 2.00x, which is
boundary-dominated and is not quoted.
---
## GATE 2 (ORPO / SimPO) + GATE 3 (KTO) — **PASS 10/11**
Run against the **shipped** code, not a spike: `TrainerWrapper.setup()` → the real
TRL trainer → `train()`, for all five tasks. Model 24 layers × hidden 1024,
453.08 MB store, vocab 64, seq 64.
```
task peak (resident) store pool batch x rows predicted
sft 61.81 (47.79) 453.08 MB 37.76 MB 1 x 1 60.75 MB
dpo 61.82 (56.31) 453.08 MB 37.76 MB 1 x 2 64.74 MB
orpo 61.82 (56.31) 453.08 MB 37.76 MB 1 x 2 64.74 MB
simpo 61.82 (56.31) 453.08 MB 37.76 MB 1 x 2 64.74 MB
kto 61.82 (56.31) 453.08 MB 37.76 MB 2 x 1 64.74 MB
```
| check | result |
|---|---|
| **C1** ORPO / SimPO have no reference model at all | PASS — the `ref_model` attribute is **absent** on both trainers |
| **C2** ORPO / SimPO store+pool identical to SFT | PASS — 453.08 MB / 37.76 MB; peak **1.000x** SFT |
| **C3** KTO takes the implicit reference | PASS — `ref_model=None`, store identical to SFT's |
| **C4** CONTROL: forcing a second model DOES cost the weights | PASS — 61.82 → 515.04 MB, **+453.22 MB vs 453.08 MB** of weights |
| **C5** pre-flight never under-predicts (dpo/orpo/simpo/kto) | PASS — **+4.7%** each |
| **C5** pre-flight never under-predicts (sft) | **1.7%** — see below |
**KTO also required `batch_size >= 2`**, refused outright by TRL ("Actual (not
effective) batch size must be > 1. KTO will not work properly because the KL term
will be equivalent to the implied reward"). So KTO is streamable *only because
v0.72.3 lifted the batch-1 restriction* — under v0.72.0.2 it could not have
shipped at all.
### The C5 SFT miss (1.7%) is a fixture artifact, with evidence
It is on the **SFT** path, which this slot does not touch: the only change to the
budget multiplies `batch` by a per-task row count, which is **1** for SFT, so the
arithmetic is identical. The authoritative check is v0.72.3's peak-VRAM grid test —
**10 real measured runs**, within 1%, never under-predicting — which passes
unchanged. The 1.7% is 1.06 MB on a 61.81 MB peak at a fixture point far outside
the fitted envelope (vocab **64** vs the 49 152 / 151 936 it was fitted on), where
the constant terms dominate and the logits term is ~0.
---
## Three measurement attempts that were INVALID, recorded so they are not repeated
1. **Peak measured across `setup()`** charged the pre-flight's own GEMM ceiling
probe — three 4096³ bf16 matrices, ~100 MB transient — to the training step, and
reported a 44% "under-prediction" that did not exist. Reset the peak counter
*after* setup.
2. **Cross-run retention inflated every peak by one buffer pool.** The pool is held
by reference cycles in the module tree: measured `close()` alone retains
**+47.65 MB**, `close()` + `gc.collect()` retains **+0.00 MB**. Not a leak and
not a defect — but back-to-back streamed runs in one process hold the previous
pool until a cycle-collection pass.
3. **Two attempts to fit a preference-specific logits constant.** With short rows
`max_length` never bound the effective sequence — DPO measured an identical
63.43 MB at seq 128, **256 and 512**; an independent variable that does not move
measures nothing. With long rows the batch shapes showed TRL was not truncating
to `max_length` either (`(1, 2401)` at every setting). **No constant was
published rather than fabricating one.**
## The row multiplier is real, and had to be re-measured to prove it
DPO, ORPO and SimPO build their forward through `concatenated_inputs` +
`torch.cat`, so **2 × batch_size** rows reach the model in ONE tensor; KTO runs its
KL batch as a **separate** forward, so its count is 1x.
At vocab 64 the logits term is ~0, so 1x and 2x predict almost the same number:
**that check passed for both answers and was therefore not evidence.** Re-run at
vocab 32 000 the two are **+71.3% apart** (83.04 MB vs 142.22 MB), and all five
tasks still never under-predict.
## The estimator is a sound UPPER bound for preference losses, and it is loose
Charging `2 × batch` rows at `ForCausalLMLoss`'s measured 14 bytes/element is a
genuine upper bound — the concatenated forward *is* an SFT-shaped forward at twice
the rows, and TRL's preference losses reduce logits to per-token log-probs
(`selective_log_softmax`) instead of holding a full-vocab fp32 upcast, so their true
per-element cost is strictly lower. Measured: DPO's whole above-resident cost was
**51.76 MB** where the 14 B/elt charge for the same shape is ~458 MB.
Practical boundary, computed exactly (Llama-3.2-1B, vocab 128 256, NF4, 3.32 GB
free — this card):
```
seq 512 SFT 1.65 GB allowed | DPO 2.63 GB allowed
seq 768 SFT 2.14 GB allowed | DPO 3.60 GB REFUSED
seq 1024 SFT 2.63 GB allowed | DPO 4.58 GB REFUSED
```
The conservatism does **not** block the realistic seq-512 configuration; the exact
point where it starts refusing runs that would probably fit is **seq 768 at batch 1**.
Shipped as-is because under-predicting is the strictly worse failure — on Windows it
is not an exception but a silent WDDM spill — and documented rather than papered over.
---
## End-to-end, through the released CLI
SmolLM2-135M, NF4, streamed (30 layers, 0.05 GB pinned RAM store, 2 × 2 MB VRAM
buffers), real `soup train`:
| task | loss | note |
|---|---|---|
| dpo | 0.6931 → 0.6695 | starts at `logsigmoid(0)` because `lora_B = 0` makes the reference equal the policy, then moves — the A3 property visible in a real run |
| orpo | 5.3816 → 5.0559 | |
| simpo | 5.2033 → 4.8749 | |
| kto | 0.5000 → 0.4904 (6 epochs) | its single-step run is flat at 0.5 for the same initialisation reason; checked over more steps rather than assumed |
All four saved adapters: **120 tensors, 0 keys carrying the streaming wrapper
segment, 60/60 non-zero `lora_B`** — i.e. ordinary LoRA adapters that load into any
non-streaming model.
Ten rejected configurations each named its own reason (rollout tasks, unsupported
task, KTO at batch 1, unsloth backend, 8-bit, no adapter, DoRA, `batch_size: auto`,
packing).

View File

@ -30,7 +30,7 @@ soup merge-sharded-fsdp-weights ./shards -o merged.safetensors Consolidate FSDP
soup delinearize-llama4 ./src --target ./out [--num-experts N] [--plan-only] Live Llama-4 fused-expert reshape [E*din,dout] -> [E,din,dout] + sidecar copy (v0.71.21)
soup spectrum scan --model <id|path> --top-percent 50 [--modules mlp,attn] [-o patch.yaml] Spectrum SNR scan (no model load) -> training.unfrozen_parameters YAML patch (v0.71.23)
soup train --config sft.yaml # training.lisa_enabled: true [lisa_num_layers lisa_interval_steps] LISA layerwise importance sampling — full-FT quality at LoRA-like memory (sft/transformers/text/quantization=none) (v0.71.34)
soup train --config sft.yaml # training.stream_layers: true [stream_source stream_buffers] BETA layer streaming — the frozen base streams from CPU RAM/NVMe one decoder layer at a time, so peak VRAM is bounded by ONE layer; quantization: 4bit streams it as NF4, ~4x smaller (sft/transformers/text, wider archs) (v0.72.0; NF4 v0.72.2; disk+batch+accum v0.72.3)
soup train --config sft.yaml # training.stream_layers: true [stream_source stream_buffers] BETA layer streaming — the frozen base streams from CPU RAM/NVMe one decoder layer at a time, so peak VRAM is bounded by ONE layer; quantization: 4bit streams it as NF4, ~4x smaller (sft/dpo/orpo/simpo/kto on transformers+text, 9 archs; grpo/ppo permanently excluded) (v0.72.0; NF4 v0.72.2; disk+batch+accum v0.72.3; preference losses v0.72.4)
soup export --model ./output --format gguf Export to GGUF (Ollama)
soup export --model ./output --deploy ollama Export GGUF + auto-deploy to Ollama
soup export --model ./output --format onnx Export to ONNX
@ -337,5 +337,3 @@ exact command that would run — they never execute training or export.
**Security:** stdio only (no network listener); every path argument stays under
the working directory and rejects symlinks; tool output is control-char
sanitized; error messages never leak filesystem paths.

View File

@ -14,7 +14,7 @@
- [Cross-Document Attention Masking](#cross-document-attention-masking)
- [Quant Menu — 9 Quantization Formats](#quant-menu--9-quantization-formats)
- [Activation Offloading (Small-VRAM Large-Batch)](#activation-offloading-small-vram-large-batch)
- [Layer Streaming (BETA, v0.72.0; NF4 v0.72.2; disk + wider archs v0.72.3)](#layer-streaming-beta-v0720-nf4-v0722-disk--wider-archs-v0723)
- [Layer Streaming (BETA, v0.72.0; NF4 v0.72.2; disk + wider archs v0.72.3)](#layer-streaming-beta-v0720-nf4-v0722-disk--wider-archs-v0723-preference-losses-v0724)
- [Correctness First (v0.36.0)](#correctness-first-v0360)
- [Multi-GPU / DeepSpeed / FSDP](#multi-gpu--deepspeed--fsdp)
- [Performance + Long-Context](#performance--long-context)
@ -226,7 +226,7 @@ training:
Not compatible with unsloth (own memory manager) or mlx. Wired across every transformer-backend trainer (SFT, DPO, GRPO, KTO, ORPO, SimPO, IPO, PPO, Reward-Model, Embedding, Pretrain).
## Layer Streaming (BETA, v0.72.0; NF4 v0.72.2; disk + wider archs v0.72.3)
## Layer Streaming (BETA, v0.72.0; NF4 v0.72.2; disk + wider archs v0.72.3; preference losses v0.72.4)
Stream frozen base-model decoder layers ONE at a time from CPU RAM into small VRAM buffers instead of keeping the whole base resident. Peak VRAM is bounded by the size of a single layer, not the entire model — so models that don't fit resident on your GPU can now train at all.
@ -342,7 +342,8 @@ prints this advice when it sees you accumulating.
- `batch_size: "auto"` → OOM-probes a resident model that streaming never loads; explicit batch sizes allowed (v0.72.3)
- `quantization` other than `none` or `4bit` → other formats cannot be streamed into a pooled buffer
- `backend: unsloth` / `backend: mlx` → streaming replaces the model-load path those backends own
- `task` other than `sft` → preference losses are v0.72.4
- `task` other than `sft` / `dpo` / `orpo` / `simpo` / `kto` → named explicitly. `grpo` and `ppo` are refused **permanently**, not pending: generation rollouts re-read every layer once per generated token, which destroys the amortisation streaming depends on
- `task: kto` with `batch_size: 1` → TRL's KL term is degenerate at batch 1; refused when the config is read rather than minutes later after sharding
- `lora.use_dora` / `lora.use_vera` / `lora.init_strategy` other than `random` → these initialise from the real base weight, which is on the meta device under streaming
- `unfrozen_parameters`, `lisa_enabled`, `packing`, `multipack`, `use_fsdp2_compile`, `train_router_only`, `expand_layers` → each independently rewrites or re-freezes the same layers
- `stream_source` / `stream_buffers` set while `stream_layers: false` → a footgun, refused
@ -393,15 +394,61 @@ output: ./output
> If that prints anything, the adapter is affected. From v0.72.1 a streamed adapter is byte-for-byte in the same layout as an ordinary LoRA run.
**Troubleshooting:**
- **"layer streaming needs the base to fit in RAM"** — the base is larger than free RAM. Free RAM or pick a smaller base; the disk overflow tier is v0.72.3.
- **"layer streaming needs the base to fit in RAM"** — the base is larger than free RAM. Set `stream_source: auto` to fall back to the NVMe disk tier, free RAM, or pick a smaller base.
- **"could not page-lock the base … falling back to a PAGEABLE RAM store"** — expected on a busy machine. Training continues, more slowly. Close other applications to keep the pinned store.
- **"layer streaming does not support model_type=…"** — Llama and Qwen only for now; more architectures land in v0.72.3.
- **"layer streaming does not support model_type=…"** — the supported list is llama / qwen2 / qwen3 / mistral / gemma / gemma2 / gemma3_text / phi / phi3. Multimodal `gemma3` is excluded on purpose; use `gemma3_text`.
- **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 names its release):**
- Disk overflow tier, batch size > 1, gradient accumulation, checkpoint/resume, more architectures (Mistral / Gemma / Phi) — **v0.72.3**
- Preference losses (DPO / ORPO / SimPO / KTO) — **v0.72.4**. GRPO and PPO are explicitly **not** planned: rollouts need generation, which re-reads the model per token
### Preference losses over streaming (v0.72.4)
`dpo`, `orpo`, `simpo` and `kto` stream exactly like `sft` — same config keys, same
pre-flight, same refusals. The interesting part is DPO's reference model.
**DPO compares the model being trained against a frozen reference.** Implemented as a
second model instance that doubles memory and there is no point streaming at all. Soup
instead uses *the same streamed base with its LoRA adapters switched off*, so the
reference costs no extra weights. Measured on an RTX 3050 4 GB with a 730 MB model:
| arm | peak VRAM | vs SFT |
|---|---|---|
| streamed SFT | 89.53 MB | — |
| **streamed DPO** | **81.87 MB** | **0.914×** |
| the same run forced to build a real second model | 812.32 MB | 9.92× |
The third row is the control: a second instance costs **+730.44 MB against 730.44 MB of
weights**, i.e. exactly one copy. The RAM store and the VRAM buffer pool are
byte-identical between the SFT and DPO arms.
**KTO is not reference-free**, however it is usually described — it selects a reference
the same way DPO does, so it gets the same treatment. ORPO and SimPO genuinely are
reference-free. All four are verified **bit-exact** against a resident run of the same
loss.
**The cost is time, not memory.** DPO runs the layer stack three times per step (policy
forward, reference forward, checkpoint recompute) against SFT's two — measured **1.52×**
the layer reads on a 24-layer model. Streaming makes the reference free in memory; it
does not make it free.
**Two things to know before you configure it:**
- **`kto` needs `batch_size: 2` or more.** TRL's KL term is degenerate at batch 1, so
the run cannot work; Soup refuses it when your config is read rather than after
sharding the checkpoint. (KTO is streamable at all only because v0.72.3 lifted
streaming's own batch-1 restriction.)
- **The VRAM pre-flight is deliberately conservative for paired losses.** DPO, ORPO and
SimPO send chosen and rejected through the model as one tensor, so the budget charges
twice the rows — correct, and it never under-predicts. But it charges them at the
*supervised* loss's measured per-element rate, and TRL's preference losses use a
cheaper path, so the estimate is an upper bound rather than a tight one. Concretely,
on a 4 GB card with a 128k-vocab 1B model: DPO at `max_length: 512` is allowed, and
from `max_length: 768` up it is refused even though it would probably fit. Lower
`max_length` if you hit that. (Tracked as a follow-up; under-predicting would be the
strictly worse failure, because on Windows it is not an error but a silent spill to
host memory.)
**Roadmap:**
- A published 14B-on-8 GB reference benchmark — hardware-blocked; it needs an 8 GB card and 32 GB of RAM, which the development box does not have
- GRPO and PPO are explicitly **not** planned: rollouts need generation, which re-reads the model per token
**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`). That costs disk space roughly equal to the base. The cache is keyed to a fingerprint of the source checkpoint, so a base retrained in place re-shards instead of silently training against stale weights.
@ -708,5 +755,3 @@ Autopilot also detects pre-quantized bases automatically — `TheBloke/Llama-2-7
The advanced GGUF pipeline uses POSIX `O_NOFOLLOW` to defeat the TOCTOU race between the dispatch-time symlink check and the actual open of the calibration data — a crafted environment cannot race-swap the calibration file between validate and read.
`soup deploy autopilot --measure` caches results at `~/.soup/deploy_autopilot_cache.json` keyed on `(base, profile, eval-tasks)`. Repeat invocations short-circuit; pass `SOUP_DEPLOY_AUTOPILOT_CACHE=<path>` to redirect (constrained to home / cwd / tempdir). The recommended candidate uses soft-fallback: first `OK` by insertion order, else the candidate with the smallest delta (least drop relative to its own baseline).

View File

@ -5,10 +5,12 @@
> SFT, DPO/GRPO/PPO/KTO/ORPO/SimPO/IPO/BCO, tool-calling, PRM, pre-training, distillation, classification, vision/audio/TTS, unlearning, RAFT/RA-DIT, and the loop-hardening detectors.
> **Training a model bigger than your GPU?** `training.stream_layers: true` streams the
> frozen base from CPU RAM (with NVMe disk overflow in v0.72.3) one decoder layer at a time,
> so peak VRAM is bounded by one layer instead of the whole model. Add `quantization: 4bit`
> and an 8B base fits a 4 GB card — see
> [Layer Streaming](performance-and-quantization.md#layer-streaming-beta-v0720-nf4-v0722-disk--wider-archs-v0723).
> frozen base from CPU RAM (with NVMe disk overflow) one decoder layer at a time, so peak
> VRAM is bounded by one layer instead of the whole model. Add `quantization: 4bit` and an
> 8B base fits a 4 GB card. Works for `sft` and, from v0.72.4, for `dpo` / `orpo` /
> `simpo` / `kto` — DPO's reference model is the same streamed base with its adapters
> switched off, so it costs no extra weights — see
> [Layer Streaming](performance-and-quantization.md#layer-streaming-beta-v0720-nf4-v0722-disk--wider-archs-v0723-preference-losses-v0724).
**Contents:**
@ -1444,5 +1446,3 @@ a conflicting combo is rejected loudly at config load. Scans cache under `~/.sou
`all` default) is recommended for very large models — it skips the giant embedding/lm_head matrices.
The SNR kernel is pure-numpy and transpose-invariant, so GPT-2 `Conv1D` weights score the same as
Linear weights. (v0.71.23)

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "soup-cli"
version = "0.72.3"
version = "0.72.4"
description = "Fine-tune and post-train LLMs in one command. No SSH, no config hell."
readme = "README.md"
license = "Apache-2.0"

View File

@ -1,3 +1,3 @@
"""Soup CLI — Fine-tune and post-train LLMs in one command."""
__version__ = "0.72.3"
__version__ = "0.72.4"

View File

@ -12,6 +12,12 @@ from soup_cli.utils.layer_stream import (
MAX_STREAM_BUFFERS,
MIN_STREAM_BUFFERS,
)
from soup_cli.utils.layer_stream import (
ROLLOUT_STREAM_TASKS as _STREAM_ROLLOUT_TASKS,
)
from soup_cli.utils.layer_stream import (
SUPPORTED_STREAM_TASKS as _STREAM_SUPPORTED_TASKS,
)
# v0.39.0 Part C — per-pattern LoRA rank/alpha bounds
_MAX_LORA_RANK_PATTERN_KEYS = 256
@ -4658,11 +4664,22 @@ class SoupConfig(BaseModel):
"the base layer-by-layer."
)
return self
if self.task != "sft":
# v0.72.4 — the four preference losses join SFT. DPO and KTO take their
# reference from the SAME streamed base with adapters disabled (TRL's
# `null_ref_context`), so the reference costs no extra weights: measured
# 0.914x SFT peak VRAM where a second instance was 9.92x.
if self.task in _STREAM_ROLLOUT_TASKS:
raise ValueError(
f"training.stream_layers requires task='sft'; got "
f"task={self.task!r}. Preference losses (DPO/ORPO/SimPO/KTO) "
f"land in v0.72.4."
f"training.stream_layers cannot be used with task={self.task!r}: "
f"it needs generation rollouts, which re-read every layer once "
f"per generated token. That destroys the amortisation streaming "
f"depends on (one weight read per step, not per token), so this "
f"is a permanent exclusion rather than an unimplemented one."
)
if self.task not in _STREAM_SUPPORTED_TASKS:
raise ValueError(
f"training.stream_layers supports task in "
f"{sorted(_STREAM_SUPPORTED_TASKS)}; got task={self.task!r}."
)
if self.backend != "transformers":
raise ValueError(
@ -4706,6 +4723,23 @@ class SoupConfig(BaseModel):
# pre-flight says so rather than the schema refusing it, because
# accumulation is the ONLY way to raise effective batch once the VRAM
# budget is exhausted (peak moved 0.842 -> 0.846 GB across accum 1->4).
# v0.72.4 — KTO's KL term is degenerate at a per-device batch of 1, so
# TRL refuses it outright ("Actual (not effective) batch size must be
# > 1"). Under streaming that ValueError arrives only AFTER the RAM
# pre-flight, the checkpoint sharding and — at quantization='4bit' —
# the NF4 quantisation pass: minutes of disk I/O on a real base, to
# fail on a config that was already invalid. Refuse it at parse time.
if (
self.task == "kto"
and isinstance(tcfg.batch_size, int)
and tcfg.batch_size < 2
):
raise ValueError(
"task='kto' requires training.batch_size >= 2 (TRL's KL term is "
"degenerate at batch 1). Checked here rather than in the "
"trainer so a streaming run fails before sharding the "
"checkpoint, not minutes into it."
)
if tcfg.lora.r < 1:
raise ValueError(
"training.stream_layers requires LoRA (training.lora.r >= 1) — "

View File

@ -7,12 +7,13 @@ from typing import Optional
from rich.console import Console
from soup_cli.config.schema import SoupConfig
from soup_cli.trainer.stream_setup import StreamingSetupMixin
from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name
console = Console()
class DPOTrainerWrapper:
class DPOTrainerWrapper(StreamingSetupMixin):
"""High-level wrapper for DPO training from SoupConfig.
DPO requires preference data with three fields:
@ -21,6 +22,11 @@ class DPOTrainerWrapper:
- rejected: the less preferred response
"""
#: TRL builds this loss's forward through ``concatenated_inputs`` +
#: ``torch.cat``, so chosen and rejected arrive as ONE tensor of twice
#: the configured batch. The VRAM pre-flight must budget for that.
_STREAM_ROWS_PER_EXAMPLE = 2
def __init__(
self,
config: SoupConfig,
@ -69,14 +75,28 @@ class DPOTrainerWrapper:
cfg = self.config
tcfg = cfg.training
use_unsloth = cfg.backend == "unsloth"
# v0.72.4 — layer streaming replaces the model-load path entirely (meta
# skeleton, never a resident load), so it dispatches ahead of the backend
# branches. The schema already rejects streaming + unsloth/mlx.
use_streaming = bool(getattr(tcfg, "stream_layers", False))
if use_unsloth:
if use_streaming:
self._setup_streaming_transformers(cfg, tcfg)
elif use_unsloth:
self._setup_unsloth(cfg, tcfg)
else:
self._setup_transformers(cfg, tcfg)
trainable, total = self.model.get_nb_trainable_parameters()
pct = 100 * trainable / total
# v0.72.4 (mirrors sft.py) — under NF4 streaming PEFT's total is wrong
# by ~6.5x: it sizes Params4bit as `numel * 2 * quant_storage.itemsize`,
# right for a RESIDENT one but not for our `meta` placeholder, which
# still carries the LOGICAL shape. The sharder counted the real source
# elements, so prefer that.
stream_total = getattr(self._stream_runtime, "total_params", 0)
if stream_total:
total = stream_total
pct = 100 * trainable / total if total else 0.0
console.print(
f"[green]LoRA applied:[/] {trainable:,} trainable"
f" / {total:,} total ({pct:.2f}%)"
@ -311,8 +331,10 @@ class DPOTrainerWrapper:
from soup_cli.utils.v028_features import activation_offloading_context
with activation_offloading_context(
self.config.training, self._output_dir,
# v0.72.4 — the shared context releases the streaming weight source even
# if training raises (see StreamingSetupMixin._training_context).
with self._training_context(
activation_offloading_context(self.config.training, self._output_dir)
):
self.trainer.train(resume_from_checkpoint=resume_from_checkpoint)
duration = time.time() - start

View File

@ -8,12 +8,13 @@ from typing import Optional
from rich.console import Console
from soup_cli.config.schema import SoupConfig
from soup_cli.trainer.stream_setup import StreamingSetupMixin
from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name
console = Console()
class KTOTrainerWrapper:
class KTOTrainerWrapper(StreamingSetupMixin):
"""High-level wrapper for KTO training from SoupConfig.
KTO uses unpaired preference data with three fields:
@ -22,6 +23,10 @@ class KTOTrainerWrapper:
- label: True (desirable) or False (undesirable)
"""
#: KTO runs its KL batch as a SEPARATE forward rather than concatenating
#: it (``kto_trainer.py`` :939-977), so the row count per forward is 1x.
_STREAM_ROWS_PER_EXAMPLE = 1
def __init__(
self,
config: SoupConfig,
@ -67,14 +72,28 @@ class KTOTrainerWrapper:
cfg = self.config
tcfg = cfg.training
use_unsloth = cfg.backend == "unsloth"
# v0.72.4 — layer streaming replaces the model-load path entirely (meta
# skeleton, never a resident load), so it dispatches ahead of the backend
# branches. The schema already rejects streaming + unsloth/mlx.
use_streaming = bool(getattr(tcfg, "stream_layers", False))
if use_unsloth:
if use_streaming:
self._setup_streaming_transformers(cfg, tcfg)
elif use_unsloth:
self._setup_unsloth(cfg, tcfg)
else:
self._setup_transformers(cfg, tcfg)
trainable, total = self.model.get_nb_trainable_parameters()
pct = 100 * trainable / total
# v0.72.4 (mirrors sft.py) — under NF4 streaming PEFT's total is wrong
# by ~6.5x: it sizes Params4bit as `numel * 2 * quant_storage.itemsize`,
# right for a RESIDENT one but not for our `meta` placeholder, which
# still carries the LOGICAL shape. The sharder counted the real source
# elements, so prefer that.
stream_total = getattr(self._stream_runtime, "total_params", 0)
if stream_total:
total = stream_total
pct = 100 * trainable / total if total else 0.0
console.print(
f"[green]LoRA applied:[/] {trainable:,} trainable"
f" / {total:,} total ({pct:.2f}%)"
@ -292,8 +311,10 @@ class KTOTrainerWrapper:
from soup_cli.utils.v028_features import activation_offloading_context
with activation_offloading_context(
self.config.training, self._output_dir,
# v0.72.4 — the shared context releases the streaming weight source even
# if training raises (see StreamingSetupMixin._training_context).
with self._training_context(
activation_offloading_context(self.config.training, self._output_dir)
):
self.trainer.train(resume_from_checkpoint=resume_from_checkpoint)
duration = time.time() - start

View File

@ -8,12 +8,13 @@ from typing import Optional
from rich.console import Console
from soup_cli.config.schema import SoupConfig
from soup_cli.trainer.stream_setup import StreamingSetupMixin
from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name
console = Console()
class ORPOTrainerWrapper:
class ORPOTrainerWrapper(StreamingSetupMixin):
"""High-level wrapper for ORPO training from SoupConfig.
ORPO uses preference data (same format as DPO) but does not
@ -25,6 +26,11 @@ class ORPOTrainerWrapper:
- rejected: the less preferred response
"""
#: TRL builds this loss's forward through ``concatenated_inputs`` +
#: ``torch.cat``, so chosen and rejected arrive as ONE tensor of twice
#: the configured batch. The VRAM pre-flight must budget for that.
_STREAM_ROWS_PER_EXAMPLE = 2
def __init__(
self,
config: SoupConfig,
@ -69,14 +75,28 @@ class ORPOTrainerWrapper:
cfg = self.config
tcfg = cfg.training
use_unsloth = cfg.backend == "unsloth"
# v0.72.4 — layer streaming replaces the model-load path entirely (meta
# skeleton, never a resident load), so it dispatches ahead of the backend
# branches. The schema already rejects streaming + unsloth/mlx.
use_streaming = bool(getattr(tcfg, "stream_layers", False))
if use_unsloth:
if use_streaming:
self._setup_streaming_transformers(cfg, tcfg)
elif use_unsloth:
self._setup_unsloth(cfg, tcfg)
else:
self._setup_transformers(cfg, tcfg)
trainable, total = self.model.get_nb_trainable_parameters()
pct = 100 * trainable / total
# v0.72.4 (mirrors sft.py) — under NF4 streaming PEFT's total is wrong
# by ~6.5x: it sizes Params4bit as `numel * 2 * quant_storage.itemsize`,
# right for a RESIDENT one but not for our `meta` placeholder, which
# still carries the LOGICAL shape. The sharder counted the real source
# elements, so prefer that.
stream_total = getattr(self._stream_runtime, "total_params", 0)
if stream_total:
total = stream_total
pct = 100 * trainable / total if total else 0.0
console.print(
f"[green]LoRA applied:[/] {trainable:,} trainable"
f" / {total:,} total ({pct:.2f}%)"
@ -290,8 +310,10 @@ class ORPOTrainerWrapper:
from soup_cli.utils.v028_features import activation_offloading_context
with activation_offloading_context(
self.config.training, self._output_dir,
# v0.72.4 — the shared context releases the streaming weight source even
# if training raises (see StreamingSetupMixin._training_context).
with self._training_context(
activation_offloading_context(self.config.training, self._output_dir)
):
self.trainer.train(resume_from_checkpoint=resume_from_checkpoint)
duration = time.time() - start

View File

@ -2,7 +2,6 @@
import json
import logging
import math
import os
import time
from pathlib import Path
@ -11,6 +10,7 @@ from typing import Optional, Tuple
from rich.console import Console
from soup_cli.config.schema import SoupConfig
from soup_cli.trainer.stream_setup import StreamingSetupMixin
from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name
logger = logging.getLogger(__name__)
@ -147,7 +147,7 @@ def _maybe_load_pretokenized(
return train_ds, eval_ds
class SFTTrainerWrapper:
class SFTTrainerWrapper(StreamingSetupMixin):
"""High-level wrapper that sets up model + tokenizer + trainer from SoupConfig."""
def __init__(
@ -234,7 +234,7 @@ class SFTTrainerWrapper:
# but not for our `meta` placeholder, which still carries the LOGICAL
# shape. Measured on SmolLM2-135M: 878,154,048 vs a true 134,515,008.
# The sharder counted the real source elements, so use that.
stream_total = getattr(getattr(self, "_stream_runtime", None), "total_params", 0)
stream_total = getattr(self._stream_runtime, "total_params", 0)
if stream_total:
total = stream_total
pct = 100 * trainable / total if total else 0.0
@ -966,398 +966,6 @@ class SFTTrainerWrapper:
self._apply_quantization_aware(tcfg)
def _setup_streaming_transformers(self, cfg, tcfg):
"""v0.72.0 BETA — layer streaming. The resident base load NEVER happens.
Builds the skeleton on ``meta`` (``accelerate.init_empty_weights``),
materialises only embeddings / final norm / LoRA, and streams each
decoder layer from CPU RAM into a small pool of pre-allocated VRAM
buffers. Peak VRAM becomes the size of ONE layer instead of the model.
"""
from dataclasses import replace
from peft import LoraConfig, TaskType
from transformers import AutoConfig, AutoTokenizer
from soup_cli.utils.layer_shard import (
QUANT_NF4,
QUANT_NONE,
resolve_shard_dir,
shard_checkpoint,
source_weight_bytes,
)
from soup_cli.utils.layer_stream import (
RAM_TIER_HEADROOM,
TIER_DISK,
TIER_RAM,
build_stream_plan,
detect_disk_kind,
dtype_bytes,
estimate_stream_store_bytes,
free_ram_bytes,
render_stream_panel,
stream_arch_of,
)
from soup_cli.utils.layer_stream_runtime import (
RamSource,
build_meta_skeleton,
build_streamed_model,
extras_resident_bytes,
probe_expandable_segments,
quantised_layer_suffixes,
)
from soup_cli.utils.spectrum_scan import resolve_model_weights
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(
cfg.base, trust_remote_code=self._trust_remote_code
)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
model_config = AutoConfig.from_pretrained(
cfg.base, trust_remote_code=self._trust_remote_code
)
# Allowlist, not a heuristic — a half-supported architecture streams
# weights into the wrong module and mis-trains silently.
arch = stream_arch_of(model_config)
on_cuda = str(self.device).startswith("cuda")
dtype = "bfloat16" if on_cuda else "float32"
# v0.72.2 — NF4. The decoder linears ship as packed nibbles + per-block
# absmax, so the RAM store is ~0.26x its bf16 size; embeddings, norms and
# an untied head stay at `dtype`, exactly as replace_with_bnb_linear
# leaves them.
quant = QUANT_NF4 if tcfg.quantization == "4bit" else QUANT_NONE
weights_dir = resolve_model_weights(cfg.base)
shard_dir = resolve_shard_dir(cfg.base)
# Cheap size probe BEFORE sharding: re-writing a checkpoint we are
# about to refuse for not fitting in RAM costs minutes of disk I/O.
# Charged at the STREAMED rate, not the on-disk one — an 8B bf16
# checkpoint is 16 GB on disk but only ~4.2 GB of NF4 store, and
# comparing the raw file size would refuse exactly the runs NF4 enables.
early_free_ram = free_ram_bytes()
if early_free_ram is not None:
source_bytes = source_weight_bytes(weights_dir)
store_estimate = estimate_stream_store_bytes(
source_bytes, dtype=dtype, quant=quant
)
if (
store_estimate >= early_free_ram * RAM_TIER_HEADROOM
and tcfg.stream_source == "ram"
):
as_streamed = (
""
if quant == QUANT_NONE
else f" ({store_estimate / 1e9:.1f} GB once quantised to NF4)"
)
raise ValueError(
f"training.stream_source='ram' but {cfg.base} is "
f"{source_bytes / 1e9:.1f} GB on disk{as_streamed} and only "
f"{early_free_ram / 1e9:.1f} GB of RAM is free. Set "
f"stream_source='auto' to fall back to the NVMe disk tier, "
f"free RAM, or pick a smaller base."
)
# The authoritative list of weights to quantise is whatever
# replace_with_bnb_linear actually converts, read off a meta skeleton —
# not a hard-coded name list that would drift per architecture.
#
# This builds a second, throwaway skeleton (build_streamed_model makes
# its own). Deliberate: a meta skeleton allocates NO weight storage, so
# the cost is module-tree construction only, and threading a pre-built
# model into build_streamed_model would couple suffix discovery to model
# construction for no memory saving.
quant_suffixes = ()
if quant == QUANT_NF4:
probe = build_meta_skeleton(
cfg.base,
dtype=dtype,
quant=quant,
trust_remote_code=self._trust_remote_code,
)
quant_suffixes = quantised_layer_suffixes(probe)
del probe
console.print(f"[dim]Preparing layer shards -> {shard_dir}[/]")
index = shard_checkpoint(
weights_dir,
shard_dir,
dtype=dtype,
arch=arch,
quant=quant,
quant_suffixes=quant_suffixes,
# Quantise on the device that will run the model: CPU and CUDA agree
# on the packed nibbles but not on every float32 nested statistic.
quant_device=str(self.device),
)
spec = RamSource.spec_from_shard(shard_dir)
# Measured from the shard headers, not derived from `total_params`:
# under NF4 a layer holds packed uint8 alongside float32 statistics, so
# element counts no longer convert to bytes at a single rate.
layer_bytes = sum(
math.prod(shape) * dtype_bytes(stored) for shape, stored in spec.values()
)
embed_bytes = extras_resident_bytes(shard_dir)
free_ram = free_ram_bytes()
if free_ram is None:
console.print(
"[yellow]psutil unavailable — cannot size the RAM tier; "
"proceeding and letting the allocation fail loudly if it must[/]"
)
free_ram = (layer_bytes * index.n_layers + embed_bytes) * 10
store_total = layer_bytes * index.n_layers + embed_bytes
# Checked BEFORE build_stream_plan so a `ram`-only run is refused with
# the message about stream_source rather than choose_tier's generic
# "needs NVMe or more RAM" — and without paying the ~9 s disk probe for
# an answer that cannot change the outcome.
if tcfg.stream_source == "ram" and store_total >= free_ram * RAM_TIER_HEADROOM:
raise ValueError(
f"training.stream_source='ram' but the base is "
f"{store_total / 1e9:.1f} GB and only {free_ram / 1e9:.1f} GB of "
f"RAM is free. Set stream_source='auto' to fall back to the NVMe "
f"disk tier, free RAM, or pick a smaller base."
)
plan = build_stream_plan(
arch=arch,
n_layers=index.n_layers,
layer_bytes=layer_bytes,
embed_bytes=embed_bytes,
available_ram_bytes=free_ram,
# The page-locked ceiling is a property of the box, not of free RAM;
# rather than probe it destructively we attempt the pinned store and
# fall back loudly (see layer_stream_runtime._build_source).
pinned_limit_bytes=None,
buffers=tcfg.stream_buffers,
# v0.72.3: the REAL media type, not a constant. Passed as a callable
# because probing costs ~9 s on Windows and the answer only matters
# when the base does not fit in RAM.
disk_kind=lambda: detect_disk_kind(shard_dir),
)
# v0.72.3 — the disk overflow tier is live, so a base that does not fit
# in RAM is no longer fatal. `stream_source` decides: 'ram' insists,
# 'disk' forces, 'auto' (the default) takes RAM when it fits and falls
# back to disk when it does not. build_stream_plan already refused a
# non-NVMe disk, so reaching here with tier='disk' means NVMe.
tier = TIER_DISK if tcfg.stream_source == "disk" else plan.tier
if tier != plan.tier:
# The panel is rendered from `plan`, so a forced tier has to be
# reflected there or the pre-flight reports "tier ram" immediately
# before the runtime announces it is streaming from disk. Every
# field that describes the RAM store is corrected with it, so no
# consumer can read a stale value.
plan = replace(
plan,
tier=tier,
store_bytes=0,
pinned=False,
notes=plan.notes
+ (
"streaming from disk because stream_source='disk' was set, "
"not because RAM was short. Nothing is held resident, and "
"the slowdown versus the RAM tier is unmeasured on this "
"hardware.",
),
)
# v0.72.3 — VRAM pre-flight. Streaming bounds the WEIGHTS; activations
# and the logits tensor are untouched by it and both scale with batch x
# seq. On a large-vocab model the logits term alone dwarfs the buffer
# pool (measured: 146x at batch 8), so a plan that reports only tier and
# buffer sizes will happily green-light a config that cannot run.
forecast_lines = self._stream_budget_lines(
cfg,
tcfg,
model_config=model_config,
layer_bytes=layer_bytes,
embed_bytes=embed_bytes,
index=index,
on_cuda=on_cuda,
)
console.print(render_stream_panel(plan, forecast_lines))
console.print(
"[yellow]Layer streaming is BETA:[/] slower than resident training, "
"but this model may not run resident on this card at all."
)
if on_cuda and not probe_expandable_segments():
console.print(
"[dim]expandable_segments allocator hint is unavailable on this "
"platform (silently ignored on Windows) — not enabled[/]"
)
target_modules = tcfg.lora.target_modules
if target_modules == "auto":
target_modules = None
lora_config = LoraConfig(
r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=target_modules,
task_type=TaskType.CAUSAL_LM,
bias="none",
use_dora=tcfg.lora.use_dora,
use_rslora=tcfg.lora.use_rslora,
)
model, runtime = build_streamed_model(
model_id=cfg.base,
shard_dir=shard_dir,
index=index,
lora_config=lora_config,
device=self.device,
dtype=dtype,
buffers=tcfg.stream_buffers,
pin=plan.pinned and on_cuda,
seed=tcfg.seed if getattr(tcfg, "seed", None) is not None else 0,
trust_remote_code=self._trust_remote_code,
console=console,
quant=quant,
tier=tier,
)
self.model = model
self._stream_runtime = runtime
stats = runtime.stats()
if stats["tier"] == TIER_RAM:
source_line = (
f"{stats['store_bytes'] / 1e9:.2f} GB "
f"{'pinned' if stats['pinned'] else 'pageable'} RAM store"
)
else:
source_line = (
f"streamed from DISK ({stats['disk_bytes'] / 1e9:.2f} GB on an "
f"NVMe volume, nothing held resident)"
)
buffer_line = (
f"{stats['buffers']} x "
f"{stats['buffer_bytes'] / stats['buffers'] / 1e6:.0f} MB VRAM buffers"
)
console.print(
f"[green]Layer streaming ready:[/] {stats['n_layers']} layers, "
f"{source_line}, {buffer_line}"
)
def _close_stream_runtime(self) -> None:
"""Release the streaming weight source, if this run had one."""
runtime = getattr(self, "_stream_runtime", None)
if runtime is not None:
runtime.close()
def _estimate_adapter_params(self, tcfg, model_config) -> int:
"""Trainable adapter parameters, before the model exists.
Deliberately coarse and biased HIGH: it assumes every targeted module is
hidden x hidden. Gate/up/down projections are larger, but the whole
adapter term is ~0.5% of a streaming step's peak, so precision here buys
nothing while under-counting would eat into the safety margin.
"""
hidden = int(getattr(model_config, "hidden_size", 0) or 0)
layers = int(getattr(model_config, "num_hidden_layers", 0) or 0)
targets = tcfg.lora.target_modules
n_targets = len(targets) if isinstance(targets, (list, tuple)) else 4
return layers * n_targets * 2 * tcfg.lora.r * hidden
def _stream_budget_lines(
self, cfg, tcfg, *, model_config, layer_bytes, embed_bytes, index, on_cuda
):
"""Predict peak VRAM + bracket throughput, and REFUSE a run that cannot fit.
Returns the extra lines for the pre-flight panel. Raises when the step is
predicted not to fit: on Linux that would be a hard OOM, and on Windows
something worse WDDM spills to host memory without raising, so the run
silently becomes an order of magnitude slower and looks like the feature
is merely slow.
"""
from soup_cli.utils.layer_stream import (
accumulation_advice,
decide_stream_fit,
estimate_logits_bytes,
estimate_stream_peak_vram,
forecast_stream_throughput,
)
from soup_cli.utils.layer_stream_runtime import measure_gemm_tflops
vocab = int(getattr(model_config, "vocab_size", 0) or 0)
hidden = int(getattr(model_config, "hidden_size", 0) or 0)
inter = int(getattr(model_config, "intermediate_size", 0) or 0)
seq_len = int(cfg.data.max_length)
batch = tcfg.batch_size if isinstance(tcfg.batch_size, int) else 1
if not (vocab and hidden and inter):
# Never silently: skipping the budget also skips the refusal that
# stops a run from OOMing (or, on Windows, spilling to host memory
# and running an order of magnitude slower with no error at all).
console.print(
"[yellow]Layer streaming could not read vocab_size / hidden_size "
"/ intermediate_size from the model config, so peak VRAM cannot "
"be predicted — the pre-flight fit check is SKIPPED for this "
"run.[/]"
)
return ()
predicted = estimate_stream_peak_vram(
layer_bytes=layer_bytes,
buffers=tcfg.stream_buffers,
extras_bytes=embed_bytes,
adapter_params=self._estimate_adapter_params(tcfg, model_config),
vocab_size=vocab,
hidden_size=hidden,
intermediate_size=inter,
n_layers=index.n_layers,
seq_len=seq_len,
batch_size=batch,
)
logits = estimate_logits_bytes(
vocab_size=vocab, seq_len=seq_len, batch_size=batch
)
lines = [
f" peak VRAM ~{predicted / 1e9:.2f} GB at batch {batch} x seq "
f"{seq_len} (logits {logits / 1e9:.2f} GB)"
]
if not on_cuda:
return tuple(lines)
import torch
available = int(torch.cuda.mem_get_info()[0])
fit = decide_stream_fit(predicted_bytes=predicted, available_bytes=available)
if not fit.fits:
raise ValueError(fit.reason)
lines.append(f" free VRAM {available / 1e9:.2f} GB")
# A per-card TFLOPS constant baked into the source would be a
# fabrication; measuring the user's own card in this session is the only
# honest input, and the result is reported as a bracket because real
# streamed runs landed at 68%-100% of their measured ceiling.
ceiling = measure_gemm_tflops(device=str(self.device))
if ceiling is not None and index.total_params:
shaped = forecast_stream_throughput(
params=index.total_params,
effective_tflops=ceiling.tflops,
tokens_per_epoch=0,
sm_clock_mhz=ceiling.sm_clock_mhz,
)
clock = f" @ {ceiling.sm_clock_mhz} MHz" if ceiling.sm_clock_mhz else ""
lines.append(
f" forecast {shaped.tokens_per_sec_low:.0f}-"
f"{shaped.tokens_per_sec_ceiling:.0f} tok/s — a compute-bound "
f"bound, not a promise"
)
lines.append(
f" (from {ceiling.tflops:.2f} TFLOPS measured on "
f"this card now{clock})"
)
advice = accumulation_advice(
batch_size=batch, accum=tcfg.gradient_accumulation_steps
)
if advice is not None:
lines.append(f" [yellow]![/] {advice}")
return tuple(lines)
def _apply_quantization_aware(self, tcfg) -> None:
"""Apply quantization-aware training post-LoRA (shared text/vision).
@ -1723,20 +1331,11 @@ class SFTTrainerWrapper:
f"{self._output_dir!r}"
)
offload_save_dir = candidate
import contextlib
with contextlib.ExitStack() as _train_ctx:
# v0.72.3 — release the streaming weight source even if training
# raises. On the disk tier that is one open shard handle per decoder
# layer (80+ on a large model), and an OOM mid-run is a realistic
# outcome on exactly the small cards this feature targets — which is
# precisely the case that leaks across back-to-back runs in one
# process (`soup sweep`, the web UI). Registered FIRST so it runs
# LAST, after the other contexts have unwound.
_train_ctx.callback(self._close_stream_runtime)
_train_ctx.enter_context(
offload_context(tcfg.activation_offloading, save_dir=offload_save_dir)
)
# v0.72.3 — the shared context releases the streaming weight source even
# if training raises (see StreamingSetupMixin._training_context).
with self._training_context(
offload_context(tcfg.activation_offloading, save_dir=offload_save_dir)
) as train_ctx:
# LongLoRA S² shifted-sparse attention (v0.49.0 schema). The override
# monkeypatches attention.forward for the duration of training and
# was previously never installed (use_longlora validated but shipped
@ -1747,7 +1346,7 @@ class SFTTrainerWrapper:
from soup_cli.utils.longlora import apply_longlora_forward_override
try:
_train_ctx.enter_context(
train_ctx.enter_context(
apply_longlora_forward_override(self.model)
)
console.print("[green]LongLoRA S² attention override active[/]")

View File

@ -8,12 +8,13 @@ from typing import Optional
from rich.console import Console
from soup_cli.config.schema import SoupConfig
from soup_cli.trainer.stream_setup import StreamingSetupMixin
from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name
console = Console()
class SimPOTrainerWrapper:
class SimPOTrainerWrapper(StreamingSetupMixin):
"""High-level wrapper for SimPO training from SoupConfig.
SimPO is a reference-free preference optimization method that uses
@ -26,6 +27,11 @@ class SimPOTrainerWrapper:
- rejected: the less preferred response
"""
#: TRL builds this loss's forward through ``concatenated_inputs`` +
#: ``torch.cat``, so chosen and rejected arrive as ONE tensor of twice
#: the configured batch. The VRAM pre-flight must budget for that.
_STREAM_ROWS_PER_EXAMPLE = 2
def __init__(
self,
config: SoupConfig,
@ -70,14 +76,28 @@ class SimPOTrainerWrapper:
cfg = self.config
tcfg = cfg.training
use_unsloth = cfg.backend == "unsloth"
# v0.72.4 — layer streaming replaces the model-load path entirely (meta
# skeleton, never a resident load), so it dispatches ahead of the backend
# branches. The schema already rejects streaming + unsloth/mlx.
use_streaming = bool(getattr(tcfg, "stream_layers", False))
if use_unsloth:
if use_streaming:
self._setup_streaming_transformers(cfg, tcfg)
elif use_unsloth:
self._setup_unsloth(cfg, tcfg)
else:
self._setup_transformers(cfg, tcfg)
trainable, total = self.model.get_nb_trainable_parameters()
pct = 100 * trainable / total
# v0.72.4 (mirrors sft.py) — under NF4 streaming PEFT's total is wrong
# by ~6.5x: it sizes Params4bit as `numel * 2 * quant_storage.itemsize`,
# right for a RESIDENT one but not for our `meta` placeholder, which
# still carries the LOGICAL shape. The sharder counted the real source
# elements, so prefer that.
stream_total = getattr(self._stream_runtime, "total_params", 0)
if stream_total:
total = stream_total
pct = 100 * trainable / total if total else 0.0
console.print(
f"[green]LoRA applied:[/] {trainable:,} trainable"
f" / {total:,} total ({pct:.2f}%)"
@ -293,8 +313,10 @@ class SimPOTrainerWrapper:
from soup_cli.utils.v028_features import activation_offloading_context
with activation_offloading_context(
self.config.training, self._output_dir,
# v0.72.4 — the shared context releases the streaming weight source even
# if training raises (see StreamingSetupMixin._training_context).
with self._training_context(
activation_offloading_context(self.config.training, self._output_dir)
):
self.trainer.train(resume_from_checkpoint=resume_from_checkpoint)
duration = time.time() - start

View File

@ -0,0 +1,453 @@
"""Shared layer-streaming setup for every trainer wrapper that supports it.
v0.72.4 extracted verbatim from ``trainer/sft.py`` so that SFT and the four
preference losses (DPO / ORPO / SimPO / KTO) cannot drift. There is exactly one
copy of the NF4 pre-flight, the RAM/disk tier decision, the VRAM fit refusal and
the runtime release; a per-wrapper copy would be five places to fix the next
time any of them is wrong.
The move is behaviour-preserving for SFT by design, so v0.72.0-.3's
bit-exactness gates remain valid without being re-run. The ONE addition is
``_STREAM_ROWS_PER_EXAMPLE``: DPO, ORPO and SimPO build their forward through
TRL's ``concatenated_inputs`` + ``torch.cat``, so 2 x ``batch_size`` rows reach
the model in a single tensor. v0.72.3's VRAM estimator was validated on the
property that it NEVER under-predicts, and budgeting those three at 1x rows
would break exactly that on Windows the consequence is not an exception but a
silent WDDM spill to host memory that makes the run an order of magnitude
slower with no error at all.
NO top-level torch: this module is imported by five trainer modules.
"""
import contextlib
import math
from rich.console import Console
console = Console()
class StreamingSetupMixin:
"""Builds a layer-streamed model in place of the resident load.
Requires the host wrapper to provide ``self.device``,
``self._trust_remote_code``, and to accept ``self.model`` / ``self.tokenizer``
/ ``self._stream_runtime`` being set.
"""
#: Rows that reach the model per dataset example. 1 for a plain causal LM
#: step; 2 for a loss whose forward concatenates chosen and rejected.
_STREAM_ROWS_PER_EXAMPLE = 1
#: Set by :meth:`_setup_streaming_transformers`; absent on a resident run.
_stream_runtime = None
@contextlib.contextmanager
def _training_context(self, *contexts):
"""The `with` block every trainer runs `trainer.train()` inside.
Its whole job is ordering: ``_close_stream_runtime`` is registered
FIRST so it runs LAST, after every other context has unwound, and it
runs even when training raises. That matters because an OOM mid-run is
a realistic outcome on exactly the small cards this feature targets,
and on the disk tier the runtime holds one open shard handle per decoder
layer which is the case that leaks across back-to-back runs in one
process (`soup sweep`, the web UI).
Yields the stack so a caller can enter further contexts conditionally.
"""
with contextlib.ExitStack() as stack:
stack.callback(self._close_stream_runtime)
for context in contexts:
stack.enter_context(context)
yield stack
def _setup_streaming_transformers(self, cfg, tcfg):
"""v0.72.0 BETA — layer streaming. The resident base load NEVER happens.
Builds the skeleton on ``meta`` (``accelerate.init_empty_weights``),
materialises only embeddings / final norm / LoRA, and streams each
decoder layer from CPU RAM into a small pool of pre-allocated VRAM
buffers. Peak VRAM becomes the size of ONE layer instead of the model.
"""
from dataclasses import replace
from peft import LoraConfig, TaskType
from transformers import AutoConfig, AutoTokenizer
from soup_cli.utils.layer_shard import (
QUANT_NF4,
QUANT_NONE,
resolve_shard_dir,
shard_checkpoint,
source_weight_bytes,
)
from soup_cli.utils.layer_stream import (
RAM_TIER_HEADROOM,
TIER_DISK,
TIER_RAM,
build_stream_plan,
detect_disk_kind,
dtype_bytes,
estimate_stream_store_bytes,
free_ram_bytes,
render_stream_panel,
stream_arch_of,
)
from soup_cli.utils.layer_stream_runtime import (
RamSource,
build_meta_skeleton,
build_streamed_model,
extras_resident_bytes,
probe_expandable_segments,
quantised_layer_suffixes,
)
from soup_cli.utils.spectrum_scan import resolve_model_weights
console.print(f"[dim]Loading tokenizer: {cfg.base}[/]")
self.tokenizer = AutoTokenizer.from_pretrained(
cfg.base, trust_remote_code=self._trust_remote_code
)
if self.tokenizer.pad_token is None:
self.tokenizer.pad_token = self.tokenizer.eos_token
model_config = AutoConfig.from_pretrained(
cfg.base, trust_remote_code=self._trust_remote_code
)
# Allowlist, not a heuristic — a half-supported architecture streams
# weights into the wrong module and mis-trains silently.
arch = stream_arch_of(model_config)
on_cuda = str(self.device).startswith("cuda")
dtype = "bfloat16" if on_cuda else "float32"
# v0.72.2 — NF4. The decoder linears ship as packed nibbles + per-block
# absmax, so the RAM store is ~0.26x its bf16 size; embeddings, norms and
# an untied head stay at `dtype`, exactly as replace_with_bnb_linear
# leaves them.
quant = QUANT_NF4 if tcfg.quantization == "4bit" else QUANT_NONE
weights_dir = resolve_model_weights(cfg.base)
shard_dir = resolve_shard_dir(cfg.base)
# Cheap size probe BEFORE sharding: re-writing a checkpoint we are
# about to refuse for not fitting in RAM costs minutes of disk I/O.
# Charged at the STREAMED rate, not the on-disk one — an 8B bf16
# checkpoint is 16 GB on disk but only ~4.2 GB of NF4 store, and
# comparing the raw file size would refuse exactly the runs NF4 enables.
early_free_ram = free_ram_bytes()
if early_free_ram is not None:
source_bytes = source_weight_bytes(weights_dir)
store_estimate = estimate_stream_store_bytes(source_bytes, dtype=dtype, quant=quant)
if store_estimate >= early_free_ram * RAM_TIER_HEADROOM and tcfg.stream_source == "ram":
as_streamed = (
""
if quant == QUANT_NONE
else f" ({store_estimate / 1e9:.1f} GB once quantised to NF4)"
)
raise ValueError(
f"training.stream_source='ram' but {cfg.base} is "
f"{source_bytes / 1e9:.1f} GB on disk{as_streamed} and only "
f"{early_free_ram / 1e9:.1f} GB of RAM is free. Set "
f"stream_source='auto' to fall back to the NVMe disk tier, "
f"free RAM, or pick a smaller base."
)
# The authoritative list of weights to quantise is whatever
# replace_with_bnb_linear actually converts, read off a meta skeleton —
# not a hard-coded name list that would drift per architecture.
#
# This builds a second, throwaway skeleton (build_streamed_model makes
# its own). Deliberate: a meta skeleton allocates NO weight storage, so
# the cost is module-tree construction only, and threading a pre-built
# model into build_streamed_model would couple suffix discovery to model
# construction for no memory saving.
quant_suffixes = ()
if quant == QUANT_NF4:
probe = build_meta_skeleton(
cfg.base,
dtype=dtype,
quant=quant,
trust_remote_code=self._trust_remote_code,
)
quant_suffixes = quantised_layer_suffixes(probe)
del probe
console.print(f"[dim]Preparing layer shards -> {shard_dir}[/]")
index = shard_checkpoint(
weights_dir,
shard_dir,
dtype=dtype,
arch=arch,
quant=quant,
quant_suffixes=quant_suffixes,
# Quantise on the device that will run the model: CPU and CUDA agree
# on the packed nibbles but not on every float32 nested statistic.
quant_device=str(self.device),
)
spec = RamSource.spec_from_shard(shard_dir)
# Measured from the shard headers, not derived from `total_params`:
# under NF4 a layer holds packed uint8 alongside float32 statistics, so
# element counts no longer convert to bytes at a single rate.
layer_bytes = sum(math.prod(shape) * dtype_bytes(stored) for shape, stored in spec.values())
embed_bytes = extras_resident_bytes(shard_dir)
free_ram = free_ram_bytes()
if free_ram is None:
console.print(
"[yellow]psutil unavailable — cannot size the RAM tier; "
"proceeding and letting the allocation fail loudly if it must[/]"
)
free_ram = (layer_bytes * index.n_layers + embed_bytes) * 10
store_total = layer_bytes * index.n_layers + embed_bytes
# Checked BEFORE build_stream_plan so a `ram`-only run is refused with
# the message about stream_source rather than choose_tier's generic
# "needs NVMe or more RAM" — and without paying the ~9 s disk probe for
# an answer that cannot change the outcome.
if tcfg.stream_source == "ram" and store_total >= free_ram * RAM_TIER_HEADROOM:
raise ValueError(
f"training.stream_source='ram' but the base is "
f"{store_total / 1e9:.1f} GB and only {free_ram / 1e9:.1f} GB of "
f"RAM is free. Set stream_source='auto' to fall back to the NVMe "
f"disk tier, free RAM, or pick a smaller base."
)
plan = build_stream_plan(
arch=arch,
n_layers=index.n_layers,
layer_bytes=layer_bytes,
embed_bytes=embed_bytes,
available_ram_bytes=free_ram,
# The page-locked ceiling is a property of the box, not of free RAM;
# rather than probe it destructively we attempt the pinned store and
# fall back loudly (see layer_stream_runtime._build_source).
pinned_limit_bytes=None,
buffers=tcfg.stream_buffers,
# v0.72.3: the REAL media type, not a constant. Passed as a callable
# because probing costs ~9 s on Windows and the answer only matters
# when the base does not fit in RAM.
disk_kind=lambda: detect_disk_kind(shard_dir),
)
# v0.72.3 — the disk overflow tier is live, so a base that does not fit
# in RAM is no longer fatal. `stream_source` decides: 'ram' insists,
# 'disk' forces, 'auto' (the default) takes RAM when it fits and falls
# back to disk when it does not. build_stream_plan already refused a
# non-NVMe disk, so reaching here with tier='disk' means NVMe.
tier = TIER_DISK if tcfg.stream_source == "disk" else plan.tier
if tier != plan.tier:
# The panel is rendered from `plan`, so a forced tier has to be
# reflected there or the pre-flight reports "tier ram" immediately
# before the runtime announces it is streaming from disk. Every
# field that describes the RAM store is corrected with it, so no
# consumer can read a stale value.
plan = replace(
plan,
tier=tier,
store_bytes=0,
pinned=False,
notes=plan.notes
+ (
"streaming from disk because stream_source='disk' was set, "
"not because RAM was short. Nothing is held resident, and "
"the slowdown versus the RAM tier is unmeasured on this "
"hardware.",
),
)
# v0.72.3 — VRAM pre-flight. Streaming bounds the WEIGHTS; activations
# and the logits tensor are untouched by it and both scale with batch x
# seq. On a large-vocab model the logits term alone dwarfs the buffer
# pool (measured: 146x at batch 8), so a plan that reports only tier and
# buffer sizes will happily green-light a config that cannot run.
forecast_lines = self._stream_budget_lines(
cfg,
tcfg,
model_config=model_config,
layer_bytes=layer_bytes,
embed_bytes=embed_bytes,
index=index,
on_cuda=on_cuda,
)
console.print(render_stream_panel(plan, forecast_lines))
console.print(
"[yellow]Layer streaming is BETA:[/] slower than resident training, "
"but this model may not run resident on this card at all."
)
if on_cuda and not probe_expandable_segments():
console.print(
"[dim]expandable_segments allocator hint is unavailable on this "
"platform (silently ignored on Windows) — not enabled[/]"
)
target_modules = tcfg.lora.target_modules
if target_modules == "auto":
target_modules = None
lora_config = LoraConfig(
r=tcfg.lora.r,
lora_alpha=tcfg.lora.alpha,
lora_dropout=tcfg.lora.dropout,
target_modules=target_modules,
task_type=TaskType.CAUSAL_LM,
bias="none",
use_dora=tcfg.lora.use_dora,
use_rslora=tcfg.lora.use_rslora,
)
model, runtime = build_streamed_model(
model_id=cfg.base,
shard_dir=shard_dir,
index=index,
lora_config=lora_config,
device=self.device,
dtype=dtype,
buffers=tcfg.stream_buffers,
pin=plan.pinned and on_cuda,
seed=tcfg.seed if getattr(tcfg, "seed", None) is not None else 0,
trust_remote_code=self._trust_remote_code,
console=console,
quant=quant,
tier=tier,
)
self.model = model
self._stream_runtime = runtime
stats = runtime.stats()
if stats["tier"] == TIER_RAM:
source_line = (
f"{stats['store_bytes'] / 1e9:.2f} GB "
f"{'pinned' if stats['pinned'] else 'pageable'} RAM store"
)
else:
source_line = (
f"streamed from DISK ({stats['disk_bytes'] / 1e9:.2f} GB on an "
f"NVMe volume, nothing held resident)"
)
buffer_line = (
f"{stats['buffers']} x "
f"{stats['buffer_bytes'] / stats['buffers'] / 1e6:.0f} MB VRAM buffers"
)
console.print(
f"[green]Layer streaming ready:[/] {stats['n_layers']} layers, "
f"{source_line}, {buffer_line}"
)
def _close_stream_runtime(self) -> None:
"""Release the streaming weight source, if this run had one."""
runtime = getattr(self, "_stream_runtime", None)
if runtime is not None:
runtime.close()
def _estimate_adapter_params(self, tcfg, model_config) -> int:
"""Trainable adapter parameters, before the model exists.
Deliberately coarse and biased HIGH: it assumes every targeted module is
hidden x hidden. Gate/up/down projections are larger, but the whole
adapter term is ~0.5% of a streaming step's peak, so precision here buys
nothing while under-counting would eat into the safety margin.
"""
hidden = int(getattr(model_config, "hidden_size", 0) or 0)
layers = int(getattr(model_config, "num_hidden_layers", 0) or 0)
targets = tcfg.lora.target_modules
n_targets = len(targets) if isinstance(targets, (list, tuple)) else 4
return layers * n_targets * 2 * tcfg.lora.r * hidden
def _stream_budget_lines(
self, cfg, tcfg, *, model_config, layer_bytes, embed_bytes, index, on_cuda
):
"""Predict peak VRAM + bracket throughput, and REFUSE a run that cannot fit.
Returns the extra lines for the pre-flight panel. Raises when the step is
predicted not to fit: on Linux that would be a hard OOM, and on Windows
something worse WDDM spills to host memory without raising, so the run
silently becomes an order of magnitude slower and looks like the feature
is merely slow.
"""
from soup_cli.utils.layer_stream import (
accumulation_advice,
decide_stream_fit,
estimate_logits_bytes,
estimate_stream_peak_vram,
forecast_stream_throughput,
)
from soup_cli.utils.layer_stream_runtime import measure_gemm_tflops
vocab = int(getattr(model_config, "vocab_size", 0) or 0)
hidden = int(getattr(model_config, "hidden_size", 0) or 0)
inter = int(getattr(model_config, "intermediate_size", 0) or 0)
seq_len = int(cfg.data.max_length)
batch = tcfg.batch_size if isinstance(tcfg.batch_size, int) else 1
# v0.72.4 — a paired loss concatenates chosen and rejected into ONE
# tensor, so twice the rows reach the model per configured batch. The
# estimator's contract is that it never under-predicts; budgeting a
# paired loss at 1x rows would halve the logits term, which is the
# dominant one (measured 146x the buffer pool at batch 8).
rows = batch * self._STREAM_ROWS_PER_EXAMPLE
if not (vocab and hidden and inter):
# Never silently: skipping the budget also skips the refusal that
# stops a run from OOMing (or, on Windows, spilling to host memory
# and running an order of magnitude slower with no error at all).
console.print(
"[yellow]Layer streaming could not read vocab_size / hidden_size "
"/ intermediate_size from the model config, so peak VRAM cannot "
"be predicted — the pre-flight fit check is SKIPPED for this "
"run.[/]"
)
return ()
predicted = estimate_stream_peak_vram(
layer_bytes=layer_bytes,
buffers=tcfg.stream_buffers,
extras_bytes=embed_bytes,
adapter_params=self._estimate_adapter_params(tcfg, model_config),
vocab_size=vocab,
hidden_size=hidden,
intermediate_size=inter,
n_layers=index.n_layers,
seq_len=seq_len,
batch_size=rows,
)
logits = estimate_logits_bytes(vocab_size=vocab, seq_len=seq_len, batch_size=rows)
paired = (
"" if rows == batch else f" ({rows} rows — chosen+rejected are one concatenated tensor)"
)
lines = [
f" peak VRAM ~{predicted / 1e9:.2f} GB at batch {batch} x seq "
f"{seq_len}{paired} (logits {logits / 1e9:.2f} GB)"
]
if not on_cuda:
return tuple(lines)
import torch
available = int(torch.cuda.mem_get_info()[0])
fit = decide_stream_fit(predicted_bytes=predicted, available_bytes=available)
if not fit.fits:
raise ValueError(fit.reason)
lines.append(f" free VRAM {available / 1e9:.2f} GB")
# A per-card TFLOPS constant baked into the source would be a
# fabrication; measuring the user's own card in this session is the only
# honest input, and the result is reported as a bracket because real
# streamed runs landed at 68%-100% of their measured ceiling.
ceiling = measure_gemm_tflops(device=str(self.device))
if ceiling is not None and index.total_params:
shaped = forecast_stream_throughput(
params=index.total_params,
effective_tflops=ceiling.tflops,
tokens_per_epoch=0,
sm_clock_mhz=ceiling.sm_clock_mhz,
)
clock = f" @ {ceiling.sm_clock_mhz} MHz" if ceiling.sm_clock_mhz else ""
lines.append(
f" forecast {shaped.tokens_per_sec_low:.0f}-"
f"{shaped.tokens_per_sec_ceiling:.0f} tok/s — a compute-bound "
f"bound, not a promise"
)
lines.append(
f" (from {ceiling.tflops:.2f} TFLOPS measured on "
f"this card now{clock})"
)
advice = accumulation_advice(batch_size=batch, accum=tcfg.gradient_accumulation_steps)
if advice is not None:
lines.append(f" [yellow]![/] {advice}")
return tuple(lines)

View File

@ -41,6 +41,21 @@ MIN_STREAM_BUFFERS = 2
MAX_STREAM_BUFFERS = 8
DEFAULT_STREAM_BUFFERS = 2
# --- tasks ----------------------------------------------------------------
#: Tasks whose trainers can run against a streamed base (v0.72.4).
#:
#: DPO and KTO take their reference model from the SAME streamed base with the
#: adapters disabled (TRL's ``null_ref_context``), so the reference costs no
#: extra weights at all — measured 0.914x the SFT peak, where forcing a real
#: second instance cost 9.92x. ORPO and SimPO are reference-free.
SUPPORTED_STREAM_TASKS = ("sft", "dpo", "orpo", "simpo", "kto")
#: Tasks PERMANENTLY excluded, not merely unimplemented. Generation rollouts
#: re-read every layer once per generated token, which destroys the whole
#: premise: streaming amortises one weight read over a training step, not over a
#: single decoded token (plan §3.2).
ROLLOUT_STREAM_TASKS = ("grpo", "ppo")
#: FLOPs per parameter per token. 6 == WITH gradient checkpointing
#: (2 forward + 2 recompute + 2 dL/dx; base weight-grads are skipped because
#: the base is frozen). Streaming always checkpoints, so this is never 4.

View File

@ -769,9 +769,18 @@ class TestStreamSchemaDefaults:
class TestStreamTaskAndBackendGates:
def test_non_sft_task_rejected(self):
with pytest.raises(ValueError, match="sft"):
_load(_stream_yaml(task="dpo"))
def test_unsupported_task_rejected(self):
"""v0.72.4 opened the four preference losses, so `dpo` is no longer the
example here. `reward_model` still has no streaming path, and `grpo` is
excluded permanently rollouts re-read every layer per generated
token."""
with pytest.raises(ValueError, match="stream_layers"):
_load(_stream_yaml(task="reward_model"))
with pytest.raises(ValueError, match="generation"):
_load(_stream_yaml(task="grpo"))
def test_preference_tasks_are_accepted_since_v0724(self):
assert _load(_stream_yaml(task="dpo")).task == "dpo"
def test_unsloth_backend_rejected(self):
with pytest.raises(ValueError, match="transformers"):

View File

@ -490,17 +490,26 @@ class TestRefusalsNameThePostRenumberSlot:
):
load_config_from_string(_stream_yaml(**{field: value}))
def test_preference_losses_name_v0724(self):
def test_preference_losses_shipped_in_v0724(self):
"""This refusal is GONE, per the shrink-not-grow rule above. What
replaced it is a PERMANENT exclusion for the rollout tasks, which must
NOT name a release a version number there would read as "coming soon"
to the next maintainer and invite them to wire up something that cannot
work."""
from soup_cli.config.loader import load_config_from_string
yaml_text = (
"base: hf-internal-testing/tiny-random-LlamaForCausalLM\n"
"task: dpo\n"
"data:\n train: data.jsonl\n"
"training:\n stream_layers: true\n batch_size: 1\n"
)
with pytest.raises(ValueError, match="v0.72.4"):
load_config_from_string(yaml_text)
def _yaml(task):
return (
"base: hf-internal-testing/tiny-random-LlamaForCausalLM\n"
f"task: {task}\n"
"data:\n train: data.jsonl\n"
"training:\n stream_layers: true\n batch_size: 1\n"
)
assert load_config_from_string(_yaml("dpo")).task == "dpo"
with pytest.raises(ValueError) as excinfo:
load_config_from_string(_yaml("grpo"))
assert "v0.72" not in str(excinfo.value)
def test_arch_allowlist_names_v0723(self):
from soup_cli.utils.layer_stream import stream_arch_of

1056
tests/test_v07204.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,82 @@
"""The version lives in two files and nothing asserted they agree.
`pyproject.toml` and `src/soup_cli/__init__.py` both carry the version, and the
release checklist says to bump both. Every other version test in this suite is a
FLOOR check (`>= 0.56.0`, `>= 0.53.11`, ...), which passes happily when one file
is bumped and the other is forgotten the wheel then ships with metadata that
disagrees with `soup version`, and CI stays green. Until now that drift was
caught only by a human eye on step 6a of the checklist.
"""
import pathlib
import re
import pytest
ROOT = pathlib.Path(__file__).resolve().parents[1]
PYPROJECT = ROOT / "pyproject.toml"
INIT = ROOT / "src" / "soup_cli" / "__init__.py"
_PYPROJECT_VERSION = re.compile(r'^version\s*=\s*"([^"]+)"', re.M)
_INIT_VERSION = re.compile(r'^__version__\s*=\s*"([^"]+)"', re.M)
def _pyproject_version(text: str) -> str:
"""The version from `[project]`, not from some other table.
A naive first-match search would also accept a `version = "..."` belonging to
a tool section, so anchor on the `[project]` table and stop at the next one.
"""
project = re.search(r"^\[project\]\s*$(.*?)^\[", text, re.M | re.S)
assert project, "pyproject.toml has no [project] table"
match = _PYPROJECT_VERSION.search(project.group(1))
assert match, "no version line in the [project] table of pyproject.toml"
return match.group(1)
def _init_version(text: str) -> str:
match = _INIT_VERSION.search(text)
assert match, "no __version__ line in src/soup_cli/__init__.py"
return match.group(1)
class TestVersionSync:
def test_the_two_files_agree(self):
declared = _pyproject_version(PYPROJECT.read_text(encoding="utf-8"))
package = _init_version(INIT.read_text(encoding="utf-8"))
assert declared == package, (
f"pyproject.toml says {declared!r} but src/soup_cli/__init__.py says "
f"{package!r} — the release checklist requires BOTH to be bumped. "
f"The wheel would ship metadata that disagrees with `soup version`."
)
def test_the_importable_package_agrees_too(self):
"""The parsed file and the imported module can differ if a stale build
artifact shadows the source tree the exact "stale shadow install"
the checklist warns about at step 6a."""
from soup_cli import __version__
assert __version__ == _init_version(INIT.read_text(encoding="utf-8"))
@pytest.mark.parametrize(
"text,expected",
[
('[project]\nname = "x"\nversion = "1.2.3"\n\n[tool.ruff]\n', "1.2.3"),
# a version in another table must not be mistaken for the project's
('[project]\nname = "x"\nversion = "1.2.3"\n\n[tool.x]\nversion = "9.9.9"\n', "1.2.3"),
],
)
def test_parser_reads_the_project_table(self, text, expected):
assert _pyproject_version(text) == expected
def test_the_assertion_would_actually_catch_a_drift(self):
"""CONTROL. Without this, `test_the_two_files_agree` proves nothing about
the comparison itself it would pass just as happily against a helper
that returned the same constant twice."""
drifted = _pyproject_version('[project]\nversion = "0.99.0"\n\n[tool.x]\n')
package = _init_version('__version__ = "0.72.4"\n')
assert drifted != package
def test_version_is_a_plausible_release_string(self):
version = _init_version(INIT.read_text(encoding="utf-8"))
assert re.fullmatch(r"\d+\.\d+\.\d+(?:[.-]?(?:a|b|rc|dev)\d*)?", version), version