Skip to content

feat(automodel): speculative decoding draft co-training - #3960

Open
yuekaizhang wants to merge 32 commits into
NVIDIA-NeMo:mainfrom
yuekaizhang:draft-cotraining-migration-rebased
Open

feat(automodel): speculative decoding draft co-training#3960
yuekaizhang wants to merge 32 commits into
NVIDIA-NeMo:mainfrom
yuekaizhang:draft-cotraining-migration-rebased

Conversation

@yuekaizhang

Copy link
Copy Markdown
Contributor

Summary

Ports speculative-decoding draft co-training (DSpark and DFlash block
drafters, and an EAGLE3 TTT drafter) from RL_dspark onto NeMo-RL's
automodel (DTensor v2) backend, so a draft model can be trained jointly
with the policy and refit into vLLM (draft.* weight stream) for
speculative-decoding generation.

  • New nemo_rl/models/automodel/draft/ module: EAGLE3 loss/integration,
    block-draft (dspark/dflash) loss, and a shared markov head, plus CP
    draft-loss rescale, long-context chunking (TTT lm_head/KL), embed-sharing
    and weights/model resume fixes, and support for native/flat (SGLang
    SpecForge-style) EAGLE3 checkpoints.
  • New recipes: Qwen3-8B (GRPO + DAPO, 1n8g) DSpark/DFlash/EAGLE3
    co-training siblings, and Qwen3-30B-A3B-Instruct-2507 (4n8g, EP32)
    EAGLE3 co-training with the lmsys SpecForge drafter (published as a
    config-patched fork at
    yuekai/SGLang-EAGLE3-Qwen3-30B-A3B-Instruct-2507-SpecForge-Nex
    only max_position_embeddings changed, 2048 -> 40960, to stop vLLM
    clamping the drafter's RoPE).
  • Nightly test coverage for dspark/dflash/eagle3 co-training on the
    automodel backend.

yuekaizhang and others added 30 commits August 31, 2026 00:20
Design for migrating dspark/dflash/eagle3 draft co-training from the
RL_dspark fork onto bump-automodel-r0.6.0: thin local extension layer
over nemo_automodel.components.speculative imports, hybrid replay +
manual re-port of the CP/DistributedSetup-refactored files, and an
nccl_reshard + draft co-training guard. Approach-C (deep refactor onto
Automodel Trainer modules) risk assessment referenced from
speculative_rl/moving_deep.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
Converged gen-plan output (3 Claude-Codex rounds): 8 acceptance criteria,
20 tasks with coding/analyze routing, resolved DEC-1 (accept-length as
trend telemetry) and DEC-2 (CP>1 guarded fallback allowed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…dspark

Thin extension layer under nemo_rl/models/automodel/draft/ importing
byte-identical modules from nemo_automodel r0.6.0 (sampling, dflash masks,
draft config, unchanged common/markov helpers) and keeping only RL deltas
local (teacher-logits distillation, DP-group loss normalization, RL anchor
gating, reduced-vocab support, dflash bonus-anchor layout, speculators
adapters, vendored eagle3 drafter). Training glue re-anchored onto the
r0.6.0 cp_sharder/DistributedSetup flow; both v2 workers use named-field
ModelAndOptimizerState access; DSparkDraftOptions is a pydantic BaseModel;
draft co-training rejects nccl_reshard and sparse refit transports; the
vLLM extension hard-errors on an enabled-but-empty draft manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…ghtly wiring

- DraftRuntimeLossWrapper gains draft_loss_scale=cp_gradient_fanout: the
  backward multiplier dp*cp/fanout under-scaled replicated draft gradients
  by exactly cp_size for CP>1 (derivation recorded in the wrapper and the
  loop notes); no-op at cp=1.
- New unit tests: DraftRuntimeLossWrapper composition/isolation/scale,
  optimizer named-groups + scheduler-ratio assertions, worker named-field
  tripwire, and draft.* streaming through the ipc/collective/checkpoint-
  engine refit generators with preserved integer/bool buffer dtypes.
- vllm_refit_preflight covers dspark (full-vocab checkpoint tolerated) in
  addition to dflash/eagle3; green for all three against live engines.
- dflash/eagle3 nightly drivers + nightly.txt entries replayed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
The canonical no-argument invocation previously skipped dspark, leaving
the dspark-only manifest keys (markov/confidence heads, full-vocab path)
unexercised. Default is now the full DRAFTERS order; verified live: all
three methods PASS with the default invocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
load_dspark_checkpoint passed the live optimizer into draft_meta_record
even when optimizer_path was absent, so weights-only draft loads enforced
optimizer_layout against checkpoints that legitimately carry none (or a
different grouping). The expected metadata now includes the layout only
when optimizer state is actually restored; covered by a unit test on both
branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
Mirrors the dflash driver; gates anchored to the 1n8g smoke run
(draft_loss ~213 declining, active confidence head, acceptance
length 4.95-5.13 from step 1). All three co-training drafter paths
are now nightly-covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
The upstream gated/rnn heads embed and project over a single vocabulary;
with draft_vocab_size < vocab_size they would add a full-vocab bias to
draft-vocab base logits and index target-space token ids into the smaller
embedding — a guaranteed crash at the first draft forward. Only the
vanilla head has a validated split-vocab implementation, so unsupported
combinations now fail loudly at build time. Covered by unit tests for the
rejection, the vanilla split, and full-vocab head construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
The Automodel checkpoint loader accepts a weights_path pointing directly
at the weights/model subdirectory; draft_checkpoint_dir derived the
sibling from that form as weights/draft instead of the saved policy/draft,
so a valid draft resume raised FileNotFoundError. A trailing model
component is now stripped before taking the parent; both path shapes are
covered by the sibling-dir unit test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…ists

_speculative_method() returns Optional[str]; indexing the skip-map with it
failed pyrefly on this type-checked file. _expected_draft_keys now raises
an actionable error when no speculative method is configured, and the
manifest-validation guard narrows None explicitly. Runtime behavior is
unchanged for every valid configuration; pyrefly reports zero errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
The dspark/dflash draft is FSDP-sharded over dp_cp only, so TP peers hold
replicated copies; sampling anchors with the ambient torch.rand let those
replicas draw different anchor sets, drift apart, and export
TP-rank-dependent draft weights at refit. Anchor sampling now takes an
explicit generator that DSparkRuntime seeds as a pure function of
(dp_rank, global-batch index, microbatch index): identical on every TP/CP
peer of a DP slice by construction (no collective needed), distinct
across DP ranks, batches, and microbatches. Unit tests cover
ambient-RNG independence and seed distinctness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
Mirror dapo-qwen3-8b-1n8g-automodel-dspark.yaml (same DAPO base) with the
draft blocks from the corresponding grpo recipes: dflash keeps its
validated 64 anchors with confidence_loss_alpha 0, eagle3 keeps ttt_steps 3
with num_speculative_tokens 3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
Mirror grpo-qwen3-8b-math-baseline-4n8g-automodel-dspark.yaml (tp=2,
activation checkpointing inherited) with the dflash and eagle3 draft
blocks from their 1n8g siblings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
At 18432-token generations the one-shot per-position KL materializes
~2.3 GiB fp32 transients (log_softmax, softmax, elementwise kl) per TTT
step and OOMs the tp=2 co-training layout on the first train step. The
KL is independent per position, so it now computes in checkpointed
2048-token chunks (the dspark loss's chunked fp32 probability-distance
pattern), recomputing the transients per chunk in backward. Covered by
an exact-equivalence unit test (values and gradients, incl. the no-grad
eval branch); test executes in torch-capable environments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…ation tooling

Remove the design/plan scratch docs and tools/draft_verification/ (manual
scripts comparing the vendored dspark/dflash/eagle3 code against a local
speculators source checkout) — dev-time artifacts, not part of the shipped
migration. Clean up the two dangling comment references left in
eagle3_qwen3.py and vllm_backend.py, and revert unrelated .gitignore
entries picked up incidentally during the port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…ath-baseline recipes

Point the dspark/dflash/eagle3 draft co-training nightly entries at the
4n8g math-baseline recipes (the ones actually kept under
examples/configs/recipes/llm/) instead of the 1n8g smoke configs. Metric
thresholds are calibrated from completed 4n8g runs (results/grpo-qwen3-8b-math-baseline-4n8g-automodel-*), not carried over from the 1n8g scale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
Documents how the dspark/dflash/eagle3 co-training integration reuses
Automodel's speculative-decoding building blocks versus what had to be
implemented in NeMo-RL for RL-loop concerns (teacher-logit capture,
DP/CP normalization, cross-TP anchor sampling, coupled checkpointing,
weight refit).

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
… to vLLM TP workers at EP-sharded scale

Ray actors don't inherit a parent actor's post-init os.environ mutations,
and vLLM's RayExecutorV2 builds its own per-TP-rank runtime_env that
doesn't inherit the VllmGenerationWorker actor's env either -- so
NRL_DRAFT_DISABLE_MODULE_SHARING set after actor creation never reached
TP>1 vLLM workers. Thread it through configure_worker() (called before
actor creation) instead, via a new _draft_module_sharing_disable_required
helper and RayWorkerBuilder passing the worker config into
configure_worker when the implementation declares that parameter.

Separately, EAGLE3's SpecDecodeBaseProposer._maybe_share_embeddings
sharing decision is independent of the _should_share patch and needs its
own no-op override, or the drafter's embed_tokens still aliases the
target model's after refit.

Found while bringing up dflash/eagle3 draft co-training on a 30B-A3B
EP32/vLLM-TP4 layout, where these paths are first exercised at TP>1.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…itecture

Newer speculators-exported eagle3 checkpoints declare
architectures: ["Eagle3DraftModel"] (the current class name) instead of
the older "Eagle3Speculator"; build_eagle3_draft_model's allowlist only
recognized the latter and rejected the RedHatAI Qwen3-30B-A3B-Instruct-2507
eagle3 checkpoint outright.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
The TTT forward materialized the full [1, T, draft_vocab_size] logits
tensor once per TTT step before handing it to the (already chunked) KL
computation -- at T=20480 and draft_vocab_size=64000 in bf16 that's a
single ~2.44 GiB allocation per step, matching the observed CUDA OOM on
the 30B-A3B EP32 eagle3 co-training recipe. Restructure the TTT loop to
run lm_head + KL/accuracy accounting per sequence chunk
(_KL_CHUNK_TOKENS, now 512) so the full-length logits tensor is never
materialized; loss/accuracy terms accumulate as running scalar sums.
_align_for_step is folded into the per-step chunk setup and removed as
dead code.

Add test_chunked_lm_head_matches_unchunked_reference, verifying the
chunked path reproduces the unchunked loss/accuracy terms and gradients
exactly.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…le3 co-training recipes at 10240 context

MoE sibling of the Qwen3-8B math-baseline recipe: EP=32 shards the
policy's 128 experts across all GPUs (dtensor TP=1), vLLM generation runs
TP=4. Both recipes run at 10240-token context (2048 prompt + 8192
response), not this repo's usual 20480: eagle3 draft co-training against
RedHatAI/Qwen3-30B-A3B-Instruct-2507-speculator.eagle3 OOMs at 20480 on
this layout even after chunking the drafter's lm_head/KL (the base memory
budget is too tight at that length), and the no-draft baseline gets a
matching 10240 sibling so the two stay comparable at the same sequence
length.

Both also switch checkpointing to synchronous dcp.save (is_async: false):
DTensorPolicyWorkerV2's default async checkpoint path (dcp.async_save's
per-rank Gloo rendezvous) has failed intermittently on this 4-node/32-rank
layout at the first save_period boundary (see NVIDIA-NeMo#1991, already
merged, which made checkpointing.is_async a valid config field).

