Skip to content

feat(sft): packed multiple-choice SFT with a pluggable loss and choice evals - #3739

Draft
kritarth-simile wants to merge 1 commit into
PrimeIntellect-ai:mainfrom
kritarth-simile:simile/packed-choice-sft
Draft

kritarth-simile wants to merge 1 commit into
PrimeIntellect-ai:mainfrom
kritarth-simile:simile/packed-choice-sft

Conversation

@kritarth-simile

Copy link
Copy Markdown

Summary

This PR adds SFT on pre-packed multiple-choice data with a pluggable loss, plus forward-only choice evals during training. It also fixes a gradient-clipping bug that affects any SFT run with validation.

Our use case: each example is a prompt and a target probability distribution over its answer choices, with a per-example weight. The loss compares the model's softmax over those choice tokens with the target. Stock SFT has a single target text per row, full-vocabulary cross-entropy and no per-example weights, so it cannot express this. The loss itself stays in user code, behind [loss] import_path, the way RL already supports custom losses.

Changes

Fix: gradient clipping after validation.

  • Problem: validation runs between backward and clip_grad_norm_. The [lm_head, norm] FSDP2 group (reshard_after_forward=False) stays unsharded after the validation forward, so clipping sees 112 of 114 parameters.
  • Symptom: on validation steps the logged grad norm is 13.7% low on GLM-5.3 (66.6 instead of 77.2), and about 1% low on Qwen3.5-9B.
  • Fix: no_grad_forwards(model) reshards after no-grad forwards and wraps validation and choice-eval forwards. Grad norms then agree within 0.04%.

Packed-choice SFT (data.type = "packed_choice").

  • Reads pre-tokenized bins (Arrow IPC plus manifest.json, schema simile-packed-choice/v2). Each bin is one micro batch. Each example carries its answer position, its candidate token ids, target probabilities, a weight and loss-mix weights.
  • An LM-head mode returns logits only for the candidate tokens at the marked positions, so the full-vocabulary logits are never materialized.
  • [loss] import_path / kwargs (SFTChoiceLossConfig): called per micro batch with right-padded choice logits, targets and weights, and returns (loss_sum, weight_sum). Gradients are normalized by the step's global weight_sum.
  • max_steps defaults to one pass over the packed bins.
  • val.data can also be packed_choice.

Choice evals during training ([choice_eval]).

  • Every interval steps (and optionally at step 0 and at the end), runs no-grad forwards over pre-packed eval sets. Rank 0 gathers each row's candidate logits into <run_dir>/choice_evals/<name>/step_<step>/predictions.arrow.
  • With import_path set, it calls a user scoring function and logs its metrics at that step. Without one, it only writes the predictions, for scoring outside the trainer, and logs eval time and peak memory.
  • A metrics.json marks a scored step, so resumed or requeued runs do not score a step twice.
  • A failing scorer is logged and never stops training.

New config surface is opt-in. Existing SFT configs and behavior are unchanged.

Test plan

  • New unit tests:

    • tests/unit/train/sft/test_packed_choice.py
    • tests/unit/train/sft/test_choice_eval.py, which covers the no-scoring-function path and config validation
    • tests/unit/train/test_no_grad_forwards.py

    56 passed on macOS with GPU-only modules stubbed.

  • Existing tests, tests/unit/train/sft, tests/unit/test_configs.py and tests/unit/train/test_model.py: same result as main on the same machine. 330 passed; the one failure, test_model.py::test_moe_custom_impl, also fails on main there.

  • ruff check and ruff format --check with the pre-commit pin (0.13.0).

  • GPU, on 8×H200, at 7f9de649aa80. This is the same code minus the optional scoring function. The only commit to the touched files since then is the unrelated Muon gate-sharding change, fix: shard single-row gate weights along columns for Muon #3723.

    • Qwen3.5-9B: step-0 validation loss 1.7839, against an independent reference of 1.787–1.793; step-1 training loss 1.28616 against 1.2857.
    • Eval metrics computed from the gathered predictions match our reference trainer's within 0.0015.
    • GLM-5.3-BF16 (real weights, 8 of 78 layers), EP 8, CP 2, SignSGD: trains, and resume matches an uninterrupted run within 0.0013.
  • GPU run of this branch on hosted training via the source-ref overlay.

