v0.74.0: the base was loaded in fp32 the whole time

116 of the 120 pull requests merged into this release came from somebody other than the maintainer, by 25 people. The maintainer's own four were a DeepSpeed guard, a dependency-drift job, an MCP compatibility layer, and the CI fix that stopped that same drift alarm firing every Monday.

Upstream gave it no codename. It titled the release with a sentence instead, and the sentence is the finding: a LoRA run freezes the base, the frozen base never receives an optimizer step, and every supervised load path was materialising it at twice its checkpoint's precision anyway.

Re-run this

Ten of this release's fixes mean something you already ran was wrong, silently. None of them raised, and most of them exited 0.

If youWhat happenedWhat to do
Wrote a config key the schema does not declareIt was silently dropped. training.quantizaton: none trained 4-bit, training.gradient_checkpoint: true did no checkpointing, data.max_len: 512 truncated at 2048, and soup train --dry-run printed "Config valid"Re-load the config on v0.74.0 and read the new unknown-key report. A typo'd key trained at the default
Ran soup sweep --param <name> with a name that is not a config fieldThe whole grid ran at the base config's value, every arm identical, and a "winner" was reportedDiscard that sweep. The parameter is checked before the first arm now, and exits 1
Used data.interleave for a multi-dataset mixtureNothing read it at training time. Every mixture trained on data.train's single path, since v0.42.0Re-run. The mixture is real now, and covers streaming and Hub datasets too
Resumed an MLX run with --resume auto or --resume <path>mlx-lm saves NNNNNNN_adapters.safetensors, not checkpoint-N, so nothing matched and training restarted from scratch every timeRe-run. What ships now is a weights-only warm start, and it says so
Selected a GRPO objective variant (gspo, dapo, dr_grpo, bnpo, two_sided, rft)The variant check read a field TRL never pre-populates, so every run fell back to stock GRPO lossRe-run. You did not train the objective you selected
Set training.quantize_reward_modelValidated by its own task-scoped check and then read by neither loader, since v0.53.0. PPO's reward model and task: reward_model quantized regardless of its valueRe-run if the flag's value mattered to you
Set use_cut_ce on a model loaded from a local directoryArchitecture detection matched a keyword against the path's last component, so checkpoint-2000 or my-finetune matched nothing and cut-cross-entropy stayed off, on a flag you explicitly set. Phi-2 also ran under the Phi-3 patcherRe-run if you were counting on the memory saving
Ran soup ship on a 4-bit or 8-bit adapterThe judge loaded the base at full precision, so an NF4-trained adapter was judged against a bf16 base it never saw during training, and the message claimed bf16 without setting a dtypeRe-judge with soup ship --config soup.yaml, which now derives the precision from the run's own config
Used batch_size: auto on Windows or WSL2The probe's only fit test was "the synthetic step did not raise", and under the WDDM driver an over-commit does not raise: it spills into host memory. A batch that ran an order of magnitude slower in shared memory was approved and cachedRe-probe. The fit is decided on a measured peak now, and the cache key carries a version tag so old entries are ignored rather than trusted
Gated training on an LLM-judge evalThe gate divided the aggregate score by 10 while the default rubric is 1 to 5, so a perfect 5.0 read as 0.50 and typical thresholds were impossible to satisfy, stopping healthy runs under on_regression: stopRe-check any run that stopped early on a judge gate

Two exports were worse than wrong, they were empty or unloadable: soup export --format tensorrt shelled out to a module that exists in no current TensorRT-LLM release and produced zero artifact bytes, and --format gptq wrote only a shard name from_pretrained does not look for. Both are repaired, and GPTQ now requires --calibration-data up front instead of crashing without it.

The headline: an unchanged config paid twice

A LoRA or QLoRA run has exactly one trainable thing, the adapter. The base is frozen. Its only job is to hold the numbers that are in the checkpoint.

All three from_pretrained call sites in the supervised trainer, text, vision and audio, passed no dtype at all, so a bf16 checkpoint was materialised as fp32.

