Skip to content

feat(speculative): support sequence packing in the DSpark Qwen3 draft - #3005

Merged
HuiyingLi merged 11 commits into
NVIDIA-NeMo:mainfrom
khazic:khazic/feat/dspark-draft-packing
Jul 12, 2026
Merged

feat(speculative): support sequence packing in the DSpark Qwen3 draft#3005
HuiyingLi merged 11 commits into
NVIDIA-NeMo:mainfrom
khazic:khazic/feat/dspark-draft-packing

Conversation

@khazic

@khazic khazic commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Stacks on #3004 (the DFlash packing PR) for the document-aware DFlash masks that DSpark reuses. Review/merge #3004 first; this diff is the DSpark-specific changes on top.

What

Extends sequence packing to the DSpark draft. DSpark is anchor/block-based like DFlash and reuses the same dflash masks plus the shared dspark/common.py helpers, so packing composes the same way. Scope: the online text-only Qwen3 path.

  • common.py: sample_anchor_positions / build_anchor_candidate_mask require the anchor's first target to stay in its document; build_eval_mask truncates each block at its anchor's document boundary (step <= doc_remaining[anchor] before the cumprod, a partial in-document block matching the sequence-end truncation); create_position_ids gathers per-document context positions; add a context_doc_ids helper. All new params default to None, so the other drafts' calls are unchanged.
  • draft_qwen3.py: forward accepts position_ids / seq_lens / doc_remaining, builds the document-aware dflash mask (ctx_doc_id / anchor_doc_id) and per-document positions, and gates supervision by document.
  • core.py / target.py: thread the packing metadata (only when packing is active, so the unpacked path stays signature-compatible with every draft); the target runs block-causal (or FA varlen at batch size 1) so context hidden states do not leak across documents, and fails loud if the target forward does not accept position_ids.
  • train_dspark.py: read packed_sequence_size into the LLM dataloaders, splat the metadata through _forward_batch, and fail fast on unsupported configs (multimodal, cached-target, non-Qwen3 draft, cp_size>1, FlashAttention target with micro_batch_size>1).

The Markov head is position-agnostic (token-id lookup) and needs no change. The other 4 DSpark drafts + the VLM and cached paths are gated off (documented follow-ups).

Correctness verification

CPU unit tests (14 pass; the packing helpers run on the SDPA/eager path), including:

  • doc-id construction from seq_lens; anchor candidate requires the first target in-document; eval_mask truncates a block at the document boundary; per-document draft positions;
  • single-document packed == unpacked trainer equivalence (seeded); a two-document packed training step with gradients; target-side document isolation of the captured hidden states; the unpacked path does not forward packing kwargs to a legacy-signature draft (so the non-Qwen3 drafts keep working); recipe helpers and fail-fast gates.

GPU recipe smoke (1x A100-80GB, bf16), the full DSpark recipe with a real Qwen3-4B target, packing enabled, and the flex_attention backend:

Training log
step 24 | loss 3.2376 | ce 5.1290 | tv 1.7975 | conf 1.1070
step 27 | loss 2.5047 | ce 5.2626 | tv 1.7849 | conf 0.3720
step 30 | loss 2.4739 | ce 5.7357 | tv 1.6559 | conf 0.4100
step 33 | loss 2.4052 | ce 5.1364 | tv 1.7021 | conf 0.3597
step 35 | loss 2.3672 | ce 4.8426 | tv 1.7004 | conf 0.3525
Finished epoch 1/1 completed_steps=35

The three-term loss falls with packing on, exercising the full path (packed dataloader -> target block-causal -> document-aware flex block mask -> anchor / position / eval-mask handling -> Markov head).

Scope

DSpark Qwen3 online text-only path only. The other 4 DSpark drafts, the VLM path, the offline-cache path, and packing with context parallelism remain follow-ups.

khazic added 3 commits July 10, 2026 16:46
DFlash builds its dataloader with build_eagle3_dataloader but never passed
packed_sequence_size, so packing was unreachable. Wire it end to end. DFlash's
Q layout is draft blocks only (context is KV), so packing composes cleanly:

- dflash_mask: create_dflash_{sdpa,block}_mask take optional ctx_doc_id /
  anchor_doc_id; a block's context prefix is restricted to its anchor's document
  (both the dense SDPA mask and the flex BlockMask mask_mod).