@eexwhyzee
eexwhyzee requested a review from samsja October 1, 2026 16:52
@kritarth-simile
kritarth-simile force-pushed the simile/packed-choice-sft branch 2 times, most recently from b645c1c to 4de0e79 Compare October 4, 2026 18:25
…e evals

Add a packed_choice SFT data type (pre-tokenized bins with answer positions,
candidate token ids, target probabilities and weights), an LM-head mode that
returns only candidate-token logits, a [loss] import_path hook for the choice
loss, and forward-only [choice_eval] runs that write each row's candidate
logits and optionally call a scoring function.

Choice-eval forwards reshard the FSDP modules afterwards, as validation does,
so gradient clipping in the first step still sees the lm_head/norm group's
gradients. A CPU FSDP test covers that reshard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kritarth-simile
kritarth-simile force-pushed the simile/packed-choice-sft branch from 4de0e79 to afa2071 Compare October 5, 2026 05:03
samsja added a commit that referenced this pull request Oct 10, 2026
Replaces the three switches `model.fsdp_cpu_offload`,
`model.optim_cpu_offload` and `model.full_offload` with one field:

```toml
[model]
offload = "optimizer"   # default; also "full", "none"
```

This applies to both the RL trainer and SFT. The `fsdp` switch is not
migrated: the mode is removed outright (rationale below). The
`[model.full_offload] numa_bind` knob is dropped: no recipe, deployment,
template or doc sets it. Full offload now always pins each rank's CPUs
to its GPU's NUMA node. That was already the default. Ranks whose GPU
reports no NUMA node still skip binding with a warning. On hosts without
GPU sysfs topology (`/sys/bus/pci/devices/<bus>/numa_node`), binding is
no longer optional: full offload fails there instead of being switched
off.

Why: the three switches could only be used one at a time, enforced by
`cpu_offload_mutual_exclusion`. Because `optim_cpu_offload` defaulted to
`true`, enabling full offload also meant writing `optim_cpu_offload =
false`, otherwise validation failed (see the old
`configs/advanced/deepseek-v4-flash/sft.toml` comment). With one value
the conflicting combinations cannot be expressed, so that validator is
deleted. The default behaviour does not change: optimizer-state offload
stays on.

