Skip to content

[trainer, model, data] feat: integrate DeepSpec draft-model training - #882

Open
zhangxin81 wants to merge 2 commits into
ByteDance-Seed:mainfrom
zhangxin81:main
Open

[trainer, model, data] feat: integrate DeepSpec draft-model training#882
zhangxin81 wants to merge 2 commits into
ByteDance-Seed:mainfrom
zhangxin81:main

Conversation

@zhangxin81

@zhangxin81 zhangxin81 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Integrate DeepSpec speculative-decoding draft-model training into VeOmni, so draft models (DSpark / DFlash / Eagle3) train through VeOmni's distributed engine instead of DeepSpec's original single-node mp.spawn + FSDP(NO_SHARD) loop.

The training loop remains decoupled from the target model: DeepSpec builds the target hidden-state cache offline, and VeOmni trains only the draft PreTrainedModel over that tensor cache. This PR reuses DeepSpec's modeling, loss, and dataset code, and adapts only the interfaces VeOmni needs.

This update also routes the Qwen3 DSpark draft model through a patchgen-generated VeOmni modeling file so DSpark's imported Qwen3 RMSNorm and MLP can dispatch through OpSlot("rms_norm", "standard") and OpSlot("swiglu_mlp", "standard") for Liger-backed runs. RoPE and DeepSpec's custom DSpark loss stay eager because their shape/loss contracts differ from VeOmni's existing generic slots.

Checklist Before Starting

  • Related: DeepSpec repo for draft models and target-cache tooling. No existing VeOmni issue/PR was found for this integration.
  • PR title follows [{modules}] {type}: {description}: [trainer, model, data] feat: integrate DeepSpec draft-model training.

Test

Validation completed:

Area Result
Static checks Passed compileall for the DeepSpec integration, model registry, trainer, task, and prep script paths.
Import safety Passed: VeOmni imports without requiring DEEPSPEC_PATH; missing DeepSpec checkout reports an actionable error.
Adapter contracts Passed fake-DeepSpec checks for path resolution, cache dataset adapter, cache collator, draft config, and algorithm inference.
1xH100 DSpark smoke Passed 3 optimizer steps, DCP checkpoint writes, and HF checkpoint export.
2xH100 FSDP2 DSpark smoke Passed 3 optimizer steps with init_device=meta, DCP checkpoint writes, and HF checkpoint export.
Checkpoint resume Passed resume from global_step_2 and saved global_step_3.
Parallelism guard Passed: rejects HSDP/non-pure-FSDP settings that would mis-scale DeepSpec's world-size-scaled loss.
2xH100 medium benchmark Passed 80 optimizer steps and DCP checkpoint save.
8xH100 production-shape validation Passed 20-step and 120-step Qwen3-4B-shape DSpark runs.
Liger smoke Passed 2xH100 smoke; logs confirm patched DSpark model and rms_norm/swiglu_mlp Liger dispatch.
Liger production-shape benchmark Passed 120 optimizer steps on 8xH100.

CPU pytest regressions were not clean on the CPU-only master environment for environment reasons, not PR-specific failures: missing einops, dataloader pin_memory requiring an accelerator, and checkpoint tests requiring CUDA/distributed support.

Performance summary from the synthetic production-shape benchmark:

Path Post-warmup throughput Peak GPU memory
Native DeepSpec NO_SHARD ~5.0 steps/s 42.21 GiB max from nvidia-smi sampling
VeOmni FSDP2, eager draft ops ~5.45 steps/s 18.53 GiB from VeOmni logger
VeOmni FSDP2 + Liger RMSNorm/SwiGLU ~6.00 steps/s 18.45 GiB from VeOmni logger

Interpretation:

  • VeOmni FSDP2 gives the main memory scalability benefit by sharding parameters, gradients, and optimizer state instead of replicating them with NO_SHARD.
  • The Liger patch improves stable post-warmup training throughput by about 6.00 / 5.45 ~= 1.10x over the eager VeOmni path, and about 6.00 / 5.00 ~= 1.20x over the native DeepSpec estimate in this synthetic benchmark.
  • Native DeepSpec completed training/checkpointing in the benchmark runs but repeatedly hung during distributed teardown, so native wrapper wall time is not comparable.

API and Usage Example

New task entry, prep script, launcher, configs, and docs. No existing public VeOmni API is changed.

