feat(automodel): speculative decoding draft co-training - #3960
feat(automodel): speculative decoding draft co-training#3960yuekaizhang wants to merge 32 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
Merge conflict note:
gh pr viewreportsmergeable=CONFLICTING,mergeStateStatus=DIRTYagainstmain— 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" |
There was a problem hiding this comment.
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.
| algo: "eagle3" |
| draft: | ||
| enabled: true | ||
| model_name: yuekai/SGLang-EAGLE3-Qwen3-30B-A3B-Instruct-2507-SpecForge-Nex | ||
| algo: "eagle3" |
There was a problem hiding this comment.
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).
| algo: "eagle3" |
| 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. | ||
| """ |
| # Loss-position weighting (loss_decay_gamma) counts proposals from here. | ||
| first_supervised_slot: int = 0 | ||
|
|
||
|
|
There was a problem hiding this comment.
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.:
| 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). | |
| """ |
| valid = valid & (doc_remaining[:, :num_candidates] >= 1) | ||
| return valid | ||
|
|
||
|
|
There was a problem hiding this comment.
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.:
| 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. | |
| """ |
| ) | ||
| else: | ||
| raise ValueError(f"Unknown draft algo {algo!r} for checkpoint metadata.") | ||
| return record |
There was a problem hiding this comment.
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 ( |
There was a problem hiding this comment.
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.
|
|
||
| import pytest | ||
|
|
||
| from nemo_rl.models.automodel.draft.integration import ( |
There was a problem hiding this comment.
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.
| bundle_indices=bundle_indices, | ||
| num_gpus_per_node=num_gpus_per_node, | ||
| ) | ||
| if "config" in inspect.signature( |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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>
Summary
Ports speculative-decoding draft co-training (DSpark and DFlash block
drafters, and an EAGLE3 TTT drafter) from
RL_dsparkonto NeMo-RL'sautomodel (DTensor v2) backend, so a draft model can be trained jointly
with the policy and refit into vLLM (
draft.*weight stream) forspeculative-decoding generation.
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.
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_embeddingschanged, 2048 -> 40960, to stop vLLMclamping the drafter's RoPE).
automodel backend.