text
Measured on an H100, Llama-3.1-8B, LoRA, frozen base:

  peak before   48,241 MiB
  peak after    18,658 MiB
  saving        28.9 GB  (2.59x)

  byte-identical across 3 repeats

Three things must travel with that number.

  1. It is a saving on a configuration that was already paying it, not a new capability. Upstream's own wording is "cuts peak VRAM 2.59x on an unchanged config".
  2. It applies to a frozen base only. A genuinely trainable base, meaning lora.r: 0, unfrozen_parameters or lisa_enabled, still loads fp32 master weights, deliberately and documented.
  3. It is one measurement, carried over from the original pull request rather than re-measured, and it states its hardware, model and adapter and nothing else. No sequence length, batch size or checkpoint dtype is published, so none should be quoted.

The same defect stood untouched in twelve more trainers: dpo, kto, orpo, simpo, ipo, bco, online_dpo, grpo, ppo, pretrain, reward_model and embedding. None of them has a full fine-tuning branch, so every load there is a frozen base. They share one resolver now, with a pre-Ampere exception: on a card whose compute is fp16 anyway, the frozen base loads torch.float16 explicitly rather than sitting in bf16 storage.

One structural repair rides along and is the reason the class existed. The "is this a full fine-tune?" question was answered by two independent copies of the logic, one in the trainer and one in the VRAM pre-flight, and they disagreed in both directions. There is one shared discriminator now.

This does not change the layer-streaming laptop figure. That run streams an NF4 base and never had a resident fp32 one, so 3.32 GB is untouched by this fix. See what changed under layer streaming for the one number that did move.

The stack moved: Transformers 5.x, TRL 0.29, PEFT 0.20

text
torch          >=2.5.0
transformers   >=5.16.1,<6.0.0
trl            >=0.29.0,<1.0.0
peft           >=0.20.0,<1.0.0
accelerate     >=0.27.0
mcp            >=1.10.0,<3      ([mcp], the <2 cap is lifted)
python         >=3.10,<3.13      (unchanged)

The APIs that moved into TRL's experimental namespace are reached by capability probe rather than by a version table, which is the rule this project earned the hard way: a version table was wrong twice, and both times the thing that settled it was constructing the object.

The visible payoff is small and long overdue. pip install "soup-cli[train,mlx]" resolves again: the two extras had declared transformers ranges that could not be satisfied together at all, so anyone who wanted Apple Silicon and the training stack in one environment was simply stuck. A new weekly dependency-drift job found that before it merged, by installing the latest resolvable stack and writing a resolved-versus-declared table into the run summary.

Known limitation, published rather than discovered. The declared torch>=2.5.0 floor does not work with trl>=0.29. At torch 2.5.1 a module TRL imports does not exist, so TRL cannot import at all and DPO, KTO, GRPO and BCO are unavailable. pip cannot catch it, because TRL declares no torch dependency; CI never sees it, because >=2.5.0 always resolves to the newest torch. A fresh install is unaffected. An environment pinned to 2.5.x is not. No floor bump ships, because 2.5.1 was measured to fail and 2.6 was not measured to work.

The free notebook tier could not stream, for a second reason

v0.73.1 removed a bf16 assumption made in fourteen places, which had broken every task on every pre-Ampere card. Layer streaming still crashed there, on a different defect entirely:

text
_amp_foreach_non_finite_check_and_unscale_cuda not implemented for 'BFloat16'

PEFT creates LoRA adapters in the base checkpoint's dtype, while the fp16 gradient scaler those cards use requires fp32 gradients. Trainable adapter parameters are now cast to fp32 before the optimizer is built, from every trainer's own entry point through one shared helper.

The exclusion is deliberate and worth knowing: lora.r: 0, Spectrum and LISA full-fine-tune paths are untouched, so trainable memory cannot double after the VRAM pre-flight has already approved the run.

No pre-Ampere measurement was added. Everything the free-tier page says about what a T4 run does and does not establish still holds: no throughput is quoted from a capped card, and backward exactness on Turing remains unshown.

What changed under layer streaming

The allowlist goes from nine architectures to ten, and the tenth is stated carefully because only the original nine are verified bit-exact in both bf16 and NF4.