# 1. Build the draft init checkpoint. This uses the target model once, offline.
python scripts/deepspec/prepare_draft_init.py \
    --algorithm dspark --arch qwen3 \
    --target_model_name_or_path Qwen/Qwen3-4B \
    --output_dir ~/deepspec_init/dspark_qwen3_4b \
    --block_size 7 --num_draft_layers 5 --target_layer_ids 1 9 17 25 33 \
    --mask_token_id 151669 --num_anchors 512 \
    --markov_rank 256 --markov_head_type vanilla \
    --confidence_head_alpha 1.0 --confidence_head_with_markov \
    --loss_decay_gamma 4.0 --ce_loss_alpha 0.1 --l1_loss_alpha 0.9

# 2. Point configs/deepspec/dspark_qwen3_4b.yaml at the init checkpoint and target cache.

# 3. Launch. The launcher auto-detects a sibling DeepSpec/ checkout.
bash scripts/deepspec/train_draft.sh configs/deepspec/dspark_qwen3_4b.yaml

Design & Code Changes

Full integration write-up: docs/design/deepspec_draft_integration.md.

Correctness anchor: DeepSpec scales its loss by world_size to cancel DDP/FSDP gradient averaging. Under pure FSDP data parallelism, where the FSDP shard group spans the full world and all other parallel dimensions are size 1, FSDP2's gradient averaging cancels DeepSpec's x world_size scaling exactly. DraftModelTrainer._validate_parallelism enforces this by rejecting SP/TP/EP/PP/CP/HSDP settings for now.

