Skip to content

feat(speculative): support sequence packing in the Domino and JetSpec drafts#3023

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

feat(speculative): support sequence packing in the Domino and JetSpec drafts#3023
HuiyingLi merged 11 commits into
NVIDIA-NeMo:mainfrom
khazic:khazic/feat/dflash-variants-packing

Conversation

@khazic

@khazic khazic commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Stacks on #3004 (the DFlash packing PR): the shared prologue extracted here builds on the document-aware masks and metadata threading #3004 added. Review/merge #3004 first; this diff is the Domino/JetSpec changes on top.

What

Extends sequence packing to the two DFlash variants, completing the packing coverage item in #2958 for the DFlash family (#3002/#3003/#3004/#3005 covered the other drafts). #3004 gated packing off for these subclasses because their trainer forwards did not thread the packing metadata; this PR threads it and lifts the gate.

  • core.py: the anchor/noise/position/mask prologue that all three block-wise trainers duplicated is extracted into DFlashTrainerModule._prepare_block_inputs (with a causal flag for JetSpec), so the packing handling lives in one place. It fails loud when the packing metadata arrives partially (seq_lens without position_ids/doc_remaining would otherwise silently drop the in-document anchor constraint). _build_block_targets gains label_start and doc_remaining: anchor sampling only guarantees offsets up to block_size - 1 stay in the anchor's document, and Domino's shifted label window reaches one past that, so the builder truncates labels at the document boundary.
  • domino_core.py: forward accepts position_ids / seq_lens / doc_remaining, delegates the prologue to the base helper, and replaces its hand-rolled label/mask gathering with the shared _build_block_targets(label_start=1 if shift_label else 0, doc_remaining=...). The GRU correction head is position-relative within the block and needs no change.
  • jetspec_core.py: forward accepts the packing metadata and calls the shared prologue with causal=True (the doc-aware mask composes with the block-causal JetSpec mask, added in feat(speculative): support sequence packing in the DFlash draft #3004). The teacher logits come from the packed block-causal target forward, so the gathered teacher distribution (positions anchor .. anchor+block_size-2) is document-local by the anchor-sampling guarantee.
  • Recipes: train_domino.py / train_jetspec.py pass the metadata through _run_trainer_step; the NotImplementedError packing gate for subclasses in train_dflash.py is removed (the remaining _validate_packing_gates checks are shared and still apply).

Correctness verification

CPU unit tests (96 pass across the dflash/domino/jetspec/packing files), the new ones covering:

  • Domino packed == unpacked single-document equivalence (seeded, both shift_label settings); a two-document packed step with gradients.
  • Domino document-boundary truncation: with a pinned anchor at the end of doc0 and shift_label, the one label that crosses into doc1 is dropped (valid_tokens goes from block_size to block_size - 1).
  • JetSpec packed == unpacked equivalence; two-document packed step with gradients; packed anchor sampling respects the document bound; partial packing metadata raises.
  • The JetSpec causal-mask spy tests were repointed at the shared prologue module.

GPU recipe smokes (1x A100-80GB, bf16, real Qwen3-4B target, packing enabled, flex_attention backend), both full recipes:

Domino: loss falls 5.0 -> 3.12, block accuracy rises to ~0.18, and the Domino-specific diagnostics (final/base loss split, accept_len ~1.45, lambda_base curriculum decay) are all live under packing.

Domino training log
2026-07-11 02:51:30 | INFO | nemo_automodel.components.loggers.log_utils | Setting logging level to 20
2026-07-11 02:51:36 | WARNING | nemo_automodel._transformers.kernel_patches | Asked to use Liger Kernel, but could not import
2026-07-11 02:51:36 | INFO | nemo_automodel._transformers.kernel_patches | Patched model with SDPA method= [<SDPBackend.CUDNN_ATTENTION: 3>, <SDPBackend.FLASH_ATTENTION: 1>, <SDPBackend.EFFICIENT_ATTENTION: 2>, <SDPBackend.MATH: 0>]
2026-07-11 02:51:44 | INFO | root | Model summary:
2026-07-11 02:51:44 | INFO | root | --------------------------------
2026-07-11 02:51:44 | INFO | root | Trainable parameters: 4,022,468,096
2026-07-11 02:51:44 | INFO | root | Total parameters: 4,022,468,096
2026-07-11 02:51:44 | INFO | root | Trainable parameters percentage: 100.00%
2026-07-11 02:51:44 | INFO | root | Param L2 norm: 1578.0900
2026-07-11 02:51:44 | INFO | root | --------------------------------
2026-07-11 02:51:49 | WARNING | root | save_consolidated=every but v4_compatible=False; checkpoint assets may be not compatible with transformers v4; [experimental] set --checkpoint.v4_compatible=True to enable
2026-07-11 02:51:49 | WARNING | nemo_automodel.components.checkpoint.checkpointing | checkpoint.save_consolidated=every exports HuggingFace safetensors during every checkpoint save and can leave GPUs idle during consolidation and filesystem writes. Recommended: checkpoint.save_consolidated=final, or checkpoint.save_consolidated=false and run bash <checkpoint>/model/consolidate.sh after training.
2026-07-11 02:51:58 | INFO | __main__ |   domino: final_loss=12.2299 base_loss=12.2342 base_acc=0.0000 accept_len=1.000 base_accept_len=1.000 lambda_base=1.000
2026-07-11 02:51:58 | INFO | __main__ |   domino: final_loss=14.6153 base_loss=14.6163 base_acc=0.0245 accept_len=1.047 base_accept_len=1.047 lambda_base=0.971
2026-07-11 02:51:58 | INFO | __main__ |   domino: final_loss=15.8441 base_loss=15.8334 base_acc=0.0000 accept_len=1.000 base_accept_len=1.000 lambda_base=0.943
2026-07-11 02:51:58 | INFO | __main__ |   domino: final_loss=10.6552 base_loss=10.6260 base_acc=0.0294 accept_len=1.047 base_accept_len=1.047 lambda_base=0.914
2026-07-11 02:51:59 | INFO | __main__ |   domino: final_loss=7.4487 base_loss=7.2746 base_acc=0.0746 accept_len=1.000 base_accept_len=1.000 lambda_base=0.886
2026-07-11 02:51:59 | INFO | __main__ |   domino: final_loss=7.8094 base_loss=7.7387 base_acc=0.0509 accept_len=1.062 base_accept_len=1.062 lambda_base=0.857
2026-07-11 02:51:59 | INFO | __main__ |   domino: final_loss=7.7191 base_loss=7.5830 base_acc=0.0500 accept_len=1.047 base_accept_len=1.047 lambda_base=0.829
2026-07-11 02:51:59 | INFO | __main__ |   domino: final_loss=7.4478 base_loss=7.2821 base_acc=0.0323 accept_len=1.141 base_accept_len=1.141 lambda_base=0.800
2026-07-11 02:51:59 | INFO | __main__ |   domino: final_loss=6.5165 base_loss=6.3817 base_acc=0.0862 accept_len=1.125 base_accept_len=1.125 lambda_base=0.771
2026-07-11 02:51:59 | INFO | __main__ |   domino: final_loss=6.2416 base_loss=6.1750 base_acc=0.0510 accept_len=1.094 base_accept_len=1.094 lambda_base=0.743
2026-07-11 02:51:59 | INFO | __main__ |   domino: final_loss=5.7094 base_loss=5.6907 base_acc=0.0509 accept_len=1.047 base_accept_len=1.047 lambda_base=0.714
2026-07-11 02:51:59 | INFO | __main__ |   domino: final_loss=5.2619 base_loss=5.3261 base_acc=0.0471 accept_len=1.062 base_accept_len=1.062 lambda_base=0.686
2026-07-11 02:52:00 | INFO | __main__ |   domino: final_loss=5.1086 base_loss=5.1724 base_acc=0.0313 accept_len=1.156 base_accept_len=1.156 lambda_base=0.657
2026-07-11 02:52:00 | INFO | __main__ |   domino: final_loss=4.4963 base_loss=4.5819 base_acc=0.0539 accept_len=1.047 base_accept_len=1.047 lambda_base=0.629
2026-07-11 02:52:00 | INFO | __main__ |   domino: final_loss=4.3410 base_loss=4.4485 base_acc=0.1167 accept_len=1.219 base_accept_len=1.219 lambda_base=0.600
2026-07-11 02:52:00 | INFO | __main__ |   domino: final_loss=4.2559 base_loss=4.3866 base_acc=0.0490 accept_len=1.047 base_accept_len=1.047 lambda_base=0.571
2026-07-11 02:52:00 | INFO | __main__ |   domino: final_loss=4.1222 base_loss=4.2310 base_acc=0.0587 accept_len=1.125 base_accept_len=1.125 lambda_base=0.543
2026-07-11 02:52:00 | INFO | __main__ |   domino: final_loss=4.0529 base_loss=4.0780 base_acc=0.0901 accept_len=1.125 base_accept_len=1.125 lambda_base=0.514
2026-07-11 02:52:00 | INFO | __main__ |   domino: final_loss=4.0426 base_loss=4.0890 base_acc=0.0705 accept_len=1.062 base_accept_len=1.062 lambda_base=0.486
2026-07-11 02:52:00 | INFO | __main__ |   domino: final_loss=3.9417 base_loss=4.0539 base_acc=0.0802 accept_len=1.078 base_accept_len=1.078 lambda_base=0.457
2026-07-11 02:52:01 | INFO | __main__ |   domino: final_loss=3.8307 base_loss=3.9422 base_acc=0.1156 accept_len=1.406 base_accept_len=1.344 lambda_base=0.429
2026-07-11 02:52:01 | INFO | __main__ |   domino: final_loss=3.6943 base_loss=3.8282 base_acc=0.1138 accept_len=1.312 base_accept_len=1.312 lambda_base=0.400
2026-07-11 02:52:01 | INFO | __main__ |   domino: final_loss=3.6077 base_loss=3.7575 base_acc=0.1234 accept_len=1.438 base_accept_len=1.391 lambda_base=0.371
2026-07-11 02:52:01 | INFO | __main__ |   domino: final_loss=3.5947 base_loss=3.7440 base_acc=0.1040 accept_len=1.781 base_accept_len=1.484 lambda_base=0.343
2026-07-11 02:52:01 | INFO | __main__ |   domino: final_loss=3.2138 base_loss=3.3649 base_acc=0.1743 accept_len=1.547 base_accept_len=1.625 lambda_base=0.314
2026-07-11 02:52:01 | INFO | __main__ |   domino: final_loss=3.4084 base_loss=3.5760 base_acc=0.0980 accept_len=1.484 base_accept_len=1.438 lambda_base=0.286
2026-07-11 02:52:01 | INFO | __main__ |   domino: final_loss=3.4315 base_loss=3.6109 base_acc=0.1227 accept_len=1.422 base_accept_len=1.469 lambda_base=0.257
2026-07-11 02:52:01 | INFO | __main__ |   domino: final_loss=3.3701 base_loss=3.5821 base_acc=0.0989 accept_len=1.531 base_accept_len=1.469 lambda_base=0.229
2026-07-11 02:52:02 | INFO | __main__ |   domino: final_loss=3.3793 base_loss=3.5778 base_acc=0.1027 accept_len=1.422 base_accept_len=1.344 lambda_base=0.200
2026-07-11 02:52:02 | INFO | __main__ |   domino: final_loss=3.3678 base_loss=3.5920 base_acc=0.0979 accept_len=1.453 base_accept_len=1.359 lambda_base=0.171
2026-07-11 02:52:02 | INFO | __main__ |   domino: final_loss=3.3731 base_loss=3.6184 base_acc=0.1184 accept_len=1.391 base_accept_len=1.391 lambda_base=0.143
2026-07-11 02:52:02 | INFO | __main__ |   domino: final_loss=3.1532 base_loss=3.3906 base_acc=0.0970 accept_len=1.516 base_accept_len=1.453 lambda_base=0.114
2026-07-11 02:52:02 | INFO | __main__ |   domino: final_loss=3.2303 base_loss=3.4923 base_acc=0.1010 accept_len=1.469 base_accept_len=1.406 lambda_base=0.086
2026-07-11 02:52:02 | INFO | __main__ |   domino: final_loss=3.2399 base_loss=3.5231 base_acc=0.0921 accept_len=1.344 base_accept_len=1.312 lambda_base=0.057
2026-07-11 02:52:02 | INFO | __main__ |   domino: final_loss=3.1120 base_loss=3.3908 base_acc=0.0902 accept_len=1.453 base_accept_len=1.391 lambda_base=0.029
2026-07-11 02:52:02 | INFO | nemo_automodel.recipes.llm.train_dflash | Finished epoch 1/1 completed_steps=35 skipped_short_micro_batches=0

JetSpec: the forward-KL loss falls 5.3 -> 4.41 and the greedy draft-vs-target accuracy rises to ~0.24 under packing.

JetSpec training log
2026-07-11 02:52:21 | INFO | nemo_automodel.components.loggers.log_utils | Setting logging level to 20
2026-07-11 02:52:22 | WARNING | nemo_automodel._transformers.kernel_patches | Asked to use Liger Kernel, but could not import
2026-07-11 02:52:22 | INFO | nemo_automodel._transformers.kernel_patches | Patched model with SDPA method= [<SDPBackend.CUDNN_ATTENTION: 3>, <SDPBackend.FLASH_ATTENTION: 1>, <SDPBackend.EFFICIENT_ATTENTION: 2>, <SDPBackend.MATH: 0>]
2026-07-11 02:52:25 | INFO | root | Model summary:
2026-07-11 02:52:25 | INFO | root | --------------------------------
2026-07-11 02:52:25 | INFO | root | Trainable parameters: 4,022,468,096
2026-07-11 02:52:25 | INFO | root | Total parameters: 4,022,468,096
2026-07-11 02:52:25 | INFO | root | Trainable parameters percentage: 100.00%
2026-07-11 02:52:25 | INFO | root | Param L2 norm: 1578.0900
2026-07-11 02:52:25 | INFO | root | --------------------------------
2026-07-11 02:52:29 | WARNING | root | save_consolidated=every but v4_compatible=False; checkpoint assets may be not compatible with transformers v4; [experimental] set --checkpoint.v4_compatible=True to enable
2026-07-11 02:52:29 | WARNING | nemo_automodel.components.checkpoint.checkpointing | checkpoint.save_consolidated=every exports HuggingFace safetensors during every checkpoint save and can leave GPUs idle during consolidation and filesystem writes. Recommended: checkpoint.save_consolidated=final, or checkpoint.save_consolidated=false and run bash <checkpoint>/model/consolidate.sh after training.
2026-07-11 02:52:50 | INFO | __main__ |   jetspec: accept_len(tau~)=1.000
2026-07-11 02:52:50 | INFO | __main__ |   jetspec: accept_len(tau~)=1.000
2026-07-11 02:52:50 | INFO | __main__ |   jetspec: accept_len(tau~)=1.000
2026-07-11 02:52:50 | INFO | __main__ |   jetspec: accept_len(tau~)=1.000
2026-07-11 02:52:51 | INFO | __main__ |   jetspec: accept_len(tau~)=1.156
2026-07-11 02:52:51 | INFO | __main__ |   jetspec: accept_len(tau~)=1.062
2026-07-11 02:52:51 | INFO | __main__ |   jetspec: accept_len(tau~)=1.047
2026-07-11 02:52:51 | INFO | __main__ |   jetspec: accept_len(tau~)=1.047
2026-07-11 02:52:51 | INFO | __main__ |   jetspec: accept_len(tau~)=1.000
2026-07-11 02:52:51 | INFO | __main__ |   jetspec: accept_len(tau~)=1.016
2026-07-11 02:52:51 | INFO | __main__ |   jetspec: accept_len(tau~)=1.000
2026-07-11 02:52:51 | INFO | __main__ |   jetspec: accept_len(tau~)=1.156
2026-07-11 02:52:52 | INFO | __main__ |   jetspec: accept_len(tau~)=1.094
2026-07-11 02:52:52 | INFO | __main__ |   jetspec: accept_len(tau~)=1.094
2026-07-11 02:52:52 | INFO | __main__ |   jetspec: accept_len(tau~)=1.281
2026-07-11 02:52:52 | INFO | __main__ |   jetspec: accept_len(tau~)=1.031
2026-07-11 02:52:52 | INFO | __main__ |   jetspec: accept_len(tau~)=1.078
2026-07-11 02:52:52 | INFO | __main__ |   jetspec: accept_len(tau~)=1.094
2026-07-11 02:52:52 | INFO | __main__ |   jetspec: accept_len(tau~)=1.094
2026-07-11 02:52:53 | INFO | __main__ |   jetspec: accept_len(tau~)=1.062
2026-07-11 02:52:53 | INFO | __main__ |   jetspec: accept_len(tau~)=1.094
2026-07-11 02:52:53 | INFO | __main__ |   jetspec: accept_len(tau~)=1.188
2026-07-11 02:52:53 | INFO | __main__ |   jetspec: accept_len(tau~)=1.094
2026-07-11 02:52:53 | INFO | __main__ |   jetspec: accept_len(tau~)=1.172
2026-07-11 02:52:53 | INFO | __main__ |   jetspec: accept_len(tau~)=1.125
2026-07-11 02:52:53 | INFO | __main__ |   jetspec: accept_len(tau~)=1.125
2026-07-11 02:52:53 | INFO | __main__ |   jetspec: accept_len(tau~)=1.156
2026-07-11 02:52:54 | INFO | __main__ |   jetspec: accept_len(tau~)=1.094
2026-07-11 02:52:54 | INFO | __main__ |   jetspec: accept_len(tau~)=1.062
2026-07-11 02:52:54 | INFO | __main__ |   jetspec: accept_len(tau~)=1.094
2026-07-11 02:52:54 | INFO | __main__ |   jetspec: accept_len(tau~)=1.062
2026-07-11 02:52:54 | INFO | __main__ |   jetspec: accept_len(tau~)=1.109
2026-07-11 02:52:54 | INFO | __main__ |   jetspec: accept_len(tau~)=1.125
2026-07-11 02:52:54 | INFO | __main__ |   jetspec: accept_len(tau~)=1.141
2026-07-11 02:52:54 | INFO | __main__ |   jetspec: accept_len(tau~)=1.219
2026-07-11 02:52:54 | INFO | nemo_automodel.recipes.llm.train_dflash | Finished epoch 1/1 completed_steps=35 skipped_short_micro_batches=0

Scope

Domino and JetSpec online text paths. Packing with context parallelism remains excluded (same shared gate as #3004).

khazic added 7 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>
… drafts

Signed-off-by: khazic <khazzz1c@gmail.com>
…ared block-target builder

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

Signed-off-by: khazic <khazzz1c@gmail.com>
@khazic khazic requested a review from a team as a code owner July 10, 2026 19:04
@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.

…fake batches

The DFlash packing base threads target_batch.position_ids / seq_lens /
doc_remaining into the trainer (None off the packing path). The fake target
wrappers and stub batches in test_train_dflash.py,
test_train_dflash_noanchor_skip.py, test_train_domino.py, and
test_train_jetspec.py lacked those attributes, so the unpacked path raised
AttributeError. Add the three attributes (None) and accept the packing kwargs,
matching the real target batch defaults.

Signed-off-by: khazic <khazzz1c@gmail.com>
HuiyingLi
HuiyingLi previously approved these changes Jul 12, 2026
@HuiyingLi

Copy link
Copy Markdown
Contributor

/claude review

Comment on lines +153 to +160
"""Return the FlexAttention ``mask_mod`` closure encoding the DFlash mask.

Factored out of :func:`create_dflash_block_mask` so the doc-aware gating can be
materialised with ``torch.nn.attention.flex_attention.create_mask`` and tested on
CPU (FlexAttention's kernel is CUDA-only). The closure uses only tensor ops (no
Python control flow) so ``torch.compile`` can trace it; see the module docstring
for the mask semantics and the ``ctx_doc_id`` / ``anchor_doc_id`` packing args.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build_dflash_mask_mod is a new module-level (public) function that accepts four tensor inputs (anchor_positions, block_keep_mask, ctx_doc_id, anchor_doc_id), but its docstring has no Args section documenting their layouts. The reference to the module docstring covers the mask semantics but not the shape/axis order of these specific tensors, so a caller can't tell the expected layout from the docstring alone. The sibling create_dflash_sdpa_mask / create_dflash_block_mask already document these; please add an Args section here, e.g.:

    Args:
        anchor_positions: ``[B, N]`` anchor positions per sample (long).
        block_keep_mask:  ``[B, N]`` per-sample valid-anchor mask (bool).
        ctx_len:          context length ``S``.
        block_size:       block size.
        num_blocks:       number of blocks ``N``.
        causal:           When True, in-block attention is causal (JetSpec).
        ctx_doc_id:       ``[B, S]`` long per-context-token document id (packing),
            or ``None`` to disable the per-document constraint.
        anchor_doc_id:    ``[B, N]`` long document id of each anchor; required when
            ``ctx_doc_id`` is given.

…variants-packing

# Conflicts:
#	nemo_automodel/components/speculative/dflash/core.py
#	nemo_automodel/components/speculative/dflash/target.py
#	nemo_automodel/recipes/llm/train_dflash.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