eagle3-10k is the first recipe pairing draft co-training with EP-sharded
policy training in this repo -- treat as unvalidated until it completes a
smoke run.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…ad of summing

DSparkRuntime/Eagle3Runtime emitted per-microbatch ratios (draft_tau,
draft_accept_rate@k, draft_full_acc@k, draft_cond_acc@k, draft_ttt_loss@k)
already divided (num/den). The training loop's default per-key metric
reduction sums every metric not on its small mean-reduction allowlist
across all microbatches and DP ranks, so these pre-divided ratios were
summed into meaningless values (e.g. a "draft_full_acc@0" of ~240 instead
of a rate in [0, 1]) -- the docstring's claim that the aggregation
"averages across microbatches and ranks" was simply wrong.

Emit raw num/den pairs instead and add finalize_draft_ratio_metrics(),
called after the per-key reduction to turn the now-correctly-summed
num/den totals back into the true token-weighted global ratio.

Found while trying to diagnose an eagle3 co-training accept-length
regression: these were the only draft metrics not already correctly
normalized (draft_loss/draft_grad_norm are unaffected -- draft_loss is an
intentionally additive per-slot quantity matching the default sum
convention, draft_grad_norm is a single value set outside the microbatch
loop).

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
Only optimizer.param_groups[0]'s lr (the policy group) was ever logged as
train/lr; the draft's own lr (a materially different value -- e.g. 1e-4
vs the policy's ~1e-6 RL lr) was never recorded anywhere, so its warmup/
schedule behavior couldn't be verified from wandb/TB. Add train/draft_lr
from param_groups[1] when draft co-training is enabled (see
DSPARK_OPTIMIZER_GROUP_NAMES = ("policy", "draft") for the group order).
draft_lr was already added to the mean-reduction allowlist in the
preceding commit.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…kpoints