Change list:

  • veomni/integrations/deepspec/: locate and lazily import a DeepSpec checkout via DEEPSPEC_PATH or a sibling DeepSpec/ checkout.
  • veomni/models/transformers/deepspec_draft/: register model_type=deepspec_draft, DeepSpecDraftConfig, and DeepSpec draft architectures.
  • veomni/models/transformers/deepspec_draft/dspark_qwen3_gpu_patch_gen_config.py: patchgen config for Qwen3 DSpark draft RMSNorm/SwiGLU OpSlot routing.
  • veomni/models/transformers/deepspec_draft/generated/: generated Qwen3 DSpark GPU modeling file and generated diff.
  • veomni/data/deepspec/: adapt DeepSpec CacheDataset and CacheCollator to VeOmni's mapping dataloader contract.
  • veomni/trainer/deepspec/draft_trainer.py: DraftModelTrainer(BaseTrainer) for model build, frozen target embeds/lm_head, dataset/collator setup, DeepSpec loss, grad accumulation, metrics, and checkpoint-compatible training.
  • tasks/train_deepspec_draft.py, scripts/deepspec/*, configs/deepspec/*: task entry, init-checkpoint prep, launcher, and sample configs.

Current limits:

  • Only pure FSDP data parallelism is enabled for DeepSpec draft training in this PR. SP/TP/EP/PP/CP/HSDP are intentionally rejected until their loss-scaling contracts are designed and tested.
  • Production-shape benchmarks use synthetic but shape-correct Qwen3-4B target/cache tensors. They validate integration behavior, memory scale, and compute-path throughput, but they are not final model-quality metrics.
  • Eagle3 long GPU testing was not run in this pass; DSpark was prioritized because it covers the prep script, cache adapter, loss bridge, FSDP2, DCP, resume, guard, and Liger patch path.

Checklist Before Submitting

  • Read the Contribute Guide.
  • Applied pre-commit checks locally where available: make quality.
  • Added/updated documentation (docs/design/deepspec_draft_integration.md, configs/deepspec/README.md).
  • No tasks/ scripts were moved/renamed; a new task entry was added.
  • Added tests to CI workflow: not added in this PR because meaningful end-to-end validation requires DeepSpec checkout plus GPU target-cache artifacts.

@CLAassistant

CLAassistant commented Jul 3, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces an integration between DeepSpec speculative-decoding draft models and the VeOmni distributed training engine. It includes a trainer implementation, data adapters for DeepSpec's target cache, and configuration utilities to support training DSpark and Eagle3 models using FSDP2. The review identified several critical issues, including a missing return value in the training step, incorrect parameter freezing logic, potential type mismatches in dataset length calculations, and issues with attribute access on FSDP-wrapped models. Additionally, a parameter naming error in the initialization script was corrected.

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.

Comment on lines +347 to +349
total_loss_dict.update(self._flush_deepspec_metrics())

self.on_step_end(loss=total_loss, loss_dict=total_loss_dict, grad_norm=grad_norm)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

The train_step method is annotated to return Dict[str, float], but it currently lacks a return statement, effectively returning None. This will cause runtime errors or static type checking failures in the training loop. Additionally, total_loss_dict does not contain the standard "loss" key because forward_backward_step returns an empty dictionary for its metrics, which prevents the standard loss metric from being logged to WandB/TensorBoard. Please update total_loss_dict with the "loss" key and return it at the end of the method.

Suggested change
total_loss_dict.update(self._flush_deepspec_metrics())
self.on_step_end(loss=total_loss, loss_dict=total_loss_dict, grad_norm=grad_norm)
total_loss_dict.update(self._flush_deepspec_metrics())
total_loss_dict["loss"] = total_loss
self.on_step_end(loss=total_loss, loss_dict=total_loss_dict, grad_norm=grad_norm)
return total_loss_dict

Comment on lines +174 to +176
for name, param in model.named_parameters():
if name.startswith("embed_tokens") or name.startswith("lm_head"):
param.requires_grad_(False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In HuggingFace models, parameters are typically nested under a sub-module (e.g., model.embed_tokens.weight or model.lm_head.weight). Using name.startswith(...) will fail to match these nested names because they start with "model." instead of the layer name directly. This means the fallback path will silently fail to freeze the embeddings and language modeling head, leading to incorrect training behavior and potential memory issues. Using "embed_tokens" in name or "lm_head" in name is much more robust.

Suggested change
for name, param in model.named_parameters():
if name.startswith("embed_tokens") or name.startswith("lm_head"):
param.requires_grad_(False)
for name, param in model.named_parameters():
if "embed_tokens" in name or "lm_head" in name:
param.requires_grad_(False)

self.train_dataset = build_target_cache_dataset(cache_dir)
self._validate_cache_against_model(self.train_dataset)

dataset_length = len(self.train_dataset) / get_parallel_state().dp_size

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The dataset length represents the number of samples per rank, which must be an integer. Using float division / can result in a float value, which might cause type mismatches or unexpected behavior in downstream step calculations. Please use integer floor division // instead.

Suggested change
dataset_length = len(self.train_dataset) / get_parallel_state().dp_size
dataset_length = len(self.train_dataset) // get_parallel_state().dp_size

def _validate_cache_against_model(self, dataset):
"""Mirror DeepSpec's ``validate_train_cache`` on the built model."""
model = self.model
expected_layer_ids = [int(x) for x in getattr(model, "target_layer_ids", [])]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

When the model is wrapped by FSDP, custom attributes like target_layer_ids are not forwarded from the underlying module to the FSDP wrapper. This means getattr(model, "target_layer_ids", []) will return [] on wrapped models, silently bypassing the target layer validation check. To ensure this safety check always runs correctly, please access target_layer_ids from self.model_config instead.

Suggested change
expected_layer_ids = [int(x) for x in getattr(model, "target_layer_ids", [])]
expected_layer_ids = [int(x) for x in getattr(self.model_config, "target_layer_ids", [])]

Comment on lines +291 to +292
ttt_length=int(getattr(self.model, "ttt_length", self.model_config.ttt_length)),
step_loss_decay=float(getattr(self.model, "step_loss_decay", self.model_config.step_loss_decay)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

When the model is wrapped by FSDP, custom attributes like ttt_length and step_loss_decay are not forwarded from the underlying module to the FSDP wrapper. This means getattr(self.model, ...) will fail to find them on the wrapper and fall back to the config values. To be safe and consistent, please access these attributes directly from self.model_config.

Suggested change
ttt_length=int(getattr(self.model, "ttt_length", self.model_config.ttt_length)),
step_loss_decay=float(getattr(self.model, "step_loss_decay", self.model_config.step_loss_decay)),
ttt_length=int(getattr(self.model_config, "ttt_length")),
step_loss_decay=float(getattr(self.model_config, "step_loss_decay")),


print("[prepare_draft_init] Copying frozen target embeddings + lm_head...")
target_model = (
AutoModelForCausalLM.from_pretrained(args.target_model_name_or_path, dtype=torch.float32)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In HuggingFace Transformers, the parameter to specify the model's data type is torch_dtype, not dtype. Passing dtype can raise a TypeError or be ignored depending on the Transformers version. Please use torch_dtype instead.

Suggested change
AutoModelForCausalLM.from_pretrained(args.target_model_name_or_path, dtype=torch.float32)
AutoModelForCausalLM.from_pretrained(args.target_model_name_or_path, torch_dtype=torch.float32)

asserts a consistent metric schema across ranks, so every rank must call
it exactly once per step.
"""
try:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe it's better to pin a commit for Deepspec and add it to pyproject.toml

model.set_embedding_head_trainable(False)
else: # defensive: freeze by name if the helper is absent
for name, param in model.named_parameters():
if name.startswith("embed_tokens") or name.startswith("lm_head"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

sub string match like "embed_tokens" in name or "lm_head" in name maybe better

micro_batch = self.preforward(micro_batch)

with self.model_fwd_context:
loss = self._compute_deepspec_loss(micro_batch)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We need get the loss reduce on global tokens, it seems _compute_deepspec_loss does not return the global loss.

Install DeepSpec as a pinned git dependency instead of a sys.path-only
bootstrap, and address reviewer comments on the draft trainer.

- Add an opt-in `deepspec` extra: git source pinned by commit in
  [tool.uv.sources] (the commit adds DeepSpec's pyproject.toml, proposed
  upstream in deepseek-ai/DeepSpec#54; repoint to upstream once merged) and
  an empty [[tool.uv.dependency-metadata]] so DeepSpec's own torch/
  transformers pins do not conflict with VeOmni's stack. DEEPSPEC_PATH /
  sibling checkout stays as a dev fallback.
- draft_trainer: report the true global-token loss (mean all-reduce over the
  fsdp group inverts DeepSpec's world-size backward scaling) instead of
  DeepSpec's per-rank mean-of-means metric.
- draft_trainer: freeze embed_tokens / lm_head by substring match so nested
  HF param names (model.embed_tokens.weight) are matched in the fallback path.
- Update integration docs, uv.md, and integration docstrings to describe the
  deepspec extra as the primary install path.
@zhangxin81

Copy link
Copy Markdown
Contributor Author

@FoolPlayer thanks for the review — addressed in the latest commit. Summary of the three comments:

1. Freeze embed_tokens / lm_head by substring match — done. The fallback now uses "embed_tokens" in name or "lm_head" in name so nested HF param names (model.embed_tokens.weight) are matched.

2. Loss should reduce over global tokens — done. train_step now reports the true token-weighted global loss via a mean all-reduce over the FSDP group, which exactly inverts DeepSpec's ×world_size backward scaling. Previously the reported loss came from DeepSpec's per-rank reduction="mean" metric (a mean-of-per-rank-means that mis-weights ranks with fewer tokens); it is now overridden with the global value. Gradients are unchanged.

3. Pin DeepSpec and add it to pyproject.toml — done, in two steps, because DeepSpec had no build backend (no setup.py/pyproject.toml), so it could not be added as a pip/uv dependency as-is:

  • Opened build: add pyproject.toml for pip/uv installable packaging deepseek-ai/DeepSpec#54 to add DeepSpec's own pyproject.toml (setuptools backend, packages deepspec* only). This makes it pip/uv-installable.
  • VeOmni now declares DeepSpec as an opt-in deepspec extra: a git source pinned by commit in [tool.uv.sources], plus an empty [[tool.uv.dependency-metadata]] so DeepSpec's own torch==2.9.1 / transformers==5.10.2 pins don't conflict with VeOmni's stack (VeOmni already provides every runtime dep the imported deepspec.modeling/data/utils modules need). uv lock / uv lock --check pass; torch/transformers are not downgraded; the extra is gated so default --extra gpu installs and CI (--locked/--frozen) are unaffected. DEEPSPEC_PATH / sibling checkout remains as a dev fallback.

Note on the pin (temporary): the rev currently points at the fork commit that carries the #54 packaging, since a working dependency must reference a commit that actually contains pyproject.toml. Once deepseek-ai/DeepSpec#54 merges, I'll repoint [tool.uv.sources].deepspec to the upstream commit and re-lock. This is flagged in the pyproject.toml comment and the design doc.

@FoolPlayer

Copy link
Copy Markdown
Collaborator

Re-review after the "address review feedback" update (f333790)

Thanks for the fast turnaround. I re-checked the update against the earlier feedback and verified the changed logic (dependency wiring, loss-reduction math, freeze fallback) plus the framework APIs it now relies on.

Resolved (verified)

1. DeepSpec is now a reproducible, git-pinned deepspec extra (was a sys.path soft-dependency).

  • Opt-in via uv sync --extra gpu --extra deepspec; doesn't touch the default install.
  • Pinned by git commit → reproducible, no runtime env var required.
  • [[tool.uv.dependency-metadata]] with empty requires-dist prevents DeepSpec's own torch==2.9.1 / transformers==5.10.2 pins from conflicting with VeOmni's stack — the same escape hatch already used for flash-qla.
  • ensure_deepspec_importable() now prefers an already-installed deepspec and only falls back to $DEEPSPEC_PATH / a sibling checkout for local dev. uv.md + design doc updated to match.

2. Headline loss is now reduced over global tokens.

  • global_loss = all_reduce(total_loss, op="mean", group=fsdp_group), used for both the logged loss and on_step_end.
  • Math checks out: DeepSpec's backward loss carries a world_size factor to cancel FSDP gradient averaging; a mean all-reduce over the fsdp group (== world under the pure-FSDP guard) cancels it exactly, giving the true token-weighted mean instead of a mean-of-per-rank-means. Reducing over the fsdp group (not the default world group) also keeps it idempotent w.r.t. VeOmni's metric callback. all_reduce accepts a Python float and parallel_state.fsdp_group exists, so the call is valid.

3. Freeze fallback bug fixed.

  • name.startswith(...) → substring match ("embed_tokens" in name / "lm_head" in name) is a genuine fix: under HF's nested naming the params are model.embed_tokens.weight / lm_head.weight, so the old prefix match froze nothing in the fallback path.

Follow-ups (non-blocking except the first)

1. The git source points to a personal fork (zhangxin81/DeepSpec), pending upstream deepseek-ai/DeepSpec#54. Commit-pinning protects against force-push, but a personal fork can be deleted outright — a supply-chain risk for main. Recommend repointing to the upstream commit (or an org-controlled mirror) before merge; there's already a TODO to do so once #54 lands.

2. Grad-accum weighting (minor). The reported global_loss is a plain mean over micro-batches of each micro-batch's global token-weighted mean, not a single token-weighted mean across the whole accumulated batch. Negligible when per-micro-batch token counts are similar, and it matches DeepSpec's per-micro-batch semantics. The new comment's phrasing "world_size * global_token_loss on every rank" is slightly loose (the per-rank value differs; only the reduced value equals the global loss).

3. _flush_deepspec_metrics() wraps a collective in try/except. deepspec.utils.metrics.flush() runs internal all-reduces; if it raises on some ranks but not others, peers can hang. Low probability (the design requires every rank to call it once per step), but the guard can't un-hang peers.

4. No CI/unit coverage for the adapter path — inherent to an opt-in extra + external target cache, so mostly a documentation matter.

Verdict

High-quality update — the three substantive issues (non-reproducible dependency, non-global loss logging, no-op freeze fallback) are genuinely resolved, and the loss fix is mathematically sound. The only item worth resolving before merge is the personal-fork git source; the rest are fine as follow-ups.

@FoolPlayer FoolPlayer mentioned this pull request Jul 28, 2026
22 tasks

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Thanks for the contribution! Cursor has started reviewing this PR and will post the full review shortly.

Open in Web View Automation 

Sent by Cursor Automation: VeOmni-Review-Agent(Outer)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review summary

Baseline: main@7be22df074b4, ahead by 2, behind by 55.

1. Does it solve a real problem? Yes. Training speculative-decoding draft models on VeOmni's engine instead of DeepSpec's FSDP.NO_SHARD setup is a genuine capability, and the reasoning is unusually well argued: the module docstring explains exactly why DeepSpec's ×world_size loss scaling cancels FSDP2's gradient averaging only when the shard group spans the world, and _validate_parallelism then refuses every other parallel dim rather than silently mis-scaling gradients. The registry integration is also done correctly — registering under a dedicated deepspec_draft model type avoids shadowing the real Qwen3, the factory signature matches loader.py:143's MODELING_REGISTRY[model_type](arch_name) contract, and deepspec is imported lazily inside _import_draft_class, so import veomni.models.transformers still works without the optional extra. That last point is easy to get wrong and worth calling out as done right.

2. Are comments, docs and tests complete? Docs, yes — a 281-line design document correctly registered in the docs/index.md toctree, a 137-line config README, two example YAMLs, and genuinely explanatory docstrings throughout. Tests, no: roughly 1,600 lines of new source (a BaseTrainer subclass that overrides train_step and forward_backward_step, a data adapter, a config class, a prep script, a patchgen target) with no test file at all. At minimum the pure-Python pieces are testable without DeepSpec installed: _infer_algorithm, _dspark_loss_weights precedence, _validate_cache_against_model's two error paths, and _validate_parallelism rejecting each parallel dim. Note also that CI will fail as-is — see the inline comment on the patchgen config.

3. Is it clean, and does it conflict with main? The code is tidy and well organized, but there are three structural problems. First, the new dependency resolves from a personal fork (github.com/zhangxin81/DeepSpec), which the comment itself flags as temporary pending upstream deepseek-ai/DeepSpec#54. Second, [[tool.uv.dependency-metadata]] requires-dist = [] suppresses DeepSpec's own pins, which include transformers==5.10.2 against VeOmni's 5.9.0 — and the generated draft modeling is produced from that code. Third, both trainer overrides silently drop behaviour the base class provides. Textually the PR also conflicts: git apply --check fails on docs/index.md, pyproject.toml and uv.lock (mergeable: false, rebaseable: false); the lockfile in particular has to be regenerated with uv lock, not merged by hand.

Overall: needs changes. The design and documentation are the strongest part of this PR and the loss-scaling analysis is convincing. The blockers are the fork-hosted dependency, the patchgen CI failure, and the silently dropped base-trainer features; tests and a rebase are needed on top.

Open in Web View Automation 

Sent by Cursor Automation: VeOmni-Review-Agent(Outer)

Comment thread pyproject.toml
# DeepSpec's pyproject.toml (upstream PR deepseek-ai/DeepSpec#54); repoint to the
# upstream commit once that merges. Only resolved when the `deepspec` extra is
# selected.
deepspec = { git = "https://github.com/zhangxin81/DeepSpec", rev = "57a01adb496229f3d93afd10679adf5ced7094a2" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This makes VeOmni's lockfile depend on a personal fork of a third-party project. Even pinned by commit that is a durability problem for a first-party repo: the fork can be renamed, made private, or have its history rewritten, and every uv sync --extra deepspec (plus any CI job that resolves it) then breaks with no recourse from this repo. The comment already acknowledges the intent to repoint once deepseek-ai/DeepSpec#54 merges.

Options, roughly in order of preference: wait for the upstream merge and pin deepseek-ai/DeepSpec; or point at the upstream commit now and cover the missing pyproject.toml with the DEEPSPEC_PATH / checkout fallback that deepspec_path.py already implements; or keep this PR as the trainer/data/registry integration only, with DeepSpec left as a documented manual install and no entry in pyproject.toml or uv.lock.

Comment thread pyproject.toml
[[tool.uv.dependency-metadata]]
name = "deepspec"
version = "0.1.0"
requires-dist = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

requires-dist = [] is an established technique in this file (flash-qla above does the same), but the justification here is riskier than for a compiled kernel package. DeepSpec pins torch==2.9.1 and transformers==5.10.2; VeOmni ships torch 2.11.0+cu130 and transformers 5.9.0, so this is not just "drop a redundant pin", it silently resolves a version conflict in the opposite direction — DeepSpec's modeling code was written against a newer transformers than VeOmni pins, and the generated draft modeling in this PR is derived from it.

Please state in the comment which transformers APIs the imported deepspec.modeling.* / deepspec.data / deepspec.utils.* modules actually touch and that they exist in 5.9.0, and enumerate DeepSpec's non-pinned runtime imports so a reader can confirm nothing is genuinely missing from the environment.



config = PatchConfig(
source_module="deepspec.modeling.dspark.qwen3.modeling",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This will break check_patchgen CI. That workflow runs uv run --extra gpu --dev patchgen --check (.github/workflows/check_patchgen.yml:45) — it does not select the deepspec extra, and this PR does not change the workflow. patchgen's resolver (patchgen-pkg/patchgen/codegen.py:83-99) first looks for deepspec/modeling/dspark/qwen3/modeling.py under sys.path, then falls back to importlib.import_module and raises CodegenError: Cannot import module 'deepspec.modeling.dspark.qwen3.modeling' when that fails. With DeepSpec absent, neither path can succeed.

So the check needs --extra deepspec added, which in turn makes every patchgen CI run fetch and execute code from the git dependency discussed above. That coupling is worth designing deliberately rather than inheriting: consider whether this patchgen target belongs in the repo at all, or whether the OpSlot wrapping is better expressed as a runtime subclass in veomni/models/transformers/deepspec_draft/ that needs no codegen against an external source module.

self.LOG_SAMPLE = False
return micro_batch

def forward_backward_step(self, micro_batch: Dict[str, torch.Tensor]):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This override silently drops several things BaseTrainer.forward_backward_step does (veomni/trainer/base.py:718-742):

  • use_parallel_state("base")
  • chunk_mbs_context(self._chunk_mbs_ranges)
  • set_batch_invariant_mode(self.args.train.enable_batch_invariant_mode)
  • the channel_loss_callback micro-step context, strip_model_inputs, and forward context

The practical consequence is that a user who sets --train.enable_batch_invariant_mode or configures channel loss for a draft run gets no error and no effect. Since _init_callbacks still constructs channel_loss_callback when configured, that one is a silent no-op rather than a clean refusal. Please either keep the contexts that still apply, or refuse the flags explicitly in _validate_parallelism with a warning_rank0 so the omission is visible.

self._num_micro_steps = max(1, len(micro_batches))

for micro_step, micro_batch in enumerate(micro_batches):
self.model_reshard(micro_step, self._num_micro_steps)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two omissions relative to the base loop (veomni/trainer/base.py:806-822): mark_compile_step_begin(getattr(self.model, "_veomni_compile_uses_cuda_graphs", False)) and self._configure_hsdp_allreduce(micro_step, num_micro_steps). The HSDP one is defensible — _validate_parallelism already rejects dp_replicate_size > 1, so it would be a no-op — but saying so in a comment would keep the next person from "fixing" it. mark_compile_step_begin is not covered by any guard, so a compiled draft model would step incorrectly. The base also wraps grad clipping in with use_parallel_state("base"), which this override calls bare.

Related: self._num_micro_steps is set here and read in forward_backward_step, so calling forward_backward_step from anywhere else (evaluation, a test) raises AttributeError. A class-level default of 1 would make it safe.

return {}
try:
summary = deepspec_metrics.flush()
except Exception as exc: # never let metric logging break training

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This wraps a collective in try/except, which is the one place that pattern is dangerous. The docstring correctly says flush() all-reduces and therefore every rank must call it exactly once per step — but if it raises on some ranks and not others, those ranks fall through to reset() and continue while the rest are still inside the collective, so the group desynchronizes and the next step hangs or aborts. And because the handler uses warning_rank0, a failure on any rank other than 0 leaves no trace at all.

Suggestions: log with logger.warning plus exc_info=True (or logger.exception) so the failing rank is identifiable, and consider letting a flush() failure propagate — an inconsistent metric schema across ranks is a real misconfiguration, not a cosmetic logging problem. The broad except Exception on the import just above is also redundant, since ensure_deepspec_importable() already ran in __init__.

)

if root not in sys.path:
sys.path.insert(0, root)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Prepending to sys.path[0] from library code means a DeepSpec checkout's top-level directory names take precedence over everything, including the standard library, for the rest of the process. Combined with the automatic sibling-directory scan above (../DeepSpec, ./DeepSpec), behaviour depends on where the repo happens to sit on disk, which is hard to debug when it goes wrong.

Consider narrowing this to the explicit DEEPSPEC_PATH case only (dropping the layout guessing now that the deepspec extra is the supported install path) and using sys.path.append instead. The lru_cache is a good touch — worth noting it also means a DEEPSPEC_PATH change after first call is ignored, which is fine but worth a line in the docstring.

}


def _import_draft_class(architecture: str) -> Type:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth affirming: keeping _ARCHITECTURE_IMPORTS as strings and importing inside this factory is the right call. veomni/models/transformers/__init__.py imports deepspec_draft unconditionally, so an eager import deepspec here would break import veomni.models.transformers for every user who has not installed the optional extra. As written, the dependency is only touched at model-build time.

super()._setup()
self._validate_parallelism()

def _validate_parallelism(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good guard, and the docstring explains the gradient-scaling identity clearly. One gap: _setup calls this, but fsdp_size/dp_shard is not checked against the world size directly — the argument relies on "every other dim is 1 implies the shard group spans the world". If fsdp_size can ever be set smaller than the world size independently of dp_replicate_size, the identity breaks without tripping any of these checks. Worth asserting get_parallel_state().fsdp_size == world_size after _setup so the invariant is verified rather than inferred.

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.

3 participants