FamilyHow it got inWhat is verified
llama, qwen2, qwen3, mistral, gemma, gemma2, gemma3_text, phi, phi3The original nineBit-exact against the same checkpoint loaded resident, under both bf16 and NF4
qwen3_5, qwen3_5_text, qwen3_5_moe, qwen3_5_moe_textAliases routed onto the qwen3 streamerBit-exact against resident controls on CPU. The MoE path was validated live on Qwen3.5-35B-A3B with NF4, MoE LoRA and a 3072-token dataset, with no resident control, because no available machine can hold it resident
qwen4_exp, qwen4_exp_textThe tenth familyAn exact float32 tiny-model parity gate, including its external N-gram table. Real-checkpoint and NF4 validation are still pending

No throughput figure exists for any of them, and none is claimed. The task allowlist is unchanged at five: sft, dpo, orpo, simpo, kto. GRPO and PPO stay permanently excluded.

Two mechanical changes matter more than the list.

An untied embedding and LM head no longer both stay resident. They are sharded separately and reuse one vocabulary-sized device buffer. That is the 2.10 GB which dominated the 8B row's 3.32 GB peak, so the peak has moved, and it is deliberately not restated: it has not been re-measured on the reference 4 GB card, and upstream's own docs say the historical figure is not being relabelled as a new one. Tied embeddings keep their existing resident path and numerics.

training.stream_pin makes page-locking an explicit choice. Until now pinning was chosen automatically and nothing could override it, which mattered while the NF4 gradient defect was live, because a pageable store was the only known mitigation and it was unreachable from soup.yaml.

yaml
training:
  stream_layers: true
  batch_size: 1       # 'auto' is refused on a streaming run
  stream_pin: false   # force the pageable store; the pre-flight prints the cost
  • false forces the pageable store and the pre-flight states what it costs, up to the 6.56x already on record, rather than absorbing it silently.
  • true forces the pinned store and, on the RAM tier, refuses if the box cannot page-lock it, naming the store size rather than a ceiling: the page-lock ceiling is left unprobed on purpose, so a refusal can never cite a figure nobody measured.
  • On the disk tier and on CPU there is nothing to page-lock, so true is announced and the run proceeds rather than bricking the large-model runs the disk tier exists for.
  • Setting it while stream_layers: false is rejected, like the other stream keys.

Also here: stream_source: auto now falls back to the disk tier when the store fits available memory but store-plus-extras would cross the physical-host safety ceiling, and a forced ram refuses at pre-flight instead of letting the kernel OOM-kill the process; the shard cache reuses ordinary Hugging Face cache files and pre-flights its writes per volume instead of exhausting the disk; a fully cached snapshot can be sharded with outgoing traffic disabled; Apple internal NVMe behind APFS is detected without an override; and training.stream_ngram_source (auto, ram, disk) governs read-only access to Qwen4-Exp's external N-gram table.

The MCP server got network transports

Until now soup mcp serve was stdio only, which suits a client that spawns Soup as a subprocess and leaves remote or multi-client setups with nothing.

bash
soup mcp serve --transport sse --host 127.0.0.1 --port 8765 --auth-token "$TOKEN"

Both new transports serve the same registry, and the end-to-end test compares advertised tool names against the registry rather than a hardcoded count, so a subset cannot creep in. The tool count is unchanged at 18.

Adding a listener is the risky part, so it is gated three ways:

  1. Every request needs Authorization: Bearer <token>, compared with a constant-time check, with no opt-out, because a loopback port is reachable by every process on the box. The token travels in the header only, so it cannot land in an access log, and it is validated by the same helper soup ui uses rather than growing a second format.
  2. DNS-rebinding protection is on, returning 421 on a foreign Host and 403 on a foreign Origin. That is the gate a token cannot be: a page the operator merely visits sends no Authorization header, and its request still reaches the port.
  3. Binding off loopback warns, and a wildcard bind warns again that the host check has nothing left to pin.