Why drop `fsdp`: the mode was the stock torch FSDP2 `CPUOffloadPolicy`
with no in-tree config, example, or test user; Muon rejected it and it
broke NCCL weight broadcast. It was kept only for long-context runs
whose fp32 masters/moments no longer fit on GPU (the reason it was
restored in #3052); the `full` mode added in #3234 keeps FP32 masters,
moments, and gradients in host RAM, covering that memory pressure
instead. Gone with it: the `CPUOffloadPolicy` selection in `setup_fsdp`,
CPU-side model init in `setup_model`/`load_dcp_from_hf`,
`_move_buffers_to_cuda`, and the fsdp leg of the Gloo special case.

Changes:
- `ModelConfig.offload: Literal["none", "optimizer", "full"] =
"optimizer"` replaces the three fields.
`OptimizerInBackwardOffloadConfig` (and with it `numa_bind`) and its
bool-normalizing `BeforeValidator` are gone.
- Validators in `TrainerConfig` and `SFTConfig`:
- full offload accepts only AdamW or SignSGD and disables clipping, now
reading `offload == "full"`;
- the `Muon optimizer does not support FSDP CPU offload` validators are
deleted with the mode.
- Runtime:
- `setup_optimizer(..., offload=...)` replaces `cpu_offload` +
`full_offload_config`, and its "both enabled" check is gone.
- `FullCPUOffloadOptimizer` drops its unused `offload_config` argument.
- `setup_full_cpu_optimizer_offload()` takes no arguments and always
binds.
- `model.py`: FSDP always uses the default `OffloadPolicy()`; CPU-side
model init in `setup_model`/`load_dcp_from_hf` and
`_move_buffers_to_cuda` are gone.
- Tomls: `glm-4.5-air/swe-budget.toml` and
`ci/integration/reverse-text-sft/full-offload-resume.toml` → `offload =
"full"`; `glm-5.3/sft/h200/16k-12-node.toml` → `offload = "none"`;
`glm-5.3/sft/h200/base.toml` → `offload = "optimizer"`. Also updated
`docs/scaling.md`, the `deepseek-v4-flash/sft.toml` comment, and
`skills/training/start-run/SKILL.md`, which documented the removed
fields.

Breaking: the old keys now fail with `extra_forbidden`, and `offload =
"fsdp"` fails the `Literal`. There is no shim.

Interaction with #3849 (one trainer-common config base): #3849 moves the
full-offload and Muon/FSDP validators from `TrainerConfig`/`SFTConfig`
onto `BaseTrainerConfig`. This PR edits the full-offload validator's
condition and deletes the Muon/FSDP one, so the conflict is textual
only: keep #3849's single copy of the full-offload validators with
`self.model.offload == "full"` and drop its Muon/FSDP copy. Whichever
lands second takes a small rebase.

Other open PRs that set or read the old keys need a rebase:
- tomls: #3883, #3879, #3766, #3765, #3729, #3629, #3627, #3616, #3544
- code: #3885 (`optim_cpu_offload`), #3739 (`full_offload` validators),
#3724, #3719, #3699, #3610, #3487 (`fsdp_cpu_offload` / `enable_gloo`)

Test plan
- `pytest tests/unit/orchestrator tests/unit/test_configs.py
tests/unit/train/test_cpu_adam.py tests/unit/train/test_state_offload.py
-m "not gpu"`: 336 passed, 1 skipped (Qwen3-VL snapshot not cached on
this host), 4 deselected (GPU; this host has no GPU). The existing
offload config tests are updated to the new field. `test_load_configs`
covers the 4 edited tomls.
- `cli(TrainerConfig|SFTConfig, ...)`: `--model.offload full|none` and
the default (`optimizer`) resolve as expected; `optim_cpu_offload` and
`offload = "fsdp"` are rejected.
- `import prime_rl.trainer.rl.train, prime_rl.trainer.sft.train` works.
- `ruff check` and `ruff format --check` on the changed Python files:
clean.
- Not run: GPU or multi-node training with `offload = "full"`.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Breaking TOML/API surface touches distributed training memory paths
(FSDP offload, optimizer-in-backward, Gloo); behavior should match prior
modes when mapped correctly, but mis-migrated configs will fail at parse
time.
> 
> **Overview**
> **Breaking config change:** `model.fsdp_cpu_offload`,
`model.optim_cpu_offload`, and `model.full_offload` are replaced by a
single **`model.offload`** setting (`"optimizer"` default, or `"full"`,
`"fsdp"`, `"none"`). RL and SFT share the same field; example TOMLs and
scaling docs are updated accordingly.
> 
> The mutual-exclusion validator between the old flags is removed
because only one mode can be expressed. **`[model.full_offload]` /
`numa_bind` is dropped** — `"full"` always runs NUMA binding and CPU
optimizer thread setup via parameterless
`setup_full_cpu_optimizer_offload()`. Runtime paths (`setup_optimizer`,
FSDP CPU offload in `model.py`, Gloo when offload is `"full"` or
`"fsdp"`) key off the new enum; `FullCPUOffloadOptimizer` no longer
takes an offload config object.
> 
> Default training behavior is unchanged: optimizer-state CPU offload
remains the default (`"optimizer"`). Old TOML keys are not aliased and
should fail validation.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
35da73a. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: sami jaghouar <sami.jaghouar@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant