20 KiB
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 resolves trl 0.29.1 / 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.
A dependency break found during this release, and two wrong diagnoses before the right one. Six trainers (
bco,dpo,ipo,kto,orpo,simpo) passmax_prompt_lengthto their trl config, and trl removed it in STAGES — which is why each spot-check gave a different answer. Read per config off the published wheels:
version dpo kto orpo cpo bco 0.24.0 yes yes yes yes yes 0.25.1 yes yes yes yes NO 0.26.0 yes NO NO NO NO 0.29.0 NO NO gone gone gone First diagnosis: "trl 1.x removed them" — taken from an existing note in this repo claiming CI runs trl 1.9.2. Wrong; CI's install log shows 0.29.1, so a
<1cap excluded nothing. Second: "the break is 0.29.0" — right fordpo, but checked onlydpo_config.pyand extrapolated. The dependency is capped<0.25, the last release on which all six work.This is a pre-existing bug, not one this release introduced: the trl imports live inside
setup(), which no test had ever called on those wrappers, so CI stayed green whilesoup train --task orpowas broken for anyone installing fresh. v0.72.4's end-to-end preference tests are what surfaced it.
Correction, post-release: the table above is wrong, and so was the cap
The paragraph above records three diagnoses. It should record four — the third was also wrong, and it is the one that shipped. Left in place above rather than edited, because the failure mode is the point.
The method ("read per config off the published wheels") was right; the execution was not. At 0.25
BCOConfigmoved totrl/experimental/bco/, and at 0.26kto/orpo/cpofollowed. All of them stayed publicly re-exported fromtrlwithmax_prompt_lengthintact. Seeing a config file vanish fromtrl/trainer/was read as the field being removed. A relocation was scored as a deletion.Re-derived by parsing each
*Configclass's own annotated fields across every wheel from 0.24.0 to 0.29.1 (all five inherit fromTrainingArguments, so there is no inherited-field escape hatch):
version dpo kto orpo cpo bco 0.24.0 – 0.26.2 yes yes yes yes yes 0.27.0 – 0.27.2 yes NO yes yes yes 0.28.0 yes NO NO NO NO 0.29.0 – 0.29.1 NO NO NO NO NO Then settled the way a static read cannot settle it — by constructing all six configs with the exact keyword arguments the wrappers pass:
trl 0.26.2 OK dpo · OK ipo · OK kto · OK orpo · OK simpo · OK bco trl 0.27.0 FAIL kto: KTOConfig.__init__() got an unexpected keyword argument 'max_prompt_length' OK dpo · OK orpo <- the control: the boundary is KTO's aloneSo the cap is
<0.27, not<0.25; 0.25.0, 0.25.1, 0.26.0, 0.26.1 and 0.26.2 were excluded for no reason. Two further corrections fall out of the same pass:
ORPOConfig/CPOConfigare not "gone" at 0.29 in the sense the table implies — the modules still exist undertrl/experimental/. What changed is that they,BCOConfigand their trainers are no longer exported from thetrlnamespace, which is what Soup imports. The 0.29 break is anImportErroratfrom trl import ORPOConfig, i.e. harder than a rejected keyword argument.- The floor
>=0.7.0was impossible and nobody checked it while carefully fixing the ceiling.setup()importsGRPOTrainerunconditionally, and trl first exports it at 0.14.0 (OnlineDPOTrainer/KTOTrainer/BCOTrainer/BasePairwiseJudgeat 0.11.0; 0.7.0 exports none of them). Now>=0.14.0.The transferable lesson is narrower than "check your bounds": a version bound derived by reading source is a hypothesis, and the experiment that tests it is constructing the object. Both wrong answers here were produced by reading, and both would have been caught in one minute by building the six configs.
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:
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|policy−ref| 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 DPO−SFT 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
- 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. - 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. - Two attempts to fit a preference-specific logits constant. With short rows
max_lengthnever 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 tomax_lengtheither ((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.
One CPU-only limitation, found by CI rather than locally
A full KTO training step over a streamed model runs on CUDA (verified here and
on the dev box) but fails on a CPU-only runner under newer torch/TRL with
Tensor on device cpu is not on the expected device meta!. Streaming exists to
bound VRAM, so a streamed model on CPU is a test convenience rather than a real
configuration — the same stance v0.72.3 took on PEFT's re-dispatch — and that test
is therefore gated to the production device. Everything else about KTO (the schema
gate, setup(), the reference behaviour, the layer-read accounting) is still
exercised on CPU.
Worth stating plainly: this was invisible on the dev box, which has CUDA. The locally-green suite is a weaker signal than it looks whenever a code path forks on device.
Correction, post-release: the gate above blamed the wrong variable
"A streamed model on CPU is a test convenience" is a reasonable-sounding generalisation applied to a specific, undiagnosed failure — and it is wrong here. Three pieces of evidence:
- The same CI run contradicts it. In the run that motivated the skip, exactly one test failed.
test_v07200.py::test_one_training_step_actually_runs— the identical streamed full-trainertrain()test for SFT — passed on that same CPU runner, as did the NF4 variants and all four preference bit-exactness tests, KTO included. CPU streaming demonstrably works there.- The test passes on CPU here. Executing that exact test body on the dev box with CUDA masked (
CUDA_VISIBLE_DEVICES=-1, torch 2.5.1 / trl 0.19.1 / transformers 4.57.6): PASSED, one step, loss 0.5. The variable is the newer stack CI resolves, not the device.- The error is a streaming property, not a device property.
Tensor on device cpu is not on the expected device meta!comes fromcheck_same_deviceintorch/_prims_common/__init__.py— an operation received ametaplaceholder alongside a real tensor, via torch's decomposition path. A meta placeholder is reachable by a real op in KTO's second (KL) forward; newer torch decomposes more operations, which is why it surfaces there and not here.The consequence is worse than a mislabelled skip. CI has no GPU runners (ubuntu / windows / macos only), so
skipif(not cuda)made the test dead everywhere in CI — it ran only on the dev box, under the old torch where it passes, whiletorchcarries no upper bound and users get the stack that fails. The skip now gates on the actual variable and the meta leak is tracked as its own issue rather than absorbed into a generalisation.
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).