- core: forward accepts position_ids / seq_lens / doc_remaining. Anchor sampling
  keeps whole blocks inside one document (doc_remaining >= block_size-1); the
  context position ids reset per document; the draft block positions are gathered
  from the per-document context positions so RoPE stays document-local.
- target: generate_batch runs the frozen target with the block-causal mask (or FA
  varlen at batch size 1) so the captured context hidden states do not leak across
  documents, and carries the packing metadata to the trainer.
- train_dflash: read packed_sequence_size into both dataloaders, splat the packing
  metadata into generate_batch and the trainer, fail fast on incompatible configs
  (cp_size>1, FlashAttention target with batch>1), and reject packing on the
  JetSpec/Domino subclasses (they override the trainer forward without threading it).

Tests: doc-id construction, SDPA mask document restriction, anchor sampling stays
in-document, per-document draft positions, single-document packed==unpacked
equivalence, a two-document packed training step, target-side document isolation,
and the recipe helpers/gates.

Signed-off-by: khazic <khazzz1c@gmail.com>
… limitation

Address /code-review findings: the flex_attention backend is the DFlash default
but every packing test used sdpa, leaving the flex doc-gating branch uncovered.
Factor the flex mask_mod into a module-level build_dflash_mask_mod so it can be
materialised on CPU via create_mask (FlexAttention's kernel is CUDA-only), and
add a test asserting the flex per-token mask equals the SDPA mask and enforces
the per-document restriction. Also document that the whole-block-in-document
anchor bound (required because _build_block_targets does not encode boundaries)
drops packed documents shorter than block_size.

Signed-off-by: khazic <khazzz1c@gmail.com>
@khazic
khazic requested a review from a team as a code owner July 10, 2026 10:02
@copy-pr-bot

copy-pr-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

khazic added 4 commits July 11, 2026 02:24
DSpark is anchor/block-based like DFlash and reuses the same dflash masks and the
shared dspark/common.py helpers, so packing composes the same way. Scope this to
the online text-only Qwen3 path:

- common.py: sample_anchor_positions / build_anchor_candidate_mask require the
  anchor's first target to stay in its document; build_eval_mask truncates each
  block at its anchor's document boundary (a partial in-document block, matching
  the sequence-end truncation); create_position_ids gathers per-document context
  positions; add a context_doc_ids helper. All new params default to None so the
  other drafts' calls are unchanged.
- draft_qwen3.py: forward accepts position_ids / seq_lens / doc_remaining, builds
  the document-aware dflash mask (ctx_doc_id/anchor_doc_id) and per-document
  positions, and gates supervision by document.
- core.py / target.py: thread the packing metadata; the target runs block-causal
  (or FA varlen at batch size 1) so context hidden states do not leak across docs.
- train_dspark.py: read packed_sequence_size into the LLM dataloaders, splat the
  metadata through _forward_batch, and fail fast on unsupported configs
  (multimodal, cached-target, non-Qwen3 draft, cp_size>1, FA target with batch>1).

The Markov head is position-agnostic (token-id lookup) and needs no change. Tests
cover the doc-aware common helpers, single-document packed==unpacked equivalence,
a two-document training step, target-side document isolation, and the recipe gates.

Signed-off-by: khazic <khazzz1c@gmail.com>
… guard target position_ids

Address /code-review findings on the DSpark packing change:

- DSparkTrainerModule.forward passed the new position_ids/seq_lens/doc_remaining
  kwargs to the draft unconditionally, but only the Qwen3 draft accepts them, so
  every non-Qwen3 DSpark draft (DeepseekV4/Gemma4/GLM-5.2/MiniMax-M3) raised a
  TypeError on the first training step even on the default unpacked path. Pass the
  packing metadata only when packing is active (seq_lens is not None); packing is
  gated to the Qwen3 draft at recipe setup.
- The target wrapper builds its forward kwargs through filter_forward_kwargs, which
  silently drops kwargs the target does not declare. Under packing, fail loud if the
  target forward does not accept position_ids (dropping it would give wrong
  per-document RoPE and, on the FlashAttention path, lose the document-boundary
  signal) instead of silently leaking context across documents.

Tests: the unpacked trainer must not forward packing kwargs to a legacy-signature draft.
Signed-off-by: khazic <khazzz1c@gmail.com>
Signed-off-by: khazic <khazzz1c@gmail.com>

# Conflicts:
#	nemo_automodel/components/speculative/dflash/core.py
…draft-packing

# Conflicts:
#	nemo_automodel/components/speculative/dflash/target.py
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants