Skip to content

feat: SFT from a HF dataset through the RL stack (dataset_sft) - #3233

Draft
mikasenghaas wants to merge 7 commits into
mainfrom
feat/dataset-sft
Draft

feat: SFT from a HF dataset through the RL stack (dataset_sft)#3233
mikasenghaas wants to merge 7 commits into
mainfrom
feat/dataset-sft

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

POC: SFT from a static HF dataset through the RL training stack, keeping the orchestrator + inference server for online evals.

  • New run-level algorithm [orchestrator.algo] type = "dataset_sft": carries an SFTDatasetConfig (name, subsets/splits/probabilities/stopping_strategy, shuffle, seed, loss_mask) — the same example schema and knobs as the native SFT trainer.
  • Config validators enforce the contract: train sources take no effect and must be empty ([[orchestrator.train.source]] is a config error); eval sources stay optional; dataset_sft is rejected as a per-source algorithm.
  • Orchestrator: DatasetSFTSource renders dataset examples through the policy renderer into ce-routed TrainingSamples (ce_weights on the loss mask, rl_weights zeroed, dummy logprobs/temperatures). A dataset_loop runs beside the main loop: it packs and ships batches through the regular packer/transport path, while the dispatcher serves evals only. Token batching takes the largest whole-sample prefix within token_batch_size (the first overflowing sample carries to the next step). The data position ({epoch, cursor}, per-epoch reshuffle) round-trips through the orchestrator checkpoint.
  • Example rendering (messages / prompt+completion resolution, tools, role loss masking) is extracted from SFTDataset._process into a shared render_example, so both SFT paths tokenize identically.
  • Trainer: unchanged loss path — the ce loss component of the algorithm-blind RL trainer executes the shipped weights.