--allow-execute is refused with either network transport, in the command and again in the app factory, so a direct caller cannot put an executing registry behind a listener either. The reasoning is stated rather than assumed: gated execution spawns real training and export processes, and behind a listener a leaked bearer token would mean process execution rather than plan disclosure, while stdio is a pipe to a client the operator already started. --host, --port and --auth-token are refused under --transport stdio rather than silently ignored.

The server also runs on both major versions of the MCP SDK now, which lifts the below-2.0 cap that had pinned anyone wanting 2.x in the same environment. The major is chosen by probing the server constructor, never by reading a version string, and a test walks the syntax tree to enforce that. The floor moves to 1.10.0, measured against published wheels: the transport-security module a listener depends on first appears there.

Still stale at this tag. The --help text for --allow-execute and --allow-mutating was not updated by either feature and still describes execution as reserved for the future. The startup banner was fixed. The changelog, the command reference and the running code all agree that it executes.

Unknown config keys stop being silent

None of the config models overrode the library's default of ignoring undeclared keys, so a key the schema did not know validated clean and was discarded.

Loading a config now walks the whole model tree, data, training, training.lora and the rest, so a guard applied to one model and forgotten on another cannot look like it works, and reports every key it cannot place in one report, naming the field you probably meant.

text
unknown config key 'training.quantizaton' - not applied. did you mean 'quantization'?

It is a warning, not a refusal, so a config written against a newer Soup still runs on an older wheel. From v0.75 the same config will fail to load. That deadline is one minor away, it is named in the message rather than left as a permanent notice, and it is asserted against the declared version by a test, so the release that crosses it turns a test red instead of shipping a promise it already broke.

soup sweep is stricter, and the split is deliberate: a swept parameter that matches no config field produces a grid of identical arms and a meaningless winner, and there is no partially useful result to preserve. It exits 1 before the first arm starts. soup sweep --dry-run now validates too, where it used to return before the config was ever loaded.

Breaking changes

  • soup serve exits 2, rather than printing a warning, when bound to a non-loopback host without --tool-auth-token. The /v1/tools/bash endpoint it protects has been re-enabled under real operating-system namespace and sandbox isolation, so it now actually executes code, and a warning was no longer a sufficient control. The endpoint fails closed with HTTP 501 anywhere strict isolation is unavailable, Windows included.
  • The SGLang backend obeys --trust-remote-code. It was hardcoded on at both runtime call sites, so soup serve --backend sglang executed a model's custom repo code whether or not you opted in. The warning panel said so, but a notice is not a gate. A custom-code model on that backend now fails to load without the flag.
  • soup sweep exits 1 on a --param that matches no config field, where it used to run the grid and report a winner.
  • soup export --format gptq requires --calibration-data, because auto-gptq has no built-in fallback dataset. It used to crash instead.
  • soup data best-of-n --export-candidates --seed N produces different output, because each prompt is now seeded independently so a resumed run reproduces the same candidates.

Security

Four spellings of one bypass, closed on both validators. The private-address check delegated to a parser that rejects non-canonical IPv4, while the OS resolver on Linux accepts it, so abbreviated (127.1), decimal (2130706433), hexadecimal (0x7f000001) and octal (0177.0.0.1) forms reached the telemetry and webhook guards. That matters because the cloud metadata address 169.254.169.254 has a decimal spelling, and a crafted webhook URL could reach it.

The OTLP tracing endpoint validator had the same hole through a path the first fix never touched, and the reachable half there was not loopback but non-loopback private ranges in alternate encodings. Telemetry validation also gained a tiered guard: an allowlist for the canonical endpoint with zero DNS lookups, static rejection of internal TLD suffixes, and a defence-in-depth resolution step that fails closed on resolver errors.

While consolidating it, an audit found the predicate had drifted into three copies of the function and six of the host set across six modules. All six import one definition now, and a guard test walks the source for a reappearing duplicate.

