Preference losses over layer streaming (v0.72.4)

Until v0.72.4, a model too big for your card could only be supervised fine-tuned on it. task had to be sft, and dpo, orpo, simpo and kto were all refused when the config was read. All four run against a streamed base now, with the same keys SFT already used, so preference alignment stops being the step that forces you to rent a bigger card.

Nothing else about streaming changed. If you have not read it yet, start with Layer streaming for the mechanism and Scaling a streaming run for architectures, batches and the disk tier. This page is only about what the preference losses add on top.

The five accepted tasks

sft   dpo   orpo   simpo   kto

That list is exact. ipo, bco and the unified task: preference dispatcher are not in the streaming allowlist, so "all preference losses stream" would be false. grpo and ppo are excluded permanently, and their refusal deliberately names no release: generation rollouts re-read every layer once per generated token, which destroys the amortisation streaming exists to buy.

DPO's reference model is free

DPO scores the policy against a frozen reference. The obvious implementation builds a second copy of the model, which doubles the weights and defeats the entire premise of streaming: you would be back to needing the model twice over on a card that could not hold it once.

Soup takes the reference from the same streamed base with its LoRA adapters switched off. Policy and reference are one set of weights, one RAM store and one buffer pool, and the adapter is the only difference between the two forward passes.

Measured on the reference box, a 4 GB RTX 3050 Laptop, in bf16 with a 365.2M-parameter model (730.4 MB of weights, 24 decoder layers, vocabulary 260, sequence length 64, batch 1):

ArmPeak VRAMvs streamed SFT
Streamed SFT89.53 MB1.000x
Streamed DPO81.87 MB0.914x
Control: DPO forced to build a real second model812.32 MB9.92x

The RAM store was byte-identical between the SFT and DPO arms (729.91 MB each), and so was the VRAM buffer pool (60.83 MB each).

The control arm is what makes the headline mean anything. Forcing a real second instance moved the peak by 730.44 MB against 730.44 MB of weights, which is exactly one copy. As the gate record puts it: "DPO is not 2x SFT" means nothing unless the same harness can show what 2x looks like.

Read 0.914x as "no second copy", not as a saving

DPO is not cheaper than SFT in general. That row sits below 1.0 because of the fixture, not because of the feature: the SFT arm computes its 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. A negative delta is simply the strongest available form of "there is no second copy of the weights".

These numbers also come from a synthetic 365.2M-parameter model, not from a real checkpoint and not from Llama-3.1-8B. They are a memory-accounting result, not a throughput result.

Why this was measured rather than asserted

A passing loss curve cannot detect the failure this feature is exposed to. The streamed layer substitutes base weights through functional_call rather than through the module's own forward, so it is entirely plausible that disabling the adapter is a silent no-op through that path. If it were, the reference would be the policy, every log-ratio would be zero, and the DPO loss would sit at 0.6931 forever, which reads as slow training rather than as a bug.

So three things were checked that a loss curve cannot check:

  1. The memory table above, with its forced-second-instance control.
  2. A policy-versus-reference gap of 8.219452e-01 on chosen completions, proving the two passes really differ.
  3. A zero-adapter control measuring exactly 0.000e+00, proving that gap comes from the adapter and not from some other difference between the two forwards.

KTO is not reference-free

However it is usually described, KTO picks its reference exactly the way DPO does, so it gets exactly the same treatment: the same streamed base with adapters disabled, no second copy. ORPO and SimPO genuinely are reference-free and never construct one at all.

KTO carries one extra requirement, and Soup enforces it when the config is read rather than minutes into a run after the checkpoint has already been sharded:

task='kto' requires training.batch_size >= 2 (TRL's KL term is
degenerate at batch 1).

Note that KTO is streamable only because v0.72.3 lifted streaming's own batch-1 restriction. Under v0.72.0 through v0.72.2 it could not have shipped at all.

Free in memory is not free in time

DPO walks the layer stack more often than supervised fine-tuning does, because it adds a reference forward on top of the policy forward and the gradient-checkpoint recompute. Measured on the same 24-layer model: 46 layer loads per step for SFT against 70 for DPO, which is 1.52x.

That is the honest cost, and it is the one to plan around. Streaming makes the reference free in memory; it does not make it free.

Correctness

Every one of the four losses is bit-exact against a resident run of the same loss, difference exactly 0.0. DPO's arm ran in float32 on CPU precisely so that "bit-exact" means literally zero rather than bf16 noise: streamed 0.67242944 against resident 0.67242944 over 16 synchronised adapter tensors. Layer-0 adapter gradients are non-zero, and two runs at the same seed differ by 0.000e+00.

End to end through the released CLI, streaming SmolLM2-135M under NF4 (30 layers, a 0.05 GB pinned RAM store, two 2 MB VRAM buffers):

TaskLoss
dpo0.6931 to 0.6695
orpo5.3816 to 5.0559
simpo5.2033 to 4.8749
kto0.5000 to 0.4904 (6 epochs)

All four saved adapters are ordinary LoRA files: 120 tensors, zero keys carrying the streaming wrapper segment, and 60 of 60 lora_B tensors non-zero. They load into a non-streaming model like any other adapter.

A config that runs

DPO over a streamed, NF4-quantized base:

yaml
base: Qwen/Qwen2.5-3B
task: dpo
backend: transformers        # streaming requires it

data:
  train: ./data/preferences.jsonl
  format: dpo
  max_length: 512            # see the pre-flight note below