Three pieces of shared infrastructure fell out of chasing the RL trainer's step time down to the native SFT trainer's (ablation details under Verification):

  • On-demand weight broadcasts. The trainer broadcast to inference after every step whether anything consumed it or not. The orchestrator knows exactly which policy versions inference will consume, so it stamps the request on the work itself: MicroBatch.sync_weights marks the steps whose weights are needed, and the trainer broadcasts exactly those. Rollout training marks every step except the final TARGET_LAG+1 (the trainer's old final-step skip, moved to the producer); dataset_sft marks eval-trigger steps and any step shipped while evals are queued or in flight — in-flight evals sample the live policy, so long-running inference keeps receiving fresh weights at the trainer's cadence, mid-generation. Producer pacing moves from policy versions to consumption acks on the ZMQ control channel (the READY-barrier socket), and shutdown holds until the last requested sync lands so the trainer is never stranded mid-NCCL-rendezvous.
  • Cost-balanced packing (new default in packed_samples_into_micro_bs). First-fit-decreasing minimizes bins but co-locates the longest samples; with few bins per DP rank the hottest rank's quadratic attention cost gates every FSDP step. Packing now tries a cost-balanced variant at the bin count the token total forces anyway (each sample placed longest-first into the cheapest compatible bin, scored by the existing additive bin_cost) and keeps it unless it needs more rounded-up bins than first-fit — never more bins, strictly better balance. Rollout batches benefit the same way whenever forced bin rounding leaves few bins per rank.
  • Teardown fix (multi_node_rl.sbatch.j2). wait -n tore a node down when its first background process exited, even cleanly — at max_steps the trainer's exit killed the orchestrator mid-drain of final-step evals, and a dataset_sft orchestrator finishing its ship loop early killed the trainer mid-step. Teardown now waits until every principal process (torchrun, orchestrator) has exited cleanly; any non-zero exit still tears down immediately.

Adds configs/basic/reverse-text/sft-rl.toml (dev-sized counterpart of sft.toml, same token budget per step) and examples/advanced/glm-4.5-air/{sft,sft-rl}.toml (GLM-4.5-Air on INTELLECT-3-SFT at 131k context, cp=4 ulysses + muon on 4 H200 nodes, identical 1,048,576 tokens/step on both entrypoints).

Verification

Reverse-text (Qwen3-0.6B, 100 steps × ~131k tokens, lr 2e-5, one training GPU each):

uv run sft (1 GPU) uv run rl + dataset_sft (1 GPU + 1 vLLM)
loss @ 25 / 50 / 75 / 100 2.78 / 1.63 / 1.12 / 0.945 2.98 / 1.60 / 1.16 / 0.879 (nll/mean)
real samples trained 11,191 12,919 (zero-padding FFD packing vs ~14% tail padding in fixed rows)
online evals 5 epochs, reward 0.066 → 0.117

GLM-4.5-Air at 131k (INTELLECT-3-SFT swe_swiss+am_if+toucan_tool, 4×8×H200 training, cp=4 ulysses, muon, 1,048,576 tokens/step). The step-time ablation that motivated the design (same batches, broadcast-free medians):

variant median step
native uv run sft 36.1 s
rl dataset_sft, per-step broadcast + first-fit packing 47.2 s (+31%)
− per-step broadcast 40.7 s
− loss-pipeline extras (entropy, per-sequence loop, stat syncs) 41.4 s (no effect — dropped)
+ cost-balanced packing 34.6 s (−4%)

Broadcast cost and packing imbalance were the whole gap: measured per-rank attention cost (max/mean Σ len²) is first-fit 2.68, native stream-order rows 1.95, cost-balanced 1.64 — which is why the balanced RL trainer edges out the native trainer per step. The loss-pipeline extras cost nothing measurable and stay.

End-to-end on-demand broadcast validation (GLM-4.5-Air 131k, 10 steps, AIME'25 eval every 5 steps): broadcasts landed at exactly v0 (startup), v1–v3 and v5 (batches shipped while evals were in flight — mid-generation weight updates at the trainer's cadence), v4 and v9 (eval-trigger steps), v10 (mid-final-eval); none on the idle steps 6–8. The shutdown hold released 0.2 s after the trainer's final broadcast, and all four training nodes tore down cleanly on their own.

🤖 Generated with Claude Code

mikasenghaas and others added 7 commits August 10, 2026 23:54
A run-level algorithm (type = "dataset_sft") that trains SFT straight
from a static HF dataset while keeping the RL stack's orchestration:
the orchestrator renders dataset examples into ce-routed training
samples and ships them through the regular pack/transport path, the
dispatcher serves online evals against the live policy, and the trainer
runs unchanged (algorithm-blind ce loss component).

Train sources take no effect and must be empty; eval sources stay
optional. Example rendering is shared with the native SFT trainer via
render_example, extracted from SFTDataset._process. The data position
({epoch, cursor}) round-trips through the orchestrator checkpoint.

Includes a dev-sized reverse-text config (sft-rl.toml) mirroring
sft.toml for side-by-side comparison.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A production-scale counterpart to the reverse-text sft/sft-rl pair: GLM-4.5-Air
(106B A12B) on INTELLECT-3-SFT at 131k context, cp=4 ulysses + muon on 4 H200
nodes, with an identical 1,048,576 tokens/step budget on both entrypoints so the
two paths are directly comparable.

- sft.toml    — native `uv run sft`, batch_size 8 x seq_len 131072
- sft-rl.toml — `uv run rl` + dataset_sft, token_batch_size 1048576, plus one
  inference node running AIME'25 evals every 10 steps against the live policy

Cache paths are user-scoped (/tmp/mika-*): the bare /tmp/.triton-cache and
/tmp/.vllm-cache used by the other configs in this directory are a
PermissionError on any node where a different user created them first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shared /tmp cache dirs (.triton-cache, .vllm-cache, .flashinfer-cache) are a
startup PermissionError for every user except whoever created them first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
next_batch() accumulated whole samples until num_tokens crossed
token_batch_size, so a budget that is an exact multiple of seq_len x dp — the
natural choice — overshot into ceil = dp+1 minimum FFD bins every step, which
the packer rounds up to 2 x dp by splitting: every rank ran two half-full
micro-batches instead of one full row, doubling per-micro-batch overhead (cp
all-gathers, MoE dispatch, activation offload cycles). Measured on GLM-4.5-Air
at 131k/cp=4/dp=8: all 20 steps shipped 8.00-8.31 x seq_len tokens -> 16
micro-batches, +65% forward+backward vs the native SFT trainer on the same
data.

The first sample that would cross the budget is now held back and leads the
next step's batch; the orchestrator checkpoint position rolls back to that
example so a resume re-renders it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…leanly

The launch script's `wait -n` tore a node down as soon as its FIRST background
process exited, even cleanly. Both directions of that race are real: at
max_steps the trainer (exit 0) killed the orchestrator mid-drain of final-step
evals (observed: step-20 AIME eval killed at 9/30), and a dataset_sft
orchestrator that finishes shipping early (exit 0) killed the trainer mid-step
(observed at step 5 with an ablated broadcast cadence), leaving peers hung in
NCCL and the allocation held by the idle inference node.

Teardown now waits until every principal process on the node — torchrun and
the orchestrator — has exited (loop over `wait -n -p`); any non-zero exit
still tears down immediately so --kill-on-bad-exit can reap peers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trainer broadcast weights to inference after every step, whether anything
consumed them or not — for dataset_sft without a pending eval that is pure
overhead (~7s/step for GLM-4.5-Air at 131k on 4 nodes).

The orchestrator knows exactly which policy versions inference will consume,
so it stamps the request on the work itself: ``MicroBatch.sync_weights`` marks
the steps whose resulting weights are needed, and the trainer broadcasts
exactly those (filesystem broadcast still writes every version for resume).
Rollout training marks every step except the final TARGET_LAG+1 (moving the
trainer's old final-step skip to the producer); dataset_sft marks steps where
an eval fires next or evals are queued/in flight — in-flight evals sample the
live policy, so long-running inference keeps receiving fresh weights at the
trainer's cadence, mid-generation.

Pacing previously rode on those per-step broadcasts (the ship hold waited on
policy versions). It now rides on consumption acks: receivers push
``ack|<rank>|<step>`` on the ZMQ control channel (the READY barrier socket)
after each step, and the dataset ship hold waits on the acked floor instead.
The filesystem transport keeps no-op pacing — batches persist on disk.

A mark is a promise that the broadcast finds its receiving side up: shutdown
holds until the last requested sync lands (``await_final_weight_sync``),
since the trainer may reach a marked step long after the last eval finished
and a mid-broadcast orchestrator exit strands it in the NCCL rendezvous.

Validated on GLM-4.5-Air/INTELLECT-3-SFT at 131k (10 steps, AIME'25 eval
every 5): broadcasts landed at exactly v0-v5 (startup + eval-active steps),
v9-v10 (eval at 10), none in between; the shutdown hold released 0.2s after
the trainer's final broadcast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First-fit-decreasing minimizes bin count but places each sample into the
first bin with room, co-locating the longest samples: with few bins per DP
rank the hottest rank's attention cost (quadratic in sample length) gates
every FSDP step. Measured on GLM-4.5-Air/INTELLECT-3-SFT at 131k with one
bin per rank, per-rank cost imbalance (max/mean sum len^2) was 2.2-3.4x and
the trainer ran 31% slower per step than the native SFT trainer on the same
data.

Packing now tries a cost-balanced variant at the bin count the token total
forces anyway (opened up front, each sample placed longest-first into the
cheapest compatible bin, scored by the existing additive bin_cost) and keeps
it unless it needs more rounded-up bins than first-fit — never more bins,
strictly better balance. On the same workload imbalance drops to a median
1.8x and the trainer matches the native SFT trainer's step time (34.6s vs
36.1s median; first-fit ran 47.2s with per-step broadcasts, 40.7s without).

Rollout batches benefit the same way whenever the forced bin rounding leaves
few bins per rank; with many bins per rank the downstream cost-balanced
distribution already evens things out and the packing choice matters less.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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