Support fine-tuning released DFlash/DSpark drafters (causal SWA, attention sink, warm start) - #2149
Support fine-tuning released DFlash/DSpark drafters (causal SWA, attention sink, warm start)#2149h-guo18 wants to merge 6 commits into
Conversation
### What does this PR do? Type of change: New feature The DFlash/DSpark draft could only be trained with one attention pattern: non-causal (MiMo-style) blocks, optionally sliding-windowed, with no attention sink. Drafters that declare `dflash_config.causal=true` and carry `self_attn.attention_sink_bias` weights — e.g. `nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16-DSpark` — could not be trained faithfully or even loaded: the sink tensors were dropped as unexpected keys, and the exporter hardcoded `causal: False`, so training and vLLM inference disagreed. Add two opt-in `DFlashConfig` options, both defaulting to today's behavior: - `dflash_draft_attention: "bidirectional" | "causal"` selects the block-internal attention pattern. `"causal"` restricts a query at block position `i` to draft positions `<= i`. - `dflash_attention_sink: bool` adds a learnable per-head `attention_sink_bias [num_attention_heads]` to every draft layer: one extra logit appended to the attention logits before the softmax and dropped after, so a head can place probability mass nowhere instead of being forced to attend inside its window (the GPT-OSS formulation). Fused SDPA cannot express that extra column, so this runs an eager attention path, used only when the option is enabled. Three latent issues surfaced while wiring this up and are fixed here: - `_build_generate_swa_mask` returned `None` whenever `dflash_swa_window_size` was unset, which would have silently dropped the causal structure at generation time while training used it. - The exporter only wrote `dflash_config.causal` under SWA, hardcoded to `False`. It is now written unconditionally from the trained setting, since vLLM's `_dflash_layer_causal` treats it as an all-layer override whose default varies per layer type. - DSpark head weights load from either the flat layout ModelOpt exports (matching upstream DeepSpec) or the nested `markov_head.` layout used by released NVIDIA drafters, via a load pre-hook. The export format is unchanged, so drafters already trained and deployed with flat names keep working. Also enable `nemotron_h` in `_FINAL_NORM_TYPE_BY_MODEL_TYPE`: despite the hybrid Mamba/attention/MoE stack, `NemotronHModel.norm_f` is a plain RMSNorm, and without the entry the offline/streaming fake base raises rather than reconstructing the distillation target. ### Usage ```yaml dflash: dflash_draft_attention: causal # default: bidirectional dflash_attention_sink: true # default: false dflash_swa_window_size: 1024 ``` ### Testing - 111 unit tests pass (`test_hf_dflash.py`, `test_hf_dspark.py`, `test_hf_domino.py`, `test_hf_dflash_offline.py`, `test_modeling_final_norm.py`), including 19 new ones covering the causal mask structure, the sink math and its gradient, key remapping, and export round-trips. - Verified `nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16-DSpark` loads with zero missing/unexpected keys and zero shape mismatches, and that all 77 tensors — the 6 sink biases and both Markov tables included — match the checkpoint bit-exactly. - Default (`bidirectional`, no sink) mask output is unchanged element-wise, so existing drafters train exactly as before. ### Before your PR is "*Ready for review*" - **Make sure you read and follow [Contributor guidelines](https://github.com/NVIDIA/TensorRT-Model-Optimizer/blob/main/CONTRIBUTING.md)** and your commits are signed. - **Is this change backward compatible?**: Yes - **Did you write any new necessary tests?**: Yes - **Did you add or update any necessary documentation?**: Yes - **Did you update [Changelog](https://github.com/NVIDIA/TensorRT-Model-Optimizer/blob/main/CHANGELOG.rst)?**: No Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
### What does this PR do? Type of change: New feature Training always started the draft module from a random init. Continuing from a published drafter — the usual way to fine-tune a release such as `nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16-DSpark` — had no entry point. Add `dflash_init_checkpoint`: a path to an exported draft checkpoint, loaded into the draft module right after it is built (before the dtype/device move, so the weights are cast with the rest of the module). It accepts the export directory or the `model.safetensors` file itself, tolerates a `dflash_module.` prefix so a raw training checkpoint works too, and ignores `embed_tokens`/`lm_head`, which the draft takes from the base model. Any missing, unexpected, or wrong-shaped tensor raises instead of warning. Loading only part of a draft and leaving the rest randomly initialized looks like a warm start but trains from a corrupted starting point, and nothing downstream would flag it. The shape check resolves the module's load pre-hooks first, so a tensor arriving under DSpark's nested `markov_head.` layout is validated too rather than slipping through to a less obvious failure later. ### Usage ```yaml dflash: dflash_init_checkpoint: /path/to/exported/drafter ``` ### Testing - 119 unit tests pass, 8 new ones here: weights actually loaded, file vs directory paths, round-trip with sink + causal attention, the default path still random-inits, and four rejection cases (missing path, draft depth mismatch, sink on/off mismatch, wrong shape under the nested layout). - End-to-end against the released Nemotron-3.5 DSpark drafter: `convert()` with `dflash_init_checkpoint` reproduces all 77 draft tensors bit-exactly (6 attention sinks and both Markov tables included), applies `causal` + sink + SWA 1024 from the checkpoint's own config, and runs a training step with a finite loss and gradients reaching the sink and Markov parameters. ### Before your PR is "*Ready for review*" - **Make sure you read and follow [Contributor guidelines](https://github.com/NVIDIA/TensorRT-Model-Optimizer/blob/main/CONTRIBUTING.md)** and your commits are signed. - **Is this change backward compatible?**: Yes - **Did you write any new necessary tests?**: Yes - **Did you add or update any necessary documentation?**: Yes - **Did you update [Changelog](https://github.com/NVIDIA/TensorRT-Model-Optimizer/blob/main/CHANGELOG.rst)?**: No Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
### What does this PR do?
Type of change: Bug fix
`HFDFlashModel.modify` recomputed `target_layer_ids` from the uniform
default on every convert, with no way to override it. A published draft
is trained against specific capture points — the released
`nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16-DSpark` uses
`[1,5,19,29,41,51]`, while the default for a 52-layer base is
`[1,11,20,30,39,49]` — so fine-tuning one fed the draft features from
layers it had never seen. Here that surfaced as a matmul shape error, but
only because the plane counts happened to disagree; with a matching count
it would have trained silently on the wrong features.
An explicit `dflash_architecture_config.target_layer_ids` is now used as
given, validated for length (one per draft layer) and range.
That fix exposes a second case. The streaming dataset splits captured
planes as `aux = planes[:-1]`, `base/KD target = planes[-1]`, which
assumes the draft's aux layers all sit below the base's last layer — true
for ModelOpt's default ids (they stop at `num_target_layers - 3`) and for
the M3 example, but not for a draft whose top aux id *is* the final
layer. vLLM captures each layer at most once, so no extra plane can be
requested. `final_aux_is_base_hidden` makes the last plane serve both
roles; it is derived from the model in `main.py` rather than configured
by hand, since a wrong manual value fails as an opaque matmul error deep
in the draft's `fc`.
### Usage
```yaml
dflash:
dflash_architecture_config:
target_layer_ids: [1, 5, 19, 29, 41, 51] # match the checkpoint
```
### Testing
- 124 unit tests pass, 5 new here: explicit ids are used, the default
still applies when unset, wrong count and out-of-range both raise, and
the ids round-trip through export.
- End-to-end streaming run against the released drafter (vLLM-served
Nemotron-3.5 base, 8 trainer GPUs): the trainer now logs
`target_layer_ids=[1,5,19,29,41,51], final_aux_is_base_hidden=True`,
and training converges — loss 1.85 → 1.21, train_acc 0.25 → 0.48 over
20 epochs.
### Before your PR is "*Ready for review*"
- **Make sure you read and follow [Contributor guidelines](https://github.com/NVIDIA/TensorRT-Model-Optimizer/blob/main/CONTRIBUTING.md)** and your commits are signed.
- **Is this change backward compatible?**: Yes
- **Did you write any new necessary tests?**: Yes
- **Did you add or update any necessary documentation?**: Yes
- **Did you update [Changelog](https://github.com/NVIDIA/TensorRT-Model-Optimizer/blob/main/CHANGELOG.rst)?**: No
Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
### What does this PR do? Type of change: Documentation Worked example for continuing training from a published DFlash/DSpark draft: it wires up `dflash_init_checkpoint`, the checkpoint's own `target_layer_ids`, and the causal + attention-sink settings the released Nemotron-3.5 drafter was trained with. The architecture block is transcribed by hand from the drafter's `config.json`, which is the weak point and is flagged as a TODO in the file: only the shape-bearing fields fail loudly when mistyped, while the mask token, causal flag, window size and block size all train "successfully" on a wrong value and surface later as a mysteriously low acceptance length. A converter should derive the block from the checkpoint's config (including its aliases) instead. ### Testing Used as-is for the end-to-end streaming run reported in this PR. ### Before your PR is "*Ready for review*" - **Make sure you read and follow [Contributor guidelines](https://github.com/NVIDIA/TensorRT-Model-Optimizer/blob/main/CONTRIBUTING.md)** and your commits are signed. - **Is this change backward compatible?**: Yes - **Did you write any new necessary tests?**: N/A (example config) - **Did you add or update any necessary documentation?**: Yes - **Did you update [Changelog](https://github.com/NVIDIA/TensorRT-Model-Optimizer/blob/main/CHANGELOG.rst)?**: No Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
Loss and train-accuracy over the 20-epoch streaming run used to validate the end-to-end pipeline (vLLM-served Nemotron-3.5 base, draft warm-started from the released DSpark checkpoint). Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
|
The full 20-epoch run flattens after epoch ~5, which obscures the trend the plot is meant to show. Same run, first 80 steps. Signed-off-by: h-guo18 <67671475+h-guo18@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2149 +/- ##
==========================================
+ Coverage 78.73% 78.76% +0.03%
==========================================
Files 522 522
Lines 60357 60439 +82
==========================================
+ Hits 47523 47606 +83
+ Misses 12834 12833 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Support fine-tuning released DFlash/DSpark drafters (causal SWA, attention sink, warm start)
What does this PR do?
Type of change: New feature + bug fix
Adds what ModelOpt was missing to fine-tune an already-published DFlash/DSpark draft
model. The concrete target is
nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16-DSparkon its hybrid Mamba/attention/MoE base, but every change is generic.
Before this PR that checkpoint could not be trained faithfully — or even loaded: its
attention-sink tensors were dropped as unexpected keys, its block-causal attention had no
implementation, and its capture layers were silently overwritten with ModelOpt's defaults.
New user-facing options (all default to today's behavior, so existing runs are unchanged):
dflash_draft_attentionbidirectional(default) /causalcausalrestricts a query at block positionito draft positions<= i.dflash_attention_sinkfalse(default) /trueattention_sink_bias [num_heads]on every draft layer — one extra logit appended before the softmax and dropped after, so a head can put probability mass nowhere instead of being forced to attend inside its window (the GPT-OSS formulation).dflash_init_checkpointdflash_architecture_config.target_layer_idsfc. Previously recomputed unconditionally with no override.Bugs fixed along the way (each one silently corrupts training rather than failing):
dflash_config.causal: Falseand only wrote it under SWA, so evena correctly-trained causal draft would be served non-causally. It now reflects the trained
setting, and emits
attention_sink_biaswhen enabled._build_generate_swa_maskreturnedNonewheneverswa_window_sizewas unset, whichwould have dropped the causal structure at generation time while training used it.
target_layer_idswas recomputed from the uniform default on every convert. The releaseddrafter uses
[1,5,19,29,41,51]; the default for a 52-layer base is[1,11,20,30,39,49]— different layers. Here it surfaced as a matmul shape error only because the plane
counts disagreed; with a matching count it would have trained on the wrong features
silently.
(
aux = planes[:-1],target = planes[-1]). A draft whose top aux id is the final layercannot get an extra plane — vLLM captures each layer once — so
final_aux_is_base_hiddennow lets the last plane serve both roles. It is derived from the model, not configured by
hand.
convention) or the nested
markov_head.layout the NVIDIA release uses. Without the remapthe two
[131072, 512]Markov tables — ~14% of the draft's parameters — stay randomlyinitialized while everything else warm-starts, with no error.
nemotron_his enabled in_FINAL_NORM_TYPE_BY_MODEL_TYPE: despite the hybrid stack,NemotronHModel.norm_fis a plain RMSNorm, and without the entry the offline/streamingfake base raises instead of reconstructing the distillation target.
Usage
A full worked example is at
modelopt_recipes/general/speculative_decoding/dspark_nemotron35_warmstart.yaml.Testing
Unit tests — 124 pass (
test_hf_dflash.py,test_hf_dspark.py,test_hf_domino.py,test_hf_dflash_offline.py,test_modeling_final_norm.py), 32 of them new: causal maskstructure (lower-triangular per block, no cross-block leakage, context visibility
unchanged), the sink math (degenerates to plain attention at
-inf, absorbs massmonotonically, receives gradient), warm-start load/reject paths, Markov key remapping, and
explicit
target_layer_ids.Checkpoint compatibility — the released drafter loads with zero missing/unexpected keys
and zero shape mismatches; all 77 tensors (6 attention sinks and both Markov tables
included) match bit-exactly, and a training step runs with gradients reaching the sink and
Markov parameters.
End-to-end streaming training — Nemotron-3.5 base served by vLLM (1 node, TP8) feeding
8 trainer GPUs over NIXL; the draft warm-starts from the released checkpoint and trains with
causal+ sink + SWA 1024. 128 Daring-Anteater conversations, 20 epochs (the plot shows thefirst 5, where the trend is clearest — the curves flatten after that):
Over the first 5 epochs loss falls 1.85 → 1.36 and train accuracy rises
0.25 → 0.49; across the full 20 epochs they reach 1.21 and 0.48 (peak 0.54)
before flattening. This validates the pipeline end-to-end — capture layers, plane split,
mask direction, sink loading and warm-start weights all have to be right for this curve to
appear. It is not a model-quality result: 128 samples over 20 epochs overfits by
construction, and the corpus is not generated by the base model, so the absolute numbers are
not meaningful.
TODO (follow-up)
A complete, robust checkpoint/config converter. Both conversions are handled ad hoc here:
drafter's
config.json. Only the shape-bearing ones (num_hidden_layers,num_attention_heads,intermediate_size,markov_rank) fail loudly when mistyped; therest —
mask_token_id,causal,swa_window_size,block_size— train "successfully" ona wrong value and only surface later as a mysteriously low acceptance length. A converter
should derive the whole block from the checkpoint, including its aliases (
pard_token,dspark_markov_rank,dflash_query_causal, top-levelsliding_window/attention_sink_bias) and duplicated fields.markov_head.remap is a load-time hook. A converter should normalizelayouts explicitly, and decide whether export should also emit the release's aliases so a
round-trip reproduces the original format (today it renames
architecturestoDFlashDraftModel).config.jsonlayer-type vocabularyupdated for the transformers-5 path (
mamba→linear_attention,attention→full_attention, plus a matchinghybrid_override_pattern). That is done by hand today andis not covered by this PR.
Before your PR is "Ready for review"