training:
  epochs: 3
  dpo_beta: 0.1
  batch_size: 1              # a concrete value; "auto" is refused
  gradient_accumulation_steps: 1
  quantization: 4bit         # or none
  gradient_checkpointing: true
  stream_layers: true
  stream_source: auto        # RAM when it fits, NVMe when it does not
  stream_buffers: 2
  lora:
    r: 64
    alpha: 16

output: ./output

The other three are the same file with one or two lines changed:

  • ORPO: task: orpo, and orpo_beta: 0.1 in place of dpo_beta.
  • SimPO: task: simpo, with simpo_gamma: 0.5 and cpo_alpha: 1.0.
  • KTO: task: kto, format: kto, kto_beta: 0.1, and batch_size: 2 or more.

Before you configure it: the pre-flight is a loose upper bound

The VRAM pre-flight predicts peak VRAM and refuses a run that will not fit. It is sound but conservative, and a preference run feels that most because it sends twice the rows through the same charge of 14 bytes per logit element, while TRL reduces preference logits to per-token log-probabilities with selective_log_softmax instead of holding a full-vocabulary fp32 upcast. The gap is large: DPO's entire above-resident cost measured 51.76 MB where the charge for the same shape is roughly 458 MB.

One correction worth carrying, from a later run of 72 configurations with three repeats and no spread: the over-prediction is not a preference-loss problem. A supervised control at the same effective row count misses by the same margin, so doubling the rows for a paired loss is the right thing to do and one shared coefficient is simply too high. Preference losses are where you notice it, not where it comes from.

In practice, on a 4 GB card with a 128k-vocabulary 1B model, DPO is allowed at max_length: 512 and refused from 768 up, even though it would probably fit.

That shipped deliberately, because under-predicting is the strictly worse failure. On Windows an overcommit is not an exception you can catch: WDDM silently spills into shared host memory, so the run completes an order of magnitude slower with no error at all. A run completing is not evidence that its configuration fits. Refusing is.

If the pre-flight refuses a preference run you believe fits, lower data.max_length first. Tightening the estimate for these losses is tracked upstream.

A packaging bug fixed along the way

Six preference trainers pass a field that TRL removed across several of its own releases, so on a fresh install soup train with task: orpo could fail at import before training ever started. It was invisible to CI because the TRL imports live inside the trainer's setup(), and no test had ever called setup() on those wrappers. The dependency bound that pins it was then settled by constructing all six configs against each candidate version rather than by reading source, which is the general lesson worth keeping: a version bound derived by reading source is a hypothesis, and the experiment that tests it is constructing the object.

This was a pre-existing defect, not one v0.72.4 introduced. A contract test now drives the real setup() for all six on the ordinary non-streaming path, and derives its covered set from the trainer sources so a seventh trainer joins automatically.

What is still out of scope

Layer streaming remains BETA.

  • task must be one of sft, dpo, orpo, simpo, kto. ipo, bco and task: preference are refused.
  • GRPO and PPO are permanently excluded, not pending a release.
  • backend: transformers, modality: text, plain LoRA, and quantization of none or 4bit.
  • No tokens-per-second figure is claimed for any preference loss, at any model size, because none has been measured. A later run on borrowed hardware timed all four in samples per second, which is a different unit and a directional one; see the section below.
  • The sizes above 8B that have been measured for streaming were measured on server hardware, not on the card this feature targets. What those runs establish.

Since measured on a real 8B

The numbers above come from a 365.2M-parameter synthetic fixture, which is the right size to make a second copy of the weights impossible to miss but the wrong size to be convincing on its own. The same claim was later checked on Llama-3.1-8B in NF4, streamed, through the shipped CLI, on an 8x H100 box:

Streamed taskReference modelPeak VRAMSamples/s, median of 3
sftnone3,689 MB6.082
orponone, genuinely reference-free3,719 MB5.973
simponone, genuinely reference-free3,719 MB5.946
dpothe same base, adapters disabled3,733 MB4.665
ktothe same base plus a separate KL forward3,669 MB3.595

Peak VRAM is flat across all five, within 64 MB or 1.7%. DPO costs 44 MB more than supervised fine-tuning, against roughly 5.6 GB for a second resident copy of an NF4 8B. That is the whole claim of this page, measured at a size where a second copy could not hide.

The throughput column needs three cautions. These are samples per second, not tokens per second, so they do not translate into the tok/s figures quoted elsewhere on this site. They come from 64-row single-epoch runs where setup is a large share of an 11 to 14 second step, so the ordering is reproducible and the absolute ratios are directional. And kto ran a different dataset, unpaired, so only its VRAM column compares directly.

Note that DPO's 0.77x here and the 1.52x layer reads quoted above are not the same metric and neither corrects the other: one counts samples per second end to end, the other counts weight reads per optimizer step.

One scoping note on a rule stated elsewhere: a preference loss on Llama-3.1-8B is refused by the pre-flight on a 4 GB card, and that is a property of the card, not of the model. On an 80 GB card the same configuration passes the pre-flight and trains, which is exactly what the table above is.

Measurement records and citation

The gate record behind this release is published in full, including the checks that failed, the diagnoses that turned out wrong, and the numbers that were measured and then discarded: the benchmarks directory. The layer-streaming work also has a preprint, 10.5281/zenodo.21771064, summarised on the paper page. Note the scope: v1 of the preprint measured the mechanism as it shipped in v0.72.3, v2 adds the 8x H100 session and the correctness repair that shipped in v0.73.0, and v3 retracts one explanation v2 gave without changing a measured number. The preference losses on this page came after v1 and are still outside what the paper measures.

See also

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.