feat: add Domino drafter training backend - #17
Conversation
Port the Domino training path from NeMo AutoModel (dflash/domino_core.py) into the SpeCo overlay as a DFlash variant, mirroring how DSpark extends DFlash. Domino adds a causal correction head on top of the DFlash parallel block backbone: a single-layer GRU encodes a causal state from each block's previous tokens, and a low-rank embed_proj over [backbone hidden | GRU state] emits a full-vocab logit delta added to the parallel base logits. Training jointly supervises the refined (final) and backbone-only (base) logits with a base-anchor curriculum loss = (1-lambda)*final + lambda*base, lambda decaying to 0. The shifted-label alignment (target x[a+1:a+1+block], prev [x[a], labels[:-1]], every position supervised) reuses the DSpark alignment, which equals AutoModel's shift_label Domino path. DominoTrainerBackend subclasses DFlashTrainerBackend; only build_model and the training forward differ. Wired through worker dispatch, base_trainer block-drafter gates, auto config routing, oldlogprob aux layers, and config keys. Domino is training-only: its GRU correction has no stock vLLM proposer, so the vLLM config builder raises and directs serving to DFLASH (the trained backbone). AI assistance was used for this change. Signed-off-by: khazic <khazzz1c@gmail.com>
AI assistance was used for this change. Signed-off-by: khazic <khazzz1c@gmail.com>
…s clone AI assistance was used for this change. Signed-off-by: khazic <khazzz1c@gmail.com>
AI assistance was used for this change. Signed-off-by: khazic <khazzz1c@gmail.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the Domino drafter training backend, a training-only speculative decoding algorithm that extends the DFlash backbone with a causal GRU correction head and a dual-logit base-anchor curriculum. The changes include the core backend implementation, configuration files, integration into the training pipeline, and comprehensive tests. The review feedback highlights three key improvement opportunities in domino_trainer_backend.py: adding a guard to prevent a TypeError crash when spec_model_path is None, registering the _forward_count step counter as a PyTorch buffer to ensure it is correctly saved and restored during checkpointing, and retrieving its scalar value safely using .item().
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def build_model(self): | ||
| target_model_path = self.config.model.path | ||
| spec_model_path = self.config.rollout.drafter.model_path | ||
| config_path = os.path.join(spec_model_path, "config.json") |
There was a problem hiding this comment.
If spec_model_path is None (which is common when starting training from scratch without pre-existing drafter weights), os.path.join will raise a TypeError and crash the initialization. Adding a guard ensures robust fallback behavior.
| config_path = os.path.join(spec_model_path, "config.json") | |
| config_path = os.path.join(spec_model_path, "config.json") if spec_model_path else None |
| self.pure_draft_prefix_len = int(pure_draft_prefix_len) | ||
| self.lambda_base_start = float(lambda_base_start) | ||
| self.lambda_base_decay_steps = int(lambda_base_decay_steps) | ||
| self._forward_count = 0 |
There was a problem hiding this comment.
The step counter _forward_count is currently defined as a plain Python integer. This means it will not be saved in the model's state dict and will be reset to 0 when resuming training from a checkpoint, which resets the curriculum schedule (lambda_base) and can cause training instability. Registering it as a buffer ensures it is correctly saved and restored.
| self._forward_count = 0 | |
| self.register_buffer("_forward_count", torch.tensor(0, dtype=torch.long)) |
| return int(self.pure_draft_prefix_len) | ||
|
|
||
| def _current_lambda_base(self) -> float: | ||
| return get_lambda_base(self._forward_count, self.lambda_base_decay_steps, self.lambda_base_start) |
There was a problem hiding this comment.
Since _forward_count is registered as a buffer tensor, we should retrieve its scalar value using .item() and cast it to an integer before passing it to get_lambda_base to avoid device or type conversion issues.
| return get_lambda_base(self._forward_count, self.lambda_base_decay_steps, self.lambda_base_start) | |
| return get_lambda_base(int(self._forward_count.item()), self.lambda_base_decay_steps, self.lambda_base_start) |
os.path.join crashes with a TypeError when rollout.drafter.model_path is None (training a Domino drafter from scratch with no pre-existing weights). Guard config_path so it falls back to None and the existing checks route to the from-scratch fallback config path instead of crashing. Signed-off-by: khazic <khazzz1c@gmail.com>
|
Thanks for the review. Addressed the findings:
Drafter resume reloads the HF drafter weights with a fresh optimizer and schedule, so the per-run counter restarting at 0 is consistent with the rest of the training state, not a desync. Keeping it a plain |
test_domino_lambda_base_schedule imported get_lambda_base from domino_trainer_backend, whose module subclasses the torch-based DFlash backend at import time, so the torch-free CPU unit-test job hit ModuleNotFoundError: No module named 'torch'. Guard with pytest.importorskip like every other test in the file. Signed-off-by: khazic <khazzz1c@gmail.com>
…drafter # Conflicts: # verl_speco/config/speco_trainer.yaml
…the serve rationale DOMINO is not an engine-level speculative algorithm: engines expose Domino as "dflash" and enable the causal correction head (prefix_gru + embed_proj) from the checkpoint dflash_config.projector_type="domino". A speculative_algorithm=DOMINO would leak into SGLang ServerArgs and fail cryptically, so mirror the vLLM guardrail and raise at the SGLang ServerArgs builder. Also correct both guardrail messages. The previous wording claimed Domino cannot be served and that its correction head is inert, which is wrong: serving with DFLASH keeps the Domino head active on engines that support it (vllm-project/vllm#48241, sgl-project/sglang#31328). Signed-off-by: khazic <khazzz1c@gmail.com>
…after # Conflicts: # verl_speco/config/speco_base.yaml # verl_speco/workers/speco_worker.py
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces the Domino drafter training backend, a DFlash variant that incorporates a causal GRU correction head and a dual-logit base-anchor curriculum loss. The implementation includes new model architectures, configuration schemas, integration with runtime engines, and smoke/contract tests. The review feedback highlights three key issues: a potential shape mismatch crash when loading pretrained models with conflicting context layer configurations, an uncalculated top-5 accuracy metric that remains at zero, and potential TypeErrors when optional configuration parameters are explicitly set to null in the YAML config.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def _normalize_dflash_config(self, drafter_config, target_hf_config, normalized_state, spec_model_path): | ||
| training_cfg = self.config.rollout.drafter.training | ||
| if training_cfg.get("domino_num_target_layers", None) is not None: | ||
| if getattr(drafter_config, "num_context_layers", None) is None: | ||
| drafter_config.num_context_layers = int(training_cfg["domino_num_target_layers"]) | ||
| return super()._normalize_dflash_config(drafter_config, target_hf_config, normalized_state, spec_model_path) |
There was a problem hiding this comment.
If a pretrained Domino model is loaded from spec_model_path, its num_context_layers is already defined in the checkpoint's config.json. If the user configures a different domino_num_target_layers in rollout.drafter.training, _normalize_dflash_config will keep the pretrained model's num_context_layers (due to the is None guard), but resolve_oldlogprob_aux_layer_ids will prioritize domino_num_target_layers from the training config. This leads to a mismatch between the number of collected context layers and the number of layers the model's projection weights expect, causing a shape mismatch crash during the forward pass. We should raise a ValueError if they mismatch.
| def _normalize_dflash_config(self, drafter_config, target_hf_config, normalized_state, spec_model_path): | |
| training_cfg = self.config.rollout.drafter.training | |
| if training_cfg.get("domino_num_target_layers", None) is not None: | |
| if getattr(drafter_config, "num_context_layers", None) is None: | |
| drafter_config.num_context_layers = int(training_cfg["domino_num_target_layers"]) | |
| return super()._normalize_dflash_config(drafter_config, target_hf_config, normalized_state, spec_model_path) | |
| def _normalize_dflash_config(self, drafter_config, target_hf_config, normalized_state, spec_model_path): | |
| training_cfg = self.config.rollout.drafter.training | |
| if training_cfg.get("domino_num_target_layers", None) is not None: | |
| num_target_layers = int(training_cfg["domino_num_target_layers"]) | |
| if getattr(drafter_config, "num_context_layers", None) is None: | |
| drafter_config.num_context_layers = num_target_layers | |
| elif drafter_config.num_context_layers != num_target_layers: | |
| raise ValueError( | |
| f"Configured domino_num_target_layers ({num_target_layers}) does not match " | |
| f"the loaded model's num_context_layers ({drafter_config.num_context_layers})." | |
| ) | |
| return super()._normalize_dflash_config(drafter_config, target_hf_config, normalized_state, spec_model_path) |
There was a problem hiding this comment.
The divergence is real, but it is not introduced here and not Domino-specific: dspark_trainer_backend.py:393 is the same code with the same is None guard, and _dflash_num_context_layers gives dspark_num_target_layers the same priority over the checkpoint value. Domino mirrors DSpark verbatim by design.
Adding the raise only to Domino would make the two backends behave differently on the same misconfiguration. Worth fixing, but as one change covering DSpark and Domino together rather than inside this PR.
| top1_correct = torch.zeros((), dtype=torch.float32, device=device) | ||
| top5_correct = torch.zeros((), dtype=torch.float32, device=device) | ||
| quality_token_count = torch.zeros((), dtype=torch.float32, device=device) | ||
| if active_final_pred is not None and active_targets.numel() > 0: | ||
| active_correct = active_final_pred.eq(active_targets) | ||
| correct[active_mask] = active_correct | ||
| base_correct[active_mask] = active_base_pred.eq(active_targets) | ||
| top1_correct = active_correct.float().sum() | ||
| quality_token_count = active_targets.new_tensor(float(active_targets.numel()), dtype=torch.float32) |
There was a problem hiding this comment.
The top5_correct tensor is initialized to 0.0 but is never updated with the actual top-5 predictions. This causes the top5_acc metric reported in get_training_metrics to always be 0.0. We should compute the top-5 accuracy by checking if the target token is within the top-5 predicted logits for both suffix and non-suffix positions.
| top1_correct = torch.zeros((), dtype=torch.float32, device=device) | |
| top5_correct = torch.zeros((), dtype=torch.float32, device=device) | |
| quality_token_count = torch.zeros((), dtype=torch.float32, device=device) | |
| if active_final_pred is not None and active_targets.numel() > 0: | |
| active_correct = active_final_pred.eq(active_targets) | |
| correct[active_mask] = active_correct | |
| base_correct[active_mask] = active_base_pred.eq(active_targets) | |
| top1_correct = active_correct.float().sum() | |
| quality_token_count = active_targets.new_tensor(float(active_targets.numel()), dtype=torch.float32) | |
| top1_correct = torch.zeros((), dtype=torch.float32, device=device) | |
| top5_correct = torch.zeros((), dtype=torch.float32, device=device) | |
| quality_token_count = torch.zeros((), dtype=torch.float32, device=device) | |
| if active_final_pred is not None and active_targets.numel() > 0: | |
| active_correct = active_final_pred.eq(active_targets) | |
| correct[active_mask] = active_correct | |
| base_correct[active_mask] = active_base_pred.eq(active_targets) | |
| top1_correct = active_correct.float().sum() | |
| quality_token_count = active_targets.new_tensor(float(active_targets.numel()), dtype=torch.float32) | |
| top5_correct_mask = torch.zeros_like(active_correct, dtype=torch.bool) | |
| if active_final_logits is not None and suffix_mask.any(): | |
| top5_final = active_final_logits.topk(5, dim=-1).indices | |
| top5_correct_mask[suffix_mask] = top5_final.eq(active_targets[suffix_mask].unsqueeze(-1)).any(dim=-1) | |
| non_suffix_mask = ~suffix_mask | |
| if non_suffix_mask.any(): | |
| top5_base = base_logits[non_suffix_mask].topk(5, dim=-1).indices | |
| top5_correct_mask[non_suffix_mask] = top5_base.eq(active_targets[non_suffix_mask].unsqueeze(-1)).any(dim=-1) | |
| top5_correct = top5_correct_mask.float().sum() |
There was a problem hiding this comment.
Good catch, fixed in e2ea1a4. DFlash and DSpark both reduce this counter and Domino did not, so top5_acc (derived in base_trainer) was pinned at 0.
I took a different shape than the suggestion: mirrored the argmax idiom already in this block (topk over base_logits, suffix rows overwritten from active_final_logits) so no second [num_active, vocab] tensor is materialized, which this forward deliberately avoids. Also guarded with topk = min(5, vocab) like DSpark, since the suggestion hardcodes topk(5) and would crash on the small-vocab configs the contract tests use. Added a CPU regression test that fails without the fix (top5=0 vs top1=2).
| return DominoTrainingModel( | ||
| draft_model=draft_model, | ||
| block_size=int(training_cfg.get("domino_block_size", getattr(drafter_config, "block_size", 16))), | ||
| num_anchors=int(training_cfg.get("domino_num_anchors", getattr(drafter_config, "num_anchors", 512))), | ||
| loss_decay_gamma=float(training_cfg.get("domino_loss_decay_gamma", getattr(drafter_config, "loss_decay_gamma", 7.0))), | ||
| pure_draft_prefix_len=int(training_cfg.get("domino_pure_draft_prefix_len", getattr(drafter_config, "pure_draft_prefix_len", 1))), | ||
| lambda_base_start=float(training_cfg.get("domino_lambda_base_start", getattr(drafter_config, "lambda_base_start", 1.0))), | ||
| lambda_base_decay_steps=int(training_cfg.get("domino_lambda_base_decay_steps", getattr(drafter_config, "lambda_base_decay_steps", 2000))), |
There was a problem hiding this comment.
If any of the domino_* parameters are explicitly set to null (None) in the YAML config, training_cfg.get(...) will return None instead of the default value. Passing None to int() or float() will raise a TypeError. We should use a helper function to safely fall back to the default value when the configured value is None.
| return DominoTrainingModel( | |
| draft_model=draft_model, | |
| block_size=int(training_cfg.get("domino_block_size", getattr(drafter_config, "block_size", 16))), | |
| num_anchors=int(training_cfg.get("domino_num_anchors", getattr(drafter_config, "num_anchors", 512))), | |
| loss_decay_gamma=float(training_cfg.get("domino_loss_decay_gamma", getattr(drafter_config, "loss_decay_gamma", 7.0))), | |
| pure_draft_prefix_len=int(training_cfg.get("domino_pure_draft_prefix_len", getattr(drafter_config, "pure_draft_prefix_len", 1))), | |
| lambda_base_start=float(training_cfg.get("domino_lambda_base_start", getattr(drafter_config, "lambda_base_start", 1.0))), | |
| lambda_base_decay_steps=int(training_cfg.get("domino_lambda_base_decay_steps", getattr(drafter_config, "lambda_base_decay_steps", 2000))), | |
| def get_val(key, default): | |
| val = training_cfg.get(key) | |
| return val if val is not None else default | |
| return DominoTrainingModel( | |
| draft_model=draft_model, | |
| block_size=int(get_val("domino_block_size", getattr(drafter_config, "block_size", 16))), | |
| num_anchors=int(get_val("domino_num_anchors", getattr(drafter_config, "num_anchors", 512))), | |
| loss_decay_gamma=float(get_val("domino_loss_decay_gamma", getattr(drafter_config, "loss_decay_gamma", 7.0))), | |
| pure_draft_prefix_len=int(get_val("domino_pure_draft_prefix_len", getattr(drafter_config, "pure_draft_prefix_len", 1))), | |
| lambda_base_start=float(get_val("domino_lambda_base_start", getattr(drafter_config, "lambda_base_start", 1.0))), | |
| lambda_base_decay_steps=int(get_val("domino_lambda_base_decay_steps", getattr(drafter_config, "lambda_base_decay_steps", 2000))), | |
| ), drafter_config |
There was a problem hiding this comment.
Same reasoning: int(training_cfg.get(key, default)) is the pattern DSpark (435-442, 477-478) and DFlash (692-698) already use, and the shipped speco_base.yaml sets all six of these keys to non-null values, so this only fires if someone explicitly writes domino_block_size: null. dspark_block_size: null has the identical hazard today.
Hardening only Domino would make it inconsistent with its siblings, so this belongs in a repo-wide change if we want it.
top5_correct was initialized to zero and never reduced, so the top5_correct_count diagnostic (which base_trainer turns into top5_acc) stayed pinned at 0. DFlash and DSpark both compute it; Domino did not. Mirror the existing top1 idiom in this forward: take topk over the base logits and overwrite the suffix rows from the Domino-corrected logits, so no second [num_active, vocab] tensor is materialized. Guard topk with min(5, vocab) like DSpark does, for small-vocab configs. Adds a CPU regression test that fails without the fix (top5=0 vs top1=2). Signed-off-by: khazic <khazzz1c@gmail.com>
The docstring still claimed Domino is training-only and that its correction head has no engine proposer. Domino is a projector_type sub-mode of DFlash: the serve method stays dflash and the head is enabled from the checkpoint, so align this with the guardrails in vllm_runtime and sglang_runtime. Signed-off-by: khazic <khazzz1c@gmail.com>
…drafter Resolves conflicts with the Domino backend (verl-project#17), EAGLE-1/2 backend (verl-project#15), and the DSpark L1 target-hidden plumbing: the peagle and domino worker dispatch branches, vllm guardrails, and speco_base.yaml config blocks are all additive and kept side by side. The merge also brings in main's supports_ulysses_sp gate, which force-disables Ulysses SP for the P-EAGLE backend (supports_ulysses_sp=False) and enforces the unsliced-batch invariant the peagle assembly relies on. Signed-off-by: khazic <khazzz1c@gmail.com>
What
Adds a Domino drafter training backend to the SpeCo overlay (part of #16). Select it for drafter training with:
Design
Domino is a DFlash variant, so
DominoTrainerBackendsubclassesDFlashTrainerBackendexactly like DSpark does; onlybuild_modeland the training forward differ. The DFlash parallel block backbone drafts a whole block in one non-causal forward, so each predicted position is blind to the block's earlier (drafted) tokens. Domino adds a causal correction head that fixes this:prefix_gru: a single-layer GRU over the block's previous-token embeddings, producing a causal state per block position.embed_proj: a low-rank MLP over[backbone hidden | GRU state]emitting a full-vocabulary logit delta added to the parallel base logits.Training jointly supervises the refined (final) and backbone-only (base) logits with a base-anchor curriculum
loss = (1 - lambda) * final + lambda * base, wherelambdadecays from its start value to 0 so early steps keep the parallel backbone strong and later steps let the correction head take over.The shifted-label alignment (target
x[a+1:a+1+block], prev[x[a], labels[:-1]], every position supervised) reuses the DSpark alignment, which equals the AutoModel reference'sshift_labelDomino path. Domino reuses the DFlash block-drafter plumbing (anchor sampling, noise block, block attention mask,dflash_auxhidden layout) and is wired through the worker dispatch,base_trainerblock-drafter gates, auto config routing, old-logprob aux layers, and config keys (domino_*, falling back todflash_*).Serving
Domino is not an engine-level speculative algorithm. Engines expose it as a
projector_typesub-mode of DFlash: the method staysdflash, and the causal correction head (prefix_gru+embed_proj) is enabled from the checkpoint'sdflash_config.projector_type="domino".DOMINOis therefore never a valid engine algorithm string, so both the vLLM and the SGLang config builders raise with a clear message directing the rollout/serve path toDFLASH, which keeps the Domino correction head active on engines that carry the projector.Engine-side support is in flight and not merged yet (vllm-project/vllm#48241 for vLLM, sgl-project/sglang#31328 for SGLang), so a Domino checkpoint cannot be served on a released engine today. This PR only adds the training backend plus the two guardrails; no serving claim is made here.
Not a duplicate
Checked the open issues/PRs (
gh pr list): no open PR touches Domino. #13 (Separate Draft Model Training) and #10 (draft weight loading) are unrelated.Testing
A human reviewed every changed line and ran the tests below.
CPU contract tests (
tests/integration/test_domino_backend_contract.py), 7 passed: projector-head shapes, the lambda-base curriculum schedule, block-drafter classification, auto-config routing forDominoDraftModel, old-logprob DFlash-aux routing, and the DOMINO serve guardrail on both the vLLM and the SGLang config builders. The full suite is149 passed, 2 skipped, 2 failed; both failures pre-exist onmain(atests/heavyrunner-env check and a stale metrics assertion) and are unrelated to this change.GPU hardware smoke (
ci/domino_gpu_smoke.py) on a real target (Qwen3-4B, 1x A100): it collects the real DFlash-style multi-layer context hidden states from the frozen target, builds the Domino draft viaDominoTrainerBackend.build_model, and runs 120 optimizer steps throughcompute_loss(the block-drafter forward with the GRU correction head and dual-logit curriculum). The draft is cold-started, so the signal is convergence: both the final (Domino-refined) and base (backbone-only) CE fall and accuracy rises. Crucially, the final loss stays below the base loss throughout, i.e. the causal correction head genuinely improves on the parallel backbone, andlambda_basedecays from ~1 to 0 as designed.Domino GPU training log (Qwen3-4B target, cold start, 120 steps)
Known follow-ups
full_vocabCE head; restricted/sampled-CE (as DFlash/DSpark support) would need to index theembed_projoutput rows and is left as a follow-up.projector_type,gru_hidden_dim,pure_draft_prefix_len,shift_label,emb_dim, and theprefix_gru.*/embed_proj.*weight names), but an end-to-end load of a SpeCo-trained Domino checkpoint on those branches has not been validated yet.