Adds a second eagle3 checkpoint family alongside the existing speculators
format: SGLang SpecForge exports (e.g. lmsys/SGLang-EAGLE3-*) ship a plain
HF-style flat config (no speculators wrapper, so load_draft_hf_config
previously rejected them outright), publish their single decoder layer
under the "midlayer." key prefix instead of the vendored model's
"layers.0.", and ship no embed_tokens of their own (the drafter is meant
to share the target model's embedding table).

- _adapt_native_flat_eagle3_config: loads via AutoConfig (draft fields are
  already flat/top-level, unlike speculators' nested transformer_layer_config),
  fills target_layer_ids from the same vLLM-default formula used when a
  speculators checkpoint pins none, and defaults norm_before_residual to
  false (classic EAGLE convention) since these checkpoints don't record it
  -- unverified against the SpecForge training source, flagged inline.
- _load_native_eagle3_weights: remaps "midlayer." -> "layers.0." in the
  checkpoint's state dict, then copies (not aliases -- the draft and
  policy are fully_shard-ed on different device meshes, so true weight
  tying isn't possible) the policy's live embed_tokens values into the
  drafter's own embedding at build time, matching how the megatron eagle
  path backfills a checkpoint's missing lm_head from the policy.
- build_eagle3_draft_model gains an optional policy_model param (only
  required for this checkpoint family) and NATIVE_FLAT_EAGLE3_ARCHS joins
  the accepted architectures allowlist.

Verified with a 1n8g smoke run against
lmsys/SGLang-EAGLE3-Qwen3-30B-A3B-Instruct-2507-SpecForge-Nex: draft_loss
finite and decreasing, and vLLM actually ran eagle3 speculative decoding
with the loaded drafter (spec_acceptance_length=2.74, per-position
acceptance decaying 0.71/0.57/0.46 -- the expected shape for a real,
non-garbage drafter, not evidence of a broken remap/copy).

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…th the lmsys SpecForge drafter

Apples-to-apples sibling of
grpo-qwen3-30ba3b-2507-instruct-math-baseline-4n8g-automodel-eagle3-10k.yaml:
only the draft checkpoint changes (RedHatAI/*-speculator.eagle3 ->
lmsys/SGLang-EAGLE3-Qwen3-30B-A3B-Instruct-2507-SpecForge-Nex), everything
else identical, to isolate whether an accept_length regression seen with
the RedHatAI checkpoint is checkpoint-specific or a general property of
this repo's draft co-training path. A controlled run confirmed the
former: same policy/data/EP32 layout, same validation/avg_length
trajectory in both runs, but spec_acceptance_length stays flat around
2.5-2.7 with the lmsys drafter where the RedHatAI one collapses to ~1.8
and stays there (see results/ealge3-30b-a3b-failure.md, not committed --
local investigation notes).

Needs a LOCAL copy of the lmsys checkpoint: the published config.json
ships max_position_embeddings=2048 (its SpecForge training length), which
makes vLLM clamp the drafter RoPE and collapse acceptance past position
2048; this points at a local copy with that field raised to 40960
(weights untouched). The checkpoint also ships no embed_tokens of its own
and uses the "midlayer." key prefix instead of this repo's "layers.0." --
both handled by the native-flat eagle3 loading path this adds support
for.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…3DraftModel

Replaces the fully hand-vendored eagle3_qwen3.py TTT drafter with a thin
wrapper (eagle3_llama.py) around nemo_automodel.components.speculative
.eagle.draft_llama.LlamaEagle3DraftModel: the attention/decoder-layer/
embedding/fc/lm_head implementation is imported, not reimplemented. Only
the RL-specific deltas that don't fit automodel's own Eagle3TrainerModule
API stay local: per-step loss num/den (vs its pre-averaged scalar loss,
so NeMo-RL's runtime can apply its own DP-reduced, microbatch-slot
normalization) and chunked lm_head/KL along the sequence (avoids
materializing the full [1, T, draft_vocab] logits tensor).

Motivation: automodel's Eagle3LlamaAttention is a materially better TTT
attention than what was hand-rolled here -- step 0's causal block runs
once, and every later TTT step attends only to its own previous-step K/V
at the SAME position via an O(T) einsum "diagonal" (a shared cache_hidden
list), instead of re-attending against the whole growing KV cache (an
O(T^2 * step) matmul per step) the way the previous implementation did.

Four real bugs found and fixed while bringing this up to full 4n8g scale:
- automodel's Eagle3LlamaDecoderLayer hardcodes the residual convention
  equivalent to norm_before_residual=False with no toggle at all;
  speculators-format checkpoints (e.g. RedHatAI's) declare
  norm_before_residual=true explicitly. Added
  _Eagle3LlamaDecoderLayerWithResidualToggle, swapped in for
  model.layers[0] right after construction (same param names/shapes, so
  checkpoints round-trip unchanged).
- vLLM's refit manifest check expects the trainer's draft.* state dict
  keys flat (matching vLLM's own serving-side drafter's
  name.removeprefix("model.") convention), but automodel's model wraps
  everything under self.model -- added _draft_refit_export_name to strip
  it at export time (dtensor_policy_worker_v2.py).
- FSDP2's fully_shard unshard/reshard hooks fire on nn.Module.__call__;
  driving embed_input_ids/project_hidden_states/compute_logits as bare
  method calls (bypassing the top-level call) left those submodules'
  params sharded as DTensors mid-forward. The whole TTT loop now lives in
  Eagle3DraftModel.forward() so callers always go through
  self.draft_model(...).
- LlamaRotaryEmbedding sizes its cos/sin cache from config.torch_dtype,
  defaulting to fp32 when unset; that promotes q/k to fp32 through RoPE
  while the cached V (untouched by RoPE) stays in the training dtype,
  mismatching eager attention's attn_probs @ v0. Set explicitly in
  build_eagle3_draft_model.

Also switches attn_implementation to flash_attention_2 (eager was kept
initially for a lower-risk staged rollout): eager attention materializes
a dense [B, H, T, T] fp32 softmax intermediate, which is a ~12 GiB single
allocation at H=32 heads and a 10k-token packed row -- OOMs at full 4n8g
training scale. FlashAttention-2's varlen kernel needs cu_seqlens/
max_seqlen instead of the dense mask; Eagle3Runtime.compute_loss now also
builds seq_lens (each packed microbatch sample's fixed-width slot,
declared as one varlen "document" -- safe despite that slot's internal
trailing padding, since causal masking already keeps real tokens from
attending to it and loss_mask excludes padding positions from the loss).

Checkpoint loading is unified into one _load_eagle3_weights covering both
checkpoint families (speculators' "layers.0." and SGLang SpecForge's
"midlayer." prefixes), remapped onto automodel's "model."-prefixed layout.

Validated: unit suite (79 tests, +2 new -- a norm_before_residual toggle
test and a CUDA+flash-attn-gated flash_attention_2-vs-eager parity test on
packed/internally-padded data) all pass; a 1n8g smoke run's per-step
metrics (draft_loss, spec_acceptance_length, per-position accept rate)
matched the pre-migration implementation almost exactly for the first two
steps before RNG-trajectory divergence (a different attention kernel
consumes vLLM's speculative-sampling random draws differently); a full
4n8g/EP32/10240-context run (lmsys SpecForge drafter) completed a full
4h TIMEOUT window with curves matching the pre-migration run within noise
(spec_acceptance_length, draft_loss, draft_grad_norm, response-length
collapse trajectory).

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…model's LlamaEagle3DraftModel

Rewrites section 2.5 (and touches 1/3.5/4/5) of the co-training design
doc to justify, post-migration, why eagle3_llama.py/integration.py still
carry local code even though the network itself is now subclassed from
Automodel: no third-party checkpoint loading, CP-only vs RL's DP/
microbatch-slot loss normalization, incompatible packing layout, no
chunking for long RL rollouts, plus two real bugs found along the way
(norm_before_residual hardcoded False, RoPE fp32-dtype mismatch) and the
vLLM refit key-prefix fix.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
finalize_draft_ratio_metrics was imported at module top level, forcing
every grpo.py/grpo_sync.py user (including Megatron-only, non-draft
runs) to have nemo_automodel importable in the driver's venv just to
import the training loop. Defer it to a local import at the call site,
matching upstream main (which has no automodel coupling in these
files at all) and no-op silently when nemo_automodel isn't installed,
since the actual draft model construction and training already happen
inside Ray actors with their own dedicated venvs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
docs/design-docs/draft-cotraining-dtensor-v2.md is a personal Chinese
design note, not meant to ship in the PR; keep it locally as untracked.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
…g recipe names

Rename the tracked draft co-training recipes and their nightly drivers to
follow this repo's <algo>-<model>-<nodes>n<gpus>g-<strategy>[-modifiers]
convention instead of carrying descriptive words like "math", "baseline",
"lmsys", and "10k" in the filename:

- grpo-qwen3-8b-math-baseline-4n8g-automodel{,-dspark,-dflash,-eagle3}.yaml/.sh
  -> grpo-qwen3-8b-4n8g-automodel{,-dspark,-dflash,-eagle3}.yaml/.sh
- grpo-qwen3-30ba3b-2507-instruct-math-baseline-4n8g-automodel-10k.yaml
  -> grpo-qwen3-30ba3b-2507-instruct-4n8g-automodel.yaml
- grpo-qwen3-30ba3b-2507-instruct-math-baseline-4n8g-automodel-eagle3-lmsys-10k.yaml
  -> grpo-qwen3-30ba3b-2507-instruct-4n8g-automodel-eagle3.yaml (now
  self-contained: points straight at the grpo-qwen3-8b-math-baseline
  exemplar chain and swaps in the uploaded
  yuekai/SGLang-EAGLE3-Qwen3-30B-A3B-Instruct-2507-SpecForge-Nex checkpoint,
  a config.json-only fork of lmsys/SGLang-EAGLE3-Qwen3-30B-A3B-Instruct-2507-
  SpecForge-Nex with max_position_embeddings raised 2048 -> 40960)

Updates every defaults:/checkpoint_dir/wandb.name reference and
tests/test_suites/nightly.txt to match. Untracked sibling recipes (still
under review) are left as-is.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
grpo-qwen3-30ba3b-2507-instruct-math-baseline-4n8g-automodel-eagle3-10k.yaml
(RedHatAI/Qwen3-30B-A3B-Instruct-2507-speculator.eagle3 drafter) is not
ready to ship in this PR; keep it locally as untracked.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.com>
@yuekaizhang
yuekaizhang requested review from a team as code owners September 2, 2026 03:12
@copy-pr-bot

copy-pr-bot Bot commented Sep 2, 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.

@yuekaizhang yuekaizhang left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Merge conflict note: gh pr view reports mergeable=CONFLICTING, mergeStateStatus=DIRTY against main — please rebase and resolve conflicts.

Reviewed by a 5-agent team (rl-expert, comment-reviewer, test-agent, bug-finder, design-reviewer) plus an adversarial devil's-advocate pass. This is a well-guarded feature port — no functional bugs found, extensive setup-time asserts/raises, strong existing unit-test coverage for the tricky numerics. Affirmations: grpo.py/grpo_sync.py have zero dspark/dflash/eagle3 string references (clean _DraftRuntimeBase polymorphic seam, single dispatch factory in dtensor_policy_worker_v2.py); dspark/dflash unification doesn't leak per-variant conditionals into shared runtime code; common.py's header comment cleanly documents the vendored-vs-local boundary against upstream Automodel.

1 critical item (this PR fails its own configs-minimize-check CI hook on 2 recipes), 9 suggestions, 2 informational notes — see inline comments.

Generated by Claude Code

draft:
enabled: true
model_name: RedHatAI/Qwen3-8B-speculator.eagle3
algo: "eagle3"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

grpo-qwen3-8b-4n8g-automodel-eagle3.yaml:20

1 action item.

PR-introduced. algo: "eagle3" is byte-identical to DEFAULT_DRAFT_ALGO (nemo_rl/models/policy/__init__.py:532). Reproduced directly: uv run tools/config_cli.py minimize-check examples/configs/recipes/llm/grpo-qwen3-8b-4n8g-automodel-eagle3.yaml reports "is not minimized" — this PR fails its own configs-minimize-check pre-commit hook (same for the 30B sibling recipe, commented separately).

Root cause: the default is scattered via .get("algo", DEFAULT_DRAFT_ALGO) across 3 call sites (setup.py:764, lm_policy.py:138, dtensor_policy_worker_v2.py:391) instead of one schema default, so an explicit algo: "eagle3" line reads as meaningful to a human but is silently treated as redundant by the minimizer.

AI-1

Action: drop this line (it's already the default), or better, make the default an explicit schema field so .get(key, MODULE_CONSTANT) doesn't need to be repeated at 3 call sites.

Suggested change
algo: "eagle3"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed in a18b69d

draft:
enabled: true
model_name: yuekai/SGLang-EAGLE3-Qwen3-30B-A3B-Instruct-2507-SpecForge-Nex
algo: "eagle3"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

grpo-qwen3-30ba3b-2507-instruct-4n8g-automodel-eagle3.yaml:101

1 action item.

PR-introduced. Same issue as the Qwen3-8B eagle3 recipe (see that comment): algo: "eagle3" duplicates DEFAULT_DRAFT_ALGO and fails configs-minimize-check here too — verified by running the tool directly on this file.

AI-1

Action: drop this line, or fix the root cause (see the other recipe's comment).

Suggested change
algo: "eagle3"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed in a18b69d

backfills a checkpoint's missing lm_head from the policy) and lets the
copy train independently thereafter, governed by ``train_embed_and_head``
like the rest of the embedding/head.
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

integration.py:530

1 action item.

PR-introduced. Redundant in-function import os — already imported at module top (line 25).

AI-1

Suggested change
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed in a18b69d

# Loss-position weighting (loss_decay_gamma) counts proposals from here.
first_supervised_slot: int = 0


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

common.py:81

1 action item.

PR-introduced. build_anchor_candidate_mask is a cross-file interface (imported by draft_qwen3.py) with no docstring.

AI-1

Action: add a Google-style docstring, e.g.:

Suggested change
def build_anchor_candidate_mask(
*,
seq_len: int,
loss_mask: torch.Tensor,
doc_remaining: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Mask of anchor positions whose first predicted token is supervised.
Args:
seq_len: source sequence length.
loss_mask: [batch_size, seq_len] per-token supervision mask.
doc_remaining: [batch_size, seq_len] tokens remaining in the current
packed document, or None when unpacked.
Returns:
[batch_size, seq_len - 1] boolean mask; True at anchor position p
when token p + 1 is supervised (and, when packing, stays inside the
anchor's document).
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed in a18b69d

valid = valid & (doc_remaining[:, :num_candidates] >= 1)
return valid


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

common.py:106

1 action item.

PR-introduced. sample_anchor_positions — same gap, also cross-file (used by draft_qwen3.py).

AI-1

Action: add a Google-style docstring, e.g.:

Suggested change
def sample_anchor_positions(
*,
seq_len: int,
loss_mask: torch.Tensor,
num_anchors: int,
device: torch.device,
doc_remaining: Optional[torch.Tensor] = None,
generator: Optional[torch.Generator] = None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Sample up to num_anchors valid anchor positions per sample.
Args:
seq_len: source sequence length.
loss_mask: [batch_size, seq_len] per-token supervision mask.
num_anchors: maximum anchors to sample per sample.
device: device to allocate outputs on.
doc_remaining: packed-document remaining-token counts, or None.
generator: optional RNG for a reproducible draw (must be seeded
identically across TP peers sharing the same draft replica).
Returns:
(anchors, keep_mask): anchors is [batch_size, num_anchors] sorted
anchor token positions (zero-padded past the valid count); keep_mask
marks which of those are real vs. padding.
"""

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed in a18b69d

)
else:
raise ValueError(f"Unknown draft algo {algo!r} for checkpoint metadata.")
return record

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

integration.py:1282

No action needed — low-severity design suggestion, not a blocker.

Context — no action. This file is 1431 lines with 4 near-independent clusters (config adaptation, model building, runtime/loss, checkpoint I/O). The checkpoint I/O cluster from here (draft_checkpoint_dir) through EOF (~150 lines) has zero references to the runtime/loss classes above it (_DraftRuntimeBase/DSparkRuntime/Eagle3Runtime/DSparkHiddenCapture) — verified by grep. A future engineer debugging checkpoint-metadata issues has to scroll past ~1100 lines of unrelated config/runtime code to reach it.

One option — of several: split this cluster into its own checkpoint.py module; the near-zero cross-references make it a low-risk, mechanical extraction.

# megatron eagle3 trainer streams a PARTIAL set (no embed_tokens) and
# relies on the drafter sharing the target's embedding.
draft_full_refit = bool(self.cfg.get("_draft_full_refit"))
if (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

vllm_worker.py:615-621

1 action item.

PR-introduced. _draft_module_sharing_disable_required(config) is defined in this same file, but this block re-implements the identical method in ("dspark","dflash") or (method=="eagle3" and draft_full_refit) predicate inline instead of calling it. A future 4th co-trained method (or a policy change to when eagle3 counts as full-stream) needs updating in both places by hand.

AI-1

Action: call the existing helper here instead of re-deriving the predicate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed in a18b69d


import pytest

from nemo_rl.models.automodel.draft.integration import (

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

test_draft_config_adapters.py:23

1 action item.

PR-introduced. This file (and 7 siblings: test_dspark_checkpoint_meta.py, test_draft_ratio_metrics.py, test_draft_refit_transports.py, test_draft_loss_decay.py, test_draft_vocab_maps.py, test_dspark_anchor_sampling.py, test_eagle3_trainer_grads.py) import from nemo_rl.models.automodel.draft.*, which unconditionally imports nemo_automodel at module scope — but none of the 8 have the try: import nemo_automodel / pytest.skip(allow_module_level=True) guard or the @pytest.mark.automodel marker that every pre-existing sibling test file in this directory has. Verified against conftest.py's marker-based include/exclude logic and the CI Docker build (single shared venv with all extras, so this won't crash any shard today) — but these 8 files are excluded from the --automodel-only shard and instead run unmarked in the generic Models shard, inconsistent with the established convention and fragile if a future change makes nemo_automodel non-default there.

AI-1

Action: add the skip guard + @pytest.mark.automodel (or module-level pytestmark) to all 8 files, matching test_automodel_checkpoint.py's pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed in a18b69d

Comment thread nemo_rl/distributed/worker_groups.py Outdated
bundle_indices=bundle_indices,
num_gpus_per_node=num_gpus_per_node,
)
if "config" in inspect.signature(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

worker_groups.py:204-210

No action needed — informational scoping note.

Context — no action. This 185-line diff (per-pg_idx IsolatedWorkerInitializer pooling, batched address allocation, runtime_env_overrides passthrough) has zero draft/eagle3/dspark references (verified by grep) — general Ray worker-group infra bundled into this feature-named PR. Not a defect, just flagging it widens this PR's review surface/blast radius and makes a future revert of the draft feature alone harder; consider a separate PR next time for this class of change.

self.capture.clear()
return loss, metrics

@staticmethod

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

integration.py:951-977

No action needed — minor nit, not a blocker.

Context — no action. compute_dspark_loss's confidence-head diagnostic terms (confidence_abs_error_num, confidence_bias_num, confidence_cumprod_bias_num, confidence_diag_den) are computed whenever a confidence head is enabled, but DSparkRuntime._terms_to_metrics never forwards them into the metrics dict — verified genuinely dropped via repo-wide grep (no consumer anywhere). Not a correctness bug, just wasted compute and no calibration visibility for confidence-head tuning. Worth a follow-up to either wire these into metrics or drop the computation.

…t co-training

Fixes surfaced by a review-pr-team pass on PR NVIDIA-NeMo#3960:

- Drop the redundant `algo: "eagle3"` line from the two eagle3 recipes:
  it's byte-identical to DEFAULT_DRAFT_ALGO, so the repo's own
  configs-minimize-check pre-commit hook flagged both as not minimized.
- Remove a dead in-function `import os` in integration.py (already
  imported at module scope).
- Add missing docstrings/type annotations: build_anchor_candidate_mask,
  sample_anchor_positions (common.py); compute_dspark_loss's return type
  and normalization contract (loss.py); build_markov_head and
  VanillaMarkov.__init__ (markov_head.py).
- Narrow vllm_backend.py's _draft_owns_speculator to
  `except AssertionError`, matching the pinned vLLM's actual
  get_pp_group() failure mode instead of a bare except Exception.
- Call the existing _draft_module_sharing_disable_required() helper in
  vllm_worker.py instead of re-deriving the same predicate inline.
- Add the missing nemo_automodel skip guard + @pytest.mark.automodel to
  8 new tests/unit/models/automodel/test_draft_*.py files so they match
  every sibling test file's convention and land in the automodel-marked
  shard instead of the generic Models shard.

Also includes the repo's own ruff/ruff-format auto-fixes (import
ordering, line wrapping) picked up while running the linter locally.

Signed-off-by: Yuekai Zhang <zhangyuekai@foxmail.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