Also in this release

  • soup eval aider runs Aider's Polyglot code-editing benchmark through its official Docker harness, behind a new [aider] extra, recording the score in eval_results for the existing run comparison. The benchmark image is source-built by you; the docs say so rather than implying a one-line setup.
  • soup train --cloud lambda plans a Lambda Cloud GPU run, plan-only by default. The API key never enters the instance: termination belongs to a local controller, and the finally both terminates and polls to confirm it happened.
  • soup monitor reads real Apple Silicon telemetry, rendering GPU utilisation and power in the existing table. NVIDIA-only fields stay unavailable rather than guessed.
  • soup data best-of-n samples from Ollama or vLLM with --provider, through the existing SSRF-validated seam, and gained a durable two-phase offline workflow with per-prompt checkpoints and manifest-last publication.
  • data.interleave is read at training time, finally, and data.train accepts a list. Local files combine with concat, under, over or probs; streaming and Hub-name lists are dispatched to the datasets library so the strategy names mean the same thing on both paths. A single path stays byte-identical.
  • LISA accepts task: pretrain, which is the same rotating-full-fine-tune mechanism it was built for, and training.lisa_train_embeddings lets you freeze its always-on group. That group is about 70% of everything LISA trains at 8B, so freezing it is a real quality-versus-memory trade rather than a free win, which is why it is opt-in. The analytical pre-flight does not credit the saving yet, so a frozen-embeddings run that would fit can still be refused; --allow-oom-attempt launches it anyway.
  • Baseline eval artifacts carry a provenance stamp, the Soup version and a scorer revision, written by soup eval gate --write-baseline. A stale baseline is now detected by provenance instead of by a hardcoded suite list, and the v0.73.2 name-based warning is gone.
  • SmolVLM and Idefics3 vision SFT reaches real training batches, keeping messages and images together until collation and letting the processor produce image-token expansion. That recipe had been parse-only since v0.71.32.
  • Cross-tokenizer drafts work in soup draft distill and soup draft measure, and in soup serve --speculative-decoding where the installed transformers supports universal assisted decoding. Same-tokenizer pairs keep the existing fast path.
  • Nineteen recipes take the catalog to 163 (the release body says fourteen; nineteen is what counting RecipeMeta( at both tags reproduces, and 163 is the total both agree on), including Qwen2.5-Coder and Qwen2.5-Math sizes, the DeepSeek-R1-Distill families in SFT and DPO, SmolLM3, and GRPO and DPO variants for the 2026 MoE giants. Every recipe's resolved config is now pinned against a committed snapshot, so a schema-default change can no longer silently retune a recipe that relied on that default.
  • The DeepSpeed empty-LoRA-group guard went from one wrapper to all eighteen that needed it. v0.73.0 fixed the failure where LoRA leaves one of Hugging Face's optimizer parameter groups empty, DeepSpeed drops it and the scheduler then hits a strict length check, but it fixed it in sft.py alone: nineteen modules accept a deepspeed_config and exactly one called the guard, so eighteen tasks still died the same way. Coverage is now enforced by a scan over the trainer directory rather than a hand-written list of names, because a hand-written list is what hid them. In the same change, a --deepspeed my.json of your own is resolved the way a preset is.
  • The SGLang backend got the two repairs vLLM got in v0.73.0. It applies the served model's own chat template through the shared builder instead of a third hand-rolled copy, and reports finish_reason: length on a truncated response instead of a hardcoded stop, on both the sync and streaming paths. Its tokenizer load now uses the same trust_remote_code setting the runtime does. Live verification against a real SGLang runtime on Linux is still open.
  • The DeepSpeed-MII backend got the same pair, which makes it the third backend to have carried it.
  • The live training panel stopped under-reporting GPU memory. It sampled memory_allocated() between steps, after activations and gradients were freed, so it reported the inter-step trough: on the reproducing run 5.8/15.9 GB while the device was fully occupied. It now reads the lifetime peak and labels the figure GPU peak: so it says what it is.
  • soup doctor recommends a CUDA wheel that matches your driver rather than always suggesting cu121. A driver at CUDA 11.8 gets cu118; an unreadable driver header falls back to cu121, which is the conservative direction. On Windows it also notes that the PyPI torch wheel is CPU-only.
  • soup mcp runs reconcile --expunge-launching recovers execution capacity from stale rows without editing the database by hand, and refuses the whole operation if any candidate records a process that is still alive.

What was measured, and what was not

No new throughput number was published in this release, and no streaming speed measurement was made. Two records were added to the public benchmarks directory, and neither is a speed claim.

A second box says the pre-flight under-prediction does not reproduce. v0.73.1 disclosed that the streaming VRAM pre-flight breaks its own never-under-predict contract at long sequence on the reference laptop: 0.934x the real peak at sequence 5120, 0.787x at 6144. A contributor ran the same sweep on an A10G with a much newer stack, and the ratio is flat at about 1.16x over-prediction from 2048 through 6144, on two models with a 3.1x vocabulary contrast. The loss term measures 12.0 with zero spread there, a third stack agreeing, and the retained copy the laptop's arithmetic implies is absent. The record publishes its own leading hypothesis as a negative result with a positive control, and explicitly does not extend it to the laptop, whose driver, operating system and torch are all different. The honest reading is the record's own, and it keeps both halves: the term varies per-stack and per-sequence-length, so no constant can carry the never-under-predict guarantee across stacks. That is the argument for measuring one real step rather than adding another coefficient to a formula. The box was an NVIDIA A10G 23 GB on AWS g5.xlarge, Ubuntu 22.04, torch 2.13.0+cu130 with transformers 5.16.1, trl 0.29.1 and peft 0.20.0, against the laptop's Windows 11 / torch 2.5.1 / transformers 4.57.6, a whole major version apart.

An Apple Silicon Qwen4-Exp gate is a partial pass. All 1,167 expected tensors mapped with none missing or mismatched, the affine vectors matched at four bit widths, the tiny parity gate passed, and a 176.9-billion-parameter checkpoint reached "Training started!" with no key, shape or routing error. Then the one-step run stopped without completing an optimizer step, and the record's own verdict is that this is not validated. Its closing line is worth quoting: do not market it as proof that the full model is trainable on a 128 GiB Mac.

The preprint is untouched. No measured number in it changes. Its scope is now narrower than the code, because it states nine streaming architectures and the code admits ten, and it stays explicitly scoped to v0.73.0.

Known limitations

  1. torch>=2.5.0 and trl>=0.29 are incompatible at the declared floor. A pinned 2.5.x environment loses DPO, KTO, GRPO and BCO. No floor bump ships, because 2.6 was not measured to work.
  2. soup doctor never reports MLX, and the MLX version always reads "unknown". Filed, fix in review.
  3. The --allow-execute and --allow-mutating help strings are stale in the shipped binary and still describe execution as reserved.
  4. Qwen4-Exp streaming is float32-parity only. Real-checkpoint and NF4 validation are pending, and no CUDA bf16 parity gate has been measured.
  5. Apple Silicon streaming is experimental. No claim is made that it fits a larger model or runs faster than resident MPS training, and backend: mlx is still rejected with stream_layers.
  6. The 8B streaming peak has not been re-measured after untied embeddings started sharing a buffer. The historical 3.32 GB figure is published as what it was, not relabelled.
  7. The LISA pre-flight still treats it as full fine-tuning regardless of lisa_train_embeddings, so a frozen-embeddings run that would fit can be conservatively refused.
  8. Layer streaming remains BETA.

Where that work gets handed out

This release was written by other people, and the next one is being written the same way. The issues, the hardware still to be tested on and the benchmarks still to be run are posted in the Soup Tasters Telegram channel, alongside the help wanted filter on GitHub. One issue, one person: claim it in the thread, open a pull request, and a merged one puts your name in CONTRIBUTORS.md.

Measurements count as much as code, and for a specific reason. Soup sends nothing about your runs unless you ask it to, and as shipped it sends nothing even then, because the bundled telemetry key is a placeholder the sender refuses. That is a deliberate choice, documented in full, and its cost is that a run nobody reports is a run nobody learns from. Training something on your own GPU, comparing it against Unsloth or Axolotl, and sending the numbers and the logs is the other half of the contribution. Two of the measurement records published in this release, the A10G pre-flight sweep and the Apple Silicon mapping gate, arrived exactly that way, on hardware this project does not own.

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.