feat(trainer): seq_lens as the packed-sample boundary contract (SFT + RL)#2944
feat(trainer): seq_lens as the packed-sample boundary contract (SFT + RL)#2944hubert-marek wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d1e5538558
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| sample_len = len(sample["input_ids"]) | ||
| would_overflow = seq_len + sample_len > self.seq_len | ||
| if seq_len > 0 and would_overflow: | ||
| yield self._finalize_pack(packed_samples, self.seq_len) |
There was a problem hiding this comment.
Persist overflow sample before yielding
When seq_len + sample_len overflows, this yield happens after the next sample has already been pulled from the wrapped dataset but before it is appended to the next pack. If StatefulDataLoader checkpoints immediately after returning that batch, state_dict() only records the wrapped dataset state, so the fetched overflow sample is considered consumed but is not stored anywhere; resuming from that checkpoint skips one sample per worker at these overflow boundaries and changes the training data stream.
Useful? React with 👍 / 👎.
| result["position_ids"].extend(range(pad_len)) | ||
| result["loss_mask"].extend([False] * pad_len) | ||
| result["target_ids"].extend([0] * pad_len) | ||
| result["seq_lens"].append(pad_len) |
There was a problem hiding this comment.
Avoid treating padding as a packed sample
Appending pad_len here makes a single real short example look like multiple packed segments, e.g. seq_lens=[100, 28] for a 128-token pack with trailing padding. Custom Qwen3.5 non-flash models reject any seq_lens.numel() > 1 when full_attention layers are present, so otherwise valid single-sample padded packs such as final validation batches or overflow flushes fail unless users switch to flash attention, even though the extra segment is only loss-masked padding.
Useful? React with 👍 / 👎.
05690b5 to
72676ec
Compare
5678692 to
8740508
Compare
60df70d to
4fd7c5d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8740508afa
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if micro_batch.seq_lens is not None: | ||
| micro_batch.seq_lens.append(padding_size) |
There was a problem hiding this comment.
Avoid adding padding as a Qwen segment
When RL padding is enabled (pad_to_multiple_of > 1) and a Qwen3.5 custom non-flash model has any full_attention layers, a single otherwise valid sample whose length is not already aligned gets seq_lens like [real_len, padding_size]. The Qwen3.5 forward path treats seq_lens.numel() > 1 as a packed batch and raises Packed Qwen3.5 batches with full_attention layers require flash attention, so this turns alignment padding into a hard runtime failure even though no second real sample was packed.
Useful? React with 👍 / 👎.
| len_sample = len(sample["input_ids"]) | ||
| if len_sample > self.max_area: | ||
| for key, value in sample.items(): | ||
| for key in ("input_ids", "position_ids", "loss_mask", "target_ids"): |
There was a problem hiding this comment.
We don't want to truncate mm_kwargs, and mm_token_type_ids, and seq_lens - only the ("input_ids", "position_ids", "loss_mask", "target_ids")
CatDataset packs now carry explicit per-sample boundaries instead of relying on position_ids resets: - _finalize_pack pads every pack to seq_len (pad tokens form their own loss-masked document) and yields the tail pack instead of dropping it; overflow samples start the next pack rather than being truncated. - Sample/Batch gain seq_lens; cat_collate forwards it for batch-size-1 packs, stack bucketing yields seq_lens=None. - trainer forward() gains seq_lens and hands it to custom models via a new PreTrainedModelPrimeRL.prime_forward_kwargs hook, so PrimeRL-only kwargs never leak into generic HF model signatures. - Dense + MoE Qwen3.5 build cu_seqlens from seq_lens on the non-flash path (previously None: fla's GatedDeltaNet carried conv/SSM state across packed samples and SDPA saw one causal document). Packed hybrid batches with full_attention layers require flash and raise otherwise; a config validator rejects pack_function='stack' for Qwen3.5 custom flash attention (its varlen kernel assumes batch 1). - Under CP the trainer passes seq_lens=None for now: boundaries span the pre-shard pack and models only have local semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror of the SFT-side contract: packed RL micro-batches carry explicit per-sample boundaries end to end — prepare_sample stamps them, _materialize_bin accumulates them across the bin (asserting they sum to the packed length), pad_micro_batch records padding as its own segment, and the RL dataloader tensorizes them into the micro-batch for forward(). Under CP the trainer passes seq_lens=None for now, matching the SFT side, until global boundary semantics land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2568553 to
054e0ff
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ededf23. Configure here.
| result["position_ids"].extend(range(pad_len)) | ||
| result["loss_mask"].extend([False] * pad_len) | ||
| result["target_ids"].extend([0] * pad_len) | ||
| result["seq_lens"].append(pad_len) |
There was a problem hiding this comment.
Pad segments false-trigger packed guards
Medium Severity
_finalize_pack and pad_micro_batch always append padding as its own seq_lens segment, so almost every underfull pack has seq_lens.numel() > 1. The new non-flash guards treat that as multi-document packing and raise, even when the pack is a single real sample plus trailing pad. Under causal attention that trailing pad cannot affect real-token outputs, so valid sdpa/eager cat-packed runs fail.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit ededf23. Configure here.


Summary
Part 3/7 of the split of #2485 + #2889. Based on #2943 (
feat/qwen35-dense-attention) — needs the dense Qwen3.5 model for its seq_lens consumption; the renderer-only SFT data path comes from #2942.seq_lensbecomes the packed-sample boundary contract for both trainers; models consume it for varlen attention.SFT + models (first commit):
_finalize_packpads every pack toseq_len(pad tokens form their own loss-masked document, so packs are always full-length and CP-divisible), yields the tail pack instead of dropping it, and overflow samples start the next pack instead of being truncated mid-sample.Sample/Batchgainseq_lens;cat_collateforwards it for batch-size-1 packs; stack bucketing yieldsseq_lens=None.seq_lensis a universal contract: every customForCausalLMdeclares the typedseq_lensforward parameter and the trainer passes it unconditionally to anyPreTrainedModelPrimeRL(generic HF models never receive it). Standard-attention families keep derivingcu_seqlensfromposition_idson the flash path (boundary-equivalent — the packer resets positions per document) and now reject packed rows loudly on non-flash paths instead of silently attending across documents; gpt_oss/glm_moe_dsa honor boundaries viaposition_ids(HF packed-sequence masks / sparse MLA varlen indices) and accept the param; Qwen3.5 dense + MoE consume it for varlen attention and fla state resets.cu_seqlensfromseq_lenson the non-flash path. Previouslycu_seqlensstayedNonethere: fla's GatedDeltaNet carried conv/SSM state across packed samples and SDPA attended across document boundaries — silent cross-sample contamination for sdpa/eager + cat packing. Packed hybrid batches withfull_attentionlayers require flash and raise otherwise.pack_function="stack"for Qwen3.5 custom flash attention (its varlen kernel assumes batch 1; stack produces genuine batches).seq_lens=Nonefor now — boundaries span the pre-shard pack; global semantics land in feat(cp): context parallelism for hybrid + VLM SFT #2946.cu_seqlensfrom position_ids only; it never receivesseq_lens(only customPreTrainedModelPrimeRLmodels do), so no seq_lens branch is added there.RL (second commit, from #2889): packed RL micro-batches carry the same boundaries end to end —
prepare_samplestamps them,_materialize_binaccumulates and asserts them,pad_micro_batchrecords padding as its own segment, the RL dataloader tensorizes them intoforward().Data efficiency: old vs new packing (offline replay, 12k docs, real tokenizer path, seq 8192)
The dataset (long CoT, mean doc 6,783 tokens) makes the trade stark:
The old packer's "zero padding" chops nearly every pack's last document and discards its remainder — half the dataset's tokens are never trained, and 42% of docs train without their endings/EOS. The new packer pays 21.4% pad compute (hardware tok/s is unchanged: pads flow through as a masked document) to train 28% more tokens with correct boundaries. Pad fraction shrinks on shorter-doc datasets.
E2E (W&B project
pr2485)test_loss_goes_down— a marginal 5-step assertion (loss delta +0.0018) whose values match the feat(sft): Enable VLM SFT #2485 reference bit-for-bit; see feat(rl): multimodal context parallelism #2948 for the full-suite/main-control analysis🤖 Generated with Claude Code
Note
High Risk
Touches core training forward paths, packing semantics, and many custom model implementations; incorrect boundaries would silently corrupt attention or loss, and packed non-flash configs now fail loudly instead of cross-attending.
Overview
Introduces
seq_lensas the shared packed-document boundary metadata from dataloaders throughforward(), so attention and hybrid layers can respect document boundaries instead of leaking across packed samples.SFT packing reworks
CatDataset: overflow starts a new pack instead of truncating mid-sample, tail packs are yielded, and each row is padded to fixedseq_lenwith pad tokens as their own masked segment (withseq_lenstracking real vs pad lengths). RL mirrors the same contract inprepare_sample, bin materialization, padding, and the RL batch type.The trainer passes
seq_lensonly to customPreTrainedModelPrimeRLmodels; generic HF models never receive it. Most custom stacks still build varlen indices fromposition_idson flash but error on multi-segment packs without flash; Qwen3.5 dense/MoE can buildcu_seqlensfromseq_lenson the non-flash path. Context parallelism clearsseq_lensin SFT/RL for now. CI reverse-text SFT configs bump step counts to match longer runs.Reviewed by Cursor Bugbot for commit ededf23. Bugbot is set up for automated code reviews on this repo. Configure here.