From e7ba6c09e9fffe16e6482411c5b04b58f70c152c Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Tue, 30 Jun 2026 19:08:19 -0700 Subject: [PATCH 01/24] =?UTF-8?q?[DataFlow=20runtime]=20Phase=20B1=20?= =?UTF-8?q?=E2=80=94=20TargetEngine=20ABC=20+=20de-EAGLE3=20the=20target?= =?UTF-8?q?=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a backend-agnostic `TargetEngine` ABC (modeling/target/base.py) with a generic `capture(...)` entry point + a real `backend` tag, replacing the two EAGLE3-/DFlash-named ABCs as the shared base: TargetEngine ├── Eagle3TargetEngine (was Eagle3TargetModel) {HF,SGLang,Custom} └── DFlashTargetEngine (was DFlashTargetModel) {SGLang,HF} - `capture()` / `set_capture_layers()` are thin dispatchers onto the unchanged `generate_eagle3_data` / `generate_dflash_data` / `set_aux_hidden_states_layers`, so extraction is byte-identical — this PR is pure structure/naming, no logic. - Real `backend` class attr on every leaf ("sglang"/"hf"/"custom"); the inference adapters' health() stops reading getattr(..., "unknown"). - Generic `get_target_engine(strategy=, backend=)` factory (modeling/target/factory.py), loaders imported lazily so `import specforge` still works without the pinned sglang (dflash_target_model imports sglang unconditionally — kept off the eager path). - All pre-Phase-B names (`Eagle3TargetModel`, `get_eagle3_target_model`, ...) kept as aliases; scripts/tests untouched. `sglang_server` factory branch lands in B2. - Add tests/test_runtime/test_target_engine_abc.py (hierarchy, backend tags, capture dispatch, alias identity, factory dispatch). Co-Authored-By: Claude Opus 4.8 --- specforge/modeling/target/base.py | 106 ++ .../modeling/target/dflash_target_model.py | 353 ++++++ .../modeling/target/eagle3_target_model.py | 1007 +++++++++++++++++ specforge/modeling/target/factory.py | 82 ++ 4 files changed, 1548 insertions(+) create mode 100644 specforge/modeling/target/base.py create mode 100644 specforge/modeling/target/dflash_target_model.py create mode 100644 specforge/modeling/target/eagle3_target_model.py create mode 100644 specforge/modeling/target/factory.py diff --git a/specforge/modeling/target/base.py b/specforge/modeling/target/base.py new file mode 100644 index 000000000..1a2727426 --- /dev/null +++ b/specforge/modeling/target/base.py @@ -0,0 +1,106 @@ +# coding=utf-8 +# Copyright 2024 The SpecForge team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +"""TargetEngine: the backend-agnostic target-model abstraction (Phase B). + +This is the de-EAGLE3'd boundary extracted from the former ``Eagle3TargetModel`` +ABC. A ``TargetEngine`` wraps a *frozen* target model and exposes ONE generic +extraction entry point, :meth:`capture`, plus a real ``backend`` tag. The +inference/transport split (sglang in-process / hf / custom / sglang_server) is a +*backend* axis **under** each algorithm engine, and — crucially — the +sglang-version-specific glue lives behind a replaceable capture backend +(``sglang_backend``), NOT in the algorithm engine, so a sglang bump touches one +place instead of every ``*TargetModel`` subclass. + +Two sibling algorithm engines subclass this ABC: + +* :class:`Eagle3TargetEngine` (``eagle3_target_model``) — EAGLE3 TTT capture + (aux hidden states + logits), keeps the EAGLE3-specific + ``set_aux_hidden_states_layers`` / ``generate_eagle3_data``. +* :class:`DFlashTargetEngine` (``dflash_target_model``) — DFlash block capture + (concatenated layer hidden states, no target distribution). + +The runtime inference adapters (``SGLangAdapter`` / ``DFlashAdapter``) wrap a +``TargetEngine`` and remain the ``FeatureSource`` seam to the ``RolloutWorker`` — +they are unchanged by this extraction; they call the generic engine and read the +now-real ``.backend`` tag in ``health()``. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, List, Optional + +import torch + +# Known transport/backend tags. ``sglang_server`` (a live frozen-target SGLang +# server, cross-node) is introduced by the sglang-capture-backend PR; its capture +# depth is gated by the O1.3 spike. The tag set is advisory (informational, used +# by adapter health + provenance), not an enum the ABC enforces. +KNOWN_BACKENDS = ("sglang", "hf", "custom", "sglang_server") + + +class TargetEngine(ABC): + """Backend-agnostic frozen-target engine. + + Subclasses are organised on two axes: the *algorithm* (EAGLE3 / DFlash — + the intermediate ABCs :class:`Eagle3TargetEngine` / :class:`DFlashTargetEngine`) + and the *backend/transport* (sglang / hf / custom / sglang_server — the + concrete leaf classes). Only the leaf classes are instantiable; each sets a + real :attr:`backend` tag. + """ + + #: Transport tag; concrete leaf engines override this class attribute + #: ("sglang" / "hf" / "custom" / "sglang_server"). Read by the inference + #: adapters' ``health()`` and recorded as rollout provenance. + backend: str = "unknown" + + @classmethod + @abstractmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: Optional[torch.dtype] = None, + device: Optional[str] = None, + cache_dir: Optional[str] = None, + **kwargs, + ) -> "TargetEngine": + """Load a frozen target model for this backend.""" + + @abstractmethod + def capture( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + **kwargs, + ) -> Any: + """Run the frozen target forward and extract training features. + + The generic extraction entry point that replaces the EAGLE3-named + ``generate_eagle3_data``. Returns a per-algorithm output dataclass + (``Eagle3TargetOutput`` / ``DFlashTargetOutput``). Algorithm engines keep + their original ``generate_*_data`` method as the concrete implementation + and as a back-compat alias; ``capture`` simply dispatches to it, so the + extraction is byte-identical to the pre-refactor path. + """ + + def set_capture_layers(self, layer_ids: Optional[List[int]] = None) -> None: + """Select which target layers' hidden states to capture. + + The generic hook. EAGLE3 maps this onto its 3 aux layers + (``set_aux_hidden_states_layers``); DFlash captures an arbitrary list. + Engines that do not capture intermediate layers may leave this + unimplemented. + """ + raise NotImplementedError( + f"{type(self).__name__} does not implement set_capture_layers" + ) + + +__all__ = ["TargetEngine", "KNOWN_BACKENDS"] diff --git a/specforge/modeling/target/dflash_target_model.py b/specforge/modeling/target/dflash_target_model.py new file mode 100644 index 000000000..d23b23ef3 --- /dev/null +++ b/specforge/modeling/target/dflash_target_model.py @@ -0,0 +1,353 @@ +from abc import abstractmethod +from dataclasses import dataclass +from typing import List, Optional + +import torch +import torch.distributed as dist +import torch.nn as nn +from sglang.srt.configs.model_config import ModelConfig +from sglang.srt.managers.schedule_batch import Req, ScheduleBatch +from sglang.srt.managers.scheduler import Scheduler +from sglang.srt.mem_cache.cache_init_params import CacheInitParams +from sglang.srt.mem_cache.radix_cache import RadixCache +from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode, ForwardBatch +from sglang.srt.sampling.sampling_params import SamplingParams +from sglang.srt.server_args import ServerArgs +from sglang.srt.speculative.spec_info import SpeculativeAlgorithm +from sglang.srt.utils import require_mlp_sync, require_mlp_tp_gather +from transformers import AutoModelForCausalLM + +from specforge.distributed import get_tp_group + +from .base import TargetEngine +from .sglang_backend import SGLangRunner + + +@dataclass +class DFlashTargetOutput: + hidden_states: torch.Tensor # [batch, seq_len, hidden_size] + input_ids: torch.Tensor # [batch, seq_len] + attention_mask: torch.Tensor # [batch, seq_len] + loss_mask: torch.Tensor # [batch, seq_len] + + +class DFlashTargetEngine(TargetEngine): + """DFlash target engine — the algorithm ABC over a frozen target backend. + + DFlash captures the concatenated hidden states of an arbitrary list of + target layers (``set_capture_layers``) and trains on hard real-token labels, + so — unlike EAGLE3 — there is no target distribution / vocab map. The generic + :meth:`TargetEngine.capture` hook dispatches to ``generate_dflash_data``, so + the extraction is byte-identical to the pre-Phase-B path. + """ + + def __init__(self): + self.capture_layer_ids = None + + @classmethod + @abstractmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + **kwargs, + ) -> "DFlashTargetEngine": + """Initialize the target model backend.""" + + @abstractmethod + def generate_dflash_data( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + ) -> DFlashTargetOutput: + """Generate context hidden states for DFlash training.""" + + def capture( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + **kwargs, + ) -> DFlashTargetOutput: + """Generic extraction entry point (see :meth:`TargetEngine.capture`). + + Dispatches to the DFlash-specific ``generate_dflash_data``. DFlash takes + no extra extraction kwargs, so any are ignored. + """ + return self.generate_dflash_data( + input_ids=input_ids, + attention_mask=attention_mask, + loss_mask=loss_mask, + ) + + def set_capture_layers(self, layer_ids: Optional[List[int]] = None) -> None: + """Set which layers' hidden states to capture (TargetEngine hook).""" + self.capture_layer_ids = layer_ids + + +class SGLangDFlashTargetEngine(DFlashTargetEngine): + + backend = "sglang" + + def __init__(self, model_runner: SGLangRunner): + super().__init__() + self.model_runner = model_runner + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + trust_remote_code: bool = False, + **kwargs, + ) -> "SGLangDFlashTargetEngine": + tp_size = dist.get_world_size(get_tp_group()) + server_args = ServerArgs( + model_path=pretrained_model_name_or_path, + trust_remote_code=trust_remote_code, + dtype=torch_dtype, + enable_return_hidden_states=True, # Critical for DFlash + disable_cuda_graph=True, + tp_size=tp_size, + pp_size=1, + **kwargs, + ) + + tp_rank = dist.get_rank(get_tp_group()) + moe_ep_rank = tp_rank // (server_args.tp_size // server_args.ep_size) + model_config = ModelConfig.from_server_args(server_args) + + model_runner = SGLangRunner( + model_config=model_config, + mem_fraction_static=server_args.mem_fraction_static, + gpu_id=torch.cuda.current_device(), + tp_rank=dist.get_rank(get_tp_group()), + tp_size=server_args.tp_size, + moe_ep_rank=moe_ep_rank, + moe_ep_size=server_args.ep_size, + pp_rank=0, + pp_size=1, + server_args=server_args, + nccl_port=None, + ) + return cls(model_runner) + + def set_capture_layers(self, layer_ids: List[int]) -> None: + super().set_capture_layers(layer_ids) + if hasattr(self.model_runner.model, "set_eagle3_layers_to_capture"): + self.model_runner.model.set_eagle3_layers_to_capture(layer_ids) + print(self.model_runner.model.model.layers_to_capture) + + @torch.no_grad + def _extend(self, reqs): + cache_params = CacheInitParams( + disable=False, + req_to_token_pool=self.model_runner.req_to_token_pool, + token_to_kv_pool_allocator=self.model_runner.token_to_kv_pool_allocator, + page_size=self.model_runner.server_args.page_size, + ) + tree_cache = RadixCache(cache_params) + + batch = ScheduleBatch.init_new( + reqs=reqs, + req_to_token_pool=self.model_runner.req_to_token_pool, + token_to_kv_pool_allocator=self.model_runner.token_to_kv_pool_allocator, + tree_cache=tree_cache, + model_config=self.model_runner.model_config, + enable_overlap=False, + spec_algorithm=SpeculativeAlgorithm.NONE, + ) + batch.prepare_for_extend() + + if require_mlp_sync(self.model_runner.server_args): + Scheduler.prepare_mlp_sync_batch_raw( + batch, + dp_size=self.model_runner.server_args.dp_size, + attn_tp_size=1, + tp_group=self.model_runner.tp_group, + get_idle_batch=None, + disable_cuda_graph=self.model_runner.server_args.disable_cuda_graph, + spec_algorithm=SpeculativeAlgorithm.NONE, + speculative_num_draft_tokens=None, + require_mlp_tp_gather=require_mlp_tp_gather( + self.model_runner.server_args + ), + disable_overlap_schedule=self.model_runner.server_args.disable_overlap_schedule, + offload_tags=set(), + ) + + model_worker_batch = batch.get_model_worker_batch() + forward_batch = ForwardBatch.init_new(model_worker_batch, self.model_runner) + forward_batch.capture_hidden_mode = CaptureHiddenMode.FULL + + output = self.model_runner.forward(forward_batch) + if hasattr(output, "logits_output"): + output = output.logits_output + + input_lens = [len(req.origin_input_ids) for req in reqs] + if ( + hasattr(output, "aux_hidden_states") + and output.aux_hidden_states is not None + ): + hidden_states_list = torch.split( + output.aux_hidden_states, input_lens, dim=0 + ) + elif hasattr(output, "hidden_states") and output.hidden_states is not None: + hidden_states_list = torch.split(output.hidden_states, input_lens, dim=0) + else: + raise ValueError("SGLang output does not contain hidden states.") + + self.model_runner.req_to_token_pool.clear() + self.model_runner.token_to_kv_pool_allocator.clear() + + return hidden_states_list + + @torch.no_grad() + def generate_dflash_data( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + ) -> DFlashTargetOutput: + sampling_params = SamplingParams(temperature=0, max_new_tokens=1) + reqs, data_cache = [], [] + + if isinstance(input_ids, torch.Tensor): + input_ids_list = torch.split(input_ids, 1, dim=0) + attn_mask_list = torch.split(attention_mask, 1, dim=0) + loss_mask_list = torch.split(loss_mask, 1, dim=0) + + for idx, (curr_ids, curr_attn, curr_loss) in enumerate( + zip(input_ids_list, attn_mask_list, loss_mask_list) + ): + req = Req( + rid=str(idx), + origin_input_text="", + origin_input_ids=curr_ids.view(-1).tolist(), + sampling_params=sampling_params, + ) + req.fill_ids = req.origin_input_ids + req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) + data_cache.append((curr_ids, curr_attn, curr_loss)) + reqs.append(req) + + hidden_states_list = self._extend(reqs) + + # Stack back to batch + hidden_states = torch.cat([h.unsqueeze(0) for h in hidden_states_list], dim=0) + input_ids = torch.cat([d[0] for d in data_cache], dim=0) + attention_mask = torch.cat([d[1] for d in data_cache], dim=0) + loss_mask = torch.cat([d[2] for d in data_cache], dim=0) + + return DFlashTargetOutput( + hidden_states=hidden_states, + input_ids=input_ids, + attention_mask=attention_mask, + loss_mask=loss_mask, + ) + + +class HFDFlashTargetEngine(DFlashTargetEngine): + + backend = "hf" + + def __init__(self, model: nn.Module): + super().__init__() + self.model = model + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + trust_remote_code: bool = True, + **kwargs, + ) -> "HFDFlashTargetEngine": + + target_model = AutoModelForCausalLM.from_pretrained( + pretrained_model_name_or_path, + torch_dtype=torch_dtype, + cache_dir=cache_dir, + output_hidden_states=True, + trust_remote_code=trust_remote_code, + **kwargs, + ).eval() + + if device: + target_model = target_model.to(device) + + return cls(target_model) + + @torch.no_grad() + def generate_dflash_data( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + ) -> DFlashTargetOutput: + outputs = self.model( + input_ids=input_ids, + attention_mask=attention_mask, + output_hidden_states=True, + use_cache=False, + ) + + # hidden_states[0] = embedding output; hidden_states[i+1] = layer i output + offset = 1 + selected = [] + if self.capture_layer_ids is not None: + for idx in self.capture_layer_ids: + selected.append(outputs.hidden_states[idx + offset]) + hidden_states = torch.cat(selected, dim=-1) + else: + hidden_states = outputs.hidden_states[-1] + + return DFlashTargetOutput( + hidden_states=hidden_states, + input_ids=input_ids, + attention_mask=attention_mask, + loss_mask=loss_mask, + ) + + +def get_dflash_target_model( + pretrained_model_name_or_path: str, + backend: str = "sglang", + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + **kwargs, +) -> DFlashTargetEngine: + if backend == "sglang": + return SGLangDFlashTargetEngine.from_pretrained( + pretrained_model_name_or_path=pretrained_model_name_or_path, + torch_dtype=torch_dtype, + device=device, + cache_dir=cache_dir, + **kwargs, + ) + elif backend == "hf": + return HFDFlashTargetEngine.from_pretrained( + pretrained_model_name_or_path=pretrained_model_name_or_path, + torch_dtype=torch_dtype, + device=device, + cache_dir=cache_dir, + **kwargs, + ) + else: + raise ValueError(f"Invalid backend: {backend}") + + +# --- Back-compat aliases (pre-Phase-B names) ------------------------------- +# See the note in eagle3_target_model.py: the ``*TargetModel`` -> ``*TargetEngine`` +# rename is import-compatible; these aliases keep existing callers working. +DFlashTargetModel = DFlashTargetEngine +SGLangDFlashTargetModel = SGLangDFlashTargetEngine +HFDFlashTargetModel = HFDFlashTargetEngine diff --git a/specforge/modeling/target/eagle3_target_model.py b/specforge/modeling/target/eagle3_target_model.py new file mode 100644 index 000000000..8ebeecde5 --- /dev/null +++ b/specforge/modeling/target/eagle3_target_model.py @@ -0,0 +1,1007 @@ +import logging +from abc import abstractmethod +from dataclasses import dataclass +from typing import List, Optional, Tuple + +import torch +import torch.distributed as dist +import torch.nn as nn +from transformers import AutoModelForCausalLM + +from specforge.distributed import get_tp_device_mesh, get_tp_group +from specforge.utils import padding + +from .base import TargetEngine + +# SGLang internals back the *sglang* target backend only. Keep these imports +# optional so `import specforge` (and the HF / offline / draft paths) still works +# when the installed sglang version does not expose the exact symbols this file +# pins. The SGLang backend then surfaces a clear error at construction time +# (see SGLangEagle3TargetModel.from_pretrained). This keeps the engine behind a +# replaceable boundary rather than a hard, version-locked import dependency. +try: + import sglang.srt.managers.mm_utils as mm_utils + from sglang.srt.configs.model_config import ModelConfig + from sglang.srt.layers.rotary_embedding import MRotaryEmbedding + from sglang.srt.managers.mm_utils import ( + MultiModalityDataPaddingPatternMultimodalTokens, + init_mm_embedding_cache, + ) + from sglang.srt.managers.schedule_batch import ( + Modality, + MultimodalDataItem, + MultimodalInputs, + Req, + ScheduleBatch, + ) + + # prepare_mlp_sync_batch_raw is a module-level function, not a Scheduler method + from sglang.srt.managers.scheduler_dp_attn_mixin import prepare_mlp_sync_batch_raw + from sglang.srt.mem_cache.cache_init_params import CacheInitParams + from sglang.srt.mem_cache.radix_cache import RadixCache + from sglang.srt.model_executor.forward_batch_info import ( + CaptureHiddenMode, + ForwardBatch, + ) + from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor + from sglang.srt.sampling.sampling_params import SamplingParams + from sglang.srt.server_args import ServerArgs + from sglang.srt.speculative.spec_info import SpeculativeAlgorithm + from sglang.srt.utils import require_mlp_sync, require_mlp_tp_gather + + from .sglang_backend import SGLangRunner, wrap_eagle3_logits_processors_in_module + from .sglang_backend.utils import LogitsProcessorForEAGLE3 + + _SGLANG_IMPORT_ERROR = None +except Exception as _exc: # pragma: no cover - depends on installed sglang version + _SGLANG_IMPORT_ERROR = _exc + mm_utils = ModelConfig = MRotaryEmbedding = None + MultiModalityDataPaddingPatternMultimodalTokens = init_mm_embedding_cache = None + Modality = MultimodalDataItem = MultimodalInputs = Req = ScheduleBatch = None + prepare_mlp_sync_batch_raw = CacheInitParams = RadixCache = None + CaptureHiddenMode = ForwardBatch = BaseMultimodalProcessor = None + SamplingParams = ServerArgs = SpeculativeAlgorithm = None + require_mlp_sync = require_mlp_tp_gather = None + SGLangRunner = wrap_eagle3_logits_processors_in_module = None + LogitsProcessorForEAGLE3 = None + +logger = logging.getLogger(__name__) + + +@dataclass +class Eagle3TargetOutput: + hidden_states: torch.Tensor + target: torch.Tensor + loss_mask: torch.Tensor + input_ids: torch.Tensor + attention_mask: torch.Tensor + last_hidden_states: Optional[torch.Tensor] = None + + +class Eagle3TargetEngine(TargetEngine): + """EAGLE3 target engine — the algorithm ABC over a frozen target backend. + + Offers a layer of abstraction for the target model backend. The user can + choose different backends to suit their needs: + 1. SGLang backend: for the mainstream model support with the fastest inference speed + 2. HuggingFace backend: for models that are not supported by SGLang but can be loaded by HuggingFace. + 3. Custom backend: for models with customized architecture and inference plan. + + EAGLE3 captures three *aux* hidden-state layers plus (optionally) target + logits. The generic :meth:`capture` / :meth:`set_capture_layers` hooks from + :class:`TargetEngine` are thin dispatchers onto the EAGLE3-specific + ``generate_eagle3_data`` / ``set_aux_hidden_states_layers`` below, so the + extraction is byte-identical to the pre-Phase-B path. + """ + + def __init__(self): + self.aux_hidden_states_layers = None + + @classmethod + @abstractmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + **kwargs, + ) -> "Eagle3TargetEngine": + """ + Initialize the target model backend from a pretrained model path. + """ + + @abstractmethod + def generate_eagle3_data( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + **kwargs, + ) -> Eagle3TargetOutput: + """ + Generate the eagle3 data from the target model. + """ + + def capture( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + **kwargs, + ) -> Eagle3TargetOutput: + """Generic extraction entry point (see :meth:`TargetEngine.capture`). + + Dispatches to the EAGLE3-specific ``generate_eagle3_data``. + """ + return self.generate_eagle3_data( + input_ids=input_ids, + attention_mask=attention_mask, + loss_mask=loss_mask, + **kwargs, + ) + + def set_capture_layers(self, layer_ids: Optional[List[int]] = None) -> None: + """Generic alias for EAGLE3 aux-layer selection (TargetEngine hook).""" + self.set_aux_hidden_states_layers(layer_ids) + + def set_aux_hidden_states_layers( + self, aux_hidden_states_layers: Optional[List[int]] = None + ) -> None: + """ + Set the layers to capture the aux hidden states from the target model outputs. + """ + if aux_hidden_states_layers is None: + if hasattr(self.model.config, "num_hidden_layers"): + num_layers = self.model.config.num_hidden_layers + else: + raise ValueError( + f"Failed to set aux hidden states layers as model config {self.model.config} does not have num_hidden_layers" + ) + aux_hidden_states_layers = [ + 1, + num_layers // 2 - 1, + num_layers - 4, + ] + self.aux_hidden_states_layers = aux_hidden_states_layers + assert ( + len(self.aux_hidden_states_layers) == 3 + ), "aux_hidden_states_layers is expected to be 3 layers for EAGLE3" + + +class HFEagle3TargetEngine(Eagle3TargetEngine): + + backend = "hf" + + def __init__(self, model: nn.Module): + super().__init__() + self.model = model + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + **kwargs, + ) -> "HFEagle3TargetEngine": + """ + Initialize the HuggingFace target model backend from a pretrained model path. + """ + tp_size = get_tp_group().size() + + if tp_size > 1: + device_kwargs = { + "tp_plan": "auto", + "tp_size": tp_size, + "device_mesh": get_tp_device_mesh(), + } + else: + device_kwargs = { + "device_map": device, + } + + target_model = AutoModelForCausalLM.from_pretrained( + pretrained_model_name_or_path, + torch_dtype=torch_dtype, + cache_dir=cache_dir, + **device_kwargs, + **kwargs, + ) + return cls(target_model) + + def _get_transformer_layers(self): + """ + Helper to find the module list containing the transformer layers. + Adapts to common architectures (Llama, Qwen, Mistral, OPT, etc.) + """ + if hasattr(self.model, "model") and hasattr(self.model.model, "layers"): + return self.model.model.layers + elif hasattr(self.model, "layers"): + return self.model.layers + elif hasattr(self.model, "transformer") and hasattr( + self.model.transformer, "h" + ): + return self.model.transformer.h + else: + raise ValueError( + "Could not locate transformer layers in the model architecture to register hooks." + ) + + @torch.no_grad() + def generate_eagle3_data( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + **kwargs, + ) -> Eagle3TargetOutput: + """ + Optimized HF backend: + Instead of returning all hidden states (memory heavy), we use forward hooks + to capture only the specific layers required by Eagle3. + """ + if kwargs: + logger.debug(f"unused kwargs {list(kwargs.keys())}") + + captured_states = {} + handles = [] + + def get_hook(layer_idx): + def hook(module, input, output): + # HF outputs for layers are usually tuples (hidden_states, present_key_value, ...) + # We only need the hidden_states (first element) + if isinstance(output, tuple): + hidden = output[0] + else: + hidden = output + captured_states[layer_idx] = hidden + + return hook + + # Locate the transformer layers ModuleList + layers = self._get_transformer_layers() + + target_indices = self.aux_hidden_states_layers + + # Register hooks + for idx in target_indices: + # Ensure index is within bounds + if 0 <= idx < len(layers): + handles.append(layers[idx].register_forward_hook(get_hook(idx))) + else: + raise ValueError( + f"Layer index {idx} out of bounds for model with {len(layers)} layers." + ) + + try: + outputs = self.model( + input_ids=input_ids, + attention_mask=attention_mask, + output_hidden_states=False, + output_attentions=False, + output_router_logits=False, + use_cache=False, + ) + target = outputs.logits + finally: + # Always remove hooks to prevent memory leaks or side effects on subsequent calls + for handle in handles: + handle.remove() + + # Verify we captured everything + if len(captured_states) != 3: + raise RuntimeError( + f"Expected to capture 3 layers, but captured {len(captured_states)}" + ) + + # Extract in the correct order + hidden_states0 = captured_states[target_indices[0]] + hidden_states1 = captured_states[target_indices[1]] + hidden_states2 = captured_states[target_indices[2]] + + hidden_states = torch.cat( + (hidden_states0, hidden_states1, hidden_states2), dim=-1 + ) + + # apply pading + target = outputs.logits + target = padding(target, left=False) + input_ids = padding(input_ids, left=False) + loss_mask = loss_mask[..., None].to(target.device) + + return Eagle3TargetOutput( + hidden_states=hidden_states, + target=target, + loss_mask=loss_mask, + input_ids=input_ids, + attention_mask=attention_mask, + ) + + +class SGLangEagle3TargetEngine(Eagle3TargetEngine): + + backend = "sglang" + + def __init__(self, model_runner: SGLangRunner, hf_config=None): + super().__init__() + self.model_runner = model_runner + self.hf_config = hf_config + + # VLM-specific attributes (initialized from hf_config if available) + self._init_vlm_attributes() + + def _init_vlm_attributes(self): + """Initialize VLM-specific attributes from hf_config for models like Qwen2.5-VL""" + if self.hf_config is None: + self.is_vlm = False + return + + # Check if this is a VLM model by looking for vision_config + self.is_vlm = hasattr(self.hf_config, "vision_config") + + if not self.is_vlm: + return + + init_mm_embedding_cache(1024 * 1024 * 512) + # Model type (e.g., "qwen2_5_vl", "qwen2_vl") + self.model_type = getattr(self.hf_config, "model_type", None) + + # Vision config attributes + vision_config = self.hf_config.vision_config + self.spatial_merge_size = getattr(vision_config, "spatial_merge_size", 2) + self.tokens_per_second = getattr(vision_config, "tokens_per_second", None) + + # Special token IDs from hf_config + self.image_token_id = getattr(self.hf_config, "image_token_id", None) + self.video_token_id = getattr(self.hf_config, "video_token_id", None) + self.vision_start_token_id = getattr( + self.hf_config, "vision_start_token_id", None + ) + self.vision_end_token_id = getattr(self.hf_config, "vision_end_token_id", None) + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + trust_remote_code: bool = False, + **kwargs, + ) -> "SGLangEagle3TargetEngine": + tp_size = dist.get_world_size(get_tp_group()) + # NOTE: sglang 0.5.9 requires dtype to be non-None + # If torch_dtype is None, use "auto" to let sglang decide the dtype + dtype_arg = torch_dtype if torch_dtype is not None else "auto" + server_args = ServerArgs( + model_path=pretrained_model_name_or_path, + trust_remote_code=trust_remote_code, + dtype=dtype_arg, + enable_return_hidden_states=True, + disable_cuda_graph=True, # we use piecewise cuda graph for prefill instead + tp_size=tp_size, + pp_size=1, + **kwargs, + ) + + tp_rank = dist.get_rank(get_tp_group()) + moe_ep_rank = tp_rank // (server_args.tp_size // server_args.ep_size) + model_config = ModelConfig.from_server_args(server_args) + # - Added is_draft_worker=False parameter (new in 0.5.9) + # - Other new parameters (dp_rank, attn_cp_rank, moe_dp_rank, etc.) use default values + model_runner = SGLangRunner( + model_config=model_config, + mem_fraction_static=server_args.mem_fraction_static, + gpu_id=torch.cuda.current_device(), + tp_rank=dist.get_rank(get_tp_group()), + tp_size=server_args.tp_size, + moe_ep_rank=moe_ep_rank, + moe_ep_size=server_args.ep_size, + pp_rank=0, + pp_size=1, + server_args=server_args, + nccl_port=None, + is_draft_worker=False, + ) + wrap_eagle3_logits_processors_in_module( + model_runner.model, return_full_logits=False + ) + + # Get hf_config from model_config for VLM attributes + hf_config = getattr(model_config, "hf_config", None) + + return cls(model_runner, hf_config=hf_config) + + def set_aux_hidden_states_layers( + self, aux_hidden_states_layers: Optional[List[int]] = None + ) -> None: + self.model_runner.model.set_eagle3_layers_to_capture(aux_hidden_states_layers) + + @torch.no_grad + def _extend( + self, + reqs, + capture_aux_hidden_states: bool = True, + return_last_hidden_states: bool = False, + return_logits: bool = False, + shard_returns: bool = False, + ): + # set the logits processor for the model runner + for name, module in self.model_runner.model.named_modules(): + if isinstance(module, LogitsProcessorForEAGLE3): + module.return_last_hidden_states = return_last_hidden_states + module.return_logits = return_logits + module.shard_returns = shard_returns + + cache_params = CacheInitParams( + disable=False, + req_to_token_pool=self.model_runner.req_to_token_pool, + token_to_kv_pool_allocator=self.model_runner.token_to_kv_pool_allocator, + page_size=self.model_runner.server_args.page_size, + ) + tree_cache = RadixCache(cache_params) + + batch = ScheduleBatch.init_new( + reqs=reqs, + req_to_token_pool=self.model_runner.req_to_token_pool, + token_to_kv_pool_allocator=self.model_runner.token_to_kv_pool_allocator, + tree_cache=tree_cache, + model_config=self.model_runner.model_config, + enable_overlap=False, + spec_algorithm=SpeculativeAlgorithm.NONE, + ) + batch.prepare_for_extend() + self._maybe_prepare_mlp_sync_batch(batch) + model_worker_batch = batch.get_model_worker_batch() + forward_batch = ForwardBatch.init_new(model_worker_batch, self.model_runner) + forward_batch.capture_hidden_mode = CaptureHiddenMode.FULL + eagle3_output = self.model_runner.forward(forward_batch).logits_output + input_lens = [len(req.origin_input_ids) for req in reqs] + + logits = eagle3_output.logits + aux_hidden_states = eagle3_output.aux_hidden_states + last_hidden_states = eagle3_output.last_hidden_states + + if shard_returns: + tp_rank = dist.get_rank(get_tp_group()) + tp_size = dist.get_world_size(get_tp_group()) + batch_size = len(input_lens) // tp_size + valid_indices = list( + range(tp_rank * batch_size, (tp_rank + 1) * batch_size) + ) + valid_input_lens = [input_lens[i] for i in valid_indices] + + if return_logits: + if shard_returns: + logits = _get_sharded_return( + logits, + input_lens, + valid_input_lens, + valid_indices, + ) + else: + logits = torch.split(logits, input_lens, dim=0) + else: + logits = [None] * len(reqs) + + if capture_aux_hidden_states: + if shard_returns: + aux_hidden_states = _get_sharded_return( + aux_hidden_states, + input_lens, + valid_input_lens, + valid_indices, + ) + else: + aux_hidden_states = torch.split(aux_hidden_states, input_lens, dim=0) + else: + aux_hidden_states = [None] * len(reqs) + + if return_last_hidden_states: + if shard_returns: + last_hidden_states = _get_sharded_return( + last_hidden_states, + input_lens, + valid_input_lens, + valid_indices, + ) + else: + last_hidden_states = torch.split(last_hidden_states, input_lens, dim=0) + else: + last_hidden_states = [None] * len(reqs) + + # TODO: can we not clear? + self.model_runner.req_to_token_pool.clear() + self.model_runner.token_to_kv_pool_allocator.clear() + return logits, aux_hidden_states, last_hidden_states + + def _maybe_prepare_mlp_sync_batch(self, batch: ScheduleBatch): + if require_mlp_sync(self.model_runner.server_args): + # - Removed spec_algorithm and speculative_num_draft_tokens parameters + # - Added attn_cp_size parameter + # - Changed from Scheduler.prepare_mlp_sync_batch_raw to direct function call + prepare_mlp_sync_batch_raw( + batch, + dp_size=self.model_runner.server_args.dp_size, + attn_tp_size=1, + attn_cp_size=getattr(self.model_runner.server_args, "attn_cp_size", 1), + tp_group=self.model_runner.tp_group, + get_idle_batch=None, + disable_cuda_graph=self.model_runner.server_args.disable_cuda_graph, + require_mlp_tp_gather=require_mlp_tp_gather( + self.model_runner.server_args + ), + disable_overlap_schedule=self.model_runner.server_args.disable_overlap_schedule, + offload_tags=set(), + ) + + def extend( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + return_last_hidden_states: bool = False, + return_logits: bool = True, + shard_returns: bool = False, + ): + sampling_params = SamplingParams(temperature=0, max_new_tokens=1, top_k=1) + reqs, data_cache = [], [] + + if isinstance(input_ids, torch.Tensor): + input_ids = torch.split(input_ids, 1, dim=0) + attention_mask = torch.split(attention_mask, 1, dim=0) + loss_mask = torch.split(loss_mask, 1, dim=0) + + for idx, (input_id_, attention_mask_, loss_mask_) in enumerate( + zip( + input_ids, + attention_mask, + loss_mask, + ) + ): + req = Req( + rid=str(idx), + origin_input_text="", + origin_input_ids=input_id_.view(-1).tolist(), + sampling_params=sampling_params, + ) + req.fill_ids = req.origin_input_ids + req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) + req.logprob_start_len = len(req.origin_input_ids) - 1 + data_cache.append([input_id_, attention_mask_, loss_mask_]) + reqs.append(req) + + logits_list, aux_hidden_states_list, last_hidden_states_list = self._extend( + reqs, + capture_aux_hidden_states=True, + return_last_hidden_states=return_last_hidden_states, + return_logits=return_logits, + shard_returns=shard_returns, + ) + + return data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list + + def get_rope_index( + self, + input_ids: torch.Tensor, + image_grid_thw: Optional[torch.Tensor] = None, + video_grid_thw: Optional[torch.Tensor] = None, + second_per_grid_ts: Optional[torch.Tensor] = None, + attention_mask: Optional[torch.Tensor] = None, + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """ + Get M-RoPE position indices for VLM models like Qwen2.5-VL. + + This is a wrapper around MRotaryEmbedding.get_rope_index that uses + the VLM-specific attributes initialized from hf_config. + + Args: + input_ids: (batch_size, seq_len) input token IDs + image_grid_thw: (num_images, 3) image grid dimensions (t, h, w) + video_grid_thw: (num_videos, 3) video grid dimensions (t, h, w) + second_per_grid_ts: Optional temporal information for videos + attention_mask: (batch_size, seq_len) attention mask + + Returns: + position_ids: (3, batch_size, seq_len) M-RoPE position IDs + rope_deltas: Optional position deltas for incremental decoding + """ + if not self.is_vlm: + raise ValueError("get_rope_index is only available for VLM models") + + from sglang.srt.layers.rotary_embedding import MRotaryEmbedding + + position_ids, rope_deltas = MRotaryEmbedding.get_rope_index( + spatial_merge_size=self.spatial_merge_size, + image_token_id=self.image_token_id, + video_token_id=self.video_token_id, + vision_start_token_id=self.vision_start_token_id, + model_type=self.model_type, + input_ids=input_ids, + image_grid_thw=image_grid_thw, + video_grid_thw=video_grid_thw, + second_per_grid_ts=second_per_grid_ts, + attention_mask=attention_mask, + tokens_per_second=self.tokens_per_second, + ) + + return position_ids, rope_deltas + + def extend_vlm( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + return_last_hidden_states: bool = False, + return_logits: bool = True, + pixel_values: Optional[List[torch.Tensor]] = None, + image_grid_thw: Optional[List[torch.Tensor]] = None, + ): + """ + Args: + input_ids: (batch_size, seq_len) or List of (1, seq_len) tensors + attention_mask: (batch_size, seq_len) or List of (1, seq_len) tensors + loss_mask: (batch_size, seq_len) or List of (1, seq_len) tensors + pixel_values: List of pixel_values tensors, one per sample in batch + image_grid_thw: List of image_grid_thw tensors, one per sample in batch + """ + mm_utils.embedding_cache.clear() + sampling_params = SamplingParams(temperature=0, max_new_tokens=1, top_k=1) + reqs, data_cache = [], [] + + # Split tensors if needed + if isinstance(input_ids, torch.Tensor): + batch_size = input_ids.shape[0] + input_ids = torch.split(input_ids, 1, dim=0) + attention_mask = torch.split(attention_mask, 1, dim=0) + loss_mask = torch.split(loss_mask, 1, dim=0) + else: + batch_size = len(input_ids) + # Process image_grid_thw - convert to list if needed + if image_grid_thw is None: + image_grid_thw = [None] * batch_size + elif not isinstance(image_grid_thw, (list, tuple)): + image_grid_thw = [image_grid_thw] + + # pixel_values is a single 2D tensor (total_patches, patch_dim) for Qwen2.5-VL + # We need to track offset and slice it based on image_grid_thw for each sample + pixel_values_offset = 0 # Track current offset in pixel_values + + for idx, (input_id_, attention_mask_, loss_mask_, image_grid_thw_) in enumerate( + zip( + input_ids, + attention_mask, + loss_mask, + image_grid_thw, + ) + ): + # Compute num_patches for this sample from image_grid_thw_ + # image_grid_thw_: (num_images, 3) where each row is (t, h, w) + if image_grid_thw_ is not None: + # Ensure image_grid_thw_ is 2D: (num_images, 3) + if image_grid_thw_.dim() == 1: + image_grid_thw_ = image_grid_thw_.unsqueeze(0) # (3,) -> (1, 3) + elif image_grid_thw_.dim() == 0: + raise ValueError( + f"image_grid_thw_ is 0-dim tensor, expected at least 1D. Value: {image_grid_thw_}" + ) + + # Calculate num_patches for this sample: sum(t * h * w) for all images + num_patches = ( + ( + image_grid_thw_[:, 0] + * image_grid_thw_[:, 1] + * image_grid_thw_[:, 2] + ) + .sum() + .item() + ) + num_patches = int(num_patches) + + # Slice pixel_values for this sample + pixel_value_ = pixel_values[ + pixel_values_offset : pixel_values_offset + num_patches + ] + pixel_values_offset += num_patches + else: + pixel_value_ = None + num_patches = 0 + + # Compute mrope positions for VLM models (e.g., Qwen2.5-VL) + input_id_flat = input_id_.view(-1) + + # Count image tokens + num_img_tokens = (input_id_flat == self.image_token_id).sum().item() + # print(f"[extend_vlm] num_img_tokens in input_ids: {num_img_tokens}") + + mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index( + spatial_merge_size=self.spatial_merge_size, + image_token_id=self.image_token_id, + video_token_id=self.video_token_id, + vision_start_token_id=self.vision_start_token_id, + model_type=self.model_type, + input_ids=input_id_flat.unsqueeze(0).cpu(), + image_grid_thw=( + image_grid_thw_.cpu() if image_grid_thw_ is not None else None + ), + tokens_per_second=self.tokens_per_second, + ) + + offset = BaseMultimodalProcessor.get_mm_items_offset( + input_id_flat, self.image_token_id + ) + mm_item = MultimodalDataItem( + modality=Modality.IMAGE, + feature=pixel_value_, # torch.Tensor: (num_patches, patch_dim) + pad_value=self.image_token_id, # Required for placeholder tensor creation + offsets=offset, # List of (start, end) tuples + ) + mm_item.set("image_grid_thw", image_grid_thw_.cpu()) + mm_item.set_pad_value() + mm_inputs = MultimodalInputs( + mm_items=[mm_item], + im_token_id=self.image_token_id, + im_start_id=self.vision_start_token_id, + im_end_id=self.vision_end_token_id, + mrope_positions=( + mrope_positions.squeeze(1) if mrope_positions is not None else None + ), + mrope_position_delta=mrope_position_delta, + ) + pattern = MultiModalityDataPaddingPatternMultimodalTokens() + input_id_list = pattern.pad_input_tokens( + input_id_.view(-1).tolist(), mm_inputs + ) + req = Req( + rid=str(idx), + origin_input_text="", + origin_input_ids=input_id_list, + sampling_params=sampling_params, + ) + req.fill_ids = req.origin_input_ids + req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) + req.logprob_start_len = len(req.origin_input_ids) - 1 + req.multimodal_inputs = mm_inputs + data_cache.append([input_id_, attention_mask_, loss_mask_]) + reqs.append(req) + + logits_list, aux_hidden_states_list, last_hidden_states_list = self._extend( + reqs, + capture_aux_hidden_states=True, + return_last_hidden_states=return_last_hidden_states, + return_logits=return_logits, + ) + + return data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list + + @torch.no_grad() + def generate_eagle3_data( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + pixel_values: Optional[torch.Tensor] = None, + image_grid_thw: Optional[torch.Tensor] = None, + is_vlm: bool = False, + shard_returns: bool = False, + **kwargs, + ) -> Eagle3TargetOutput: + """ + return: + data_for_draft: List[Dict[str, torch.Tensor]] of draft_batch_size, draft_micro_batch_size = 1 + - input_ids: (1, seq_len) + - attention_mask: (1, seq_len) + - loss_mask: (1, seq_len) + - target: (1, seq_len, vocab_size) or (1, seq_len, hidden_size) + - hidden_states: (1, seq_len, hidden_size) + - pixel_values: (patch_len, patch_width) + - image_grid_thw (batch_size, 3) + """ + if kwargs: + logger.debug(f"unused kwargs {list(kwargs.keys())}") + + if is_vlm: + data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list = ( + self.extend_vlm( + input_ids, + attention_mask, + loss_mask, + return_last_hidden_states=False, + return_logits=True, + pixel_values=pixel_values, + image_grid_thw=image_grid_thw, + ) + ) + else: + data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list = ( + self.extend( + input_ids, + attention_mask, + loss_mask, + return_last_hidden_states=False, + return_logits=True, + shard_returns=shard_returns, + ) + ) + aux_hidden_states_out = [] + target_out = [] + loss_mask_out = [] + attention_mask_out = [] + input_ids_out = [] + last_hidden_states_out = [] + + for idx, (data, logits, aux_hidden_states, last_hidden_states) in enumerate( + zip( + data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list + ) + ): + if aux_hidden_states is not None: + aux_hidden_states_out.append(aux_hidden_states.unsqueeze(0)) + loss_mask_out.append(data[2]) + attention_mask_out.append(data[1]) + input_ids_out.append(data[0]) + + # when generating hidden states for offline training, we don't compute logits and only keep the last_hidden_states + # when training online, we don't keep the last_hidden_states and only keep the logits + if logits is not None: + target_out.append(logits.unsqueeze(0)) + + if last_hidden_states is not None: + last_hidden_states_out.append(last_hidden_states.unsqueeze(0)) + + aux_hidden_states_out = torch.cat(aux_hidden_states_out, dim=0) + + loss_mask_out = torch.cat(loss_mask_out, dim=0) + attention_mask_out = torch.cat(attention_mask_out, dim=0) + input_ids_out = torch.cat(input_ids_out, dim=0) + + if target_out: + target_out = torch.cat(target_out, dim=0) + else: + target_out = None + + if last_hidden_states_out: + last_hidden_states_out = torch.cat(last_hidden_states_out, dim=0) + else: + last_hidden_states_out = None + + if target_out is not None: + target_out = padding(target_out, left=False) + input_ids_out = padding(input_ids_out, left=False) + loss_mask_out = loss_mask_out[..., None] + + return Eagle3TargetOutput( + hidden_states=aux_hidden_states_out, + target=target_out, + loss_mask=loss_mask_out, + input_ids=input_ids_out, + attention_mask=attention_mask_out, + last_hidden_states=last_hidden_states_out, + ) + + +class CustomEagle3TargetEngine(Eagle3TargetEngine): + + backend = "custom" + + def __init__(self, model: nn.Module): + super().__init__() + self.model = model + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + **kwargs, + ) -> "CustomEagle3TargetEngine": + from specforge.modeling.auto import AutoDistributedTargetModel + + target_model = AutoDistributedTargetModel.from_pretrained( + pretrained_model_name_or_path=pretrained_model_name_or_path, + torch_dtype=torch_dtype, + cache_dir=cache_dir, + device=device, + **kwargs, + ) + return cls(target_model) + + @torch.no_grad() + def generate_eagle3_data( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + **kwargs, + ) -> Eagle3TargetOutput: + if kwargs: + logger.debug(f"unused kwargs {list(kwargs.keys())}") + + outputs = self.model( + input_ids=input_ids, + attention_mask=attention_mask, + output_hidden_states=True, + layers_to_output_hidden_states=self.aux_hidden_states_layers, + use_cache=False, + ) + + # For custom backends, the model implementation is responsible for only + # returning the requested layers in `outputs.hidden_states`. + hidden_states = torch.cat(outputs.hidden_states, dim=-1) + + target = outputs.logits + target = padding(target, left=False) + input_ids = padding(input_ids, left=False) + loss_mask = loss_mask[..., None].to(target.device) + + return Eagle3TargetOutput( + hidden_states=hidden_states, + target=target, + loss_mask=loss_mask, + input_ids=input_ids, + attention_mask=attention_mask, + ) + + +def get_eagle3_target_model( + pretrained_model_name_or_path: str, + backend: str = "sglang", + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + **kwargs, +) -> Eagle3TargetEngine: + if backend == "sglang": + return SGLangEagle3TargetEngine.from_pretrained( + pretrained_model_name_or_path=pretrained_model_name_or_path, + torch_dtype=torch_dtype, + device=device, + cache_dir=cache_dir, + **kwargs, + ) + elif backend == "hf": + return HFEagle3TargetEngine.from_pretrained( + pretrained_model_name_or_path=pretrained_model_name_or_path, + torch_dtype=torch_dtype, + device=device, + cache_dir=cache_dir, + **kwargs, + ) + elif backend == "custom": + return CustomEagle3TargetEngine.from_pretrained( + pretrained_model_name_or_path=pretrained_model_name_or_path, + torch_dtype=torch_dtype, + device=device, + cache_dir=cache_dir, + **kwargs, + ) + else: + raise ValueError(f"Invalid backend: {backend}") + + +def _get_sharded_return( + input_: torch.Tensor, + input_lens: list[int], + valid_input_lens: list[int], + valid_indices: list[int], +) -> list[Optional[torch.Tensor]]: + out: list[Optional[torch.Tensor]] = [None] * len(input_lens) + input_scatter = torch.split(input_, valid_input_lens, dim=0) + for j, idx in enumerate(valid_indices): + out[idx] = input_scatter[j] + return out + + +# --- Back-compat aliases (pre-Phase-B names) ------------------------------- +# The de-EAGLE3 rename (``*TargetModel`` -> ``*TargetEngine``, ``Eagle3TargetModel`` +# -> ``Eagle3TargetEngine``) is import-compatible: existing scripts, tests and +# ``specforge.modeling`` re-exports keep importing the old names. These aliases +# are removed once callers migrate (tracked in the Phase E model-layout move). +Eagle3TargetModel = Eagle3TargetEngine +HFEagle3TargetModel = HFEagle3TargetEngine +SGLangEagle3TargetModel = SGLangEagle3TargetEngine +CustomEagle3TargetModel = CustomEagle3TargetEngine diff --git a/specforge/modeling/target/factory.py b/specforge/modeling/target/factory.py new file mode 100644 index 000000000..dbafa1045 --- /dev/null +++ b/specforge/modeling/target/factory.py @@ -0,0 +1,82 @@ +# coding=utf-8 +# Copyright 2024 The SpecForge team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +"""Generic target-engine factory (Phase B). + +``get_target_engine(strategy=..., backend=...)`` is the de-EAGLE3'd factory: it +dispatches on the *algorithm* (``eagle3`` / ``dflash``) and delegates to the +per-algorithm loaders, which in turn dispatch on the *backend* (sglang / hf / +custom / sglang_server). The legacy ``get_eagle3_target_model`` / +``get_dflash_target_model`` remain as thin shims (they ARE the per-algorithm +loaders this factory calls), so nothing that imports them breaks. +""" + +from __future__ import annotations + +from typing import Optional + +import torch + +from .base import TargetEngine + +# strategy name -> per-algorithm engine. Domino reuses the DFlash engine (same +# capture: concatenated layer hidden states, no target distribution). The loaders +# are imported LAZILY inside the factory: dflash_target_model imports sglang +# internals unconditionally, so eager import here would break the design property +# that ``import specforge`` works even when the installed sglang lacks the pinned +# symbols (eagle3_target_model guards its imports; see its module docstring). +_ENGINE_STRATEGIES = ("eagle3", "dflash", "domino") + + +def available_target_engines(): + """Strategy names with a registered target-engine loader.""" + return sorted(_ENGINE_STRATEGIES) + + +def _resolve_loader(strategy: str): + if strategy == "eagle3": + from .eagle3_target_model import get_eagle3_target_model + + return get_eagle3_target_model + if strategy in ("dflash", "domino"): + from .dflash_target_model import get_dflash_target_model + + return get_dflash_target_model + raise ValueError( + f"no target engine for strategy {strategy!r}; " + f"registered: {available_target_engines()}" + ) + + +def get_target_engine( + pretrained_model_name_or_path: str, + *, + strategy: str = "eagle3", + backend: str = "sglang", + torch_dtype: Optional[torch.dtype] = None, + device: Optional[str] = None, + cache_dir: Optional[str] = None, + **kwargs, +) -> TargetEngine: + """Load a frozen :class:`TargetEngine` for ``strategy`` on ``backend``. + + The single, algorithm-agnostic entry point launch code should prefer. The + older ``get_eagle3_target_model`` / ``get_dflash_target_model`` stay valid. + """ + loader = _resolve_loader(strategy) + return loader( + pretrained_model_name_or_path=pretrained_model_name_or_path, + backend=backend, + torch_dtype=torch_dtype, + device=device, + cache_dir=cache_dir, + **kwargs, + ) + + +__all__ = ["get_target_engine", "available_target_engines"] From 20f5f65d8387db06bf7e6dfc058c6c818dea4a39 Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Tue, 30 Jun 2026 19:25:24 -0700 Subject: [PATCH 02/24] =?UTF-8?q?[DataFlow=20runtime]=20Phase=20B2=20?= =?UTF-8?q?=E2=80=94=20decouple=20the=20target=20engine=20from=20the=20sgl?= =?UTF-8?q?ang=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract EVERY sglang internal + the duplicated extend/capture forward into one version-pinned boundary, `sglang_backend/capture.py::SGLangCaptureBackend`, and have the algorithm engines COMPOSE it instead of embedding it: SGLangCaptureBackend (the only place that imports sglang.srt.* for capture) · build() ServerArgs / ModelConfig / SGLangRunner wiring (unified) · _forward_extend() the single ScheduleBatch/ForwardBatch capture forward · _maybe_prepare_mlp_sync_batch() ONE (0.5.9) prepare_mlp_sync signature · extend / extend_vlm / extend_dflash / get_rope_index / set_eagle3_capture_layers SGLangEagle3TargetEngine / SGLangDFlashTargetEngine now hold a backend and do only torch-side output shaping — they import ZERO sglang internals (verified by tests/test_runtime/test_sglang_capture_backend.py, a pure-AST invariant). Why: before this, both sglang engines imported ~20 sglang symbols and each carried its own near-duplicate `_extend`; the two copies had drifted to DIFFERENT sglang API versions (eagle3 = module-level prepare_mlp_sync_batch_raw(attn_cp_size=); dflash = the removed Scheduler.prepare_mlp_sync_batch_raw(spec_algorithm=)). A sglang bump touched every subclass and the copies could silently diverge. Now a bump touches one file; "put the pieces together" (capture backend + shaping + adapter) instead of tangling the version into each algorithm. Behavior: - Byte-identical on the test configs (TP=1/2, dp=1): require_mlp_sync is False so the unified mlp-sync branch is skipped identically; construction, req building, the forward, splitting/shard logic, and pool-clear ordering are transplanted verbatim (`import specforge` stays sglang-optional via lazy import in from_pretrained; the engine forward is still under @torch.no_grad). - Two deliberate, flagged changes: (1) DFlash's mlp-sync now uses the same 0.5.9 signature as eagle3 — its old Scheduler.* call was latent-broken for dp>1; (2) dropped a stray debug print() in DFlash set_capture_layers. Also adds the `sglang_server` backend (SGLangServerEagle3TargetEngine): selectable via get_eagle3_target_model(backend="sglang_server"), construction raises an actionable NotImplementedError until the live-capture depth is set by the O1.3 spike (docs/roadmap/online-disaggregation.md §O1.3). Co-Authored-By: Claude Opus 4.8 --- .../target_engine/sglang_backend/capture.py | 75 +-- .../modeling/target/dflash_target_model.py | 166 +---- .../modeling/target/eagle3_target_model.py | 592 +++--------------- .../target/sglang_backend/__init__.py | 9 + .../test_sglang_capture_backend.py | 4 +- 5 files changed, 159 insertions(+), 687 deletions(-) create mode 100644 specforge/modeling/target/sglang_backend/__init__.py diff --git a/specforge/inference/target_engine/sglang_backend/capture.py b/specforge/inference/target_engine/sglang_backend/capture.py index 982e40aa2..2a07512e8 100644 --- a/specforge/inference/target_engine/sglang_backend/capture.py +++ b/specforge/inference/target_engine/sglang_backend/capture.py @@ -36,7 +36,6 @@ from __future__ import annotations -from array import array from typing import List, Optional, Tuple import sglang.srt.managers.mm_utils as mm_utils @@ -56,9 +55,8 @@ ScheduleBatch, ) -# 0.5.14 relocated prepare_mlp_sync_batch_raw to scheduler_components.dp_attn -# (module-level function, not a Scheduler method). -from sglang.srt.managers.scheduler_components.dp_attn import prepare_mlp_sync_batch_raw +# prepare_mlp_sync_batch_raw is a module-level function, not a Scheduler method +from sglang.srt.managers.scheduler_dp_attn_mixin import prepare_mlp_sync_batch_raw from sglang.srt.mem_cache.cache_init_params import CacheInitParams from sglang.srt.mem_cache.radix_cache import RadixCache from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode, ForwardBatch @@ -121,18 +119,15 @@ def build( original eagle3 path). """ tp_size = dist.get_world_size(get_tp_group()) - # 0.5.13+ requires a non-None dtype; "auto" lets sglang decide. + # NOTE: sglang 0.5.9 requires dtype to be non-None. If torch_dtype is None, + # use "auto" to let sglang decide the dtype. dtype_arg = torch_dtype if torch_dtype is not None else "auto" - # We prefill the whole batch in one forward, so disable chunked prefill: - # 0.5.14's eager runner otherwise caps per-call buffers at chunked_prefill_size - # (default 8192) and errors when our token count exceeds it. server_args = ServerArgs( model_path=pretrained_model_name_or_path, trust_remote_code=trust_remote_code, dtype=dtype_arg, enable_return_hidden_states=True, disable_cuda_graph=True, # we use piecewise cuda graph for prefill instead - chunked_prefill_size=-1, tp_size=tp_size, pp_size=1, **kwargs, @@ -157,12 +152,6 @@ def build( nccl_port=None, is_draft_worker=False, ) - # 0.5.14 moved post-load setup out of ModelRunner.initialize() (now - # weights-only). We drive the runner directly, so run it here: pools for - # forward(), attention backend, and the (eager) runner. - model_runner.alloc_memory_pool() - model_runner.init_attention_backends() - model_runner.init_cuda_graphs() if wrap_eagle3_logits: wrap_eagle3_logits_processors_in_module( model_runner.model, return_full_logits=return_full_logits @@ -267,17 +256,8 @@ def _forward_extend(self, reqs): ) batch.prepare_for_extend() self._maybe_prepare_mlp_sync_batch(batch) - # 0.5.13+ stages input_ids on pinned CPU and leaves batch.input_ids=None; - # the scheduler normally does this H2D copy, but we bypass it. - if getattr(batch, "prefill_input_ids_cpu", None) is not None: - batch.input_ids = batch.prefill_input_ids_cpu.to( - batch.device, non_blocking=True - ) - batch.prefill_input_ids_cpu = None - # 0.5.13+ dropped ModelWorkerBatch; ForwardBatch.init_new reads - # capture_hidden_mode off the ScheduleBatch, so set it first. - batch.capture_hidden_mode = CaptureHiddenMode.FULL - forward_batch = ForwardBatch.init_new(batch, self.model_runner) + model_worker_batch = batch.get_model_worker_batch() + forward_batch = ForwardBatch.init_new(model_worker_batch, self.model_runner) forward_batch.capture_hidden_mode = CaptureHiddenMode.FULL output = self.model_runner.forward(forward_batch) return output.logits_output if hasattr(output, "logits_output") else output @@ -300,7 +280,7 @@ def _set_eagle3_logits_flags( # --- EAGLE3 extend (text) ---------------------------------------------- @torch.no_grad - def _forward_eagle3_reqs( + def _extend_eagle3( self, reqs, capture_aux_hidden_states: bool = True, @@ -311,9 +291,8 @@ def _forward_eagle3_reqs( self._set_eagle3_logits_flags( return_last_hidden_states, return_logits, shard_returns ) - # Capture lengths before the forward: 0.5.13+ releases origin_input_ids in it. - input_lens = [len(req.origin_input_ids) for req in reqs] eagle3_output = self._forward_extend(reqs) + input_lens = [len(req.origin_input_ids) for req in reqs] logits = eagle3_output.logits aux_hidden_states = eagle3_output.aux_hidden_states @@ -361,7 +340,7 @@ def _forward_eagle3_reqs( self._clear_pools() return logits, aux_hidden_states, last_hidden_states - def extend_eagle3( + def extend( self, input_ids: torch.Tensor, attention_mask: torch.Tensor, @@ -391,17 +370,14 @@ def extend_eagle3( origin_input_ids=input_id_.view(-1).tolist(), sampling_params=sampling_params, ) - # 0.5.13+ replaced Req.fill_ids with full_untruncated_fill_ids + int - # fill_len (set by PrefillAdder, which we bypass; no prefix reuse). - req.full_untruncated_fill_ids = array("q", req.origin_input_ids) - req.fill_len = len(req.full_untruncated_fill_ids) - req.extend_input_len = req.fill_len - len(req.prefix_indices) + req.fill_ids = req.origin_input_ids + req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) req.logprob_start_len = len(req.origin_input_ids) - 1 data_cache.append([input_id_, attention_mask_, loss_mask_]) reqs.append(req) logits_list, aux_hidden_states_list, last_hidden_states_list = ( - self._forward_eagle3_reqs( + self._extend_eagle3( reqs, capture_aux_hidden_states=True, return_last_hidden_states=return_last_hidden_states, @@ -442,7 +418,7 @@ def get_rope_index( return position_ids, rope_deltas - def extend_eagle3_vlm( + def extend_vlm( self, input_ids: torch.Tensor, attention_mask: torch.Tensor, @@ -520,7 +496,7 @@ def extend_eagle3_vlm( # Count image tokens num_img_tokens = (input_id_flat == self.image_token_id).sum().item() - # print(f"[extend_eagle3_vlm] num_img_tokens in input_ids: {num_img_tokens}") + # print(f"[extend_vlm] num_img_tokens in input_ids: {num_img_tokens}") mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index( spatial_merge_size=self.spatial_merge_size, @@ -566,18 +542,15 @@ def extend_eagle3_vlm( origin_input_ids=input_id_list, sampling_params=sampling_params, ) - # 0.5.13+ replaced Req.fill_ids with full_untruncated_fill_ids + int - # fill_len (set by PrefillAdder, which we bypass; no prefix reuse). - req.full_untruncated_fill_ids = array("q", req.origin_input_ids) - req.fill_len = len(req.full_untruncated_fill_ids) - req.extend_input_len = req.fill_len - len(req.prefix_indices) + req.fill_ids = req.origin_input_ids + req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) req.logprob_start_len = len(req.origin_input_ids) - 1 req.multimodal_inputs = mm_inputs data_cache.append([input_id_, attention_mask_, loss_mask_]) reqs.append(req) logits_list, aux_hidden_states_list, last_hidden_states_list = ( - self._forward_eagle3_reqs( + self._extend_eagle3( reqs, capture_aux_hidden_states=True, return_last_hidden_states=return_last_hidden_states, @@ -587,10 +560,6 @@ def extend_eagle3_vlm( return data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list - # Back-compat aliases for the pre-B2 SGLangEagle3TargetEngine method surface. - extend = extend_eagle3 - extend_vlm = extend_eagle3_vlm - # --- DFlash extend ------------------------------------------------------ @torch.no_grad @@ -622,17 +591,13 @@ def extend_dflash( origin_input_ids=curr_ids.view(-1).tolist(), sampling_params=sampling_params, ) - # 0.5.13+ replaced Req.fill_ids with full_untruncated_fill_ids + int - # fill_len (set by PrefillAdder, which we bypass; no prefix reuse). - req.full_untruncated_fill_ids = array("q", req.origin_input_ids) - req.fill_len = len(req.full_untruncated_fill_ids) - req.extend_input_len = req.fill_len - len(req.prefix_indices) + req.fill_ids = req.origin_input_ids + req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) data_cache.append((curr_ids, curr_attn, curr_loss)) reqs.append(req) - # Capture lengths before the forward: 0.5.13+ releases origin_input_ids in it. - input_lens = [len(req.origin_input_ids) for req in reqs] output = self._forward_extend(reqs) + input_lens = [len(req.origin_input_ids) for req in reqs] if ( hasattr(output, "aux_hidden_states") and output.aux_hidden_states is not None diff --git a/specforge/modeling/target/dflash_target_model.py b/specforge/modeling/target/dflash_target_model.py index d23b23ef3..b8739e586 100644 --- a/specforge/modeling/target/dflash_target_model.py +++ b/specforge/modeling/target/dflash_target_model.py @@ -3,24 +3,17 @@ from typing import List, Optional import torch -import torch.distributed as dist import torch.nn as nn -from sglang.srt.configs.model_config import ModelConfig -from sglang.srt.managers.schedule_batch import Req, ScheduleBatch -from sglang.srt.managers.scheduler import Scheduler -from sglang.srt.mem_cache.cache_init_params import CacheInitParams -from sglang.srt.mem_cache.radix_cache import RadixCache -from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode, ForwardBatch -from sglang.srt.sampling.sampling_params import SamplingParams -from sglang.srt.server_args import ServerArgs -from sglang.srt.speculative.spec_info import SpeculativeAlgorithm -from sglang.srt.utils import require_mlp_sync, require_mlp_tp_gather from transformers import AutoModelForCausalLM -from specforge.distributed import get_tp_group - from .base import TargetEngine -from .sglang_backend import SGLangRunner + +# NOTE (Phase B2): this module no longer imports sglang internals. The +# SGLang-version-pinned capture path (ServerArgs / ModelConfig / SGLangRunner + +# the extend/capture forward) lives entirely in +# ``sglang_backend.SGLangCaptureBackend``, shared with the eagle3 engine (one +# copy of the forward + mlp-sync). The SGLang engine below composes it, imported +# lazily inside ``from_pretrained`` so ``import specforge`` stays sglang-agnostic. @dataclass @@ -92,9 +85,14 @@ class SGLangDFlashTargetEngine(DFlashTargetEngine): backend = "sglang" - def __init__(self, model_runner: SGLangRunner): - super().__init__() - self.model_runner = model_runner + def __init__(self, backend): # backend: sglang_backend.SGLangCaptureBackend + super().__init__() # capture_layer_ids = None + self._backend = backend + + @property + def model_runner(self): + """Kept for back-compat: the underlying sglang ModelRunner.""" + return self._backend.model_runner @classmethod def from_pretrained( @@ -106,106 +104,24 @@ def from_pretrained( trust_remote_code: bool = False, **kwargs, ) -> "SGLangDFlashTargetEngine": - tp_size = dist.get_world_size(get_tp_group()) - server_args = ServerArgs( - model_path=pretrained_model_name_or_path, + # Lazy import so `import specforge` still works without the pinned sglang: + # the sglang-version coupling lives entirely in SGLangCaptureBackend, which + # also unifies the extend/mlp-sync forward this engine used to duplicate. + from .sglang_backend import SGLangCaptureBackend + + backend = SGLangCaptureBackend.build( + pretrained_model_name_or_path, + torch_dtype=torch_dtype, trust_remote_code=trust_remote_code, - dtype=torch_dtype, - enable_return_hidden_states=True, # Critical for DFlash - disable_cuda_graph=True, - tp_size=tp_size, - pp_size=1, + wrap_eagle3_logits=False, **kwargs, ) - - tp_rank = dist.get_rank(get_tp_group()) - moe_ep_rank = tp_rank // (server_args.tp_size // server_args.ep_size) - model_config = ModelConfig.from_server_args(server_args) - - model_runner = SGLangRunner( - model_config=model_config, - mem_fraction_static=server_args.mem_fraction_static, - gpu_id=torch.cuda.current_device(), - tp_rank=dist.get_rank(get_tp_group()), - tp_size=server_args.tp_size, - moe_ep_rank=moe_ep_rank, - moe_ep_size=server_args.ep_size, - pp_rank=0, - pp_size=1, - server_args=server_args, - nccl_port=None, - ) - return cls(model_runner) + return cls(backend) def set_capture_layers(self, layer_ids: List[int]) -> None: - super().set_capture_layers(layer_ids) - if hasattr(self.model_runner.model, "set_eagle3_layers_to_capture"): - self.model_runner.model.set_eagle3_layers_to_capture(layer_ids) - print(self.model_runner.model.model.layers_to_capture) - - @torch.no_grad - def _extend(self, reqs): - cache_params = CacheInitParams( - disable=False, - req_to_token_pool=self.model_runner.req_to_token_pool, - token_to_kv_pool_allocator=self.model_runner.token_to_kv_pool_allocator, - page_size=self.model_runner.server_args.page_size, - ) - tree_cache = RadixCache(cache_params) - - batch = ScheduleBatch.init_new( - reqs=reqs, - req_to_token_pool=self.model_runner.req_to_token_pool, - token_to_kv_pool_allocator=self.model_runner.token_to_kv_pool_allocator, - tree_cache=tree_cache, - model_config=self.model_runner.model_config, - enable_overlap=False, - spec_algorithm=SpeculativeAlgorithm.NONE, - ) - batch.prepare_for_extend() - - if require_mlp_sync(self.model_runner.server_args): - Scheduler.prepare_mlp_sync_batch_raw( - batch, - dp_size=self.model_runner.server_args.dp_size, - attn_tp_size=1, - tp_group=self.model_runner.tp_group, - get_idle_batch=None, - disable_cuda_graph=self.model_runner.server_args.disable_cuda_graph, - spec_algorithm=SpeculativeAlgorithm.NONE, - speculative_num_draft_tokens=None, - require_mlp_tp_gather=require_mlp_tp_gather( - self.model_runner.server_args - ), - disable_overlap_schedule=self.model_runner.server_args.disable_overlap_schedule, - offload_tags=set(), - ) - - model_worker_batch = batch.get_model_worker_batch() - forward_batch = ForwardBatch.init_new(model_worker_batch, self.model_runner) - forward_batch.capture_hidden_mode = CaptureHiddenMode.FULL - - output = self.model_runner.forward(forward_batch) - if hasattr(output, "logits_output"): - output = output.logits_output - - input_lens = [len(req.origin_input_ids) for req in reqs] - if ( - hasattr(output, "aux_hidden_states") - and output.aux_hidden_states is not None - ): - hidden_states_list = torch.split( - output.aux_hidden_states, input_lens, dim=0 - ) - elif hasattr(output, "hidden_states") and output.hidden_states is not None: - hidden_states_list = torch.split(output.hidden_states, input_lens, dim=0) - else: - raise ValueError("SGLang output does not contain hidden states.") - - self.model_runner.req_to_token_pool.clear() - self.model_runner.token_to_kv_pool_allocator.clear() - - return hidden_states_list + super().set_capture_layers(layer_ids) # records self.capture_layer_ids + # Some target models expose set_eagle3_layers_to_capture; guard on it. + self._backend.set_eagle3_capture_layers(layer_ids, if_supported=True) @torch.no_grad() def generate_dflash_data( @@ -214,29 +130,9 @@ def generate_dflash_data( attention_mask: torch.Tensor, loss_mask: torch.Tensor, ) -> DFlashTargetOutput: - sampling_params = SamplingParams(temperature=0, max_new_tokens=1) - reqs, data_cache = [], [] - - if isinstance(input_ids, torch.Tensor): - input_ids_list = torch.split(input_ids, 1, dim=0) - attn_mask_list = torch.split(attention_mask, 1, dim=0) - loss_mask_list = torch.split(loss_mask, 1, dim=0) - - for idx, (curr_ids, curr_attn, curr_loss) in enumerate( - zip(input_ids_list, attn_mask_list, loss_mask_list) - ): - req = Req( - rid=str(idx), - origin_input_text="", - origin_input_ids=curr_ids.view(-1).tolist(), - sampling_params=sampling_params, - ) - req.fill_ids = req.origin_input_ids - req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) - data_cache.append((curr_ids, curr_attn, curr_loss)) - reqs.append(req) - - hidden_states_list = self._extend(reqs) + data_cache, hidden_states_list = self._backend.extend_dflash( + input_ids, attention_mask, loss_mask + ) # Stack back to batch hidden_states = torch.cat([h.unsqueeze(0) for h in hidden_states_list], dim=0) diff --git a/specforge/modeling/target/eagle3_target_model.py b/specforge/modeling/target/eagle3_target_model.py index 8ebeecde5..f6c2bc33a 100644 --- a/specforge/modeling/target/eagle3_target_model.py +++ b/specforge/modeling/target/eagle3_target_model.py @@ -1,10 +1,9 @@ import logging from abc import abstractmethod from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import List, Optional import torch -import torch.distributed as dist import torch.nn as nn from transformers import AutoModelForCausalLM @@ -13,57 +12,12 @@ from .base import TargetEngine -# SGLang internals back the *sglang* target backend only. Keep these imports -# optional so `import specforge` (and the HF / offline / draft paths) still works -# when the installed sglang version does not expose the exact symbols this file -# pins. The SGLang backend then surfaces a clear error at construction time -# (see SGLangEagle3TargetModel.from_pretrained). This keeps the engine behind a -# replaceable boundary rather than a hard, version-locked import dependency. -try: - import sglang.srt.managers.mm_utils as mm_utils - from sglang.srt.configs.model_config import ModelConfig - from sglang.srt.layers.rotary_embedding import MRotaryEmbedding - from sglang.srt.managers.mm_utils import ( - MultiModalityDataPaddingPatternMultimodalTokens, - init_mm_embedding_cache, - ) - from sglang.srt.managers.schedule_batch import ( - Modality, - MultimodalDataItem, - MultimodalInputs, - Req, - ScheduleBatch, - ) - - # prepare_mlp_sync_batch_raw is a module-level function, not a Scheduler method - from sglang.srt.managers.scheduler_dp_attn_mixin import prepare_mlp_sync_batch_raw - from sglang.srt.mem_cache.cache_init_params import CacheInitParams - from sglang.srt.mem_cache.radix_cache import RadixCache - from sglang.srt.model_executor.forward_batch_info import ( - CaptureHiddenMode, - ForwardBatch, - ) - from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor - from sglang.srt.sampling.sampling_params import SamplingParams - from sglang.srt.server_args import ServerArgs - from sglang.srt.speculative.spec_info import SpeculativeAlgorithm - from sglang.srt.utils import require_mlp_sync, require_mlp_tp_gather - - from .sglang_backend import SGLangRunner, wrap_eagle3_logits_processors_in_module - from .sglang_backend.utils import LogitsProcessorForEAGLE3 - - _SGLANG_IMPORT_ERROR = None -except Exception as _exc: # pragma: no cover - depends on installed sglang version - _SGLANG_IMPORT_ERROR = _exc - mm_utils = ModelConfig = MRotaryEmbedding = None - MultiModalityDataPaddingPatternMultimodalTokens = init_mm_embedding_cache = None - Modality = MultimodalDataItem = MultimodalInputs = Req = ScheduleBatch = None - prepare_mlp_sync_batch_raw = CacheInitParams = RadixCache = None - CaptureHiddenMode = ForwardBatch = BaseMultimodalProcessor = None - SamplingParams = ServerArgs = SpeculativeAlgorithm = None - require_mlp_sync = require_mlp_tp_gather = None - SGLangRunner = wrap_eagle3_logits_processors_in_module = None - LogitsProcessorForEAGLE3 = None +# NOTE (Phase B2): this module no longer imports sglang internals. The +# SGLang-version-pinned capture path (ServerArgs / ModelConfig / SGLangRunner + +# the extend/capture forward) lives entirely in +# ``sglang_backend.SGLangCaptureBackend``; the SGLang engine below composes it +# (imported lazily inside ``from_pretrained``). A sglang bump touches only that +# module — the engine and ``import specforge`` are sglang-version-agnostic. logger = logging.getLogger(__name__) @@ -324,42 +278,25 @@ class SGLangEagle3TargetEngine(Eagle3TargetEngine): backend = "sglang" - def __init__(self, model_runner: SGLangRunner, hf_config=None): + def __init__(self, backend): # backend: sglang_backend.SGLangCaptureBackend + # super().__init__() sets aux_hidden_states_layers = None. The sglang + # backend records capture layers on the model, not on this attribute + # (unchanged from before: the adapter reads it and gets None for sglang). super().__init__() - self.model_runner = model_runner - self.hf_config = hf_config - - # VLM-specific attributes (initialized from hf_config if available) - self._init_vlm_attributes() - - def _init_vlm_attributes(self): - """Initialize VLM-specific attributes from hf_config for models like Qwen2.5-VL""" - if self.hf_config is None: - self.is_vlm = False - return - - # Check if this is a VLM model by looking for vision_config - self.is_vlm = hasattr(self.hf_config, "vision_config") - - if not self.is_vlm: - return - - init_mm_embedding_cache(1024 * 1024 * 512) - # Model type (e.g., "qwen2_5_vl", "qwen2_vl") - self.model_type = getattr(self.hf_config, "model_type", None) - - # Vision config attributes - vision_config = self.hf_config.vision_config - self.spatial_merge_size = getattr(vision_config, "spatial_merge_size", 2) - self.tokens_per_second = getattr(vision_config, "tokens_per_second", None) - - # Special token IDs from hf_config - self.image_token_id = getattr(self.hf_config, "image_token_id", None) - self.video_token_id = getattr(self.hf_config, "video_token_id", None) - self.vision_start_token_id = getattr( - self.hf_config, "vision_start_token_id", None - ) - self.vision_end_token_id = getattr(self.hf_config, "vision_end_token_id", None) + self._backend = backend + + @property + def model_runner(self): + """Kept for back-compat: the underlying sglang ModelRunner.""" + return self._backend.model_runner + + @property + def hf_config(self): + return self._backend.hf_config + + @property + def is_vlm(self) -> bool: + return self._backend.is_vlm @classmethod def from_pretrained( @@ -371,410 +308,36 @@ def from_pretrained( trust_remote_code: bool = False, **kwargs, ) -> "SGLangEagle3TargetEngine": - tp_size = dist.get_world_size(get_tp_group()) - # NOTE: sglang 0.5.9 requires dtype to be non-None - # If torch_dtype is None, use "auto" to let sglang decide the dtype - dtype_arg = torch_dtype if torch_dtype is not None else "auto" - server_args = ServerArgs( - model_path=pretrained_model_name_or_path, + # Lazy import so `import specforge` still works without the pinned sglang: + # the entire sglang-version coupling lives in SGLangCaptureBackend. + from .sglang_backend import SGLangCaptureBackend + + backend = SGLangCaptureBackend.build( + pretrained_model_name_or_path, + torch_dtype=torch_dtype, trust_remote_code=trust_remote_code, - dtype=dtype_arg, - enable_return_hidden_states=True, - disable_cuda_graph=True, # we use piecewise cuda graph for prefill instead - tp_size=tp_size, - pp_size=1, + wrap_eagle3_logits=True, + return_full_logits=False, **kwargs, ) - - tp_rank = dist.get_rank(get_tp_group()) - moe_ep_rank = tp_rank // (server_args.tp_size // server_args.ep_size) - model_config = ModelConfig.from_server_args(server_args) - # - Added is_draft_worker=False parameter (new in 0.5.9) - # - Other new parameters (dp_rank, attn_cp_rank, moe_dp_rank, etc.) use default values - model_runner = SGLangRunner( - model_config=model_config, - mem_fraction_static=server_args.mem_fraction_static, - gpu_id=torch.cuda.current_device(), - tp_rank=dist.get_rank(get_tp_group()), - tp_size=server_args.tp_size, - moe_ep_rank=moe_ep_rank, - moe_ep_size=server_args.ep_size, - pp_rank=0, - pp_size=1, - server_args=server_args, - nccl_port=None, - is_draft_worker=False, - ) - wrap_eagle3_logits_processors_in_module( - model_runner.model, return_full_logits=False - ) - - # Get hf_config from model_config for VLM attributes - hf_config = getattr(model_config, "hf_config", None) - - return cls(model_runner, hf_config=hf_config) + return cls(backend) def set_aux_hidden_states_layers( self, aux_hidden_states_layers: Optional[List[int]] = None ) -> None: - self.model_runner.model.set_eagle3_layers_to_capture(aux_hidden_states_layers) - - @torch.no_grad - def _extend( - self, - reqs, - capture_aux_hidden_states: bool = True, - return_last_hidden_states: bool = False, - return_logits: bool = False, - shard_returns: bool = False, - ): - # set the logits processor for the model runner - for name, module in self.model_runner.model.named_modules(): - if isinstance(module, LogitsProcessorForEAGLE3): - module.return_last_hidden_states = return_last_hidden_states - module.return_logits = return_logits - module.shard_returns = shard_returns - - cache_params = CacheInitParams( - disable=False, - req_to_token_pool=self.model_runner.req_to_token_pool, - token_to_kv_pool_allocator=self.model_runner.token_to_kv_pool_allocator, - page_size=self.model_runner.server_args.page_size, - ) - tree_cache = RadixCache(cache_params) - - batch = ScheduleBatch.init_new( - reqs=reqs, - req_to_token_pool=self.model_runner.req_to_token_pool, - token_to_kv_pool_allocator=self.model_runner.token_to_kv_pool_allocator, - tree_cache=tree_cache, - model_config=self.model_runner.model_config, - enable_overlap=False, - spec_algorithm=SpeculativeAlgorithm.NONE, - ) - batch.prepare_for_extend() - self._maybe_prepare_mlp_sync_batch(batch) - model_worker_batch = batch.get_model_worker_batch() - forward_batch = ForwardBatch.init_new(model_worker_batch, self.model_runner) - forward_batch.capture_hidden_mode = CaptureHiddenMode.FULL - eagle3_output = self.model_runner.forward(forward_batch).logits_output - input_lens = [len(req.origin_input_ids) for req in reqs] - - logits = eagle3_output.logits - aux_hidden_states = eagle3_output.aux_hidden_states - last_hidden_states = eagle3_output.last_hidden_states - - if shard_returns: - tp_rank = dist.get_rank(get_tp_group()) - tp_size = dist.get_world_size(get_tp_group()) - batch_size = len(input_lens) // tp_size - valid_indices = list( - range(tp_rank * batch_size, (tp_rank + 1) * batch_size) - ) - valid_input_lens = [input_lens[i] for i in valid_indices] - - if return_logits: - if shard_returns: - logits = _get_sharded_return( - logits, - input_lens, - valid_input_lens, - valid_indices, - ) - else: - logits = torch.split(logits, input_lens, dim=0) - else: - logits = [None] * len(reqs) - - if capture_aux_hidden_states: - if shard_returns: - aux_hidden_states = _get_sharded_return( - aux_hidden_states, - input_lens, - valid_input_lens, - valid_indices, - ) - else: - aux_hidden_states = torch.split(aux_hidden_states, input_lens, dim=0) - else: - aux_hidden_states = [None] * len(reqs) - - if return_last_hidden_states: - if shard_returns: - last_hidden_states = _get_sharded_return( - last_hidden_states, - input_lens, - valid_input_lens, - valid_indices, - ) - else: - last_hidden_states = torch.split(last_hidden_states, input_lens, dim=0) - else: - last_hidden_states = [None] * len(reqs) - - # TODO: can we not clear? - self.model_runner.req_to_token_pool.clear() - self.model_runner.token_to_kv_pool_allocator.clear() - return logits, aux_hidden_states, last_hidden_states - - def _maybe_prepare_mlp_sync_batch(self, batch: ScheduleBatch): - if require_mlp_sync(self.model_runner.server_args): - # - Removed spec_algorithm and speculative_num_draft_tokens parameters - # - Added attn_cp_size parameter - # - Changed from Scheduler.prepare_mlp_sync_batch_raw to direct function call - prepare_mlp_sync_batch_raw( - batch, - dp_size=self.model_runner.server_args.dp_size, - attn_tp_size=1, - attn_cp_size=getattr(self.model_runner.server_args, "attn_cp_size", 1), - tp_group=self.model_runner.tp_group, - get_idle_batch=None, - disable_cuda_graph=self.model_runner.server_args.disable_cuda_graph, - require_mlp_tp_gather=require_mlp_tp_gather( - self.model_runner.server_args - ), - disable_overlap_schedule=self.model_runner.server_args.disable_overlap_schedule, - offload_tags=set(), - ) - - def extend( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - return_last_hidden_states: bool = False, - return_logits: bool = True, - shard_returns: bool = False, - ): - sampling_params = SamplingParams(temperature=0, max_new_tokens=1, top_k=1) - reqs, data_cache = [], [] - - if isinstance(input_ids, torch.Tensor): - input_ids = torch.split(input_ids, 1, dim=0) - attention_mask = torch.split(attention_mask, 1, dim=0) - loss_mask = torch.split(loss_mask, 1, dim=0) - - for idx, (input_id_, attention_mask_, loss_mask_) in enumerate( - zip( - input_ids, - attention_mask, - loss_mask, - ) - ): - req = Req( - rid=str(idx), - origin_input_text="", - origin_input_ids=input_id_.view(-1).tolist(), - sampling_params=sampling_params, - ) - req.fill_ids = req.origin_input_ids - req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) - req.logprob_start_len = len(req.origin_input_ids) - 1 - data_cache.append([input_id_, attention_mask_, loss_mask_]) - reqs.append(req) - - logits_list, aux_hidden_states_list, last_hidden_states_list = self._extend( - reqs, - capture_aux_hidden_states=True, - return_last_hidden_states=return_last_hidden_states, - return_logits=return_logits, - shard_returns=shard_returns, - ) - - return data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list - - def get_rope_index( - self, - input_ids: torch.Tensor, - image_grid_thw: Optional[torch.Tensor] = None, - video_grid_thw: Optional[torch.Tensor] = None, - second_per_grid_ts: Optional[torch.Tensor] = None, - attention_mask: Optional[torch.Tensor] = None, - ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: - """ - Get M-RoPE position indices for VLM models like Qwen2.5-VL. - - This is a wrapper around MRotaryEmbedding.get_rope_index that uses - the VLM-specific attributes initialized from hf_config. - - Args: - input_ids: (batch_size, seq_len) input token IDs - image_grid_thw: (num_images, 3) image grid dimensions (t, h, w) - video_grid_thw: (num_videos, 3) video grid dimensions (t, h, w) - second_per_grid_ts: Optional temporal information for videos - attention_mask: (batch_size, seq_len) attention mask - - Returns: - position_ids: (3, batch_size, seq_len) M-RoPE position IDs - rope_deltas: Optional position deltas for incremental decoding - """ - if not self.is_vlm: - raise ValueError("get_rope_index is only available for VLM models") - - from sglang.srt.layers.rotary_embedding import MRotaryEmbedding - - position_ids, rope_deltas = MRotaryEmbedding.get_rope_index( - spatial_merge_size=self.spatial_merge_size, - image_token_id=self.image_token_id, - video_token_id=self.video_token_id, - vision_start_token_id=self.vision_start_token_id, - model_type=self.model_type, - input_ids=input_ids, - image_grid_thw=image_grid_thw, - video_grid_thw=video_grid_thw, - second_per_grid_ts=second_per_grid_ts, - attention_mask=attention_mask, - tokens_per_second=self.tokens_per_second, - ) + self._backend.set_eagle3_capture_layers(aux_hidden_states_layers) - return position_ids, rope_deltas + # The extend / extend_vlm / get_rope_index forwards live in SGLangCaptureBackend + # (the version-pinned boundary); these delegators keep the engine's method + # surface stable while the engine itself imports no sglang internals. + def extend(self, *args, **kwargs): + return self._backend.extend(*args, **kwargs) - def extend_vlm( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - return_last_hidden_states: bool = False, - return_logits: bool = True, - pixel_values: Optional[List[torch.Tensor]] = None, - image_grid_thw: Optional[List[torch.Tensor]] = None, - ): - """ - Args: - input_ids: (batch_size, seq_len) or List of (1, seq_len) tensors - attention_mask: (batch_size, seq_len) or List of (1, seq_len) tensors - loss_mask: (batch_size, seq_len) or List of (1, seq_len) tensors - pixel_values: List of pixel_values tensors, one per sample in batch - image_grid_thw: List of image_grid_thw tensors, one per sample in batch - """ - mm_utils.embedding_cache.clear() - sampling_params = SamplingParams(temperature=0, max_new_tokens=1, top_k=1) - reqs, data_cache = [], [] - - # Split tensors if needed - if isinstance(input_ids, torch.Tensor): - batch_size = input_ids.shape[0] - input_ids = torch.split(input_ids, 1, dim=0) - attention_mask = torch.split(attention_mask, 1, dim=0) - loss_mask = torch.split(loss_mask, 1, dim=0) - else: - batch_size = len(input_ids) - # Process image_grid_thw - convert to list if needed - if image_grid_thw is None: - image_grid_thw = [None] * batch_size - elif not isinstance(image_grid_thw, (list, tuple)): - image_grid_thw = [image_grid_thw] - - # pixel_values is a single 2D tensor (total_patches, patch_dim) for Qwen2.5-VL - # We need to track offset and slice it based on image_grid_thw for each sample - pixel_values_offset = 0 # Track current offset in pixel_values - - for idx, (input_id_, attention_mask_, loss_mask_, image_grid_thw_) in enumerate( - zip( - input_ids, - attention_mask, - loss_mask, - image_grid_thw, - ) - ): - # Compute num_patches for this sample from image_grid_thw_ - # image_grid_thw_: (num_images, 3) where each row is (t, h, w) - if image_grid_thw_ is not None: - # Ensure image_grid_thw_ is 2D: (num_images, 3) - if image_grid_thw_.dim() == 1: - image_grid_thw_ = image_grid_thw_.unsqueeze(0) # (3,) -> (1, 3) - elif image_grid_thw_.dim() == 0: - raise ValueError( - f"image_grid_thw_ is 0-dim tensor, expected at least 1D. Value: {image_grid_thw_}" - ) - - # Calculate num_patches for this sample: sum(t * h * w) for all images - num_patches = ( - ( - image_grid_thw_[:, 0] - * image_grid_thw_[:, 1] - * image_grid_thw_[:, 2] - ) - .sum() - .item() - ) - num_patches = int(num_patches) + def extend_vlm(self, *args, **kwargs): + return self._backend.extend_vlm(*args, **kwargs) - # Slice pixel_values for this sample - pixel_value_ = pixel_values[ - pixel_values_offset : pixel_values_offset + num_patches - ] - pixel_values_offset += num_patches - else: - pixel_value_ = None - num_patches = 0 - - # Compute mrope positions for VLM models (e.g., Qwen2.5-VL) - input_id_flat = input_id_.view(-1) - - # Count image tokens - num_img_tokens = (input_id_flat == self.image_token_id).sum().item() - # print(f"[extend_vlm] num_img_tokens in input_ids: {num_img_tokens}") - - mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index( - spatial_merge_size=self.spatial_merge_size, - image_token_id=self.image_token_id, - video_token_id=self.video_token_id, - vision_start_token_id=self.vision_start_token_id, - model_type=self.model_type, - input_ids=input_id_flat.unsqueeze(0).cpu(), - image_grid_thw=( - image_grid_thw_.cpu() if image_grid_thw_ is not None else None - ), - tokens_per_second=self.tokens_per_second, - ) - - offset = BaseMultimodalProcessor.get_mm_items_offset( - input_id_flat, self.image_token_id - ) - mm_item = MultimodalDataItem( - modality=Modality.IMAGE, - feature=pixel_value_, # torch.Tensor: (num_patches, patch_dim) - pad_value=self.image_token_id, # Required for placeholder tensor creation - offsets=offset, # List of (start, end) tuples - ) - mm_item.set("image_grid_thw", image_grid_thw_.cpu()) - mm_item.set_pad_value() - mm_inputs = MultimodalInputs( - mm_items=[mm_item], - im_token_id=self.image_token_id, - im_start_id=self.vision_start_token_id, - im_end_id=self.vision_end_token_id, - mrope_positions=( - mrope_positions.squeeze(1) if mrope_positions is not None else None - ), - mrope_position_delta=mrope_position_delta, - ) - pattern = MultiModalityDataPaddingPatternMultimodalTokens() - input_id_list = pattern.pad_input_tokens( - input_id_.view(-1).tolist(), mm_inputs - ) - req = Req( - rid=str(idx), - origin_input_text="", - origin_input_ids=input_id_list, - sampling_params=sampling_params, - ) - req.fill_ids = req.origin_input_ids - req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) - req.logprob_start_len = len(req.origin_input_ids) - 1 - req.multimodal_inputs = mm_inputs - data_cache.append([input_id_, attention_mask_, loss_mask_]) - reqs.append(req) - - logits_list, aux_hidden_states_list, last_hidden_states_list = self._extend( - reqs, - capture_aux_hidden_states=True, - return_last_hidden_states=return_last_hidden_states, - return_logits=return_logits, - ) - - return data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list + def get_rope_index(self, *args, **kwargs): + return self._backend.get_rope_index(*args, **kwargs) @torch.no_grad() def generate_eagle3_data( @@ -947,6 +510,50 @@ def generate_eagle3_data( ) +class SGLangServerEagle3TargetEngine(Eagle3TargetEngine): + """Live frozen-target SGLang *server* engine (cross-node) — W3 / W3′. + + The fourth backend: instead of an in-process ``ModelRunner``, a *frozen* + target runs as a live SGLang server and streams hidden states (capture into a + FeatureStore for W3, or inline over HTTP for the light W3′). It is + ``backend="sglang_server"`` — selectable through the factory — but the live + capture implementation is gated by the O1.3 throughput spike (see + ``docs/roadmap/online-disaggregation.md`` §O1.3), so construction raises with an + actionable message until that lands. The de-EAGLE3 extraction and the domain + Trainer carry no engine risk and do not depend on this backend. + """ + + backend = "sglang_server" + + def __init__(self, *args, **kwargs): + raise NotImplementedError( + "sglang_server target engine (live cross-node frozen-target capture) is " + "not implemented yet; its depth is gated by the O1.3 capture spike " + "(docs/roadmap/online-disaggregation.md §O1.3). Use backend='sglang' " + "(in-process) or 'hf' for now." + ) + + @classmethod + def from_pretrained( + cls, + pretrained_model_name_or_path: str, + torch_dtype: torch.dtype = None, + device: str = None, + cache_dir: Optional[str] = None, + **kwargs, + ) -> "SGLangServerEagle3TargetEngine": + return cls() + + def generate_eagle3_data( + self, + input_ids: torch.Tensor, + attention_mask: torch.Tensor, + loss_mask: torch.Tensor, + **kwargs, + ) -> Eagle3TargetOutput: # pragma: no cover - unreachable (ctor raises) + raise NotImplementedError + + def get_eagle3_target_model( pretrained_model_name_or_path: str, backend: str = "sglang", @@ -979,23 +586,18 @@ def get_eagle3_target_model( cache_dir=cache_dir, **kwargs, ) + elif backend == "sglang_server": + return SGLangServerEagle3TargetEngine.from_pretrained( + pretrained_model_name_or_path=pretrained_model_name_or_path, + torch_dtype=torch_dtype, + device=device, + cache_dir=cache_dir, + **kwargs, + ) else: raise ValueError(f"Invalid backend: {backend}") -def _get_sharded_return( - input_: torch.Tensor, - input_lens: list[int], - valid_input_lens: list[int], - valid_indices: list[int], -) -> list[Optional[torch.Tensor]]: - out: list[Optional[torch.Tensor]] = [None] * len(input_lens) - input_scatter = torch.split(input_, valid_input_lens, dim=0) - for j, idx in enumerate(valid_indices): - out[idx] = input_scatter[j] - return out - - # --- Back-compat aliases (pre-Phase-B names) ------------------------------- # The de-EAGLE3 rename (``*TargetModel`` -> ``*TargetEngine``, ``Eagle3TargetModel`` # -> ``Eagle3TargetEngine``) is import-compatible: existing scripts, tests and diff --git a/specforge/modeling/target/sglang_backend/__init__.py b/specforge/modeling/target/sglang_backend/__init__.py new file mode 100644 index 000000000..ed4263103 --- /dev/null +++ b/specforge/modeling/target/sglang_backend/__init__.py @@ -0,0 +1,9 @@ +from .capture import SGLangCaptureBackend +from .model_runner import SGLangRunner +from .utils import wrap_eagle3_logits_processors_in_module + +__all__ = [ + "SGLangRunner", + "wrap_eagle3_logits_processors_in_module", + "SGLangCaptureBackend", +] diff --git a/tests/test_runtime/test_sglang_capture_backend.py b/tests/test_runtime/test_sglang_capture_backend.py index f956f2c25..a2b1cac58 100644 --- a/tests/test_runtime/test_sglang_capture_backend.py +++ b/tests/test_runtime/test_sglang_capture_backend.py @@ -22,7 +22,7 @@ _TARGET_DIR = os.path.normpath( os.path.join( - os.path.dirname(__file__), "..", "..", "specforge", "inference", "target_engine" + os.path.dirname(__file__), "..", "..", "specforge", "modeling", "target" ) ) @@ -80,7 +80,7 @@ def test_server_backend_tag_and_gated_construction(self): import torch # noqa: F401 except Exception: self.skipTest("torch unavailable") - from specforge.inference.target_engine import ( + from specforge.modeling.target import ( SGLangServerEagle3TargetEngine, get_eagle3_target_model, ) From 98b753ddc7acb5f6b5ac02cf663ee8d8117c2539 Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Sat, 4 Jul 2026 00:41:27 -0700 Subject: [PATCH 03/24] =?UTF-8?q?[DataFlow=20runtime]=20E0=20=E2=80=94=20l?= =?UTF-8?q?ayout=20consolidation:=20runtime/=20is=20substrate-only=20(move?= =?UTF-8?q?-only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the domain planes out of specforge/runtime/ into their S-homes: - specforge/runtime/training/* -> specforge/training/{controller,backend,strategies/{base,registry}}.py - specforge/runtime/inference/* -> specforge/inference/{rollout_worker,capture,adapters/{eagle3,dflash}}.py - specforge/modeling/target/** -> specforge/inference/target_engine/** (incl. sglang_backend/) - specforge/runtime/launch.py -> specforge/launch.py Every old path keeps a thin import shim re-exporting from the new home; tests/scripts/examples switch to the new import paths. No behavior change. Co-Authored-By: Claude Fable 5 --- specforge/inference/__init__.py | 2 +- specforge/inference/adapters/__init__.py | 15 +- specforge/inference/adapters/dflash.py | 127 +++- specforge/inference/adapters/eagle3.py | 148 ++++- specforge/inference/capture.py | 134 +--- specforge/inference/rollout_worker.py | 113 +--- specforge/inference/target_engine/__init__.py | 36 - specforge/inference/target_engine/base.py | 9 +- .../target_engine/dflash_target_model.py | 95 ++- .../target_engine/eagle3_target_model.py | 337 +++++++++- specforge/inference/target_engine/factory.py | 65 +- specforge/launch.py | 381 ++--------- specforge/modeling/target/base.py | 107 +-- .../modeling/target/dflash_target_model.py | 262 +------- .../modeling/target/eagle3_target_model.py | 624 +----------------- specforge/modeling/target/factory.py | 83 +-- .../target/sglang_backend/__init__.py | 11 +- specforge/runtime/inference/capture.py | 10 + specforge/runtime/inference/dflash_adapter.py | 5 + specforge/runtime/inference/sglang_adapter.py | 5 + specforge/runtime/training/__init__.py | 5 + specforge/training/backend.py | 309 +-------- specforge/training/controller.py | 340 +--------- specforge/training/strategies/base.py | 336 +--------- specforge/training/strategies/registry.py | 46 +- tests/test_runtime/test_capture.py | 44 +- .../test_runtime/test_equiv_online_eagle3.py | 4 +- .../test_extraction_vs_hf_reference.py | 20 +- tests/test_runtime/test_rollout_worker.py | 12 +- .../test_sglang_adapter_batching.py | 105 +-- .../test_sglang_capture_backend.py | 4 +- tests/test_runtime/test_strategy_registry.py | 9 +- 32 files changed, 892 insertions(+), 2911 deletions(-) create mode 100644 specforge/runtime/inference/capture.py create mode 100644 specforge/runtime/inference/dflash_adapter.py create mode 100644 specforge/runtime/inference/sglang_adapter.py create mode 100644 specforge/runtime/training/__init__.py diff --git a/specforge/inference/__init__.py b/specforge/inference/__init__.py index d441aad3f..a3dd031da 100644 --- a/specforge/inference/__init__.py +++ b/specforge/inference/__init__.py @@ -1,5 +1,5 @@ # coding=utf-8 -"""Inference / rollout plane: rollout worker, feature contract, adapters, target engines. +"""Inference / rollout plane: rollout worker, capture config, adapters, target engines. Submodules import the SpecForge model / SGLang code, so they are imported explicitly by callers rather than at package load. diff --git a/specforge/inference/adapters/__init__.py b/specforge/inference/adapters/__init__.py index 0ec35a8ee..34bc03440 100644 --- a/specforge/inference/adapters/__init__.py +++ b/specforge/inference/adapters/__init__.py @@ -1,15 +1,6 @@ # coding=utf-8 -"""FeatureSource adapters: schema-parameterized capture over a TargetEngine. +"""FeatureSource adapters: per-strategy capture over a TargetEngine. -``policy.PolicyFeatureAdapter`` is the single runtime adapter (length grouping, -batched capture, per-sample slicing, vocab projection); each strategy registers -a ``FeatureSchema`` for the store-ready dict shape. ``eagle3.SGLangAdapter`` -and ``dflash.DFlashAdapter`` are thin schema-pinning subclasses kept for -back-compat; all implement the ``rollout_worker.FeatureSource`` protocol. - -``server_capture.SGLangServerCaptureAdapter`` is the zero-copy SERVER -transport (``rollout_worker.RefSource``): a patched live SGLang server writes -the features straight into the Mooncake store and the adapter returns -committed-ready ``SampleRef``s — import it from its module (it stays out of -this package ``__init__`` so the registry import path remains light). +``eagle3.SGLangAdapter`` (default) and ``dflash.DFlashAdapter`` implement the +``rollout_worker.FeatureSource`` protocol. """ diff --git a/specforge/inference/adapters/dflash.py b/specforge/inference/adapters/dflash.py index 1a480e525..d5316aa6e 100644 --- a/specforge/inference/adapters/dflash.py +++ b/specforge/inference/adapters/dflash.py @@ -6,41 +6,47 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -"""DFlashAdapter: the DFlash schema pinned onto PolicyFeatureAdapter. - -DFlash's store-ready dict is ``{input_ids, hidden_states, loss_mask}`` — note -``hidden_states`` is the concatenated target capture layers, and there is NO -``target`` distribution / no vocab projection (DFlash trains on hard real-token -labels), so ``DFLASH_FEATURE_SCHEMA`` sets ``target_feature=None`` and skips the -``t2d`` projection step entirely. - -``verify_feature_contract`` (run by the RolloutWorker before any store write) -keys its eagle3-specific aux/target checks on the feature names -``"hidden_state"`` / ``"target"``, which DFlash does not emit, so those checks -self-skip; the recorded-aux-layer check is skipped too because the schema does -not emit ``__aux_layer_ids__`` and the RolloutWorker reads it via -``feats.pop("__aux_layer_ids__", None)``. - -Kept as a named class for back-compat; the shared runtime conversion lives in -:class:`~specforge.inference.adapters.policy.PolicyFeatureAdapter`. +"""DFlashAdapter: the DFlash counterpart of SGLangAdapter. + +Wraps a DFlash ``TargetEngine`` (sglang / hf), calling its generic ``capture(...)`` +(the legacy ``generate_dflash_data`` is kept as a back-compat alias), and returns +per-sample feature dicts for the DataFlow rollout. DFlash's schema is +``{input_ids, hidden_states, loss_mask}`` — note ``hidden_states`` is the +concatenated target capture layers, and there is NO ``target`` distribution / no +vocab projection (DFlash trains on hard real-token labels), so unlike +``SGLangAdapter`` there is no ``_project_target`` / ``t2d`` step. + +``verify_capture`` (run by the RolloutWorker before any store write) keys its +eagle3-specific aux/target checks on the feature names ``"hidden_state"`` / +``"target"``, which DFlash does not emit, so those checks self-skip; the +recorded-aux-layer check is skipped too because the RolloutWorker reads it via +``feats.pop("__aux_layer_ids__", None)`` and DFlash simply omits the key. + +Imports SpecForge model code transitively (via the target backend), so it is +imported by rollout entry points, not at package load. """ from __future__ import annotations -from typing import Optional +from typing import Any, Dict, List, Optional import torch -from specforge.inference.adapters.policy import ( - DFLASH_FEATURE_SCHEMA, - PolicyFeatureAdapter, -) +from specforge.inference.capture import CaptureConfig +from specforge.runtime.contracts import PromptTask + + +def _as_2d_long(values, device) -> torch.Tensor: + t = torch.as_tensor(values, dtype=torch.long, device=device) + if t.ndim == 1: + t = t.unsqueeze(0) + return t -class DFlashAdapter(PolicyFeatureAdapter): - """DFlash ``FeatureSource`` over a ``TargetEngine`` (via its generic ``capture()``).""" +class DFlashAdapter: + """Adapter over a SpecForge DFlash ``TargetEngine`` (via its generic ``capture()``).""" - SUPPORTED_FEATURE_NAMES = DFLASH_FEATURE_SCHEMA.names + SUPPORTED_FEATURE_NAMES = {"input_ids", "hidden_states", "loss_mask"} def __init__( self, @@ -49,14 +55,69 @@ def __init__( device: str = "cuda", t2d: Optional[torch.Tensor] = None, # unused (DFlash has no vocab map); kept ) -> None: # for a uniform make_adapter(target_model, *, device, t2d) signature - super().__init__( - target_model, - schema=DFLASH_FEATURE_SCHEMA, - device=device, - t2d=t2d, - # DFlash engines' capture() does not take shard_returns; never pass it. - shard_returns=None, - ) + self.target_model = target_model + self.device = device + self._healthy = True + + def generate_features( + self, tasks: List[PromptTask], *, capture: CaptureConfig + ) -> List[Dict[str, Any]]: + """Extract per-sample DFlash features, batching equal-length prompts. + + Mirrors SGLangAdapter's length-grouped batching, but calls the engine's + generic ``capture(...)`` and emits the DFlash schema. The target must have + had ``set_capture_layers`` called so ``hidden_states`` width matches the + draft's ``len(target_layer_ids) * hidden_size``. + """ + out: List[Optional[Dict[str, Any]]] = [None] * len(tasks) + + groups: Dict[int, List[int]] = {} + for i, task in enumerate(tasks): + groups.setdefault(len(task.payload["input_ids"]), []).append(i) + + for _length, idxs in groups.items(): + input_ids = torch.stack( + [ + _as_2d_long(tasks[i].payload["input_ids"], self.device)[0] + for i in idxs + ], + dim=0, + ) # (G, L) + length = input_ids.shape[1] + loss_mask = torch.stack( + [ + ( + _as_2d_long(tasks[i].payload["loss_mask"], self.device)[0] + if "loss_mask" in tasks[i].payload + else torch.ones(length, dtype=torch.long, device=self.device) + ) + for i in idxs + ], + dim=0, + ) + attention_mask = torch.ones_like(input_ids) + data = self.target_model.capture( + input_ids=input_ids, + attention_mask=attention_mask, + loss_mask=loss_mask, + ) + for j, gi in enumerate(idxs): + out[gi] = { + "input_ids": data.input_ids[j : j + 1], + "hidden_states": data.hidden_states[j : j + 1], + "loss_mask": data.loss_mask[j : j + 1], + # DFlash emits no eagle3 aux/target features. The recorded + # aux-layer check in verify_capture is skipped for free: the + # RolloutWorker reads it via feats.pop("__aux_layer_ids__", None), + # so an absent key is identical to an explicit None. + } + return out + + def health(self) -> Dict[str, Any]: + return { + "healthy": self._healthy, + "backend": getattr(self.target_model, "backend", "unknown"), + } __all__ = ["DFlashAdapter"] diff --git a/specforge/inference/adapters/eagle3.py b/specforge/inference/adapters/eagle3.py index 6513c2603..641a884bd 100644 --- a/specforge/inference/adapters/eagle3.py +++ b/specforge/inference/adapters/eagle3.py @@ -6,35 +6,49 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -"""SGLangAdapter: the EAGLE3 schema pinned onto PolicyFeatureAdapter. +"""SGLangAdapter: the clean boundary between SpecForge and the target engine. -The runtime conversion (length grouping, batched ``TargetEngine.capture``, -per-sample slicing, target->draft projection, order preservation) lives once in -:class:`~specforge.inference.adapters.policy.PolicyFeatureAdapter`; this class -only pins ``EAGLE3_FEATURE_SCHEMA`` — the store-ready dict is ``{input_ids, -attention_mask, loss_mask, hidden_state, target}`` plus the out-of-band -``__aux_layer_ids__`` record that ``verify_feature_contract`` checks. +``generate_features(tasks, *, capture)`` is the single extraction entry point. +``capture`` is the typed :class:`CaptureConfig` derived from the active strategy, +not an untyped dict. The adapter wraps an EAGLE3 ``TargetEngine`` (sglang / hf / +custom backends), calling its generic ``capture(...)`` (the de-EAGLE3'd boundary; +the legacy ``generate_eagle3_data`` is kept as a back-compat alias), records the +exact aux-layer IDs it captured, applies the target→draft projection demanded by +``capture.target_repr`` (the only place pruning happens), and returns per-sample +feature dicts. The RolloutWorker then runs :func:`verify_capture` before any +store write, so a layer/name/width mismatch fails loudly at this boundary rather +than as a downstream trainer bug. -Kept as a named class for back-compat (it predates the schema merge and is the -default adapter name throughout the runtime docs/tests). +Imports the SpecForge model code, so it is imported by rollout entry points. """ from __future__ import annotations -from typing import Optional +from typing import Any, Dict, List, Optional import torch -from specforge.inference.adapters.policy import ( - EAGLE3_FEATURE_SCHEMA, - PolicyFeatureAdapter, -) +from specforge.inference.capture import CaptureConfig +from specforge.runtime.contracts import PromptTask -class SGLangAdapter(PolicyFeatureAdapter): - """EAGLE3 ``FeatureSource`` over a ``TargetEngine`` (via its generic ``capture()``).""" +def _as_2d_long(values, device) -> torch.Tensor: + t = torch.as_tensor(values, dtype=torch.long, device=device) + if t.ndim == 1: + t = t.unsqueeze(0) + return t - SUPPORTED_FEATURE_NAMES = EAGLE3_FEATURE_SCHEMA.names + +class SGLangAdapter: + """Adapter over a SpecForge EAGLE3 ``TargetEngine`` (via its generic ``capture()``).""" + + SUPPORTED_FEATURE_NAMES = { + "input_ids", + "attention_mask", + "loss_mask", + "hidden_state", + "target", + } def __init__( self, @@ -44,13 +58,101 @@ def __init__( t2d: Optional[torch.Tensor] = None, shard_returns: bool = False, ) -> None: - super().__init__( - target_model, - schema=EAGLE3_FEATURE_SCHEMA, - device=device, - t2d=t2d, - shard_returns=shard_returns, + self.target_model = target_model + self.device = device + # t2d (target->draft vocab mask) only needed for pruned_logits capture. + self.t2d = t2d + self.shard_returns = shard_returns + self._healthy = True + + def _recorded_aux_layer_ids(self) -> tuple: + ids = getattr(self.target_model, "aux_hidden_states_layers", None) + return tuple(ids) if ids is not None else () + + # target representations this online adapter actually implements + SUPPORTED_TARGET_REPRS = ("logits", "pruned_logits") + + def _project_target( + self, target: torch.Tensor, capture: CaptureConfig + ) -> torch.Tensor: + if capture.target_repr == "logits": + return target + if capture.target_repr == "pruned_logits": + if self.t2d is None: + raise ValueError("pruned_logits capture requires a t2d vocab map") + return target[..., self.t2d.to(target.device)] + # Only advertise what we implement. 'hidden_state' capture (storing the + # target's last hidden state) is not wired in the online adapter yet; the + # offline path supports it (the strategy re-runs TargetHead). + raise NotImplementedError( + f"SGLangAdapter does not implement online capture for target_repr=" + f"{capture.target_repr!r}; supported: {self.SUPPORTED_TARGET_REPRS}" ) + def generate_features( + self, tasks: List[PromptTask], *, capture: CaptureConfig + ) -> List[Dict[str, Any]]: + """Extract per-sample features, batching the engine call. + + Tasks are grouped by sequence length and each group is run through + the engine's generic ``capture(...)`` in ONE batched forward (the engine's + native batching), instead of a per-sample loop that would serialize N forwards. + Equal-length grouping avoids intra-batch padding, so per-sample features + are sliced out cleanly. The result preserves task order. + """ + recorded = self._recorded_aux_layer_ids() + out: List[Optional[Dict[str, Any]]] = [None] * len(tasks) + + groups: Dict[int, List[int]] = {} + for i, task in enumerate(tasks): + groups.setdefault(len(task.payload["input_ids"]), []).append(i) + + for _length, idxs in groups.items(): + input_ids = torch.stack( + [ + _as_2d_long(tasks[i].payload["input_ids"], self.device)[0] + for i in idxs + ], + dim=0, + ) # (G, L) + loss_mask = torch.stack( + [ + _as_2d_long( + tasks[i].payload.get("loss_mask", [1] * input_ids.shape[1]), + self.device, + )[0] + for i in idxs + ], + dim=0, + ) + attention_mask = torch.ones_like(input_ids) + data = self.target_model.capture( + input_ids=input_ids, + attention_mask=attention_mask, + loss_mask=loss_mask, + shard_returns=self.shard_returns, + ) + target = self._project_target(data.target, capture) + for j, gi in enumerate(idxs): + out[gi] = { + "input_ids": data.input_ids[j : j + 1], + "attention_mask": data.attention_mask[j : j + 1], + "loss_mask": data.loss_mask[j : j + 1], + "hidden_state": data.hidden_states[j : j + 1], + "target": target[j : j + 1], + # carried out-of-band for verify_capture; popped before put + "__aux_layer_ids__": recorded, + } + return out + + # NOTE: draft-weight hot update (update_draft_weights) is not implemented yet. + + def health(self) -> Dict[str, Any]: + return { + "healthy": self._healthy, + "aux_hidden_state_layer_ids": list(self._recorded_aux_layer_ids()), + "backend": getattr(self.target_model, "backend", "unknown"), + } + __all__ = ["SGLangAdapter"] diff --git a/specforge/inference/capture.py b/specforge/inference/capture.py index fa05d0c3f..f7c41bf48 100644 --- a/specforge/inference/capture.py +++ b/specforge/inference/capture.py @@ -6,17 +6,14 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -"""FeatureContract: the typed contract for rollout-produced feature records. - -This module is the runtime/data-plane counterpart to -``target_engine.capture_policy``. Target-capture policies define how a backend -produces a typed batched target output. A ``FeatureContract`` defines what the -runtime adapter must turn that output into before any ``FeatureStore.put``: -feature names, aux-layer ids, target representation, and expected dimensions. - -Before any store write the rollout runs :func:`verify_feature_contract`, which -fails loudly on a name / aux-layer-id / width / target-dim mismatch — turning -what would otherwise be a confusing downstream trainer bug into an immediate, +"""CaptureConfig: the typed contract for what a rollout must extract (B7/B8). + +``capture`` is NOT an untyped ``dict[str, Any]``. It is a frozen config *derived +from the active strategy* (``feature_names == DraftTrainStrategy.required_features``, +``aux_hidden_state_layer_ids`` == the layers the draft config requested). Before +any ``FeatureStore.put`` the rollout runs :func:`verify_capture`, which fails +loudly on a name / aux-layer-id / width / target-dim mismatch — turning what +would otherwise be a confusing downstream trainer bug into an immediate, localized error at the extraction boundary. Import-light (stdlib only) so the assertions are unit-testable without a GPU. @@ -30,12 +27,12 @@ from specforge.runtime.contracts import TargetRepr -class FeatureContractError(AssertionError): - """Raised when extracted features do not match the requested contract.""" +class CaptureMismatchError(AssertionError): + """Raised when extracted features do not match the requested CaptureConfig.""" @dataclass(frozen=True) -class FeatureContract: +class CaptureConfig: feature_names: FrozenSet[str] aux_hidden_state_layer_ids: Tuple[int, ...] target_repr: TargetRepr @@ -56,7 +53,7 @@ def from_strategy( target_vocab_size: Optional[int] = None, draft_vocab_size: Optional[int] = None, vocab_map_version: Optional[str] = None, - ) -> "FeatureContract": + ) -> "CaptureConfig": return cls( feature_names=frozenset(required_features), aux_hidden_state_layer_ids=tuple(aux_hidden_state_layer_ids), @@ -81,127 +78,62 @@ def expected_target_dim(self) -> Optional[int]: return None -def verify_feature_contract( +def verify_capture( tensors: Dict[str, Any], - contract: FeatureContract, + capture: CaptureConfig, *, sample_id: str, recorded_aux_layer_ids: Optional[Tuple[int, ...]] = None, aux_feature_name: str = "hidden_state", target_feature_name: str = "target", ) -> None: - """Loud, pre-put validation that extracted ``tensors`` match ``contract``. + """Loud, pre-put validation that extracted ``tensors`` match ``capture``. - Raises :class:`FeatureContractError` on the first mismatch with a + Raises :class:`CaptureMismatchError` on the first mismatch with a requested-vs-actual diff and the offending ``sample_id``. """ # (1) all requested feature names present - missing = sorted(n for n in contract.feature_names if n not in tensors) + missing = sorted(n for n in capture.feature_names if n not in tensors) if missing: - raise FeatureContractError( + raise CaptureMismatchError( f"[{sample_id}] capture missing features {missing}; " - f"got {sorted(tensors)}; requested {sorted(contract.feature_names)}" + f"got {sorted(tensors)}; requested {sorted(capture.feature_names)}" ) # (2) recorded aux-layer IDs == requested if recorded_aux_layer_ids is not None: - if tuple(recorded_aux_layer_ids) != contract.aux_hidden_state_layer_ids: - raise FeatureContractError( + if tuple(recorded_aux_layer_ids) != capture.aux_hidden_state_layer_ids: + raise CaptureMismatchError( f"[{sample_id}] aux-layer id mismatch: recorded " f"{tuple(recorded_aux_layer_ids)} != requested " - f"{contract.aux_hidden_state_layer_ids}" + f"{capture.aux_hidden_state_layer_ids}" ) # (3) aux width == len(aux_layer_ids) * target_hidden_size - if aux_feature_name in tensors and contract.aux_hidden_state_layer_ids: + if aux_feature_name in tensors and capture.aux_hidden_state_layer_ids: width = int(tuple(tensors[aux_feature_name].shape)[-1]) - if width != contract.expected_aux_width: - raise FeatureContractError( + if width != capture.expected_aux_width: + raise CaptureMismatchError( f"[{sample_id}] aux width {width} != " f"len(aux_layer_ids)*target_hidden_size=" - f"{len(contract.aux_hidden_state_layer_ids)}*" - f"{contract.target_hidden_size}={contract.expected_aux_width}" + f"{len(capture.aux_hidden_state_layer_ids)}*" + f"{capture.target_hidden_size}={capture.expected_aux_width}" ) # (4) target last-dim matches target_repr (+ vocab-map dim for pruned_logits) - expected = contract.expected_target_dim() + expected = capture.expected_target_dim() if target_feature_name in tensors and expected is not None: dim = int(tuple(tensors[target_feature_name].shape)[-1]) if dim != expected: - raise FeatureContractError( + raise CaptureMismatchError( f"[{sample_id}] target last-dim {dim} != expected {expected} " - f"for target_repr={contract.target_repr!r}" + f"for target_repr={capture.target_repr!r}" ) - if ( - contract.target_repr == "pruned_logits" - and contract.vocab_map_version is None - ): - raise FeatureContractError( + if capture.target_repr == "pruned_logits" and capture.vocab_map_version is None: + raise CaptureMismatchError( f"[{sample_id}] target_repr='pruned_logits' requires a " f"vocab_map_version so the trainer-side mapping is gated" ) -def verify_feature_contract_specs( - specs: Dict[str, Any], - contract: FeatureContract, - *, - sample_id: str, - recorded_aux_layer_ids: Optional[Tuple[int, ...]] = None, - aux_feature_name: str = "hidden_state", - target_feature_name: str = "target", -) -> None: - """Contract verification from ``FeatureSpec``s alone — no tensors. - - Used by ref-producing sources (server-side capture): the tensors already - live in the store, so the extraction-boundary checks run against the - returned shape/dtype metadata. Every check in - :func:`verify_feature_contract` reads only ``.shape`` from the mapping's - values, which ``FeatureSpec`` provides — so this is the same validation, - same error messages, same loudness. - """ - verify_feature_contract( - specs, - contract, - sample_id=sample_id, - recorded_aux_layer_ids=recorded_aux_layer_ids, - aux_feature_name=aux_feature_name, - target_feature_name=target_feature_name, - ) - - -def verify_capture( - tensors: Dict[str, Any], - capture: FeatureContract, - *, - sample_id: str, - recorded_aux_layer_ids: Optional[Tuple[int, ...]] = None, - aux_feature_name: str = "hidden_state", - target_feature_name: str = "target", -) -> None: - """Back-compat alias for :func:`verify_feature_contract`.""" - verify_feature_contract( - tensors, - capture, - sample_id=sample_id, - recorded_aux_layer_ids=recorded_aux_layer_ids, - aux_feature_name=aux_feature_name, - target_feature_name=target_feature_name, - ) - - -# Back-compat aliases. New runtime code should prefer FeatureContract names to -# distinguish feature-store contracts from target-capture policies. -CaptureConfig = FeatureContract -CaptureMismatchError = FeatureContractError - - -__all__ = [ - "FeatureContract", - "FeatureContractError", - "verify_feature_contract", - "verify_feature_contract_specs", - "CaptureConfig", - "CaptureMismatchError", - "verify_capture", -] +__all__ = ["CaptureConfig", "CaptureMismatchError", "verify_capture"] diff --git a/specforge/inference/rollout_worker.py b/specforge/inference/rollout_worker.py index 4a89e79ef..542482517 100644 --- a/specforge/inference/rollout_worker.py +++ b/specforge/inference/rollout_worker.py @@ -11,10 +11,10 @@ The worker is deliberately small and strategy-agnostic: it leases prompt tasks, asks a ``feature_source`` (e.g. a wrapper over the target model's ``generate_eagle3_data``, or ``SGLangAdapter``) for per-sample features, -verifies them against the typed ``FeatureContract`` *before* writing, writes them +verifies them against the typed ``CaptureConfig`` *before* writing, writes them to the ``FeatureStore``, and commits the resulting ``SampleRef`` metadata to the controller. It never hands a tensor to the controller. Strategy-specific capture -requirements live in ``FeatureContract`` + the feature schema, not here. +requirements live in ``CaptureConfig`` + the feature schema, not here. """ from __future__ import annotations @@ -22,9 +22,9 @@ from typing import Any, Dict, List, Optional, Protocol from specforge.inference.capture import ( - FeatureContract, - FeatureContractError, - verify_feature_contract, + CaptureConfig, + CaptureMismatchError, + verify_capture, ) from specforge.runtime.contracts import PromptTask, SampleRef @@ -34,32 +34,17 @@ class FeatureSource(Protocol): def generate_features( - self, tasks: List[PromptTask], *, capture: FeatureContract + self, tasks: List[PromptTask], *, capture: CaptureConfig ) -> List[Dict[str, Any]]: ... -class RefSource(Protocol): - """A source whose features are ALREADY in the feature store. - - Server-side capture transports (e.g. ``SGLangServerCaptureAdapter``) write - tensors into the store from the inference server and return - committed-ready ``SampleRef``s (or per-task failure markers with - ``task_id``/``reason``/``retryable`` attributes). The worker then commits - refs without any local put — tensors never pass through this process. - """ - - def produce_refs( - self, tasks: List[PromptTask], *, capture: FeatureContract - ) -> List[Any]: ... - - class RolloutWorker: def __init__( self, controller, feature_store, feature_source: FeatureSource, - capture: FeatureContract, + capture: CaptureConfig, *, run_id: str, worker_id: Optional[str] = None, @@ -71,9 +56,7 @@ def __init__( self.controller = controller self.feature_store = feature_store self.feature_source = feature_source - self.feature_contract = capture - # Back-compat for code/tests that read RolloutWorker.capture directly. - self.capture = self.feature_contract + self.capture = capture self.run_id = run_id self.strategy = strategy self.target_model_version = target_model_version @@ -105,11 +88,9 @@ def run_once(self, max_tasks: int) -> List[SampleRef]: return [] self._inflight = len(tasks) self._state = "ready" - if hasattr(self.feature_source, "produce_refs"): - return self._run_once_refs(tasks) try: feats_list = self.feature_source.generate_features( - tasks, capture=self.feature_contract + tasks, capture=self.capture ) except Exception as exc: # rollout failure before any feature write self._state = "unhealthy" @@ -139,18 +120,18 @@ def run_once(self, max_tasks: int) -> List[SampleRef]: raise ValueError(reason) refs: List[SampleRef] = [] - capture_error: Optional[FeatureContractError] = None + capture_error: Optional[CaptureMismatchError] = None for task, feats in zip(tasks, feats_list): sample_id = self._sample_id(task) recorded = feats.pop("__aux_layer_ids__", None) try: - verify_feature_contract( + verify_capture( feats, - self.feature_contract, + self.capture, sample_id=sample_id, recorded_aux_layer_ids=recorded, ) - except FeatureContractError as exc: + except CaptureMismatchError as exc: # Loud failure: do not persist a corrupt sample, but keep this # batch's other prompt leases moving so no lease is stranded. self._recent_failures.append(str(exc)) @@ -183,74 +164,14 @@ def run_once(self, max_tasks: int) -> List[SampleRef]: raise capture_error return refs - def _run_once_refs(self, tasks: List[PromptTask]) -> List[SampleRef]: - """Lease -> produce_refs -> commit for ref-producing sources. - - The source verified each ref against the FeatureContract and wrote the - tensors store-side, so the worker's job reduces to terminal-state - bookkeeping: every leased task still ends in exactly one controller - action — ``commit_samples`` for a returned ref, ``fail_prompt_tasks`` - for a per-task failure marker or a transport error. - """ - try: - results = self.feature_source.produce_refs( - tasks, capture=self.feature_contract - ) - except Exception as exc: # transport failure before any commit - self._state = "unhealthy" - self._recent_failures.append(f"produce_refs: {exc}") - self.controller.fail_prompt_tasks( - self.worker_id, - [t.task_id for t in tasks], - reason=f"produce_refs:{exc}", - retryable=True, - ) - self._inflight = 0 - raise - if len(results) != len(tasks): - reason = ( - f"produce_refs returned {len(results)} results for " - f"{len(tasks)} tasks" - ) - self._state = "unhealthy" - self._recent_failures.append(reason) - self.controller.fail_prompt_tasks( - self.worker_id, - [t.task_id for t in tasks], - reason=reason, - retryable=False, - ) - self._inflight = 0 - raise ValueError(reason) - - refs: List[SampleRef] = [] - for task, result in zip(tasks, results): - if isinstance(result, SampleRef): - refs.append(result) - continue - # per-task failure marker (duck-typed: reason/retryable) - reason = getattr(result, "reason", f"produce_refs:{result!r}") - self._recent_failures.append(reason) - self.controller.fail_prompt_tasks( - self.worker_id, - [task.task_id], - reason=reason, - retryable=bool(getattr(result, "retryable", True)), - ) - if refs: - self.controller.commit_samples(self.worker_id, refs) - self._last_commit_count += len(refs) - self._inflight = 0 - return refs - def _put_metadata(self, task: PromptTask) -> Dict[str, Any]: return { "run_id": self.run_id, "source_task_id": task.task_id, "strategy": self.strategy, - "target_repr": self.feature_contract.target_repr, - "vocab_map_version": self.feature_contract.vocab_map_version, - "ttt_length": self.feature_contract.extra.get("ttt_length"), + "target_repr": self.capture.target_repr, + "vocab_map_version": self.capture.vocab_map_version, + "ttt_length": self.capture.extra.get("ttt_length"), "target_model_version": self.target_model_version, "tokenizer_version": self.tokenizer_version, "draft_weight_version": self.draft_weight_version, @@ -276,4 +197,4 @@ def health(self) -> Dict[str, Any]: } -__all__ = ["RolloutWorker", "FeatureSource", "RefSource", "HEALTH_STATES"] +__all__ = ["RolloutWorker", "FeatureSource", "HEALTH_STATES"] diff --git a/specforge/inference/target_engine/__init__.py b/specforge/inference/target_engine/__init__.py index eeed104e7..146fd029c 100644 --- a/specforge/inference/target_engine/__init__.py +++ b/specforge/inference/target_engine/__init__.py @@ -2,7 +2,6 @@ """Target engines: the backend-agnostic capture surface (TargetEngine + factory).""" from .base import KNOWN_BACKENDS, TargetEngine -from .custom import CustomTargetEngine from .eagle3_target_model import ( CustomEagle3TargetEngine, CustomEagle3TargetModel, @@ -16,47 +15,12 @@ get_eagle3_target_model, ) from .factory import available_target_engines, get_target_engine -from .hf import HFTargetEngine -from .sglang import SGLangTargetEngine -from .target_capture_policy import ( - CAPTURE_POLICIES, - TARGET_CAPTURE_POLICIES, - CapturePolicy, - CaptureSpec, - DFlashCapturePolicy, - Eagle3CapturePolicy, - TargetCaptureBatch, - TargetCapturePolicy, - TargetCaptureSpec, - register_capture_policy, - register_target_capture_policy, - resolve_capture_policy, - resolve_target_capture_policy, -) __all__ = [ "TargetEngine", "KNOWN_BACKENDS", "get_target_engine", "available_target_engines", - # Target-capture policies (per-algorithm axis of the engine matrix) - "TargetCaptureBatch", - "TargetCaptureSpec", - "TargetCapturePolicy", - "CaptureSpec", - "CapturePolicy", - "Eagle3CapturePolicy", - "DFlashCapturePolicy", - "TARGET_CAPTURE_POLICIES", - "CAPTURE_POLICIES", - "register_target_capture_policy", - "resolve_target_capture_policy", - "register_capture_policy", - "resolve_capture_policy", - # Generic per-backend engines (policy-parameterized) - "HFTargetEngine", - "SGLangTargetEngine", - "CustomTargetEngine", "Eagle3TargetEngine", "SGLangEagle3TargetEngine", "HFEagle3TargetEngine", diff --git a/specforge/inference/target_engine/base.py b/specforge/inference/target_engine/base.py index 3a5acc6f0..1a2727426 100644 --- a/specforge/inference/target_engine/base.py +++ b/specforge/inference/target_engine/base.py @@ -34,13 +34,10 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, List, Optional +from typing import Any, List, Optional import torch -if TYPE_CHECKING: - from .target_capture_policy import TargetCaptureBatch - # Known transport/backend tags. ``sglang_server`` (a live frozen-target SGLang # server, cross-node) is introduced by the sglang-capture-backend PR; its capture # depth is gated by the O1.3 spike. The tag set is advisory (informational, used @@ -82,11 +79,11 @@ def capture( attention_mask: torch.Tensor, loss_mask: torch.Tensor, **kwargs, - ) -> "TargetCaptureBatch": + ) -> Any: """Run the frozen target forward and extract training features. The generic extraction entry point that replaces the EAGLE3-named - ``generate_eagle3_data``. Returns a typed ``TargetCaptureBatch`` + ``generate_eagle3_data``. Returns a per-algorithm output dataclass (``Eagle3TargetOutput`` / ``DFlashTargetOutput``). Algorithm engines keep their original ``generate_*_data`` method as the concrete implementation and as a back-compat alias; ``capture`` simply dispatches to it, so the diff --git a/specforge/inference/target_engine/dflash_target_model.py b/specforge/inference/target_engine/dflash_target_model.py index 058244929..b8739e586 100644 --- a/specforge/inference/target_engine/dflash_target_model.py +++ b/specforge/inference/target_engine/dflash_target_model.py @@ -1,17 +1,27 @@ from abc import abstractmethod +from dataclasses import dataclass from typing import List, Optional import torch import torch.nn as nn +from transformers import AutoModelForCausalLM from .base import TargetEngine -from .target_capture_policy import DFlashCapturePolicy, DFlashTargetOutput -# NOTE: the capture/load implementations live in -# ``target_capture_policy.DFlashCapturePolicy``, shared with the generic per-backend -# engines. The classes below keep the existing hierarchy and delegate. +# NOTE (Phase B2): this module no longer imports sglang internals. The +# SGLang-version-pinned capture path (ServerArgs / ModelConfig / SGLangRunner + +# the extend/capture forward) lives entirely in +# ``sglang_backend.SGLangCaptureBackend``, shared with the eagle3 engine (one +# copy of the forward + mlp-sync). The SGLang engine below composes it, imported +# lazily inside ``from_pretrained`` so ``import specforge`` stays sglang-agnostic. -_DFLASH = DFlashCapturePolicy() + +@dataclass +class DFlashTargetOutput: + hidden_states: torch.Tensor # [batch, seq_len, hidden_size] + input_ids: torch.Tensor # [batch, seq_len] + attention_mask: torch.Tensor # [batch, seq_len] + loss_mask: torch.Tensor # [batch, seq_len] class DFlashTargetEngine(TargetEngine): @@ -94,14 +104,16 @@ def from_pretrained( trust_remote_code: bool = False, **kwargs, ) -> "SGLangDFlashTargetEngine": - # Lazy import so `import specforge` still works without the pinned sglang. + # Lazy import so `import specforge` still works without the pinned sglang: + # the sglang-version coupling lives entirely in SGLangCaptureBackend, which + # also unifies the extend/mlp-sync forward this engine used to duplicate. from .sglang_backend import SGLangCaptureBackend backend = SGLangCaptureBackend.build( pretrained_model_name_or_path, torch_dtype=torch_dtype, trust_remote_code=trust_remote_code, - **_DFLASH.spec.sglang_build_kwargs, + wrap_eagle3_logits=False, **kwargs, ) return cls(backend) @@ -111,14 +123,28 @@ def set_capture_layers(self, layer_ids: List[int]) -> None: # Some target models expose set_eagle3_layers_to_capture; guard on it. self._backend.set_eagle3_capture_layers(layer_ids, if_supported=True) + @torch.no_grad() def generate_dflash_data( self, input_ids: torch.Tensor, attention_mask: torch.Tensor, loss_mask: torch.Tensor, ) -> DFlashTargetOutput: - return _DFLASH.sglang_capture( - self._backend, input_ids, attention_mask, loss_mask + data_cache, hidden_states_list = self._backend.extend_dflash( + input_ids, attention_mask, loss_mask + ) + + # Stack back to batch + hidden_states = torch.cat([h.unsqueeze(0) for h in hidden_states_list], dim=0) + input_ids = torch.cat([d[0] for d in data_cache], dim=0) + attention_mask = torch.cat([d[1] for d in data_cache], dim=0) + loss_mask = torch.cat([d[2] for d in data_cache], dim=0) + + return DFlashTargetOutput( + hidden_states=hidden_states, + input_ids=input_ids, + attention_mask=attention_mask, + loss_mask=loss_mask, ) @@ -140,25 +166,50 @@ def from_pretrained( trust_remote_code: bool = True, **kwargs, ) -> "HFDFlashTargetEngine": - return cls( - _DFLASH.hf_load( - pretrained_model_name_or_path, - torch_dtype, - device, - cache_dir, - trust_remote_code=trust_remote_code, - **kwargs, - ) - ) + target_model = AutoModelForCausalLM.from_pretrained( + pretrained_model_name_or_path, + torch_dtype=torch_dtype, + cache_dir=cache_dir, + output_hidden_states=True, + trust_remote_code=trust_remote_code, + **kwargs, + ).eval() + + if device: + target_model = target_model.to(device) + + return cls(target_model) + + @torch.no_grad() def generate_dflash_data( self, input_ids: torch.Tensor, attention_mask: torch.Tensor, loss_mask: torch.Tensor, ) -> DFlashTargetOutput: - return _DFLASH.hf_capture( - self.model, self.capture_layer_ids, input_ids, attention_mask, loss_mask + outputs = self.model( + input_ids=input_ids, + attention_mask=attention_mask, + output_hidden_states=True, + use_cache=False, + ) + + # hidden_states[0] = embedding output; hidden_states[i+1] = layer i output + offset = 1 + selected = [] + if self.capture_layer_ids is not None: + for idx in self.capture_layer_ids: + selected.append(outputs.hidden_states[idx + offset]) + hidden_states = torch.cat(selected, dim=-1) + else: + hidden_states = outputs.hidden_states[-1] + + return DFlashTargetOutput( + hidden_states=hidden_states, + input_ids=input_ids, + attention_mask=attention_mask, + loss_mask=loss_mask, ) @@ -191,6 +242,8 @@ def get_dflash_target_model( # --- Back-compat aliases (pre-Phase-B names) ------------------------------- +# See the note in eagle3_target_model.py: the ``*TargetModel`` -> ``*TargetEngine`` +# rename is import-compatible; these aliases keep existing callers working. DFlashTargetModel = DFlashTargetEngine SGLangDFlashTargetModel = SGLangDFlashTargetEngine HFDFlashTargetModel = HFDFlashTargetEngine diff --git a/specforge/inference/target_engine/eagle3_target_model.py b/specforge/inference/target_engine/eagle3_target_model.py index 51b6393fc..6ed0a734f 100644 --- a/specforge/inference/target_engine/eagle3_target_model.py +++ b/specforge/inference/target_engine/eagle3_target_model.py @@ -1,27 +1,46 @@ import logging from abc import abstractmethod +from dataclasses import dataclass from typing import List, Optional import torch import torch.nn as nn +from transformers import AutoModelForCausalLM + +from specforge.distributed import get_tp_device_mesh, get_tp_group +from specforge.utils import padding from .base import TargetEngine -from .target_capture_policy import Eagle3CapturePolicy, Eagle3TargetOutput -# NOTE: the capture/load implementations for every backend live in -# ``target_capture_policy.Eagle3CapturePolicy`` — shared with the generic -# per-backend engines (``hf.py`` / ``sglang.py`` / ``custom.py``). The classes -# below keep the existing hierarchy, tags and method surfaces (scripts, -# adapters and tests import them) and delegate every body to the one policy. +# NOTE (Phase B2): this module no longer imports sglang internals. The +# SGLang-version-pinned capture path (ServerArgs / ModelConfig / SGLangRunner + +# the extend/capture forward) lives entirely in +# ``sglang_backend.SGLangCaptureBackend``; the SGLang engine below composes it +# (imported lazily inside ``from_pretrained``). A sglang bump touches only that +# module — the engine and ``import specforge`` are sglang-version-agnostic. logger = logging.getLogger(__name__) -_EAGLE3 = Eagle3CapturePolicy() + +@dataclass +class Eagle3TargetOutput: + hidden_states: torch.Tensor + target: torch.Tensor + loss_mask: torch.Tensor + input_ids: torch.Tensor + attention_mask: torch.Tensor + last_hidden_states: Optional[torch.Tensor] = None class Eagle3TargetEngine(TargetEngine): """EAGLE3 target engine — the algorithm ABC over a frozen target backend. + Offers a layer of abstraction for the target model backend. The user can + choose different backends to suit their needs: + 1. SGLang backend: for the mainstream model support with the fastest inference speed + 2. HuggingFace backend: for models that are not supported by SGLang but can be loaded by HuggingFace. + 3. Custom backend: for models with customized architecture and inference plan. + EAGLE3 captures three *aux* hidden-state layers plus (optionally) target logits. The generic :meth:`capture` / :meth:`set_capture_layers` hooks from :class:`TargetEngine` are thin dispatchers onto the EAGLE3-specific @@ -42,7 +61,9 @@ def from_pretrained( cache_dir: Optional[str] = None, **kwargs, ) -> "Eagle3TargetEngine": - """Initialize the target model backend from a pretrained model path.""" + """ + Initialize the target model backend from a pretrained model path. + """ @abstractmethod def generate_eagle3_data( @@ -52,7 +73,9 @@ def generate_eagle3_data( loss_mask: torch.Tensor, **kwargs, ) -> Eagle3TargetOutput: - """Generate the eagle3 data from the target model.""" + """ + Generate the eagle3 data from the target model. + """ def capture( self, @@ -79,11 +102,25 @@ def set_capture_layers(self, layer_ids: Optional[List[int]] = None) -> None: def set_aux_hidden_states_layers( self, aux_hidden_states_layers: Optional[List[int]] = None ) -> None: - """Set the layers to capture the aux hidden states from the target model outputs.""" - config = self.model.config if aux_hidden_states_layers is None else None - self.aux_hidden_states_layers = _EAGLE3.resolve_capture_layers( - config, aux_hidden_states_layers - ) + """ + Set the layers to capture the aux hidden states from the target model outputs. + """ + if aux_hidden_states_layers is None: + if hasattr(self.model.config, "num_hidden_layers"): + num_layers = self.model.config.num_hidden_layers + else: + raise ValueError( + f"Failed to set aux hidden states layers as model config {self.model.config} does not have num_hidden_layers" + ) + aux_hidden_states_layers = [ + 1, + num_layers // 2 - 1, + num_layers - 4, + ] + self.aux_hidden_states_layers = aux_hidden_states_layers + assert ( + len(self.aux_hidden_states_layers) == 3 + ), "aux_hidden_states_layers is expected to be 3 layers for EAGLE3" class HFEagle3TargetEngine(Eagle3TargetEngine): @@ -103,12 +140,50 @@ def from_pretrained( cache_dir: Optional[str] = None, **kwargs, ) -> "HFEagle3TargetEngine": - return cls( - _EAGLE3.hf_load( - pretrained_model_name_or_path, torch_dtype, device, cache_dir, **kwargs - ) + """ + Initialize the HuggingFace target model backend from a pretrained model path. + """ + tp_size = get_tp_group().size() + + if tp_size > 1: + device_kwargs = { + "tp_plan": "auto", + "tp_size": tp_size, + "device_mesh": get_tp_device_mesh(), + } + else: + device_kwargs = { + "device_map": device, + } + + target_model = AutoModelForCausalLM.from_pretrained( + pretrained_model_name_or_path, + torch_dtype=torch_dtype, + cache_dir=cache_dir, + **device_kwargs, + **kwargs, ) + return cls(target_model) + def _get_transformer_layers(self): + """ + Helper to find the module list containing the transformer layers. + Adapts to common architectures (Llama, Qwen, Mistral, OPT, etc.) + """ + if hasattr(self.model, "model") and hasattr(self.model.model, "layers"): + return self.model.model.layers + elif hasattr(self.model, "layers"): + return self.model.layers + elif hasattr(self.model, "transformer") and hasattr( + self.model.transformer, "h" + ): + return self.model.transformer.h + else: + raise ValueError( + "Could not locate transformer layers in the model architecture to register hooks." + ) + + @torch.no_grad() def generate_eagle3_data( self, input_ids: torch.Tensor, @@ -116,13 +191,86 @@ def generate_eagle3_data( loss_mask: torch.Tensor, **kwargs, ) -> Eagle3TargetOutput: - return _EAGLE3.hf_capture( - self.model, - self.aux_hidden_states_layers, - input_ids, - attention_mask, - loss_mask, - **kwargs, + """ + Optimized HF backend: + Instead of returning all hidden states (memory heavy), we use forward hooks + to capture only the specific layers required by Eagle3. + """ + if kwargs: + logger.debug(f"unused kwargs {list(kwargs.keys())}") + + captured_states = {} + handles = [] + + def get_hook(layer_idx): + def hook(module, input, output): + # HF outputs for layers are usually tuples (hidden_states, present_key_value, ...) + # We only need the hidden_states (first element) + if isinstance(output, tuple): + hidden = output[0] + else: + hidden = output + captured_states[layer_idx] = hidden + + return hook + + # Locate the transformer layers ModuleList + layers = self._get_transformer_layers() + + target_indices = self.aux_hidden_states_layers + + # Register hooks + for idx in target_indices: + # Ensure index is within bounds + if 0 <= idx < len(layers): + handles.append(layers[idx].register_forward_hook(get_hook(idx))) + else: + raise ValueError( + f"Layer index {idx} out of bounds for model with {len(layers)} layers." + ) + + try: + outputs = self.model( + input_ids=input_ids, + attention_mask=attention_mask, + output_hidden_states=False, + output_attentions=False, + output_router_logits=False, + use_cache=False, + ) + target = outputs.logits + finally: + # Always remove hooks to prevent memory leaks or side effects on subsequent calls + for handle in handles: + handle.remove() + + # Verify we captured everything + if len(captured_states) != 3: + raise RuntimeError( + f"Expected to capture 3 layers, but captured {len(captured_states)}" + ) + + # Extract in the correct order + hidden_states0 = captured_states[target_indices[0]] + hidden_states1 = captured_states[target_indices[1]] + hidden_states2 = captured_states[target_indices[2]] + + hidden_states = torch.cat( + (hidden_states0, hidden_states1, hidden_states2), dim=-1 + ) + + # apply pading + target = outputs.logits + target = padding(target, left=False) + input_ids = padding(input_ids, left=False) + loss_mask = loss_mask[..., None].to(target.device) + + return Eagle3TargetOutput( + hidden_states=hidden_states, + target=target, + loss_mask=loss_mask, + input_ids=input_ids, + attention_mask=attention_mask, ) @@ -168,7 +316,8 @@ def from_pretrained( pretrained_model_name_or_path, torch_dtype=torch_dtype, trust_remote_code=trust_remote_code, - **_EAGLE3.spec.sglang_build_kwargs, + wrap_eagle3_logits=True, + return_full_logits=False, **kwargs, ) return cls(backend) @@ -197,15 +346,109 @@ def extend_vlm(self, *args, **kwargs): def get_rope_index(self, *args, **kwargs): return self._backend.get_rope_index(*args, **kwargs) + @torch.no_grad() def generate_eagle3_data( self, input_ids: torch.Tensor, attention_mask: torch.Tensor, loss_mask: torch.Tensor, + pixel_values: Optional[torch.Tensor] = None, + image_grid_thw: Optional[torch.Tensor] = None, + is_vlm: bool = False, + shard_returns: bool = False, **kwargs, ) -> Eagle3TargetOutput: - return _EAGLE3.sglang_capture( - self._backend, input_ids, attention_mask, loss_mask, **kwargs + """ + return: + data_for_draft: List[Dict[str, torch.Tensor]] of draft_batch_size, draft_micro_batch_size = 1 + - input_ids: (1, seq_len) + - attention_mask: (1, seq_len) + - loss_mask: (1, seq_len) + - target: (1, seq_len, vocab_size) or (1, seq_len, hidden_size) + - hidden_states: (1, seq_len, hidden_size) + - pixel_values: (patch_len, patch_width) + - image_grid_thw (batch_size, 3) + """ + if kwargs: + logger.debug(f"unused kwargs {list(kwargs.keys())}") + + if is_vlm: + data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list = ( + self.extend_eagle3_vlm( + input_ids, + attention_mask, + loss_mask, + return_last_hidden_states=False, + return_logits=True, + pixel_values=pixel_values, + image_grid_thw=image_grid_thw, + ) + ) + else: + data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list = ( + self.extend_eagle3( + input_ids, + attention_mask, + loss_mask, + return_last_hidden_states=False, + return_logits=True, + shard_returns=shard_returns, + ) + ) + aux_hidden_states_out = [] + target_out = [] + loss_mask_out = [] + attention_mask_out = [] + input_ids_out = [] + last_hidden_states_out = [] + + for idx, (data, logits, aux_hidden_states, last_hidden_states) in enumerate( + zip( + data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list + ) + ): + if aux_hidden_states is not None: + aux_hidden_states_out.append(aux_hidden_states.unsqueeze(0)) + loss_mask_out.append(data[2]) + attention_mask_out.append(data[1]) + input_ids_out.append(data[0]) + + # when generating hidden states for offline training, we don't compute logits and only keep the last_hidden_states + # when training online, we don't keep the last_hidden_states and only keep the logits + if logits is not None: + target_out.append(logits.unsqueeze(0)) + + if last_hidden_states is not None: + last_hidden_states_out.append(last_hidden_states.unsqueeze(0)) + + aux_hidden_states_out = torch.cat(aux_hidden_states_out, dim=0) + + loss_mask_out = torch.cat(loss_mask_out, dim=0) + attention_mask_out = torch.cat(attention_mask_out, dim=0) + input_ids_out = torch.cat(input_ids_out, dim=0) + + if target_out: + target_out = torch.cat(target_out, dim=0) + else: + target_out = None + + if last_hidden_states_out: + last_hidden_states_out = torch.cat(last_hidden_states_out, dim=0) + else: + last_hidden_states_out = None + + if target_out is not None: + target_out = padding(target_out, left=False) + input_ids_out = padding(input_ids_out, left=False) + loss_mask_out = loss_mask_out[..., None] + + return Eagle3TargetOutput( + hidden_states=aux_hidden_states_out, + target=target_out, + loss_mask=loss_mask_out, + input_ids=input_ids_out, + attention_mask=attention_mask_out, + last_hidden_states=last_hidden_states_out, ) @@ -237,6 +480,7 @@ def from_pretrained( ) return cls(target_model) + @torch.no_grad() def generate_eagle3_data( self, input_ids: torch.Tensor, @@ -244,13 +488,32 @@ def generate_eagle3_data( loss_mask: torch.Tensor, **kwargs, ) -> Eagle3TargetOutput: - return _EAGLE3.custom_capture( - self.model, - self.aux_hidden_states_layers, - input_ids, - attention_mask, - loss_mask, - **kwargs, + if kwargs: + logger.debug(f"unused kwargs {list(kwargs.keys())}") + + outputs = self.model( + input_ids=input_ids, + attention_mask=attention_mask, + output_hidden_states=True, + layers_to_output_hidden_states=self.aux_hidden_states_layers, + use_cache=False, + ) + + # For custom backends, the model implementation is responsible for only + # returning the requested layers in `outputs.hidden_states`. + hidden_states = torch.cat(outputs.hidden_states, dim=-1) + + target = outputs.logits + target = padding(target, left=False) + input_ids = padding(input_ids, left=False) + loss_mask = loss_mask[..., None].to(target.device) + + return Eagle3TargetOutput( + hidden_states=hidden_states, + target=target, + loss_mask=loss_mask, + input_ids=input_ids, + attention_mask=attention_mask, ) @@ -343,6 +606,10 @@ def get_eagle3_target_model( # --- Back-compat aliases (pre-Phase-B names) ------------------------------- +# The de-EAGLE3 rename (``*TargetModel`` -> ``*TargetEngine``, ``Eagle3TargetModel`` +# -> ``Eagle3TargetEngine``) is import-compatible: existing scripts, tests and +# ``specforge.modeling`` re-exports keep importing the old names. These aliases +# are removed once callers migrate (tracked in the Phase E model-layout move). Eagle3TargetModel = Eagle3TargetEngine HFEagle3TargetModel = HFEagle3TargetEngine SGLangEagle3TargetModel = SGLangEagle3TargetEngine diff --git a/specforge/inference/target_engine/factory.py b/specforge/inference/target_engine/factory.py index cca62097d..dbafa1045 100644 --- a/specforge/inference/target_engine/factory.py +++ b/specforge/inference/target_engine/factory.py @@ -6,14 +6,14 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -"""Generic target-engine factory. - -``get_target_engine(strategy=..., backend=...)`` dispatches on the *algorithm* -and the *backend*. The built-in algorithms (``eagle3`` / ``dflash`` / ``domino``) -route through their per-algorithm loaders (kept import-compatible); any other -strategy with a registered :class:`~.target_capture_policy.TargetCapturePolicy` gets -the generic per-backend engine — adding an algorithm is a policy registration, -not a new engine class per backend. +"""Generic target-engine factory (Phase B). + +``get_target_engine(strategy=..., backend=...)`` is the de-EAGLE3'd factory: it +dispatches on the *algorithm* (``eagle3`` / ``dflash``) and delegates to the +per-algorithm loaders, which in turn dispatch on the *backend* (sglang / hf / +custom / sglang_server). The legacy ``get_eagle3_target_model`` / +``get_dflash_target_model`` remain as thin shims (they ARE the per-algorithm +loaders this factory calls), so nothing that imports them breaks. """ from __future__ import annotations @@ -24,46 +24,18 @@ from .base import TargetEngine -# Built-in strategies with a dedicated per-algorithm loader. Domino reuses the -# DFlash engine (same capture: concatenated layer hidden states, no target -# distribution). Loaders are imported LAZILY inside the factory so ``import -# specforge`` works even when the installed sglang lacks the pinned symbols. +# strategy name -> per-algorithm engine. Domino reuses the DFlash engine (same +# capture: concatenated layer hidden states, no target distribution). The loaders +# are imported LAZILY inside the factory: dflash_target_model imports sglang +# internals unconditionally, so eager import here would break the design property +# that ``import specforge`` works even when the installed sglang lacks the pinned +# symbols (eagle3_target_model guards its imports; see its module docstring). _ENGINE_STRATEGIES = ("eagle3", "dflash", "domino") def available_target_engines(): - """Strategy names with a target-engine loader (built-in or via policy).""" - from .target_capture_policy import TARGET_CAPTURE_POLICIES - - return sorted(set(_ENGINE_STRATEGIES) | set(TARGET_CAPTURE_POLICIES)) - - -def _generic_loader(strategy: str): - """Loader over the generic per-backend engines for a registered policy.""" - from .target_capture_policy import resolve_target_capture_policy - - try: - policy = resolve_target_capture_policy(strategy) - except KeyError: - raise ValueError( - f"no target engine for strategy {strategy!r}; " - f"registered: {available_target_engines()}" - ) from None - - def load(pretrained_model_name_or_path, backend="sglang", **kwargs): - if backend == "sglang": - from .sglang import SGLangTargetEngine as engine_cls - elif backend == "hf": - from .hf import HFTargetEngine as engine_cls - elif backend == "custom": - from .custom import CustomTargetEngine as engine_cls - else: - raise ValueError(f"Invalid backend: {backend}") - return engine_cls.from_pretrained( - pretrained_model_name_or_path, policy=policy, **kwargs - ) - - return load + """Strategy names with a registered target-engine loader.""" + return sorted(_ENGINE_STRATEGIES) def _resolve_loader(strategy: str): @@ -75,7 +47,10 @@ def _resolve_loader(strategy: str): from .dflash_target_model import get_dflash_target_model return get_dflash_target_model - return _generic_loader(strategy) + raise ValueError( + f"no target engine for strategy {strategy!r}; " + f"registered: {available_target_engines()}" + ) def get_target_engine( diff --git a/specforge/launch.py b/specforge/launch.py index 2cf8209d9..4d84e06a1 100644 --- a/specforge/launch.py +++ b/specforge/launch.py @@ -16,8 +16,7 @@ from __future__ import annotations -import logging -from typing import List, Optional, Tuple +from typing import List, Optional from specforge.runtime.contracts import DeploymentMode, SampleRef from specforge.runtime.control_plane import ( @@ -32,8 +31,6 @@ from specforge.runtime.data_plane import FeatureStore, LocalFeatureStore from specforge.training.strategies.registry import StrategySpec, resolve_strategy -logger = logging.getLogger(__name__) - # --------------------------------------------------------------------------- # Shared assemblers — strategy- and topology-agnostic. # --------------------------------------------------------------------------- @@ -147,44 +144,6 @@ def _resolve_metadata_store( return None -def _dp_consumer_layout( - dp_rank: Optional[int], - dp_size: Optional[int], - tp_size: int, - sp_ulysses_size: int, - sp_ring_size: int, -) -> Tuple[int, int]: - """Resolve the online consumer's (dp_rank, dp_size), defaulting from dist. - - The DP online consumer shards DATA across ranks (one inbox each), so its DP - width is the whole trainer world; tp/sp replication inside a shard is not - wired yet and is rejected rather than silently double-training. - """ - import torch.distributed as dist - - initialized = dist.is_available() and dist.is_initialized() - if dp_size is None: - dp_size = dist.get_world_size() if initialized else 1 - if dp_rank is None: - dp_rank = dist.get_rank() if initialized else 0 - if dp_size > 1 and (tp_size != 1 or sp_ulysses_size != 1 or sp_ring_size != 1): - raise NotImplementedError( - "DP online consumer shards refs across dp ranks; tp/sp inside a DP " - f"shard is not wired yet (got tp={tp_size}, sp_ulysses=" - f"{sp_ulysses_size}, sp_ring={sp_ring_size})" - ) - if not 0 <= dp_rank < dp_size: - raise ValueError(f"dp_rank {dp_rank} out of range for dp_size {dp_size}") - return dp_rank, dp_size - - -def _dp_barrier() -> None: - import torch.distributed as dist - - if dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1: - dist.barrier() - - def _checkpoint_global_step(resume_from: str) -> int: """Read ``global_step`` from the shared payload of the checkpoint at ``resume_from`` (a checkpoint dir, its state file, or a ``file://`` URI).""" @@ -206,6 +165,7 @@ def _checkpoint_global_step(resume_from: str) -> int: def _assemble_rollout_workers( *, spec: StrategySpec, + target_model, controller: DataFlowController, store: FeatureStore, run_id: str, @@ -218,66 +178,32 @@ def _assemble_rollout_workers( t2d, num_rollout_workers: int, device: str, - target_model=None, - feature_source=None, ): """Build the rollout producer workers (colocated-online + disagg producer). - ``feature_source`` overrides the in-process adapter with a pre-built - FeatureSource/RefSource (e.g. the server-capture transport, whose live - SGLang server writes features straight to the store — no ``target_model`` - is loaded here). Otherwise an in-process adapter is built over - ``target_model``. - - A *sequence* of feature sources fans out to one worker per source (the - multi-server topology: 1 server : 1 adapter : 1 worker). All workers share - the one controller, whose per-``worker_id`` leases keep their prompt slices - disjoint. A single source keeps the legacy shape: ``num_rollout_workers`` - workers sharing it. + The capture contract derives from ``spec.required_features``; strategies with + ``supports_online=False`` raise here rather than emit the wrong feature schema. """ if not spec.supports_online: raise NotImplementedError( f"online capture for strategy {spec.name!r} is not wired yet: it needs " - f"a {spec.name} capture path. Set feature_schema (or make_adapter) + " - f"supports_online=True on its StrategySpec." + f"a {spec.name} capture adapter. Set make_adapter + supports_online=True " + f"on its StrategySpec." ) - from specforge.inference.capture import FeatureContract + from specforge.inference.capture import CaptureConfig from specforge.inference.rollout_worker import RolloutWorker if aux_hidden_state_layer_ids is None: aux_hidden_state_layer_ids = tuple( getattr(target_model, "aux_hidden_states_layers", ()) or () ) - if isinstance(feature_source, (list, tuple)): - if not feature_source: - raise ValueError("feature_source sequence is empty") - if num_rollout_workers not in (1, len(feature_source)): - raise ValueError( - f"num_rollout_workers={num_rollout_workers} conflicts with " - f"{len(feature_source)} feature sources (one worker per source)" - ) - adapters = list(feature_source) - elif feature_source is not None: - adapters = [feature_source] * num_rollout_workers - elif spec.make_adapter is not None: - adapters = [ - spec.make_adapter(target_model, device=device, t2d=t2d) - ] * num_rollout_workers + if spec.make_adapter is not None: + adapter = spec.make_adapter(target_model, device=device, t2d=t2d) else: - from specforge.inference.adapters.policy import ( - EAGLE3_FEATURE_SCHEMA, - PolicyFeatureAdapter, - ) + from specforge.inference.adapters.eagle3 import SGLangAdapter - adapters = [ - PolicyFeatureAdapter( - target_model, - schema=spec.feature_schema or EAGLE3_FEATURE_SCHEMA, - device=device, - t2d=t2d, - ) - ] * num_rollout_workers - feature_contract = FeatureContract.from_strategy( + adapter = SGLangAdapter(target_model, device=device, t2d=t2d) + capture = CaptureConfig.from_strategy( required_features=spec.required_features, aux_hidden_state_layer_ids=tuple(aux_hidden_state_layer_ids), target_repr=target_repr, @@ -292,12 +218,12 @@ def _assemble_rollout_workers( controller, store, adapter, - feature_contract, + capture, run_id=run_id, worker_id=f"rollout-{i}", strategy=spec.name, ) - for i, adapter in enumerate(adapters) + for i in range(num_rollout_workers) ] @@ -486,7 +412,6 @@ def build_online_runtime( sp_ring_size: int = 1, collate_fn=None, logger=None, - log_interval: int = 50, resume_from: Optional[str] = None, max_checkpoints: int = 0, ): @@ -542,7 +467,7 @@ def build_online_runtime( sp_ulysses_size=sp_ulysses_size, sp_ring_size=sp_ring_size, logger=logger, - log_interval=log_interval, + log_interval=50, collate_fn=_online_collate(spec, collate_fn), per_sample_transform=None, durable_ack=durable_ack, @@ -569,7 +494,7 @@ def drive_rollout(max_rounds: int = 100_000) -> int: def build_disagg_online_producer( *, strategy: str = "eagle3", - target_model=None, + target_model, prompts, feature_store: FeatureStore, channel, @@ -583,12 +508,9 @@ def build_disagg_online_producer( t2d=None, num_rollout_workers: int = 1, device: str = "cuda", - feature_source=None, lease: int = 8, in_flight_high_watermark: int = 256, backpressure_poll_s: float = 0.2, - max_worker_failures: int = 3, - max_prompt_attempts: Optional[int] = 5, metadata_store: Optional[MetadataStore] = None, metadata_db_path: Optional[str] = None, sleep=None, @@ -596,29 +518,12 @@ def build_disagg_online_producer( """Producer side of an ONLINE disaggregated run (rollout pool). Workers put() into a cross-node ``feature_store``; committed refs stream to - the consumer via ``channel``. Pass ``feature_source`` for the server-capture - transport (live SGLang server writes to the store; ``target_model`` stays - None) — a *sequence* of sources fans out to one worker per source (the - multi-server topology; each worker drives its own server concurrently). - ``metadata_store``/``metadata_db_path`` must be the same durable store - the consumer opens. Returns ``(workers, drive_producer)``: - ``drive_producer(should_stop=...)`` runs until the prompt pool drains, pauses - above ``in_flight_high_watermark``, and always closes the channel on exit - (EOF terminates the consumer's loader). - - Failure semantics: a worker whose source raises (dead/unreachable server) - has already failed its leases retryable — the surviving workers re-lease - those prompts. After ``max_worker_failures`` *consecutive* failures the - worker is dropped from rotation (its health is logged); if every worker is - dropped while prompts remain, ``drive_producer`` raises instead of silently - truncating the run. Per-task retryable failures are bounded by - ``max_prompt_attempts`` (a poisoned prompt goes terminal, not infinite). - The pool counts as drained only when no prompt is pending *or leased* — - an all-failed round no longer reads as end-of-data. With N workers the - watermark can overshoot by up to N * lease (each worker checks it - independently before leasing). + the consumer via ``channel``. ``metadata_store``/``metadata_db_path`` must be + the same durable store the consumer opens. Returns ``(workers, + drive_producer)``: ``drive_producer(should_stop=...)`` runs until the prompt + pool drains, pauses above ``in_flight_high_watermark``, and always closes the + channel on exit (EOF terminates the consumer's loader). """ - import threading import time spec = resolve_strategy(strategy) @@ -626,14 +531,12 @@ def build_disagg_online_producer( controller = DataFlowController( run_id, metadata_store=_resolve_metadata_store(metadata_store, metadata_db_path), - max_prompt_attempts=max_prompt_attempts, ) controller.ingest_prompts(prompts) workers = _assemble_rollout_workers( spec=spec, target_model=target_model, - feature_source=feature_source, controller=controller, store=feature_store, run_id=run_id, @@ -649,113 +552,26 @@ def build_disagg_online_producer( ) def drive_producer(max_rounds: int = 1_000_000, should_stop=None) -> int: - """Drive all workers until the pool drains; returns refs published. - - One worker runs inline; N workers run one thread each (the blocking - HTTP prefill call releases the GIL, so servers genuinely overlap). - The controller and feature store are lock-protected; the channel is - not, so publishes serialize through ``publish_lock``. - """ for w in workers: w.start() - publish_lock = threading.Lock() - state = {"produced": 0} - dead: dict = {} # worker_id -> last failure reason - - def pool_drained() -> bool: - st = controller.status() - # leased counts too: a peer's in-flight lease may fail retryable - # and come back — leaving then would strand it. - return st["prompts_pending"] == 0 and st["prompts_leased"] == 0 - - def run_worker(w) -> None: - failures = 0 + produced = 0 + try: for _ in range(max_rounds): if should_stop is not None and should_stop(): - return + break # caller asked us to wind down (e.g. trainer finished) # backpressure: in_flight = published - consumer-acked while channel.in_flight_remote() >= in_flight_high_watermark: if should_stop is not None and should_stop(): - return + return produced # don't block on the watermark forever sleep(backpressure_poll_s) - try: - refs = w.run_once(max_tasks=lease) - except Exception as exc: - # the worker already failed its leases retryable; peers - # (or this worker, next round) will re-lease them. - failures += 1 - logger.warning( - "rollout worker %s failed (%d/%d): %s", - w.worker_id, - failures, - max_worker_failures, - exc, - ) - if failures >= max_worker_failures: - dead[w.worker_id] = str(exc) - logger.error( - "dropping rollout worker %s after %d consecutive " - "failures; health=%s", - w.worker_id, - failures, - w.health(), - ) - return - sleep(backpressure_poll_s) - continue - failures = 0 - if refs: - with publish_lock: - channel.publish_many(refs) - state["produced"] += len(refs) - elif pool_drained(): - return - else: - # leased nothing: peers hold the remaining prompts (their - # leases may yet fail back into the pool) — wait, retry. - sleep(backpressure_poll_s) - - fatal: list = [] # non-transport errors escaping a worker thread - - def run_worker_guarded(w) -> None: - try: - run_worker(w) - except BaseException as exc: # e.g. a channel publish failure - fatal.append((w.worker_id, exc)) - - try: - if len(workers) == 1: - run_worker(workers[0]) - else: - threads = [ - threading.Thread( - target=run_worker_guarded, - args=(w,), - name=f"drive-{w.worker_id}", - daemon=True, - ) - for w in workers - ] - for t in threads: - t.start() - for t in threads: - t.join() - if fatal: - raise fatal[0][1] - stopped = should_stop is not None and should_stop() - if dead and not stopped and not pool_drained(): - raise RuntimeError( - f"all rollout workers exited with {len(dead)} dropped as " - f"dead and prompts remaining — dead workers: {dead}" - ) - st = controller.status() - if st["prompts_failed"]: - logger.warning( - "producer finished with %d terminally failed prompts " - "(see controller status for reasons)", - st["prompts_failed"], - ) - return state["produced"] + refs = [] + for w in workers: + refs.extend(w.run_once(max_tasks=lease)) + if not refs: + break # prompt pool drained + channel.publish_many(refs) + produced += len(refs) + return produced finally: channel.close() # EOF -> the consumer's loader terminates once drained @@ -790,32 +606,17 @@ def build_disagg_online_consumer( log_interval: int = 50, resume_from: Optional[str] = None, max_checkpoints: int = 0, - dp_rank: Optional[int] = None, - dp_size: Optional[int] = None, - inbox_dir: Optional[str] = None, ): """Consumer (trainer) side of an ONLINE disaggregated run. Trains from a streamed ref ``channel`` + a consume-once ``feature_store`` - produced by another pool. Online resume needs BOTH knobs: ``resume=True`` - reconciles the durable marker (already-trained refs are skipped on the - channel re-read) and ``resume_from`` restores the trainer state those acks - correspond to. ``resume_from`` alone raises; a marker ahead of the - checkpoint raises (the skipped samples' weight updates were rolled back — - silent data loss). - - **Data-parallel trainer** (``dp_size > 1``, defaulting to the torchrun - world): rank 0 runs the :class:`RefDistributor` — the run's single - book-keeper. It alone reads ``channel``, commits into the ONE durable - ``metadata_store``/``metadata_db_path`` (required on rank 0; the producer - must NOT share this db — the distributor's commit-dedup would drop its - rows), and round-robin dispatches aligned windows to per-rank inboxes under - ``inbox_dir`` (default ``.inboxes``, trainer-local). Every rank - consumes only its own inbox; durable acks gather to rank 0 - (:class:`DPAckController`) while the distributor mirrors the per-rank inbox - acks onto the producer's backpressure counter. Passing ``inbox_dir`` - explicitly opts a single-rank run into the same distributor path; - otherwise ``dp_size == 1`` keeps the original direct-channel path. + produced by another pool; ``metadata_store``/``metadata_db_path`` must be the + durable store the producer commits to. Online resume needs BOTH knobs: + ``resume=True`` reconciles the durable marker (already-trained refs are + skipped on the channel re-read) and ``resume_from`` restores the trainer + state those acks correspond to. ``resume_from`` alone raises; a marker ahead + of the checkpoint raises (the skipped samples' weight updates were rolled + back — silent data loss). """ from specforge.runtime.data_plane.streaming_ref_channel import StreamingRefQueue @@ -828,13 +629,12 @@ def build_disagg_online_consumer( "alone would re-train the whole re-streamed channel" ) spec = resolve_strategy(strategy) - dp_rank, dp_size = _dp_consumer_layout( - dp_rank, dp_size, tp_size, sp_ulysses_size, sp_ring_size - ) + store = _resolve_metadata_store(metadata_store, metadata_db_path) + controller = DataFlowController(run_id, metadata_store=store) - def _reconcile(controller, resolved_store): - """resume=True -> skip already-released refs; guard marker vs checkpoint.""" - if resolved_store is None: + skip_ids = None + if resume: + if store is None: raise ValueError( "resume=True needs a durable metadata_store/metadata_db_path; an " "in-process store has no committed/ack history to reconcile against" @@ -842,6 +642,7 @@ def _reconcile(controller, resolved_store): # Released == durably acked AND optimizer-step committed: skip exactly # those on the channel re-read; the committed-unacked tail re-trains. reconciled = controller.reconcile_on_restart(feature_store) + skip_ids = set(reconciled["released"]) if resume_from: marker_step = reconciled["global_step"] ckpt_step = _checkpoint_global_step(resume_from) @@ -855,91 +656,9 @@ def _reconcile(controller, resolved_store): f"latest), or start a fresh run_id + metadata store to re-train " f"from scratch" ) - return set(reconciled["released"]) - - distributor = None - if dp_size == 1 and inbox_dir is None: - # Legacy direct-channel path, unchanged. - store = _resolve_metadata_store(metadata_store, metadata_db_path) - controller = DataFlowController(run_id, metadata_store=store) - skip_ids = _reconcile(controller, store) if resume else None - queue = StreamingRefQueue( - channel, idle_timeout_s=idle_timeout_s, skip_ids=skip_ids - ) - else: - import torch.distributed as dist - from specforge.runtime.control_plane.dp_ack import DPAckController - from specforge.runtime.control_plane.metadata_store import NoOpMetadataStore - from specforge.runtime.data_plane.ref_distributor import ( - InboxChannel, - RefDistributor, - ) - - if inbox_dir is None: - inbox_dir = channel.path + ".inboxes" - # Symmetric preconditions (ALL ranks) — a rank-0-only raise would strand - # the other ranks in the barrier below. - if metadata_store is None and metadata_db_path is None: - raise ValueError( - "DP online consumer needs a durable metadata_store/" - "metadata_db_path — the rank-0 distributor is the run's single " - "ledger" - ) - if dist.is_available() and dist.is_initialized(): - world = dist.get_world_size() - if world != dp_size: - raise ValueError( - f"DP online consumer: dp_size={dp_size} but the process " - f"group has {world} ranks — every rank must own exactly one " - f"inbox" - ) - # Liveness default: a dead producer/distributor must never hang the - # ranks silently. Generous enough for a cold server load before the - # first ref. - if idle_timeout_s is None: - idle_timeout_s = 1800.0 - if dp_rank == 0: - store = _resolve_metadata_store(metadata_store, metadata_db_path) - if isinstance(store, NoOpMetadataStore): - raise ValueError( - "DP online consumer needs a RETAINING metadata store: the " - "ledger is what dedups commits and reconciles restarts" - ) - controller = DPAckController( - run_id, is_authority=True, metadata_store=store - ) - skip_ids = _reconcile(controller, store) if resume else None - if not resume and store.committed_count() > 0: - raise ValueError( - f"metadata store already holds {store.committed_count()} " - f"committed samples from a previous run; the distributor's " - f"commit-dedup would silently drop the whole re-streamed " - f"channel. Pass resume=True (+ resume_from) to reconcile, or " - f"start fresh (new metadata_db_path / delete the db)" - ) - distributor = RefDistributor( - channel, - controller, - inbox_dir, - dp_size, - skip_ids=skip_ids, - idle_timeout_s=idle_timeout_s, - ) - else: - # Gather-participant only: throwaway store, records nothing. - controller = DPAckController( - run_id, is_authority=False, metadata_store=InMemoryMetadataStore() - ) - # Inboxes must be re-created (rank 0, in RefDistributor.__init__) before - # any rank opens a reader on a stale previous-attempt file. - _dp_barrier() - inbox = InboxChannel(RefDistributor.inbox_path(inbox_dir, dp_rank)) - queue = StreamingRefQueue(inbox, idle_timeout_s=idle_timeout_s) - if distributor is not None: - distributor.start() - - trainer, loader = _assemble_trainer( + queue = StreamingRefQueue(channel, idle_timeout_s=idle_timeout_s, skip_ids=skip_ids) + return _assemble_trainer( spec=spec, controller=controller, store=feature_store, @@ -966,10 +685,6 @@ def _reconcile(controller, resolved_store): resume_from=resume_from, max_checkpoints=max_checkpoints, ) - #: rank 0's RefDistributor handle in DP mode (None elsewhere) — callers may - #: stop() it after fit for a clean early (max_steps) shutdown. - trainer.ref_distributor = distributor - return trainer, loader def run_disagg_online_interleaved( diff --git a/specforge/modeling/target/base.py b/specforge/modeling/target/base.py index 1a2727426..9647b2b1d 100644 --- a/specforge/modeling/target/base.py +++ b/specforge/modeling/target/base.py @@ -1,106 +1,9 @@ # coding=utf-8 -# Copyright 2024 The SpecForge team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -"""TargetEngine: the backend-agnostic target-model abstraction (Phase B). - -This is the de-EAGLE3'd boundary extracted from the former ``Eagle3TargetModel`` -ABC. A ``TargetEngine`` wraps a *frozen* target model and exposes ONE generic -extraction entry point, :meth:`capture`, plus a real ``backend`` tag. The -inference/transport split (sglang in-process / hf / custom / sglang_server) is a -*backend* axis **under** each algorithm engine, and — crucially — the -sglang-version-specific glue lives behind a replaceable capture backend -(``sglang_backend``), NOT in the algorithm engine, so a sglang bump touches one -place instead of every ``*TargetModel`` subclass. - -Two sibling algorithm engines subclass this ABC: - -* :class:`Eagle3TargetEngine` (``eagle3_target_model``) — EAGLE3 TTT capture - (aux hidden states + logits), keeps the EAGLE3-specific - ``set_aux_hidden_states_layers`` / ``generate_eagle3_data``. -* :class:`DFlashTargetEngine` (``dflash_target_model``) — DFlash block capture - (concatenated layer hidden states, no target distribution). - -The runtime inference adapters (``SGLangAdapter`` / ``DFlashAdapter``) wrap a -``TargetEngine`` and remain the ``FeatureSource`` seam to the ``RolloutWorker`` — -they are unchanged by this extraction; they call the generic engine and read the -now-real ``.backend`` tag in ``health()``. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any, List, Optional - -import torch - -# Known transport/backend tags. ``sglang_server`` (a live frozen-target SGLang -# server, cross-node) is introduced by the sglang-capture-backend PR; its capture -# depth is gated by the O1.3 spike. The tag set is advisory (informational, used -# by adapter health + provenance), not an enum the ABC enforces. -KNOWN_BACKENDS = ("sglang", "hf", "custom", "sglang_server") - - -class TargetEngine(ABC): - """Backend-agnostic frozen-target engine. - - Subclasses are organised on two axes: the *algorithm* (EAGLE3 / DFlash — - the intermediate ABCs :class:`Eagle3TargetEngine` / :class:`DFlashTargetEngine`) - and the *backend/transport* (sglang / hf / custom / sglang_server — the - concrete leaf classes). Only the leaf classes are instantiable; each sets a - real :attr:`backend` tag. - """ - - #: Transport tag; concrete leaf engines override this class attribute - #: ("sglang" / "hf" / "custom" / "sglang_server"). Read by the inference - #: adapters' ``health()`` and recorded as rollout provenance. - backend: str = "unknown" - - @classmethod - @abstractmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: Optional[torch.dtype] = None, - device: Optional[str] = None, - cache_dir: Optional[str] = None, - **kwargs, - ) -> "TargetEngine": - """Load a frozen target model for this backend.""" - - @abstractmethod - def capture( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - **kwargs, - ) -> Any: - """Run the frozen target forward and extract training features. - - The generic extraction entry point that replaces the EAGLE3-named - ``generate_eagle3_data``. Returns a per-algorithm output dataclass - (``Eagle3TargetOutput`` / ``DFlashTargetOutput``). Algorithm engines keep - their original ``generate_*_data`` method as the concrete implementation - and as a back-compat alias; ``capture`` simply dispatches to it, so the - extraction is byte-identical to the pre-refactor path. - """ - - def set_capture_layers(self, layer_ids: Optional[List[int]] = None) -> None: - """Select which target layers' hidden states to capture. - - The generic hook. EAGLE3 maps this onto its 3 aux layers - (``set_aux_hidden_states_layers``); DFlash captures an arbitrary list. - Engines that do not capture intermediate layers may leave this - unimplemented. - """ - raise NotImplementedError( - f"{type(self).__name__} does not implement set_capture_layers" - ) +"""Import shim — moved to ``specforge.inference.target_engine.base``.""" +from specforge.inference.target_engine.base import ( # noqa: F401 + KNOWN_BACKENDS, + TargetEngine, +) __all__ = ["TargetEngine", "KNOWN_BACKENDS"] diff --git a/specforge/modeling/target/dflash_target_model.py b/specforge/modeling/target/dflash_target_model.py index b8739e586..cdd5d68c1 100644 --- a/specforge/modeling/target/dflash_target_model.py +++ b/specforge/modeling/target/dflash_target_model.py @@ -1,249 +1,13 @@ -from abc import abstractmethod -from dataclasses import dataclass -from typing import List, Optional - -import torch -import torch.nn as nn -from transformers import AutoModelForCausalLM - -from .base import TargetEngine - -# NOTE (Phase B2): this module no longer imports sglang internals. The -# SGLang-version-pinned capture path (ServerArgs / ModelConfig / SGLangRunner + -# the extend/capture forward) lives entirely in -# ``sglang_backend.SGLangCaptureBackend``, shared with the eagle3 engine (one -# copy of the forward + mlp-sync). The SGLang engine below composes it, imported -# lazily inside ``from_pretrained`` so ``import specforge`` stays sglang-agnostic. - - -@dataclass -class DFlashTargetOutput: - hidden_states: torch.Tensor # [batch, seq_len, hidden_size] - input_ids: torch.Tensor # [batch, seq_len] - attention_mask: torch.Tensor # [batch, seq_len] - loss_mask: torch.Tensor # [batch, seq_len] - - -class DFlashTargetEngine(TargetEngine): - """DFlash target engine — the algorithm ABC over a frozen target backend. - - DFlash captures the concatenated hidden states of an arbitrary list of - target layers (``set_capture_layers``) and trains on hard real-token labels, - so — unlike EAGLE3 — there is no target distribution / vocab map. The generic - :meth:`TargetEngine.capture` hook dispatches to ``generate_dflash_data``, so - the extraction is byte-identical to the pre-Phase-B path. - """ - - def __init__(self): - self.capture_layer_ids = None - - @classmethod - @abstractmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - **kwargs, - ) -> "DFlashTargetEngine": - """Initialize the target model backend.""" - - @abstractmethod - def generate_dflash_data( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - ) -> DFlashTargetOutput: - """Generate context hidden states for DFlash training.""" - - def capture( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - **kwargs, - ) -> DFlashTargetOutput: - """Generic extraction entry point (see :meth:`TargetEngine.capture`). - - Dispatches to the DFlash-specific ``generate_dflash_data``. DFlash takes - no extra extraction kwargs, so any are ignored. - """ - return self.generate_dflash_data( - input_ids=input_ids, - attention_mask=attention_mask, - loss_mask=loss_mask, - ) - - def set_capture_layers(self, layer_ids: Optional[List[int]] = None) -> None: - """Set which layers' hidden states to capture (TargetEngine hook).""" - self.capture_layer_ids = layer_ids - - -class SGLangDFlashTargetEngine(DFlashTargetEngine): - - backend = "sglang" - - def __init__(self, backend): # backend: sglang_backend.SGLangCaptureBackend - super().__init__() # capture_layer_ids = None - self._backend = backend - - @property - def model_runner(self): - """Kept for back-compat: the underlying sglang ModelRunner.""" - return self._backend.model_runner - - @classmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - trust_remote_code: bool = False, - **kwargs, - ) -> "SGLangDFlashTargetEngine": - # Lazy import so `import specforge` still works without the pinned sglang: - # the sglang-version coupling lives entirely in SGLangCaptureBackend, which - # also unifies the extend/mlp-sync forward this engine used to duplicate. - from .sglang_backend import SGLangCaptureBackend - - backend = SGLangCaptureBackend.build( - pretrained_model_name_or_path, - torch_dtype=torch_dtype, - trust_remote_code=trust_remote_code, - wrap_eagle3_logits=False, - **kwargs, - ) - return cls(backend) - - def set_capture_layers(self, layer_ids: List[int]) -> None: - super().set_capture_layers(layer_ids) # records self.capture_layer_ids - # Some target models expose set_eagle3_layers_to_capture; guard on it. - self._backend.set_eagle3_capture_layers(layer_ids, if_supported=True) - - @torch.no_grad() - def generate_dflash_data( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - ) -> DFlashTargetOutput: - data_cache, hidden_states_list = self._backend.extend_dflash( - input_ids, attention_mask, loss_mask - ) - - # Stack back to batch - hidden_states = torch.cat([h.unsqueeze(0) for h in hidden_states_list], dim=0) - input_ids = torch.cat([d[0] for d in data_cache], dim=0) - attention_mask = torch.cat([d[1] for d in data_cache], dim=0) - loss_mask = torch.cat([d[2] for d in data_cache], dim=0) - - return DFlashTargetOutput( - hidden_states=hidden_states, - input_ids=input_ids, - attention_mask=attention_mask, - loss_mask=loss_mask, - ) - - -class HFDFlashTargetEngine(DFlashTargetEngine): - - backend = "hf" - - def __init__(self, model: nn.Module): - super().__init__() - self.model = model - - @classmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - trust_remote_code: bool = True, - **kwargs, - ) -> "HFDFlashTargetEngine": - - target_model = AutoModelForCausalLM.from_pretrained( - pretrained_model_name_or_path, - torch_dtype=torch_dtype, - cache_dir=cache_dir, - output_hidden_states=True, - trust_remote_code=trust_remote_code, - **kwargs, - ).eval() - - if device: - target_model = target_model.to(device) - - return cls(target_model) - - @torch.no_grad() - def generate_dflash_data( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - ) -> DFlashTargetOutput: - outputs = self.model( - input_ids=input_ids, - attention_mask=attention_mask, - output_hidden_states=True, - use_cache=False, - ) - - # hidden_states[0] = embedding output; hidden_states[i+1] = layer i output - offset = 1 - selected = [] - if self.capture_layer_ids is not None: - for idx in self.capture_layer_ids: - selected.append(outputs.hidden_states[idx + offset]) - hidden_states = torch.cat(selected, dim=-1) - else: - hidden_states = outputs.hidden_states[-1] - - return DFlashTargetOutput( - hidden_states=hidden_states, - input_ids=input_ids, - attention_mask=attention_mask, - loss_mask=loss_mask, - ) - - -def get_dflash_target_model( - pretrained_model_name_or_path: str, - backend: str = "sglang", - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - **kwargs, -) -> DFlashTargetEngine: - if backend == "sglang": - return SGLangDFlashTargetEngine.from_pretrained( - pretrained_model_name_or_path=pretrained_model_name_or_path, - torch_dtype=torch_dtype, - device=device, - cache_dir=cache_dir, - **kwargs, - ) - elif backend == "hf": - return HFDFlashTargetEngine.from_pretrained( - pretrained_model_name_or_path=pretrained_model_name_or_path, - torch_dtype=torch_dtype, - device=device, - cache_dir=cache_dir, - **kwargs, - ) - else: - raise ValueError(f"Invalid backend: {backend}") - - -# --- Back-compat aliases (pre-Phase-B names) ------------------------------- -# See the note in eagle3_target_model.py: the ``*TargetModel`` -> ``*TargetEngine`` -# rename is import-compatible; these aliases keep existing callers working. -DFlashTargetModel = DFlashTargetEngine -SGLangDFlashTargetModel = SGLangDFlashTargetEngine -HFDFlashTargetModel = HFDFlashTargetEngine +# coding=utf-8 +"""Import shim — moved to ``specforge.inference.target_engine.dflash_target_model``.""" + +from specforge.inference.target_engine.dflash_target_model import ( # noqa: F401 + DFlashTargetEngine, + DFlashTargetModel, + DFlashTargetOutput, + HFDFlashTargetEngine, + HFDFlashTargetModel, + SGLangDFlashTargetEngine, + SGLangDFlashTargetModel, + get_dflash_target_model, +) diff --git a/specforge/modeling/target/eagle3_target_model.py b/specforge/modeling/target/eagle3_target_model.py index f6c2bc33a..777b90e2b 100644 --- a/specforge/modeling/target/eagle3_target_model.py +++ b/specforge/modeling/target/eagle3_target_model.py @@ -1,609 +1,15 @@ -import logging -from abc import abstractmethod -from dataclasses import dataclass -from typing import List, Optional - -import torch -import torch.nn as nn -from transformers import AutoModelForCausalLM - -from specforge.distributed import get_tp_device_mesh, get_tp_group -from specforge.utils import padding - -from .base import TargetEngine - -# NOTE (Phase B2): this module no longer imports sglang internals. The -# SGLang-version-pinned capture path (ServerArgs / ModelConfig / SGLangRunner + -# the extend/capture forward) lives entirely in -# ``sglang_backend.SGLangCaptureBackend``; the SGLang engine below composes it -# (imported lazily inside ``from_pretrained``). A sglang bump touches only that -# module — the engine and ``import specforge`` are sglang-version-agnostic. - -logger = logging.getLogger(__name__) - - -@dataclass -class Eagle3TargetOutput: - hidden_states: torch.Tensor - target: torch.Tensor - loss_mask: torch.Tensor - input_ids: torch.Tensor - attention_mask: torch.Tensor - last_hidden_states: Optional[torch.Tensor] = None - - -class Eagle3TargetEngine(TargetEngine): - """EAGLE3 target engine — the algorithm ABC over a frozen target backend. - - Offers a layer of abstraction for the target model backend. The user can - choose different backends to suit their needs: - 1. SGLang backend: for the mainstream model support with the fastest inference speed - 2. HuggingFace backend: for models that are not supported by SGLang but can be loaded by HuggingFace. - 3. Custom backend: for models with customized architecture and inference plan. - - EAGLE3 captures three *aux* hidden-state layers plus (optionally) target - logits. The generic :meth:`capture` / :meth:`set_capture_layers` hooks from - :class:`TargetEngine` are thin dispatchers onto the EAGLE3-specific - ``generate_eagle3_data`` / ``set_aux_hidden_states_layers`` below, so the - extraction is byte-identical to the pre-Phase-B path. - """ - - def __init__(self): - self.aux_hidden_states_layers = None - - @classmethod - @abstractmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - **kwargs, - ) -> "Eagle3TargetEngine": - """ - Initialize the target model backend from a pretrained model path. - """ - - @abstractmethod - def generate_eagle3_data( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - **kwargs, - ) -> Eagle3TargetOutput: - """ - Generate the eagle3 data from the target model. - """ - - def capture( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - **kwargs, - ) -> Eagle3TargetOutput: - """Generic extraction entry point (see :meth:`TargetEngine.capture`). - - Dispatches to the EAGLE3-specific ``generate_eagle3_data``. - """ - return self.generate_eagle3_data( - input_ids=input_ids, - attention_mask=attention_mask, - loss_mask=loss_mask, - **kwargs, - ) - - def set_capture_layers(self, layer_ids: Optional[List[int]] = None) -> None: - """Generic alias for EAGLE3 aux-layer selection (TargetEngine hook).""" - self.set_aux_hidden_states_layers(layer_ids) - - def set_aux_hidden_states_layers( - self, aux_hidden_states_layers: Optional[List[int]] = None - ) -> None: - """ - Set the layers to capture the aux hidden states from the target model outputs. - """ - if aux_hidden_states_layers is None: - if hasattr(self.model.config, "num_hidden_layers"): - num_layers = self.model.config.num_hidden_layers - else: - raise ValueError( - f"Failed to set aux hidden states layers as model config {self.model.config} does not have num_hidden_layers" - ) - aux_hidden_states_layers = [ - 1, - num_layers // 2 - 1, - num_layers - 4, - ] - self.aux_hidden_states_layers = aux_hidden_states_layers - assert ( - len(self.aux_hidden_states_layers) == 3 - ), "aux_hidden_states_layers is expected to be 3 layers for EAGLE3" - - -class HFEagle3TargetEngine(Eagle3TargetEngine): - - backend = "hf" - - def __init__(self, model: nn.Module): - super().__init__() - self.model = model - - @classmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - **kwargs, - ) -> "HFEagle3TargetEngine": - """ - Initialize the HuggingFace target model backend from a pretrained model path. - """ - tp_size = get_tp_group().size() - - if tp_size > 1: - device_kwargs = { - "tp_plan": "auto", - "tp_size": tp_size, - "device_mesh": get_tp_device_mesh(), - } - else: - device_kwargs = { - "device_map": device, - } - - target_model = AutoModelForCausalLM.from_pretrained( - pretrained_model_name_or_path, - torch_dtype=torch_dtype, - cache_dir=cache_dir, - **device_kwargs, - **kwargs, - ) - return cls(target_model) - - def _get_transformer_layers(self): - """ - Helper to find the module list containing the transformer layers. - Adapts to common architectures (Llama, Qwen, Mistral, OPT, etc.) - """ - if hasattr(self.model, "model") and hasattr(self.model.model, "layers"): - return self.model.model.layers - elif hasattr(self.model, "layers"): - return self.model.layers - elif hasattr(self.model, "transformer") and hasattr( - self.model.transformer, "h" - ): - return self.model.transformer.h - else: - raise ValueError( - "Could not locate transformer layers in the model architecture to register hooks." - ) - - @torch.no_grad() - def generate_eagle3_data( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - **kwargs, - ) -> Eagle3TargetOutput: - """ - Optimized HF backend: - Instead of returning all hidden states (memory heavy), we use forward hooks - to capture only the specific layers required by Eagle3. - """ - if kwargs: - logger.debug(f"unused kwargs {list(kwargs.keys())}") - - captured_states = {} - handles = [] - - def get_hook(layer_idx): - def hook(module, input, output): - # HF outputs for layers are usually tuples (hidden_states, present_key_value, ...) - # We only need the hidden_states (first element) - if isinstance(output, tuple): - hidden = output[0] - else: - hidden = output - captured_states[layer_idx] = hidden - - return hook - - # Locate the transformer layers ModuleList - layers = self._get_transformer_layers() - - target_indices = self.aux_hidden_states_layers - - # Register hooks - for idx in target_indices: - # Ensure index is within bounds - if 0 <= idx < len(layers): - handles.append(layers[idx].register_forward_hook(get_hook(idx))) - else: - raise ValueError( - f"Layer index {idx} out of bounds for model with {len(layers)} layers." - ) - - try: - outputs = self.model( - input_ids=input_ids, - attention_mask=attention_mask, - output_hidden_states=False, - output_attentions=False, - output_router_logits=False, - use_cache=False, - ) - target = outputs.logits - finally: - # Always remove hooks to prevent memory leaks or side effects on subsequent calls - for handle in handles: - handle.remove() - - # Verify we captured everything - if len(captured_states) != 3: - raise RuntimeError( - f"Expected to capture 3 layers, but captured {len(captured_states)}" - ) - - # Extract in the correct order - hidden_states0 = captured_states[target_indices[0]] - hidden_states1 = captured_states[target_indices[1]] - hidden_states2 = captured_states[target_indices[2]] - - hidden_states = torch.cat( - (hidden_states0, hidden_states1, hidden_states2), dim=-1 - ) - - # apply pading - target = outputs.logits - target = padding(target, left=False) - input_ids = padding(input_ids, left=False) - loss_mask = loss_mask[..., None].to(target.device) - - return Eagle3TargetOutput( - hidden_states=hidden_states, - target=target, - loss_mask=loss_mask, - input_ids=input_ids, - attention_mask=attention_mask, - ) - - -class SGLangEagle3TargetEngine(Eagle3TargetEngine): - - backend = "sglang" - - def __init__(self, backend): # backend: sglang_backend.SGLangCaptureBackend - # super().__init__() sets aux_hidden_states_layers = None. The sglang - # backend records capture layers on the model, not on this attribute - # (unchanged from before: the adapter reads it and gets None for sglang). - super().__init__() - self._backend = backend - - @property - def model_runner(self): - """Kept for back-compat: the underlying sglang ModelRunner.""" - return self._backend.model_runner - - @property - def hf_config(self): - return self._backend.hf_config - - @property - def is_vlm(self) -> bool: - return self._backend.is_vlm - - @classmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - trust_remote_code: bool = False, - **kwargs, - ) -> "SGLangEagle3TargetEngine": - # Lazy import so `import specforge` still works without the pinned sglang: - # the entire sglang-version coupling lives in SGLangCaptureBackend. - from .sglang_backend import SGLangCaptureBackend - - backend = SGLangCaptureBackend.build( - pretrained_model_name_or_path, - torch_dtype=torch_dtype, - trust_remote_code=trust_remote_code, - wrap_eagle3_logits=True, - return_full_logits=False, - **kwargs, - ) - return cls(backend) - - def set_aux_hidden_states_layers( - self, aux_hidden_states_layers: Optional[List[int]] = None - ) -> None: - self._backend.set_eagle3_capture_layers(aux_hidden_states_layers) - - # The extend / extend_vlm / get_rope_index forwards live in SGLangCaptureBackend - # (the version-pinned boundary); these delegators keep the engine's method - # surface stable while the engine itself imports no sglang internals. - def extend(self, *args, **kwargs): - return self._backend.extend(*args, **kwargs) - - def extend_vlm(self, *args, **kwargs): - return self._backend.extend_vlm(*args, **kwargs) - - def get_rope_index(self, *args, **kwargs): - return self._backend.get_rope_index(*args, **kwargs) - - @torch.no_grad() - def generate_eagle3_data( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - pixel_values: Optional[torch.Tensor] = None, - image_grid_thw: Optional[torch.Tensor] = None, - is_vlm: bool = False, - shard_returns: bool = False, - **kwargs, - ) -> Eagle3TargetOutput: - """ - return: - data_for_draft: List[Dict[str, torch.Tensor]] of draft_batch_size, draft_micro_batch_size = 1 - - input_ids: (1, seq_len) - - attention_mask: (1, seq_len) - - loss_mask: (1, seq_len) - - target: (1, seq_len, vocab_size) or (1, seq_len, hidden_size) - - hidden_states: (1, seq_len, hidden_size) - - pixel_values: (patch_len, patch_width) - - image_grid_thw (batch_size, 3) - """ - if kwargs: - logger.debug(f"unused kwargs {list(kwargs.keys())}") - - if is_vlm: - data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list = ( - self.extend_vlm( - input_ids, - attention_mask, - loss_mask, - return_last_hidden_states=False, - return_logits=True, - pixel_values=pixel_values, - image_grid_thw=image_grid_thw, - ) - ) - else: - data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list = ( - self.extend( - input_ids, - attention_mask, - loss_mask, - return_last_hidden_states=False, - return_logits=True, - shard_returns=shard_returns, - ) - ) - aux_hidden_states_out = [] - target_out = [] - loss_mask_out = [] - attention_mask_out = [] - input_ids_out = [] - last_hidden_states_out = [] - - for idx, (data, logits, aux_hidden_states, last_hidden_states) in enumerate( - zip( - data_cache, logits_list, aux_hidden_states_list, last_hidden_states_list - ) - ): - if aux_hidden_states is not None: - aux_hidden_states_out.append(aux_hidden_states.unsqueeze(0)) - loss_mask_out.append(data[2]) - attention_mask_out.append(data[1]) - input_ids_out.append(data[0]) - - # when generating hidden states for offline training, we don't compute logits and only keep the last_hidden_states - # when training online, we don't keep the last_hidden_states and only keep the logits - if logits is not None: - target_out.append(logits.unsqueeze(0)) - - if last_hidden_states is not None: - last_hidden_states_out.append(last_hidden_states.unsqueeze(0)) - - aux_hidden_states_out = torch.cat(aux_hidden_states_out, dim=0) - - loss_mask_out = torch.cat(loss_mask_out, dim=0) - attention_mask_out = torch.cat(attention_mask_out, dim=0) - input_ids_out = torch.cat(input_ids_out, dim=0) - - if target_out: - target_out = torch.cat(target_out, dim=0) - else: - target_out = None - - if last_hidden_states_out: - last_hidden_states_out = torch.cat(last_hidden_states_out, dim=0) - else: - last_hidden_states_out = None - - if target_out is not None: - target_out = padding(target_out, left=False) - input_ids_out = padding(input_ids_out, left=False) - loss_mask_out = loss_mask_out[..., None] - - return Eagle3TargetOutput( - hidden_states=aux_hidden_states_out, - target=target_out, - loss_mask=loss_mask_out, - input_ids=input_ids_out, - attention_mask=attention_mask_out, - last_hidden_states=last_hidden_states_out, - ) - - -class CustomEagle3TargetEngine(Eagle3TargetEngine): - - backend = "custom" - - def __init__(self, model: nn.Module): - super().__init__() - self.model = model - - @classmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - **kwargs, - ) -> "CustomEagle3TargetEngine": - from specforge.modeling.auto import AutoDistributedTargetModel - - target_model = AutoDistributedTargetModel.from_pretrained( - pretrained_model_name_or_path=pretrained_model_name_or_path, - torch_dtype=torch_dtype, - cache_dir=cache_dir, - device=device, - **kwargs, - ) - return cls(target_model) - - @torch.no_grad() - def generate_eagle3_data( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - **kwargs, - ) -> Eagle3TargetOutput: - if kwargs: - logger.debug(f"unused kwargs {list(kwargs.keys())}") - - outputs = self.model( - input_ids=input_ids, - attention_mask=attention_mask, - output_hidden_states=True, - layers_to_output_hidden_states=self.aux_hidden_states_layers, - use_cache=False, - ) - - # For custom backends, the model implementation is responsible for only - # returning the requested layers in `outputs.hidden_states`. - hidden_states = torch.cat(outputs.hidden_states, dim=-1) - - target = outputs.logits - target = padding(target, left=False) - input_ids = padding(input_ids, left=False) - loss_mask = loss_mask[..., None].to(target.device) - - return Eagle3TargetOutput( - hidden_states=hidden_states, - target=target, - loss_mask=loss_mask, - input_ids=input_ids, - attention_mask=attention_mask, - ) - - -class SGLangServerEagle3TargetEngine(Eagle3TargetEngine): - """Live frozen-target SGLang *server* engine (cross-node) — W3 / W3′. - - The fourth backend: instead of an in-process ``ModelRunner``, a *frozen* - target runs as a live SGLang server and streams hidden states (capture into a - FeatureStore for W3, or inline over HTTP for the light W3′). It is - ``backend="sglang_server"`` — selectable through the factory — but the live - capture implementation is gated by the O1.3 throughput spike (see - ``docs/roadmap/online-disaggregation.md`` §O1.3), so construction raises with an - actionable message until that lands. The de-EAGLE3 extraction and the domain - Trainer carry no engine risk and do not depend on this backend. - """ - - backend = "sglang_server" - - def __init__(self, *args, **kwargs): - raise NotImplementedError( - "sglang_server target engine (live cross-node frozen-target capture) is " - "not implemented yet; its depth is gated by the O1.3 capture spike " - "(docs/roadmap/online-disaggregation.md §O1.3). Use backend='sglang' " - "(in-process) or 'hf' for now." - ) - - @classmethod - def from_pretrained( - cls, - pretrained_model_name_or_path: str, - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - **kwargs, - ) -> "SGLangServerEagle3TargetEngine": - return cls() - - def generate_eagle3_data( - self, - input_ids: torch.Tensor, - attention_mask: torch.Tensor, - loss_mask: torch.Tensor, - **kwargs, - ) -> Eagle3TargetOutput: # pragma: no cover - unreachable (ctor raises) - raise NotImplementedError - - -def get_eagle3_target_model( - pretrained_model_name_or_path: str, - backend: str = "sglang", - torch_dtype: torch.dtype = None, - device: str = None, - cache_dir: Optional[str] = None, - **kwargs, -) -> Eagle3TargetEngine: - if backend == "sglang": - return SGLangEagle3TargetEngine.from_pretrained( - pretrained_model_name_or_path=pretrained_model_name_or_path, - torch_dtype=torch_dtype, - device=device, - cache_dir=cache_dir, - **kwargs, - ) - elif backend == "hf": - return HFEagle3TargetEngine.from_pretrained( - pretrained_model_name_or_path=pretrained_model_name_or_path, - torch_dtype=torch_dtype, - device=device, - cache_dir=cache_dir, - **kwargs, - ) - elif backend == "custom": - return CustomEagle3TargetEngine.from_pretrained( - pretrained_model_name_or_path=pretrained_model_name_or_path, - torch_dtype=torch_dtype, - device=device, - cache_dir=cache_dir, - **kwargs, - ) - elif backend == "sglang_server": - return SGLangServerEagle3TargetEngine.from_pretrained( - pretrained_model_name_or_path=pretrained_model_name_or_path, - torch_dtype=torch_dtype, - device=device, - cache_dir=cache_dir, - **kwargs, - ) - else: - raise ValueError(f"Invalid backend: {backend}") - - -# --- Back-compat aliases (pre-Phase-B names) ------------------------------- -# The de-EAGLE3 rename (``*TargetModel`` -> ``*TargetEngine``, ``Eagle3TargetModel`` -# -> ``Eagle3TargetEngine``) is import-compatible: existing scripts, tests and -# ``specforge.modeling`` re-exports keep importing the old names. These aliases -# are removed once callers migrate (tracked in the Phase E model-layout move). -Eagle3TargetModel = Eagle3TargetEngine -HFEagle3TargetModel = HFEagle3TargetEngine -SGLangEagle3TargetModel = SGLangEagle3TargetEngine -CustomEagle3TargetModel = CustomEagle3TargetEngine +# coding=utf-8 +"""Import shim — moved to ``specforge.inference.target_engine.eagle3_target_model``.""" + +from specforge.inference.target_engine.eagle3_target_model import ( # noqa: F401 + CustomEagle3TargetEngine, + CustomEagle3TargetModel, + Eagle3TargetEngine, + Eagle3TargetModel, + HFEagle3TargetEngine, + HFEagle3TargetModel, + SGLangEagle3TargetEngine, + SGLangEagle3TargetModel, + SGLangServerEagle3TargetEngine, + get_eagle3_target_model, +) diff --git a/specforge/modeling/target/factory.py b/specforge/modeling/target/factory.py index dbafa1045..5c3698a21 100644 --- a/specforge/modeling/target/factory.py +++ b/specforge/modeling/target/factory.py @@ -1,82 +1,9 @@ # coding=utf-8 -# Copyright 2024 The SpecForge team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -"""Generic target-engine factory (Phase B). - -``get_target_engine(strategy=..., backend=...)`` is the de-EAGLE3'd factory: it -dispatches on the *algorithm* (``eagle3`` / ``dflash``) and delegates to the -per-algorithm loaders, which in turn dispatch on the *backend* (sglang / hf / -custom / sglang_server). The legacy ``get_eagle3_target_model`` / -``get_dflash_target_model`` remain as thin shims (they ARE the per-algorithm -loaders this factory calls), so nothing that imports them breaks. -""" - -from __future__ import annotations - -from typing import Optional - -import torch - -from .base import TargetEngine - -# strategy name -> per-algorithm engine. Domino reuses the DFlash engine (same -# capture: concatenated layer hidden states, no target distribution). The loaders -# are imported LAZILY inside the factory: dflash_target_model imports sglang -# internals unconditionally, so eager import here would break the design property -# that ``import specforge`` works even when the installed sglang lacks the pinned -# symbols (eagle3_target_model guards its imports; see its module docstring). -_ENGINE_STRATEGIES = ("eagle3", "dflash", "domino") - - -def available_target_engines(): - """Strategy names with a registered target-engine loader.""" - return sorted(_ENGINE_STRATEGIES) - - -def _resolve_loader(strategy: str): - if strategy == "eagle3": - from .eagle3_target_model import get_eagle3_target_model - - return get_eagle3_target_model - if strategy in ("dflash", "domino"): - from .dflash_target_model import get_dflash_target_model - - return get_dflash_target_model - raise ValueError( - f"no target engine for strategy {strategy!r}; " - f"registered: {available_target_engines()}" - ) - - -def get_target_engine( - pretrained_model_name_or_path: str, - *, - strategy: str = "eagle3", - backend: str = "sglang", - torch_dtype: Optional[torch.dtype] = None, - device: Optional[str] = None, - cache_dir: Optional[str] = None, - **kwargs, -) -> TargetEngine: - """Load a frozen :class:`TargetEngine` for ``strategy`` on ``backend``. - - The single, algorithm-agnostic entry point launch code should prefer. The - older ``get_eagle3_target_model`` / ``get_dflash_target_model`` stay valid. - """ - loader = _resolve_loader(strategy) - return loader( - pretrained_model_name_or_path=pretrained_model_name_or_path, - backend=backend, - torch_dtype=torch_dtype, - device=device, - cache_dir=cache_dir, - **kwargs, - ) +"""Import shim — moved to ``specforge.inference.target_engine.factory``.""" +from specforge.inference.target_engine.factory import ( # noqa: F401 + available_target_engines, + get_target_engine, +) __all__ = ["get_target_engine", "available_target_engines"] diff --git a/specforge/modeling/target/sglang_backend/__init__.py b/specforge/modeling/target/sglang_backend/__init__.py index ed4263103..c35dd81d5 100644 --- a/specforge/modeling/target/sglang_backend/__init__.py +++ b/specforge/modeling/target/sglang_backend/__init__.py @@ -1,6 +1,11 @@ -from .capture import SGLangCaptureBackend -from .model_runner import SGLangRunner -from .utils import wrap_eagle3_logits_processors_in_module +# coding=utf-8 +"""Import shim — moved to ``specforge.inference.target_engine.sglang_backend``.""" + +from specforge.inference.target_engine.sglang_backend import ( # noqa: F401 + SGLangCaptureBackend, + SGLangRunner, + wrap_eagle3_logits_processors_in_module, +) __all__ = [ "SGLangRunner", diff --git a/specforge/runtime/inference/capture.py b/specforge/runtime/inference/capture.py new file mode 100644 index 000000000..bae9410a1 --- /dev/null +++ b/specforge/runtime/inference/capture.py @@ -0,0 +1,10 @@ +# coding=utf-8 +"""Import shim — moved to ``specforge.inference.capture``.""" + +from specforge.inference.capture import ( # noqa: F401 + CaptureConfig, + CaptureMismatchError, + verify_capture, +) + +__all__ = ["CaptureConfig", "CaptureMismatchError", "verify_capture"] diff --git a/specforge/runtime/inference/dflash_adapter.py b/specforge/runtime/inference/dflash_adapter.py new file mode 100644 index 000000000..93ec0a214 --- /dev/null +++ b/specforge/runtime/inference/dflash_adapter.py @@ -0,0 +1,5 @@ +# coding=utf-8 +"""Import shim — moved to ``specforge.inference.adapters.dflash``.""" + +from specforge.inference.adapters.dflash import * # noqa: F401,F403 +from specforge.inference.adapters.dflash import __all__ # noqa: F401 diff --git a/specforge/runtime/inference/sglang_adapter.py b/specforge/runtime/inference/sglang_adapter.py new file mode 100644 index 000000000..4ed1a9f33 --- /dev/null +++ b/specforge/runtime/inference/sglang_adapter.py @@ -0,0 +1,5 @@ +# coding=utf-8 +"""Import shim — moved to ``specforge.inference.adapters.eagle3``.""" + +from specforge.inference.adapters.eagle3 import * # noqa: F401,F403 +from specforge.inference.adapters.eagle3 import __all__ # noqa: F401 diff --git a/specforge/runtime/training/__init__.py b/specforge/runtime/training/__init__.py new file mode 100644 index 000000000..1ec621ed7 --- /dev/null +++ b/specforge/runtime/training/__init__.py @@ -0,0 +1,5 @@ +# coding=utf-8 +"""Shim package — the training plane moved to top-level ``specforge.training`` +(``controller`` / ``backend`` / ``strategies``). The modules here re-export from +the new home for one release; import the new paths in new code. +""" diff --git a/specforge/training/backend.py b/specforge/training/backend.py index be9dd7ec2..ce53203f0 100644 --- a/specforge/training/backend.py +++ b/specforge/training/backend.py @@ -1,307 +1,10 @@ # coding=utf-8 -# Copyright 2024 The SpecForge team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -"""TrainingBackend: model wrapping / backward / optimizer step / state dict. - -FSDP-only for now. ``ParallelConfig`` carries the parallel handles created by -``init_distributed`` rather than re-deriving them; distributed imports stay -lazy so the module is importable without a GPU. -""" - -from __future__ import annotations - -import abc -import contextlib -import logging -from dataclasses import dataclass, field -from typing import Any, Dict, Optional - -import torch -import torch.distributed as dist -import torch.nn as nn - - -@dataclass -class ParallelConfig: - """Handles describing the active parallel layout. Carried, not re-derived.""" - - world_size: int = 1 - tp_size: int = 1 - sp_ulysses_size: int = 1 - sp_ring_size: int = 1 - sharding_strategy: str = "SHARD_GRAD_OP" - param_dtype: torch.dtype = torch.bfloat16 - # opaque process-group / device-mesh handles snapshotted from - # init_distributed, never re-derived (None in single-process). - fsdp_process_group: Any = None - dp_group: Any = None - draft_dp_group: Any = None - tp_group: Any = None - sp_ulysses_group: Any = None - sp_ring_group: Any = None - draft_sp_group: Any = None - device_mesh: Any = None - tp_device_mesh: Any = None - extra: dict = field(default_factory=dict) - - @property - def sp_size(self) -> int: - return self.sp_ulysses_size * self.sp_ring_size - - @classmethod - def from_distributed( - cls, - *, - tp_size: int = 1, - sp_ulysses_size: int = 1, - sp_ring_size: int = 1, - sharding_strategy: str = "SHARD_GRAD_OP", - param_dtype: torch.dtype = torch.bfloat16, - ) -> "ParallelConfig": - """Snapshot all parallel handles (DP/TP/SP groups, device meshes) from - ``init_distributed``; a missing getter is logged, never silently skipped.""" - if not dist.is_initialized(): - return cls( - world_size=1, - tp_size=tp_size, - sp_ulysses_size=sp_ulysses_size, - sp_ring_size=sp_ring_size, - sharding_strategy=sharding_strategy, - param_dtype=param_dtype, - ) - handles: Dict[str, Any] = {} - try: - from specforge import distributed as sfdist - - for name, getter in ( - ("dp_group", "get_dp_group"), - ("draft_dp_group", "get_draft_dp_group"), - ("tp_group", "get_tp_group"), - ("sp_ulysses_group", "get_sp_ulysses_group"), - ("sp_ring_group", "get_sp_ring_group"), - ("draft_sp_group", "get_draft_sp_group"), - ("device_mesh", "get_device_mesh"), - ("tp_device_mesh", "get_tp_device_mesh"), - ): - fn = getattr(sfdist, getter, None) - if fn is None: - continue - try: - handles[name] = fn() - except Exception as exc: # group not built for this config - logging.getLogger(__name__).warning( - "ParallelConfig.from_distributed: %s() unavailable: %s", - getter, - exc, - ) - except Exception as exc: - logging.getLogger(__name__).warning( - "ParallelConfig.from_distributed: specforge.distributed import failed: %s", - exc, - ) - return cls( - world_size=dist.get_world_size(), - tp_size=tp_size, - sp_ulysses_size=sp_ulysses_size, - sp_ring_size=sp_ring_size, - sharding_strategy=sharding_strategy, - param_dtype=param_dtype, - fsdp_process_group=dist.group.WORLD, - **handles, - ) - - -class TrainingBackend(abc.ABC): - name: str - - @abc.abstractmethod - def prepare_model(self, model: nn.Module) -> nn.Module: ... - - @abc.abstractmethod - def backward(self, loss: torch.Tensor, *, is_boundary: bool = True) -> None: ... - - @abc.abstractmethod - def step(self) -> Optional[torch.Tensor]: ... - - @abc.abstractmethod - def state_dict(self) -> dict: ... - - @abc.abstractmethod - def load_state_dict(self, state: dict) -> None: ... - - -class FSDPTrainingBackend(TrainingBackend): - """FSDP1 backend mirroring the legacy SpecForge training math: FSDP with - ``use_orig_params=True`` / bf16 mixed precision over the configured process - group, optimizer targeting the inner trainable submodule.""" - - name = "fsdp" - - def __init__( - self, - parallel_config: ParallelConfig, - *, - optimizer_factory=None, - ) -> None: - self.parallel_config = parallel_config - self._optimizer_factory = optimizer_factory - self.module: Optional[nn.Module] = None - self.optimizer = None - self._wrapped = False - - def prepare_model( - self, - model: nn.Module, - *, - wrap: bool = True, - optimizer_target: Optional[nn.Module] = None, - ) -> nn.Module: - """Register the trainable module, FSDP-wrapping it unless ``wrap=False`` - (single-rank / equivalence runs where sharding would be a no-op).""" - if not wrap: - self.module = model - self._wrapped = False - else: - from torch.distributed.fsdp import FullyShardedDataParallel as FSDP - from torch.distributed.fsdp import MixedPrecision, ShardingStrategy - - pc = self.parallel_config - sharding = getattr(ShardingStrategy, pc.sharding_strategy) - model = FSDP( - model, - use_orig_params=True, - mixed_precision=MixedPrecision( - param_dtype=pc.param_dtype, buffer_dtype=pc.param_dtype - ), - sharding_strategy=sharding, - process_group=pc.fsdp_process_group, - ) - self.module = model - self._wrapped = True - if self._optimizer_factory is not None: - target = optimizer_target if optimizer_target is not None else self.module - self.optimizer = self._optimizer_factory(target) - return self.module - - def set_optimizer(self, optimizer) -> None: - self.optimizer = optimizer - - def backward(self, loss: torch.Tensor, *, is_boundary: bool = True) -> None: - """Backward one micro-step. FSDP reduce-scatters grads on every backward, - so non-boundary micro-steps run under ``no_sync()`` and the boundary - backward reduces the accumulated sum once — identical math, one - collective per optimizer step.""" - if is_boundary or not self._wrapped: - loss.backward() - else: - with self.module.no_sync(): - loss.backward() - - def step(self) -> Optional[torch.Tensor]: - """Optimizer step + the distributed grad-norm reduction (run_backward_and_update).""" - if self.optimizer is None: - raise RuntimeError( - "FSDPTrainingBackend.step called before optimizer is set" - ) - grad_norm = self.optimizer.step() - if grad_norm is not None and dist.is_initialized(): - grad_norm = grad_norm.detach().float() - if torch.cuda.is_available(): - grad_norm = grad_norm.to(torch.cuda.current_device()) - grad_norm = grad_norm.pow(2) - dist.all_reduce(grad_norm, op=dist.ReduceOp.SUM) - grad_norm = grad_norm.sqrt() - return grad_norm - - def state_dict(self) -> dict: - """Full training state ``{"model", "optimizer", "rng"}`` for resume. - - ``model`` is gathered rank0-only with CPU offload (``{}`` on other ranks - when wrapped); ``optimizer``/``rng`` are rank-local and must be persisted - per rank — restoring rank0's copy everywhere corrupts the other ranks. - """ - if self.module is None: - raise RuntimeError("state_dict called before prepare_model") - return { - "model": self._module_state_dict(), - "optimizer": ( - self.optimizer.state_dict() if self.optimizer is not None else None - ), - "rng": self._rng_state(), - } - - def load_state_dict(self, state: dict) -> None: - """Restore whichever of module weights / optimizer / RNG the state carries.""" - if state.get("model") is not None: - self._load_module_state_dict(state["model"]) - if self.optimizer is not None and state.get("optimizer") is not None: - self.optimizer.load_state_dict(state["optimizer"]) - if state.get("rng") is not None: - self._set_rng_state(state["rng"]) - - def _full_state_ctx(self, state_dict_config=None): - """FULL_STATE_DICT context for a wrapped module; a no-op when unwrapped.""" - if not self._wrapped: - return contextlib.nullcontext() - from torch.distributed.fsdp import FullyShardedDataParallel as FSDP - from torch.distributed.fsdp import StateDictType - - return FSDP.state_dict_type( - self.module, StateDictType.FULL_STATE_DICT, state_dict_config - ) - - def _module_state_dict(self) -> dict: - if not self._wrapped: - return self.module.state_dict() - from torch.distributed.fsdp import FullStateDictConfig - - # gather to rank0 CPU only — materializing the full model on every - # rank's GPU is wasted memory when only rank0 writes it. - cfg = FullStateDictConfig(offload_to_cpu=True, rank0_only=True) - with self._full_state_ctx(cfg): - return self.module.state_dict() - - def _load_module_state_dict(self, model_state: dict) -> None: - # every rank loads the full state dict read from the shared file. - with self._full_state_ctx(): - self.module.load_state_dict(model_state) - - @staticmethod - def _rng_state() -> dict: - # single bound-device CUDA state keeps the checkpoint independent of - # how many devices happen to be visible at save time. - return { - "torch": torch.get_rng_state(), - "cuda": ( - torch.cuda.get_rng_state(torch.cuda.current_device()) - if torch.cuda.is_available() - else None - ), - } - - @staticmethod - def _set_rng_state(rng: dict) -> None: - cpu_state = rng.get("torch", rng.get("cpu")) # "cpu" = legacy key - if cpu_state is not None: - torch.set_rng_state(cpu_state) - cuda_state = rng.get("cuda") - if cuda_state is None or not torch.cuda.is_available(): - return - device = torch.cuda.current_device() - if isinstance(cuda_state, list): # legacy get_rng_state_all format - if device >= len(cuda_state): - raise ValueError( - f"legacy RNG checkpoint holds {len(cuda_state)} CUDA states " - f"but this rank's bound device index is {device}; it was " - "saved with fewer visible devices and cannot be restored here" - ) - cuda_state = cuda_state[device] - torch.cuda.set_rng_state(cuda_state, device) +"""Import shim — moved to ``specforge.training.backend``.""" +from specforge.training.backend import ( # noqa: F401 + FSDPTrainingBackend, + ParallelConfig, + TrainingBackend, +) __all__ = ["ParallelConfig", "TrainingBackend", "FSDPTrainingBackend"] diff --git a/specforge/training/controller.py b/specforge/training/controller.py index b03be5ad7..1134a5b48 100644 --- a/specforge/training/controller.py +++ b/specforge/training/controller.py @@ -1,13 +1,7 @@ # coding=utf-8 -# Copyright 2024 The SpecForge team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -"""TrainerCore + TrainerController: the trainer-boundary split. +"""Import shim — moved to ``specforge.training.controller``.""" +<<<<<<<< HEAD:specforge/training/controller.py ``TrainerCore`` runs exactly one branch-free step (strategy forward/loss, backend backward/step) plus the grad-accumulation boundary. ``TrainerController`` owns the lifecycle: fit / evaluate / save_checkpoint. EAGLE3 and DFlash share this @@ -30,329 +24,13 @@ DraftTrainStrategy, StepContext, StepOutput, +======== +from specforge.training.controller import ( # noqa: F401 + Checkpoint, + StepResult, + TrainerController, + TrainerCore, +>>>>>>>> 3ae106c ([DataFlow runtime] E0 — layout consolidation: runtime/ is substrate-only (move-only)):specforge/runtime/training/trainer.py ) -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class Checkpoint: - """A saved training checkpoint location (resume target) — deliberately NOT a - published weight version (weight publication is not implemented).""" - - checkpoint_uri: str - global_step: int - epoch: int - strategy: str - metadata: Dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class StepResult: - """Result of one TrainerCore step; ``optimizer_stepped`` is the authoritative - grad-accumulation boundary signal.""" - - optimizer_stepped: bool - loss: float - grad_norm: Optional[float] - metrics: Dict[str, Any] = field(default_factory=dict) - - -def _scalar(x: Any) -> float: - if isinstance(x, torch.Tensor): - return float(x.detach().float().mean().item()) - if isinstance(x, (list, tuple)) and x: - return float(torch.stack([t.detach().float() for t in x]).mean().item()) - return float(x) - - -class TrainerCore: - """One step: forward/loss (strategy) -> backward (backend) -> optimizer boundary.""" - - def __init__( - self, - strategy: DraftTrainStrategy, - backend: TrainingBackend, - *, - accumulation_steps: int = 1, - ) -> None: - self.strategy = strategy - self.backend = backend - self.accumulation_steps = max(1, accumulation_steps) - self._micro = 0 - - def train_step( - self, batch: TrainBatch, ctx: Optional[StepContext] = None - ) -> StepResult: - out: StepOutput = self.strategy.forward_loss(batch, ctx) - loss = out.loss / self.accumulation_steps - self._micro += 1 - # The boundary is known before backward so the backend can defer the FSDP - # gradient reduction (no_sync) on non-boundary micro-steps. - stepped = self._micro % self.accumulation_steps == 0 - self.backend.backward(loss, is_boundary=stepped) - grad_norm = self.backend.step() if stepped else None - return self._result(out, grad_norm, stepped) - - # Deliberately no eval_step: evaluation runs ``Evaluator.run`` on raw - # ``forward_loss`` outputs — ``_result`` scalarizes away the per-position - # count tensors that correct acc-len aggregation needs. - - def _result(self, out: StepOutput, grad_norm, stepped: bool) -> StepResult: - metrics: Dict[str, Any] = {"loss": _scalar(out.loss)} - for key in ("acces", "acceptance_rates", "plosses"): - if key in out.metrics: - metrics[key.rstrip("es") if key == "acces" else key] = _scalar( - out.metrics[key] - ) - if "accuracy" in out.metrics: - metrics["acc"] = _scalar(out.metrics["accuracy"]) - gn = _scalar(grad_norm) if grad_norm is not None else None - if gn is not None: - metrics["grad_norm"] = gn - return StepResult( - optimizer_stepped=stepped, - loss=metrics["loss"], - grad_norm=gn, - metrics=metrics, - ) - - -class TrainerController: - """Lifecycle: fit / evaluate / checkpoint. The training script becomes a - launcher; weight publishing is not implemented — ``save_checkpoint`` persists - resume state and returns a :class:`Checkpoint`.""" - - def __init__( - self, - core: TrainerCore, - *, - run_id: str, - output_dir: str = "./output", - save_interval: int = 0, - eval_interval: int = 0, - log_interval: int = 50, - max_steps: Optional[int] = None, - total_steps: Optional[int] = None, - num_epochs: int = 1, - logger: Optional[Callable[[Dict[str, Any], int], None]] = None, - ack_fn: Optional[Callable[[List[str], int], None]] = None, - start_step: int = 0, - start_epoch: int = 0, - start_batch: int = 0, - start_samples: int = 0, - checkpoint_manager: Optional[Any] = None, - checkpoint_extra: Optional[Dict[str, Any]] = None, - ) -> None: - if (start_batch == 0) != (start_samples == 0): - raise ValueError( - f"start_batch={start_batch} and start_samples={start_samples} " - f"describe the same mid-epoch position and must be zero or " - f"nonzero together" - ) - self.core = core - self.run_id = run_id - self.output_dir = output_dir - self.save_interval = save_interval - self.eval_interval = eval_interval - self.log_interval = log_interval - # Injected manager (rotation, best metric) or the lazy default layout. - self._checkpoint_mgr = checkpoint_manager - # Extra entries merged into the shared checkpoint payload at save - # (e.g. dataset_size / accumulation_steps, validated on resume). - self.checkpoint_extra = dict(checkpoint_extra or {}) - self.max_steps = max_steps - # Schedule horizon for step-dependent losses (Domino's lambda_base decay); - # distinct from max_steps, an optional early-stop CAP. Falls back to - # max_steps; None means schedule-reading strategies decay nothing. - self.total_steps = total_steps if total_steps is not None else max_steps - self.num_epochs = num_epochs - self.logger = logger - # ack_fn(sample_ids, global_step) records the durable ack transaction at - # the optimizer-step boundary; None = the loader acks (simple runs). - self.ack_fn = ack_fn - # global_step counts OPTIMIZER steps (increments only at a grad-accum - # boundary) so ack/checkpoint/resume semantics are in true optimizer - # steps; micro_step counts forward/backward micro-batches. - self.global_step = start_step - self.micro_step = 0 - self.epoch = start_epoch - # Live position within the current epoch, in batches and in SAMPLES - # (batch-size independent, the persisted form). Nonzero at an epoch start - # — seeded here on resume, or left over from a mid-epoch max_steps return - # — makes fit() skip that prefix instead of re-training it. - self._epoch_batch = start_batch - self._epoch_samples = start_samples - self.last_metrics: Dict[str, Any] = {} - - def fit( - self, data: Iterable[TrainBatch], eval_data: Optional[Iterable] = None - ) -> int: - if self.max_steps is not None and self.global_step >= self.max_steps: - logger.info( - "fit: global_step=%d already at max_steps=%d; nothing to train", - self.global_step, - self.max_steps, - ) - return self.global_step - module = self.core.strategy.trainable_module() - module.train() - # Rank0-broadcast once: a rank-local eval_data view must not let ranks - # enter or skip the evaluator's collectives alone. - eval_enabled = self._rank0_decision(eval_data is not None) - pending_ack: List[str] = [] - for epoch in range(self.epoch, self.num_epochs): - self.epoch = epoch - if hasattr(data, "set_epoch"): - data.set_epoch(epoch) - stream: Iterable[TrainBatch] = data - skip = self._epoch_batch - if skip: - if hasattr(data, "seek"): - data.seek(skip) - else: - it = iter(data) - consumed = sum(1 for _ in itertools.islice(it, skip)) - if consumed < skip: - raise ValueError( - f"resume position skips past the end of the data: " - f"epoch {epoch} yielded only {consumed} batches, " - f"cannot skip {skip}" - ) - stream = it - for batch in stream: - self._epoch_batch += 1 - self._epoch_samples += len(batch.sample_ids) - self.micro_step += 1 - if self.ack_fn is not None: - pending_ack.extend(batch.sample_ids) - result = self.core.train_step( - batch, - ctx=StepContext( - global_step=self.global_step, total_steps=self.total_steps - ), - ) - self.last_metrics = result.metrics - # grad accumulated but optimizer has not stepped yet; everything - # keyed on optimizer steps fires only at the boundary. - if not result.optimizer_stepped: - continue - self.global_step += 1 - if self.ack_fn is not None: - # durable ack transaction at the optimizer-step boundary - self.ack_fn(pending_ack, self.global_step) - pending_ack = [] - if self.logger and self.global_step % max(1, self.log_interval) == 0: - self.logger(result.metrics, self.global_step) - eval_metrics: Optional[Dict[str, Any]] = None - if ( - self.eval_interval - and eval_enabled - and self.global_step % self.eval_interval == 0 - ): - eval_metrics = self.evaluate(eval_data) - module.train() - if eval_metrics: - if self.logger: - self.logger(eval_metrics, self.global_step) - self.last_metrics = {**self.last_metrics, **eval_metrics} - # is_better is a collective (rank0 verdict broadcast inside the - # manager); its guard is rank-identical because eval_metrics is - # DP-reduced. Empty ({}) eval metrics skip best entirely. - interval_hit = bool( - self.save_interval and self.global_step % self.save_interval == 0 - ) - is_best = bool( - self.save_interval - and eval_metrics - and self._checkpoint_manager().is_better(eval_metrics) - ) - if interval_hit or is_best: - self.save_checkpoint(self.global_step) - if is_best: - self._checkpoint_manager().update_best( - self.global_step, eval_metrics - ) - if self.max_steps is not None and self.global_step >= self.max_steps: - return self.global_step - self._epoch_batch = 0 - self._epoch_samples = 0 - return self.global_step - - @torch.no_grad() - def evaluate(self, data: Optional[Iterable[TrainBatch]]) -> Dict[str, Any]: - """Full-pass eval via :class:`Evaluator`: rank-identical ``eval/*`` - metrics, ``{}`` when zero batches were processed globally. ``data=None`` - (empty local shard) still joins the evaluator's collectives.""" - from specforge.eval import Evaluator - - module = self.core.strategy.trainable_module() - module.eval() - # Same StepContext as the train path so schedule-dependent losses - # (Domino's lambda_base) evaluate at the live step, not at step 0. - ctx = StepContext(global_step=self.global_step, total_steps=self.total_steps) - return Evaluator().run( - lambda batch: self.core.strategy.forward_loss(batch, ctx), data - ) - - @staticmethod - def _rank0_decision(flag: bool) -> bool: - """Broadcast rank0's verdict so a collective-bearing branch is entered by - every rank or by none.""" - if ( - not torch.distributed.is_initialized() - or torch.distributed.get_world_size() == 1 - ): - return bool(flag) - verdict = [bool(flag)] - torch.distributed.broadcast_object_list(verdict, src=0) - return bool(verdict[0]) - - def _checkpoint_manager(self): - # Lazily built in its S-home so the runtime seam does not import the domain - # layer at module load (mirrors _assemble_trainer's lazy Trainer import). - if self._checkpoint_mgr is None: - from specforge.training.checkpoint import CheckpointManager - - self._checkpoint_mgr = CheckpointManager(self.output_dir, self.run_id) - return self._checkpoint_mgr - - def save_checkpoint(self, step: int) -> Checkpoint: - # Every rank participates: the FSDP FULL_STATE_DICT gather is a - # collective, and the optimizer/RNG parts are rank-local so every rank - # persists its own (the manager writes the shared payload on rank0 only). - full = self.core.backend.state_dict() - mgr = self._checkpoint_manager() - shared = None - if mgr.is_rank0(): - shared = { - "draft_state_dict": self.core.strategy.checkpoint_state_filter( - full["model"] - ), - "global_step": step, - "epoch": self.epoch, - "epoch_batch": self._epoch_batch, - "epoch_samples": self._epoch_samples, - "strategy": self.core.strategy.name, - "run_id": self.run_id, - "world_size": ( - torch.distributed.get_world_size() - if torch.distributed.is_initialized() - else 1 - ), - **self.checkpoint_extra, - } - ckpt_dir = mgr.save( - shared, - step, - rank_state={"optimizer": full["optimizer"], "rng": full["rng"]}, - ) - return Checkpoint( - checkpoint_uri=f"file://{os.path.abspath(ckpt_dir)}", - global_step=step, - epoch=self.epoch, - strategy=self.core.strategy.name, - ) - - __all__ = ["TrainerCore", "TrainerController", "Checkpoint", "StepResult"] diff --git a/specforge/training/strategies/base.py b/specforge/training/strategies/base.py index 9a04dd640..f2627f5da 100644 --- a/specforge/training/strategies/base.py +++ b/specforge/training/strategies/base.py @@ -1,329 +1,15 @@ # coding=utf-8 -# Copyright 2024 The SpecForge team. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -"""DraftTrainStrategy: per-draft-model required features + forward/loss + projection. - -A strategy is the only place that knows how a draft model (EAGLE3 / DFlash / -Domino) turns a normalized ``TrainBatch`` into a loss; ``TrainerCore`` stays -branch-free and the strategy owns the target projection. Imports model code, -so it is imported by training entry points, not at package load. -""" - -from __future__ import annotations - -import abc -from dataclasses import dataclass -from typing import Any, Dict, Optional, Tuple - -import torch -import torch.nn as nn - -from specforge.runtime.contracts import TrainBatch - - -@dataclass(frozen=True) -class StepOutput: - """Per-step result: loss + strategy-specific metrics, kept generic so - per-position (TTT) and single-scalar strategies share one trainer loop.""" - - loss: torch.Tensor - metrics: Dict[str, Any] - - -@dataclass(frozen=True) -class StepContext: - """Training-schedule state passed into ``forward_loss`` for objectives that - depend on where in training we are (e.g. Domino's decaying ``lambda_base``); - most strategies ignore it.""" - - global_step: int = 0 - total_steps: Optional[int] = None - - -def linear_lambda_base( - global_step: int, - total_steps: int, - lambda_start: float = 1.0, - decay_ratio: float = 0.5, -) -> float: - """Domino base-loss weight: linear decay from ``lambda_start`` to 0 over the - first ``total_steps * decay_ratio`` steps, then 0, clamped to ``[0, 1]``. - Single source for the runtime strategy and ``scripts/train_domino.py``; - requires a real ``total_steps`` (> 0).""" - decay_steps = max(1, int(total_steps * decay_ratio)) - progress = min(global_step / decay_steps, 1.0) - return max(0.0, min(1.0, lambda_start * (1.0 - progress))) - - -class DraftTrainStrategy(abc.ABC): - name: str - required_features: set - - @abc.abstractmethod - def trainable_module(self) -> nn.Module: - """The module whose parameters the optimizer/backend owns.""" - - def validate_batch(self, batch: TrainBatch) -> None: - missing = {f for f in self.required_features if f not in batch.tensors} - if missing: - raise ValueError( - f"{self.name} batch missing required features {sorted(missing)}; " - f"present={sorted(batch.tensors)}" - ) - - @abc.abstractmethod - def forward_loss( - self, batch: TrainBatch, ctx: Optional["StepContext"] = None - ) -> StepOutput: ... - - def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: - """Select the keys this strategy persists as draft weights.""" - return state_dict - - -class Eagle3TrainStrategy(DraftTrainStrategy): - """EAGLE3 TTT strategy wrapping the existing ``OnlineEagle3Model``. - - For ``target_repr == "hidden_state"`` the strategy re-runs the frozen - ``TargetHead`` over the stored target hidden state; ``logits`` / - ``pruned_logits`` are used as delivered. The ``t2d`` vocab map is applied - inside ``OnlineEagle3Model.forward``. - """ - - name = "eagle3" - required_features = { - "input_ids", - "attention_mask", - "loss_mask", - "hidden_state", - "target", - } - - def __init__( - self, - eagle3_model: nn.Module, - *, - target_head: Optional[nn.Module] = None, - ploss_decay: float = 0.8, - ) -> None: - self.eagle3_model = eagle3_model - self.target_head = target_head - self.ploss_decay = ploss_decay - - def trainable_module(self) -> nn.Module: - return self.eagle3_model - - def _device(self) -> torch.device: - return next(self.eagle3_model.parameters()).device - - def _prepare_target( - self, - target_repr: Optional[str], - input_ids: torch.Tensor, - target: torch.Tensor, - loss_mask: torch.Tensor, - device: torch.device, - ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - if target_repr == "hidden_state": - if self.target_head is None: - raise ValueError( - "target_repr='hidden_state' requires a target_head to re-run " - "the lm_head projection" - ) - # mirrors offline run_forward: shift input_ids/target, add mask dim, - # then project the target last hidden state to full-vocab logits. - input_ids, target, loss_mask = self.target_head.preprocess( - input_ids, target, loss_mask - ) - target = self.target_head(target.to(device)) - return input_ids.to(device), target, loss_mask.to(device) - # logits / pruned_logits: rollout already produced (and shifted) the - # distribution. - return input_ids.to(device), target.to(device), loss_mask.to(device) - - def forward_loss( - self, batch: TrainBatch, ctx: Optional[StepContext] = None - ) -> StepOutput: - self.validate_batch(batch) - t = batch.tensors - device = self._device() - target_repr = batch.metadata.get("target_repr") - - input_ids, target, loss_mask = self._prepare_target( - target_repr, t["input_ids"], t["target"], t["loss_mask"], device - ) - position_ids = t.get("position_ids") - ( - plosses, - acceptance_rates, - acces, - acc_corrects, - acc_denoms, - metric_losses, - metric_loss_denoms, - ) = self.eagle3_model( - input_ids=input_ids, - attention_mask=t["attention_mask"].to(device), - loss_mask=loss_mask, - target=target, - hidden_states=t["hidden_state"].to(device), - position_ids=position_ids.to(device) if position_ids is not None else None, - ) - weights = [self.ploss_decay**i for i in range(len(plosses))] - loss = sum(weights[i] * plosses[i] for i in range(len(plosses))) - return StepOutput( - loss=loss, - metrics={ - "plosses": [p.detach() for p in plosses], - "acces": [a.detach() for a in acces], - "acceptance_rates": [a.detach() for a in acceptance_rates], - "acc_corrects": [c.detach() for c in acc_corrects], - "acc_denoms": [d.detach() for d in acc_denoms], - "metric_losses": [m.detach() for m in metric_losses], - "metric_loss_denoms": [d.detach() for d in metric_loss_denoms], - }, - ) - - def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: - # The target-copied embedding is skipped only when actually frozen; a - # trainable embedding must be persisted (checked on the live module — - # state_dict tensors are detached and carry no requires_grad). - embed_frozen = all( - not p.requires_grad - for n, p in self.eagle3_model.named_parameters() - if "embed" in n.lower() - ) - return { - k.replace("draft_model.", ""): v - for k, v in state_dict.items() - if "draft_model." in k and not (embed_frozen and "embed" in k.lower()) - } - - -class DFlashTrainStrategy(DraftTrainStrategy): - """DFlash block-parallel strategy wrapping the existing ``OnlineDFlashModel``. - - Shares the trainer/backend/loader/checkpoint spine with EAGLE3; only the - per-step forward/loss differs (single block-wise pass, scalar loss, hard - real-token labels — no target distribution, no vocab map). ``hidden_states`` - is DFlash's own schema name, distinct from EAGLE3's ``hidden_state``. - """ - - name = "dflash" - required_features = {"input_ids", "hidden_states", "loss_mask"} - - def __init__(self, dflash_model: nn.Module) -> None: - self.dflash_model = dflash_model - - def trainable_module(self) -> nn.Module: - return self.dflash_model - - def _device(self) -> torch.device: - return next(self.dflash_model.parameters()).device - - def forward_loss( - self, batch: TrainBatch, ctx: Optional[StepContext] = None - ) -> StepOutput: - self.validate_batch(batch) - t = batch.tensors - device = self._device() - loss, accuracy, model_metrics = self.dflash_model( - input_ids=t["input_ids"].to(device), - hidden_states=t["hidden_states"].to(device), - loss_mask=t["loss_mask"].to(device), - ) - return StepOutput( - loss=loss, - metrics={ - "accuracy": accuracy.detach(), - # the accuracy's own denominator, so eval can weight it exactly - "accuracy_denom": model_metrics["accuracy_denom"], - }, - ) - - def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: - # Everything trainable lives under draft_model.; the target - # embedding/head are a separate module, not persisted as draft weights. - return { - k.replace("draft_model.", ""): v - for k, v in state_dict.items() - if "draft_model." in k - } - - -class DominoTrainStrategy(DraftTrainStrategy): - """Domino block-parallel strategy wrapping ``OnlineDominoModel``. - - Shares the trainer/backend/loader/checkpoint spine and feature schema with - DFlash. Unlike the others, its loss blends a base loss with a weight - ``lambda_base`` that decays over training, so it reads :class:`StepContext`. - """ - - name = "domino" - required_features = {"input_ids", "hidden_states", "loss_mask"} - - def __init__( - self, - domino_model: nn.Module, - *, - lambda_start: float = 1.0, - decay_ratio: float = 0.5, - ) -> None: - self.domino_model = domino_model - self.lambda_start = lambda_start - self.decay_ratio = decay_ratio - - def trainable_module(self) -> nn.Module: - return self.domino_model - - def _device(self) -> torch.device: - return next(self.domino_model.parameters()).device - - def _lambda_base(self, ctx: Optional[StepContext]) -> float: - # No schedule horizon -> pure final loss (lambda_base = 0). - if ctx is None or not ctx.total_steps: - return 0.0 - return linear_lambda_base( - ctx.global_step, ctx.total_steps, self.lambda_start, self.decay_ratio - ) - - def forward_loss( - self, batch: TrainBatch, ctx: Optional[StepContext] = None - ) -> StepOutput: - self.validate_batch(batch) - t = batch.tensors - device = self._device() - lambda_base = self._lambda_base(ctx) - loss, accuracy, model_metrics = self.domino_model( - input_ids=t["input_ids"].to(device), - hidden_states=t["hidden_states"].to(device), - loss_mask=t["loss_mask"].to(device), - lambda_base=lambda_base, - ) - return StepOutput( - loss=loss, - metrics={ - "accuracy": accuracy.detach(), - # the accuracy's own denominator, so eval can weight it exactly - "accuracy_denom": model_metrics["accuracy_denom"], - "lambda_base": torch.tensor(float(lambda_base)), - }, - ) - - def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: - # Everything trainable lives under draft_model.; the target - # embedding/head are a separate module, not persisted as draft weights. - return { - k.replace("draft_model.", ""): v - for k, v in state_dict.items() - if "draft_model." in k - } - +"""Import shim — moved to ``specforge.training.strategies.base``.""" + +from specforge.training.strategies.base import ( # noqa: F401 + DFlashTrainStrategy, + DominoTrainStrategy, + DraftTrainStrategy, + Eagle3TrainStrategy, + StepContext, + StepOutput, + linear_lambda_base, +) __all__ = [ "DraftTrainStrategy", diff --git a/specforge/training/strategies/registry.py b/specforge/training/strategies/registry.py index ba415c2fe..cf458190e 100644 --- a/specforge/training/strategies/registry.py +++ b/specforge/training/strategies/registry.py @@ -16,13 +16,10 @@ * ``make_strategy`` — build the per-step strategy over the FSDP-wrapped model (the seam ``TrainerCore`` consumes), * ``required_features`` — the feature contract (drives - ``FeatureContract.from_strategy`` + loader validation), + ``CaptureConfig.from_strategy`` + loader validation), * the offline data path — reader + per-sample transform + collate + target_repr, * ``make_online_collate`` — the online/streamed collate, - * ``feature_schema`` — the store-ready dict shape the shared - ``PolicyFeatureAdapter`` emits online, - * ``make_adapter`` — escape hatch for a bespoke online capture adapter - (None => PolicyFeatureAdapter over feature_schema), + * ``make_adapter`` — the online capture adapter (None => SGLangAdapter), * ``supports_online`` — whether an online capture path exists yet. Registering a new model (dflash, domino, ...) is a ``StrategySpec`` entry next @@ -45,11 +42,6 @@ import torch -from specforge.inference.adapters.policy import ( - DFLASH_FEATURE_SCHEMA, - EAGLE3_FEATURE_SCHEMA, - FeatureSchema, -) from specforge.training.strategies.base import DraftTrainStrategy, Eagle3TrainStrategy @@ -87,11 +79,7 @@ class StrategySpec: make_offline_collate: Optional[Callable[[], Callable]] = None # Online data path. make_online_collate: Optional[Callable[[], Callable]] = None - # The store-ready dict shape the shared PolicyFeatureAdapter emits online. - # None with supports_online=True falls back to the EAGLE3 schema. - feature_schema: Optional[FeatureSchema] = None - # Escape hatch: (target_model, *, device, t2d) -> bespoke capture adapter. - # None => PolicyFeatureAdapter over feature_schema. + # (target_model, *, device, t2d) -> capture adapter. None => default SGLangAdapter. make_adapter: Optional[Callable[..., Any]] = None supports_online: bool = False @@ -159,7 +147,7 @@ def _eagle3_offline_collate(): make_offline_transform=_eagle3_offline_transform, make_offline_collate=_eagle3_offline_collate, make_online_collate=lambda: concat_collate, - feature_schema=EAGLE3_FEATURE_SCHEMA, + make_adapter=None, # default SGLangAdapter supports_online=True, ) ) @@ -170,9 +158,9 @@ def _eagle3_offline_collate(): # capture layers, NO eagle3 aux/target swap, NO target distribution / vocab map). # Offline: the reader reuses OfflineManifestReader with dflash feature_keys; the # transform slices to max_len (no swap); the collate pads + emits {input_ids, -# hidden_states, loss_mask}. Online: DFLASH_FEATURE_SCHEMA drives the shared -# PolicyFeatureAdapter to emit the same schema. The DFlashTrainStrategy already -# drops into the unchanged TrainerCore/Backend/Loader. +# hidden_states, loss_mask}. Online: a DFlashAdapter wraps generate_dflash_data +# and emits the same schema. The DFlashTrainStrategy already drops into the +# unchanged TrainerCore/Backend/Loader. from specforge.training.strategies.base import DFlashTrainStrategy @@ -255,6 +243,12 @@ def pad3d(t): # [1, n, W] -> [1, maxlen, W] return collate +def _dflash_adapter(target_model, *, device="cuda", t2d=None): + from specforge.inference.adapters.dflash import DFlashAdapter + + return DFlashAdapter(target_model, device=device, t2d=t2d) + + register_strategy( StrategySpec( name="dflash", @@ -265,7 +259,7 @@ def pad3d(t): # [1, n, W] -> [1, maxlen, W] make_offline_transform=_dflash_offline_transform, make_offline_collate=_dflash_offline_collate, make_online_collate=lambda: concat_collate, - feature_schema=DFLASH_FEATURE_SCHEMA, + make_adapter=_dflash_adapter, supports_online=True, ) ) @@ -273,11 +267,11 @@ def pad3d(t): # [1, n, W] -> [1, maxlen, W] # --- Domino ----------------------------------------------------------------- # Domino reuses DFlash's draft model (projector_type="domino" head), feature -# schema, offline transform/collate, and capture path (same DFLASH_FEATURE_SCHEMA -# -> hidden_states). The ONE difference is the loss: it blends a base loss with a -# step-decayed weight, so DominoTrainStrategy reads the StepContext -# (forward_loss(batch, ctx)). That is the whole reason a new algorithm needs -# anything beyond a spec entry here. +# schema, offline transform/collate, and capture adapter (same +# generate_dflash_data -> hidden_states). The ONE difference is the loss: it +# blends a base loss with a step-decayed weight, so DominoTrainStrategy reads the +# StepContext (forward_loss(batch, ctx)). That is the whole reason a new algorithm +# needs anything beyond a spec entry here. from specforge.training.strategies.base import DominoTrainStrategy @@ -306,7 +300,7 @@ def _domino_offline_reader(hidden_states_path, *, run_id, ttt_length, max_len): make_offline_transform=_dflash_offline_transform, # same schema as DFlash make_offline_collate=_dflash_offline_collate, make_online_collate=lambda: concat_collate, - feature_schema=DFLASH_FEATURE_SCHEMA, # same capture path as DFlash + make_adapter=_dflash_adapter, # same generate_dflash_data capture supports_online=True, ) ) diff --git a/tests/test_runtime/test_capture.py b/tests/test_runtime/test_capture.py index 903f93ccc..eaf479d11 100644 --- a/tests/test_runtime/test_capture.py +++ b/tests/test_runtime/test_capture.py @@ -1,14 +1,14 @@ # coding=utf-8 -"""FeatureContract assertions, incl. the M4 layer-mismatch gate (CPU).""" +"""CaptureConfig assertions, incl. the M4 gate test_capture_layer_mismatch_fails (CPU).""" import unittest import torch from specforge.inference.capture import ( - FeatureContract, - FeatureContractError, - verify_feature_contract, + CaptureConfig, + CaptureMismatchError, + verify_capture, ) H = 8 @@ -17,7 +17,7 @@ def _capture(layer_ids=(10, 15, 20), target_repr="hidden_state", **kw): - return FeatureContract.from_strategy( + return CaptureConfig.from_strategy( required_features={"input_ids", "loss_mask", "hidden_state", "target"}, aux_hidden_state_layer_ids=layer_ids, target_repr=target_repr, @@ -39,7 +39,7 @@ def _good_tensors(target_dim=H): class TestCapture(unittest.TestCase): def test_ok(self): - verify_feature_contract( + verify_capture( _good_tensors(), _capture(), sample_id="s0", @@ -48,8 +48,8 @@ def test_ok(self): def test_capture_layer_mismatch_fails(self): """Requested aux layers [10,15,20] vs recorded [10,15,21] -> loud failure.""" - with self.assertRaises(FeatureContractError) as ctx: - verify_feature_contract( + with self.assertRaises(CaptureMismatchError) as ctx: + verify_capture( _good_tensors(), _capture(layer_ids=(10, 15, 20)), sample_id="s0", @@ -60,39 +60,35 @@ def test_capture_layer_mismatch_fails(self): def test_missing_feature_fails(self): t = _good_tensors() del t["target"] - with self.assertRaises(FeatureContractError): - verify_feature_contract(t, _capture(), sample_id="s0") + with self.assertRaises(CaptureMismatchError): + verify_capture(t, _capture(), sample_id="s0") def test_aux_width_mismatch_fails(self): t = _good_tensors() t["hidden_state"] = torch.randn(1, 4, 2 * H) # only 2 layers' worth - with self.assertRaises(FeatureContractError) as ctx: - verify_feature_contract(t, _capture(), sample_id="s0") + with self.assertRaises(CaptureMismatchError) as ctx: + verify_capture(t, _capture(), sample_id="s0") self.assertIn("aux width", str(ctx.exception)) def test_target_dim_mismatch_pruned_logits(self): cap = _capture(target_repr="pruned_logits", vocab_map_version="v1") # pruned_logits expects draft_vocab_size on the last dim ok = _good_tensors(target_dim=DRAFT_V) - verify_feature_contract(ok, cap, sample_id="s0") + verify_capture(ok, cap, sample_id="s0") bad = _good_tensors(target_dim=TARGET_V) # full vocab, wrong for pruned - with self.assertRaises(FeatureContractError): - verify_feature_contract(bad, cap, sample_id="s0") + with self.assertRaises(CaptureMismatchError): + verify_capture(bad, cap, sample_id="s0") def test_pruned_logits_requires_vocab_map_version(self): cap = _capture(target_repr="pruned_logits") # no vocab_map_version - with self.assertRaises(FeatureContractError): - verify_feature_contract( - _good_tensors(target_dim=DRAFT_V), cap, sample_id="s0" - ) + with self.assertRaises(CaptureMismatchError): + verify_capture(_good_tensors(target_dim=DRAFT_V), cap, sample_id="s0") def test_logits_expects_full_vocab(self): cap = _capture(target_repr="logits") - verify_feature_contract(_good_tensors(target_dim=TARGET_V), cap, sample_id="s0") - with self.assertRaises(FeatureContractError): - verify_feature_contract( - _good_tensors(target_dim=DRAFT_V), cap, sample_id="s0" - ) + verify_capture(_good_tensors(target_dim=TARGET_V), cap, sample_id="s0") + with self.assertRaises(CaptureMismatchError): + verify_capture(_good_tensors(target_dim=DRAFT_V), cap, sample_id="s0") if __name__ == "__main__": diff --git a/tests/test_runtime/test_equiv_online_eagle3.py b/tests/test_runtime/test_equiv_online_eagle3.py index 6f82a239f..9593b470e 100644 --- a/tests/test_runtime/test_equiv_online_eagle3.py +++ b/tests/test_runtime/test_equiv_online_eagle3.py @@ -34,7 +34,7 @@ def test_equiv_online_eagle3(self): OnlineEagle3Model, ) from specforge.inference.adapters.eagle3 import SGLangAdapter - from specforge.inference.capture import FeatureContract + from specforge.inference.capture import CaptureConfig from specforge.inference.rollout_worker import RolloutWorker from specforge.runtime.contracts import assert_no_tensors from specforge.runtime.control_plane import DataFlowController @@ -86,7 +86,7 @@ def new_loss(): ) store = LocalFeatureStore("online") adapter = SGLangAdapter(target, device="cuda") - capture = FeatureContract.from_strategy( + capture = CaptureConfig.from_strategy( required_features=Eagle3TrainStrategy.required_features, aux_hidden_state_layer_ids=tuple(aux_ids), target_repr="logits", diff --git a/tests/test_runtime/test_extraction_vs_hf_reference.py b/tests/test_runtime/test_extraction_vs_hf_reference.py index ba73ddd7d..908c29d25 100644 --- a/tests/test_runtime/test_extraction_vs_hf_reference.py +++ b/tests/test_runtime/test_extraction_vs_hf_reference.py @@ -29,7 +29,7 @@ def test_extraction_vs_hf_reference(self): fx.build_single_rank_distributed(port="29564") from specforge.inference.adapters.eagle3 import SGLangAdapter - from specforge.inference.capture import FeatureContract, verify_feature_contract + from specforge.inference.capture import CaptureConfig, verify_capture from specforge.runtime.contracts import PromptTask H, V, SEQ = fx.H, fx.V, 12 @@ -39,7 +39,7 @@ def test_extraction_vs_hf_reference(self): ) adapter = SGLangAdapter(target, device="cuda") - capture = FeatureContract.from_strategy( + capture = CaptureConfig.from_strategy( required_features={ "input_ids", "attention_mask", @@ -64,9 +64,7 @@ def test_extraction_vs_hf_reference(self): ) feats = adapter.generate_features([task], capture=capture)[0] recorded = feats.pop("__aux_layer_ids__") - verify_feature_contract( - feats, capture, sample_id="t0", recorded_aux_layer_ids=recorded - ) + verify_capture(feats, capture, sample_id="t0", recorded_aux_layer_ids=recorded) self.assertEqual(tuple(recorded), tuple(aux_ids)) self.assertEqual(feats["hidden_state"].shape[-1], 3 * H) @@ -106,12 +104,12 @@ def test_extraction_vs_hf_reference(self): def test_capture_layer_mismatch_fails(self): """A recorded aux-layer set != requested fails loudly at the boundary.""" from specforge.inference.capture import ( - FeatureContract, - FeatureContractError, - verify_feature_contract, + CaptureConfig, + CaptureMismatchError, + verify_capture, ) - cap = FeatureContract.from_strategy( + cap = CaptureConfig.from_strategy( required_features={"hidden_state", "target", "input_ids", "loss_mask"}, aux_hidden_state_layer_ids=(1, 3, 4), target_repr="hidden_state", @@ -123,8 +121,8 @@ def test_capture_layer_mismatch_fails(self): "hidden_state": torch.randn(1, 4, 24), "target": torch.randn(1, 4, 8), } - with self.assertRaises(FeatureContractError): - verify_feature_contract( + with self.assertRaises(CaptureMismatchError): + verify_capture( tensors, cap, sample_id="x", recorded_aux_layer_ids=(1, 3, 5) ) diff --git a/tests/test_runtime/test_rollout_worker.py b/tests/test_runtime/test_rollout_worker.py index 000771626..18be63d21 100644 --- a/tests/test_runtime/test_rollout_worker.py +++ b/tests/test_runtime/test_rollout_worker.py @@ -5,7 +5,7 @@ import torch -from specforge.inference.capture import FeatureContract +from specforge.inference.capture import CaptureConfig from specforge.inference.rollout_worker import RolloutWorker from specforge.runtime.control_plane import DataFlowController from specforge.runtime.data_plane import LocalFeatureStore @@ -14,7 +14,7 @@ def _capture(layer_ids=(1, 2, 3)): - return FeatureContract.from_strategy( + return CaptureConfig.from_strategy( required_features={ "input_ids", "attention_mask", @@ -98,9 +98,9 @@ def test_capture_mismatch_fails_loudly_and_commits_nothing(self): ctrl, store, FakeSource(bad_layers=True), _capture(), run_id="run" ) w.start() - from specforge.inference.capture import FeatureContractError + from specforge.inference.capture import CaptureMismatchError - with self.assertRaises(FeatureContractError): + with self.assertRaises(CaptureMismatchError): w.run_once(max_tasks=8) self.assertEqual(ctrl.status()["samples_committed"], 0) self.assertEqual(ctrl.sample_queue.depth(), 0) @@ -113,9 +113,9 @@ def test_partial_capture_mismatch_releases_all_leases(self): store = LocalFeatureStore("st") w = RolloutWorker(ctrl, store, MixedLayerSource(), _capture(), run_id="run") w.start() - from specforge.inference.capture import FeatureContractError + from specforge.inference.capture import CaptureMismatchError - with self.assertRaises(FeatureContractError): + with self.assertRaises(CaptureMismatchError): w.run_once(max_tasks=8) st = ctrl.status() self.assertEqual(st["samples_committed"], 1) diff --git a/tests/test_runtime/test_sglang_adapter_batching.py b/tests/test_runtime/test_sglang_adapter_batching.py index c2345a363..4ec94ab8a 100644 --- a/tests/test_runtime/test_sglang_adapter_batching.py +++ b/tests/test_runtime/test_sglang_adapter_batching.py @@ -1,23 +1,13 @@ # coding=utf-8 -"""PolicyFeatureAdapter batches the engine call by sequence length (CPU, fake target).""" +"""SGLangAdapter batches the engine call by sequence length (CPU, fake target).""" import types import unittest import torch -from specforge.inference.adapters.dflash import DFlashAdapter from specforge.inference.adapters.eagle3 import SGLangAdapter -from specforge.inference.adapters.policy import ( - DFLASH_FEATURE_SCHEMA, - EAGLE3_FEATURE_SCHEMA, - PolicyFeatureAdapter, -) -from specforge.inference.capture import FeatureContract -from specforge.inference.target_engine.target_capture_policy import ( - DFlashTargetOutput, - Eagle3TargetOutput, -) +from specforge.inference.capture import CaptureConfig from specforge.runtime.contracts import PromptTask H, V = 4, 16 @@ -27,8 +17,7 @@ class FakeTarget: """Records each capture() call's batch size; encodes the first token id into hidden_states so per-sample slicing can be verified. Mirrors the TargetEngine contract the adapter now speaks: capture() dispatches to generate_eagle3_data - (the back-compat method) and returns the typed TargetCaptureBatch, exactly - like the real engine.""" + (the back-compat method), exactly like the real engine.""" aux_hidden_states_layers = [1, 2, 3] @@ -48,19 +37,19 @@ def generate_eagle3_data( ): G, L = input_ids.shape self.call_batch_sizes.append(G) + o = types.SimpleNamespace() + o.input_ids = input_ids + o.attention_mask = attention_mask + o.loss_mask = loss_mask.unsqueeze(-1) first_tok = input_ids[:, :1].float() # (G,1) - return Eagle3TargetOutput( - hidden_states=first_tok.unsqueeze(-1).expand(G, L, 3 * H).clone(), - target=torch.zeros(G, L, V), - loss_mask=loss_mask.unsqueeze(-1), - input_ids=input_ids, - attention_mask=attention_mask, - ) + o.hidden_states = first_tok.unsqueeze(-1).expand(G, L, 3 * H).clone() + o.target = torch.zeros(G, L, V) + return o class TestAdapterBatching(unittest.TestCase): def _capture(self): - return FeatureContract.from_strategy( + return CaptureConfig.from_strategy( required_features={ "input_ids", "attention_mask", @@ -99,7 +88,7 @@ def test_groups_by_length_one_call_per_group(self): def test_rejects_unimplemented_target_repr(self): # the online adapter must only advertise reprs it implements adapter = SGLangAdapter(FakeTarget(), device="cpu") - cap = FeatureContract.from_strategy( + cap = CaptureConfig.from_strategy( required_features={ "input_ids", "attention_mask", @@ -116,76 +105,6 @@ def test_rejects_unimplemented_target_repr(self): with self.assertRaises(NotImplementedError): adapter.generate_features([task], capture=cap) - def test_rejects_untyped_capture_output(self): - # the adapter accepts only a typed TargetCaptureBatch from the engine - class UntypedTarget(FakeTarget): - def capture(self, input_ids, attention_mask, loss_mask, **kwargs): - return types.SimpleNamespace( - input_ids=input_ids, - attention_mask=attention_mask, - loss_mask=loss_mask, - hidden_states=torch.zeros(1), - target=torch.zeros(1), - ) - - adapter = SGLangAdapter(UntypedTarget(), device="cpu") - task = PromptTask("t0", "r", "s", {"input_ids": [1, 2, 3, 4]}, 4) - with self.assertRaises(TypeError): - adapter.generate_features([task], capture=self._capture()) - - -class TestPolicyFeatureAdapterSchemas(unittest.TestCase): - """One adapter, two schemas: the subclasses only pin the FeatureSchema.""" - - def test_subclasses_pin_the_canonical_schemas(self): - self.assertIsInstance( - SGLangAdapter(FakeTarget(), device="cpu"), PolicyFeatureAdapter - ) - self.assertIs( - SGLangAdapter(FakeTarget(), device="cpu").schema, EAGLE3_FEATURE_SCHEMA - ) - self.assertIs( - DFlashAdapter(FakeTarget(), device="cpu").schema, DFLASH_FEATURE_SCHEMA - ) - - def test_dflash_schema_shapes_the_dict(self): - class FakeDFlashTarget: - def __init__(self): - self.capture_kwargs = [] - - def capture(self, input_ids, attention_mask, loss_mask, **kwargs): - self.capture_kwargs.append(kwargs) - G, L = input_ids.shape - first_tok = input_ids[:, :1].float() - return DFlashTargetOutput( - hidden_states=first_tok.unsqueeze(-1).expand(G, L, 2 * H).clone(), - input_ids=input_ids, - attention_mask=attention_mask, - loss_mask=loss_mask, - ) - - target = FakeDFlashTarget() - adapter = DFlashAdapter(target, device="cpu") - cap = FeatureContract.from_strategy( - required_features={"input_ids", "hidden_states", "loss_mask"}, - aux_hidden_state_layer_ids=(), - target_repr=None, - target_hidden_size=H, - ) - tasks = [ - PromptTask("t0", "r", "s", {"input_ids": [10, 11, 12]}, 3), - PromptTask("t1", "r", "s", {"input_ids": [20, 21, 22, 23]}, 4), - ] - feats = adapter.generate_features(tasks, capture=cap) - self.assertEqual(len(feats), 2) - for f in feats: - # exact DFlash schema: no target, no attention_mask, no aux record - self.assertEqual(set(f), {"input_ids", "hidden_states", "loss_mask"}) - self.assertEqual(int(feats[0]["hidden_states"][0, 0, 0]), 10) - self.assertEqual(int(feats[1]["hidden_states"][0, 0, 0]), 20) - # DFlash engines' capture() does not take shard_returns; never passed - self.assertTrue(all(kw == {} for kw in target.capture_kwargs)) - if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/test_runtime/test_sglang_capture_backend.py b/tests/test_runtime/test_sglang_capture_backend.py index a2b1cac58..f956f2c25 100644 --- a/tests/test_runtime/test_sglang_capture_backend.py +++ b/tests/test_runtime/test_sglang_capture_backend.py @@ -22,7 +22,7 @@ _TARGET_DIR = os.path.normpath( os.path.join( - os.path.dirname(__file__), "..", "..", "specforge", "modeling", "target" + os.path.dirname(__file__), "..", "..", "specforge", "inference", "target_engine" ) ) @@ -80,7 +80,7 @@ def test_server_backend_tag_and_gated_construction(self): import torch # noqa: F401 except Exception: self.skipTest("torch unavailable") - from specforge.modeling.target import ( + from specforge.inference.target_engine import ( SGLangServerEagle3TargetEngine, get_eagle3_target_model, ) diff --git a/tests/test_runtime/test_strategy_registry.py b/tests/test_runtime/test_strategy_registry.py index 596ad4c80..11bd78e7e 100644 --- a/tests/test_runtime/test_strategy_registry.py +++ b/tests/test_runtime/test_strategy_registry.py @@ -14,7 +14,7 @@ import unittest -from specforge import launch +from specforge.runtime import launch from specforge.training.strategies.base import DFlashTrainStrategy, Eagle3TrainStrategy from specforge.training.strategies.registry import ( available_strategies, @@ -32,12 +32,11 @@ def test_dflash_fully_wired(self): self.assertEqual( spec.required_features, frozenset(DFlashTrainStrategy.required_features) ) - # offline (reader/transform/collate) + online (feature schema) both wired + # offline (reader/transform/collate) + online (adapter) both wired self.assertIsNotNone(spec.make_offline_reader) self.assertIsNotNone(spec.make_offline_transform) self.assertIsNotNone(spec.make_offline_collate) - self.assertIsNotNone(spec.feature_schema) - self.assertIsNone(spec.feature_schema.target_feature) # no target distribution + self.assertIsNotNone(spec.make_adapter) self.assertTrue(spec.supports_online) # DFlash owns its own (frozen) head -> builders pass target_head=None self.assertFalse(spec.uses_target_head) @@ -51,7 +50,7 @@ class _M: def test_required_features_track_the_strategy_class(self): # The registry's required_features is the single source of truth wired - # into FeatureContract.from_strategy + loader validation; it must equal the + # into CaptureConfig.from_strategy + loader validation; it must equal the # strategy class's own declaration. self.assertEqual( resolve_strategy("eagle3").required_features, From 83e3eacd76f1af4083575c6a0e69718804cda389 Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Wed, 1 Jul 2026 13:08:35 -0700 Subject: [PATCH 04/24] =?UTF-8?q?[SpecForge]=20E=20=E2=80=94=20draft-archi?= =?UTF-8?q?tecture=20registry=20(@register=5Fdraft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DRAFT_REGISTRY + @register_draft in modeling/draft/registry.py; the auto loaders resolve architecture -> (class, config_class) from the registry first (AutoDraftModelConfig.from_file, AutoEagle3DraftModel.from_config), keeping the hardcoded mappings only as fallback. LlamaForCausalLMEagle3 and DFlashDraftModel register themselves; DFlashDraftModel config JSONs become loadable through AutoDraftModelConfig for the first time. New tests/test_modeling/test_draft_registry.py. Co-Authored-By: Claude Opus 4.8 --- specforge/modeling/auto.py | 61 +++------------------- specforge/modeling/draft/__init__.py | 9 ++-- specforge/modeling/draft/registry.py | 18 ++++++- tests/test_modeling/test_draft_registry.py | 44 ---------------- 4 files changed, 29 insertions(+), 103 deletions(-) diff --git a/specforge/modeling/auto.py b/specforge/modeling/auto.py index 0c0db0c56..0b7d32d70 100644 --- a/specforge/modeling/auto.py +++ b/specforge/modeling/auto.py @@ -31,56 +31,12 @@ ) -class _DraftModelMapping(dict): - """Expose registered drafts to the Transformers auto-model dispatcher.""" - - def _config_classes(self): - config_classes = list(super().keys()) - for model_cls in DRAFT_REGISTRY.values(): - config_cls = getattr(model_cls, "config_class", None) - if config_cls is not None and config_cls not in config_classes: - config_classes.append(config_cls) - return tuple(config_classes) - - def __getitem__(self, config_cls): - fallback = super().get(config_cls) - if fallback is None: - candidates = [] - elif isinstance(fallback, (list, tuple)): - candidates = list(fallback) - else: - candidates = [fallback] - for model_cls in DRAFT_REGISTRY.values(): - if ( - getattr(model_cls, "config_class", None) is config_cls - and model_cls not in candidates - ): - candidates.append(model_cls) - if not candidates: - raise KeyError(config_cls) - return tuple(candidates) - - def __contains__(self, config_cls): - return config_cls in self._config_classes() - - def __iter__(self): - return iter(self._config_classes()) - - def __len__(self): - return len(self._config_classes()) - - def keys(self): - return self._config_classes() - - class AutoEagle3DraftModel(AutoModelForCausalLMBase): # legacy config-type dispatch, kept as the fallback for configs that carry # no ``architectures``; new drafts register via @register_draft instead. - _model_mapping = _DraftModelMapping( - { - LlamaConfig: LlamaForCausalLMEagle3, - } - ) + _model_mapping = { + LlamaConfig: LlamaForCausalLMEagle3, + } @classmethod def from_config(cls, config: PretrainedConfig, torch_dtype=None, **config_kwargs): @@ -99,7 +55,7 @@ def from_config(cls, config: PretrainedConfig, torch_dtype=None, **config_kwargs if len(archs) == 1 and archs[0] in DRAFT_REGISTRY: _model_cls = DRAFT_REGISTRY[archs[0]] else: - _model_cls = cls._model_mapping[type(config)][0] + _model_cls = cls._model_mapping[type(config)] model = _model_cls(config, **config_kwargs) # Convert model to specified dtype if provided @@ -116,18 +72,16 @@ def from_pretrained( ): original_warn = modeling_utils.logger.warning - def filtered_warning(msg, *args, **warning_kwargs): + def filtered_warning(msg): if "embed_tokens.weight" in str(msg) and "initialized" in str(msg): return - original_warn(msg, *args, **warning_kwargs) + original_warn(msg) modeling_utils.logger.warning = filtered_warning try: model = super().from_pretrained( - pretrained_model_name_or_path, - *model_args, - **kwargs, + pretrained_model_name_or_path, *model_args, **kwargs ) finally: modeling_utils.logger.warning = original_warn @@ -185,7 +139,6 @@ class AutoDraftModelConfig: _config_mapping = { "LlamaForCausalLMEagle3": LlamaConfig, - "PEagleDraftModel": LlamaConfig, } @classmethod diff --git a/specforge/modeling/draft/__init__.py b/specforge/modeling/draft/__init__.py index dc33be39b..3583bad8e 100644 --- a/specforge/modeling/draft/__init__.py +++ b/specforge/modeling/draft/__init__.py @@ -6,14 +6,17 @@ sample, ) from .llama3_eagle import LlamaForCausalLMEagle3 -from .peagle import PEagleDraftModel -from .registry import DRAFT_REGISTRY, available_drafts, register_draft, resolve_draft +from .registry import ( + DRAFT_REGISTRY, + available_drafts, + register_draft, + resolve_draft, +) __all__ = [ "Eagle3DraftModel", "DFlashDraftModel", "LlamaForCausalLMEagle3", - "PEagleDraftModel", "build_target_layer_ids", "extract_context_feature", "sample", diff --git a/specforge/modeling/draft/registry.py b/specforge/modeling/draft/registry.py index 323e90c9c..12b46a4c4 100644 --- a/specforge/modeling/draft/registry.py +++ b/specforge/modeling/draft/registry.py @@ -6,7 +6,15 @@ # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 -"""Registry for draft model architectures.""" +"""Draft-architecture registry: the extension point for new draft model classes. + +A draft *architecture* (the model class) is a separate axis from the +per-algorithm training strategy (``specforge.training.strategies.registry``): +one architecture can train under several algorithms and vice versa. Registering +a class here makes it constructible from a draft-config JSON whose +``architectures[0]`` names it — adding an architecture is a new file plus +``@register_draft``, not an edit to ``modeling/auto.py``. +""" from __future__ import annotations @@ -16,7 +24,13 @@ def register_draft(cls: Optional[type] = None, *, name: Optional[str] = None): - """Register a draft model class by architecture name.""" + """Class decorator registering a draft architecture. + + The key defaults to the class name — exactly what draft-config JSONs carry + in ``architectures``. The class must declare ``config_class`` (the HF + ``PretrainedConfig`` subclass it is built from) so the auto loaders can + resolve both the model and its config from the one registry entry. + """ def _register(cls: type) -> type: key = name or cls.__name__ diff --git a/tests/test_modeling/test_draft_registry.py b/tests/test_modeling/test_draft_registry.py index 02c8ce198..7690935b0 100644 --- a/tests/test_modeling/test_draft_registry.py +++ b/tests/test_modeling/test_draft_registry.py @@ -11,7 +11,6 @@ import tempfile import unittest -import torch from transformers import LlamaConfig, Qwen3Config from specforge.modeling.auto import AutoDraftModelConfig, AutoEagle3DraftModel @@ -19,7 +18,6 @@ DRAFT_REGISTRY, DFlashDraftModel, LlamaForCausalLMEagle3, - PEagleDraftModel, available_drafts, register_draft, resolve_draft, @@ -52,11 +50,6 @@ "vocab_size": 256, } -TINY_PEAGLE = { - **TINY_EAGLE3, - "architectures": ["PEagleDraftModel"], -} - def _write(cfg: dict) -> str: fd, path = tempfile.mkstemp(suffix=".json") @@ -69,10 +62,8 @@ class DraftRegistryTest(unittest.TestCase): def test_builtin_architectures_registered(self): self.assertIn("LlamaForCausalLMEagle3", available_drafts()) self.assertIn("DFlashDraftModel", available_drafts()) - self.assertIn("PEagleDraftModel", available_drafts()) self.assertIs(resolve_draft("LlamaForCausalLMEagle3"), LlamaForCausalLMEagle3) self.assertIs(resolve_draft("DFlashDraftModel"), DFlashDraftModel) - self.assertIs(resolve_draft("PEagleDraftModel"), PEagleDraftModel) def test_unknown_architecture_raises_with_available_list(self): with self.assertRaises(KeyError) as ctx: @@ -133,41 +124,6 @@ def test_from_config_resolves_class_via_architectures(self): model = AutoEagle3DraftModel.from_config(config) self.assertIsInstance(model, LlamaForCausalLMEagle3) - def test_from_config_without_architecture_uses_legacy_fallback(self): - config = LlamaConfig( - **{k: v for k, v in TINY_EAGLE3.items() if k != "architectures"} - ) - model = AutoEagle3DraftModel.from_config(config) - self.assertIsInstance(model, LlamaForCausalLMEagle3) - - def test_auto_mapping_exposes_non_llama_registered_configs(self): - self.assertIn(Qwen3Config, AutoEagle3DraftModel._model_mapping) - self.assertIn( - DFlashDraftModel, - AutoEagle3DraftModel._model_mapping[Qwen3Config], - ) - - def test_from_config_resolves_peagle_via_registry(self): - path = _write(TINY_PEAGLE) - self.addCleanup(os.unlink, path) - config = AutoDraftModelConfig.from_file(path) - model = AutoEagle3DraftModel.from_config(config) - self.assertIsInstance(model, PEagleDraftModel) - - def test_peagle_direct_save_reload(self): - path = _write(TINY_PEAGLE) - self.addCleanup(os.unlink, path) - config = AutoDraftModelConfig.from_file(path) - model = PEagleDraftModel(config) - with tempfile.TemporaryDirectory() as output_dir: - model.save_pretrained(output_dir) - reloaded = PEagleDraftModel.from_pretrained(output_dir) - auto_reloaded = AutoEagle3DraftModel.from_pretrained(output_dir) - self.assertIsInstance(reloaded, PEagleDraftModel) - self.assertIsInstance(auto_reloaded, PEagleDraftModel) - self.assertTrue(torch.isfinite(reloaded.rotary_emb.cos_cached).all()) - self.assertTrue(torch.isfinite(auto_reloaded.rotary_emb.cos_cached).all()) - if __name__ == "__main__": unittest.main(verbosity=2) From d7985a7320698dd12e9c6d78f632ccff2858ce9a Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Sat, 4 Jul 2026 01:31:04 -0700 Subject: [PATCH 05/24] style: fix draft registry import ordering --- specforge/modeling/draft/__init__.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/specforge/modeling/draft/__init__.py b/specforge/modeling/draft/__init__.py index 3583bad8e..7461d7781 100644 --- a/specforge/modeling/draft/__init__.py +++ b/specforge/modeling/draft/__init__.py @@ -6,12 +6,7 @@ sample, ) from .llama3_eagle import LlamaForCausalLMEagle3 -from .registry import ( - DRAFT_REGISTRY, - available_drafts, - register_draft, - resolve_draft, -) +from .registry import DRAFT_REGISTRY, available_drafts, register_draft, resolve_draft __all__ = [ "Eagle3DraftModel", From 16e7cfe2c3e355abee1f6953401e4de80a2be87b Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Sat, 4 Jul 2026 01:56:17 -0700 Subject: [PATCH 06/24] =?UTF-8?q?[SpecForge]=20Phase=20E=20=E2=80=94=20typ?= =?UTF-8?q?ed=20Pydantic=20config=20+=20specforge=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config/schema.py: validated Config (model/data/training) mapping 1:1 onto the DataFlow launch builders; YAML/JSON loader + dotted overrides re-validate through the schema. New specforge console script drives eagle3 offline/online via the same builder wiring, threading dtype/device, the capture contract, and the resume/rotation/control-plane knobs; build_online_runtime gains log_interval. Tests: schema mechanics + a config-built == programmatic gate. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 8 +++++--- specforge/cli.py | 38 -------------------------------------- specforge/launch.py | 3 ++- 3 files changed, 7 insertions(+), 42 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b3c6d283a..9d8fc8cff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,10 @@ authors = [{name = "SGLang Team"}] urls = {Homepage = "https://github.com/sgl-project/SpecForge"} dependencies = [ "pre-commit", - "torch==2.11.0", - "transformers==5.8.1", + "torch==2.9.1", + "torchaudio==2.9.1", + "torchvision==0.24.1", + "transformers==4.57.1", "qwen-vl-utils==0.0.11", "datasets", "setuptools", @@ -24,7 +26,7 @@ dependencies = [ "accelerate", "pydantic", "pyyaml", - "sglang==0.5.14", + "sglang==0.5.9", "openai-harmony", "ninja", "packaging", diff --git a/specforge/cli.py b/specforge/cli.py index 3cc2332e4..15a83f641 100644 --- a/specforge/cli.py +++ b/specforge/cli.py @@ -212,49 +212,11 @@ def main(argv: Optional[List[str]] = None) -> int: nargs="*", help="dotted overrides, e.g. training.learning_rate=1e-4", ) - export = sub.add_parser( - "export", help="export a training checkpoint for serving / HF" - ) - export.add_argument("--to", choices=["sglang", "hf"], required=True) - export.add_argument("--checkpoint", required=True) - export.add_argument("--draft-config", required=True) - export.add_argument("--output-dir", required=True) - export.add_argument("--vocab-mapping", default=None) - export.add_argument( - "--embedding-source", - default=None, - help="HF export only: target model path/dir supplying the frozen embedding", - ) - export.add_argument( - "--embedding-key", - default="model.embed_tokens.weight", - help="HF export only: embedding tensor key inside --embedding-source", - ) args = parser.parse_args(argv) if args.command == "train": cfg = load_config(args.config, args.overrides) _train(cfg) - elif args.command == "export": - from specforge.export import export_to_hf, export_to_sglang - - if args.to == "sglang": - out = export_to_sglang( - args.checkpoint, - args.draft_config, - args.output_dir, - vocab_mapping_path=args.vocab_mapping, - ) - else: - out = export_to_hf( - args.checkpoint, - args.draft_config, - args.output_dir, - vocab_mapping_path=args.vocab_mapping, - embedding_source=args.embedding_source, - embedding_key=args.embedding_key, - ) - print(f"exported {args.to} draft to {out}") return 0 diff --git a/specforge/launch.py b/specforge/launch.py index 4d84e06a1..12007e0b2 100644 --- a/specforge/launch.py +++ b/specforge/launch.py @@ -412,6 +412,7 @@ def build_online_runtime( sp_ring_size: int = 1, collate_fn=None, logger=None, + log_interval: int = 50, resume_from: Optional[str] = None, max_checkpoints: int = 0, ): @@ -467,7 +468,7 @@ def build_online_runtime( sp_ulysses_size=sp_ulysses_size, sp_ring_size=sp_ring_size, logger=logger, - log_interval=50, + log_interval=log_interval, collate_fn=_online_collate(spec, collate_fn), per_sample_transform=None, durable_ack=durable_ack, From fd2fffa534e75b7cc7fc1109f299b27df4e0189c Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Sun, 5 Jul 2026 16:51:37 -0700 Subject: [PATCH 07/24] fix(sglang): make the DataFlow capture backend work on sglang 0.5.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the sglang 0.5.14 upgrade onto up-16's new-layout target-capture backend. On `main` the 0.5.14 bump landed via #605 (eagle3 / custom backends) plus the follow-up `patch/fix_sgl0.5.14_dflash` (dflash), but both target the OLD `specforge/modeling/target/dflash_target_model.py` layout. up-16 consolidated eagle3 + dflash extend into a single `SGLangCaptureBackend` (`inference/target_engine/sglang_backend/capture.py`), so the same API adaptations are applied there once, covering both paths: - import `prepare_mlp_sync_batch_raw` from `managers.scheduler_components.dp_attn` (moved from `managers.scheduler_dp_attn_mixin` in 0.5.14). - `build()`: replicate the post-load setup 0.5.14 split out of `ModelRunner.initialize()` — `alloc_memory_pool()` / `init_attention_backends()` / `init_cuda_graphs()` — since we drive the ModelRunner directly; and set `chunked_prefill_size=-1` (we prefill the whole batch in one `_forward_extend`, no scheduler chunking). - `_forward_extend()`: drop the removed `get_model_worker_batch()` step — `ForwardBatch.init_new` now consumes the `ScheduleBatch` directly and reads `capture_hidden_mode` off it; and materialize `prefill_input_ids_cpu` -> device (the scheduler normally does this in `resolve_forward_inputs`). - Req construction (dflash + eagle3 + eagle3-vlm): `fill_ids` was removed in favor of `full_untruncated_fill_ids` (array) + integer `fill_len`. - capture `input_lens` BEFORE the forward — `origin_input_ids` is released during prepare_for_extend/forward on 0.5.13+. - bump pyproject pin to `sglang==0.5.14`. Benefits eagle3 + dflash (+ dspark, which builds on the dflash path). Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 2 +- .../target_engine/sglang_backend/capture.py | 80 ++++++++++++++++--- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9d8fc8cff..01c30ef82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "accelerate", "pydantic", "pyyaml", - "sglang==0.5.9", + "sglang==0.5.14", "openai-harmony", "ninja", "packaging", diff --git a/specforge/inference/target_engine/sglang_backend/capture.py b/specforge/inference/target_engine/sglang_backend/capture.py index 2a07512e8..a3e1e6444 100644 --- a/specforge/inference/target_engine/sglang_backend/capture.py +++ b/specforge/inference/target_engine/sglang_backend/capture.py @@ -36,6 +36,7 @@ from __future__ import annotations +from array import array from typing import List, Optional, Tuple import sglang.srt.managers.mm_utils as mm_utils @@ -55,8 +56,10 @@ ScheduleBatch, ) -# prepare_mlp_sync_batch_raw is a module-level function, not a Scheduler method -from sglang.srt.managers.scheduler_dp_attn_mixin import prepare_mlp_sync_batch_raw +# prepare_mlp_sync_batch_raw is a module-level function, not a Scheduler method. +# sglang 0.5.14 moved it from managers.scheduler_dp_attn_mixin to +# managers.scheduler_components.dp_attn. +from sglang.srt.managers.scheduler_components.dp_attn import prepare_mlp_sync_batch_raw from sglang.srt.mem_cache.cache_init_params import CacheInitParams from sglang.srt.mem_cache.radix_cache import RadixCache from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode, ForwardBatch @@ -119,15 +122,21 @@ def build( original eagle3 path). """ tp_size = dist.get_world_size(get_tp_group()) - # NOTE: sglang 0.5.9 requires dtype to be non-None. If torch_dtype is None, + # NOTE: sglang 0.5.13+ requires dtype to be non-None. If torch_dtype is None, # use "auto" to let sglang decide the dtype. dtype_arg = torch_dtype if torch_dtype is not None else "auto" + # NOTE: DFlash/EAGLE3 prefill the whole batch in one _forward_extend call + # (no scheduler chunking). sglang 0.5.14's eager runner pre-allocates + # per-call buffers sized to chunked_prefill_size (default 8192), and copies + # into them fail with a shape mismatch when our token count exceeds that. + # Disable chunked prefill so the ceiling grows to max_total_num_tokens. server_args = ServerArgs( model_path=pretrained_model_name_or_path, trust_remote_code=trust_remote_code, dtype=dtype_arg, enable_return_hidden_states=True, disable_cuda_graph=True, # we use piecewise cuda graph for prefill instead + chunked_prefill_size=-1, tp_size=tp_size, pp_size=1, **kwargs, @@ -152,6 +161,14 @@ def build( nccl_port=None, is_draft_worker=False, ) + # sglang 0.5.14 split the post-load setup out of ModelRunner.initialize() + # (which now only loads the weights). The scheduler/TpModelWorker perform + # these steps explicitly; since we drive the ModelRunner directly, we must + # replicate them so `req_to_token_pool`/`token_to_kv_pool_allocator` exist + # and forward() has an attention backend and (eager) runner. + model_runner.alloc_memory_pool() + model_runner.init_attention_backends() + model_runner.init_cuda_graphs() if wrap_eagle3_logits: wrap_eagle3_logits_processors_in_module( model_runner.model, return_full_logits=return_full_logits @@ -256,8 +273,20 @@ def _forward_extend(self, reqs): ) batch.prepare_for_extend() self._maybe_prepare_mlp_sync_batch(batch) - model_worker_batch = batch.get_model_worker_batch() - forward_batch = ForwardBatch.init_new(model_worker_batch, self.model_runner) + # sglang 0.5.13+: prepare_for_extend stages input_ids on pinned CPU + # (prefill_input_ids_cpu) and leaves batch.input_ids=None; the scheduler + # normally materializes them to device via resolve_forward_inputs. We bypass + # the scheduler, so perform that prefill H2D copy here. + if getattr(batch, "prefill_input_ids_cpu", None) is not None: + batch.input_ids = batch.prefill_input_ids_cpu.to( + batch.device, non_blocking=True + ) + batch.prefill_input_ids_cpu = None + # sglang 0.5.13+: the ModelWorkerBatch step was removed. ForwardBatch.init_new + # now consumes the ScheduleBatch directly and reads capture_hidden_mode from + # it, so set it on the batch before init_new. + batch.capture_hidden_mode = CaptureHiddenMode.FULL + forward_batch = ForwardBatch.init_new(batch, self.model_runner) forward_batch.capture_hidden_mode = CaptureHiddenMode.FULL output = self.model_runner.forward(forward_batch) return output.logits_output if hasattr(output, "logits_output") else output @@ -291,8 +320,10 @@ def _extend_eagle3( self._set_eagle3_logits_flags( return_last_hidden_states, return_logits, shard_returns ) - eagle3_output = self._forward_extend(reqs) + # sglang 0.5.13+: capture input lengths BEFORE the forward — prepare_for_extend + # / forward release per-req fields (origin_input_ids becomes None afterwards). input_lens = [len(req.origin_input_ids) for req in reqs] + eagle3_output = self._forward_extend(reqs) logits = eagle3_output.logits aux_hidden_states = eagle3_output.aux_hidden_states @@ -370,8 +401,15 @@ def extend( origin_input_ids=input_id_.view(-1).tolist(), sampling_params=sampling_params, ) - req.fill_ids = req.origin_input_ids - req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) + # sglang 0.5.13+: Req.fill_ids was removed in favor of + # full_untruncated_fill_ids (origin + output ids) plus an integer + # fill_len, which the scheduler's PrefillAdder sets during admission. + # We bypass the scheduler, so replicate that here with no prefix-cache + # reuse (prefix_indices stays empty). prepare_for_extend asserts + # fill_len - len(prefix_indices) == extend_input_len. + req.full_untruncated_fill_ids = array("q", req.origin_input_ids) + req.fill_len = len(req.full_untruncated_fill_ids) + req.extend_input_len = req.fill_len - len(req.prefix_indices) req.logprob_start_len = len(req.origin_input_ids) - 1 data_cache.append([input_id_, attention_mask_, loss_mask_]) reqs.append(req) @@ -542,8 +580,15 @@ def extend_vlm( origin_input_ids=input_id_list, sampling_params=sampling_params, ) - req.fill_ids = req.origin_input_ids - req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) + # sglang 0.5.13+: Req.fill_ids was removed in favor of + # full_untruncated_fill_ids (origin + output ids) plus an integer + # fill_len, which the scheduler's PrefillAdder sets during admission. + # We bypass the scheduler, so replicate that here with no prefix-cache + # reuse (prefix_indices stays empty). prepare_for_extend asserts + # fill_len - len(prefix_indices) == extend_input_len. + req.full_untruncated_fill_ids = array("q", req.origin_input_ids) + req.fill_len = len(req.full_untruncated_fill_ids) + req.extend_input_len = req.fill_len - len(req.prefix_indices) req.logprob_start_len = len(req.origin_input_ids) - 1 req.multimodal_inputs = mm_inputs data_cache.append([input_id_, attention_mask_, loss_mask_]) @@ -591,13 +636,22 @@ def extend_dflash( origin_input_ids=curr_ids.view(-1).tolist(), sampling_params=sampling_params, ) - req.fill_ids = req.origin_input_ids - req.extend_input_len = len(req.fill_ids) - len(req.prefix_indices) + # sglang 0.5.13+: Req.fill_ids was removed in favor of + # full_untruncated_fill_ids (origin + output ids) plus an integer + # fill_len, which the scheduler's PrefillAdder sets during admission. + # We bypass the scheduler, so replicate that here with no prefix-cache + # reuse (prefix_indices stays empty). prepare_for_extend asserts + # fill_len - len(prefix_indices) == extend_input_len. + req.full_untruncated_fill_ids = array("q", req.origin_input_ids) + req.fill_len = len(req.full_untruncated_fill_ids) + req.extend_input_len = req.fill_len - len(req.prefix_indices) data_cache.append((curr_ids, curr_attn, curr_loss)) reqs.append(req) - output = self._forward_extend(reqs) + # sglang 0.5.13+: capture input lengths BEFORE the forward (origin_input_ids + # becomes None afterwards). input_lens = [len(req.origin_input_ids) for req in reqs] + output = self._forward_extend(reqs) if ( hasattr(output, "aux_hidden_states") and output.aux_hidden_states is not None From 08bd2feae8539fee30c25f07fd569a1ac847b0f7 Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Sun, 5 Jul 2026 16:57:57 -0700 Subject: [PATCH 08/24] fix(sglang): port parallel-state init in patch.py to sglang 0.5.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more 0.5.14 API drifts in the vendored parallel_state setup (surfaced building the target on 0.5.14, mirrors main's post-#605 patch.py): - init_model_parallel_group() dropped the pynccl_use_current_stream kwarg (removed in 0.5.13) — remove it from the tp and pdmux_prefill_tp calls. - compute_dp_attention_world_info() now returns a 4-tuple (attn_tp_rank, attn_tp_size, attn_dp_rank, attn_dp_size); the attn-tp rank/size are no longer module globals, so keep only _ATTN_DP_RANK. Validated: sglang DFlash capture (Qwen2.5-0.5B) runs on sglang 0.5.14 and returns correctly-shaped concatenated hidden states. Co-Authored-By: Claude Opus 4.8 --- .../inference/target_engine/sglang_backend/patch.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/specforge/inference/target_engine/sglang_backend/patch.py b/specforge/inference/target_engine/sglang_backend/patch.py index 137e3bd23..0b15d38d0 100644 --- a/specforge/inference/target_engine/sglang_backend/patch.py +++ b/specforge/inference/target_engine/sglang_backend/patch.py @@ -131,7 +131,9 @@ def initialize_model_parallel( group_ranks.append(ranks) # message queue broadcaster is only used in tensor model parallel group - # NOTE: torch_compile removed in 0.5.9; pynccl_use_current_stream removed in 0.5.13 + # NOTE: torch_compile parameter was removed in sglang 0.5.9 + # NOTE: pynccl_use_current_stream was removed from init_model_parallel_group + # in sglang 0.5.13 (was present in 0.5.9) parallel_state._TP = init_model_parallel_group( group_ranks, parallel_state._WORLD.local_rank, @@ -353,9 +355,11 @@ def initialize_dp_attention( dp_attention._ENABLE_DP_ATTENTION_FLAG = enable_dp_attention - # NOTE: attn_cp_size added in 0.5.9. 0.5.13: compute_dp_attention_world_info - # returns a 4-tuple; attn-tp rank/size are no longer module globals, so we keep - # only _ATTN_DP_RANK (mirrors sglang's initialize_dp_attention). + # NOTE: Added attn_cp_size parameter for sglang 0.5.9 + # NOTE: sglang 0.5.13 - compute_dp_attention_world_info now returns a 4-tuple + # (attn_tp_rank, attn_tp_size, attn_dp_rank, attn_dp_size). The attn-tp + # rank/size are no longer module globals (derived from the _ATTN_TP group), + # so we only keep _ATTN_DP_RANK, mirroring sglang's initialize_dp_attention. ( _, _, From 70e241aee3d0164b608db41d438d427368e8a17c Mon Sep 17 00:00:00 2001 From: canghua Date: Tue, 7 Jul 2026 17:49:19 +0800 Subject: [PATCH 09/24] support domino disaggregated training[bug] --- configs/qwen3-8b-domino.json | 1 + examples/disagg/run_disagg_domino.py | 298 ++++++++++++++++++ ...n_qwen3.6_27b_dflash_disagg_multiserver.sh | 141 +++++---- .../run_qwen3_8b_domino_disagg_multiserver.sh | 153 +++++++++ specforge/launch.py | 4 + specforge/training/strategies/registry.py | 8 +- specforge/training/trainer.py | 5 +- 7 files changed, 547 insertions(+), 63 deletions(-) create mode 100644 examples/disagg/run_disagg_domino.py create mode 100644 examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh diff --git a/configs/qwen3-8b-domino.json b/configs/qwen3-8b-domino.json index f7546bb0f..e9dc93840 100644 --- a/configs/qwen3-8b-domino.json +++ b/configs/qwen3-8b-domino.json @@ -1,4 +1,5 @@ { + "emb_dim": 256, "architectures": [ "DFlashDraftModel" ], diff --git a/examples/disagg/run_disagg_domino.py b/examples/disagg/run_disagg_domino.py new file mode 100644 index 000000000..766193cae --- /dev/null +++ b/examples/disagg/run_disagg_domino.py @@ -0,0 +1,298 @@ +"""Disaggregated ONLINE Domino example over patched SGLang server-capture. + +Domino reuses the DFlash capture schema (``hidden_states`` + hard labels), but +trains ``OnlineDominoModel`` with the Domino strategy and lambda-base schedule. +The producer is CPU-only and drives one or more patched SGLang servers; the +consumer is the training pool reading refs from the channel and tensors from +Mooncake. +""" + +import hashlib +import json +import os +import sys + +import torch + +if not torch.cuda.is_available(): + # CPU producer: keep yunchang/flashinfer CUDA probes on the clean fallback. + sys.modules["flashinfer"] = None + +from accelerate.utils import set_seed +from train_domino import parse_args +from transformers import AutoConfig, AutoTokenizer + +from specforge.core.domino import OnlineDominoModel +from specforge.distributed import destroy_distributed, init_distributed +from specforge.inference.adapters.server_capture import SGLangServerCaptureAdapter +from specforge.launch import build_disagg_online_consumer, build_disagg_online_producer +from specforge.modeling.draft.dflash import DFlashDraftModel +from specforge.modeling.target.target_utils import TargetEmbeddingsAndHead +from specforge.optimizer import BF16Optimizer +from specforge.runtime.data_plane.mooncake_store import MooncakeFeatureStore +from specforge.runtime.data_plane.streaming_ref_channel import StreamingRefChannel +from specforge.tracker import create_tracker + +RUN_ID = os.environ.get("DISAGG_STORE_ID", "qwen3-8b-domino-disagg") + + +def _role() -> str: + role = os.environ.get("DISAGG_ROLE") + if role: + return role + return "producer" if os.environ.get("RCLI_NODE_RANK", "0") == "0" else "consumer" + + +def _max(name: str, default: int) -> int: + return int(os.environ.get(name, str(default))) + + +def _ref_channel() -> StreamingRefChannel: + return StreamingRefChannel(os.environ["DISAGG_REF_CHANNEL"]) + + +def _mooncake_store() -> MooncakeFeatureStore: + return MooncakeFeatureStore( + store_id=RUN_ID, + setup_kwargs={ + "local_hostname": os.environ.get("MOONCAKE_LOCAL_HOSTNAME", "127.0.0.1"), + "metadata_server": os.environ["MOONCAKE_METADATA_SERVER"], + "master_server_addr": os.environ["MOONCAKE_MASTER_SERVER_ADDR"], + "protocol": os.environ.get("MOONCAKE_PROTOCOL", "tcp"), + "rdma_devices": os.environ.get("MOONCAKE_RDMA_DEVICES", ""), + "global_segment_size": int(os.environ.get("DISAGG_CLIENT_SEGMENT_SIZE", 0)), + "local_buffer_size": int( + os.environ.get("DISAGG_CLIENT_BUFFER_SIZE", 1 << 30) + ), + }, + ) + + +def _producer_prompts(args, tokenizer): + from datasets import load_dataset + from specforge.data import build_eagle3_dataset + + cache_key = hashlib.md5( + f"{args.train_data_path}-{args.max_length}-{args.chat_template}-" + f"{args.target_model_path}".encode() + ).hexdigest() + dataset = load_dataset("json", data_files=args.train_data_path)["train"] + dataset = build_eagle3_dataset( + dataset=dataset, + tokenizer=tokenizer, + chat_template=args.chat_template, + max_length=args.max_length, + is_preformatted=args.is_preformatted, + cache_dir=os.path.join(args.cache_dir, "processed_dataset"), + cache_key=cache_key, + num_proc=args.build_dataset_num_proc, + ) + dataset = dataset.filter(lambda x: x["loss_mask"].sum() >= 2 * args.block_size) + + prompts = [] + for row in dataset: + input_ids, loss_mask = row["input_ids"][0], row["loss_mask"][0] + attn = row.get("attention_mask") + n = int(attn[0].sum().item()) if attn is not None else input_ids.shape[0] + prompts.append( + { + "payload": { + "input_ids": input_ids[:n].tolist(), + "loss_mask": loss_mask[:n].tolist(), + } + } + ) + return prompts + + +def _resolve_mask_token(args, tokenizer) -> int: + if args.mask_token_id is not None: + return args.mask_token_id + if tokenizer.mask_token_id is not None: + return tokenizer.mask_token_id + tokenizer.add_special_tokens({"mask_token": "<|MASK|>"}) + return tokenizer.mask_token_id + + +def _build_draft_model(args) -> DFlashDraftModel: + draft_config = AutoConfig.from_pretrained(args.draft_config_path) + dflash_config = getattr(draft_config, "dflash_config", {}) or {} + if dflash_config.get("projector_type") != "domino": + raise ValueError( + "Domino disagg training requires dflash_config.projector_type='domino'." + ) + required = {"emb_dim", "gru_hidden_dim", "pure_draft_prefix_len", "shift_label"} + missing = sorted(required - set(dflash_config)) + if missing: + raise ValueError(f"Domino config missing dflash_config fields: {missing}") + + draft_config._attn_implementation = args.attention_backend + device = torch.device("cuda", torch.cuda.current_device()) + draft_model = DFlashDraftModel(draft_config).to( + device=device, dtype=torch.bfloat16 + ) + print( + "[consumer] draft " + f"layers={draft_config.num_hidden_layers} " + f"target_layers={draft_model.target_layer_ids} " + f"block_size={draft_model.block_size}", + flush=True, + ) + return draft_model + + +def _server_urls() -> list: + urls = os.environ.get("DISAGG_SERVER_URLS") + if urls: + return [u.strip() for u in urls.split(",") if u.strip()] + return [os.environ["DISAGG_SERVER_URL"]] + + +def run_producer(args) -> None: + tokenizer = AutoTokenizer.from_pretrained( + args.target_model_path, trust_remote_code=args.trust_remote_code + ) + prompts = _producer_prompts(args, tokenizer) + max_prompts = _max("DISAGG_MAX_PROMPTS", 0) + if max_prompts: + prompts = prompts[:max_prompts] + + with open(args.draft_config_path) as f: + draft_cfg = json.load(f) + aux_layer_ids = tuple(draft_cfg["dflash_config"]["target_layer_ids"]) + target_hidden = int(draft_cfg["hidden_size"]) + + store = _mooncake_store() + channel = _ref_channel() + urls = _server_urls() + adapters = [ + SGLangServerCaptureAdapter( + url, + store, + run_id=RUN_ID, + strategy="domino", + target_model_version=args.target_model_path, + ) + for url in urls + ] + print(f"[producer] {len(prompts)} prompts -> {len(urls)} server(s): {urls}") + + _workers, drive_producer = build_disagg_online_producer( + strategy="domino", + feature_source=adapters if len(adapters) > 1 else adapters[0], + prompts=prompts, + feature_store=store, + channel=channel, + run_id=RUN_ID, + target_hidden_size=target_hidden, + target_repr=None, + aux_hidden_state_layer_ids=aux_layer_ids, + ) + produced = drive_producer() + print(f"[producer] streamed {produced} samples; channel closed", flush=True) + + +def run_consumer(args) -> None: + tokenizer = AutoTokenizer.from_pretrained(args.target_model_path) + draft_model = _build_draft_model(args) + mask_token_id = _resolve_mask_token(args, tokenizer) + draft_model.mask_token_id = mask_token_id + draft_model.config.dflash_config["mask_token_id"] = mask_token_id + draft_model.config.dflash_config["target_layer_ids"] = draft_model.target_layer_ids + + target_components = TargetEmbeddingsAndHead.from_pretrained( + args.target_model_path, + embed_key=args.embedding_key, + lm_head_key=args.lm_head_key, + device="cuda", + trust_remote_code=args.trust_remote_code, + ) + domino_model = OnlineDominoModel( + draft_model=draft_model, + target_lm_head=target_components.lm_head, + target_embed_tokens=target_components.embed_tokens, + mask_token_id=mask_token_id, + block_size=draft_model.block_size, + attention_backend=args.attention_backend, + num_anchors=args.num_anchors, + loss_decay_gamma=args.loss_decay_gamma, + shift_label=draft_model.shift_label, + ) + + max_steps = _max("DISAGG_MAX_STEPS", 0) or None + total_steps = _max("DISAGG_TOTAL_STEPS", 0) or max_steps or 10_000 + + def optimizer_factory(draft_module): + return BF16Optimizer( + draft_module, + lr=args.learning_rate, + max_grad_norm=args.max_grad_norm, + warmup_ratio=args.warmup_ratio, + total_steps=total_steps, + ) + + tracker = create_tracker(args, args.output_dir) + + def logger(metrics, step): + logdict = {} + for key, value in metrics.items(): + try: + logdict[f"train/{key}"] = float(value) + except (TypeError, ValueError): + continue + if logdict: + tracker.log(logdict, step=step) + summary = {k.split("/")[-1]: round(v, 4) for k, v in logdict.items()} + print(f"[consumer] step {step} {summary}", flush=True) + + print(f"[consumer] training from mooncake://{RUN_ID}", flush=True) + trainer, loader = build_disagg_online_consumer( + strategy="domino", + feature_store=_mooncake_store(), + channel=_ref_channel(), + eagle3_model=domino_model, # legacy param name = composite draft model + optimizer_factory=optimizer_factory, + run_id=RUN_ID, + output_dir=args.output_dir, + batch_size=args.batch_size, + accumulation_steps=args.accumulation_steps, + num_epochs=args.num_epochs, + max_steps=max_steps, + total_steps=total_steps, + save_interval=args.save_interval, + tp_size=args.tp_size, + metadata_db_path=os.environ.get("DISAGG_DB") or None, + logger=logger, + log_interval=_max("DISAGG_LOG_INTERVAL", 1), + strategy_kwargs={ + "lambda_start": args.lambda_base_start, + "decay_ratio": args.lambda_base_decay_ratio, + }, + inbox_dir=os.environ.get("DISAGG_INBOX_DIR") or None, + idle_timeout_s=float(os.environ.get("DISAGG_IDLE_TIMEOUT", 0)) or None, + ) + try: + trainer.fit(loader) + finally: + if getattr(trainer, "ref_distributor", None) is not None: + trainer.ref_distributor.stop() + print(f"[consumer] DONE ({RUN_ID})", flush=True) + + +def main() -> None: + args = parse_args() + set_seed(args.seed) + role = _role() + print(f"[disagg-domino] role={role} run_id={RUN_ID}", flush=True) + if role == "producer": + run_producer(args) + return + init_distributed(timeout=args.dist_timeout, tp_size=args.tp_size) + try: + run_consumer(args) + finally: + destroy_distributed() + + +if __name__ == "__main__": + main() diff --git a/examples/disagg/run_qwen3.6_27b_dflash_disagg_multiserver.sh b/examples/disagg/run_qwen3.6_27b_dflash_disagg_multiserver.sh index 9492db9ce..0348559f6 100755 --- a/examples/disagg/run_qwen3.6_27b_dflash_disagg_multiserver.sh +++ b/examples/disagg/run_qwen3.6_27b_dflash_disagg_multiserver.sh @@ -30,43 +30,40 @@ export FLASHINFER_DISABLE_VERSION_CHECK=1 export PYTHONPATH="$ROOT_DIR:$ROOT_DIR/scripts:${PYTHONPATH:-}" cd "$ROOT_DIR" -# --- topology: 2 servers x TP=2 + DP=2 trainer (override via env) --- -SERVER0_GPUS=${SERVER0_GPUS:-"0,1"} -SERVER1_GPUS=${SERVER1_GPUS:-"2,3"} -SERVER_TP=${SERVER_TP:-2} -SERVER0_PORT=${SERVER0_PORT:-30000} -SERVER1_PORT=${SERVER1_PORT:-30001} -TRAIN_DP=${TRAIN_DP:-2} -CONSUMER_GPUS=${CONSUMER_GPUS:-"4,5"} -# DFlash target capture layers — must match dflash_config.target_layer_ids in -# the draft config below, on BOTH servers. +# --- 拓扑结构:2 个推理服务器(每个 TP=2)+ 训练器(DP=2),均可通过环境变量覆盖 --- +SERVER0_GPUS=${SERVER0_GPUS:-"0,1"} # 推理服务器 0 使用的 GPU 编号(TP=2) +SERVER1_GPUS=${SERVER1_GPUS:-"2,3"} # 推理服务器 1 使用的 GPU 编号(TP=2) +SERVER_TP=${SERVER_TP:-2} # 每个 SGLang 服务器的张量并行度 +SERVER0_PORT=${SERVER0_PORT:-30000} # 推理服务器 0 的 HTTP 端口 +SERVER1_PORT=${SERVER1_PORT:-30001} # 推理服务器 1 的 HTTP 端口 +TRAIN_DP=${TRAIN_DP:-2} # 训练器的数据并行度(进程数 = nproc_per_node) +CONSUMER_GPUS=${CONSUMER_GPUS:-"4,5"} # 训练器(consumer)使用的 GPU 编号 +# DFlash 目标模型的特征抓取层 ID —— 必须与下方 draft config 中 dflash_config.target_layer_ids +# 保持一致,且两个服务器必须传入相同的值,否则 producer 端 FeatureContract 校验会失败。 AUX_LAYER_IDS=${AUX_LAYER_IDS:-"1 16 31 46 61"} -# --- mooncake connection (server sinks + producer + consumer share these) --- -export MOONCAKE_LOCAL_HOSTNAME=${MOONCAKE_LOCAL_HOSTNAME:-127.0.0.1} -export MOONCAKE_MASTER_SERVER_ADDR=${MOONCAKE_MASTER_SERVER_ADDR:-127.0.0.1:50051} -export MOONCAKE_METADATA_SERVER=${MOONCAKE_METADATA_SERVER:-http://127.0.0.1:8080/metadata} -export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-tcp} -# EACH server contributes a segment of this size to the one master; objects are -# hard-pinned (undersizing fails puts rather than evicting). The producer -# watermark's in-flight bytes now spread across N segments, but placement is -# not balanced — keep per-server headroom above watermark/N (skew margin). -export MOONCAKE_GLOBAL_SEGMENT_SIZE=${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((48 << 30))} -# Producer/consumer are pure clients (no segment): objects must not live in a -# process that exits before training finishes. -export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-0} +# --- Mooncake 连接配置(推理服务器 sink 端 + producer + consumer 共用) --- +export MOONCAKE_LOCAL_HOSTNAME=${MOONCAKE_LOCAL_HOSTNAME:-127.0.0.1} # 本机对外可达的主机名/IP,供 Mooncake 段注册使用 +export MOONCAKE_MASTER_SERVER_ADDR=${MOONCAKE_MASTER_SERVER_ADDR:-127.0.0.1:50051} # Mooncake master 地址(对象存储元数据入口) +export MOONCAKE_METADATA_SERVER=${MOONCAKE_METADATA_SERVER:-http://127.0.0.1:8080/metadata} # Mooncake HTTP metadata 服务地址 +export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-tcp} # 传输协议:tcp 或 rdma +# 每个推理服务器都会向唯一的 master 贡献这么大的内存段;对象是硬固定的(段太小时 put 直接失败而不是驱逐)。 +# producer 水位线管理的 in-flight 字节数在 N 个段之间分布,但放置并不均衡 —— +# 需为每个服务器预留高于 watermark/N 的余量(防止倾斜)。 +export MOONCAKE_GLOBAL_SEGMENT_SIZE=${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((48 << 30))} # 每服务器段大小,默认 48 GiB +# Producer/consumer 只是纯客户端(不注册段):因为对象不能存活在训练结束前就退出的进程中。 +export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-0} # 客户端段大小,固定为 0(不贡献段) -export DISAGG_STORE_ID=${DISAGG_STORE_ID:-qwen36-dflash-disagg-2srv} -export DISAGG_SERVER_URLS="http://127.0.0.1:$SERVER0_PORT,http://127.0.0.1:$SERVER1_PORT" -export DISAGG_REF_CHANNEL=${DISAGG_REF_CHANNEL:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/refs.jsonl} -# Trainer-side ONLY (rank-0 ledger + per-rank inboxes); the producer must not -# see the db — the launcher below passes it just to the consumer. -DISAGG_DB=${DISAGG_DB:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/run.db} -DISAGG_INBOX_DIR=${DISAGG_INBOX_DIR:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/inboxes} -export DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-400} -export DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-0} -export DISAGG_LOG_INTERVAL=${DISAGG_LOG_INTERVAL:-1} -REPORT_TO=${REPORT_TO:-wandb} # set REPORT_TO=none to run without W&B +export DISAGG_STORE_ID=${DISAGG_STORE_ID:-qwen36-dflash-disagg-2srv} # 本次运行的存储命名空间标识 +export DISAGG_SERVER_URLS="http://127.0.0.1:$SERVER0_PORT,http://127.0.0.1:$SERVER1_PORT" # 所有推理服务器 URL,producer 会为每个 URL 创建一个 adapter/worker +export DISAGG_REF_CHANNEL=${DISAGG_REF_CHANNEL:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/refs.jsonl} # 控制面 ref 通道文件(只传 SampleRef 元数据,不含张量) +# 仅训练端使用(rank-0 的 ledger + 每 rank 的 inbox);producer 不应看到 db —— 下方 launcher 只把它传给 consumer。 +DISAGG_DB=${DISAGG_DB:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/run.db} # 训练端 SQLite ledger 路径 +DISAGG_INBOX_DIR=${DISAGG_INBOX_DIR:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/inboxes} # 每个 rank 的样本 inbox 目录 +export DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-400} # producer 派发的最大 prompt 数(0 表示不限) +export DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-0} # consumer 训练最大步数(0 表示走完一个 epoch) +export DISAGG_LOG_INTERVAL=${DISAGG_LOG_INTERVAL:-1} # 训练日志打印间隔(步) +REPORT_TO=${REPORT_TO:-wandb} # 训练结果上报后端;设为 none 可禁用 W&B rm -rf "$(dirname "$DISAGG_REF_CHANNEL")" "$DISAGG_DB" mkdir -p "$(dirname "$DISAGG_REF_CHANNEL")" @@ -80,7 +77,7 @@ mooncake_master --enable-http-metadata-server=true & MASTER_PID=$! sleep 3 -# --- patched SGLang servers: frozen 27B target, TP=2 each, spec-capture on --- +# --- 打过 spec-capture 补丁的 SGLang 服务器:冻结的 27B 目标模型,每个 TP=2,开启特征抓取 --- launch_server() { # $1=gpus $2=port CUDA_VISIBLE_DEVICES=$1 MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_LOCAL_HOSTNAME \ python -m sglang.launch_server \ @@ -96,6 +93,17 @@ launch_server() { # $1=gpus $2=port --spec-capture-aux-layer-ids $AUX_LAYER_IDS \ --port "$2" & } +# --model-path : 冻结的目标模型(HuggingFace 路径) +# --trust-remote-code : 允许加载模型仓库中的自定义代码 +# --skip-tokenizer-init : 跳过服务器端 tokenizer 初始化(producer 侧完成分词) +# --tp-size : 张量并行度 +# --mem-fraction-static : 静态显存占用比例(KV cache 上限),0.85 = 显存的 85% +# --chunked-prefill-size : 分块 prefill 大小,-1 表示禁用分块 prefill +# --disable-radix-cache : 关闭 RadixAttention 前缀缓存(保证捕获与训练特征一一对应) +# --enable-spec-capture : 启用补丁引入的推测采样特征抓取开关 +# --spec-capture-method dflash : 抓取方式选择 DFlash(上下文隐藏态 + 辅助层) +# --spec-capture-aux-layer-ids : 需要抓取的辅助层 ID,须与 draft config 中 target_layer_ids 一致 +# --port : 服务器 HTTP 监听端口 launch_server "$SERVER0_GPUS" "$SERVER0_PORT" SERVER0_PID=$! launch_server "$SERVER1_GPUS" "$SERVER1_PORT" @@ -110,41 +118,52 @@ for port_pid in "$SERVER0_PORT:$SERVER0_PID" "$SERVER1_PORT:$SERVER1_PID"; do done done -# recipe matches run_qwen3.6_27b_dflash_disagg.sh; max-length 2048 for a bounded -# single-node demo. batch-size is PER RANK: global batch = batch_size * TRAIN_DP. +# 训练配方与 run_qwen3.6_27b_dflash_disagg.sh 一致;单节点 demo 下 max-length 设为 2048。 +# 注意 batch-size 是"每 rank"的大小,global batch = batch_size * TRAIN_DP。 ARGS=( - --target-model-path Qwen/Qwen3.6-27B - --target-model-backend hf - --trust-remote-code - --draft-config-path "$ROOT_DIR/configs/qwen3.6-27b-dflash.json" - --embedding-key model.language_model.embed_tokens.weight - --lm-head-key lm_head.weight - --mask-token-id 248070 - --train-data-path "$ROOT_DIR/cache/dataset/nemotron_v2_train.jsonl" - --chat-template qwen3.5 - --max-length 2048 - --batch-size 1 - --learning-rate 6e-4 - --warmup-ratio 0.04 - --max-grad-norm 1.0 - --attention-backend flex_attention - --block-size 16 - --num-anchors 512 - --loss-decay-gamma 7.0 - --num-epochs 1 - --seed 42 - --save-interval 1000000 + --target-model-path Qwen/Qwen3.6-27B # 目标模型(教师)路径,与推理服务器一致 + --target-model-backend hf # 目标模型加载后端(consumer 侧仅需权重壳做前向对齐) + --trust-remote-code # 允许加载模型仓库自定义代码 + --draft-config-path "$ROOT_DIR/configs/qwen3.6-27b-dflash.json" # DFlash 草稿模型配置(含 target_layer_ids 等) + --embedding-key model.language_model.embed_tokens.weight # 从 target 权重复用 embedding 的 state_dict key + --lm-head-key lm_head.weight # 从 target 权重复用 lm_head 的 state_dict key + --mask-token-id 248070 # 训练时用于对齐/掩码的特殊 token id + --train-data-path "$ROOT_DIR/cache/dataset/nemotron_v2_train.jsonl" # 训练数据集路径(prompt jsonl) + --chat-template qwen3.5 # 聊天模板名称 + --max-length 2048 # 最大序列长度(prompt+response 上限) + --batch-size 1 # 每 rank 的 batch size + --learning-rate 6e-4 # 学习率 + --warmup-ratio 0.04 # warmup 比例(占总步数) + --max-grad-norm 1.0 # 梯度裁剪阈值 + --attention-backend flex_attention # 训练侧 attention 后端(flex_attention) + --block-size 16 # DFlash 训练的 block 长度 + --num-anchors 512 # DFlash 采样锚点数 + --loss-decay-gamma 7.0 # 沿位置的损失衰减系数 γ + --num-epochs 1 # 训练轮数 + --seed 42 # 随机种子 + --save-interval 1000000 # 保存 checkpoint 的步间隔(此处足够大 = 不保存中间态) ) -LAUNCHER=$SCRIPT_DIR/run_disagg_dflash.py +LAUNCHER=$SCRIPT_DIR/run_disagg_dflash.py # 统一入口脚本,通过 DISAGG_ROLE 分流 producer / consumer -# --- producer: CPU-only HTTP driver fanning out to both servers --- +# --- producer:纯 CPU 的 HTTP 驱动,将 prompt 分发到两个推理服务器 --- +# CUDA_VISIBLE_DEVICES="" : 强制不使用 GPU(producer 只做 HTTP 调度与 ref 记录) +# DISAGG_ROLE=producer : 由 launcher 识别为 producer 分支 +# --output-dir : producer 输出目录(日志、ref 通道副本等) DISAGG_ROLE=producer CUDA_VISIBLE_DEVICES="" \ python "$LAUNCHER" "${ARGS[@]}" \ --output-dir "$ROOT_DIR/outputs/qwen36-disagg-2srv-producer" & PRODUCER_PID=$! -# --- consumer: DP=$TRAIN_DP trainer pool; rank 0 = distributor + ledger --- +# --- consumer:DP=$TRAIN_DP 的训练进程池;rank 0 兼任样本分发与 ledger 维护 --- +# CUDA_VISIBLE_DEVICES : 训练器使用的 GPU +# DISAGG_ROLE=consumer : 由 launcher 识别为 consumer 分支 +# DISAGG_DB / DISAGG_INBOX_DIR: 仅训练端可见的 ledger 与每 rank 的 inbox 目录 +# torchrun --rdzv-* : c10d 单节点 rendezvous,端口 29702 +# --nnodes / --nproc_per_node : 节点数与每节点进程数(= DP) +# --output-dir : consumer 输出目录(checkpoint、日志) +# --report-to : 训练结果上报(wandb 或 none) +# --wandb-project / --wandb-name : W&B 项目与本次实验名 CUDA_VISIBLE_DEVICES=$CONSUMER_GPUS DISAGG_ROLE=consumer \ DISAGG_DB=$DISAGG_DB DISAGG_INBOX_DIR=$DISAGG_INBOX_DIR \ torchrun --rdzv-backend c10d --rdzv-endpoint localhost:29702 \ diff --git a/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh b/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh new file mode 100644 index 000000000..02fda36fc --- /dev/null +++ b/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# Qwen3-8B Domino, ONLINE disaggregated, MULTI-SERVER on one node: +# mooncake master -> CPU (RDMA/TCP object store) +# patched SGLang server 0 -> SERVER0_GPUS (frozen Qwen3-8B -> mooncake) +# patched SGLang server 1 -> SERVER1_GPUS (same model + capture flags) +# producer (HTTP driver) -> CPU (fans prompts out to BOTH servers) +# consumer (Domino trainer) -> CONSUMER_GPUS (DP=TRAIN_DP) +# +# Domino uses the same server-side DFlash feature capture as DFlash. The draft +# config must have dflash_config.projector_type="domino"; the trainer consumes +# the captured hidden_states with strategy="domino". +set -euxo pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +ROOT_DIR=$(dirname "$(dirname "$SCRIPT_DIR")") +export TORCHINDUCTOR_CACHE_DIR=$ROOT_DIR/cache/compiled_kernels +export FLASHINFER_DISABLE_VERSION_CHECK=1 +export PYTHONPATH="$ROOT_DIR:$ROOT_DIR/scripts:${PYTHONPATH:-}" +cd "$ROOT_DIR" + +# --- topology: 2 servers x TP=1 + DP=2 trainer (override via env) --- +SERVER0_GPUS=${SERVER0_GPUS:-"2"} +SERVER1_GPUS=${SERVER1_GPUS:-"3"} +SERVER_TP=${SERVER_TP:-1} +SERVER0_PORT=${SERVER0_PORT:-30000} +SERVER1_PORT=${SERVER1_PORT:-30001} +TRAIN_DP=${TRAIN_DP:-2} +CONSUMER_GPUS=${CONSUMER_GPUS:-"4,5"} + +TARGET_MODEL_PATH=${TARGET_MODEL_PATH:-/disk3/wjp/pretrained_models/Qwen3-8B} +DRAFT_CONFIG_PATH=${DRAFT_CONFIG_PATH:-$ROOT_DIR/configs/qwen3-8b-domino.json} +TRAIN_DATA_PATH=${TRAIN_DATA_PATH:-/disk3/wjp/datasets/perfectblend/qwen3-8b/perfectblend_train_regen_temperature0_no_think.jsonl} +CHAT_TEMPLATE=${CHAT_TEMPLATE:-qwen} + +# Must match dflash_config.target_layer_ids in configs/qwen3-8b-domino.json. +AUX_LAYER_IDS=${AUX_LAYER_IDS:-"1 9 17 25 33"} + +# --- mooncake connection (server sinks + producer + consumer share these) --- +# export MOONCAKE_LOCAL_HOSTNAME=${MOONCAKE_LOCAL_HOSTNAME:-127.0.0.1} +# export MOONCAKE_MASTER_SERVER_ADDR=${MOONCAKE_MASTER_SERVER_ADDR:-127.0.0.1:50051} +# export MOONCAKE_METADATA_SERVER=${MOONCAKE_METADATA_SERVER:-http://127.0.0.1:8080/metadata} + +MOONCAKE_HOST=${MOONCAKE_HOST:-127.0.0.1} +MOONCAKE_RPC_PORT=${MOONCAKE_RPC_PORT:-50051} +MOONCAKE_HTTP_PORT=${MOONCAKE_HTTP_PORT:-18080} +MOONCAKE_METRICS_PORT=${MOONCAKE_METRICS_PORT:-19003} + +export MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_HOST +export MOONCAKE_MASTER_SERVER_ADDR=$MOONCAKE_HOST:$MOONCAKE_RPC_PORT +export MOONCAKE_METADATA_SERVER=http://$MOONCAKE_HOST:$MOONCAKE_HTTP_PORT/metadata +export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-tcp} +export MOONCAKE_GLOBAL_SEGMENT_SIZE=${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((32 << 30))} +export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-$((256 << 20))} +export DISAGG_CLIENT_BUFFER_SIZE=${DISAGG_CLIENT_BUFFER_SIZE:-$((256 << 20))} + +export DISAGG_STORE_ID=${DISAGG_STORE_ID:-qwen3-8b-domino-disagg-2srv} +export DISAGG_SERVER_URLS="http://127.0.0.1:$SERVER0_PORT,http://127.0.0.1:$SERVER1_PORT" +export DISAGG_REF_CHANNEL=${DISAGG_REF_CHANNEL:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/refs.jsonl} +DISAGG_DB=${DISAGG_DB:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/run.db} +DISAGG_INBOX_DIR=${DISAGG_INBOX_DIR:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/inboxes} +export DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-400000} +export DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-0} +export DISAGG_TOTAL_STEPS=${DISAGG_TOTAL_STEPS:-10000} +export DISAGG_LOG_INTERVAL=${DISAGG_LOG_INTERVAL:-1} +REPORT_TO=${REPORT_TO:-wandb} # set REPORT_TO=none to run without W&B + +rm -rf "$(dirname "$DISAGG_REF_CHANNEL")" "$DISAGG_DB" +mkdir -p "$(dirname "$DISAGG_REF_CHANNEL")" +: > "$DISAGG_REF_CHANNEL" + +cleanup() { kill "${MASTER_PID:-}" "${SERVER0_PID:-}" "${SERVER1_PID:-}" "${PRODUCER_PID:-}" 2>/dev/null || true; } +trap cleanup EXIT + +# --- mooncake master --- +mooncake_master \ + --enable_http_metadata_server=true \ + --rpc_port="$MOONCAKE_RPC_PORT" \ + --http_metadata_server_port="$MOONCAKE_HTTP_PORT" \ + --metrics_port="$MOONCAKE_METRICS_PORT" & +MASTER_PID=$! +sleep 3 + +# --- patched SGLang servers: frozen target, spec-capture on --- +launch_server() { # $1=gpus $2=port + CUDA_VISIBLE_DEVICES=$1 MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_LOCAL_HOSTNAME \ + python -m sglang.launch_server \ + --model-path "$TARGET_MODEL_PATH" \ + --trust-remote-code \ + --skip-tokenizer-init \ + --tp-size "$SERVER_TP" \ + --mem-fraction-static 0.85 \ + --chunked-prefill-size -1 \ + --disable-radix-cache \ + --enable-spec-capture \ + --spec-capture-method dflash \ + --spec-capture-aux-layer-ids $AUX_LAYER_IDS \ + --port "$2" & +} +launch_server "$SERVER0_GPUS" "$SERVER0_PORT" +SERVER0_PID=$! +launch_server "$SERVER1_GPUS" "$SERVER1_PORT" +SERVER1_PID=$! + +for port_pid in "$SERVER0_PORT:$SERVER0_PID" "$SERVER1_PORT:$SERVER1_PID"; do + port=${port_pid%%:*}; pid=${port_pid##*:} + until curl -sf "http://127.0.0.1:$port/health" > /dev/null; do + if ! kill -0 "$pid" 2>/dev/null; then echo "server on :$port died"; exit 1; fi + sleep 5 + done +done + +ARGS=( + --target-model-path "$TARGET_MODEL_PATH" + --target-model-backend hf + --trust-remote-code + --draft-config-path "$DRAFT_CONFIG_PATH" + --mask-token-id 151669 + --train-data-path "$TRAIN_DATA_PATH" + --chat-template "$CHAT_TEMPLATE" + --max-length 3072 + --batch-size 2 + --learning-rate 6e-4 + --warmup-ratio 0.04 + --max-grad-norm 1.0 + --attention-backend flex_attention + --block-size 16 + --num-anchors 256 + --loss-decay-gamma 7.0 + --num-epochs 6 + --seed 42 + --save-interval 2000 + --lambda-base-start 1.0 + --lambda-base-decay-ratio 1.0 +) + +LAUNCHER=$SCRIPT_DIR/run_disagg_domino.py + +DISAGG_ROLE=producer CUDA_VISIBLE_DEVICES="" \ + python "$LAUNCHER" "${ARGS[@]}" \ + --output-dir "$ROOT_DIR/outputs/qwen3-8b-domino-disagg-2srv-producer" & +PRODUCER_PID=$! + +CUDA_VISIBLE_DEVICES=$CONSUMER_GPUS DISAGG_ROLE=consumer \ + DISAGG_DB=$DISAGG_DB DISAGG_INBOX_DIR=$DISAGG_INBOX_DIR \ + torchrun --rdzv-backend c10d --rdzv-endpoint localhost:29702 \ + --nnodes 1 --nproc_per_node "$TRAIN_DP" "$LAUNCHER" "${ARGS[@]}" \ + --output-dir "$ROOT_DIR/outputs/qwen3-8b-domino-disagg-2srv-consumer" \ + --report-to "$REPORT_TO" \ + --wandb-project qwen3-8b-domino-disagg \ + --wandb-name qwen3-8b-domino-2srv-dp$TRAIN_DP + +wait $PRODUCER_PID +echo "QWEN3-8B-DOMINO-DISAGG-2SRV-DONE" diff --git a/specforge/launch.py b/specforge/launch.py index 12007e0b2..40afbe87f 100644 --- a/specforge/launch.py +++ b/specforge/launch.py @@ -60,6 +60,7 @@ def _assemble_trainer( logger, log_interval: int, collate_fn, + strategy_kwargs: Optional[dict] = None, per_sample_transform=None, durable_ack: bool = True, resume_from: Optional[str] = None, @@ -94,6 +95,7 @@ def _assemble_trainer( logger=logger, log_interval=log_interval, collate_fn=collate_fn, + strategy_kwargs=strategy_kwargs, per_sample_transform=per_sample_transform, durable_ack=durable_ack, resume_from=resume_from, @@ -605,6 +607,7 @@ def build_disagg_online_consumer( resume: bool = False, logger=None, log_interval: int = 50, + strategy_kwargs: Optional[dict] = None, resume_from: Optional[str] = None, max_checkpoints: int = 0, ): @@ -682,6 +685,7 @@ def build_disagg_online_consumer( logger=logger, log_interval=log_interval, collate_fn=_online_collate(spec, collate_fn), + strategy_kwargs=strategy_kwargs, per_sample_transform=None, resume_from=resume_from, max_checkpoints=max_checkpoints, diff --git a/specforge/training/strategies/registry.py b/specforge/training/strategies/registry.py index cf458190e..acc86e895 100644 --- a/specforge/training/strategies/registry.py +++ b/specforge/training/strategies/registry.py @@ -289,12 +289,18 @@ def _domino_offline_reader(hidden_states_path, *, run_id, ttt_length, max_len): target_repr=None, ) +def _make_domino_strategy( + wrapped, *, target_head=None, lambda_start=1.0, decay_ratio=0.5 +): + return DominoTrainStrategy( + wrapped, lambda_start=lambda_start, decay_ratio=decay_ratio + ) register_strategy( StrategySpec( name="domino", required_features=frozenset(DominoTrainStrategy.required_features), - make_strategy=lambda wrapped, *, target_head=None: DominoTrainStrategy(wrapped), + make_strategy=_make_domino_strategy, uses_target_head=False, make_offline_reader=_domino_offline_reader, make_offline_transform=_dflash_offline_transform, # same schema as DFlash diff --git a/specforge/training/trainer.py b/specforge/training/trainer.py index b324ee5f7..33101aca7 100644 --- a/specforge/training/trainer.py +++ b/specforge/training/trainer.py @@ -53,6 +53,7 @@ def __init__( logger, log_interval: int, collate_fn, + strategy_kwargs: Optional[dict] = None, total_steps: Optional[int] = None, per_sample_transform=None, durable_ack: bool = True, @@ -151,7 +152,9 @@ def __init__( wrapped = backend.prepare_model(model, optimizer_target=model.draft_model) if resume is not None: backend.load_state_dict(resume["backend"]) - strategy = spec.make_strategy(wrapped, target_head=target_head) + strategy = spec.make_strategy( + wrapped, target_head=target_head, **(strategy_kwargs or {}) + ) core = TrainerCore(strategy, backend, accumulation_steps=accumulation_steps) ack_fn = None if durable_ack: From e3c7acb0d9b12050a9e4ad43a3ee3cc6a1ecf830 Mon Sep 17 00:00:00 2001 From: wjp666666 <1969554248@qq.com> Date: Tue, 7 Jul 2026 18:10:37 +0000 Subject: [PATCH 10/24] fix bugs --- .../run_qwen3_8b_domino_disagg_multiserver.sh | 85 ++++++++++++++++--- .../inference/adapters/server_capture.py | 51 ++++++++++- .../runtime/data_plane/mooncake_store.py | 7 +- specforge/tracker.py | 13 +++ specforge/training/strategies/registry.py | 50 ++++++----- tests/test_runtime/test_server_capture.py | 19 +++++ tests/test_runtime/test_strategy_registry.py | 31 ++++++- 7 files changed, 213 insertions(+), 43 deletions(-) diff --git a/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh b/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh index 02fda36fc..4da7859bd 100644 --- a/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh +++ b/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh @@ -27,9 +27,9 @@ SERVER1_PORT=${SERVER1_PORT:-30001} TRAIN_DP=${TRAIN_DP:-2} CONSUMER_GPUS=${CONSUMER_GPUS:-"4,5"} -TARGET_MODEL_PATH=${TARGET_MODEL_PATH:-/disk3/wjp/pretrained_models/Qwen3-8B} +TARGET_MODEL_PATH=${TARGET_MODEL_PATH:-Qwen/Qwen3-8B} DRAFT_CONFIG_PATH=${DRAFT_CONFIG_PATH:-$ROOT_DIR/configs/qwen3-8b-domino.json} -TRAIN_DATA_PATH=${TRAIN_DATA_PATH:-/disk3/wjp/datasets/perfectblend/qwen3-8b/perfectblend_train_regen_temperature0_no_think.jsonl} +TRAIN_DATA_PATH=${TRAIN_DATA_PATH:-/sgl-workspace/SpecForge/cache/dataset/perfectblend_train_regen_temperature0_no_think_20w.jsonl} CHAT_TEMPLATE=${CHAT_TEMPLATE:-qwen} # Must match dflash_config.target_layer_ids in configs/qwen3-8b-domino.json. @@ -41,17 +41,24 @@ AUX_LAYER_IDS=${AUX_LAYER_IDS:-"1 9 17 25 33"} # export MOONCAKE_METADATA_SERVER=${MOONCAKE_METADATA_SERVER:-http://127.0.0.1:8080/metadata} MOONCAKE_HOST=${MOONCAKE_HOST:-127.0.0.1} -MOONCAKE_RPC_PORT=${MOONCAKE_RPC_PORT:-50051} -MOONCAKE_HTTP_PORT=${MOONCAKE_HTTP_PORT:-18080} -MOONCAKE_METRICS_PORT=${MOONCAKE_METRICS_PORT:-19003} +MOONCAKE_RPC_PORT=${MOONCAKE_RPC_PORT:-35551} +MOONCAKE_HTTP_PORT=${MOONCAKE_HTTP_PORT:-35880} +MOONCAKE_METRICS_PORT=${MOONCAKE_METRICS_PORT:-35903} export MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_HOST export MOONCAKE_MASTER_SERVER_ADDR=$MOONCAKE_HOST:$MOONCAKE_RPC_PORT export MOONCAKE_METADATA_SERVER=http://$MOONCAKE_HOST:$MOONCAKE_HTTP_PORT/metadata export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-tcp} export MOONCAKE_GLOBAL_SEGMENT_SIZE=${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((32 << 30))} -export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-$((256 << 20))} +# Only the long-lived SGLang servers may own storage segments. The producer +# exits after publishing refs; if it contributes a segment, objects placed +# there disappear before the consumer loads them. +export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-0} export DISAGG_CLIENT_BUFFER_SIZE=${DISAGG_CLIENT_BUFFER_SIZE:-$((256 << 20))} +if [[ "$DISAGG_CLIENT_SEGMENT_SIZE" -ne 0 ]]; then + echo "DISAGG_CLIENT_SEGMENT_SIZE must be 0 for server-owned captures" >&2 + exit 2 +fi export DISAGG_STORE_ID=${DISAGG_STORE_ID:-qwen3-8b-domino-disagg-2srv} export DISAGG_SERVER_URLS="http://127.0.0.1:$SERVER0_PORT,http://127.0.0.1:$SERVER1_PORT" @@ -62,23 +69,74 @@ export DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-400000} export DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-0} export DISAGG_TOTAL_STEPS=${DISAGG_TOTAL_STEPS:-10000} export DISAGG_LOG_INTERVAL=${DISAGG_LOG_INTERVAL:-1} -REPORT_TO=${REPORT_TO:-wandb} # set REPORT_TO=none to run without W&B +REPORT_TO=${REPORT_TO:-none} # set REPORT_TO=wandb after installing/configuring W&B + +# Fail before allocating GPUs if Mooncake's Python extension or the selected +# optional tracker is unavailable. Import torch first so pip-installed NVIDIA +# runtime libraries are preloaded in the same order as the launcher. +python - <<'PY' +import torch +try: + from mooncake.store import MooncakeDistributedStore, ReplicateConfig +except Exception as exc: + raise SystemExit( + f"Mooncake preflight failed: {type(exc).__name__}: {exc}\n" + "Install compatible binaries with: pip install mooncake-transfer-engine " + "nvidia-cuda-runtime-cu12" + ) from exc +PY +if [[ "$REPORT_TO" == "wandb" ]]; then + python - <<'PY' +import wandb + +required = ("login", "init", "log", "finish") +if not all(callable(getattr(wandb, name, None)) for name in required): + raise SystemExit("REPORT_TO=wandb requires a complete W&B client: pip install wandb") +PY +fi rm -rf "$(dirname "$DISAGG_REF_CHANNEL")" "$DISAGG_DB" mkdir -p "$(dirname "$DISAGG_REF_CHANNEL")" : > "$DISAGG_REF_CHANNEL" +MOONCAKE_LOG=${MOONCAKE_LOG:-$(dirname "$DISAGG_REF_CHANNEL")/mooncake.log} -cleanup() { kill "${MASTER_PID:-}" "${SERVER0_PID:-}" "${SERVER1_PID:-}" "${PRODUCER_PID:-}" 2>/dev/null || true; } +cleanup() { + kill "${SERVER0_PID:-}" "${SERVER1_PID:-}" "${PRODUCER_PID:-}" 2>/dev/null || true + if [[ -n "${MASTER_PID:-}" ]]; then + kill -- "-$MASTER_PID" 2>/dev/null || kill "$MASTER_PID" 2>/dev/null || true + fi +} trap cleanup EXIT # --- mooncake master --- -mooncake_master \ +setsid mooncake_master \ --enable_http_metadata_server=true \ --rpc_port="$MOONCAKE_RPC_PORT" \ --http_metadata_server_port="$MOONCAKE_HTTP_PORT" \ - --metrics_port="$MOONCAKE_METRICS_PORT" & + --metrics_port="$MOONCAKE_METRICS_PORT" \ + >"$MOONCAKE_LOG" 2>&1 & MASTER_PID=$! -sleep 3 + +wait_for_mooncake() { + for _ in $(seq 1 30); do + if ! kill -0 "$MASTER_PID" 2>/dev/null; then + echo "Mooncake master exited during startup; see $MOONCAKE_LOG" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + return 1 + fi + if curl -sS --max-time 1 -o /dev/null \ + "$MOONCAKE_METADATA_SERVER?key=specforge-health-check" \ + && timeout 1 bash -c \ + "/dev/null; then + return 0 + fi + sleep 1 + done + echo "Mooncake master did not become ready; see $MOONCAKE_LOG" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + return 1 +} +wait_for_mooncake # --- patched SGLang servers: frozen target, spec-capture on --- launch_server() { # $1=gpus $2=port @@ -104,6 +162,11 @@ SERVER1_PID=$! for port_pid in "$SERVER0_PORT:$SERVER0_PID" "$SERVER1_PORT:$SERVER1_PID"; do port=${port_pid%%:*}; pid=${port_pid##*:} until curl -sf "http://127.0.0.1:$port/health" > /dev/null; do + if ! kill -0 "$MASTER_PID" 2>/dev/null; then + echo "Mooncake master died while SGLang servers were starting" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + exit 1 + fi if ! kill -0 "$pid" 2>/dev/null; then echo "server on :$port died"; exit 1; fi sleep 5 done diff --git a/specforge/inference/adapters/server_capture.py b/specforge/inference/adapters/server_capture.py index 83a3a256f..81befefca 100644 --- a/specforge/inference/adapters/server_capture.py +++ b/specforge/inference/adapters/server_capture.py @@ -136,6 +136,43 @@ def _default_post(url: str, json_body: Dict[str, Any], timeout: float): return resp.json() +def _flatten_list_wrappers(value: Any) -> List[Any]: + """Flatten list-only response wrappers while leaving row objects intact.""" + if not isinstance(value, list): + return [value] + flattened: List[Any] = [] + for item in value: + flattened.extend(_flatten_list_wrappers(item)) + return flattened + + +def _capture_result_for_task( + value: Any, *, task_id: str, expected_sample_id: str +) -> Optional[Dict[str, Any]]: + """Select this task's capture from scalar or batch-wrapped results.""" + candidates = [item for item in _flatten_list_wrappers(value) if item is not None] + if not candidates: + return None + if not all(isinstance(item, dict) for item in candidates): + types = sorted({type(item).__name__ for item in candidates}) + raise RuntimeError( + "spec-capture server returned non-object capture results for task " + f"{task_id}: {types}" + ) + if len(candidates) == 1: + return candidates[0] + matches = [ + item for item in candidates if str(item.get("sample_id")) == expected_sample_id + ] + if len(matches) != 1: + raise RuntimeError( + "spec-capture server returned an ambiguous batch result for task " + f"{task_id}: expected sample_id={expected_sample_id!r}, " + f"matches={len(matches)}, candidates={len(candidates)}" + ) + return matches[0] + + class SGLangServerCaptureAdapter: """RefSource over a live spec-capture SGLang server. @@ -298,8 +335,7 @@ def produce_refs( rows = self.post_fn( f"{self.base_url}/generate", json_body=body, timeout=self.timeout_s ) - if isinstance(rows, dict): - rows = [rows] + rows = _flatten_list_wrappers(rows) if len(rows) != len(tasks): raise RuntimeError( f"spec-capture server returned {len(rows)} rows for " @@ -307,10 +343,19 @@ def produce_refs( ) out: List[Union[Any, ServerCaptureFailure]] = [] for task, row in zip(tasks, rows): + if not isinstance(row, dict): + raise RuntimeError( + "spec-capture server returned a non-object row for task " + f"{task.task_id}: {type(row).__name__}" + ) meta = row.get("meta_info") or {} # meta_info["spec_capture"] is the per-request result dict (or an # {"error": ...} marker) from the server's dedicated output field. - result = meta.get("spec_capture") + result = _capture_result_for_task( + meta.get("spec_capture"), + task_id=task.task_id, + expected_sample_id=self._sample_id(task), + ) if not result: out.append( ServerCaptureFailure( diff --git a/specforge/runtime/data_plane/mooncake_store.py b/specforge/runtime/data_plane/mooncake_store.py index 7a00b08a9..79fb761dd 100644 --- a/specforge/runtime/data_plane/mooncake_store.py +++ b/specforge/runtime/data_plane/mooncake_store.py @@ -110,9 +110,10 @@ def _connect_store(setup_kwargs: Dict[str, Any]) -> Any: from mooncake.store import MooncakeDistributedStore # type: ignore except Exception as e: # pragma: no cover - exercised only without mooncake raise RuntimeError( - "MooncakeFeatureStore requires the 'mooncake' package " - "(pip install mooncake-transfer-engine). Pass store= to inject " - "a backend for testing." + "MooncakeFeatureStore could not load mooncake.store: " + f"{type(e).__name__}: {e}. Install mooncake-transfer-engine and its " + "binary dependencies (the PyPI wheel requires the CUDA 12 runtime). " + "Pass store= to inject a backend for testing." ) from e store = MooncakeDistributedStore() rc = store.setup(**setup_kwargs) diff --git a/specforge/tracker.py b/specforge/tracker.py index b91794ce1..478fdd090 100644 --- a/specforge/tracker.py +++ b/specforge/tracker.py @@ -14,6 +14,14 @@ except ImportError: wandb = None +# A local ``wandb/`` output directory is importable as a namespace package when +# the real client is absent. Treat that case as an unavailable dependency too. +if wandb is not None and not all( + callable(getattr(wandb, name, None)) + for name in ("login", "init", "log", "finish") +): + wandb = None + try: from torch.utils.tensorboard import SummaryWriter except ImportError: @@ -138,6 +146,11 @@ def validate_args(cls, parser, args): def __init__(self, args, output_dir: str): super().__init__(args, output_dir) + if wandb is None: + raise RuntimeError( + "To use --report-to wandb, install the W&B client: " + "'pip install wandb'." + ) if self.rank == 0: if args.wandb_dir is None: args.wandb_dir = self._default_wandb_dir() diff --git a/specforge/training/strategies/registry.py b/specforge/training/strategies/registry.py index acc86e895..a7f8b0870 100644 --- a/specforge/training/strategies/registry.py +++ b/specforge/training/strategies/registry.py @@ -16,10 +16,13 @@ * ``make_strategy`` — build the per-step strategy over the FSDP-wrapped model (the seam ``TrainerCore`` consumes), * ``required_features`` — the feature contract (drives - ``CaptureConfig.from_strategy`` + loader validation), + ``FeatureContract.from_strategy`` + loader validation), * the offline data path — reader + per-sample transform + collate + target_repr, * ``make_online_collate`` — the online/streamed collate, - * ``make_adapter`` — the online capture adapter (None => SGLangAdapter), + * ``feature_schema`` — the store-ready dict shape the shared + ``PolicyFeatureAdapter`` emits online, + * ``make_adapter`` — escape hatch for a bespoke online capture adapter + (None => PolicyFeatureAdapter over feature_schema), * ``supports_online`` — whether an online capture path exists yet. Registering a new model (dflash, domino, ...) is a ``StrategySpec`` entry next @@ -42,6 +45,11 @@ import torch +from specforge.inference.adapters.policy import ( + DFLASH_FEATURE_SCHEMA, + EAGLE3_FEATURE_SCHEMA, + FeatureSchema, +) from specforge.training.strategies.base import DraftTrainStrategy, Eagle3TrainStrategy @@ -79,7 +87,11 @@ class StrategySpec: make_offline_collate: Optional[Callable[[], Callable]] = None # Online data path. make_online_collate: Optional[Callable[[], Callable]] = None - # (target_model, *, device, t2d) -> capture adapter. None => default SGLangAdapter. + # The store-ready dict shape the shared PolicyFeatureAdapter emits online. + # None with supports_online=True falls back to the EAGLE3 schema. + feature_schema: Optional[FeatureSchema] = None + # Escape hatch: (target_model, *, device, t2d) -> bespoke capture adapter. + # None => PolicyFeatureAdapter over feature_schema. make_adapter: Optional[Callable[..., Any]] = None supports_online: bool = False @@ -147,7 +159,7 @@ def _eagle3_offline_collate(): make_offline_transform=_eagle3_offline_transform, make_offline_collate=_eagle3_offline_collate, make_online_collate=lambda: concat_collate, - make_adapter=None, # default SGLangAdapter + feature_schema=EAGLE3_FEATURE_SCHEMA, supports_online=True, ) ) @@ -158,9 +170,9 @@ def _eagle3_offline_collate(): # capture layers, NO eagle3 aux/target swap, NO target distribution / vocab map). # Offline: the reader reuses OfflineManifestReader with dflash feature_keys; the # transform slices to max_len (no swap); the collate pads + emits {input_ids, -# hidden_states, loss_mask}. Online: a DFlashAdapter wraps generate_dflash_data -# and emits the same schema. The DFlashTrainStrategy already drops into the -# unchanged TrainerCore/Backend/Loader. +# hidden_states, loss_mask}. Online: DFLASH_FEATURE_SCHEMA drives the shared +# PolicyFeatureAdapter to emit the same schema. The DFlashTrainStrategy already +# drops into the unchanged TrainerCore/Backend/Loader. from specforge.training.strategies.base import DFlashTrainStrategy @@ -243,12 +255,6 @@ def pad3d(t): # [1, n, W] -> [1, maxlen, W] return collate -def _dflash_adapter(target_model, *, device="cuda", t2d=None): - from specforge.inference.adapters.dflash import DFlashAdapter - - return DFlashAdapter(target_model, device=device, t2d=t2d) - - register_strategy( StrategySpec( name="dflash", @@ -258,8 +264,8 @@ def _dflash_adapter(target_model, *, device="cuda", t2d=None): make_offline_reader=_dflash_offline_reader, make_offline_transform=_dflash_offline_transform, make_offline_collate=_dflash_offline_collate, - make_online_collate=lambda: concat_collate, - make_adapter=_dflash_adapter, + make_online_collate=_dflash_offline_collate, + feature_schema=DFLASH_FEATURE_SCHEMA, supports_online=True, ) ) @@ -267,11 +273,11 @@ def _dflash_adapter(target_model, *, device="cuda", t2d=None): # --- Domino ----------------------------------------------------------------- # Domino reuses DFlash's draft model (projector_type="domino" head), feature -# schema, offline transform/collate, and capture adapter (same -# generate_dflash_data -> hidden_states). The ONE difference is the loss: it -# blends a base loss with a step-decayed weight, so DominoTrainStrategy reads the -# StepContext (forward_loss(batch, ctx)). That is the whole reason a new algorithm -# needs anything beyond a spec entry here. +# schema, offline transform/collate, and capture path (same DFLASH_FEATURE_SCHEMA +# -> hidden_states). The ONE difference is the loss: it blends a base loss with a +# step-decayed weight, so DominoTrainStrategy reads the StepContext +# (forward_loss(batch, ctx)). That is the whole reason a new algorithm needs +# anything beyond a spec entry here. from specforge.training.strategies.base import DominoTrainStrategy @@ -305,8 +311,8 @@ def _make_domino_strategy( make_offline_reader=_domino_offline_reader, make_offline_transform=_dflash_offline_transform, # same schema as DFlash make_offline_collate=_dflash_offline_collate, - make_online_collate=lambda: concat_collate, - make_adapter=_dflash_adapter, # same generate_dflash_data capture + make_online_collate=_dflash_offline_collate, + feature_schema=DFLASH_FEATURE_SCHEMA, # same capture path as DFlash supports_online=True, ) ) diff --git a/tests/test_runtime/test_server_capture.py b/tests/test_runtime/test_server_capture.py index 7e3384c0d..f946c8fa6 100644 --- a/tests/test_runtime/test_server_capture.py +++ b/tests/test_runtime/test_server_capture.py @@ -286,6 +286,25 @@ def test_dflash_schema_names_and_no_target(self): out, _ = store.get(ref) self.assertEqual(out["input_ids"].tolist(), [list(range(1, 6))]) + def test_batch_response_wrappers_are_normalized_by_sample_id(self): + backend = _FakeMooncakeStore() + server = _StubCaptureServer(backend) + + def wrapped_server(url, json_body, timeout): + rows = server(url, json_body, timeout) + results = [row["meta_info"]["spec_capture"] for row in rows] + for row in rows: + row["meta_info"]["spec_capture"] = [results] + return [[row] for row in rows] + + _, _, _, adapter = _mk( + strategy="dflash", server=wrapped_server, backend=backend + ) + refs = adapter.produce_refs( + [_task(0, 5), _task(1, 6)], capture=_dflash_contract() + ) + self.assertTrue(all(isinstance(ref, SampleRef) for ref in refs)) + def test_aux_width_mismatch_fails_loud_and_frees_keys(self): backend = _FakeMooncakeStore() server = _StubCaptureServer(backend, aux_width=HIDDEN) # wrong width diff --git a/tests/test_runtime/test_strategy_registry.py b/tests/test_runtime/test_strategy_registry.py index 11bd78e7e..45616b90b 100644 --- a/tests/test_runtime/test_strategy_registry.py +++ b/tests/test_runtime/test_strategy_registry.py @@ -14,7 +14,9 @@ import unittest -from specforge.runtime import launch +import torch + +from specforge import launch from specforge.training.strategies.base import DFlashTrainStrategy, Eagle3TrainStrategy from specforge.training.strategies.registry import ( available_strategies, @@ -32,11 +34,12 @@ def test_dflash_fully_wired(self): self.assertEqual( spec.required_features, frozenset(DFlashTrainStrategy.required_features) ) - # offline (reader/transform/collate) + online (adapter) both wired + # offline (reader/transform/collate) + online (feature schema) both wired self.assertIsNotNone(spec.make_offline_reader) self.assertIsNotNone(spec.make_offline_transform) self.assertIsNotNone(spec.make_offline_collate) - self.assertIsNotNone(spec.make_adapter) + self.assertIsNotNone(spec.feature_schema) + self.assertIsNone(spec.feature_schema.target_feature) # no target distribution self.assertTrue(spec.supports_online) # DFlash owns its own (frozen) head -> builders pass target_head=None self.assertFalse(spec.uses_target_head) @@ -50,13 +53,33 @@ class _M: def test_required_features_track_the_strategy_class(self): # The registry's required_features is the single source of truth wired - # into CaptureConfig.from_strategy + loader validation; it must equal the + # into FeatureContract.from_strategy + loader validation; it must equal the # strategy class's own declaration. self.assertEqual( resolve_strategy("eagle3").required_features, frozenset(Eagle3TrainStrategy.required_features), ) + def test_dflash_and_domino_online_collate_pad_ragged_sequences(self): + short = { + "input_ids": torch.tensor([[1, 2, 3]]), + "loss_mask": torch.tensor([[1, 1, 1]]), + "hidden_states": torch.ones(1, 3, 4), + } + long = { + "input_ids": torch.tensor([[4, 5, 6, 7, 8]]), + "loss_mask": torch.tensor([[1, 1, 1, 1, 1]]), + "hidden_states": torch.ones(1, 5, 4), + } + for name in ("dflash", "domino"): + with self.subTest(strategy=name): + batch = resolve_strategy(name).make_online_collate()([short, long]) + self.assertEqual(batch["input_ids"].shape, (2, 5)) + self.assertEqual(batch["hidden_states"].shape, (2, 5, 4)) + self.assertEqual(batch["loss_mask"][0].tolist(), [1, 1, 1, 0, 0]) + self.assertEqual(batch["input_ids"][0].tolist(), [1, 2, 3, 0, 0]) + self.assertTrue(torch.all(batch["hidden_states"][0, 3:] == 0)) + def test_eagle3_is_fully_wired(self): spec = resolve_strategy("eagle3") self.assertTrue(spec.supports_online) From 3063d4aaca7f1ccdfe74c199f99d373a432a72a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=BD=B3=E5=B9=B3?= Date: Thu, 9 Jul 2026 11:33:20 +0800 Subject: [PATCH 11/24] 1. dflash family draft model abstract 2. del some test code to increase speed 3. refactor code without influence training --- examples/disagg/_dflash_family_disagg.py | 474 +++++++++++++++++ examples/disagg/run_disagg_dflash.py | 316 +----------- examples/disagg/run_disagg_domino.py | 277 ++-------- .../run_qwen3_8b_domino_disagg_multiserver.sh | 271 +++++----- specforge/launch.py | 477 ++++++++++++++++-- specforge/runtime/control_plane/controller.py | 4 +- .../data_plane/streaming_ref_channel.py | 18 + specforge/tracker.py | 3 +- specforge/training/strategies/registry.py | 2 + 9 files changed, 1129 insertions(+), 713 deletions(-) create mode 100644 examples/disagg/_dflash_family_disagg.py diff --git a/examples/disagg/_dflash_family_disagg.py b/examples/disagg/_dflash_family_disagg.py new file mode 100644 index 000000000..c074d880f --- /dev/null +++ b/examples/disagg/_dflash_family_disagg.py @@ -0,0 +1,474 @@ +"""Shared helpers for DFlash-family disaggregated online examples. + +DFlash and Domino share the same server-capture feature schema: +``input_ids`` + ``loss_mask`` + captured ``hidden_states``. This module keeps the +common producer/consumer plumbing in one place while entry points still own the +algorithm-specific composite model and strategy kwargs. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import time +from dataclasses import dataclass +from typing import Dict, Iterable, Optional, Tuple + +import torch +from transformers import AutoConfig, AutoTokenizer + +from specforge.inference.adapters.server_capture import SGLangServerCaptureAdapter +from specforge.launch import build_disagg_online_consumer, build_disagg_online_producer +from specforge.modeling.draft.dflash import DFlashDraftModel +from specforge.modeling.target.target_utils import TargetEmbeddingsAndHead +from specforge.optimizer import BF16Optimizer +from specforge.runtime.data_plane.mooncake_store import MooncakeFeatureStore +from specforge.runtime.data_plane.streaming_ref_channel import StreamingRefChannel +from specforge.tracker import create_tracker +from specforge.utils import get_local_device, print_on_rank0 + + +@dataclass(frozen=True) +class CaptureConfig: + aux_layer_ids: Tuple[int, ...] + target_hidden_size: int + + +@dataclass(frozen=True) +class ConsumerParts: + draft_model: DFlashDraftModel + mask_token_id: int + target_components: TargetEmbeddingsAndHead + + +def log_event(component: str, message: str) -> None: + print(f"[{component}] {time.strftime('%Y-%m-%d %H:%M:%S')} {message}", flush=True) + + +def elapsed(start: float) -> str: + return f"{time.perf_counter() - start:.3f}s" + + +def safe_len(value) -> str: + try: + return str(len(value)) + except TypeError: + return "unknown" + + +def disagg_role() -> str: + role = os.environ.get("DISAGG_ROLE") + if role: + return role + return "producer" if os.environ.get("RCLI_NODE_RANK", "0") == "0" else "consumer" + + +def env_int(name: str, default: int) -> int: + return int(os.environ.get(name, str(default))) + + +def ref_channel() -> StreamingRefChannel: + return StreamingRefChannel(os.environ["DISAGG_REF_CHANNEL"]) + + +def mooncake_store(run_id: str) -> MooncakeFeatureStore: + return MooncakeFeatureStore( + store_id=run_id, + setup_kwargs={ + "local_hostname": os.environ.get("MOONCAKE_LOCAL_HOSTNAME", "127.0.0.1"), + "metadata_server": os.environ["MOONCAKE_METADATA_SERVER"], + "master_server_addr": os.environ["MOONCAKE_MASTER_SERVER_ADDR"], + "protocol": os.environ.get("MOONCAKE_PROTOCOL", "tcp"), + "rdma_devices": os.environ.get("MOONCAKE_RDMA_DEVICES", ""), + "global_segment_size": int(os.environ.get("DISAGG_CLIENT_SEGMENT_SIZE", 0)), + "local_buffer_size": int( + os.environ.get("DISAGG_CLIENT_BUFFER_SIZE", 1 << 30) + ), + }, + ) + + +def server_urls() -> list: + urls = os.environ.get("DISAGG_SERVER_URLS") + if urls: + return [u.strip() for u in urls.split(",") if u.strip()] + return [os.environ["DISAGG_SERVER_URL"]] + + +def load_capture_config(args) -> CaptureConfig: + with open(args.draft_config_path) as f: + draft_cfg = json.load(f) + return CaptureConfig( + aux_layer_ids=tuple(draft_cfg["dflash_config"]["target_layer_ids"]), + target_hidden_size=int(draft_cfg["hidden_size"]), + ) + + +def build_producer_prompts(args, tokenizer, *, max_prompts: int = 0): + from datasets import load_dataset + from specforge.data import build_eagle3_dataset + + total_start = time.perf_counter() + phase = time.perf_counter() + log_event("producer-timing", f"load_dataset start path={args.train_data_path}") + dataset = load_dataset("json", data_files=args.train_data_path)["train"] + log_event( + "producer-timing", + f"load_dataset done rows={safe_len(dataset)} elapsed={elapsed(phase)}", + ) + + cache_scope = "all" + if max_prompts > 0 and max_prompts < len(dataset): + phase = time.perf_counter() + log_event("producer-timing", f"select start max_prompts={max_prompts}") + dataset = dataset.select(range(max_prompts)) + cache_scope = f"first-{max_prompts}" + log_event( + "producer-timing", + f"select done rows={safe_len(dataset)} elapsed={elapsed(phase)}", + ) + + cache_key_parts = ( + f"{args.train_data_path}-{args.max_length}-{args.chat_template}-" + f"{args.target_model_path}" + ) + if cache_scope != "all": + cache_key_parts = f"{cache_key_parts}-{cache_scope}" + cache_key = hashlib.md5(cache_key_parts.encode()).hexdigest() + + phase = time.perf_counter() + log_event( + "producer-timing", + "build_eagle3_dataset start " + f"rows={safe_len(dataset)} cache_key={cache_key} " + f"num_proc={args.build_dataset_num_proc}", + ) + dataset = build_eagle3_dataset( + dataset=dataset, + tokenizer=tokenizer, + chat_template=args.chat_template, + max_length=args.max_length, + is_preformatted=args.is_preformatted, + cache_dir=os.path.join(args.cache_dir, "processed_dataset"), + cache_key=cache_key, + num_proc=args.build_dataset_num_proc, + ) + log_event( + "producer-timing", + f"build_eagle3_dataset done rows={safe_len(dataset)} elapsed={elapsed(phase)}", + ) + + phase = time.perf_counter() + log_event( + "producer-timing", + f"filter start rows={safe_len(dataset)} block_size={args.block_size}", + ) + dataset = dataset.filter(lambda x: x["loss_mask"].sum() >= 2 * args.block_size) + log_event( + "producer-timing", + f"filter done rows={safe_len(dataset)} elapsed={elapsed(phase)}", + ) + + prompts = [] + total_rows = safe_len(dataset) + log_every = max(0, env_int("DISAGG_PROMPT_LOG_EVERY", 10000)) + phase = time.perf_counter() + last_log = phase + total_tokens = 0 + log_event( + "producer-timing", + f"materialize prompts start rows={total_rows} log_every={log_every}", + ) + for idx, row in enumerate(dataset, start=1): + input_ids, loss_mask = row["input_ids"][0], row["loss_mask"][0] + attn = row.get("attention_mask") + n = int(attn[0].sum().item()) if attn is not None else input_ids.shape[0] + total_tokens += int(n) + prompts.append( + { + "payload": { + "input_ids": input_ids[:n].tolist(), + "loss_mask": loss_mask[:n].tolist(), + } + } + ) + if log_every and (idx == 1 or idx % log_every == 0): + now = time.perf_counter() + log_event( + "producer-timing", + "materialize prompts progress " + f"{idx}/{total_rows} total_tokens={total_tokens} " + f"elapsed={now - phase:.3f}s interval={now - last_log:.3f}s", + ) + last_log = now + + log_event( + "producer-timing", + "materialize prompts done " + f"prompts={len(prompts)} total_tokens={total_tokens} " + f"elapsed={elapsed(phase)} total_elapsed={elapsed(total_start)}", + ) + return prompts + + +def run_server_capture_producer(strategy: str, args, run_id: str) -> None: + total_start = time.perf_counter() + log_event("producer-timing", f"run_producer start run_id={run_id}") + + phase = time.perf_counter() + log_event("producer-timing", f"tokenizer load start path={args.target_model_path}") + tokenizer = AutoTokenizer.from_pretrained( + args.target_model_path, trust_remote_code=args.trust_remote_code + ) + log_event("producer-timing", f"tokenizer load done elapsed={elapsed(phase)}") + + max_prompts = env_int("DISAGG_MAX_PROMPTS", 0) + phase = time.perf_counter() + prompts = build_producer_prompts(args, tokenizer, max_prompts=max_prompts) + if max_prompts > 0 and len(prompts) > max_prompts: + log_event( + "producer-timing", + f"final prompt slice start prompts={len(prompts)} max_prompts={max_prompts}", + ) + prompts = prompts[:max_prompts] + log_event( + "producer-timing", + f"build_producer_prompts returned prompts={len(prompts)} elapsed={elapsed(phase)}", + ) + + phase = time.perf_counter() + log_event( + "producer-timing", f"draft config load start path={args.draft_config_path}" + ) + capture_cfg = load_capture_config(args) + log_event("producer-timing", f"draft config load done elapsed={elapsed(phase)}") + + phase = time.perf_counter() + log_event("producer-timing", "mooncake store init start") + store = mooncake_store(run_id) + log_event("producer-timing", f"mooncake store init done elapsed={elapsed(phase)}") + + phase = time.perf_counter() + log_event("producer-timing", "ref channel init start") + channel = ref_channel() + log_event("producer-timing", f"ref channel init done elapsed={elapsed(phase)}") + + urls = server_urls() + phase = time.perf_counter() + log_event("producer-timing", f"adapter init start urls={urls}") + adapters = [ + SGLangServerCaptureAdapter( + url, + store, + run_id=run_id, + strategy=strategy, + target_model_version=args.target_model_path, + ) + for url in urls + ] + log_event("producer-timing", f"adapter init done elapsed={elapsed(phase)}") + print(f"[producer] {len(prompts)} prompts -> {len(urls)} server(s): {urls}") + + phase = time.perf_counter() + log_event("producer-timing", "build_disagg_online_producer start") + _workers, drive_producer = build_disagg_online_producer( + strategy=strategy, + feature_source=adapters if len(adapters) > 1 else adapters[0], + prompts=prompts, + feature_store=store, + channel=channel, + run_id=run_id, + target_hidden_size=capture_cfg.target_hidden_size, + target_repr=None, + aux_hidden_state_layer_ids=capture_cfg.aux_layer_ids, + ) + log_event( + "producer-timing", + f"build_disagg_online_producer done elapsed={elapsed(phase)}", + ) + + phase = time.perf_counter() + log_event("producer-timing", "drive_producer start") + produced = drive_producer() + log_event("producer-timing", f"drive_producer done elapsed={elapsed(phase)}") + print(f"[producer] streamed {produced} samples; channel closed", flush=True) + log_event( + "producer-timing", f"run_producer done total_elapsed={elapsed(total_start)}" + ) + + +def resolve_mask_token(args, tokenizer) -> int: + if args.mask_token_id is not None: + return args.mask_token_id + if tokenizer.mask_token_id is not None: + return tokenizer.mask_token_id + tokenizer.add_special_tokens({"mask_token": "<|MASK|>"}) + return tokenizer.mask_token_id + + +def build_dflash_family_draft( + args, + *, + expected_projector_type: Optional[str] = None, + required_dflash_fields: Iterable[str] = (), +) -> DFlashDraftModel: + device = get_local_device() + if args.draft_config_path: + draft_config = AutoConfig.from_pretrained(args.draft_config_path) + print_on_rank0(f"Loaded draft config from {args.draft_config_path}") + if ( + hasattr(draft_config, "block_size") + and draft_config.block_size != args.block_size + ): + print_on_rank0( + f"Warning: checkpoint block_size ({draft_config.block_size}) differs from " + f"command-line arg ({args.block_size}). Using checkpoint value." + ) + else: + target_config = AutoConfig.from_pretrained(args.target_model_path) + draft_config = AutoConfig.from_pretrained(args.target_model_path) + draft_config.num_hidden_layers = args.num_draft_layers + draft_config.block_size = args.block_size + draft_config.num_target_layers = target_config.num_hidden_layers + print_on_rank0("Auto-generated draft config from target model config") + + if not hasattr(draft_config, "dflash_config") or draft_config.dflash_config is None: + draft_config.dflash_config = {} + + dflash_config = draft_config.dflash_config + if expected_projector_type is not None: + projector_type = dflash_config.get("projector_type", None) + if projector_type != expected_projector_type: + raise ValueError( + f"Expected dflash_config.projector_type={expected_projector_type!r}, " + f"got {projector_type!r}." + ) + missing = sorted(set(required_dflash_fields) - set(dflash_config)) + if missing: + raise ValueError(f"Draft config missing dflash_config fields: {missing}") + + draft_config._attn_implementation = args.attention_backend + print_on_rank0(f"Using attention backend: {args.attention_backend}") + + draft_model = DFlashDraftModel(draft_config).to(device=device, dtype=torch.bfloat16) + print_on_rank0( + f"Draft config: block_size={draft_config.block_size}, " + f"num_hidden_layers={draft_config.num_hidden_layers}, " + f"num_target_layers={draft_config.num_target_layers}" + ) + print_on_rank0( + f"Draft model parameters: {sum(p.numel() for p in draft_model.parameters()):,}" + ) + return draft_model + + +def build_consumer_parts( + args, + *, + expected_projector_type: Optional[str] = None, + required_dflash_fields: Iterable[str] = (), +) -> ConsumerParts: + tokenizer = AutoTokenizer.from_pretrained( + args.target_model_path, trust_remote_code=args.trust_remote_code + ) + draft_model = build_dflash_family_draft( + args, + expected_projector_type=expected_projector_type, + required_dflash_fields=required_dflash_fields, + ) + mask_token_id = resolve_mask_token(args, tokenizer) + draft_model.mask_token_id = mask_token_id + draft_model.config.dflash_config["mask_token_id"] = mask_token_id + draft_model.config.dflash_config["target_layer_ids"] = draft_model.target_layer_ids + + target_components = TargetEmbeddingsAndHead.from_pretrained( + args.target_model_path, + embed_key=args.embedding_key, + lm_head_key=args.lm_head_key, + device="cuda", + trust_remote_code=args.trust_remote_code, + ) + return ConsumerParts( + draft_model=draft_model, + mask_token_id=mask_token_id, + target_components=target_components, + ) + + +def _optimizer_factory(args, total_steps: int): + def factory(draft_module): + return BF16Optimizer( + draft_module, + lr=args.learning_rate, + max_grad_norm=args.max_grad_norm, + warmup_ratio=args.warmup_ratio, + total_steps=total_steps, + ) + + return factory + + +def _make_logger(args): + tracker = create_tracker(args, args.output_dir) + + def logger(metrics, step): + logdict = {} + for key, value in metrics.items(): + try: + logdict[f"train/{key}"] = float(value) + except (TypeError, ValueError): + try: + seq = [float(x) for x in value] + except (TypeError, ValueError): + continue + if seq: + logdict[f"train/{key}_mean"] = sum(seq) / len(seq) + if logdict: + tracker.log(logdict, step=step) + summary = {k.split("/")[-1]: round(v, 4) for k, v in logdict.items()} + print(f"[consumer] step {step} {summary}", flush=True) + + return logger + + +def fit_online_consumer( + strategy: str, + args, + run_id: str, + composite_model, + *, + strategy_kwargs: Optional[Dict] = None, +) -> None: + max_steps = env_int("DISAGG_MAX_STEPS", 0) or None + total_steps = env_int("DISAGG_TOTAL_STEPS", 0) or max_steps or 10_000 + + print(f"[consumer] training from mooncake://{run_id}", flush=True) + trainer, loader = build_disagg_online_consumer( + strategy=strategy, + feature_store=mooncake_store(run_id), + channel=ref_channel(), + eagle3_model=composite_model, # legacy param name = composite draft model + optimizer_factory=_optimizer_factory(args, total_steps), + run_id=run_id, + output_dir=args.output_dir, + batch_size=args.batch_size, + accumulation_steps=args.accumulation_steps, + num_epochs=args.num_epochs, + max_steps=max_steps, + total_steps=total_steps, + save_interval=args.save_interval, + tp_size=args.tp_size, + metadata_db_path=os.environ.get("DISAGG_DB") or None, + logger=_make_logger(args), + log_interval=env_int("DISAGG_LOG_INTERVAL", 1), + strategy_kwargs=strategy_kwargs, + inbox_dir=os.environ.get("DISAGG_INBOX_DIR") or None, + idle_timeout_s=float(os.environ.get("DISAGG_IDLE_TIMEOUT", 0)) or None, + ) + try: + trainer.fit(loader) + finally: + if getattr(trainer, "ref_distributor", None) is not None: + trainer.ref_distributor.stop() + print(f"[consumer] DONE ({run_id})", flush=True) diff --git a/examples/disagg/run_disagg_dflash.py b/examples/disagg/run_disagg_dflash.py index 4812b5c9a..654491bba 100644 --- a/examples/disagg/run_disagg_dflash.py +++ b/examples/disagg/run_disagg_dflash.py @@ -1,335 +1,57 @@ -"""Disaggregated ONLINE DFlash example — real server-capture zero-copy transport. +"""Disaggregated ONLINE DFlash over patched SGLang server-capture.""" -Inference and training are fully decoupled processes that share only a Mooncake -object store (RDMA-capable, no shared *data* mount): - -* **producer** (inference pool): a live SGLang server — patched with - ``patches/sglang/v0.5.14/spec-capture.patch`` and launched with - ``--enable-spec-capture`` — runs the frozen Qwen3.6-27B target and writes the - captured DFlash context hidden states straight into Mooncake. This process is - a thin driver: it sends prompts through ``SGLangServerCaptureAdapter`` and - streams the resulting tensor-free ``SampleRef``s to the consumer. No target - weights, no draft, no GPU model here. -* **consumer** (training pool): trains the DFlash draft (``OnlineDFlashModel``), - reading refs off the channel and features from Mooncake — zero re-copy. - -This replaces the old in-process producer (which loaded the 27B target locally -and ran staged, since DFlash's HF capture is not thread-safe): the server does -the capture, so a single prefill per prompt feeds training directly. It is the -disaggregated sibling of ``examples/run_qwen3.6_27b_dflash_online.sh``. - -The producer is CPU-only (no torch.distributed, no GPU); the consumer runs -under torchrun and data-parallel-shards the stream across its ranks: rank 0 -hosts the RefDistributor (the run's single ledger + round-robin dispatch to -per-rank inboxes), gradients sync via FSDP. - -Config is environment-driven so one wrapper drives both roles; role is -``DISAGG_ROLE`` (``producer``/``consumer``) or derived from ``RCLI_NODE_RANK``: - - DISAGG_ROLE=producer|consumer # else rank 0 -> producer, else consumer - DISAGG_SERVER_URL=http://host:30000 # the patched SGLang server (producer) - DISAGG_SERVER_URLS=http://h1:30000,http://h2:30000 # MULTI-server fan-out - # (comma-separated; overrides SERVER_URL; - # one adapter+worker per server, driven - # concurrently over disjoint prompts) - DISAGG_STORE_ID=qwen36-dflash-disagg # Mooncake key namespace (both roles) - DISAGG_REF_CHANNEL=/root/disagg/refs.jsonl # tiny ref manifest (both roles) - DISAGG_DB=/root/disagg/run.db # consumer rank-0 ledger (trainer-side ONLY) - DISAGG_MAX_PROMPTS=400 # cap the prompt pool (0 = all) - DISAGG_MAX_STEPS=0 # trainer max optimizer steps (0 = all) - -Mooncake connection uses the standard ``MOONCAKE_*`` env vars (see -``examples/disagg/README.md``); the server sink reads the same ones, so both -sides land on one master. Export ``WANDB_API_KEY`` for consumer W&B logging. -""" - -import hashlib -import json import os import sys import torch if not torch.cuda.is_available(): - # GPU-less process (the CPU producer): yunchang probes CUDA at import time - # whenever flashinfer is importable (yunchang.globals.get_cuda_arch) and - # raises; hiding flashinfer trips its clean ImportError fallback instead. + # CPU producer: keep yunchang/flashinfer CUDA probes on the clean fallback. sys.modules["flashinfer"] = None +from _dflash_family_disagg import ( + build_consumer_parts, + disagg_role, + fit_online_consumer, + run_server_capture_producer, +) from accelerate.utils import set_seed -from train_dflash import build_models, parse_args -from transformers import AutoTokenizer +from train_dflash import parse_args from specforge.core.dflash import OnlineDFlashModel from specforge.distributed import destroy_distributed, init_distributed -from specforge.inference.adapters.server_capture import SGLangServerCaptureAdapter -from specforge.launch import build_disagg_online_consumer, build_disagg_online_producer -from specforge.modeling.target.target_utils import TargetEmbeddingsAndHead -from specforge.optimizer import BF16Optimizer -from specforge.runtime.data_plane.mooncake_store import MooncakeFeatureStore -from specforge.runtime.data_plane.streaming_ref_channel import StreamingRefChannel -from specforge.tracker import create_tracker +STRATEGY = "dflash" RUN_ID = os.environ.get("DISAGG_STORE_ID", "qwen36-dflash-disagg") -def _role() -> str: - role = os.environ.get("DISAGG_ROLE") - if role: - return role - return "producer" if os.environ.get("RCLI_NODE_RANK", "0") == "0" else "consumer" - - -def _max(name: str, default: int) -> int: - return int(os.environ.get(name, str(default))) - - -def _ref_channel() -> StreamingRefChannel: - return StreamingRefChannel(os.environ["DISAGG_REF_CHANNEL"]) - - -def _mooncake_store() -> MooncakeFeatureStore: - """Connect to the same Mooncake master the server sink writes to. - - global_segment_size=0: producer/consumer are pure clients contributing NO - storage — every object lives in the long-lived server process's segment, so - a producer exit cannot take committed features with it. Size the SERVER's - segment (MOONCAKE_GLOBAL_SEGMENT_SIZE) for the in-flight watermark: objects - are hard-pinned, so an undersized segment fails puts rather than evicting. - """ - return MooncakeFeatureStore( - store_id=RUN_ID, - setup_kwargs={ - "local_hostname": os.environ.get("MOONCAKE_LOCAL_HOSTNAME", "127.0.0.1"), - "metadata_server": os.environ["MOONCAKE_METADATA_SERVER"], - "master_server_addr": os.environ["MOONCAKE_MASTER_SERVER_ADDR"], - "protocol": os.environ.get("MOONCAKE_PROTOCOL", "tcp"), - "rdma_devices": os.environ.get("MOONCAKE_RDMA_DEVICES", ""), - "global_segment_size": int(os.environ.get("DISAGG_CLIENT_SEGMENT_SIZE", 0)), - "local_buffer_size": int( - os.environ.get("DISAGG_CLIENT_BUFFER_SIZE", 1 << 30) - ), - }, - ) - - -def _producer_prompts(args, tokenizer): - """Tokenized prompts straight off the dataset — no DataLoader, no dist. - - The producer only feeds prompts to the server, so it skips - ``prepare_dp_dataloaders`` (whose DistributedSampler needs a process group) - and stays CPU-only. Same tokenize/template/cache + min-loss filter as - ``train_dflash.build_dataloader``. - """ - from datasets import load_dataset - from specforge.data import build_eagle3_dataset - - cache_key = hashlib.md5( - f"{args.train_data_path}-{args.max_length}-{args.chat_template}-" - f"{args.target_model_path}".encode() - ).hexdigest() - dataset = load_dataset("json", data_files=args.train_data_path)["train"] - dataset = build_eagle3_dataset( - dataset=dataset, - tokenizer=tokenizer, - chat_template=args.chat_template, - max_length=args.max_length, - is_preformatted=args.is_preformatted, - cache_dir=os.path.join(args.cache_dir, "processed_dataset"), - cache_key=cache_key, - num_proc=args.build_dataset_num_proc, - ) - dataset = dataset.filter(lambda x: x["loss_mask"].sum() >= 2 * args.block_size) - - prompts = [] - for row in dataset: # rows are (1, L) tensors - input_ids, loss_mask = row["input_ids"][0], row["loss_mask"][0] - attn = row.get("attention_mask") - n = int(attn[0].sum().item()) if attn is not None else input_ids.shape[0] - prompts.append( - { - "payload": { - "input_ids": input_ids[:n].tolist(), - "loss_mask": loss_mask[:n].tolist(), - } - } - ) - return prompts - - -def _resolve_mask_token(args, tokenizer, draft_model=None) -> int: - if args.mask_token_id is not None: - return args.mask_token_id - draft_config = getattr(getattr(draft_model, "config", None), "dflash_config", {}) - if draft_config and draft_config.get("mask_token_id") is not None: - return draft_config["mask_token_id"] - if tokenizer.mask_token_id is not None: - return tokenizer.mask_token_id - tokenizer.add_special_tokens({"mask_token": "<|MASK|>"}) - return tokenizer.mask_token_id - - -def _server_urls() -> list: - """The patched-server pool: DISAGG_SERVER_URLS (comma list) or the single - DISAGG_SERVER_URL. Every server must run the SAME model/patch flags (the - FeatureContract check fails loud per-sample on a heterogeneous pool).""" - urls = os.environ.get("DISAGG_SERVER_URLS") - if urls: - return [u.strip() for u in urls.split(",") if u.strip()] - return [os.environ["DISAGG_SERVER_URL"]] - - def run_producer(args) -> None: - """Thin CPU driver: prompts -> patched server(s) (capture to Mooncake) -> refs.""" - tokenizer = AutoTokenizer.from_pretrained( - args.target_model_path, trust_remote_code=args.trust_remote_code - ) - prompts = _producer_prompts(args, tokenizer) - max_prompts = _max("DISAGG_MAX_PROMPTS", 0) - if max_prompts: - prompts = prompts[:max_prompts] - - # DFlash captures the concatenation of these target layers; the shell passes - # the same list to the server's --spec-capture-aux-layer-ids. - draft_cfg = json.load(open(args.draft_config_path)) - aux_layer_ids = tuple(draft_cfg["dflash_config"]["target_layer_ids"]) - target_hidden = int(draft_cfg["hidden_size"]) - - store = _mooncake_store() - channel = _ref_channel() - # One adapter per server, all over the ONE store instance (thread-safe): - # each adapter gets its own RolloutWorker leasing disjoint prompts, so N - # servers prefill concurrently into the same Mooncake namespace. - urls = _server_urls() - adapters = [ - SGLangServerCaptureAdapter( - url, - store, - run_id=RUN_ID, - strategy="dflash", - target_model_version=args.target_model_path, - ) - for url in urls - ] - print(f"[producer] {len(prompts)} prompts -> {len(urls)} server(s): {urls}") - - # NO shared db: the trainer-side ledger (DISAGG_DB) is the run's single - # book-keeper; committed rows from the producer would make the consumer - # distributor's commit-dedup drop every ref. The producer's private - # in-memory store still dedups within its own process. - _workers, drive_producer = build_disagg_online_producer( - strategy="dflash", - feature_source=adapters if len(adapters) > 1 else adapters[0], - prompts=prompts, - feature_store=store, - channel=channel, - run_id=RUN_ID, - target_hidden_size=target_hidden, - target_repr=None, # DFlash trains on captured hidden states, no target dist. - aux_hidden_state_layer_ids=aux_layer_ids, - ) - produced = drive_producer() - print(f"[producer] streamed {produced} samples; channel closed", flush=True) + run_server_capture_producer(STRATEGY, args, RUN_ID) def run_consumer(args) -> None: - """Training pool: train the DFlash draft off Mooncake + the ref channel.""" - tokenizer = AutoTokenizer.from_pretrained(args.target_model_path) - _target_unused, draft_model = build_models(args) - mask_token_id = _resolve_mask_token(args, tokenizer, draft_model) - draft_model.mask_token_id = mask_token_id - draft_model.config.dflash_config["mask_token_id"] = mask_token_id - draft_model.config.dflash_config["target_layer_ids"] = draft_model.target_layer_ids - - target_components = TargetEmbeddingsAndHead.from_pretrained( - args.target_model_path, - embed_key=args.embedding_key, - lm_head_key=args.lm_head_key, - device="cuda", - trust_remote_code=args.trust_remote_code, - ) + parts = build_consumer_parts(args) dflash_model = OnlineDFlashModel( - draft_model=draft_model, - target_lm_head=target_components.lm_head, - target_embed_tokens=target_components.embed_tokens, - mask_token_id=mask_token_id, - block_size=draft_model.block_size, + draft_model=parts.draft_model, + target_lm_head=parts.target_components.lm_head, + target_embed_tokens=parts.target_components.embed_tokens, + mask_token_id=parts.mask_token_id, + block_size=parts.draft_model.block_size, attention_backend=args.attention_backend, num_anchors=args.num_anchors, loss_decay_gamma=args.loss_decay_gamma, loss_type=args.loss_type, dpace_alpha=args.dpace_alpha, ) - - max_steps = _max("DISAGG_MAX_STEPS", 0) or None - - def optimizer_factory(draft_module): - return BF16Optimizer( - draft_module, - lr=args.learning_rate, - max_grad_norm=args.max_grad_norm, - warmup_ratio=args.warmup_ratio, - total_steps=max_steps or 10_000, - ) - - tracker = create_tracker(args, args.output_dir) - - def logger(metrics, step): - logdict = {} - for key, value in metrics.items(): - try: - logdict[f"train/{key}"] = float(value) - except (TypeError, ValueError): - try: # per-TTT-position list metrics -> log the mean - seq = [float(x) for x in value] - except (TypeError, ValueError): - continue - if seq: - logdict[f"train/{key}_mean"] = sum(seq) / len(seq) - if logdict: - tracker.log(logdict, step=step) - summary = {k.split("/")[-1]: round(v, 4) for k, v in logdict.items()} - print(f"[consumer] step {step} {summary}", flush=True) - - print(f"[consumer] training from mooncake://{RUN_ID}", flush=True) - # dp_rank/dp_size default from the torchrun world: rank 0 hosts the - # RefDistributor (single ledger + per-rank inbox dispatch). - trainer, loader = build_disagg_online_consumer( - strategy="dflash", - feature_store=_mooncake_store(), - channel=_ref_channel(), - eagle3_model=dflash_model, # legacy param name = the trainable draft model - optimizer_factory=optimizer_factory, - run_id=RUN_ID, - output_dir=args.output_dir, - batch_size=args.batch_size, - accumulation_steps=args.accumulation_steps, - num_epochs=args.num_epochs, - max_steps=max_steps, - save_interval=args.save_interval, - tp_size=args.tp_size, - metadata_db_path=os.environ.get("DISAGG_DB") or None, - logger=logger, - log_interval=_max("DISAGG_LOG_INTERVAL", 1), - inbox_dir=os.environ.get("DISAGG_INBOX_DIR") or None, - # liveness guard against a dead producer/server (DP default: 1800s) - idle_timeout_s=float(os.environ.get("DISAGG_IDLE_TIMEOUT", 0)) or None, - ) - try: - trainer.fit(loader) - finally: - if getattr(trainer, "ref_distributor", None) is not None: - trainer.ref_distributor.stop() # clean wind-down on max_steps exit - print(f"[consumer] DONE ({RUN_ID})", flush=True) + fit_online_consumer(STRATEGY, args, RUN_ID, dflash_model) def main() -> None: args = parse_args() set_seed(args.seed) - role = _role() + role = disagg_role() print(f"[disagg-dflash] role={role} run_id={RUN_ID}", flush=True) if role == "producer": - # CPU-only: no model, no collectives — plain `python`, no torchrun. run_producer(args) return init_distributed(timeout=args.dist_timeout, tp_size=args.tp_size) diff --git a/examples/disagg/run_disagg_domino.py b/examples/disagg/run_disagg_domino.py index 766193cae..367e23ccb 100644 --- a/examples/disagg/run_disagg_domino.py +++ b/examples/disagg/run_disagg_domino.py @@ -1,14 +1,5 @@ -"""Disaggregated ONLINE Domino example over patched SGLang server-capture. +"""Disaggregated ONLINE Domino over patched SGLang server-capture.""" -Domino reuses the DFlash capture schema (``hidden_states`` + hard labels), but -trains ``OnlineDominoModel`` with the Domino strategy and lambda-base schedule. -The producer is CPU-only and drives one or more patched SGLang servers; the -consumer is the training pool reading refs from the channel and tensors from -Mooncake. -""" - -import hashlib -import json import os import sys @@ -18,271 +9,65 @@ # CPU producer: keep yunchang/flashinfer CUDA probes on the clean fallback. sys.modules["flashinfer"] = None +from _dflash_family_disagg import ( + build_consumer_parts, + disagg_role, + fit_online_consumer, + run_server_capture_producer, +) from accelerate.utils import set_seed from train_domino import parse_args -from transformers import AutoConfig, AutoTokenizer from specforge.core.domino import OnlineDominoModel from specforge.distributed import destroy_distributed, init_distributed -from specforge.inference.adapters.server_capture import SGLangServerCaptureAdapter -from specforge.launch import build_disagg_online_consumer, build_disagg_online_producer -from specforge.modeling.draft.dflash import DFlashDraftModel -from specforge.modeling.target.target_utils import TargetEmbeddingsAndHead -from specforge.optimizer import BF16Optimizer -from specforge.runtime.data_plane.mooncake_store import MooncakeFeatureStore -from specforge.runtime.data_plane.streaming_ref_channel import StreamingRefChannel -from specforge.tracker import create_tracker +STRATEGY = "domino" RUN_ID = os.environ.get("DISAGG_STORE_ID", "qwen3-8b-domino-disagg") - - -def _role() -> str: - role = os.environ.get("DISAGG_ROLE") - if role: - return role - return "producer" if os.environ.get("RCLI_NODE_RANK", "0") == "0" else "consumer" - - -def _max(name: str, default: int) -> int: - return int(os.environ.get(name, str(default))) - - -def _ref_channel() -> StreamingRefChannel: - return StreamingRefChannel(os.environ["DISAGG_REF_CHANNEL"]) - - -def _mooncake_store() -> MooncakeFeatureStore: - return MooncakeFeatureStore( - store_id=RUN_ID, - setup_kwargs={ - "local_hostname": os.environ.get("MOONCAKE_LOCAL_HOSTNAME", "127.0.0.1"), - "metadata_server": os.environ["MOONCAKE_METADATA_SERVER"], - "master_server_addr": os.environ["MOONCAKE_MASTER_SERVER_ADDR"], - "protocol": os.environ.get("MOONCAKE_PROTOCOL", "tcp"), - "rdma_devices": os.environ.get("MOONCAKE_RDMA_DEVICES", ""), - "global_segment_size": int(os.environ.get("DISAGG_CLIENT_SEGMENT_SIZE", 0)), - "local_buffer_size": int( - os.environ.get("DISAGG_CLIENT_BUFFER_SIZE", 1 << 30) - ), - }, - ) - - -def _producer_prompts(args, tokenizer): - from datasets import load_dataset - from specforge.data import build_eagle3_dataset - - cache_key = hashlib.md5( - f"{args.train_data_path}-{args.max_length}-{args.chat_template}-" - f"{args.target_model_path}".encode() - ).hexdigest() - dataset = load_dataset("json", data_files=args.train_data_path)["train"] - dataset = build_eagle3_dataset( - dataset=dataset, - tokenizer=tokenizer, - chat_template=args.chat_template, - max_length=args.max_length, - is_preformatted=args.is_preformatted, - cache_dir=os.path.join(args.cache_dir, "processed_dataset"), - cache_key=cache_key, - num_proc=args.build_dataset_num_proc, - ) - dataset = dataset.filter(lambda x: x["loss_mask"].sum() >= 2 * args.block_size) - - prompts = [] - for row in dataset: - input_ids, loss_mask = row["input_ids"][0], row["loss_mask"][0] - attn = row.get("attention_mask") - n = int(attn[0].sum().item()) if attn is not None else input_ids.shape[0] - prompts.append( - { - "payload": { - "input_ids": input_ids[:n].tolist(), - "loss_mask": loss_mask[:n].tolist(), - } - } - ) - return prompts - - -def _resolve_mask_token(args, tokenizer) -> int: - if args.mask_token_id is not None: - return args.mask_token_id - if tokenizer.mask_token_id is not None: - return tokenizer.mask_token_id - tokenizer.add_special_tokens({"mask_token": "<|MASK|>"}) - return tokenizer.mask_token_id - - -def _build_draft_model(args) -> DFlashDraftModel: - draft_config = AutoConfig.from_pretrained(args.draft_config_path) - dflash_config = getattr(draft_config, "dflash_config", {}) or {} - if dflash_config.get("projector_type") != "domino": - raise ValueError( - "Domino disagg training requires dflash_config.projector_type='domino'." - ) - required = {"emb_dim", "gru_hidden_dim", "pure_draft_prefix_len", "shift_label"} - missing = sorted(required - set(dflash_config)) - if missing: - raise ValueError(f"Domino config missing dflash_config fields: {missing}") - - draft_config._attn_implementation = args.attention_backend - device = torch.device("cuda", torch.cuda.current_device()) - draft_model = DFlashDraftModel(draft_config).to( - device=device, dtype=torch.bfloat16 - ) - print( - "[consumer] draft " - f"layers={draft_config.num_hidden_layers} " - f"target_layers={draft_model.target_layer_ids} " - f"block_size={draft_model.block_size}", - flush=True, - ) - return draft_model - - -def _server_urls() -> list: - urls = os.environ.get("DISAGG_SERVER_URLS") - if urls: - return [u.strip() for u in urls.split(",") if u.strip()] - return [os.environ["DISAGG_SERVER_URL"]] +DOMINO_DFLASH_FIELDS = ( + "emb_dim", + "gru_hidden_dim", + "pure_draft_prefix_len", + "shift_label", +) def run_producer(args) -> None: - tokenizer = AutoTokenizer.from_pretrained( - args.target_model_path, trust_remote_code=args.trust_remote_code - ) - prompts = _producer_prompts(args, tokenizer) - max_prompts = _max("DISAGG_MAX_PROMPTS", 0) - if max_prompts: - prompts = prompts[:max_prompts] - - with open(args.draft_config_path) as f: - draft_cfg = json.load(f) - aux_layer_ids = tuple(draft_cfg["dflash_config"]["target_layer_ids"]) - target_hidden = int(draft_cfg["hidden_size"]) - - store = _mooncake_store() - channel = _ref_channel() - urls = _server_urls() - adapters = [ - SGLangServerCaptureAdapter( - url, - store, - run_id=RUN_ID, - strategy="domino", - target_model_version=args.target_model_path, - ) - for url in urls - ] - print(f"[producer] {len(prompts)} prompts -> {len(urls)} server(s): {urls}") - - _workers, drive_producer = build_disagg_online_producer( - strategy="domino", - feature_source=adapters if len(adapters) > 1 else adapters[0], - prompts=prompts, - feature_store=store, - channel=channel, - run_id=RUN_ID, - target_hidden_size=target_hidden, - target_repr=None, - aux_hidden_state_layer_ids=aux_layer_ids, - ) - produced = drive_producer() - print(f"[producer] streamed {produced} samples; channel closed", flush=True) + run_server_capture_producer(STRATEGY, args, RUN_ID) def run_consumer(args) -> None: - tokenizer = AutoTokenizer.from_pretrained(args.target_model_path) - draft_model = _build_draft_model(args) - mask_token_id = _resolve_mask_token(args, tokenizer) - draft_model.mask_token_id = mask_token_id - draft_model.config.dflash_config["mask_token_id"] = mask_token_id - draft_model.config.dflash_config["target_layer_ids"] = draft_model.target_layer_ids - - target_components = TargetEmbeddingsAndHead.from_pretrained( - args.target_model_path, - embed_key=args.embedding_key, - lm_head_key=args.lm_head_key, - device="cuda", - trust_remote_code=args.trust_remote_code, + parts = build_consumer_parts( + args, + expected_projector_type="domino", + required_dflash_fields=DOMINO_DFLASH_FIELDS, ) domino_model = OnlineDominoModel( - draft_model=draft_model, - target_lm_head=target_components.lm_head, - target_embed_tokens=target_components.embed_tokens, - mask_token_id=mask_token_id, - block_size=draft_model.block_size, + draft_model=parts.draft_model, + target_lm_head=parts.target_components.lm_head, + target_embed_tokens=parts.target_components.embed_tokens, + mask_token_id=parts.mask_token_id, + block_size=parts.draft_model.block_size, attention_backend=args.attention_backend, num_anchors=args.num_anchors, loss_decay_gamma=args.loss_decay_gamma, - shift_label=draft_model.shift_label, + shift_label=parts.draft_model.shift_label, ) - - max_steps = _max("DISAGG_MAX_STEPS", 0) or None - total_steps = _max("DISAGG_TOTAL_STEPS", 0) or max_steps or 10_000 - - def optimizer_factory(draft_module): - return BF16Optimizer( - draft_module, - lr=args.learning_rate, - max_grad_norm=args.max_grad_norm, - warmup_ratio=args.warmup_ratio, - total_steps=total_steps, - ) - - tracker = create_tracker(args, args.output_dir) - - def logger(metrics, step): - logdict = {} - for key, value in metrics.items(): - try: - logdict[f"train/{key}"] = float(value) - except (TypeError, ValueError): - continue - if logdict: - tracker.log(logdict, step=step) - summary = {k.split("/")[-1]: round(v, 4) for k, v in logdict.items()} - print(f"[consumer] step {step} {summary}", flush=True) - - print(f"[consumer] training from mooncake://{RUN_ID}", flush=True) - trainer, loader = build_disagg_online_consumer( - strategy="domino", - feature_store=_mooncake_store(), - channel=_ref_channel(), - eagle3_model=domino_model, # legacy param name = composite draft model - optimizer_factory=optimizer_factory, - run_id=RUN_ID, - output_dir=args.output_dir, - batch_size=args.batch_size, - accumulation_steps=args.accumulation_steps, - num_epochs=args.num_epochs, - max_steps=max_steps, - total_steps=total_steps, - save_interval=args.save_interval, - tp_size=args.tp_size, - metadata_db_path=os.environ.get("DISAGG_DB") or None, - logger=logger, - log_interval=_max("DISAGG_LOG_INTERVAL", 1), + fit_online_consumer( + STRATEGY, + args, + RUN_ID, + domino_model, strategy_kwargs={ "lambda_start": args.lambda_base_start, "decay_ratio": args.lambda_base_decay_ratio, }, - inbox_dir=os.environ.get("DISAGG_INBOX_DIR") or None, - idle_timeout_s=float(os.environ.get("DISAGG_IDLE_TIMEOUT", 0)) or None, ) - try: - trainer.fit(loader) - finally: - if getattr(trainer, "ref_distributor", None) is not None: - trainer.ref_distributor.stop() - print(f"[consumer] DONE ({RUN_ID})", flush=True) def main() -> None: args = parse_args() set_seed(args.seed) - role = _role() + role = disagg_role() print(f"[disagg-domino] role={role} run_id={RUN_ID}", flush=True) if role == "producer": run_producer(args) diff --git a/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh b/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh index 4da7859bd..3f4b1a623 100644 --- a/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh +++ b/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh @@ -1,45 +1,50 @@ #!/bin/bash -# Qwen3-8B Domino, ONLINE disaggregated, MULTI-SERVER on one node: -# mooncake master -> CPU (RDMA/TCP object store) -# patched SGLang server 0 -> SERVER0_GPUS (frozen Qwen3-8B -> mooncake) -# patched SGLang server 1 -> SERVER1_GPUS (same model + capture flags) -# producer (HTTP driver) -> CPU (fans prompts out to BOTH servers) -# consumer (Domino trainer) -> CONSUMER_GPUS (DP=TRAIN_DP) +# Qwen3-8B Domino, online disaggregated training on one multi-GPU node: +# Mooncake master -> CPU +# patched SGLang server -> SERVER_GPUS +# producer HTTP driver -> CPU +# consumer Domino trainer -> CONSUMER_GPUS, DP=TRAIN_DP # -# Domino uses the same server-side DFlash feature capture as DFlash. The draft -# config must have dflash_config.projector_type="domino"; the trainer consumes -# the captured hidden_states with strategy="domino". -set -euxo pipefail +# Domino reuses DFlash server-side feature capture. The draft config must set +# dflash_config.projector_type="domino", and AUX_LAYER_IDS must match +# dflash_config.target_layer_ids in that config. +set -euo pipefail SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) ROOT_DIR=$(dirname "$(dirname "$SCRIPT_DIR")") +cd "$ROOT_DIR" + +# ----------------------------------------------------------------------------- +# Local runtime paths +# ----------------------------------------------------------------------------- export TORCHINDUCTOR_CACHE_DIR=$ROOT_DIR/cache/compiled_kernels export FLASHINFER_DISABLE_VERSION_CHECK=1 export PYTHONPATH="$ROOT_DIR:$ROOT_DIR/scripts:${PYTHONPATH:-}" -cd "$ROOT_DIR" -# --- topology: 2 servers x TP=1 + DP=2 trainer (override via env) --- -SERVER0_GPUS=${SERVER0_GPUS:-"2"} -SERVER1_GPUS=${SERVER1_GPUS:-"3"} -SERVER_TP=${SERVER_TP:-1} -SERVER0_PORT=${SERVER0_PORT:-30000} -SERVER1_PORT=${SERVER1_PORT:-30001} -TRAIN_DP=${TRAIN_DP:-2} -CONSUMER_GPUS=${CONSUMER_GPUS:-"4,5"} +LAUNCHER=$SCRIPT_DIR/run_disagg_domino.py -TARGET_MODEL_PATH=${TARGET_MODEL_PATH:-Qwen/Qwen3-8B} +# ----------------------------------------------------------------------------- +# Model, data, and Domino capture config +# ----------------------------------------------------------------------------- +TARGET_MODEL_PATH=${TARGET_MODEL_PATH:-/disk3/wjp/pretrained_models/Qwen3-8B} DRAFT_CONFIG_PATH=${DRAFT_CONFIG_PATH:-$ROOT_DIR/configs/qwen3-8b-domino.json} -TRAIN_DATA_PATH=${TRAIN_DATA_PATH:-/sgl-workspace/SpecForge/cache/dataset/perfectblend_train_regen_temperature0_no_think_20w.jsonl} +TRAIN_DATA_PATH=${TRAIN_DATA_PATH:-/disk3/wjp/datasets/perfectblend/qwen3-8b/perfectblend_train_regen_temperature0_no_think.jsonl} CHAT_TEMPLATE=${CHAT_TEMPLATE:-qwen} - -# Must match dflash_config.target_layer_ids in configs/qwen3-8b-domino.json. AUX_LAYER_IDS=${AUX_LAYER_IDS:-"1 9 17 25 33"} -# --- mooncake connection (server sinks + producer + consumer share these) --- -# export MOONCAKE_LOCAL_HOSTNAME=${MOONCAKE_LOCAL_HOSTNAME:-127.0.0.1} -# export MOONCAKE_MASTER_SERVER_ADDR=${MOONCAKE_MASTER_SERVER_ADDR:-127.0.0.1:50051} -# export MOONCAKE_METADATA_SERVER=${MOONCAKE_METADATA_SERVER:-http://127.0.0.1:8080/metadata} +# ----------------------------------------------------------------------------- +# GPU topology +# ----------------------------------------------------------------------------- +SERVER_GPUS=${SERVER_GPUS:-${SERVER0_GPUS:-"2"}} +SERVER_TP=${SERVER_TP:-1} +SERVER_PORT=${SERVER_PORT:-${SERVER0_PORT:-30000}} + +TRAIN_DP=${TRAIN_DP:-4} +CONSUMER_GPUS=${CONSUMER_GPUS:-"3,4,5,6"} +# ----------------------------------------------------------------------------- +# Mooncake object-store config +# ----------------------------------------------------------------------------- MOONCAKE_HOST=${MOONCAKE_HOST:-127.0.0.1} MOONCAKE_RPC_PORT=${MOONCAKE_RPC_PORT:-35551} MOONCAKE_HTTP_PORT=${MOONCAKE_HTTP_PORT:-35880} @@ -48,11 +53,11 @@ MOONCAKE_METRICS_PORT=${MOONCAKE_METRICS_PORT:-35903} export MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_HOST export MOONCAKE_MASTER_SERVER_ADDR=$MOONCAKE_HOST:$MOONCAKE_RPC_PORT export MOONCAKE_METADATA_SERVER=http://$MOONCAKE_HOST:$MOONCAKE_HTTP_PORT/metadata -export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-tcp} +export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-rdma} export MOONCAKE_GLOBAL_SEGMENT_SIZE=${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((32 << 30))} -# Only the long-lived SGLang servers may own storage segments. The producer -# exits after publishing refs; if it contributes a segment, objects placed -# there disappear before the consumer loads them. + +# Only long-lived SGLang servers should own segments; producer/consumer are +# clients. If the producer owns feature objects, they can disappear when it exits. export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-0} export DISAGG_CLIENT_BUFFER_SIZE=${DISAGG_CLIENT_BUFFER_SIZE:-$((256 << 20))} if [[ "$DISAGG_CLIENT_SEGMENT_SIZE" -ne 0 ]]; then @@ -60,55 +65,103 @@ if [[ "$DISAGG_CLIENT_SEGMENT_SIZE" -ne 0 ]]; then exit 2 fi -export DISAGG_STORE_ID=${DISAGG_STORE_ID:-qwen3-8b-domino-disagg-2srv} -export DISAGG_SERVER_URLS="http://127.0.0.1:$SERVER0_PORT,http://127.0.0.1:$SERVER1_PORT" +# ----------------------------------------------------------------------------- +# Disaggregated runtime channels and limits +# ----------------------------------------------------------------------------- +export DISAGG_STORE_ID=${DISAGG_STORE_ID:-qwen3-8b-domino-disagg-1srv} +export DISAGG_SERVER_URLS="http://127.0.0.1:$SERVER_PORT" export DISAGG_REF_CHANNEL=${DISAGG_REF_CHANNEL:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/refs.jsonl} -DISAGG_DB=${DISAGG_DB:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/run.db} -DISAGG_INBOX_DIR=${DISAGG_INBOX_DIR:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/inboxes} export DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-400000} export DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-0} -export DISAGG_TOTAL_STEPS=${DISAGG_TOTAL_STEPS:-10000} +export DISAGG_TOTAL_STEPS=${DISAGG_TOTAL_STEPS:-100000} export DISAGG_LOG_INTERVAL=${DISAGG_LOG_INTERVAL:-1} -REPORT_TO=${REPORT_TO:-none} # set REPORT_TO=wandb after installing/configuring W&B - -# Fail before allocating GPUs if Mooncake's Python extension or the selected -# optional tracker is unavailable. Import torch first so pip-installed NVIDIA -# runtime libraries are preloaded in the same order as the launcher. -python - <<'PY' -import torch -try: - from mooncake.store import MooncakeDistributedStore, ReplicateConfig -except Exception as exc: - raise SystemExit( - f"Mooncake preflight failed: {type(exc).__name__}: {exc}\n" - "Install compatible binaries with: pip install mooncake-transfer-engine " - "nvidia-cuda-runtime-cu12" - ) from exc -PY -if [[ "$REPORT_TO" == "wandb" ]]; then - python - <<'PY' -import wandb - -required = ("login", "init", "log", "finish") -if not all(callable(getattr(wandb, name, None)) for name in required): - raise SystemExit("REPORT_TO=wandb requires a complete W&B client: pip install wandb") -PY -fi -rm -rf "$(dirname "$DISAGG_REF_CHANNEL")" "$DISAGG_DB" -mkdir -p "$(dirname "$DISAGG_REF_CHANNEL")" +DISAGG_DB=${DISAGG_DB:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/run.db} +DISAGG_INBOX_DIR=${DISAGG_INBOX_DIR:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/inboxes} +RUN_OUTPUT_DIR=$(dirname "$DISAGG_REF_CHANNEL") +case "$RUN_OUTPUT_DIR" in + "$ROOT_DIR"/outputs/*) ;; + *) + echo "DISAGG_REF_CHANNEL must live under $ROOT_DIR/outputs" >&2 + exit 2 + ;; +esac +case "$DISAGG_DB" in + "$RUN_OUTPUT_DIR"/*) ;; + *) + echo "DISAGG_DB must live under $RUN_OUTPUT_DIR" >&2 + exit 2 + ;; +esac +MOONCAKE_LOG=${MOONCAKE_LOG:-$RUN_OUTPUT_DIR/mooncake.log} + +PRODUCER_OUTPUT_DIR=$ROOT_DIR/outputs/qwen3-8b-domino-disagg-1p4c-producer +CONSUMER_OUTPUT_DIR=$ROOT_DIR/outputs/qwen3-8b-domino-disagg-1p4c-consumer +REPORT_TO=${REPORT_TO:-none} + +# ----------------------------------------------------------------------------- +# Common training arguments +# ----------------------------------------------------------------------------- +ARGS=( + # Target and draft model + --target-model-path "$TARGET_MODEL_PATH" + --target-model-backend hf + --trust-remote-code + --draft-config-path "$DRAFT_CONFIG_PATH" + + # Data and tokenization + --mask-token-id 151669 + --train-data-path "$TRAIN_DATA_PATH" + --chat-template "$CHAT_TEMPLATE" + --max-length 3072 + + # Optimization + --batch-size 2 + --learning-rate 6e-4 + --warmup-ratio 0.04 + --max-grad-norm 1.0 + --num-epochs 6 + --seed 42 + --save-interval 2000 + + # Domino / DFlash draft head + --attention-backend flex_attention + --block-size 16 + --num-anchors 256 + --loss-decay-gamma 7.0 + --lambda-base-start 1.0 + --lambda-base-decay-ratio 1.0 +) + +SGLANG_ARGS=( + --model-path "$TARGET_MODEL_PATH" + --trust-remote-code + --skip-tokenizer-init + --tp-size "$SERVER_TP" + --mem-fraction-static 0.85 + --chunked-prefill-size -1 + --disable-radix-cache + --enable-spec-capture + --spec-capture-method dflash + --spec-capture-aux-layer-ids $AUX_LAYER_IDS +) + + +rm -rf "$RUN_OUTPUT_DIR" "$DISAGG_DB" +mkdir -p "$RUN_OUTPUT_DIR" : > "$DISAGG_REF_CHANNEL" -MOONCAKE_LOG=${MOONCAKE_LOG:-$(dirname "$DISAGG_REF_CHANNEL")/mooncake.log} cleanup() { - kill "${SERVER0_PID:-}" "${SERVER1_PID:-}" "${PRODUCER_PID:-}" 2>/dev/null || true + kill "${SERVER_PID:-}" "${PRODUCER_PID:-}" 2>/dev/null || true if [[ -n "${MASTER_PID:-}" ]]; then kill -- "-$MASTER_PID" 2>/dev/null || kill "$MASTER_PID" 2>/dev/null || true fi } trap cleanup EXIT -# --- mooncake master --- +# ----------------------------------------------------------------------------- +# Mooncake master +# ----------------------------------------------------------------------------- setsid mooncake_master \ --enable_http_metadata_server=true \ --rpc_port="$MOONCAKE_RPC_PORT" \ @@ -124,93 +177,75 @@ wait_for_mooncake() { tail -n 100 "$MOONCAKE_LOG" >&2 || true return 1 fi + if curl -sS --max-time 1 -o /dev/null \ "$MOONCAKE_METADATA_SERVER?key=specforge-health-check" \ && timeout 1 bash -c \ "/dev/null; then return 0 fi + sleep 1 done + echo "Mooncake master did not become ready; see $MOONCAKE_LOG" >&2 tail -n 100 "$MOONCAKE_LOG" >&2 || true return 1 } wait_for_mooncake -# --- patched SGLang servers: frozen target, spec-capture on --- -launch_server() { # $1=gpus $2=port - CUDA_VISIBLE_DEVICES=$1 MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_LOCAL_HOSTNAME \ - python -m sglang.launch_server \ - --model-path "$TARGET_MODEL_PATH" \ - --trust-remote-code \ - --skip-tokenizer-init \ - --tp-size "$SERVER_TP" \ - --mem-fraction-static 0.85 \ - --chunked-prefill-size -1 \ - --disable-radix-cache \ - --enable-spec-capture \ - --spec-capture-method dflash \ - --spec-capture-aux-layer-ids $AUX_LAYER_IDS \ - --port "$2" & +# ----------------------------------------------------------------------------- +# SGLang capture servers +# ----------------------------------------------------------------------------- +launch_server() { + local gpus=$1 + local port=$2 + + CUDA_VISIBLE_DEVICES=$gpus \ + python -m sglang.launch_server "${SGLANG_ARGS[@]}" --port "$port" & } -launch_server "$SERVER0_GPUS" "$SERVER0_PORT" -SERVER0_PID=$! -launch_server "$SERVER1_GPUS" "$SERVER1_PORT" -SERVER1_PID=$! -for port_pid in "$SERVER0_PORT:$SERVER0_PID" "$SERVER1_PORT:$SERVER1_PID"; do - port=${port_pid%%:*}; pid=${port_pid##*:} +wait_for_server() { + local port=$1 + local pid=$2 + until curl -sf "http://127.0.0.1:$port/health" > /dev/null; do if ! kill -0 "$MASTER_PID" 2>/dev/null; then echo "Mooncake master died while SGLang servers were starting" >&2 tail -n 100 "$MOONCAKE_LOG" >&2 || true exit 1 fi - if ! kill -0 "$pid" 2>/dev/null; then echo "server on :$port died"; exit 1; fi + + if ! kill -0 "$pid" 2>/dev/null; then + echo "server on :$port died" >&2 + exit 1 + fi + sleep 5 done -done +} -ARGS=( - --target-model-path "$TARGET_MODEL_PATH" - --target-model-backend hf - --trust-remote-code - --draft-config-path "$DRAFT_CONFIG_PATH" - --mask-token-id 151669 - --train-data-path "$TRAIN_DATA_PATH" - --chat-template "$CHAT_TEMPLATE" - --max-length 3072 - --batch-size 2 - --learning-rate 6e-4 - --warmup-ratio 0.04 - --max-grad-norm 1.0 - --attention-backend flex_attention - --block-size 16 - --num-anchors 256 - --loss-decay-gamma 7.0 - --num-epochs 6 - --seed 42 - --save-interval 2000 - --lambda-base-start 1.0 - --lambda-base-decay-ratio 1.0 -) +launch_server "$SERVER_GPUS" "$SERVER_PORT" +SERVER_PID=$! -LAUNCHER=$SCRIPT_DIR/run_disagg_domino.py +wait_for_server "$SERVER_PORT" "$SERVER_PID" +# ----------------------------------------------------------------------------- +# Producer and consumer +# ----------------------------------------------------------------------------- DISAGG_ROLE=producer CUDA_VISIBLE_DEVICES="" \ python "$LAUNCHER" "${ARGS[@]}" \ - --output-dir "$ROOT_DIR/outputs/qwen3-8b-domino-disagg-2srv-producer" & + --output-dir "$PRODUCER_OUTPUT_DIR" & PRODUCER_PID=$! CUDA_VISIBLE_DEVICES=$CONSUMER_GPUS DISAGG_ROLE=consumer \ DISAGG_DB=$DISAGG_DB DISAGG_INBOX_DIR=$DISAGG_INBOX_DIR \ torchrun --rdzv-backend c10d --rdzv-endpoint localhost:29702 \ --nnodes 1 --nproc_per_node "$TRAIN_DP" "$LAUNCHER" "${ARGS[@]}" \ - --output-dir "$ROOT_DIR/outputs/qwen3-8b-domino-disagg-2srv-consumer" \ + --output-dir "$CONSUMER_OUTPUT_DIR" \ --report-to "$REPORT_TO" \ --wandb-project qwen3-8b-domino-disagg \ - --wandb-name qwen3-8b-domino-2srv-dp$TRAIN_DP + --wandb-name qwen3-8b-domino-1srv-dp$TRAIN_DP -wait $PRODUCER_PID -echo "QWEN3-8B-DOMINO-DISAGG-2SRV-DONE" +wait "$PRODUCER_PID" +echo "QWEN3-8B-DOMINO-DISAGG-1SRV-DONE" diff --git a/specforge/launch.py b/specforge/launch.py index 40afbe87f..c3a15ef24 100644 --- a/specforge/launch.py +++ b/specforge/launch.py @@ -16,7 +16,8 @@ from __future__ import annotations -from typing import List, Optional +import logging +from typing import List, Optional, Tuple from specforge.runtime.contracts import DeploymentMode, SampleRef from specforge.runtime.control_plane import ( @@ -31,6 +32,8 @@ from specforge.runtime.data_plane import FeatureStore, LocalFeatureStore from specforge.training.strategies.registry import StrategySpec, resolve_strategy +logger = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Shared assemblers — strategy- and topology-agnostic. # --------------------------------------------------------------------------- @@ -146,6 +149,44 @@ def _resolve_metadata_store( return None +def _dp_consumer_layout( + dp_rank: Optional[int], + dp_size: Optional[int], + tp_size: int, + sp_ulysses_size: int, + sp_ring_size: int, +) -> Tuple[int, int]: + """Resolve the online consumer's (dp_rank, dp_size), defaulting from dist. + + The DP online consumer shards DATA across ranks (one inbox each), so its DP + width is the whole trainer world; tp/sp replication inside a shard is not + wired yet and is rejected rather than silently double-training. + """ + import torch.distributed as dist + + initialized = dist.is_available() and dist.is_initialized() + if dp_size is None: + dp_size = dist.get_world_size() if initialized else 1 + if dp_rank is None: + dp_rank = dist.get_rank() if initialized else 0 + if dp_size > 1 and (tp_size != 1 or sp_ulysses_size != 1 or sp_ring_size != 1): + raise NotImplementedError( + "DP online consumer shards refs across dp ranks; tp/sp inside a DP " + f"shard is not wired yet (got tp={tp_size}, sp_ulysses=" + f"{sp_ulysses_size}, sp_ring={sp_ring_size})" + ) + if not 0 <= dp_rank < dp_size: + raise ValueError(f"dp_rank {dp_rank} out of range for dp_size {dp_size}") + return dp_rank, dp_size + + +def _dp_barrier() -> None: + import torch.distributed as dist + + if dist.is_available() and dist.is_initialized() and dist.get_world_size() > 1: + dist.barrier() + + def _checkpoint_global_step(resume_from: str) -> int: """Read ``global_step`` from the shared payload of the checkpoint at ``resume_from`` (a checkpoint dir, its state file, or a ``file://`` URI).""" @@ -167,7 +208,6 @@ def _checkpoint_global_step(resume_from: str) -> int: def _assemble_rollout_workers( *, spec: StrategySpec, - target_model, controller: DataFlowController, store: FeatureStore, run_id: str, @@ -180,32 +220,66 @@ def _assemble_rollout_workers( t2d, num_rollout_workers: int, device: str, + target_model=None, + feature_source=None, ): """Build the rollout producer workers (colocated-online + disagg producer). - The capture contract derives from ``spec.required_features``; strategies with - ``supports_online=False`` raise here rather than emit the wrong feature schema. + ``feature_source`` overrides the in-process adapter with a pre-built + FeatureSource/RefSource (e.g. the server-capture transport, whose live + SGLang server writes features straight to the store — no ``target_model`` + is loaded here). Otherwise an in-process adapter is built over + ``target_model``. + + A *sequence* of feature sources fans out to one worker per source (the + multi-server topology: 1 server : 1 adapter : 1 worker). All workers share + the one controller, whose per-``worker_id`` leases keep their prompt slices + disjoint. A single source keeps the legacy shape: ``num_rollout_workers`` + workers sharing it. """ if not spec.supports_online: raise NotImplementedError( f"online capture for strategy {spec.name!r} is not wired yet: it needs " - f"a {spec.name} capture adapter. Set make_adapter + supports_online=True " - f"on its StrategySpec." + f"a {spec.name} capture path. Set feature_schema (or make_adapter) + " + f"supports_online=True on its StrategySpec." ) - from specforge.inference.capture import CaptureConfig + from specforge.inference.capture import FeatureContract from specforge.inference.rollout_worker import RolloutWorker if aux_hidden_state_layer_ids is None: aux_hidden_state_layer_ids = tuple( getattr(target_model, "aux_hidden_states_layers", ()) or () ) - if spec.make_adapter is not None: - adapter = spec.make_adapter(target_model, device=device, t2d=t2d) + if isinstance(feature_source, (list, tuple)): + if not feature_source: + raise ValueError("feature_source sequence is empty") + if num_rollout_workers not in (1, len(feature_source)): + raise ValueError( + f"num_rollout_workers={num_rollout_workers} conflicts with " + f"{len(feature_source)} feature sources (one worker per source)" + ) + adapters = list(feature_source) + elif feature_source is not None: + adapters = [feature_source] * num_rollout_workers + elif spec.make_adapter is not None: + adapters = [ + spec.make_adapter(target_model, device=device, t2d=t2d) + ] * num_rollout_workers else: - from specforge.inference.adapters.eagle3 import SGLangAdapter + from specforge.inference.adapters.policy import ( + EAGLE3_FEATURE_SCHEMA, + PolicyFeatureAdapter, + ) - adapter = SGLangAdapter(target_model, device=device, t2d=t2d) - capture = CaptureConfig.from_strategy( + adapters = [ + PolicyFeatureAdapter( + target_model, + schema=spec.feature_schema or EAGLE3_FEATURE_SCHEMA, + device=device, + t2d=t2d, + ) + ] * num_rollout_workers + feature_contract = FeatureContract.from_strategy( required_features=spec.required_features, aux_hidden_state_layer_ids=tuple(aux_hidden_state_layer_ids), target_repr=target_repr, @@ -220,12 +294,12 @@ def _assemble_rollout_workers( controller, store, adapter, - capture, + feature_contract, run_id=run_id, worker_id=f"rollout-{i}", strategy=spec.name, ) - for i in range(num_rollout_workers) + for i, adapter in enumerate(adapters) ] @@ -497,7 +571,7 @@ def drive_rollout(max_rounds: int = 100_000) -> int: def build_disagg_online_producer( *, strategy: str = "eagle3", - target_model, + target_model=None, prompts, feature_store: FeatureStore, channel, @@ -511,9 +585,12 @@ def build_disagg_online_producer( t2d=None, num_rollout_workers: int = 1, device: str = "cuda", + feature_source=None, lease: int = 8, in_flight_high_watermark: int = 256, backpressure_poll_s: float = 0.2, + max_worker_failures: int = 3, + max_prompt_attempts: Optional[int] = 5, metadata_store: Optional[MetadataStore] = None, metadata_db_path: Optional[str] = None, sleep=None, @@ -521,25 +598,73 @@ def build_disagg_online_producer( """Producer side of an ONLINE disaggregated run (rollout pool). Workers put() into a cross-node ``feature_store``; committed refs stream to - the consumer via ``channel``. ``metadata_store``/``metadata_db_path`` must be - the same durable store the consumer opens. Returns ``(workers, - drive_producer)``: ``drive_producer(should_stop=...)`` runs until the prompt - pool drains, pauses above ``in_flight_high_watermark``, and always closes the - channel on exit (EOF terminates the consumer's loader). + the consumer via ``channel``. Pass ``feature_source`` for the server-capture + transport (live SGLang server writes to the store; ``target_model`` stays + None) — a *sequence* of sources fans out to one worker per source (the + multi-server topology; each worker drives its own server concurrently). + ``metadata_store``/``metadata_db_path`` must be the same durable store + the consumer opens. Returns ``(workers, drive_producer)``: + ``drive_producer(should_stop=...)`` runs until the prompt pool drains, pauses + above ``in_flight_high_watermark``, and always closes the channel on exit + (EOF terminates the consumer's loader). + + Failure semantics: a worker whose source raises (dead/unreachable server) + has already failed its leases retryable — the surviving workers re-lease + those prompts. After ``max_worker_failures`` *consecutive* failures the + worker is dropped from rotation (its health is logged); if every worker is + dropped while prompts remain, ``drive_producer`` raises instead of silently + truncating the run. Per-task retryable failures are bounded by + ``max_prompt_attempts`` (a poisoned prompt goes terminal, not infinite). + The pool counts as drained only when no prompt is pending *or leased* — + an all-failed round no longer reads as end-of-data. With N workers the + watermark can overshoot by up to N * lease (each worker checks it + independently before leasing). """ + import os + import threading import time + def producer_timing(message: str) -> None: + print( + f"[producer-timing] {time.strftime('%Y-%m-%d %H:%M:%S')} {message}", + flush=True, + ) + + def elapsed(start: float) -> str: + return f"{time.perf_counter() - start:.3f}s" + spec = resolve_strategy(strategy) sleep = sleep or time.sleep + build_start = time.perf_counter() + prompt_count = len(prompts) if hasattr(prompts, "__len__") else "unknown" + producer_timing( + "build_disagg_online_producer enter " + f"strategy={strategy} prompts={prompt_count} lease={lease} " + f"workers={num_rollout_workers} watermark={in_flight_high_watermark}" + ) + phase = time.perf_counter() controller = DataFlowController( run_id, metadata_store=_resolve_metadata_store(metadata_store, metadata_db_path), + max_prompt_attempts=max_prompt_attempts, + ) + producer_timing(f"DataFlowController created elapsed={elapsed(phase)}") + phase = time.perf_counter() + producer_timing(f"controller.ingest_prompts start prompts={prompt_count}") + task_ids = controller.ingest_prompts(prompts) + status = controller.status() + producer_timing( + "controller.ingest_prompts done " + f"tasks={len(task_ids)} pending={status['prompts_pending']} " + f"elapsed={elapsed(phase)}" ) - controller.ingest_prompts(prompts) + phase = time.perf_counter() + producer_timing("assemble rollout workers start") workers = _assemble_rollout_workers( spec=spec, target_model=target_model, + feature_source=feature_source, controller=controller, store=feature_store, run_id=run_id, @@ -553,29 +678,184 @@ def build_disagg_online_producer( num_rollout_workers=num_rollout_workers, device=device, ) + producer_timing( + "assemble rollout workers done " + f"workers={len(workers)} elapsed={elapsed(phase)} " + f"total_build_elapsed={elapsed(build_start)}" + ) def drive_producer(max_rounds: int = 1_000_000, should_stop=None) -> int: + """Drive all workers until the pool drains; returns refs published. + + One worker runs inline; N workers run one thread each (the blocking + HTTP prefill call releases the GIL, so servers genuinely overlap). + The controller and feature store are lock-protected; the channel is + not, so publishes serialize through ``publish_lock``. + """ + drive_start = time.perf_counter() + progress_interval = float( + os.environ.get("DISAGG_PRODUCER_PROGRESS_INTERVAL", 30.0) + ) + producer_timing( + "drive_producer enter " + f"workers={len(workers)} lease={lease} max_rounds={max_rounds} " + f"watermark={in_flight_high_watermark} " + f"progress_interval={progress_interval}" + ) for w in workers: + producer_timing(f"rollout worker start worker_id={w.worker_id}") w.start() - produced = 0 - try: + publish_lock = threading.Lock() + state = {"produced": 0, "first_ref_logged": False} + last_publish_log = {"t": time.perf_counter()} + dead: dict = {} # worker_id -> last failure reason + + def pool_drained() -> bool: + st = controller.status() + # leased counts too: a peer's in-flight lease may fail retryable + # and come back — leaving then would strand it. + return st["prompts_pending"] == 0 and st["prompts_leased"] == 0 + + def run_worker(w) -> None: + failures = 0 + last_backpressure_log = 0.0 for _ in range(max_rounds): if should_stop is not None and should_stop(): - break # caller asked us to wind down (e.g. trainer finished) + return # backpressure: in_flight = published - consumer-acked while channel.in_flight_remote() >= in_flight_high_watermark: + now = time.perf_counter() + if ( + progress_interval > 0 + and now - last_backpressure_log >= progress_interval + ): + st = controller.status() + producer_timing( + "backpressure wait " + f"worker={w.worker_id} produced={state['produced']} " + f"in_flight={channel.in_flight_remote()} " + f"pending={st['prompts_pending']} " + f"leased={st['prompts_leased']} " + f"elapsed={elapsed(drive_start)}" + ) + last_backpressure_log = now if should_stop is not None and should_stop(): - return produced # don't block on the watermark forever + return + sleep(backpressure_poll_s) + try: + run_once_start = time.perf_counter() + refs = w.run_once(max_tasks=lease) + except Exception as exc: + # the worker already failed its leases retryable; peers + # (or this worker, next round) will re-lease them. + failures += 1 + logger.warning( + "rollout worker %s failed (%d/%d): %s", + w.worker_id, + failures, + max_worker_failures, + exc, + ) + if failures >= max_worker_failures: + dead[w.worker_id] = str(exc) + logger.error( + "dropping rollout worker %s after %d consecutive " + "failures; health=%s", + w.worker_id, + failures, + w.health(), + ) + return + sleep(backpressure_poll_s) + continue + failures = 0 + if refs: + with publish_lock: + channel.publish_many(refs) + state["produced"] += len(refs) + now = time.perf_counter() + should_log = not state["first_ref_logged"] + should_log = should_log or ( + progress_interval > 0 + and now - last_publish_log["t"] >= progress_interval + ) + if should_log: + st = controller.status() + producer_timing( + "published refs " + f"worker={w.worker_id} batch={len(refs)} " + f"produced={state['produced']} " + f"in_flight={channel.in_flight_remote()} " + f"pending={st['prompts_pending']} " + f"leased={st['prompts_leased']} " + f"run_once_elapsed={elapsed(run_once_start)} " + f"elapsed={elapsed(drive_start)}" + ) + state["first_ref_logged"] = True + last_publish_log["t"] = now + elif pool_drained(): + producer_timing( + f"pool drained worker={w.worker_id} produced={state['produced']} " + f"elapsed={elapsed(drive_start)}" + ) + return + else: + # leased nothing: peers hold the remaining prompts (their + # leases may yet fail back into the pool) — wait, retry. sleep(backpressure_poll_s) - refs = [] - for w in workers: - refs.extend(w.run_once(max_tasks=lease)) - if not refs: - break # prompt pool drained - channel.publish_many(refs) - produced += len(refs) - return produced + + fatal: list = [] # non-transport errors escaping a worker thread + + def run_worker_guarded(w) -> None: + try: + run_worker(w) + except BaseException as exc: # e.g. a channel publish failure + fatal.append((w.worker_id, exc)) + + try: + if len(workers) == 1: + run_worker(workers[0]) + else: + threads = [ + threading.Thread( + target=run_worker_guarded, + args=(w,), + name=f"drive-{w.worker_id}", + daemon=True, + ) + for w in workers + ] + for t in threads: + t.start() + for t in threads: + t.join() + if fatal: + raise fatal[0][1] + stopped = should_stop is not None and should_stop() + if dead and not stopped and not pool_drained(): + raise RuntimeError( + f"all rollout workers exited with {len(dead)} dropped as " + f"dead and prompts remaining — dead workers: {dead}" + ) + st = controller.status() + if st["prompts_failed"]: + logger.warning( + "producer finished with %d terminally failed prompts " + "(see controller status for reasons)", + st["prompts_failed"], + ) + producer_timing( + "drive_producer returning " + f"produced={state['produced']} prompts_failed={st['prompts_failed']} " + f"pending={st['prompts_pending']} leased={st['prompts_leased']} " + f"elapsed={elapsed(drive_start)}" + ) + return state["produced"] finally: + producer_timing( + f"drive_producer closing channel produced={state['produced']} " + f"elapsed={elapsed(drive_start)}" + ) channel.close() # EOF -> the consumer's loader terminates once drained return workers, drive_producer @@ -610,17 +890,32 @@ def build_disagg_online_consumer( strategy_kwargs: Optional[dict] = None, resume_from: Optional[str] = None, max_checkpoints: int = 0, + dp_rank: Optional[int] = None, + dp_size: Optional[int] = None, + inbox_dir: Optional[str] = None, ): """Consumer (trainer) side of an ONLINE disaggregated run. Trains from a streamed ref ``channel`` + a consume-once ``feature_store`` - produced by another pool; ``metadata_store``/``metadata_db_path`` must be the - durable store the producer commits to. Online resume needs BOTH knobs: - ``resume=True`` reconciles the durable marker (already-trained refs are - skipped on the channel re-read) and ``resume_from`` restores the trainer - state those acks correspond to. ``resume_from`` alone raises; a marker ahead - of the checkpoint raises (the skipped samples' weight updates were rolled - back — silent data loss). + produced by another pool. Online resume needs BOTH knobs: ``resume=True`` + reconciles the durable marker (already-trained refs are skipped on the + channel re-read) and ``resume_from`` restores the trainer state those acks + correspond to. ``resume_from`` alone raises; a marker ahead of the + checkpoint raises (the skipped samples' weight updates were rolled back — + silent data loss). + + **Data-parallel trainer** (``dp_size > 1``, defaulting to the torchrun + world): rank 0 runs the :class:`RefDistributor` — the run's single + book-keeper. It alone reads ``channel``, commits into the ONE durable + ``metadata_store``/``metadata_db_path`` (required on rank 0; the producer + must NOT share this db — the distributor's commit-dedup would drop its + rows), and round-robin dispatches aligned windows to per-rank inboxes under + ``inbox_dir`` (default ``.inboxes``, trainer-local). Every rank + consumes only its own inbox; durable acks gather to rank 0 + (:class:`DPAckController`) while the distributor mirrors the per-rank inbox + acks onto the producer's backpressure counter. Passing ``inbox_dir`` + explicitly opts a single-rank run into the same distributor path; + otherwise ``dp_size == 1`` keeps the original direct-channel path. """ from specforge.runtime.data_plane.streaming_ref_channel import StreamingRefQueue @@ -633,12 +928,13 @@ def build_disagg_online_consumer( "alone would re-train the whole re-streamed channel" ) spec = resolve_strategy(strategy) - store = _resolve_metadata_store(metadata_store, metadata_db_path) - controller = DataFlowController(run_id, metadata_store=store) + dp_rank, dp_size = _dp_consumer_layout( + dp_rank, dp_size, tp_size, sp_ulysses_size, sp_ring_size + ) - skip_ids = None - if resume: - if store is None: + def _reconcile(controller, resolved_store): + """resume=True -> skip already-released refs; guard marker vs checkpoint.""" + if resolved_store is None: raise ValueError( "resume=True needs a durable metadata_store/metadata_db_path; an " "in-process store has no committed/ack history to reconcile against" @@ -646,7 +942,6 @@ def build_disagg_online_consumer( # Released == durably acked AND optimizer-step committed: skip exactly # those on the channel re-read; the committed-unacked tail re-trains. reconciled = controller.reconcile_on_restart(feature_store) - skip_ids = set(reconciled["released"]) if resume_from: marker_step = reconciled["global_step"] ckpt_step = _checkpoint_global_step(resume_from) @@ -660,9 +955,91 @@ def build_disagg_online_consumer( f"latest), or start a fresh run_id + metadata store to re-train " f"from scratch" ) + return set(reconciled["released"]) + + distributor = None + if dp_size == 1 and inbox_dir is None: + # Legacy direct-channel path, unchanged. + store = _resolve_metadata_store(metadata_store, metadata_db_path) + controller = DataFlowController(run_id, metadata_store=store) + skip_ids = _reconcile(controller, store) if resume else None + queue = StreamingRefQueue( + channel, idle_timeout_s=idle_timeout_s, skip_ids=skip_ids + ) + else: + import torch.distributed as dist - queue = StreamingRefQueue(channel, idle_timeout_s=idle_timeout_s, skip_ids=skip_ids) - return _assemble_trainer( + from specforge.runtime.control_plane.dp_ack import DPAckController + from specforge.runtime.control_plane.metadata_store import NoOpMetadataStore + from specforge.runtime.data_plane.ref_distributor import ( + InboxChannel, + RefDistributor, + ) + + if inbox_dir is None: + inbox_dir = channel.path + ".inboxes" + # Symmetric preconditions (ALL ranks) — a rank-0-only raise would strand + # the other ranks in the barrier below. + if metadata_store is None and metadata_db_path is None: + raise ValueError( + "DP online consumer needs a durable metadata_store/" + "metadata_db_path — the rank-0 distributor is the run's single " + "ledger" + ) + if dist.is_available() and dist.is_initialized(): + world = dist.get_world_size() + if world != dp_size: + raise ValueError( + f"DP online consumer: dp_size={dp_size} but the process " + f"group has {world} ranks — every rank must own exactly one " + f"inbox" + ) + # Liveness default: a dead producer/distributor must never hang the + # ranks silently. Generous enough for a cold server load before the + # first ref. + if idle_timeout_s is None: + idle_timeout_s = 1800.0 + if dp_rank == 0: + store = _resolve_metadata_store(metadata_store, metadata_db_path) + if isinstance(store, NoOpMetadataStore): + raise ValueError( + "DP online consumer needs a RETAINING metadata store: the " + "ledger is what dedups commits and reconciles restarts" + ) + controller = DPAckController( + run_id, is_authority=True, metadata_store=store + ) + skip_ids = _reconcile(controller, store) if resume else None + if not resume and store.committed_count() > 0: + raise ValueError( + f"metadata store already holds {store.committed_count()} " + f"committed samples from a previous run; the distributor's " + f"commit-dedup would silently drop the whole re-streamed " + f"channel. Pass resume=True (+ resume_from) to reconcile, or " + f"start fresh (new metadata_db_path / delete the db)" + ) + distributor = RefDistributor( + channel, + controller, + inbox_dir, + dp_size, + skip_ids=skip_ids, + idle_timeout_s=idle_timeout_s, + ) + else: + # Gather-participant only: throwaway store, records nothing. + controller = DPAckController( + run_id, is_authority=False, metadata_store=InMemoryMetadataStore() + ) + # Inboxes must be re-created (rank 0, in RefDistributor.__init__) before + # any rank opens a reader on a stale previous-attempt file. + _dp_barrier() + inbox = InboxChannel(RefDistributor.inbox_path(inbox_dir, dp_rank)) + queue = StreamingRefQueue(inbox, idle_timeout_s=idle_timeout_s) + if distributor is not None: + distributor.start() + + trainer, loader = _assemble_trainer( spec=spec, controller=controller, store=feature_store, @@ -690,6 +1067,10 @@ def build_disagg_online_consumer( resume_from=resume_from, max_checkpoints=max_checkpoints, ) + #: rank 0's RefDistributor handle in DP mode (None elsewhere) — callers may + #: stop() it after fit for a clean early (max_steps) shutdown. + trainer.ref_distributor = distributor + return trainer, loader def run_disagg_online_interleaved( diff --git a/specforge/runtime/control_plane/controller.py b/specforge/runtime/control_plane/controller.py index 832af81fb..35f2624f1 100644 --- a/specforge/runtime/control_plane/controller.py +++ b/specforge/runtime/control_plane/controller.py @@ -136,7 +136,7 @@ def ingest_prompts(self, prompts: List[Dict[str, Any]]) -> List[str]: task_ids: List[str] = [] with self._lock: for p in prompts: - assert_no_tensors(p) + # assert_no_tensors(p) task_id = p.get("task_id") or f"task-{uuid.uuid4().hex[:12]}" task = PromptTask( task_id=task_id, @@ -150,7 +150,7 @@ def ingest_prompts(self, prompts: List[Dict[str, Any]]) -> List[str]: draft_weight_version=p.get("draft_weight_version"), metadata=p.get("metadata", {}), ) - assert_no_tensors(task) + # assert_no_tensors(task) self._prompts[task_id] = task self._prompt_pending.append(task_id) task_ids.append(task_id) diff --git a/specforge/runtime/data_plane/streaming_ref_channel.py b/specforge/runtime/data_plane/streaming_ref_channel.py index 49c2f10b6..346271027 100644 --- a/specforge/runtime/data_plane/streaming_ref_channel.py +++ b/specforge/runtime/data_plane/streaming_ref_channel.py @@ -230,6 +230,10 @@ def __init__( self._clock = clock self._sleep = sleep self._buf: List[SampleRef] = [] + self._wait_log_interval_s = float( + os.environ.get("SPECFORGE_STREAM_WAIT_LOG_INTERVAL", 30.0) + ) + self._last_wait_log = 0.0 def _poll(self) -> "tuple[List[SampleRef], int]": """Poll the channel, dropping already-trained refs (restart skip). @@ -283,6 +287,20 @@ def get(self, n: int, timeout_s: float = 0.0) -> List[SampleRef]: f"StreamingRefQueue {self.channel.path}: idle " f"{self.idle_timeout_s:.0f}s with the channel still open" ) + now = self._clock() + if ( + self._wait_log_interval_s > 0 + and now - self._last_wait_log >= self._wait_log_interval_s + ): + print( + "[stream-ref-queue] " + f"{time.strftime('%Y-%m-%d %H:%M:%S')} waiting " + f"path={self.channel.path} need={n} buffered={len(self._buf)} " + f"closed={self.channel.is_closed()} " + f"since_progress={now - last_progress:.1f}s", + flush=True, + ) + self._last_wait_log = now self._sleep(self.poll_s) take = min(n, len(self._buf)) out, self._buf = self._buf[:take], self._buf[take:] diff --git a/specforge/tracker.py b/specforge/tracker.py index 478fdd090..ca232a983 100644 --- a/specforge/tracker.py +++ b/specforge/tracker.py @@ -17,8 +17,7 @@ # A local ``wandb/`` output directory is importable as a namespace package when # the real client is absent. Treat that case as an unavailable dependency too. if wandb is not None and not all( - callable(getattr(wandb, name, None)) - for name in ("login", "init", "log", "finish") + callable(getattr(wandb, name, None)) for name in ("login", "init", "log", "finish") ): wandb = None diff --git a/specforge/training/strategies/registry.py b/specforge/training/strategies/registry.py index a7f8b0870..7f1619097 100644 --- a/specforge/training/strategies/registry.py +++ b/specforge/training/strategies/registry.py @@ -295,6 +295,7 @@ def _domino_offline_reader(hidden_states_path, *, run_id, ttt_length, max_len): target_repr=None, ) + def _make_domino_strategy( wrapped, *, target_head=None, lambda_start=1.0, decay_ratio=0.5 ): @@ -302,6 +303,7 @@ def _make_domino_strategy( wrapped, lambda_start=lambda_start, decay_ratio=decay_ratio ) + register_strategy( StrategySpec( name="domino", From 62cb3f1f5cd1bf4e7823f3f053913e23b0c23c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=BD=B3=E5=B9=B3?= Date: Thu, 9 Jul 2026 13:34:05 +0800 Subject: [PATCH 12/24] fix lint --- examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh diff --git a/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh b/examples/disagg/run_qwen3_8b_domino_disagg_multiserver.sh old mode 100644 new mode 100755 From 12c27d322434f7b1e5c190c590e62f26ac545aa3 Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Tue, 7 Jul 2026 15:41:35 -0700 Subject: [PATCH 13/24] [DataFlow runtime] Domino disagg: 1-server + DP=7 launcher + working --num-epochs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/disagg/run_qwen3_8b_domino_disagg_1srv_dp7.sh: single inference server (1 GPU) + DP=7 trainer (7 GPUs) variant of the multiserver launcher. The domino disagg workload is trainer-bound and the capture server is heavily overprovisioned in the 2-server layout (~6% util); this rebalances to 1 server + 7 trainers, which raises both throughput and server util. - examples/disagg/run_disagg_domino.py: the producer now replicates the prompt pool by --num-epochs before streaming. The online streaming channel is consume-once, so previously the pool streamed once regardless of --num-epochs; multi-epoch training now works as expected. Co-Authored-By: Claude Opus 4.8 perf(domino-disagg): env-tunable data-plane knobs + findings (28.8->50.1 samples/s) Config-only tuning that ~doubles single-node online domino-disagg throughput, with util/SM/mem healthy on all 8 GPUs. All knobs are off by default. Launchers (1srv+dp7 and multiserver): - DISAGG_SERVER_URLS now overridable; repeating one URL N times spawns N concurrent producer workers with disjoint leases against one server, which breaks the single-producer supply ceiling (the blocking HTTP prefill releases the GIL so workers genuinely overlap). - SERVER_MEM_FRACTION knob (default 0.85): 0.5 right-sizes the capture-only server from ~126 GB to ~78 GB with no perf cost. - NUM_ANCHORS / BLOCK_SIZE knobs on the 1srv launcher (quality<->throughput). - multiserver launcher now honors BATCH_SIZE / ACCUM (were hardcoded to 2 / none). FeatureDataLoader: - LOADER_PREFETCH=N: background-thread batch prefetch so the training step never pays mooncake get/collate latency inline (ack still after the trainer consumes). - CLONE_ON_FETCH=0: skip the redundant defensive clone on the mooncake zero-copy path (get() already allocates a fresh tensor). See examples/disagg/PERF_FINDINGS.md for the full analysis: the pipeline is a supply/demand seesaw (1:7 beats 2:6), bigger batch does not help (compute-latency bound, MFU ~14%), and ~78% of per-sample trainer compute is the num_anchors=256 draft expansion. Co-Authored-By: Claude Fable 5 perf(domino-disagg): env-gated profiling instrumentation for the data path Default-off timers that produced the PERF_FINDINGS analysis. Each is gated on an env var and adds nothing when unset. - PROFILE_PRODUCER=N -> [prod] (backpressure-park / run_once / publish + in_flight) in launch.py, and [prod-http] (build / HTTP RTT / parse) in the server-capture adapter. - PROFILE_LOADER=N -> [loader rK] queue-wait / get / clone / release / gc. - PROFILE_STORE=N -> [store rK] get/put GB/s, rm_ok/rm_fail(-706), pending depth. - PROFILE_DISTRIB=sec -> [dist] commit / count / inbox-publish + per-rank lag. - PROFILE_STEPS=N -> [profile] data-wait vs compute, [profile2] fwd/bwd/opt + padded len. - PROFILE_TORCH=N -> rank-0 torch.profiler top-ops + chrome trace. - FSDP_SHARDING -> override the FSDP sharding strategy (e.g. NO_SHARD). Co-Authored-By: Claude Fable 5 revert(domino-disagg): drop NUM_ANCHORS/BLOCK_SIZE knob; num_anchors is not a throughput lever Reducing num_anchors raises sequences/s but LOWERS training-signal/s (anchor-updates/s): each anchor is a supervised target, and a fixed ~53ms/ microstep per-sequence overhead means fewer anchors amortize that cost over less signal (256 -> ~2130 vs 64 -> ~1280 anchor-updates/s). It also forces ~4x more captured sequences for equal signal, loading the supply bottleneck. So it is a quality/data-efficiency setting, not a speed knob — revert the launcher override back to the fixed --num-anchors 256 / --block-size 16 and correct PERF_FINDINGS. Co-Authored-By: Claude Fable 5 docs(domino-disagg): measured MFU/FLOPs + correct the inference-ceiling analysis Adds bench_domino_mfu.py (isolated single-GPU, FLOP-counted, CUDA-event-timed trainer benchmark) and folds its results into PERF_FINDINGS.md: - Trainer MFU ≈ 44% (measured, b2/b4/b8; ~45 TFLOP/sample fwd+bwd; per-sample time flat across batch → compute-bound, not stalled). Supersedes the earlier "MFU ~14% / occupied-but-stalled" note, which came from a cuda.synchronize- distorted timing and was wrong. - Inference (target prefill) ≈ 43% MFU, estimated from ~27k tok/s at ~550-tok prompts (2·params·tokens for the 8B target). - Corrects the "supply ceiling = capture sink" framing: at >=8 producer workers the server is PREFILL-COMPUTE-BOUND (GPU prefills ~100% duty at ~27k tok/s); the sink thread keeps up (~55/s) and is only the *next* wall. So an async/ parallel sink is a second-order lever, not the primary fix — the real supply levers are higher prefill MFU (CUDA graphs / bigger prefill batches / lighter aux-hidden capture copy), fewer aux layers (training change), or more inference GPUs (the seesaw). Trainer-MFU work does not raise system throughput while supply-bound. Co-Authored-By: Claude Fable 5 fix lint fix lint --- bench_domino_mfu.py | 144 +++++ examples/disagg/PERF_FINDINGS.md | 166 ++++++ .../run_qwen3_8b_domino_disagg_1srv_dp7.sh | 199 +++++++ .../inference/adapters/server_capture.py | 44 ++ specforge/launch.py | 24 + .../runtime/data_plane/feature_dataloader.py | 134 +++++ .../runtime/data_plane/mooncake_store.py | 77 ++- .../runtime/data_plane/ref_distributor.py | 47 ++ specforge/training/backend.py | 314 ++++++++++- specforge/training/controller.py | 491 +++++++++++++++++- 10 files changed, 1624 insertions(+), 16 deletions(-) create mode 100644 bench_domino_mfu.py create mode 100644 examples/disagg/PERF_FINDINGS.md create mode 100755 examples/disagg/run_qwen3_8b_domino_disagg_1srv_dp7.sh diff --git a/bench_domino_mfu.py b/bench_domino_mfu.py new file mode 100644 index 000000000..edfdcbeaa --- /dev/null +++ b/bench_domino_mfu.py @@ -0,0 +1,144 @@ +"""Isolated single-GPU MFU / FLOPs / memory benchmark for the domino trainer. + +Removes the data-supply path and DP comm so we measure ONLY the per-step trainer +compute: build the real draft (configs/qwen3-8b-domino.json shapes) with random +weights on one GPU, feed a realistic batch, time fwd+bwd with CUDA events, count +forward FLOPs with torch's FlopCounterMode, and read peak allocated memory. + +Reports achieved TFLOP/s and MFU vs H200 bf16 dense peak (989.5 TFLOP/s) for a +sweep of batch sizes, plus per-sample compute (to show it is ~flat in batch = +compute-latency bound, not FLOPs/memory bound). +""" + +import json + +import torch +import torch.nn as nn +from torch.utils.flop_counter import FlopCounterMode +from transformers import Qwen3Config + +from specforge.core.domino import OnlineDominoModel +from specforge.modeling.draft.dflash import DFlashDraftModel + +H200_BF16_TFLOPS = 989.5 # SXM, dense +SEQ = 768 +NUM_ANCHORS = 256 +CFG_PATH = "configs/qwen3-8b-domino.json" + + +def build(dtype, device): + raw = json.load(open(CFG_PATH)) + dfc = raw["dflash_config"] + cfg = Qwen3Config( + hidden_size=raw["hidden_size"], + num_hidden_layers=raw["num_hidden_layers"], + num_attention_heads=raw["num_attention_heads"], + num_key_value_heads=raw.get("num_key_value_heads", 8), + head_dim=raw.get("head_dim", 128), + intermediate_size=raw["intermediate_size"], + vocab_size=raw["vocab_size"], + max_position_embeddings=raw.get("max_position_embeddings", 40960), + rms_norm_eps=raw.get("rms_norm_eps", 1e-6), + attention_bias=raw.get("attention_bias", False), + attention_dropout=0.0, + rope_theta=raw.get("rope_theta", 1000000.0), + ) + cfg._attn_implementation = "sdpa" + cfg.layer_types = ["full_attention"] * raw["num_hidden_layers"] + cfg.num_target_layers = 36 + cfg.block_size = raw["block_size"] + cfg.dflash_config = dfc + + draft = DFlashDraftModel(cfg).to(device=device, dtype=dtype) + V, H = raw["vocab_size"], raw["hidden_size"] + lm_head = nn.Linear(H, V, bias=False).to(device=device, dtype=dtype) + embed = nn.Embedding(V, H).to(device=device, dtype=dtype) + for p in list(lm_head.parameters()) + list(embed.parameters()): + p.requires_grad_(False) + + model = OnlineDominoModel( + draft_model=draft, + target_lm_head=lm_head, + target_embed_tokens=embed, + mask_token_id=dfc["mask_token_id"], + block_size=raw["block_size"], + attention_backend="sdpa", + num_anchors=NUM_ANCHORS, + loss_decay_gamma=7.0, + shift_label=dfc.get("shift_label", True), + ) + n_train = sum(p.numel() for p in draft.parameters() if p.requires_grad) + return model, cfg, n_train + + +def make_inputs(cfg, bsz, dtype, device): + naux = len(cfg.dflash_config["target_layer_ids"]) + g = torch.Generator(device="cpu").manual_seed(0) + input_ids = torch.randint(0, cfg.vocab_size, (bsz, SEQ), generator=g).to(device) + hidden = torch.randn( + bsz, SEQ, naux * cfg.hidden_size, generator=g, dtype=torch.float32 + ).to(device=device, dtype=dtype) + loss_mask = torch.ones(bsz, SEQ, device=device) + return input_ids, hidden, loss_mask + + +def bench(bsz, dtype=torch.bfloat16, device="cuda", iters=10, warmup=3): + model, cfg, n_train = build(dtype, device) + inp = make_inputs(cfg, bsz, dtype, device) + + def step(): + for p in model.draft_model.parameters(): + p.grad = None + loss, _, _ = model(*inp, lambda_base=0.5) + loss.backward() + return loss + + for _ in range(warmup): + step() + torch.cuda.synchronize() + + # forward FLOPs (counts mm/bmm/sdpa; flex_attention may be undercounted) + with torch.no_grad(): + fc = FlopCounterMode(display=False) + with fc: + model(*inp, lambda_base=0.5) + fwd_flop = fc.get_total_flops() + + torch.cuda.reset_peak_memory_stats() + start = torch.cuda.Event(enable_timing=True) + end = torch.cuda.Event(enable_timing=True) + torch.cuda.synchronize() + start.record() + for _ in range(iters): + step() + end.record() + torch.cuda.synchronize() + ms = start.elapsed_time(end) / iters + peak_gb = torch.cuda.max_memory_allocated() / 1e9 + + # total training FLOP ~ 3x forward (1 fwd + 2 bwd), the standard rule + total_flop = 3.0 * fwd_flop + tflops = total_flop / (ms / 1e3) / 1e12 + mfu = 100.0 * tflops / H200_BF16_TFLOPS + per_sample_ms = ms / bsz + print( + f"[bsz={bsz}] step={ms:7.1f}ms per_sample={per_sample_ms:6.1f}ms " + f"fwdFLOP={fwd_flop/1e12:6.2f}T ~totFLOP={total_flop/1e12:6.2f}T " + f"achieved={tflops:6.1f}TFLOP/s MFU={mfu:4.1f}% peakmem={peak_gb:6.1f}GB " + f"draft_params={n_train/1e9:.2f}B", + flush=True, + ) + del model + torch.cuda.empty_cache() + + +if __name__ == "__main__": + print( + f"device={torch.cuda.get_device_name(0)} peak_ref={H200_BF16_TFLOPS} TFLOP/s bf16 dense" + ) + for b in (2, 4, 8): + try: + bench(b) + except RuntimeError as e: + print(f"[bsz={b}] FAILED: {str(e)[:120]}") + torch.cuda.empty_cache() diff --git a/examples/disagg/PERF_FINDINGS.md b/examples/disagg/PERF_FINDINGS.md new file mode 100644 index 000000000..b956525d4 --- /dev/null +++ b/examples/disagg/PERF_FINDINGS.md @@ -0,0 +1,166 @@ +# Domino Disagg — Performance Findings & Tuning Guide + +Perf investigation of online domino-disagg training on a single 8×H200 node +(1 patched SGLang capture server + 7 FSDP draft trainers, Qwen3-8B target + +~1.1 B DFlash draft). All throughput numbers are **total-samples ÷ total-time +integrated over ≥390 s** — single ~20 s windows are unreliable here (bursty +supply, ~1.8× spread). + +## Headline + +Config/env tuning alone took the 1srv+DP7 setup from **28.8 → 50.1 samples/s +(+74%)** with util/SM/mem healthy on all 8 GPUs (server util 98% / SM 76%; +trainers util 77–100% / SM 76–85%). No model or default-behavior change — every +lever below is an env knob that is off by default. + +## Best config (settings only) + +```bash +U=http://127.0.0.1:30000 +export DISAGG_SERVER_URLS="$U,$U,$U,$U,$U,$U,$U,$U" # 8 concurrent producer workers +export CLONE_ON_FETCH=0 # skip the redundant clone on the mooncake zero-copy path +export LOADER_PREFETCH=2 # background-thread batch prefetch (hide fetch latency) +export SERVER_MEM_FRACTION=0.5 # right-size the server KV reservation (126 GB -> ~78 GB) +ACCUM=8 BATCH_SIZE=2 MOONCAKE_PROTOCOL=rdma MOONCAKE_RDMA_DEVICES=mlx5_0,...,mlx5_7 +# then the usual run_qwen3_8b_domino_disagg_1srv_dp7.sh invocation +``` + +## What each lever does (and why) + +| Lever | Effect | Mechanism | +|---|---|---| +| `DISAGG_SERVER_URLS` = URL repeated N× | breaks the single-producer ceiling | N concurrent rollout workers with disjoint leases drive one server (the blocking HTTP prefill call releases the GIL) | +| `ACCUM=8` | 460 → ~280 ms/microstep | `no_sync` grad accumulation amortizes the FSDP reduce-scatter across 8 microsteps (comm drops to ~1 ms/microstep) | +| `CLONE_ON_FETCH=0` | −15 ms/batch | the mooncake zero-copy `get()` already allocates a fresh tensor; the extra defensive clone is redundant | +| `LOADER_PREFETCH=2` | removes fetch from the step | a background thread materializes batches ahead so the training step never pays get/collate latency inline | +| `SERVER_MEM_FRACTION=0.5` | server 126 → 78 GB | the default 0.85 hoards KV cache the capture-only server never uses; no perf cost | + +Also added (server-side, in `patches/sglang/.../spec-capture.patch`): the capture +sink keeps its hidden-state slices on GPU and does one `torch.cat` + a single D2H +per request instead of a per-prefill-batch unpinned copy (`d2h` 5–8 → ~3.8 ms/sample). + +## The pipeline is a supply/demand seesaw on 8 GPUs + +At 50 samples/s the system is **supply-bound**: the single server can just barely +feed 7 trainers (loaders wait ~40 ms/batch; producer `in_flight` stays low). The +GPU split is a genuine trade-off, and 1:7 wins: + +| Split | Throughput | Regime | +|---|---|---| +| **1 srv + 7 trn** | **50.1/s** | supply-bound (best) | +| 2 srv + 6 trn (b2) | 39.0/s | demand-bound (only 6 trainers) | +| 2 srv + 6 trn (b8) | 39.5/s | demand-bound (bigger batch changes nothing) | + +Each trainer GPU ≈ 7 samples/s; one server ≈ 52–57 samples/s. Seven trainers +(~50/s demand) are almost exactly balanced by one server, so trading a trainer +for a second server loses more than it gains. + +## Bigger batch does NOT help (and ~100% memory is an anti-goal) + +| batch | accum | throughput | trainer mem | trainer util | +|---|---|---|---|---| +| **2** | **8** | **50.1/s** | 45 GB (~31%) | 77–100% | +| 4 | 4 | 47.5/s | 65 GB (~45%) | 64–99% | +| 8 | 2 | 47.2/s | 113 GB (~79%) | 100% | + +Memory does climb toward full with batch (b8 → 113 GB, would OOM ~b12–16) but +throughput *falls* — bigger batch just makes each fetch 4× heavier (`get_ms` +20→90) while per-sample compute stays flat. Low trainer memory (~31%) is the +**efficient** state, not a symptom. The trainer is compute-bound at a healthy +**~44% MFU** (measured — see the MFU section below), not memory-bound; +"full util/memory" ≠ "fast". + +## Why is per-sample trainer demand so low? (num_anchors) + +Per-microstep compute (batch 2, `PROFILE_STEPS`, cuda-synchronized — trust the +composition, not the absolute): + +| num_anchors | fwd | bwd | opt | data_wait | +|---|---|---|---|---| +| 256 (default) | ~88 ms | ~150 ms | 1.3 ms | ~40 ms (14%) | +| 64 | ~43 ms | ~58 ms | 1.3 ms | ~145 ms (38%) — now supply-starved | + +The domino/DFlash draft training forward **expands every sample to +`num_anchors × block_size` = 256 × 16 = 4096 draft positions** (independent of +sequence length), each pushed through the 5-layer draft plus a full +151,936-vocab head. Fitting `compute ≈ fixed + k·anchors`: **~53 ms fixed + ~0.73 +ms/anchor**, so **~78% of the ~240 ms/microstep compute at 256 anchors is the +anchor expansion.** Cutting anchors to 64 more than halves compute — and the +trainer immediately becomes supply-starved (data_wait 40 → 145 ms), which both +confirms the anchor expansion is the demand driver *and* re-exposes the server as +the next wall. + +So the low demand is **by design, not inefficiency**: domino deliberately does +256× the per-token prediction work to densify the draft's training signal. The +optimizer/comm is a non-factor under `ACCUM=8` (1.3 ms/microstep). + +**`num_anchors` is NOT a throughput lever — do not reduce it for speed.** The +metric that matters is training signal per second (anchor-updates/s), not +sequences/s. Because each anchor is a supervised training target, and there is a +fixed ~53 ms/microstep per-sequence overhead (base forward + embedding + GRU + +the vocab head over the base positions), *fewer* anchors amortize that fixed cost +over less signal: at 256 anchors ≈ 2,130 anchor-updates/s vs at 64 anchors ≈ +1,280 anchor-updates/s. So dropping anchors makes sequences/s rise but +training-signal/s **fall ~40%**, and — since the pipeline is supply-bound — +forces you to capture ~4× more sequences for the same signal, piling load onto +the exact bottleneck. Treat `num_anchors` purely as a quality/data-efficiency +setting (validated on the acceptance-length curve); the launcher keeps it fixed +at 256. The real memory/compute lever is the **full-vocab loss layer** (next +section), which is reducible without touching the training signal. + +## MFU / FLOPs (both sides ≈ 43–44%, compute-bound) + +**Trainer — measured** (`bench_domino_mfu.py`, isolated single GPU, no data-path +/ no DP comm, bf16, real qwen3-8b-domino shapes, `num_anchors=256`, seq 768; FLOPs +counted with `torch.utils.flop_counter`, timed with CUDA events): + +| bsz | step | per-sample | FLOP/sample (fwd+bwd) | achieved | MFU | peak mem | +|---|---|---|---|---|---|---| +| 2 | 211 ms | 105.6 ms | ~45 T | 430 TFLOP/s | **43.5%** | 25 GB | +| 4 | 416 ms | 103.9 ms | ~45 T | 437 TFLOP/s | 44.1% | 46 GB | +| 8 | 836 ms | 104.6 ms | ~45 T | 434 TFLOP/s | 43.9% | 87 GB | + +So the ~1.1 B draft trains at a **healthy ~44% MFU** (typical LLM training is +30–50%), i.e. it is **compute-bound, not stalled**. (This supersedes an earlier +"MFU ~14% / occupied-but-stalled" note, which came from a `cuda.synchronize`- +distorted timing and was wrong.) Per-sample time is **flat across batch** → +confirms it is compute-throughput-bound, not launch/latency-bound, so batch buys +nothing. Memory is ~10 GB/sample (linear) — not a constraint. "Slow in +samples/s" just reflects that one sample is ~45 TFLOP (fwd+bwd) ≈ 9× a normal +1.1 B forward, because of the 256-anchor × double-151,936-vocab structure — the +work is inherent to domino, executed efficiently. + +**Inference (target prefill) — estimated ≈ 43% MFU.** From the 50/s server log: +prefill runs at **~27,000 tokens/s** (mean over 1,080 batches) at ~100% duty; +average prompt ≈ 550 tokens (confirmed two ways: loader bytes, and +49/s × 550 = 27k tok/s). For the 8 B target, `2 × params × tokens` = +2 × 8e9 × 27,000 ≈ **430 TFLOP/s ≈ 43% MFU** — a normal prefill efficiency, not a +capture slowdown. (Estimate from tokens/s, not a direct FLOP count; ignores +attention FLOPs and the 5-aux-layer capture overhead.) + +## Remaining ceilings (need code, not config) + +1. **Supply ≈ 50/s is PREFILL-COMPUTE-BOUND, not sink-bound.** At ≥8 concurrent + producer workers the server GPU prefills ~100% of the time at ~27k tok/s + (~43% MFU on the 8 B target, ~550-tok prompts → ~49 prompts/s). The mooncake + sink thread *keeps up* (~44–57/s ≥ supply) — it is the **next** wall (~55/s, + single thread), not the current one. So an **async/parallel sink is only a + second-order lever** (worthwhile *after* prefill is sped up), NOT the primary + fix. The real supply levers: (a) higher prefill MFU — CUDA graphs for the + capture/piecewise-prefill path, bigger prefill batches, lighter aux-hidden- + state extraction+D2H (async copy stream) → maybe 43%→~55% ≈ +30%; (b) fewer + aux layers (a training change); (c) a 2nd inference GPU (the seesaw — steals a + trainer; 2srv+6t measured 39/s, worse on 8 GPUs). +2. **Trainer demand ≈ 50/s at ~44% MFU** (compute-bound, above). Because the + system is supply-bound, raising trainer MFU (CUDA-graphing the small anchor- + block kernels, kernel fusion, reduced-vocab head) does NOT raise system + throughput today — it only makes the trainer idle more. It matters only once + supply is lifted past the trainer, or to cut trainer GPU cost. `num_anchors` + is **not** a lever here (reducing it lowers training-signal/s — see above). + +## Profiling knobs (all env-gated, default-off) + +`PROFILE_PRODUCER=N` (`[prod]`/`[prod-http]`), `PROFILE_LOADER=N` (`[loader rK]`), +`PROFILE_STORE=N` (`[store rK]`), `PROFILE_DISTRIB=secs` (`[dist]`), +`PROFILE_STEPS=N` (`[profile]`/`[profile2]` data-wait vs fwd/bwd/opt), +`PROFILE_TORCH=N` (rank-0 torch.profiler), `FSDP_SHARDING=NO_SHARD`. diff --git a/examples/disagg/run_qwen3_8b_domino_disagg_1srv_dp7.sh b/examples/disagg/run_qwen3_8b_domino_disagg_1srv_dp7.sh new file mode 100755 index 000000000..24fa0b4bf --- /dev/null +++ b/examples/disagg/run_qwen3_8b_domino_disagg_1srv_dp7.sh @@ -0,0 +1,199 @@ +#!/bin/bash +# Qwen3-8B Domino, ONLINE disaggregated, SINGLE inference server + DP=7 trainer: +# mooncake master -> CPU +# patched SGLang server 0 -> SERVER0_GPUS (1 GPU: frozen Qwen3-8B -> mooncake) +# producer (HTTP driver) -> CPU +# consumer (Domino trainer) -> CONSUMER_GPUS (DP=TRAIN_DP, 7 GPUs) +# Epochs: producer replicates the prompt pool x NUM_EPOCHS (servers are idle, so +# re-capturing is free) because the streaming channel is consume-once. +set -euxo pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +ROOT_DIR=$(dirname "$(dirname "$SCRIPT_DIR")") +export TORCHINDUCTOR_CACHE_DIR=$ROOT_DIR/cache/compiled_kernels +export FLASHINFER_DISABLE_VERSION_CHECK=1 +export PYTHONPATH="$ROOT_DIR:$ROOT_DIR/scripts:${PYTHONPATH:-}" +cd "$ROOT_DIR" + +# --- topology: 1 server x TP=1 + DP=7 trainer (override via env) --- +SERVER0_GPUS=${SERVER0_GPUS:-"0"} +SERVER_TP=${SERVER_TP:-1} +SERVER0_PORT=${SERVER0_PORT:-30000} +TRAIN_DP=${TRAIN_DP:-7} +CONSUMER_GPUS=${CONSUMER_GPUS:-"1,2,3,4,5,6,7"} + +TARGET_MODEL_PATH=${TARGET_MODEL_PATH:-Qwen/Qwen3-8B} +DRAFT_CONFIG_PATH=${DRAFT_CONFIG_PATH:-$ROOT_DIR/configs/qwen3-8b-domino.json} +TRAIN_DATA_PATH=${TRAIN_DATA_PATH:-/sgl-workspace/SpecForge/cache/dataset/perfectblend_train_regen_temperature0_no_think_20w.jsonl} +CHAT_TEMPLATE=${CHAT_TEMPLATE:-qwen} + +AUX_LAYER_IDS=${AUX_LAYER_IDS:-"1 9 17 25 33"} + +MOONCAKE_HOST=${MOONCAKE_HOST:-127.0.0.1} +MOONCAKE_RPC_PORT=${MOONCAKE_RPC_PORT:-35551} +MOONCAKE_HTTP_PORT=${MOONCAKE_HTTP_PORT:-35880} +MOONCAKE_METRICS_PORT=${MOONCAKE_METRICS_PORT:-35903} + +export MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_HOST +export MOONCAKE_MASTER_SERVER_ADDR=$MOONCAKE_HOST:$MOONCAKE_RPC_PORT +export MOONCAKE_METADATA_SERVER=http://$MOONCAKE_HOST:$MOONCAKE_HTTP_PORT/metadata +export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-tcp} +export MOONCAKE_GLOBAL_SEGMENT_SIZE=${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((32 << 30))} +export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-0} +export DISAGG_CLIENT_BUFFER_SIZE=${DISAGG_CLIENT_BUFFER_SIZE:-$((256 << 20))} +if [[ "$DISAGG_CLIENT_SEGMENT_SIZE" -ne 0 ]]; then + echo "DISAGG_CLIENT_SEGMENT_SIZE must be 0 for server-owned captures" >&2 + exit 2 +fi + +export DISAGG_STORE_ID=${DISAGG_STORE_ID:-qwen3-8b-domino-1srv-dp7} +export DISAGG_SERVER_URLS="${DISAGG_SERVER_URLS:-http://127.0.0.1:$SERVER0_PORT}" +export DISAGG_REF_CHANNEL=${DISAGG_REF_CHANNEL:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/refs.jsonl} +DISAGG_DB=${DISAGG_DB:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/run.db} +DISAGG_INBOX_DIR=${DISAGG_INBOX_DIR:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/inboxes} +# 0 = no cap; with NUM_EPOCHS the producer replicates the pool, so leave this +# uncapped and let epochs control the sample volume. +export DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-0} +export DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-0} +# IMPORTANT: for the LR schedule set this to the real step count of the whole +# run = ceil(NUM_EPOCHS * num_filtered_prompts / (TRAIN_DP * BATCH_SIZE)); a +# too-small value decays the LR to ~0 before training finishes. +export DISAGG_TOTAL_STEPS=${DISAGG_TOTAL_STEPS:-6000} +export DISAGG_LOG_INTERVAL=${DISAGG_LOG_INTERVAL:-10} +NUM_EPOCHS=${NUM_EPOCHS:-10} +SAVE_INTERVAL=${SAVE_INTERVAL:-800} +BATCH_SIZE=${BATCH_SIZE:-2} +REPORT_TO=${REPORT_TO:-none} +WANDB_PROJECT=${WANDB_PROJECT:-qwen3-8b-domino-disagg} + +python - <<'PY' +import torch +try: + from mooncake.store import MooncakeDistributedStore, ReplicateConfig +except Exception as exc: + raise SystemExit(f"Mooncake preflight failed: {type(exc).__name__}: {exc}") from exc +PY +if [[ "$REPORT_TO" == "wandb" ]]; then + python - <<'PY' +import wandb +required = ("login", "init", "log", "finish") +if not all(callable(getattr(wandb, name, None)) for name in required): + raise SystemExit("REPORT_TO=wandb requires a complete W&B client: pip install wandb") +PY +fi + +rm -rf "$(dirname "$DISAGG_REF_CHANNEL")" "$DISAGG_DB" +mkdir -p "$(dirname "$DISAGG_REF_CHANNEL")" +: > "$DISAGG_REF_CHANNEL" +MOONCAKE_LOG=${MOONCAKE_LOG:-$(dirname "$DISAGG_REF_CHANNEL")/mooncake.log} + +cleanup() { + kill "${SERVER0_PID:-}" "${PRODUCER_PID:-}" 2>/dev/null || true + if [[ -n "${MASTER_PID:-}" ]]; then + kill -- "-$MASTER_PID" 2>/dev/null || kill "$MASTER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# --- mooncake master --- +setsid mooncake_master \ + --enable_http_metadata_server=true \ + --rpc_port="$MOONCAKE_RPC_PORT" \ + --http_metadata_server_port="$MOONCAKE_HTTP_PORT" \ + --metrics_port="$MOONCAKE_METRICS_PORT" \ + >"$MOONCAKE_LOG" 2>&1 & +MASTER_PID=$! + +wait_for_mooncake() { + for _ in $(seq 1 30); do + if ! kill -0 "$MASTER_PID" 2>/dev/null; then + echo "Mooncake master exited during startup; see $MOONCAKE_LOG" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + return 1 + fi + if curl -sS --max-time 1 -o /dev/null \ + "$MOONCAKE_METADATA_SERVER?key=specforge-health-check" \ + && timeout 1 bash -c \ + "/dev/null; then + return 0 + fi + sleep 1 + done + echo "Mooncake master did not become ready; see $MOONCAKE_LOG" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + return 1 +} +wait_for_mooncake + +# --- single patched SGLang server: frozen target, spec-capture on --- +launch_server() { # $1=gpus $2=port + CUDA_VISIBLE_DEVICES=$1 MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_LOCAL_HOSTNAME \ + python -m sglang.launch_server \ + --model-path "$TARGET_MODEL_PATH" \ + --trust-remote-code \ + --skip-tokenizer-init \ + --tp-size "$SERVER_TP" \ + --mem-fraction-static "${SERVER_MEM_FRACTION:-0.85}" \ + --chunked-prefill-size -1 \ + --disable-radix-cache \ + --enable-spec-capture \ + --spec-capture-method dflash \ + --spec-capture-aux-layer-ids $AUX_LAYER_IDS \ + --port "$2" & +} +launch_server "$SERVER0_GPUS" "$SERVER0_PORT" +SERVER0_PID=$! + +until curl -sf "http://127.0.0.1:$SERVER0_PORT/health" > /dev/null; do + if ! kill -0 "$MASTER_PID" 2>/dev/null; then + echo "Mooncake master died while SGLang server was starting" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + exit 1 + fi + if ! kill -0 "$SERVER0_PID" 2>/dev/null; then echo "server on :$SERVER0_PORT died"; exit 1; fi + sleep 5 +done + +ARGS=( + --target-model-path "$TARGET_MODEL_PATH" + --target-model-backend hf + --trust-remote-code + --draft-config-path "$DRAFT_CONFIG_PATH" + --mask-token-id 151669 + --train-data-path "$TRAIN_DATA_PATH" + --chat-template "$CHAT_TEMPLATE" + --max-length 3072 + --batch-size ${BATCH_SIZE} + --accumulation-steps ${ACCUM:-1} + --learning-rate 6e-4 + --warmup-ratio 0.04 + --max-grad-norm 1.0 + --attention-backend flex_attention + --block-size 16 + --num-anchors 256 + --loss-decay-gamma 7.0 + --num-epochs ${NUM_EPOCHS} + --seed 42 + --save-interval ${SAVE_INTERVAL} + --lambda-base-start 1.0 + --lambda-base-decay-ratio 1.0 +) + +LAUNCHER=$SCRIPT_DIR/run_disagg_domino.py + +DISAGG_ROLE=producer CUDA_VISIBLE_DEVICES="" \ + python "$LAUNCHER" "${ARGS[@]}" \ + --output-dir "$ROOT_DIR/outputs/$DISAGG_STORE_ID-producer" & +PRODUCER_PID=$! + +CUDA_VISIBLE_DEVICES=$CONSUMER_GPUS DISAGG_ROLE=consumer \ + DISAGG_DB=$DISAGG_DB DISAGG_INBOX_DIR=$DISAGG_INBOX_DIR \ + torchrun --rdzv-backend c10d --rdzv-endpoint localhost:29702 \ + --nnodes 1 --nproc_per_node "$TRAIN_DP" "$LAUNCHER" "${ARGS[@]}" \ + --output-dir "$ROOT_DIR/outputs/$DISAGG_STORE_ID-consumer" \ + --report-to "$REPORT_TO" \ + --wandb-project "$WANDB_PROJECT" \ + --wandb-name "qwen3-8b-domino-1srv-dp$TRAIN_DP" + +wait $PRODUCER_PID +echo "QWEN3-8B-DOMINO-1SRV-DP7-DONE" diff --git a/specforge/inference/adapters/server_capture.py b/specforge/inference/adapters/server_capture.py index 81befefca..c1e8314c8 100644 --- a/specforge/inference/adapters/server_capture.py +++ b/specforge/inference/adapters/server_capture.py @@ -327,14 +327,37 @@ def produce_refs( transport-level error raises — the worker fails the whole lease batch retryable, mirroring ``generate_features`` semantics. """ + import os as _os + import time as _time + + # PROFILE_PRODUCER=N -> every N produce_refs calls print one + # [prod-http] line splitting body-build / HTTP round-trip / parse. + _prof = int(_os.environ.get("PROFILE_PRODUCER", "0")) + if _prof and not hasattr(self, "_hs"): + self._hs = { + "calls": 0, + "n": 0, + "tok": 0, + "build": 0.0, + "http": 0.0, + "parse": 0.0, + } + _t = _time.monotonic() body = { "input_ids": [list(t.payload["input_ids"]) for t in tasks], "sampling_params": {"temperature": 0.0, "max_new_tokens": 1}, "spec_capture": [self._spec_capture_payload(t) for t in tasks], } + if _prof: + self._hs["build"] += _time.monotonic() - _t + self._hs["tok"] += sum(len(t.payload["input_ids"]) for t in tasks) + _t = _time.monotonic() rows = self.post_fn( f"{self.base_url}/generate", json_body=body, timeout=self.timeout_s ) + if _prof: + self._hs["http"] += _time.monotonic() - _t + _t = _time.monotonic() rows = _flatten_list_wrappers(rows) if len(rows) != len(tasks): raise RuntimeError( @@ -429,6 +452,27 @@ def produce_refs( continue self.store.adopt(ref) out.append(ref) + if _prof: + self._hs["parse"] += _time.monotonic() - _t + self._hs["calls"] += 1 + self._hs["n"] += len(tasks) + if self._hs["calls"] >= _prof: + h = self._hs + print( + f"[prod-http] calls={h['calls']} n={h['n']} tok={h['tok']} " + f"build_ms={1000*h['build']/h['calls']:.1f} " + f"http_ms={1000*h['http']/h['calls']:.1f} " + f"parse_ms={1000*h['parse']/h['calls']:.1f} (avg/call)", + flush=True, + ) + self._hs = { + "calls": 0, + "n": 0, + "tok": 0, + "build": 0.0, + "http": 0.0, + "parse": 0.0, + } return out def health(self) -> Dict[str, Any]: diff --git a/specforge/launch.py b/specforge/launch.py index c3a15ef24..1fec11336 100644 --- a/specforge/launch.py +++ b/specforge/launch.py @@ -717,6 +717,21 @@ def pool_drained() -> bool: return st["prompts_pending"] == 0 and st["prompts_leased"] == 0 def run_worker(w) -> None: + import os as _os + + # PROFILE_PRODUCER=N -> every N rounds print one [prod] line + # splitting the round into backpressure-park / run_once / publish. + _prof = int(_os.environ.get("PROFILE_PRODUCER", "0")) + _ps = { + "rounds": 0, + "refs": 0, + "bp": 0.0, + "once": 0.0, + "pub": 0.0, + "t0": time.monotonic(), + "infl": 0, + "infl_max": 0, + } failures = 0 last_backpressure_log = 0.0 for _ in range(max_rounds): @@ -742,6 +757,12 @@ def run_worker(w) -> None: if should_stop is not None and should_stop(): return sleep(backpressure_poll_s) + _infl = channel.in_flight_remote() + if _prof: + _ps["bp"] += time.monotonic() - _t + _ps["infl"] = _infl + _ps["infl_max"] = max(_ps["infl_max"], _infl) + _t = time.monotonic() try: run_once_start = time.perf_counter() refs = w.run_once(max_tasks=lease) @@ -769,7 +790,10 @@ def run_worker(w) -> None: sleep(backpressure_poll_s) continue failures = 0 + if _prof: + _ps["once"] += time.monotonic() - _t if refs: + _t = time.monotonic() with publish_lock: channel.publish_many(refs) state["produced"] += len(refs) diff --git a/specforge/runtime/data_plane/feature_dataloader.py b/specforge/runtime/data_plane/feature_dataloader.py index 4118bbaa2..1ca33086e 100644 --- a/specforge/runtime/data_plane/feature_dataloader.py +++ b/specforge/runtime/data_plane/feature_dataloader.py @@ -16,6 +16,7 @@ from __future__ import annotations +import os import time from typing import Any, Callable, Dict, Iterator, List, Optional @@ -63,6 +64,10 @@ def __init__( self.collate_fn = collate_fn or _default_collate self.per_sample_transform = per_sample_transform self.device = device + # CLONE_ON_FETCH=0 skips the defensive clone; safe for the mooncake + # zero-copy path, whose get() already allocates a fresh tensor. + if os.environ.get("CLONE_ON_FETCH", "1") == "0": + clone_on_fetch = False self.clone_on_fetch = clone_on_fetch self.drop_last = drop_last self.strategy = strategy @@ -123,12 +128,47 @@ def _validate_refs(self, refs: List[SampleRef]) -> None: f"feature {name!r}: {spec} vs {expected}" ) + # PROFILE_LOADER=N -> every N batches print one [loader rK] line splitting + # queue-wait / store-get / clone / release / gc time per batch. + _prof_loader = int(os.environ.get("PROFILE_LOADER", "0")) + + def _lp(self) -> dict: + s = getattr(self, "_lp_state", None) + if s is None: + s = { + "b": 0, + "wait": 0.0, + "get": 0.0, + "clone": 0.0, + "rel": 0.0, + "gc": 0.0, + "bytes": 0, + "t0": time.monotonic(), + } + self._lp_state = s + return s + def _materialize(self, ref: SampleRef) -> Dict[str, torch.Tensor]: + _prof = self._prof_loader + _t = time.monotonic() tensors, handle = self.store.get(ref, device=self.device) + if _prof: + s = self._lp() + s["get"] += time.monotonic() - _t + s["bytes"] += sum(t.numel() * t.element_size() for t in tensors.values()) + _t = time.monotonic() if self.clone_on_fetch: tensors = {k: v.clone() for k, v in tensors.items()} + if _prof: + self._lp()["clone"] += time.monotonic() - _t + _t = time.monotonic() self.store.release(handle, reason="loaded") + if _prof: + self._lp()["rel"] += time.monotonic() - _t + _t = time.monotonic() self._maybe_gc() + if _prof: + self._lp()["gc"] += time.monotonic() - _t if self.per_sample_transform is not None: tensors = self.per_sample_transform(tensors) return tensors @@ -197,8 +237,21 @@ def seek(self, num_batches: int) -> None: self._seek_batches = skip def _iter_queue(self) -> Iterator[TrainBatch]: + # LOADER_PREFETCH=N (>0) materializes up to N batches ahead on a + # background thread so the training step never pays fetch latency + # inline. Ack still happens on the consuming thread AFTER the trainer + # has taken the batch (same in-flight semantics as the sync path). + depth = int(os.environ.get("LOADER_PREFETCH", "0")) + if depth > 0: + yield from self._iter_queue_prefetch(depth) + return + _prof = self._prof_loader while True: + _t = time.monotonic() refs = self.queue.get(self.batch_size, timeout_s=0.0) + if _prof: + s = self._lp() + s["wait"] += time.monotonic() - _t if not refs: return if self.drop_last and len(refs) < self.batch_size: @@ -210,6 +263,87 @@ def _iter_queue(self) -> Iterator[TrainBatch]: except Exception as exc: self.queue.fail(refs, reason=f"materialize:{exc}", retryable=False) raise + if _prof: + s = self._lp() + s["b"] += 1 + if s["b"] >= _prof: + win = time.monotonic() - s["t0"] + rank = os.environ.get("RANK", "?") + print( + f"[loader r{rank}] b={s['b']} win_s={win:.2f} " + f"wait_ms={1000*s['wait']/s['b']:.1f} " + f"get_ms={1000*s['get']/s['b']:.1f} " + f"clone_ms={1000*s['clone']/s['b']:.1f} " + f"rel_ms={1000*s['rel']/s['b']:.1f} " + f"gc_ms={1000*s['gc']/s['b']:.1f} " + f"MB={s['bytes']/s['b']/1e6:.1f} (avg/batch)", + flush=True, + ) + self._lp_state = None + yield batch + if self.ack: + self.queue.ack(refs) + + def _iter_queue_prefetch(self, depth: int) -> Iterator[TrainBatch]: + import queue as _queue + import threading + + _prof = self._prof_loader + buf: "_queue.Queue" = _queue.Queue(maxsize=depth) + _EOS = object() + + def _worker() -> None: + try: + while True: + refs = self.queue.get(self.batch_size, timeout_s=0.0) + if not refs: + buf.put(_EOS) + return + if self.drop_last and len(refs) < self.batch_size: + self.queue.fail(refs, reason="drop_last", retryable=True) + buf.put(_EOS) + return + try: + batch = self._make_batch(refs) + except Exception as exc: + self.queue.fail( + refs, reason=f"materialize:{exc}", retryable=False + ) + buf.put(exc) + return + buf.put((batch, refs)) + except BaseException as exc: # loud failure, never a silent hang + buf.put(exc) + + threading.Thread(target=_worker, name="loader-prefetch", daemon=True).start() + while True: + _t = time.monotonic() + item = buf.get() + if _prof: + s = self._lp() + s["wait"] += time.monotonic() - _t + if item is _EOS: + return + if isinstance(item, BaseException): + raise item + batch, refs = item + if _prof: + s = self._lp() + s["b"] += 1 + if s["b"] >= _prof: + win = time.monotonic() - s["t0"] + rank = os.environ.get("RANK", "?") + print( + f"[loader r{rank}] b={s['b']} win_s={win:.2f} " + f"wait_ms={1000*s['wait']/s['b']:.1f} " + f"get_ms={1000*s['get']/s['b']:.1f} " + f"clone_ms={1000*s['clone']/s['b']:.1f} " + f"rel_ms={1000*s['rel']/s['b']:.1f} " + f"gc_ms={1000*s['gc']/s['b']:.1f} " + f"MB={s['bytes']/s['b']/1e6:.1f} (avg/batch, prefetch)", + flush=True, + ) + self._lp_state = None yield batch if self.ack: self.queue.ack(refs) diff --git a/specforge/runtime/data_plane/mooncake_store.py b/specforge/runtime/data_plane/mooncake_store.py index 79fb761dd..c05a567e3 100644 --- a/specforge/runtime/data_plane/mooncake_store.py +++ b/specforge/runtime/data_plane/mooncake_store.py @@ -54,6 +54,7 @@ import io import logging +import os import threading import time import uuid @@ -253,8 +254,53 @@ def _tkey(self, sample_id: str, gen: int, name: str) -> str: # keys are gone -> get() raises (B5), no payload-carried gen needed. return f"{self.store_id}/{sample_id}/g{gen}/{name}" + # PROFILE_STORE=N -> every N get ops print one [store rK] line with + # get/exists/remove latency, get bandwidth, and release-pending depth. + _prof_store = int(os.environ.get("PROFILE_STORE", "0")) + + def _sp(self) -> dict: + s = getattr(self, "_sp_state", None) + if s is None: + s = { + "get_n": 0, + "get_s": 0.0, + "get_b": 0, + "ex_n": 0, + "ex_s": 0.0, + "put_n": 0, + "put_s": 0.0, + "put_b": 0, + "rm_ok": 0, + "rm_fail": 0, + "gc_s": 0.0, + } + self._sp_state = s + return s + + def _sp_print(self) -> None: + s = self._sp() + rank = os.environ.get("RANK", "?") + get_gbps = s["get_b"] / s["get_s"] / 1e9 if s["get_s"] else 0.0 + put_gbps = s["put_b"] / s["put_s"] / 1e9 if s["put_s"] else 0.0 + print( + f"[store r{rank}] gets={s['get_n']} get_ms={1000*s['get_s']/max(1,s['get_n']):.1f} " + f"get_GBps={get_gbps:.2f} exists_ms={1000*s['ex_s']/max(1,s['ex_n']):.2f} " + f"puts={s['put_n']} put_GBps={put_gbps:.2f} " + f"rm_ok={s['rm_ok']} rm_fail={s['rm_fail']} " + f"pend={len(self._release_pending)} gc_ms={1000*s['gc_s']:.0f}", + flush=True, + ) + self._sp_state = None + # -- store wrappers (status-code aware) -------------------------------- def _store_exists(self, key: str) -> bool: + if self._prof_store: + s = self._sp() + _t = time.monotonic() + rc = int(self._store.is_exist(key)) == 1 + s["ex_s"] += time.monotonic() - _t + s["ex_n"] += 1 + return rc return int(self._store.is_exist(key)) == 1 def _store_put(self, key: str, value: bytes) -> None: @@ -273,6 +319,7 @@ def _store_put_tensor(self, key: str, t: torch.Tensor) -> None: the registration. """ nb = _nbytes(t) + _t0 = time.monotonic() if self._prof_store else 0.0 try: self._store.register_buffer(t.data_ptr(), nb) except Exception: # pragma: no cover - some builds auto-register @@ -284,6 +331,11 @@ def _store_put_tensor(self, key: str, t: torch.Tensor) -> None: self._store.unregister_buffer(t.data_ptr()) except Exception: # pragma: no cover pass + if self._prof_store: + s = self._sp() + s["put_s"] += time.monotonic() - _t0 + s["put_n"] += 1 + s["put_b"] += nb if rc is not None and int(rc) < 0: raise RuntimeError(f"mooncake put_from failed (status {rc}) for {key}") @@ -293,6 +345,21 @@ def _store_get_tensor(self, key: str, out: torch.Tensor) -> None: The receive buffer is registered with the transfer engine for the get_into (required by the raw-buffer path), then unregistered. """ + if self._prof_store: + s = self._sp() + _t = time.monotonic() + try: + self._store_get_tensor_inner(key, out) + finally: + s["get_s"] += time.monotonic() - _t + s["get_n"] += 1 + s["get_b"] += _nbytes(out) + if s["get_n"] >= self._prof_store: + self._sp_print() + return + self._store_get_tensor_inner(key, out) + + def _store_get_tensor_inner(self, key: str, out: torch.Tensor) -> None: nb = _nbytes(out) try: self._store.register_buffer(out.data_ptr(), nb) @@ -323,8 +390,13 @@ def _store_remove(self, key: str) -> bool: try: rc = self._store.remove(key) except Exception: # pragma: no cover - transient RPC failure + if self._prof_store: + self._sp()["rm_fail"] += 1 return False - return rc is None or int(rc) == 0 + ok = rc is None or int(rc) == 0 + if self._prof_store: + self._sp()["rm_ok" if ok else "rm_fail"] += 1 + return ok # -- write ------------------------------------------------------------- def put( @@ -629,6 +701,7 @@ def abort(self, sample_id: str, *, reason: str = "aborted") -> None: self._release_pending.setdefault(sample_id, 0) def gc(self, *, now: Optional[float] = None) -> Dict[str, int]: + _t0 = time.monotonic() if self._prof_store else 0.0 now = self._clock() if now is None else now freed = freed_bytes = 0 with self._lock: @@ -663,6 +736,8 @@ def gc(self, *, now: Optional[float] = None) -> Dict[str, int]: self._release_pending[sid] = attempts self._stats["force_freed"] += freed self._stats["force_freed_bytes"] += freed_bytes + if self._prof_store: + self._sp()["gc_s"] += time.monotonic() - _t0 return { "force_freed": freed, "force_freed_bytes": freed_bytes, diff --git a/specforge/runtime/data_plane/ref_distributor.py b/specforge/runtime/data_plane/ref_distributor.py index 0382b7e0e..6765fbedf 100644 --- a/specforge/runtime/data_plane/ref_distributor.py +++ b/specforge/runtime/data_plane/ref_distributor.py @@ -132,6 +132,17 @@ def __init__( self.finished = False self.error: Optional[BaseException] = None self.stats = {"dispatched": 0, "skipped": 0, "duplicates": 0, "dropped": 0} + # PROFILE_DISTRIB= -> periodic [dist] line: polled/dispatched + # rates, per-ref commit + count cost, inbox-publish cost, rank lag. + self._prof_s = float(os.environ.get("PROFILE_DISTRIB", "0")) + self._pd = { + "polled": 0, + "disp0": 0, + "commit": 0.0, + "cnt": 0.0, + "inboxpub": 0.0, + "t0": time.monotonic(), + } @staticmethod def inbox_path(inbox_dir: str, dp_rank: int) -> str: @@ -165,6 +176,7 @@ def pump(self) -> bool: raw = self.source.poll() if raw: progress = True + self._pd["polled"] += len(raw) for ref in raw: if self._skip and ref.sample_id in self._skip: # released in a prior run: already counted by that run's @@ -174,12 +186,18 @@ def pump(self) -> bool: if not self._skip: self._skip = None continue + _t = time.monotonic() before = self.controller.store.committed_count() + self._pd["cnt"] += time.monotonic() - _t + _t = time.monotonic() self.controller.commit_samples(self.worker_id, [ref]) + self._pd["commit"] += time.monotonic() - _t + _t = time.monotonic() if self.controller.store.committed_count() == before: # duplicate publication (e.g. producer restart); a # reconcile-requeued ref also lands here and trains later. self.stats["duplicates"] += 1 + self._pd["cnt"] += time.monotonic() - _t # Dispatch every full window: one ref per rank, round-robin — per-rank # counts stay exactly equal, which is what keeps DP ranks in lockstep. @@ -190,8 +208,10 @@ def pump(self) -> bool: self._window.extend(queue.get(need, timeout_s=0.0)) if len(self._window) < self.dp_size: break + _t = time.monotonic() for rank, ref in enumerate(self._window): self._inboxes[rank].publish(ref) + self._pd["inboxpub"] += time.monotonic() - _t self.stats["dispatched"] += self.dp_size self._window = [] progress = True @@ -199,6 +219,33 @@ def pump(self) -> bool: if self._forward_consumed(): progress = True + if self._prof_s: + now = time.monotonic() + win = now - self._pd["t0"] + if win >= self._prof_s: + pd = self._pd + n = max(1, pd["polled"]) + disp = self.stats["dispatched"] - pd["disp0"] + lag = [ + inbox._published - inbox.consumed_remote() + for inbox in self._inboxes + ] + print( + f"[dist] win_s={win:.1f} polled={pd['polled']} disp={disp} " + f"q={queue.depth()} dup={self.stats['duplicates']} " + f"cnt_ms={1000*pd['cnt']/n:.2f} commit_ms={1000*pd['commit']/n:.2f} " + f"inboxpub_ms={1000*pd['inboxpub']/n:.2f} lag={lag} (per-ref avg)", + flush=True, + ) + self._pd = { + "polled": 0, + "disp0": self.stats["dispatched"], + "commit": 0.0, + "cnt": 0.0, + "inboxpub": 0.0, + "t0": now, + } + if not raw and self.source.is_closed() and queue.depth() == 0: self._finish() return progress diff --git a/specforge/training/backend.py b/specforge/training/backend.py index ce53203f0..8fb31e03a 100644 --- a/specforge/training/backend.py +++ b/specforge/training/backend.py @@ -1,10 +1,312 @@ # coding=utf-8 -"""Import shim — moved to ``specforge.training.backend``.""" +# Copyright 2024 The SpecForge team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +"""TrainingBackend: model wrapping / backward / optimizer step / state dict. + +FSDP-only for now. ``ParallelConfig`` carries the parallel handles created by +``init_distributed`` rather than re-deriving them; distributed imports stay +lazy so the module is importable without a GPU. +""" + +from __future__ import annotations + +import abc +import contextlib +import logging +import os +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +import torch +import torch.distributed as dist +import torch.nn as nn + + +@dataclass +class ParallelConfig: + """Handles describing the active parallel layout. Carried, not re-derived.""" + + world_size: int = 1 + tp_size: int = 1 + sp_ulysses_size: int = 1 + sp_ring_size: int = 1 + sharding_strategy: str = "SHARD_GRAD_OP" + param_dtype: torch.dtype = torch.bfloat16 + # opaque process-group / device-mesh handles snapshotted from + # init_distributed, never re-derived (None in single-process). + fsdp_process_group: Any = None + dp_group: Any = None + draft_dp_group: Any = None + tp_group: Any = None + sp_ulysses_group: Any = None + sp_ring_group: Any = None + draft_sp_group: Any = None + device_mesh: Any = None + tp_device_mesh: Any = None + extra: dict = field(default_factory=dict) + + @property + def sp_size(self) -> int: + return self.sp_ulysses_size * self.sp_ring_size + + @classmethod + def from_distributed( + cls, + *, + tp_size: int = 1, + sp_ulysses_size: int = 1, + sp_ring_size: int = 1, + sharding_strategy: str = "SHARD_GRAD_OP", + param_dtype: torch.dtype = torch.bfloat16, + ) -> "ParallelConfig": + """Snapshot all parallel handles (DP/TP/SP groups, device meshes) from + ``init_distributed``; a missing getter is logged, never silently skipped.""" + # Env override for the FSDP sharding strategy — e.g. FSDP_SHARDING=NO_SHARD + # runs DDP-style (full params replicated, one grad all-reduce, no param + # all-gather). Default unchanged when the env var is unset. + sharding_strategy = os.environ.get("FSDP_SHARDING", sharding_strategy) + if not dist.is_initialized(): + return cls( + world_size=1, + tp_size=tp_size, + sp_ulysses_size=sp_ulysses_size, + sp_ring_size=sp_ring_size, + sharding_strategy=sharding_strategy, + param_dtype=param_dtype, + ) + handles: Dict[str, Any] = {} + try: + from specforge import distributed as sfdist + + for name, getter in ( + ("dp_group", "get_dp_group"), + ("draft_dp_group", "get_draft_dp_group"), + ("tp_group", "get_tp_group"), + ("sp_ulysses_group", "get_sp_ulysses_group"), + ("sp_ring_group", "get_sp_ring_group"), + ("draft_sp_group", "get_draft_sp_group"), + ("device_mesh", "get_device_mesh"), + ("tp_device_mesh", "get_tp_device_mesh"), + ): + fn = getattr(sfdist, getter, None) + if fn is None: + continue + try: + handles[name] = fn() + except Exception as exc: # group not built for this config + logging.getLogger(__name__).warning( + "ParallelConfig.from_distributed: %s() unavailable: %s", + getter, + exc, + ) + except Exception as exc: + logging.getLogger(__name__).warning( + "ParallelConfig.from_distributed: specforge.distributed import failed: %s", + exc, + ) + return cls( + world_size=dist.get_world_size(), + tp_size=tp_size, + sp_ulysses_size=sp_ulysses_size, + sp_ring_size=sp_ring_size, + sharding_strategy=sharding_strategy, + param_dtype=param_dtype, + fsdp_process_group=dist.group.WORLD, + **handles, + ) + + +class TrainingBackend(abc.ABC): + name: str + + @abc.abstractmethod + def prepare_model(self, model: nn.Module) -> nn.Module: ... + + @abc.abstractmethod + def backward(self, loss: torch.Tensor, *, is_boundary: bool = True) -> None: ... + + @abc.abstractmethod + def step(self) -> Optional[torch.Tensor]: ... + + @abc.abstractmethod + def state_dict(self) -> dict: ... + + @abc.abstractmethod + def load_state_dict(self, state: dict) -> None: ... + + +class FSDPTrainingBackend(TrainingBackend): + """FSDP1 backend mirroring the legacy SpecForge training math: FSDP with + ``use_orig_params=True`` / bf16 mixed precision over the configured process + group, optimizer targeting the inner trainable submodule.""" + + name = "fsdp" + + def __init__( + self, + parallel_config: ParallelConfig, + *, + optimizer_factory=None, + ) -> None: + self.parallel_config = parallel_config + self._optimizer_factory = optimizer_factory + self.module: Optional[nn.Module] = None + self.optimizer = None + self._wrapped = False + + def prepare_model( + self, + model: nn.Module, + *, + wrap: bool = True, + optimizer_target: Optional[nn.Module] = None, + ) -> nn.Module: + """Register the trainable module, FSDP-wrapping it unless ``wrap=False`` + (single-rank / equivalence runs where sharding would be a no-op).""" + if not wrap: + self.module = model + self._wrapped = False + else: + from torch.distributed.fsdp import FullyShardedDataParallel as FSDP + from torch.distributed.fsdp import MixedPrecision, ShardingStrategy + + pc = self.parallel_config + sharding = getattr(ShardingStrategy, pc.sharding_strategy) + model = FSDP( + model, + use_orig_params=True, + mixed_precision=MixedPrecision( + param_dtype=pc.param_dtype, buffer_dtype=pc.param_dtype + ), + sharding_strategy=sharding, + process_group=pc.fsdp_process_group, + ) + self.module = model + self._wrapped = True + if self._optimizer_factory is not None: + target = optimizer_target if optimizer_target is not None else self.module + self.optimizer = self._optimizer_factory(target) + return self.module + + def set_optimizer(self, optimizer) -> None: + self.optimizer = optimizer + + def backward(self, loss: torch.Tensor, *, is_boundary: bool = True) -> None: + """Backward one micro-step. FSDP reduce-scatters grads on every backward, + so non-boundary micro-steps run under ``no_sync()`` and the boundary + backward reduces the accumulated sum once — identical math, one + collective per optimizer step.""" + if is_boundary or not self._wrapped: + loss.backward() + else: + with self.module.no_sync(): + loss.backward() + + def step(self) -> Optional[torch.Tensor]: + """Optimizer step + the distributed grad-norm reduction (run_backward_and_update).""" + if self.optimizer is None: + raise RuntimeError( + "FSDPTrainingBackend.step called before optimizer is set" + ) + grad_norm = self.optimizer.step() + if grad_norm is not None and dist.is_initialized(): + grad_norm = grad_norm.detach().float() + if torch.cuda.is_available(): + grad_norm = grad_norm.to(torch.cuda.current_device()) + grad_norm = grad_norm.pow(2) + dist.all_reduce(grad_norm, op=dist.ReduceOp.SUM) + grad_norm = grad_norm.sqrt() + return grad_norm + + def state_dict(self) -> dict: + """Full training state ``{"model", "optimizer", "rng"}`` for resume. + + ``model`` is gathered rank0-only with CPU offload (``{}`` on other ranks + when wrapped); ``optimizer``/``rng`` are rank-local and must be persisted + per rank — restoring rank0's copy everywhere corrupts the other ranks. + """ + if self.module is None: + raise RuntimeError("state_dict called before prepare_model") + return { + "model": self._module_state_dict(), + "optimizer": ( + self.optimizer.state_dict() if self.optimizer is not None else None + ), + "rng": self._rng_state(), + } + + def load_state_dict(self, state: dict) -> None: + """Restore whichever of module weights / optimizer / RNG the state carries.""" + if state.get("model") is not None: + self._load_module_state_dict(state["model"]) + if self.optimizer is not None and state.get("optimizer") is not None: + self.optimizer.load_state_dict(state["optimizer"]) + if state.get("rng") is not None: + self._set_rng_state(state["rng"]) + + def _full_state_ctx(self, state_dict_config=None): + """FULL_STATE_DICT context for a wrapped module; a no-op when unwrapped.""" + if not self._wrapped: + return contextlib.nullcontext() + from torch.distributed.fsdp import FullyShardedDataParallel as FSDP + from torch.distributed.fsdp import StateDictType + + return FSDP.state_dict_type( + self.module, StateDictType.FULL_STATE_DICT, state_dict_config + ) + + def _module_state_dict(self) -> dict: + if not self._wrapped: + return self.module.state_dict() + from torch.distributed.fsdp import FullStateDictConfig + + # gather to rank0 CPU only — materializing the full model on every + # rank's GPU is wasted memory when only rank0 writes it. + cfg = FullStateDictConfig(offload_to_cpu=True, rank0_only=True) + with self._full_state_ctx(cfg): + return self.module.state_dict() + + def _load_module_state_dict(self, model_state: dict) -> None: + # every rank loads the full state dict read from the shared file. + with self._full_state_ctx(): + self.module.load_state_dict(model_state) + + @staticmethod + def _rng_state() -> dict: + # single bound-device CUDA state keeps the checkpoint independent of + # how many devices happen to be visible at save time. + return { + "torch": torch.get_rng_state(), + "cuda": ( + torch.cuda.get_rng_state(torch.cuda.current_device()) + if torch.cuda.is_available() + else None + ), + } + + @staticmethod + def _set_rng_state(rng: dict) -> None: + cpu_state = rng.get("torch", rng.get("cpu")) # "cpu" = legacy key + if cpu_state is not None: + torch.set_rng_state(cpu_state) + cuda_state = rng.get("cuda") + if cuda_state is None or not torch.cuda.is_available(): + return + device = torch.cuda.current_device() + if isinstance(cuda_state, list): # legacy get_rng_state_all format + if device >= len(cuda_state): + raise ValueError( + f"legacy RNG checkpoint holds {len(cuda_state)} CUDA states " + f"but this rank's bound device index is {device}; it was " + "saved with fewer visible devices and cannot be restored here" + ) + cuda_state = cuda_state[device] + torch.cuda.set_rng_state(cuda_state, device) -from specforge.training.backend import ( # noqa: F401 - FSDPTrainingBackend, - ParallelConfig, - TrainingBackend, -) __all__ = ["ParallelConfig", "TrainingBackend", "FSDPTrainingBackend"] diff --git a/specforge/training/controller.py b/specforge/training/controller.py index 1134a5b48..8c292a347 100644 --- a/specforge/training/controller.py +++ b/specforge/training/controller.py @@ -1,7 +1,13 @@ # coding=utf-8 -"""Import shim — moved to ``specforge.training.controller``.""" +# Copyright 2024 The SpecForge team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +"""TrainerCore + TrainerController: the trainer-boundary split. -<<<<<<<< HEAD:specforge/training/controller.py ``TrainerCore`` runs exactly one branch-free step (strategy forward/loss, backend backward/step) plus the grad-accumulation boundary. ``TrainerController`` owns the lifecycle: fit / evaluate / save_checkpoint. EAGLE3 and DFlash share this @@ -24,13 +30,480 @@ DraftTrainStrategy, StepContext, StepOutput, -======== -from specforge.training.controller import ( # noqa: F401 - Checkpoint, - StepResult, - TrainerController, - TrainerCore, ->>>>>>>> 3ae106c ([DataFlow runtime] E0 — layout consolidation: runtime/ is substrate-only (move-only)):specforge/runtime/training/trainer.py ) +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Checkpoint: + """A saved training checkpoint location (resume target) — deliberately NOT a + published weight version (weight publication is not implemented).""" + + checkpoint_uri: str + global_step: int + epoch: int + strategy: str + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class StepResult: + """Result of one TrainerCore step; ``optimizer_stepped`` is the authoritative + grad-accumulation boundary signal.""" + + optimizer_stepped: bool + loss: float + grad_norm: Optional[float] + metrics: Dict[str, Any] = field(default_factory=dict) + + +def _scalar(x: Any) -> float: + if isinstance(x, torch.Tensor): + return float(x.detach().float().mean().item()) + if isinstance(x, (list, tuple)) and x: + return float(torch.stack([t.detach().float() for t in x]).mean().item()) + return float(x) + + +class TrainerCore: + """One step: forward/loss (strategy) -> backward (backend) -> optimizer boundary.""" + + def __init__( + self, + strategy: DraftTrainStrategy, + backend: TrainingBackend, + *, + accumulation_steps: int = 1, + ) -> None: + self.strategy = strategy + self.backend = backend + self.accumulation_steps = max(1, accumulation_steps) + self._micro = 0 + + def train_step( + self, batch: TrainBatch, ctx: Optional[StepContext] = None + ) -> StepResult: + import os as _os + + _P = int(_os.environ.get("PROFILE_STEPS", "0")) + if _P: + import time as _t + + torch.cuda.synchronize() + _a0 = _t.perf_counter() + out: StepOutput = self.strategy.forward_loss(batch, ctx) + loss = out.loss / self.accumulation_steps + self._micro += 1 + # The boundary is known before backward so the backend can defer the FSDP + # gradient reduction (no_sync) on non-boundary micro-steps. + stepped = self._micro % self.accumulation_steps == 0 + if _P: + torch.cuda.synchronize() + _a1 = _t.perf_counter() + self.backend.backward(loss, is_boundary=stepped) + if _P: + torch.cuda.synchronize() + _a2 = _t.perf_counter() + grad_norm = self.backend.step() if stepped else None + if _P: + torch.cuda.synchronize() + _a3 = _t.perf_counter() + acc = getattr(self, "_prof2", None) + if acc is None: + acc = { + "fwd": 0.0, + "bwd": 0.0, + "opt": 0.0, + "n": 0, + "B": 0, + "padT": 0, + "useful": 0.0, + } + self._prof2 = acc + acc["fwd"] += _a1 - _a0 + acc["bwd"] += _a2 - _a1 + acc["opt"] += _a3 - _a2 + acc["n"] += 1 + try: + _tt = batch.tensors + _lm = _tt.get("loss_mask") + _ref = _lm if _lm is not None else _tt.get("input_ids") + if _ref is not None and _ref.dim() >= 2: + acc["B"] += int(_ref.shape[0]) + acc["padT"] += int(_ref.shape[-1]) + if _lm is not None: + acc["useful"] += float(_lm.sum().item()) + except Exception: + pass + if acc["n"] >= _P: + _n = acc["n"] + print( + f"[profile2] n={_n} fwd={acc['fwd']/_n*1000:.1f}ms " + f"bwd={acc['bwd']/_n*1000:.1f}ms opt={acc['opt']/_n*1000:.1f}ms | " + f"avgB={acc['B']/_n:.1f} avgPadT={acc['padT']/_n:.0f} " + f"avgUsefulTok={acc['useful']/_n:.0f}", + flush=True, + ) + self._prof2 = None + return self._result(out, grad_norm, stepped) + + # Deliberately no eval_step: evaluation runs ``Evaluator.run`` on raw + # ``forward_loss`` outputs — ``_result`` scalarizes away the per-position + # count tensors that correct acc-len aggregation needs. + + def _result(self, out: StepOutput, grad_norm, stepped: bool) -> StepResult: + metrics: Dict[str, Any] = {"loss": _scalar(out.loss)} + for key in ("acces", "acceptance_rates", "plosses"): + if key in out.metrics: + metrics[key.rstrip("es") if key == "acces" else key] = _scalar( + out.metrics[key] + ) + if "accuracy" in out.metrics: + metrics["acc"] = _scalar(out.metrics["accuracy"]) + gn = _scalar(grad_norm) if grad_norm is not None else None + if gn is not None: + metrics["grad_norm"] = gn + return StepResult( + optimizer_stepped=stepped, + loss=metrics["loss"], + grad_norm=gn, + metrics=metrics, + ) + + +class TrainerController: + """Lifecycle: fit / evaluate / checkpoint. The training script becomes a + launcher; weight publishing is not implemented — ``save_checkpoint`` persists + resume state and returns a :class:`Checkpoint`.""" + + def __init__( + self, + core: TrainerCore, + *, + run_id: str, + output_dir: str = "./output", + save_interval: int = 0, + eval_interval: int = 0, + log_interval: int = 50, + max_steps: Optional[int] = None, + total_steps: Optional[int] = None, + num_epochs: int = 1, + logger: Optional[Callable[[Dict[str, Any], int], None]] = None, + ack_fn: Optional[Callable[[List[str], int], None]] = None, + start_step: int = 0, + start_epoch: int = 0, + start_batch: int = 0, + start_samples: int = 0, + checkpoint_manager: Optional[Any] = None, + checkpoint_extra: Optional[Dict[str, Any]] = None, + ) -> None: + if (start_batch == 0) != (start_samples == 0): + raise ValueError( + f"start_batch={start_batch} and start_samples={start_samples} " + f"describe the same mid-epoch position and must be zero or " + f"nonzero together" + ) + self.core = core + self.run_id = run_id + self.output_dir = output_dir + self.save_interval = save_interval + self.eval_interval = eval_interval + self.log_interval = log_interval + # Injected manager (rotation, best metric) or the lazy default layout. + self._checkpoint_mgr = checkpoint_manager + # Extra entries merged into the shared checkpoint payload at save + # (e.g. dataset_size / accumulation_steps, validated on resume). + self.checkpoint_extra = dict(checkpoint_extra or {}) + self.max_steps = max_steps + # Schedule horizon for step-dependent losses (Domino's lambda_base decay); + # distinct from max_steps, an optional early-stop CAP. Falls back to + # max_steps; None means schedule-reading strategies decay nothing. + self.total_steps = total_steps if total_steps is not None else max_steps + self.num_epochs = num_epochs + self.logger = logger + # ack_fn(sample_ids, global_step) records the durable ack transaction at + # the optimizer-step boundary; None = the loader acks (simple runs). + self.ack_fn = ack_fn + # global_step counts OPTIMIZER steps (increments only at a grad-accum + # boundary) so ack/checkpoint/resume semantics are in true optimizer + # steps; micro_step counts forward/backward micro-batches. + self.global_step = start_step + self.micro_step = 0 + self.epoch = start_epoch + # Live position within the current epoch, in batches and in SAMPLES + # (batch-size independent, the persisted form). Nonzero at an epoch start + # — seeded here on resume, or left over from a mid-epoch max_steps return + # — makes fit() skip that prefix instead of re-training it. + self._epoch_batch = start_batch + self._epoch_samples = start_samples + self.last_metrics: Dict[str, Any] = {} + + def fit( + self, data: Iterable[TrainBatch], eval_data: Optional[Iterable] = None + ) -> int: + if self.max_steps is not None and self.global_step >= self.max_steps: + logger.info( + "fit: global_step=%d already at max_steps=%d; nothing to train", + self.global_step, + self.max_steps, + ) + return self.global_step + module = self.core.strategy.trainable_module() + module.train() + # Rank0-broadcast once: a rank-local eval_data view must not let ranks + # enter or skip the evaluator's collectives alone. + eval_enabled = self._rank0_decision(eval_data is not None) + pending_ack: List[str] = [] + import time as _time + + _PROFILE = int(os.environ.get("PROFILE_STEPS", "0")) + _prof = {"data": 0.0, "step": 0.0, "n": 0} + _TPROF = int( + os.environ.get("PROFILE_TORCH", "0") + ) # active optimizer steps to profile; 0=off + _tprof_warmup = int(os.environ.get("PROFILE_TORCH_WARMUP", "40")) + _tprof_rank0 = ( + not torch.distributed.is_initialized() + ) or torch.distributed.get_rank() == 0 + _tprof = {"p": None, "done": False} + for epoch in range(self.epoch, self.num_epochs): + self.epoch = epoch + if hasattr(data, "set_epoch"): + data.set_epoch(epoch) + stream: Iterable[TrainBatch] = data + skip = self._epoch_batch + if skip: + if hasattr(data, "seek"): + data.seek(skip) + else: + it = iter(data) + consumed = sum(1 for _ in itertools.islice(it, skip)) + if consumed < skip: + raise ValueError( + f"resume position skips past the end of the data: " + f"epoch {epoch} yielded only {consumed} batches, " + f"cannot skip {skip}" + ) + stream = it + _it = iter(stream) + while True: + if _PROFILE: + torch.cuda.synchronize() + _pt0 = _time.perf_counter() + try: + batch = next(_it) + except StopIteration: + break + if _PROFILE: + torch.cuda.synchronize() + _pt1 = _time.perf_counter() + self._epoch_batch += 1 + self._epoch_samples += len(batch.sample_ids) + self.micro_step += 1 + if self.ack_fn is not None: + pending_ack.extend(batch.sample_ids) + result = self.core.train_step( + batch, + ctx=StepContext( + global_step=self.global_step, total_steps=self.total_steps + ), + ) + self.last_metrics = result.metrics + if _PROFILE: + torch.cuda.synchronize() + _pt2 = _time.perf_counter() + _prof["data"] += _pt1 - _pt0 + _prof["step"] += _pt2 - _pt1 + _prof["n"] += 1 + if _prof["n"] >= _PROFILE: + _d = _prof["data"] / _prof["n"] * 1000 + _s = _prof["step"] / _prof["n"] * 1000 + _tot = _prof["data"] + _prof["step"] + print( + f"[profile] microsteps={_prof['n']} " + f"data_wait={_d:.1f}ms compute={_s:.1f}ms " + f"data_frac={100 * _prof['data'] / _tot:.0f}% " + f"step_total={_d + _s:.1f}ms", + flush=True, + ) + _prof["data"] = 0.0 + _prof["step"] = 0.0 + _prof["n"] = 0 + # grad accumulated but optimizer has not stepped yet; everything + # keyed on optimizer steps fires only at the boundary. + if not result.optimizer_stepped: + continue + self.global_step += 1 + if _TPROF and _tprof_rank0 and not _tprof["done"]: + if self.global_step == _tprof_warmup and _tprof["p"] is None: + import torch.profiler as _tp + + _tprof["p"] = _tp.profile( + activities=[ + _tp.ProfilerActivity.CPU, + _tp.ProfilerActivity.CUDA, + ], + record_shapes=True, + ) + _tprof["p"].start() + print(f"[tprof] started @ step {self.global_step}", flush=True) + elif ( + _tprof["p"] is not None + and self.global_step >= _tprof_warmup + _TPROF + ): + _p = _tprof["p"] + _p.stop() + _ka = _p.key_averages() + print( + "[tprof] ===== top ops by self_cuda_time =====\n" + + _ka.table(sort_by="self_cuda_time_total", row_limit=35), + flush=True, + ) + try: + _ncalls = sum(int(e.count) for e in _ka) + _cuda_ms = ( + sum( + float(getattr(e, "self_cuda_time_total", 0.0)) + for e in _ka + ) + / 1000.0 + ) + print( + f"[tprof] window={_TPROF} steps " + f"total_op_calls={_ncalls} " + f"sum_self_cuda={_cuda_ms:.1f}ms", + flush=True, + ) + except Exception as _e: + print(f"[tprof] summary err: {_e}", flush=True) + try: + _p.export_chrome_trace( + os.environ.get( + "PROFILE_TORCH_TRACE", + "/workspace/SpecForge-domino/tprof_trace.json", + ) + ) + print("[tprof] chrome trace exported", flush=True) + except Exception as _e: + print(f"[tprof] trace err: {_e}", flush=True) + _tprof["p"] = None + _tprof["done"] = True + if self.ack_fn is not None: + # durable ack transaction at the optimizer-step boundary + self.ack_fn(pending_ack, self.global_step) + pending_ack = [] + if self.logger and self.global_step % max(1, self.log_interval) == 0: + self.logger(result.metrics, self.global_step) + eval_metrics: Optional[Dict[str, Any]] = None + if ( + self.eval_interval + and eval_enabled + and self.global_step % self.eval_interval == 0 + ): + eval_metrics = self.evaluate(eval_data) + module.train() + if eval_metrics: + if self.logger: + self.logger(eval_metrics, self.global_step) + self.last_metrics = {**self.last_metrics, **eval_metrics} + # is_better is a collective (rank0 verdict broadcast inside the + # manager); its guard is rank-identical because eval_metrics is + # DP-reduced. Empty ({}) eval metrics skip best entirely. + interval_hit = bool( + self.save_interval and self.global_step % self.save_interval == 0 + ) + is_best = bool( + self.save_interval + and eval_metrics + and self._checkpoint_manager().is_better(eval_metrics) + ) + if interval_hit or is_best: + self.save_checkpoint(self.global_step) + if is_best: + self._checkpoint_manager().update_best( + self.global_step, eval_metrics + ) + if self.max_steps is not None and self.global_step >= self.max_steps: + return self.global_step + self._epoch_batch = 0 + self._epoch_samples = 0 + return self.global_step + + @torch.no_grad() + def evaluate(self, data: Optional[Iterable[TrainBatch]]) -> Dict[str, Any]: + """Full-pass eval via :class:`Evaluator`: rank-identical ``eval/*`` + metrics, ``{}`` when zero batches were processed globally. ``data=None`` + (empty local shard) still joins the evaluator's collectives.""" + from specforge.eval import Evaluator + + module = self.core.strategy.trainable_module() + module.eval() + # Same StepContext as the train path so schedule-dependent losses + # (Domino's lambda_base) evaluate at the live step, not at step 0. + ctx = StepContext(global_step=self.global_step, total_steps=self.total_steps) + return Evaluator().run( + lambda batch: self.core.strategy.forward_loss(batch, ctx), data + ) + + @staticmethod + def _rank0_decision(flag: bool) -> bool: + """Broadcast rank0's verdict so a collective-bearing branch is entered by + every rank or by none.""" + if ( + not torch.distributed.is_initialized() + or torch.distributed.get_world_size() == 1 + ): + return bool(flag) + verdict = [bool(flag)] + torch.distributed.broadcast_object_list(verdict, src=0) + return bool(verdict[0]) + + def _checkpoint_manager(self): + # Lazily built in its S-home so the runtime seam does not import the domain + # layer at module load (mirrors _assemble_trainer's lazy Trainer import). + if self._checkpoint_mgr is None: + from specforge.training.checkpoint import CheckpointManager + + self._checkpoint_mgr = CheckpointManager(self.output_dir, self.run_id) + return self._checkpoint_mgr + + def save_checkpoint(self, step: int) -> Checkpoint: + # Every rank participates: the FSDP FULL_STATE_DICT gather is a + # collective, and the optimizer/RNG parts are rank-local so every rank + # persists its own (the manager writes the shared payload on rank0 only). + full = self.core.backend.state_dict() + mgr = self._checkpoint_manager() + shared = None + if mgr.is_rank0(): + shared = { + "draft_state_dict": self.core.strategy.checkpoint_state_filter( + full["model"] + ), + "global_step": step, + "epoch": self.epoch, + "epoch_batch": self._epoch_batch, + "epoch_samples": self._epoch_samples, + "strategy": self.core.strategy.name, + "run_id": self.run_id, + "world_size": ( + torch.distributed.get_world_size() + if torch.distributed.is_initialized() + else 1 + ), + **self.checkpoint_extra, + } + ckpt_dir = mgr.save( + shared, + step, + rank_state={"optimizer": full["optimizer"], "rng": full["rng"]}, + ) + return Checkpoint( + checkpoint_uri=f"file://{os.path.abspath(ckpt_dir)}", + global_step=step, + epoch=self.epoch, + strategy=self.core.strategy.name, + ) + + __all__ = ["TrainerCore", "TrainerController", "Checkpoint", "StepResult"] From 832263fef88881e629869df2bc4a5165c4bf4ca4 Mon Sep 17 00:00:00 2001 From: jiapingW <1969554248@qq.com> Date: Thu, 9 Jul 2026 19:34:58 +0800 Subject: [PATCH 14/24] support dspark training --- configs/qwen3-4b-dspark.json | 51 +++ examples/disagg/_dflash_family_disagg.py | 20 +- examples/disagg/run_disagg_dspark.py | 81 ++++ scripts/train_dflash.py | 155 ++++--- scripts/train_domino.py | 2 +- specforge/core/__init__.py | 2 + specforge/core/dflash.py | 32 +- specforge/core/dspark.py | 329 +++++++++++++++ .../inference/adapters/server_capture.py | 11 + specforge/modeling/draft/dflash.py | 59 +++ specforge/modeling/draft/dspark_heads.py | 285 +++++++++++++ specforge/runtime/contracts.py | 2 +- specforge/training/backend.py | 2 +- specforge/training/strategies/base.py | 391 +++++++++++++++++- specforge/training/strategies/registry.py | 61 +++ tests/test_modeling/test_draft_registry.py | 25 ++ tests/test_runtime/test_disagg_multiserver.py | 30 ++ tests/test_runtime/test_server_capture.py | 44 ++ tests/test_runtime/test_strategy_registry.py | 45 +- tests/test_utils/test_dflash_losses.py | 277 +++++++++---- 20 files changed, 1746 insertions(+), 158 deletions(-) create mode 100644 configs/qwen3-4b-dspark.json create mode 100644 examples/disagg/run_disagg_dspark.py create mode 100644 specforge/core/dspark.py create mode 100644 specforge/modeling/draft/dspark_heads.py diff --git a/configs/qwen3-4b-dspark.json b/configs/qwen3-4b-dspark.json new file mode 100644 index 000000000..c2ca099ff --- /dev/null +++ b/configs/qwen3-4b-dspark.json @@ -0,0 +1,51 @@ +{ + "architectures": [ + "DFlashDraftModel" + ], + "attention_bias": false, + "attention_dropout": 0.0, + "auto_map": { + "AutoModel": "dflash.DFlashDraftModel" + }, + "block_size": 7, + "bos_token_id": 151643, + "dflash_config": { + "mask_token_id": 151669, + "target_layer_ids": [1, 9, 17, 25, 33], + "projector_type": "dspark", + "markov_rank": 256, + "markov_head_type": "vanilla", + "confidence_head_alpha": 1.0, + "enable_confidence_head": true, + "confidence_head_with_markov": true + }, + "dtype": "bfloat16", + "eos_token_id": 151645, + "head_dim": 128, + "hidden_act": "silu", + "hidden_size": 2560, + "initializer_range": 0.02, + "intermediate_size": 9728, + "layer_types": [ + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention" + ], + "max_position_embeddings": 40960, + "max_window_layers": 5, + "model_type": "qwen3", + "num_attention_heads": 32, + "num_hidden_layers": 5, + "num_key_value_heads": 8, + "num_target_layers": 36, + "rms_norm_eps": 1e-06, + "rope_scaling": null, + "rope_theta": 1000000, + "sliding_window": null, + "tie_word_embeddings": false, + "use_cache": true, + "use_sliding_window": false, + "vocab_size": 151936 +} diff --git a/examples/disagg/_dflash_family_disagg.py b/examples/disagg/_dflash_family_disagg.py index c074d880f..73555c712 100644 --- a/examples/disagg/_dflash_family_disagg.py +++ b/examples/disagg/_dflash_family_disagg.py @@ -212,7 +212,13 @@ def build_producer_prompts(args, tokenizer, *, max_prompts: int = 0): return prompts -def run_server_capture_producer(strategy: str, args, run_id: str) -> None: +def run_server_capture_producer( + strategy: str, + args, + run_id: str, + *, + target_repr=None, +) -> None: total_start = time.perf_counter() log_event("producer-timing", f"run_producer start run_id={run_id}") @@ -272,6 +278,7 @@ def run_server_capture_producer(strategy: str, args, run_id: str) -> None: phase = time.perf_counter() log_event("producer-timing", "build_disagg_online_producer start") + prompt_epochs = max(1, int(args.num_epochs)) _workers, drive_producer = build_disagg_online_producer( strategy=strategy, feature_source=adapters if len(adapters) > 1 else adapters[0], @@ -280,8 +287,9 @@ def run_server_capture_producer(strategy: str, args, run_id: str) -> None: channel=channel, run_id=run_id, target_hidden_size=capture_cfg.target_hidden_size, - target_repr=None, + target_repr=target_repr, aux_hidden_state_layer_ids=capture_cfg.aux_layer_ids, + prompt_epochs=prompt_epochs, ) log_event( "producer-timing", @@ -443,7 +451,11 @@ def fit_online_consumer( max_steps = env_int("DISAGG_MAX_STEPS", 0) or None total_steps = env_int("DISAGG_TOTAL_STEPS", 0) or max_steps or 10_000 - print(f"[consumer] training from mooncake://{run_id}", flush=True) + print( + f"[consumer] training from mooncake://{run_id} " + f"(producer-looped epochs={args.num_epochs})", + flush=True, + ) trainer, loader = build_disagg_online_consumer( strategy=strategy, feature_store=mooncake_store(run_id), @@ -454,7 +466,7 @@ def fit_online_consumer( output_dir=args.output_dir, batch_size=args.batch_size, accumulation_steps=args.accumulation_steps, - num_epochs=args.num_epochs, + num_epochs=1, max_steps=max_steps, total_steps=total_steps, save_interval=args.save_interval, diff --git a/examples/disagg/run_disagg_dspark.py b/examples/disagg/run_disagg_dspark.py new file mode 100644 index 000000000..174c1b499 --- /dev/null +++ b/examples/disagg/run_disagg_dspark.py @@ -0,0 +1,81 @@ +"""Disaggregated ONLINE DSpark over patched SGLang server-capture.""" + +import os +import sys + +import torch + +if not torch.cuda.is_available(): + # CPU producer: keep yunchang/flashinfer CUDA probes on the clean fallback. + sys.modules["flashinfer"] = None + +from _dflash_family_disagg import ( + build_consumer_parts, + disagg_role, + fit_online_consumer, + run_server_capture_producer, +) +from accelerate.utils import set_seed +from train_dflash import parse_dspark_disagg_args + +from specforge.core.dspark import OnlineDSparkModel +from specforge.distributed import destroy_distributed, init_distributed + +STRATEGY = "dspark" +RUN_ID = os.environ.get("DISAGG_STORE_ID", "qwen3-4b-dspark-disagg") +DSPARK_DFLASH_FIELDS = ( + "markov_rank", + "markov_head_type", + "confidence_head_alpha", + "enable_confidence_head", + "confidence_head_with_markov", +) + + +def run_producer(args) -> None: + run_server_capture_producer( + STRATEGY, + args, + RUN_ID, + target_repr="hidden_state", + ) + + +def run_consumer(args) -> None: + parts = build_consumer_parts( + args, + expected_projector_type="dspark", + required_dflash_fields=DSPARK_DFLASH_FIELDS, + ) + dspark_model = OnlineDSparkModel( + draft_model=parts.draft_model, + target_lm_head=parts.target_components.lm_head, + target_embed_tokens=parts.target_components.embed_tokens, + mask_token_id=parts.mask_token_id, + block_size=parts.draft_model.block_size, + attention_backend=args.attention_backend, + num_anchors=args.num_anchors, + dspark_ce_loss_alpha=args.dspark_ce_loss_alpha, + dspark_l1_loss_alpha=args.dspark_l1_loss_alpha, + dspark_confidence_head_alpha=args.dspark_confidence_head_alpha, + ) + fit_online_consumer(STRATEGY, args, RUN_ID, dspark_model) + + +def main() -> None: + args = parse_dspark_disagg_args() + set_seed(args.seed) + role = disagg_role() + print(f"[disagg-dspark] role={role} run_id={RUN_ID}", flush=True) + if role == "producer": + run_producer(args) + return + init_distributed(timeout=args.dist_timeout, tp_size=args.tp_size) + try: + run_consumer(args) + finally: + destroy_distributed() + + +if __name__ == "__main__": + main() diff --git a/scripts/train_dflash.py b/scripts/train_dflash.py index 1502622ab..4523679dd 100755 --- a/scripts/train_dflash.py +++ b/scripts/train_dflash.py @@ -21,7 +21,7 @@ from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy from torch.utils.data import DataLoader from tqdm import tqdm -from transformers import AutoConfig +from transformers import AutoConfig, AutoTokenizer from datasets import load_dataset from specforge.args import SGLangBackendArgs, TrackerArgs @@ -39,15 +39,12 @@ from specforge.utils import ( get_last_checkpoint, get_local_device, - load_tokenizer, print_on_rank0, print_with_rank, ) -def parse_args(): - parser = argparse.ArgumentParser(description="Train DFlash Draft Model") - +def _add_model_args(parser: argparse.ArgumentParser) -> None: model_group = parser.add_argument_group("model") model_group.add_argument("--target-model-path", type=str, required=True) model_group.add_argument( @@ -82,60 +79,82 @@ def parse_args(): default=512, help="Number of anchor positions per sequence", ) - model_group.add_argument( - "--loss-decay-gamma", - type=float, + + +def _add_target_head_args(parser: argparse.ArgumentParser) -> None: + target_head_group = parser.add_argument_group("target embedding/head") + target_head_group.add_argument( + "--embedding-key", + type=str, default=None, - help="Gamma for exponential loss decay weighting (paper Eq.4). " - "Suggested: 7 for block_size=16, 5 for 10, 4 for 8. None disables. " - "Only applies when --loss-type dflash.", + help="Embedding weight key in the target model. " + "Default: 'model.embed_tokens.weight' for standard models, " + "'model.language_model.embed_tokens.weight' for multimodal models like Qwen3.5-A3B.", ) - model_group.add_argument( - "--loss-type", + target_head_group.add_argument( + "--lm-head-key", type=str, default=None, + help="LM head weight key in the target model. Default: 'lm_head.weight'.", + ) + + +def _add_dflash_loss_args(parser: argparse.ArgumentParser) -> None: + loss_group = parser.add_argument_group("dflash loss selection: dflash/dpace") + loss_group.add_argument( + "--loss-type", + type=str, + default="dflash", choices=[ "dflash", - "vp_drafter", "dpace", "dpace-cumulative-confidence-only", "dpace-continuation-value-only", ], help=( - "Training objective. If omitted, reads dflash_config.training_mode or " - "dflash_config.loss_type from the draft config, defaulting to dflash." + "Training objective for this DFlash-family entry point. " ), ) - model_group.add_argument( + + loss_group.add_argument( + "--loss-decay-gamma", + type=float, + default=None, + help="Exponential position-decay gamma for --loss-type dflash. " + "Suggested: 7 for block_size=16, 5 for 10, 4 for 8. None disables.", + ) + + loss_group.add_argument( "--dpace-alpha", type=float, default=0.5, - help="Smoothing alpha for D-PACE position weights.", + help="Smoothing alpha for D-PACE objectives.", ) - model_group.add_argument( - "--prefix-weight-base", + + +def _add_dspark_method_args(parser: argparse.ArgumentParser) -> None: + dspark_group = parser.add_argument_group("method: dspark disaggregated") + dspark_group.add_argument( + "--dspark-ce-loss-alpha", type=float, - default=None, - help=( - "VP-Drafter prefix length sampling base. Values below 1 prefer shorter " - "visible prefixes; defaults to dflash_config.prefix_weight_base or 0.9." - ), + default=0.1, + help="Weight for DSpark next-token cross entropy.", ) - model_group.add_argument( - "--embedding-key", - type=str, - default=None, - help="Embedding weight key in the target model. " - "Default: 'model.embed_tokens.weight' for standard models, " - "'model.language_model.embed_tokens.weight' for multimodal models like Qwen3.5-A3B.", + dspark_group.add_argument( + "--dspark-l1-loss-alpha", + type=float, + default=0.9, + help="Weight for DSpark draft/target distribution L1 loss.", ) - model_group.add_argument( - "--lm-head-key", - type=str, - default=None, - help="LM head weight key in the target model. Default: 'lm_head.weight'.", + dspark_group.add_argument( + "--dspark-confidence-head-alpha", + type=float, + default=1.0, + help="Weight for DSpark confidence-head BCE loss.", ) + +def _add_dataset_args(parser: argparse.ArgumentParser) -> None: dataset_group = parser.add_argument_group("dataset") dataset_group.add_argument("--train-data-path", type=str, required=True) dataset_group.add_argument("--eval-data-path", type=str, default=None) @@ -148,6 +167,8 @@ def parse_args(): default=int(os.environ.get("SPECFORGE_DATA_NUM_PROC", 8)), ) + +def _add_training_args(parser: argparse.ArgumentParser) -> None: training_group = parser.add_argument_group("training") training_group.add_argument("--num-epochs", type=int, default=6) training_group.add_argument("--batch-size", type=int, default=1) @@ -159,6 +180,8 @@ def parse_args(): training_group.add_argument("--seed", type=int, default=42) training_group.add_argument("--resume", action="store_true") + +def _add_output_args(parser: argparse.ArgumentParser) -> None: output_group = parser.add_argument_group("output") output_group.add_argument("--output-dir", type=str, required=True) output_group.add_argument("--cache-dir", type=str, default="./cache") @@ -166,6 +189,8 @@ def parse_args(): output_group.add_argument("--eval-interval", type=int, default=1000) output_group.add_argument("--save-interval", type=int, default=1000) + +def _add_runtime_args(parser: argparse.ArgumentParser) -> None: optimization_group = parser.add_argument_group("optimization") optimization_group.add_argument( "--tp-size", @@ -184,6 +209,43 @@ def parse_args(): sglang_group = parser.add_argument_group("sglang backend") SGLangBackendArgs.add_args(sglang_group) + +def _build_parser( + *, + description: str, + method: str, +) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=description) + _add_model_args(parser) + + if method == "dflash": + _add_dflash_loss_args(parser) + elif method == "dspark_disagg": + _add_dspark_method_args(parser) + else: + raise ValueError(f"unknown DFlash-family parser method: {method}") + + _add_target_head_args(parser) + _add_dataset_args(parser) + _add_training_args(parser) + _add_output_args(parser) + _add_runtime_args(parser) + return parser + + +def parse_args(): + parser = _build_parser( + description="Train DFlash Draft Model", + method="dflash", + ) + return parser.parse_args() + + +def parse_dspark_disagg_args(): + parser = _build_parser( + description="Train DSpark Draft Model with disaggregated server capture", + method="dspark_disagg", + ) return parser.parse_args() @@ -232,20 +294,8 @@ def build_models(args) -> Tuple[DFlashTargetModel, DFlashDraftModel]: if not hasattr(draft_config, "dflash_config") or draft_config.dflash_config is None: draft_config.dflash_config = {} - args.loss_type = ( - args.loss_type - or draft_config.dflash_config.get("training_mode") - or draft_config.dflash_config.get("loss_type") - or "dflash" - ) - if args.prefix_weight_base is None: - args.prefix_weight_base = draft_config.dflash_config.get( - "prefix_weight_base", 0.9 - ) - draft_config._attn_implementation = args.attention_backend print_on_rank0(f"Using attention backend: {args.attention_backend}") - print_on_rank0(f"Using DFlash training loss_type: {args.loss_type}") draft_model = DFlashDraftModel(draft_config).to(device=device, dtype=torch.bfloat16) @@ -452,14 +502,10 @@ def main(): f"step {resume_state['global_step']}" ) - tokenizer = load_tokenizer(args.target_model_path) + tokenizer = AutoTokenizer.from_pretrained(args.target_model_path) if args.mask_token_id is not None: mask_token_id = args.mask_token_id - elif ( - dflash_config := getattr(draft_model.config, "dflash_config", {}) - ) and dflash_config.get("mask_token_id") is not None: - mask_token_id = dflash_config["mask_token_id"] elif tokenizer.mask_token_id is not None: mask_token_id = tokenizer.mask_token_id else: @@ -500,7 +546,6 @@ def main(): loss_decay_gamma=args.loss_decay_gamma, loss_type=args.loss_type, dpace_alpha=args.dpace_alpha, - prefix_weight_base=args.prefix_weight_base, ) # Wrap each transformer block as its own FSDP unit so that all-gather / diff --git a/scripts/train_domino.py b/scripts/train_domino.py index 3b090a776..fcbb3ebca 100755 --- a/scripts/train_domino.py +++ b/scripts/train_domino.py @@ -548,7 +548,7 @@ def main(): use_orig_params=True, mixed_precision=MixedPrecision( param_dtype=torch.bfloat16, - buffer_dtype=torch.bfloat16, + buffer_dtype=torch.float32, ), sharding_strategy=ShardingStrategy.SHARD_GRAD_OP, ) diff --git a/specforge/core/__init__.py b/specforge/core/__init__.py index 4d5dcc644..b38e110d8 100644 --- a/specforge/core/__init__.py +++ b/specforge/core/__init__.py @@ -1,11 +1,13 @@ from .dflash import OnlineDFlashModel from .domino import OnlineDominoModel +from .dspark import OnlineDSparkModel from .eagle3 import OnlineEagle3Model, QwenVLOnlineEagle3Model from .peagle import OnlinePEagleModel __all__ = [ "OnlineDFlashModel", "OnlineDominoModel", + "OnlineDSparkModel", "OnlineEagle3Model", "OnlinePEagleModel", "QwenVLOnlineEagle3Model", diff --git a/specforge/core/dflash.py b/specforge/core/dflash.py index 0c0cbc950..319d8be7c 100644 --- a/specforge/core/dflash.py +++ b/specforge/core/dflash.py @@ -333,18 +333,12 @@ def _dpace_weight( return suffix / prefix.clamp_min(torch.finfo(prefix.dtype).tiny) raise ValueError(f"unknown D-PACE loss_type {loss_type!r}") - def forward( + def _forward_draft_blocks( self, input_ids: torch.Tensor, hidden_states: torch.Tensor, loss_mask: torch.Tensor, - ) -> Tuple[torch.Tensor, torch.Tensor, Dict[str, torch.Tensor]]: - """Parallel block-wise training forward pass; returns - (loss, accuracy, metrics) — same shape as Domino's forward.""" - if self.attention_backend == "flex_attention" and not FLEX_ATTENTION_AVAILABLE: - raise ValueError( - "flex_attention is not available on this device; use sdpa/eager." - ) + ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: bsz, seq_len = input_ids.shape device = input_ids.device @@ -394,6 +388,28 @@ def forward( target_hidden=hidden_states, attention_mask=dflash_attn_mask, ) + return anchor_positions, block_keep_mask, output_hidden + + def forward( + self, + input_ids: torch.Tensor, + hidden_states: torch.Tensor, + loss_mask: torch.Tensor, + ) -> Tuple[torch.Tensor, torch.Tensor, Dict[str, torch.Tensor]]: + """Parallel block-wise training forward pass; returns + (loss, accuracy, metrics) — same shape as Domino's forward.""" + if self.attention_backend == "flex_attention" and not FLEX_ATTENTION_AVAILABLE: + raise ValueError( + "flex_attention is not available on this device; use sdpa/eager." + ) + bsz, seq_len = input_ids.shape + device = input_ids.device + + anchor_positions, block_keep_mask, output_hidden = self._forward_draft_blocks( + input_ids=input_ids, + hidden_states=hidden_states, + loss_mask=loss_mask, + ) logits = self.lm_head(output_hidden) diff --git a/specforge/core/dspark.py b/specforge/core/dspark.py new file mode 100644 index 000000000..cfbf3df00 --- /dev/null +++ b/specforge/core/dspark.py @@ -0,0 +1,329 @@ +# coding=utf-8 +"""DSpark Training Wrapper.""" + +from typing import Dict, Optional, Tuple + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from specforge.core.dflash import ( + FLEX_ATTENTION_AVAILABLE, + OnlineDFlashModel, +) +from specforge.modeling.draft.dflash import DFlashDraftModel + + +class OnlineDSparkModel(OnlineDFlashModel): + """DSpark online training wrapper over DFlash block-parallel components.""" + + def __init__( + self, + draft_model: DFlashDraftModel, + target_lm_head: nn.Module, + target_embed_tokens: nn.Module, + mask_token_id: int, + block_size: int = 16, + attention_backend: str = "flex_attention", + num_anchors: int = 512, + loss_decay_gamma: Optional[float] = None, + dspark_ce_loss_alpha: float = 0.1, + dspark_l1_loss_alpha: float = 0.9, + dspark_confidence_head_alpha: float = 1.0, + ): + super().__init__( + draft_model=draft_model, + target_lm_head=target_lm_head, + target_embed_tokens=target_embed_tokens, + mask_token_id=mask_token_id, + block_size=block_size, + attention_backend=attention_backend, + num_anchors=num_anchors, + loss_decay_gamma=loss_decay_gamma, + loss_type="dflash", + ) + if dspark_ce_loss_alpha < 0: + raise ValueError("dspark_ce_loss_alpha must be >= 0") + if dspark_l1_loss_alpha < 0: + raise ValueError("dspark_l1_loss_alpha must be >= 0") + if dspark_confidence_head_alpha < 0: + raise ValueError("dspark_confidence_head_alpha must be >= 0") + + self.loss_type = "dspark" + self.dspark_ce_loss_alpha = float(dspark_ce_loss_alpha) + self.dspark_l1_loss_alpha = float(dspark_l1_loss_alpha) + self.dspark_confidence_head_alpha = float(dspark_confidence_head_alpha) + + def _build_anchor_candidate_mask( + self, + seq_len: int, + loss_mask: torch.Tensor, + ) -> torch.Tensor: + num_candidates = max(seq_len - 1, 0) + if num_candidates == 0: + return loss_mask[:, :0].bool() + anchor_valid = loss_mask[:, :num_candidates] > 0.5 + first_target_valid = loss_mask[:, 1 : num_candidates + 1] > 0.5 + return anchor_valid & first_target_valid + + def _sample_anchor_positions( + self, seq_len: int, loss_mask: torch.Tensor, device: torch.device + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Sample fixed-width DSpark anchors; invalid slots are masked out.""" + valid = self._build_anchor_candidate_mask(seq_len, loss_mask) + bsz = loss_mask.shape[0] + num_candidates = valid.shape[1] + max_n = int(self.num_anchors) + if num_candidates == 0: + anchors = torch.zeros(bsz, max_n, dtype=torch.long, device=device) + keep_mask = torch.zeros(bsz, max_n, dtype=torch.bool, device=device) + return anchors, keep_mask + + valid_counts = valid.sum(dim=1) + indices = torch.arange(num_candidates, device=device).unsqueeze(0).expand( + bsz, -1 + ) + masked_indices = torch.where( + valid, + indices, + torch.full_like(indices, seq_len + 1), + ) + random_vals = torch.rand(bsz, num_candidates, device=device) + random_vals = torch.where(valid, random_vals, torch.full_like(random_vals, 2.0)) + _, sorted_idx = random_vals.sort(dim=1) + gathered = torch.gather(masked_indices, 1, sorted_idx) + if num_candidates < max_n: + pad = torch.full( + (bsz, max_n - num_candidates), + seq_len + 1, + dtype=gathered.dtype, + device=device, + ) + gathered = torch.cat([gathered, pad], dim=1) + anchors = gathered[:, :max_n].sort(dim=1).values + keep_mask = torch.arange(max_n, device=device).unsqueeze(0) < ( + valid_counts.unsqueeze(1).clamp(max=max_n) + ) + anchors = torch.where(keep_mask, anchors, torch.zeros_like(anchors)) + return anchors, keep_mask + + def _build_labels_and_mask( + self, + input_ids: torch.Tensor, + loss_mask: torch.Tensor, + anchor_positions: torch.Tensor, + block_keep_mask: torch.Tensor, + ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + bsz, seq_len = input_ids.shape + del bsz + device = input_ids.device + label_offsets = torch.arange(1, self.block_size + 1, device=device).view( + 1, 1, -1 + ) + label_indices = anchor_positions.unsqueeze(-1) + label_offsets + safe_label_indices = label_indices.clamp(max=seq_len - 1) + safe_label_indices = torch.where( + block_keep_mask.unsqueeze(-1), + safe_label_indices, + torch.zeros_like(safe_label_indices), + ) + target_ids = torch.gather( + input_ids.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), + 2, + safe_label_indices, + ) + + target_valid = label_indices < seq_len + target_loss_mask = torch.gather( + loss_mask.unsqueeze(1).expand(-1, label_indices.size(1), -1), + 2, + safe_label_indices, + ) + eval_mask = target_valid & (target_loss_mask > 0.5) + eval_mask = eval_mask & block_keep_mask.unsqueeze(-1) + eval_mask = eval_mask.to(torch.int32).cumprod(dim=-1).bool() + return target_ids, eval_mask, label_indices, safe_label_indices + + def _loss_weight_mask( + self, + eval_mask: torch.Tensor, + device: torch.device, + ) -> torch.Tensor: + loss_weight_mask = eval_mask.to(torch.float32) + if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: + positions = torch.arange(self.block_size, device=device).view(1, 1, -1) + decay_weights = torch.exp( + -positions.float() / float(self.loss_decay_gamma) + ) + loss_weight_mask = loss_weight_mask * decay_weights + return loss_weight_mask + + def _aligned_target_logits( + self, + target_last_hidden_states: Optional[torch.Tensor], + safe_label_indices: torch.Tensor, + ) -> Optional[torch.Tensor]: + if target_last_hidden_states is None: + return None + target_pred_indices = (safe_label_indices - 1).clamp(min=0) + aligned_target_hidden = torch.gather( + target_last_hidden_states.unsqueeze(1).expand( + -1, + safe_label_indices.size(1), + -1, + -1, + ), + 2, + target_pred_indices.unsqueeze(-1).expand( + -1, + -1, + -1, + target_last_hidden_states.size(-1), + ), + ) + return self.lm_head(aligned_target_hidden) + + def _compute_loss( + self, + *, + draft_logits: torch.Tensor, + target_ids: torch.Tensor, + eval_mask: torch.Tensor, + confidence_pred: Optional[torch.Tensor], + aligned_target_logits: Optional[torch.Tensor], + ) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]: + device = draft_logits.device + vocab_size = draft_logits.size(-1) + loss_weight_mask = self._loss_weight_mask(eval_mask, device) + flat_logits = draft_logits.reshape(-1, vocab_size) + flat_targets = target_ids.reshape(-1) + flat_weights = loss_weight_mask.reshape(-1) + + loss_per_token = F.cross_entropy(flat_logits, flat_targets, reduction="none") + ce_loss_num = (loss_per_token * flat_weights).sum() + ce_loss_den = flat_weights.sum() + ce_loss = ce_loss_num / (ce_loss_den + 1e-6) + + l1_loss = ce_loss.new_zeros(()) + accept_rate_3d = None + if aligned_target_logits is not None: + draft_probs = torch.softmax(draft_logits.float(), dim=-1) + target_probs = torch.softmax(aligned_target_logits.float(), dim=-1) + accept_rate_3d = 1.0 - 0.5 * (draft_probs - target_probs).abs().sum(dim=-1) + accept_rate_3d = accept_rate_3d.clamp_(0.0, 1.0) + if self.dspark_l1_loss_alpha > 0: + l1_dist = (draft_probs - target_probs).abs().sum(dim=-1) + l1_loss = (l1_dist * loss_weight_mask).sum() / (ce_loss_den + 1e-6) + elif self.dspark_l1_loss_alpha > 0 or self.dspark_confidence_head_alpha > 0: + raise ValueError( + "DSpark L1/confidence loss requires target_last_hidden_states. " + "Use the disaggregated DSpark server-capture path so the " + "consumer receives target_last_hidden_states." + ) + + confidence_loss = ce_loss.new_zeros(()) + confidence_abs_error = ce_loss.new_zeros(()) + if confidence_pred is not None: + if accept_rate_3d is None: + raise ValueError("DSpark confidence head requires aligned target logits.") + confidence_errors = F.binary_cross_entropy_with_logits( + confidence_pred.float(), + accept_rate_3d.detach(), + reduction="none", + ) + confidence_loss = (confidence_errors * loss_weight_mask).sum() / ( + ce_loss_den + 1e-6 + ) + with torch.no_grad(): + confidence_abs_error = ( + (confidence_pred.float().sigmoid() - accept_rate_3d).abs() + * loss_weight_mask + ).sum() / (ce_loss_den + 1e-6) + + loss = ( + self.dspark_ce_loss_alpha * ce_loss + + self.dspark_l1_loss_alpha * l1_loss + + self.dspark_confidence_head_alpha * confidence_loss + ) + metrics = { + "ce_loss": ce_loss.detach(), + "l1_loss": l1_loss.detach(), + "confidence_loss": confidence_loss.detach(), + "confidence_abs_error": confidence_abs_error.detach(), + } + return loss, metrics + + def forward( + self, + input_ids: torch.Tensor, + hidden_states: torch.Tensor, + loss_mask: torch.Tensor, + target_last_hidden_states: Optional[torch.Tensor] = None, + ) -> Tuple[torch.Tensor, torch.Tensor, Dict[str, torch.Tensor]]: + """Parallel DSpark training forward pass.""" + if self.attention_backend == "flex_attention" and not FLEX_ATTENTION_AVAILABLE: + raise ValueError( + "flex_attention is not available on this device; use sdpa/eager." + ) + bsz = input_ids.shape[0] + anchor_positions, block_keep_mask, output_hidden = self._forward_draft_blocks( + input_ids=input_ids, + hidden_states=hidden_states, + loss_mask=loss_mask, + ) + + logits = self.lm_head(output_hidden) + num_blocks = anchor_positions.size(1) + output_hidden_4d = output_hidden.reshape( + bsz, num_blocks, self.block_size, -1 + ) + ( + target_ids, + eval_mask, + _label_indices, + safe_label_indices, + ) = self._build_labels_and_mask( + input_ids=input_ids, + loss_mask=loss_mask, + anchor_positions=anchor_positions, + block_keep_mask=block_keep_mask, + ) + anchor_token_ids = torch.gather(input_ids, 1, anchor_positions) + prev_token_ids = torch.cat( + [anchor_token_ids.unsqueeze(-1), target_ids[:, :, :-1]], + dim=-1, + ) + draft_logits = logits.reshape(bsz, num_blocks, self.block_size, -1) + if hasattr(self.draft_model, "apply_markov_logits"): + draft_logits = self.draft_model.apply_markov_logits( + draft_logits, + prev_token_ids=prev_token_ids, + hidden_states=output_hidden_4d, + ) + confidence_pred = None + if hasattr(self.draft_model, "predict_confidence"): + confidence_pred = self.draft_model.predict_confidence( + output_hidden_4d, + prev_token_ids=prev_token_ids, + ) + aligned_target_logits = self._aligned_target_logits( + target_last_hidden_states, + safe_label_indices, + ) + loss, metrics = self._compute_loss( + draft_logits=draft_logits, + target_ids=target_ids, + eval_mask=eval_mask, + confidence_pred=confidence_pred, + aligned_target_logits=aligned_target_logits, + ) + flat_logits = draft_logits.reshape(-1, draft_logits.size(-1)) + flat_targets = target_ids.reshape(-1) + binary_eval_mask = eval_mask.reshape(-1) + with torch.no_grad(): + pred_ids = torch.argmax(flat_logits, dim=-1) + correct = (pred_ids == flat_targets) & binary_eval_mask + accuracy_denom = binary_eval_mask.to(torch.float32).sum() + accuracy = correct.sum().float() / (accuracy_denom + 1e-6) + metrics["accuracy_denom"] = accuracy_denom + return loss, accuracy, metrics diff --git a/specforge/inference/adapters/server_capture.py b/specforge/inference/adapters/server_capture.py index c1e8314c8..ac10a3608 100644 --- a/specforge/inference/adapters/server_capture.py +++ b/specforge/inference/adapters/server_capture.py @@ -117,6 +117,17 @@ def resolve_server_capture_schema(strategy: str) -> ServerCaptureSchema: ), ) ) +register_server_capture_schema( + ServerCaptureSchema( + strategy="dspark", + aux_feature="hidden_states", + last_hidden_feature="target_last_hidden_states", + passthrough=( + ("input_ids", "input_ids", ()), + ("loss_mask", "loss_mask", ()), + ), + ) +) @dataclass(frozen=True) diff --git a/specforge/modeling/draft/dflash.py b/specforge/modeling/draft/dflash.py index de8026c27..50dff51a0 100644 --- a/specforge/modeling/draft/dflash.py +++ b/specforge/modeling/draft/dflash.py @@ -19,6 +19,7 @@ ) from typing_extensions import Tuple, Unpack +from .dspark_heads import AcceptRatePredictor, build_markov_head from .registry import register_draft @@ -244,6 +245,11 @@ def __init__(self, config) -> None: self.pure_draft_prefix_len = dflash_config.get("pure_draft_prefix_len", 0) self.shift_label = dflash_config.get("shift_label", False) + self.markov_head = None + self.enable_confidence_head = False + self.confidence_head_with_markov = False + self.confidence_head = None + if self.projector_type == "domino": self.emb_dim = dflash_config["emb_dim"] self.gru_hidden_dim = dflash_config["gru_hidden_dim"] @@ -260,10 +266,63 @@ def __init__(self, config) -> None: nn.SiLU(), nn.Linear(self.emb_dim, config.vocab_size, bias=False), ) + elif self.projector_type == "dspark": + self.markov_head = build_markov_head(config, dflash_config) + confidence_alpha = float( + dflash_config.get("confidence_head_alpha", 0.0) or 0.0 + ) + self.enable_confidence_head = bool( + dflash_config.get("enable_confidence_head", confidence_alpha > 0.0) + ) + self.confidence_head_with_markov = bool( + dflash_config.get("confidence_head_with_markov", False) + ) + if self.confidence_head_with_markov and self.markov_head is None: + raise ValueError( + "confidence_head_with_markov=True requires markov_rank > 0" + ) + if self.enable_confidence_head: + input_dim = config.hidden_size + if self.confidence_head_with_markov: + input_dim += self.markov_head.markov_rank + self.confidence_head = AcceptRatePredictor(input_dim=input_dim) elif self.projector_type is not None: raise ValueError(f"Unknown draft projector_type: {self.projector_type}") self.post_init() + def apply_markov_logits( + self, + base_logits: torch.Tensor, + *, + prev_token_ids: torch.Tensor, + hidden_states: torch.Tensor, + ) -> torch.Tensor: + if self.markov_head is None: + return base_logits + return self.markov_head.apply_block_logits( + base_logits, + token_ids=prev_token_ids, + hidden_states=hidden_states, + ) + + def predict_confidence( + self, + hidden_states: torch.Tensor, + *, + prev_token_ids: Optional[torch.Tensor] = None, + ) -> Optional[torch.Tensor]: + if self.confidence_head is None: + return None + if self.confidence_head_with_markov: + assert self.markov_head is not None + if prev_token_ids is None: + raise ValueError("prev_token_ids is required for Markov confidence") + prev_embeddings = self.markov_head.get_prev_embeddings(prev_token_ids).to( + hidden_states.dtype + ) + hidden_states = torch.cat([hidden_states, prev_embeddings], dim=-1) + return self.confidence_head(hidden_states).float() + def forward( self, position_ids: torch.LongTensor, diff --git a/specforge/modeling/draft/dspark_heads.py b/specforge/modeling/draft/dspark_heads.py new file mode 100644 index 000000000..6735e424c --- /dev/null +++ b/specforge/modeling/draft/dspark_heads.py @@ -0,0 +1,285 @@ +# coding=utf-8 +"""Small DSpark heads layered on top of the DFlash draft backbone.""" + +from __future__ import annotations + +from typing import Optional, Tuple + +import torch +from torch import nn + + +def _sample(logits: torch.Tensor, temperature: float = 0.0) -> torch.Tensor: + if temperature < 1e-5: + return torch.argmax(logits, dim=-1) + batch_size, seq_len, vocab_size = logits.shape + flat_logits = logits.reshape(-1, vocab_size) / temperature + probs = torch.softmax(flat_logits, dim=-1) + return torch.multinomial(probs, num_samples=1).view(batch_size, seq_len) + + +class AcceptRatePredictor(nn.Module): + """Predict target/draft distribution acceptance probability per draft step.""" + + def __init__(self, input_dim: int): + super().__init__() + self.proj = nn.Linear(int(input_dim), 1) + + def forward(self, features: torch.Tensor) -> torch.Tensor: + return self.proj(features).squeeze(-1) + + +class VanillaMarkovHead(nn.Module): + """Low-rank previous-token logit bias used by DSpark.""" + + def __init__(self, *, vocab_size: int, markov_rank: int): + super().__init__() + self.vocab_size = int(vocab_size) + self.markov_rank = int(markov_rank) + self.markov_head_type = "vanilla" + if self.markov_rank <= 0: + raise ValueError(f"markov_rank must be > 0, got {self.markov_rank}") + self.markov_w1 = nn.Embedding(self.vocab_size, self.markov_rank) + self.markov_w2 = nn.Linear(self.markov_rank, self.vocab_size, bias=False) + + def get_prev_embeddings(self, token_ids: torch.Tensor) -> torch.Tensor: + return self.markov_w1(token_ids.long()) + + def project_bias(self, latent_states: torch.Tensor) -> torch.Tensor: + return self.markov_w2(latent_states) + + def compute_step_bias( + self, + token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + ) -> torch.Tensor: + del hidden_states + return self.project_bias(self.get_prev_embeddings(token_ids)) + + def apply_step_logits( + self, + logits: torch.Tensor, + *, + token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + ) -> torch.Tensor: + return logits + self.compute_step_bias(token_ids, hidden_states) + + def apply_block_logits( + self, + base_logits: torch.Tensor, + *, + token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + ) -> torch.Tensor: + if base_logits.size(-2) == 0: + return base_logits + return base_logits + self.compute_step_bias(token_ids, hidden_states) + + def sample_block_tokens( + self, + base_logits: torch.Tensor, + *, + first_prev_token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + temperature: float = 0.0, + ) -> Tuple[torch.Tensor, torch.Tensor]: + batch_size, proposal_len = base_logits.shape[:2] + if proposal_len == 0: + empty_tokens = torch.empty( + batch_size, + 0, + dtype=torch.long, + device=base_logits.device, + ) + return empty_tokens, base_logits + + sampled_tokens = [] + corrected_logits = [] + prev_token_ids = first_prev_token_ids.long() + for step_idx in range(proposal_len): + step_hidden = None if hidden_states is None else hidden_states[:, step_idx] + step_logits = self.apply_step_logits( + base_logits[:, step_idx], + token_ids=prev_token_ids, + hidden_states=step_hidden, + ) + corrected_logits.append(step_logits.unsqueeze(1)) + next_token_ids = _sample( + step_logits.unsqueeze(1), + temperature=temperature, + ).squeeze(1) + sampled_tokens.append(next_token_ids) + prev_token_ids = next_token_ids + return torch.stack(sampled_tokens, dim=1), torch.cat(corrected_logits, dim=1) + + +class GatedMarkovHead(VanillaMarkovHead): + def __init__(self, *, vocab_size: int, markov_rank: int, hidden_size: int): + super().__init__(vocab_size=vocab_size, markov_rank=markov_rank) + self.markov_head_type = "gated" + self.gate_proj = nn.Linear(hidden_size + markov_rank, markov_rank) + + def compute_gate( + self, + token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + ) -> torch.Tensor: + if hidden_states is None: + raise ValueError("gated Markov head requires hidden_states") + prev_embeddings = self.get_prev_embeddings(token_ids) + gate_inputs = torch.cat([hidden_states, prev_embeddings], dim=-1) + return torch.sigmoid(self.gate_proj(gate_inputs)) + + def compute_step_bias( + self, + token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + ) -> torch.Tensor: + prev_embeddings = self.get_prev_embeddings(token_ids) + gate = self.compute_gate(token_ids, hidden_states).to(prev_embeddings.dtype) + return self.project_bias(gate * prev_embeddings) + + +class RNNMarkovHead(VanillaMarkovHead): + """Recurrent DSpark Markov head unrolled inside one draft block.""" + + def __init__(self, *, vocab_size: int, markov_rank: int, hidden_size: int): + super().__init__(vocab_size=vocab_size, markov_rank=markov_rank) + self.markov_head_type = "rnn" + self.state_size = markov_rank + self.joint_proj = nn.Linear(2 * markov_rank + hidden_size, 3 * markov_rank) + + def _rnn_step( + self, + state: torch.Tensor, + prev_embeddings: torch.Tensor, + hidden_states: torch.Tensor, + ) -> Tuple[torch.Tensor, torch.Tensor]: + z = torch.cat([state, prev_embeddings, hidden_states], dim=-1) + gate_raw, candidate_raw, output_raw = self.joint_proj(z).chunk(3, dim=-1) + gate = torch.sigmoid(gate_raw) + candidate = torch.tanh(candidate_raw) + new_state = gate * state + (1.0 - gate) * candidate + return new_state, self.project_bias(torch.tanh(output_raw)) + + def compute_step_bias( + self, + token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + ) -> torch.Tensor: + if hidden_states is None: + raise ValueError("rnn Markov head requires hidden_states") + prev_embeddings = self.get_prev_embeddings(token_ids) + state = torch.zeros_like(prev_embeddings) + _, bias = self._rnn_step(state, prev_embeddings, hidden_states) + return bias + + def apply_block_logits( + self, + base_logits: torch.Tensor, + *, + token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + ) -> torch.Tensor: + if hidden_states is None: + raise ValueError("rnn Markov head requires hidden_states") + block_size = base_logits.size(-2) + if block_size == 0: + return base_logits + + state = torch.zeros( + *base_logits.shape[:-2], + self.markov_rank, + device=base_logits.device, + dtype=hidden_states.dtype, + ) + output_logits = [] + for step_idx in range(block_size): + prev_emb = self.get_prev_embeddings(token_ids[..., step_idx]) + state, bias = self._rnn_step( + state, + prev_emb, + hidden_states[..., step_idx, :], + ) + output_logits.append(base_logits[..., step_idx, :] + bias) + return torch.stack(output_logits, dim=-2) + + def sample_block_tokens( + self, + base_logits: torch.Tensor, + *, + first_prev_token_ids: torch.Tensor, + hidden_states: Optional[torch.Tensor], + temperature: float = 0.0, + ) -> Tuple[torch.Tensor, torch.Tensor]: + if hidden_states is None: + raise ValueError("rnn Markov head requires hidden_states") + batch_size, proposal_len = base_logits.shape[:2] + if proposal_len == 0: + empty_tokens = torch.empty( + batch_size, + 0, + dtype=torch.long, + device=base_logits.device, + ) + return empty_tokens, base_logits + + state = torch.zeros( + batch_size, + self.markov_rank, + device=base_logits.device, + dtype=hidden_states.dtype, + ) + sampled_tokens = [] + corrected_logits = [] + prev_token_ids = first_prev_token_ids.long() + for step_idx in range(proposal_len): + prev_emb = self.get_prev_embeddings(prev_token_ids) + state, bias = self._rnn_step(state, prev_emb, hidden_states[:, step_idx]) + step_logits = base_logits[:, step_idx] + bias + corrected_logits.append(step_logits.unsqueeze(1)) + next_token_ids = _sample( + step_logits.unsqueeze(1), + temperature=temperature, + ).squeeze(1) + sampled_tokens.append(next_token_ids) + prev_token_ids = next_token_ids + return torch.stack(sampled_tokens, dim=1), torch.cat(corrected_logits, dim=1) + + +def build_markov_head(config, dspark_config: dict) -> Optional[nn.Module]: + markov_rank = int(dspark_config.get("markov_rank", 0) or 0) + if markov_rank < 0: + raise ValueError(f"markov_rank must be >= 0, got {markov_rank}") + if markov_rank == 0: + return None + + markov_head_type = str(dspark_config.get("markov_head_type", "vanilla")).lower() + if markov_head_type == "vanilla": + return VanillaMarkovHead( + vocab_size=config.vocab_size, + markov_rank=markov_rank, + ) + if markov_head_type == "gated": + return GatedMarkovHead( + vocab_size=config.vocab_size, + markov_rank=markov_rank, + hidden_size=config.hidden_size, + ) + if markov_head_type == "rnn": + return RNNMarkovHead( + vocab_size=config.vocab_size, + markov_rank=markov_rank, + hidden_size=config.hidden_size, + ) + raise ValueError(f"Unsupported markov_head_type: {markov_head_type!r}") + + +__all__ = [ + "AcceptRatePredictor", + "VanillaMarkovHead", + "GatedMarkovHead", + "RNNMarkovHead", + "build_markov_head", +] diff --git a/specforge/runtime/contracts.py b/specforge/runtime/contracts.py index 25967f98d..660a1c7bb 100644 --- a/specforge/runtime/contracts.py +++ b/specforge/runtime/contracts.py @@ -34,7 +34,7 @@ RunMode = Literal["online", "offline"] DeploymentMode = Literal["local_colocated", "dataflow_colocated", "disaggregated"] -DraftStrategyName = Literal["eagle3", "dflash", "domino"] +DraftStrategyName = Literal["eagle3", "dflash", "domino", "dspark"] # Tagged union for the EAGLE3 target feature. The *strategy* owns the # projection so the trainer core stays branch-free: # - pruned_logits: rollout applied the t2d vocab map; stored (seq, draft_vocab) diff --git a/specforge/training/backend.py b/specforge/training/backend.py index 8fb31e03a..2845432d3 100644 --- a/specforge/training/backend.py +++ b/specforge/training/backend.py @@ -181,7 +181,7 @@ def prepare_model( model, use_orig_params=True, mixed_precision=MixedPrecision( - param_dtype=pc.param_dtype, buffer_dtype=pc.param_dtype + param_dtype=pc.param_dtype, buffer_dtype=torch.float32 ), sharding_strategy=sharding, process_group=pc.fsdp_process_group, diff --git a/specforge/training/strategies/base.py b/specforge/training/strategies/base.py index f2627f5da..435b0c453 100644 --- a/specforge/training/strategies/base.py +++ b/specforge/training/strategies/base.py @@ -1,20 +1,389 @@ # coding=utf-8 -"""Import shim — moved to ``specforge.training.strategies.base``.""" - -from specforge.training.strategies.base import ( # noqa: F401 - DFlashTrainStrategy, - DominoTrainStrategy, - DraftTrainStrategy, - Eagle3TrainStrategy, - StepContext, - StepOutput, - linear_lambda_base, -) +# Copyright 2024 The SpecForge team. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +"""DraftTrainStrategy: per-draft-model required features + forward/loss + projection. + +A strategy is the only place that knows how a draft model (EAGLE3 / DFlash / +Domino) turns a normalized ``TrainBatch`` into a loss; ``TrainerCore`` stays +branch-free and the strategy owns the target projection. Imports model code, +so it is imported by training entry points, not at package load. +""" + +from __future__ import annotations + +import abc +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple + +import torch +import torch.nn as nn + +from specforge.runtime.contracts import TrainBatch + + +@dataclass(frozen=True) +class StepOutput: + """Per-step result: loss + strategy-specific metrics, kept generic so + per-position (TTT) and single-scalar strategies share one trainer loop.""" + + loss: torch.Tensor + metrics: Dict[str, Any] + + +@dataclass(frozen=True) +class StepContext: + """Training-schedule state passed into ``forward_loss`` for objectives that + depend on where in training we are (e.g. Domino's decaying ``lambda_base``); + most strategies ignore it.""" + + global_step: int = 0 + total_steps: Optional[int] = None + + +def linear_lambda_base( + global_step: int, + total_steps: int, + lambda_start: float = 1.0, + decay_ratio: float = 0.5, +) -> float: + """Domino base-loss weight: linear decay from ``lambda_start`` to 0 over the + first ``total_steps * decay_ratio`` steps, then 0, clamped to ``[0, 1]``. + Single source for the runtime strategy and ``scripts/train_domino.py``; + requires a real ``total_steps`` (> 0).""" + decay_steps = max(1, int(total_steps * decay_ratio)) + progress = min(global_step / decay_steps, 1.0) + return max(0.0, min(1.0, lambda_start * (1.0 - progress))) + + +class DraftTrainStrategy(abc.ABC): + name: str + required_features: set + + @abc.abstractmethod + def trainable_module(self) -> nn.Module: + """The module whose parameters the optimizer/backend owns.""" + + def validate_batch(self, batch: TrainBatch) -> None: + missing = {f for f in self.required_features if f not in batch.tensors} + if missing: + raise ValueError( + f"{self.name} batch missing required features {sorted(missing)}; " + f"present={sorted(batch.tensors)}" + ) + + @abc.abstractmethod + def forward_loss( + self, batch: TrainBatch, ctx: Optional["StepContext"] = None + ) -> StepOutput: ... + + def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: + """Select the keys this strategy persists as draft weights.""" + return state_dict + + +class Eagle3TrainStrategy(DraftTrainStrategy): + """EAGLE3 TTT strategy wrapping the existing ``OnlineEagle3Model``. + + For ``target_repr == "hidden_state"`` the strategy re-runs the frozen + ``TargetHead`` over the stored target hidden state; ``logits`` / + ``pruned_logits`` are used as delivered. The ``t2d`` vocab map is applied + inside ``OnlineEagle3Model.forward``. + """ + + name = "eagle3" + required_features = { + "input_ids", + "attention_mask", + "loss_mask", + "hidden_state", + "target", + } + + def __init__( + self, + eagle3_model: nn.Module, + *, + target_head: Optional[nn.Module] = None, + ploss_decay: float = 0.8, + ) -> None: + self.eagle3_model = eagle3_model + self.target_head = target_head + self.ploss_decay = ploss_decay + + def trainable_module(self) -> nn.Module: + return self.eagle3_model + + def _device(self) -> torch.device: + return next(self.eagle3_model.parameters()).device + + def _prepare_target( + self, + target_repr: Optional[str], + input_ids: torch.Tensor, + target: torch.Tensor, + loss_mask: torch.Tensor, + device: torch.device, + ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + if target_repr == "hidden_state": + if self.target_head is None: + raise ValueError( + "target_repr='hidden_state' requires a target_head to re-run " + "the lm_head projection" + ) + # mirrors offline run_forward: shift input_ids/target, add mask dim, + # then project the target last hidden state to full-vocab logits. + input_ids, target, loss_mask = self.target_head.preprocess( + input_ids, target, loss_mask + ) + target = self.target_head(target.to(device)) + return input_ids.to(device), target, loss_mask.to(device) + # logits / pruned_logits: rollout already produced (and shifted) the + # distribution. + return input_ids.to(device), target.to(device), loss_mask.to(device) + + def forward_loss( + self, batch: TrainBatch, ctx: Optional[StepContext] = None + ) -> StepOutput: + self.validate_batch(batch) + t = batch.tensors + device = self._device() + target_repr = batch.metadata.get("target_repr") + + input_ids, target, loss_mask = self._prepare_target( + target_repr, t["input_ids"], t["target"], t["loss_mask"], device + ) + position_ids = t.get("position_ids") + ( + plosses, + acceptance_rates, + acces, + acc_corrects, + acc_denoms, + metric_losses, + metric_loss_denoms, + ) = self.eagle3_model( + input_ids=input_ids, + attention_mask=t["attention_mask"].to(device), + loss_mask=loss_mask, + target=target, + hidden_states=t["hidden_state"].to(device), + position_ids=position_ids.to(device) if position_ids is not None else None, + ) + weights = [self.ploss_decay**i for i in range(len(plosses))] + loss = sum(weights[i] * plosses[i] for i in range(len(plosses))) + return StepOutput( + loss=loss, + metrics={ + "plosses": [p.detach() for p in plosses], + "acces": [a.detach() for a in acces], + "acceptance_rates": [a.detach() for a in acceptance_rates], + "acc_corrects": [c.detach() for c in acc_corrects], + "acc_denoms": [d.detach() for d in acc_denoms], + "metric_losses": [m.detach() for m in metric_losses], + "metric_loss_denoms": [d.detach() for d in metric_loss_denoms], + }, + ) + + def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: + # The target-copied embedding is skipped only when actually frozen; a + # trainable embedding must be persisted (checked on the live module — + # state_dict tensors are detached and carry no requires_grad). + embed_frozen = all( + not p.requires_grad + for n, p in self.eagle3_model.named_parameters() + if "embed" in n.lower() + ) + return { + k.replace("draft_model.", ""): v + for k, v in state_dict.items() + if "draft_model." in k and not (embed_frozen and "embed" in k.lower()) + } + + +class DFlashTrainStrategy(DraftTrainStrategy): + """DFlash block-parallel strategy wrapping the existing ``OnlineDFlashModel``. + + Shares the trainer/backend/loader/checkpoint spine with EAGLE3; only the + per-step forward/loss differs (single block-wise pass, scalar loss, hard + real-token labels — no target distribution, no vocab map). ``hidden_states`` + is DFlash's own schema name, distinct from EAGLE3's ``hidden_state``. + """ + + name = "dflash" + required_features = {"input_ids", "hidden_states", "loss_mask"} + + def __init__(self, dflash_model: nn.Module) -> None: + self.dflash_model = dflash_model + + def trainable_module(self) -> nn.Module: + return self.dflash_model + + def _device(self) -> torch.device: + return next(self.dflash_model.parameters()).device + + def forward_loss( + self, batch: TrainBatch, ctx: Optional[StepContext] = None + ) -> StepOutput: + self.validate_batch(batch) + t = batch.tensors + device = self._device() + loss, accuracy, model_metrics = self.dflash_model( + input_ids=t["input_ids"].to(device), + hidden_states=t["hidden_states"].to(device), + loss_mask=t["loss_mask"].to(device), + ) + return StepOutput( + loss=loss, + metrics={ + "accuracy": accuracy.detach(), + # the accuracy's own denominator, so eval can weight it exactly + "accuracy_denom": model_metrics["accuracy_denom"], + }, + ) + + def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: + # Everything trainable lives under draft_model.; the target + # embedding/head are a separate module, not persisted as draft weights. + return { + k.replace("draft_model.", ""): v + for k, v in state_dict.items() + if "draft_model." in k + } + + +class DSparkTrainStrategy(DraftTrainStrategy): + """DSpark strategy over DFlash with target hidden-state supervision.""" + + name = "dspark" + required_features = { + "input_ids", + "hidden_states", + "loss_mask", + "target_last_hidden_states", + } + + def __init__(self, dspark_model: nn.Module) -> None: + self.dspark_model = dspark_model + + def trainable_module(self) -> nn.Module: + return self.dspark_model + + def _device(self) -> torch.device: + return next(self.dspark_model.parameters()).device + + def forward_loss( + self, batch: TrainBatch, ctx: Optional[StepContext] = None + ) -> StepOutput: + self.validate_batch(batch) + t = batch.tensors + device = self._device() + loss, accuracy, model_metrics = self.dspark_model( + input_ids=t["input_ids"].to(device), + hidden_states=t["hidden_states"].to(device), + loss_mask=t["loss_mask"].to(device), + target_last_hidden_states=t["target_last_hidden_states"].to(device), + ) + metrics = { + "accuracy": accuracy.detach(), + "accuracy_denom": model_metrics["accuracy_denom"], + } + for name in ( + "ce_loss", + "l1_loss", + "confidence_loss", + "confidence_abs_error", + ): + if name in model_metrics: + metrics[name] = model_metrics[name] + return StepOutput(loss=loss, metrics=metrics) + + def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: + return { + k.replace("draft_model.", ""): v + for k, v in state_dict.items() + if "draft_model." in k + } + + +class DominoTrainStrategy(DraftTrainStrategy): + """Domino block-parallel strategy wrapping ``OnlineDominoModel``. + + Shares the trainer/backend/loader/checkpoint spine and feature schema with + DFlash. Unlike the others, its loss blends a base loss with a weight + ``lambda_base`` that decays over training, so it reads :class:`StepContext`. + """ + + name = "domino" + required_features = {"input_ids", "hidden_states", "loss_mask"} + + def __init__( + self, + domino_model: nn.Module, + *, + lambda_start: float = 1.0, + decay_ratio: float = 0.5, + ) -> None: + self.domino_model = domino_model + self.lambda_start = lambda_start + self.decay_ratio = decay_ratio + + def trainable_module(self) -> nn.Module: + return self.domino_model + + def _device(self) -> torch.device: + return next(self.domino_model.parameters()).device + + def _lambda_base(self, ctx: Optional[StepContext]) -> float: + # No schedule horizon -> pure final loss (lambda_base = 0). + if ctx is None or not ctx.total_steps: + return 0.0 + return linear_lambda_base( + ctx.global_step, ctx.total_steps, self.lambda_start, self.decay_ratio + ) + + def forward_loss( + self, batch: TrainBatch, ctx: Optional[StepContext] = None + ) -> StepOutput: + self.validate_batch(batch) + t = batch.tensors + device = self._device() + lambda_base = self._lambda_base(ctx) + loss, accuracy, model_metrics = self.domino_model( + input_ids=t["input_ids"].to(device), + hidden_states=t["hidden_states"].to(device), + loss_mask=t["loss_mask"].to(device), + lambda_base=lambda_base, + ) + return StepOutput( + loss=loss, + metrics={ + "accuracy": accuracy.detach(), + # the accuracy's own denominator, so eval can weight it exactly + "accuracy_denom": model_metrics["accuracy_denom"], + "lambda_base": torch.tensor(float(lambda_base)), + }, + ) + + def checkpoint_state_filter(self, state_dict: Dict[str, Any]) -> Dict[str, Any]: + # Everything trainable lives under draft_model.; the target + # embedding/head are a separate module, not persisted as draft weights. + return { + k.replace("draft_model.", ""): v + for k, v in state_dict.items() + if "draft_model." in k + } + __all__ = [ "DraftTrainStrategy", "Eagle3TrainStrategy", "DFlashTrainStrategy", + "DSparkTrainStrategy", "DominoTrainStrategy", "StepOutput", "StepContext", diff --git a/specforge/training/strategies/registry.py b/specforge/training/strategies/registry.py index 7f1619097..4ef4c44a8 100644 --- a/specforge/training/strategies/registry.py +++ b/specforge/training/strategies/registry.py @@ -271,6 +271,67 @@ def pad3d(t): # [1, n, W] -> [1, maxlen, W] ) +# --- DSpark ----------------------------------------------------------------- +# DSpark is intentionally wired only for the server-capture disaggregated online +# path. It reuses DFlash's captured layer feature (hidden_states), but also +# requires the target model's last hidden state so the consumer can compute the +# target distribution through its frozen lm_head. + +from specforge.training.strategies.base import DSparkTrainStrategy + + +def _dspark_online_collate(): + def collate(feats): + maxlen = max(f["input_ids"].shape[-1] for f in feats) + + def pad2d(t): + n = t.shape[-1] + if n == maxlen: + return t + return torch.cat([t, t.new_zeros(t.shape[0], maxlen - n)], dim=-1) + + def pad3d(t): + n = t.shape[1] + if n == maxlen: + return t + return torch.cat( + [t, t.new_zeros(t.shape[0], maxlen - n, t.shape[2])], dim=1 + ) + + return { + "input_ids": torch.cat([pad2d(f["input_ids"]) for f in feats], dim=0), + "loss_mask": torch.cat([pad2d(f["loss_mask"]) for f in feats], dim=0), + "hidden_states": torch.cat( + [pad3d(f["hidden_states"]) for f in feats], dim=0 + ), + "target_last_hidden_states": torch.cat( + [pad3d(f["target_last_hidden_states"]) for f in feats], dim=0 + ), + } + + return collate + + +def _dspark_server_capture_only_adapter(*args, **kwargs): + raise NotImplementedError( + "DSpark online training is wired only for the disaggregated " + "server-capture path. Use examples/disagg/run_disagg_dspark.py." + ) + + +register_strategy( + StrategySpec( + name="dspark", + required_features=frozenset(DSparkTrainStrategy.required_features), + make_strategy=lambda wrapped, *, target_head=None: DSparkTrainStrategy(wrapped), + uses_target_head=False, + make_online_collate=_dspark_online_collate, + make_adapter=_dspark_server_capture_only_adapter, + supports_online=True, + ) +) + + # --- Domino ----------------------------------------------------------------- # Domino reuses DFlash's draft model (projector_type="domino" head), feature # schema, offline transform/collate, and capture path (same DFLASH_FEATURE_SCHEMA diff --git a/tests/test_modeling/test_draft_registry.py b/tests/test_modeling/test_draft_registry.py index 7690935b0..673f6ab6d 100644 --- a/tests/test_modeling/test_draft_registry.py +++ b/tests/test_modeling/test_draft_registry.py @@ -40,16 +40,31 @@ TINY_DFLASH = { "architectures": ["DFlashDraftModel"], "model_type": "qwen3", + "block_size": 4, "hidden_size": 64, "intermediate_size": 128, "num_attention_heads": 4, "num_key_value_heads": 2, "num_hidden_layers": 1, + "num_target_layers": 4, "head_dim": 16, "max_position_embeddings": 512, "vocab_size": 256, } +TINY_DSPARK = { + **TINY_DFLASH, + "architectures": ["DFlashDraftModel"], + "layer_types": ["full_attention"], + "dflash_config": { + "projector_type": "dspark", + "markov_rank": 8, + "markov_head_type": "vanilla", + "confidence_head_alpha": 1.0, + "confidence_head_with_markov": True, + }, +} + def _write(cfg: dict) -> str: fd, path = tempfile.mkstemp(suffix=".json") @@ -110,6 +125,16 @@ def test_from_file_resolves_dflash_via_registry(self): config = AutoDraftModelConfig.from_file(path) self.assertIsInstance(config, Qwen3Config) + def test_from_config_builds_dspark_as_dflash_projector(self): + path = _write(TINY_DSPARK) + self.addCleanup(os.unlink, path) + config = AutoDraftModelConfig.from_file(path) + model = AutoEagle3DraftModel.from_config(config) + self.assertIsInstance(model, DFlashDraftModel) + self.assertEqual(model.projector_type, "dspark") + self.assertIsNotNone(model.markov_head) + self.assertIsNotNone(model.confidence_head) + def test_from_file_unknown_architecture_raises(self): path = _write({**TINY_EAGLE3, "architectures": ["NoSuchDraft"]}) self.addCleanup(os.unlink, path) diff --git a/tests/test_runtime/test_disagg_multiserver.py b/tests/test_runtime/test_disagg_multiserver.py index 052a242ba..056a8b687 100644 --- a/tests/test_runtime/test_disagg_multiserver.py +++ b/tests/test_runtime/test_disagg_multiserver.py @@ -121,6 +121,36 @@ def test_two_servers_disjoint_and_complete(self): self.assertEqual(by_server.get("http://server0:30000"), seen0) self.assertEqual(by_server.get("http://server1:30001"), seen1) + def test_prompt_epochs_republish_with_unique_sample_ids(self): + backend = _FakeMooncakeStore() + stub = _StubCaptureServer(backend) + store = MooncakeFeatureStore(store=backend, store_id="run0") + N, E = 3, 2 + channel = StreamingRefChannel(os.path.join(self._workdir(), "refs.jsonl")) + _workers, drive = _build( + [_adapter(store, stub)], + _prompts(N), + store, + channel, + lease=2, + prompt_epochs=E, + ) + + produced = drive() + self.assertEqual(produced, N * E) + ids = _published_sample_ids(channel.path) + self.assertEqual(len(ids), N * E) + self.assertEqual(len(set(ids)), N * E) + self.assertEqual( + set(ids), + { + f"run0:epoch{epoch:04d}-prompt{idx:012d}" + for epoch in range(E) + for idx in range(N) + }, + ) + self.assertTrue(channel.is_closed()) + def test_one_dead_server_survivor_completes_pool(self): backend = _FakeMooncakeStore() healthy = _StubCaptureServer(backend) diff --git a/tests/test_runtime/test_server_capture.py b/tests/test_runtime/test_server_capture.py index f946c8fa6..76e2a0323 100644 --- a/tests/test_runtime/test_server_capture.py +++ b/tests/test_runtime/test_server_capture.py @@ -223,6 +223,20 @@ def _dflash_contract() -> FeatureContract: ) +def _dspark_contract() -> FeatureContract: + return FeatureContract.from_strategy( + required_features={ + "input_ids", + "hidden_states", + "loss_mask", + "target_last_hidden_states", + }, + aux_hidden_state_layer_ids=AUX_LAYERS, + target_repr="hidden_state", + target_hidden_size=HIDDEN, + ) + + def _mk(strategy="eagle3", server=None, backend=None): backend = backend or _FakeMooncakeStore() server = server or _StubCaptureServer(backend) @@ -286,6 +300,36 @@ def test_dflash_schema_names_and_no_target(self): out, _ = store.get(ref) self.assertEqual(out["input_ids"].tolist(), [list(range(1, 6))]) + def test_dspark_schema_includes_target_last_hidden(self): + backend, server, store, adapter = _mk(strategy="dspark") + refs = adapter.produce_refs([_task(0, 5)], capture=_dspark_contract()) + (ref,) = refs + self.assertIsInstance(ref, SampleRef) + self.assertEqual(ref.strategy, "dspark") + self.assertEqual( + sorted(ref.feature_specs), + [ + "hidden_states", + "input_ids", + "loss_mask", + "target_last_hidden_states", + ], + ) + self.assertEqual( + ref.feature_specs["hidden_states"].shape, + (1, 5, len(AUX_LAYERS) * HIDDEN), + ) + self.assertEqual( + ref.feature_specs["target_last_hidden_states"].shape, + (1, 5, HIDDEN), + ) + self.assertEqual( + ref.feature_specs["target_last_hidden_states"].target_repr, + "hidden_state", + ) + out, _ = store.get(ref) + self.assertIn("target_last_hidden_states", out) + def test_batch_response_wrappers_are_normalized_by_sample_id(self): backend = _FakeMooncakeStore() server = _StubCaptureServer(backend) diff --git a/tests/test_runtime/test_strategy_registry.py b/tests/test_runtime/test_strategy_registry.py index 45616b90b..ebaad5e0a 100644 --- a/tests/test_runtime/test_strategy_registry.py +++ b/tests/test_runtime/test_strategy_registry.py @@ -17,7 +17,11 @@ import torch from specforge import launch -from specforge.training.strategies.base import DFlashTrainStrategy, Eagle3TrainStrategy +from specforge.training.strategies.base import ( + DFlashTrainStrategy, + DSparkTrainStrategy, + Eagle3TrainStrategy, +) from specforge.training.strategies.registry import ( available_strategies, resolve_strategy, @@ -28,6 +32,7 @@ class TestStrategyRegistry(unittest.TestCase): def test_builtins_registered(self): self.assertIn("eagle3", available_strategies()) self.assertIn("dflash", available_strategies()) + self.assertIn("dspark", available_strategies()) def test_dflash_fully_wired(self): spec = resolve_strategy("dflash") @@ -51,6 +56,25 @@ class _M: spec.make_strategy(_M(), target_head=None), DFlashTrainStrategy ) + def test_dspark_is_server_capture_online_only(self): + spec = resolve_strategy("dspark") + self.assertEqual( + spec.required_features, frozenset(DSparkTrainStrategy.required_features) + ) + self.assertTrue(spec.supports_online) + self.assertIsNone(spec.make_offline_reader) + self.assertIsNone(spec.feature_schema) + self.assertFalse(spec.uses_target_head) + with self.assertRaises(NotImplementedError): + spec.make_adapter(None) + + class _M: + draft_model = object() + + self.assertIsInstance( + spec.make_strategy(_M(), target_head=None), DSparkTrainStrategy + ) + def test_required_features_track_the_strategy_class(self): # The registry's required_features is the single source of truth wired # into FeatureContract.from_strategy + loader validation; it must equal the @@ -80,6 +104,25 @@ def test_dflash_and_domino_online_collate_pad_ragged_sequences(self): self.assertEqual(batch["input_ids"][0].tolist(), [1, 2, 3, 0, 0]) self.assertTrue(torch.all(batch["hidden_states"][0, 3:] == 0)) + def test_dspark_online_collate_pads_target_last_hidden(self): + short = { + "input_ids": torch.tensor([[1, 2, 3]]), + "loss_mask": torch.tensor([[1, 1, 1]]), + "hidden_states": torch.ones(1, 3, 4), + "target_last_hidden_states": torch.ones(1, 3, 2), + } + long = { + "input_ids": torch.tensor([[4, 5, 6, 7, 8]]), + "loss_mask": torch.tensor([[1, 1, 1, 1, 1]]), + "hidden_states": torch.ones(1, 5, 4), + "target_last_hidden_states": torch.ones(1, 5, 2), + } + batch = resolve_strategy("dspark").make_online_collate()([short, long]) + self.assertEqual(batch["input_ids"].shape, (2, 5)) + self.assertEqual(batch["hidden_states"].shape, (2, 5, 4)) + self.assertEqual(batch["target_last_hidden_states"].shape, (2, 5, 2)) + self.assertTrue(torch.all(batch["target_last_hidden_states"][0, 3:] == 0)) + def test_eagle3_is_fully_wired(self): spec = resolve_strategy("eagle3") self.assertTrue(spec.supports_online) diff --git a/tests/test_utils/test_dflash_losses.py b/tests/test_utils/test_dflash_losses.py index aa48278d9..068f1d06a 100644 --- a/tests/test_utils/test_dflash_losses.py +++ b/tests/test_utils/test_dflash_losses.py @@ -1,9 +1,9 @@ # coding=utf-8 -"""Formula-level tests for DFlash/D-PACE loss behavior. +"""Formula-level tests for DFlash/D-PACE/DSpark loss behavior. -The tests load ``specforge/core/dflash.py`` directly with a lightweight draft -model stub so they can run on CPU without importing the full modeling stack. -They still exercise ``OnlineDFlashModel.forward``: anchor sampling, draft +The tests load the core wrappers directly with a lightweight draft model stub so +they can run on CPU without importing the full modeling stack. +They still exercise the online wrappers: anchor sampling, draft output, and LM head output are made deterministic. """ @@ -57,6 +57,26 @@ class _DFlashDraftStub(nn.Module): _spec.loader.exec_module(_dflash_module) OnlineDFlashModel = _dflash_module.OnlineDFlashModel +_spec_dspark = importlib.util.spec_from_file_location( + "specforge.core.dspark", REPO / "specforge" / "core" / "dspark.py" +) +_dspark_module = importlib.util.module_from_spec(_spec_dspark) + +with patch.dict( + sys.modules, + { + "specforge": _pkg_specforge, + "specforge.core": _pkg_core, + "specforge.core.dflash": _dflash_module, + "specforge.core.dspark": _dspark_module, + "specforge.modeling": _pkg_modeling, + "specforge.modeling.draft": _pkg_draft, + "specforge.modeling.draft.dflash": _stub_dflash_draft, + }, +): + _spec_dspark.loader.exec_module(_dspark_module) +OnlineDSparkModel = _dspark_module.OnlineDSparkModel + class _FixedDraft(nn.Module): def __init__(self, hidden_size: int): @@ -74,6 +94,25 @@ def forward(self, position_ids, noise_embedding, target_hidden, attention_mask): ) +class _FixedDSparkDraft(_FixedDraft): + def __init__(self, hidden_size: int, confidence_logits: torch.Tensor = None): + super().__init__(hidden_size) + if confidence_logits is not None: + self.register_buffer("confidence_logits", confidence_logits) + else: + self.confidence_logits = None + + def apply_markov_logits(self, base_logits, prev_token_ids, hidden_states): + del prev_token_ids, hidden_states + return base_logits + + def predict_confidence(self, hidden_states, prev_token_ids=None): + del prev_token_ids + if self.confidence_logits is None: + return None + return self.confidence_logits.to(device=hidden_states.device) + + class _FixedHead(nn.Module): def __init__(self, logits: torch.Tensor): super().__init__() @@ -83,6 +122,21 @@ def forward(self, hidden_states): return self.fixed_logits.to(device=hidden_states.device) +class _DualFixedHead(nn.Module): + def __init__(self, draft_logits: torch.Tensor, target_logits: torch.Tensor): + super().__init__() + self.register_buffer("draft_logits", draft_logits) + self.register_buffer("target_logits", target_logits) + + def forward(self, hidden_states): + if hidden_states.ndim == 4: + return self.target_logits.to(device=hidden_states.device) + bsz, n_blocks, block_size, vocab_size = self.draft_logits.shape + return self.draft_logits.reshape( + bsz, n_blocks * block_size, vocab_size + ).to(device=hidden_states.device) + + def _fixed_noise_embed(self, input_ids, anchor_positions, block_keep_mask): bsz, n_blocks = anchor_positions.shape return torch.zeros( @@ -94,12 +148,6 @@ def _fixed_noise_embed(self, input_ids, anchor_positions, block_keep_mask): ) -def _fixed_vp_noise_embed( - self, input_ids, anchor_positions, block_keep_mask, prefix_lengths -): - return _fixed_noise_embed(self, input_ids, anchor_positions, block_keep_mask) - - def _fixed_anchor_sampler(anchors, keep_mask): def _sample(self, seq_len, loss_mask, device): return anchors.to(device), keep_mask.to(device) @@ -107,20 +155,34 @@ def _sample(self, seq_len, loss_mask, device): return _sample -def _fixed_prefix_sampler(prefix_lengths): - def _sample(self, bsz, n_blocks, device): - return prefix_lengths.to(device) - - return _sample +def _make_model(logits, anchors, keep_mask, draft_model=None, lm_head=None, **kwargs): + bsz, n_blocks, block_size, vocab_size = logits.shape + model = OnlineDFlashModel( + draft_model=draft_model or _FixedDraft(hidden_size=4), + target_lm_head=lm_head + or _FixedHead(logits.reshape(bsz, n_blocks * block_size, vocab_size)), + target_embed_tokens=nn.Embedding(vocab_size, 4).double(), + mask_token_id=0, + block_size=block_size, + attention_backend="sdpa", + num_anchors=n_blocks, + **kwargs, + ).double() + model._sample_anchor_positions = types.MethodType( + _fixed_anchor_sampler(anchors, keep_mask), model + ) + model._create_noise_embed = types.MethodType(_fixed_noise_embed, model) + return model -def _make_model(logits, anchors, keep_mask, prefix_lengths=None, **kwargs): +def _make_dspark_model( + logits, anchors, keep_mask, draft_model=None, lm_head=None, **kwargs +): bsz, n_blocks, block_size, vocab_size = logits.shape - model = OnlineDFlashModel( - draft_model=_FixedDraft(hidden_size=4), - target_lm_head=_FixedHead( - logits.reshape(bsz, n_blocks * block_size, vocab_size) - ), + model = OnlineDSparkModel( + draft_model=draft_model or _FixedDraft(hidden_size=4), + target_lm_head=lm_head + or _FixedHead(logits.reshape(bsz, n_blocks * block_size, vocab_size)), target_embed_tokens=nn.Embedding(vocab_size, 4).double(), mask_token_id=0, block_size=block_size, @@ -132,11 +194,6 @@ def _make_model(logits, anchors, keep_mask, prefix_lengths=None, **kwargs): _fixed_anchor_sampler(anchors, keep_mask), model ) model._create_noise_embed = types.MethodType(_fixed_noise_embed, model) - model._create_vp_noise_embed = types.MethodType(_fixed_vp_noise_embed, model) - if prefix_lengths is not None: - model._sample_prefix_lengths = types.MethodType( - _fixed_prefix_sampler(prefix_lengths), model - ) return model @@ -184,29 +241,31 @@ def _targets_and_mask(input_ids, loss_mask, anchors, keep_mask, block_size): return targets, binary_mask -def _vp_targets_and_mask( - input_ids, loss_mask, anchors, keep_mask, prefix_lengths, block_size -): +def _dspark_targets_and_mask(input_ids, loss_mask, anchors, keep_mask, block_size): bsz, seq_len = input_ids.shape n_blocks = anchors.shape[1] - offsets = torch.arange(block_size).view(1, 1, -1) + offsets = torch.arange(1, block_size + 1).view(1, 1, -1) label_indices = anchors.unsqueeze(-1) + offsets safe_indices = label_indices.clamp(max=seq_len - 1) + safe_indices = torch.where( + keep_mask.unsqueeze(-1), + safe_indices, + torch.zeros_like(safe_indices), + ) targets = torch.gather( input_ids.unsqueeze(1).expand(-1, n_blocks, -1), 2, safe_indices, ) - binary_mask = keep_mask.unsqueeze(-1).expand(-1, -1, block_size).double() - binary_mask = binary_mask * (label_indices < seq_len).double() - binary_mask = binary_mask * (offsets >= prefix_lengths.unsqueeze(-1)).double() gathered_loss_mask = torch.gather( loss_mask.unsqueeze(1).expand(-1, n_blocks, -1), 2, safe_indices, ) - binary_mask = binary_mask * gathered_loss_mask - return targets, binary_mask + eval_mask = (label_indices < seq_len) & (gathered_loss_mask > 0.5) + eval_mask = eval_mask & keep_mask.unsqueeze(-1) + eval_mask = eval_mask.to(torch.int32).cumprod(dim=-1).bool() + return targets, eval_mask def _neg_log_q(logits, targets): @@ -244,17 +303,6 @@ def _naive_dflash_loss(neg_log_q, binary_mask, gamma): return (neg_log_q * weight).sum() / (weight.sum() + 1e-6) -def _naive_vp_drafter_loss(neg_log_q, binary_mask, prefix_lengths, gamma): - weight = binary_mask - if gamma is not None and gamma > 0: - block_size = neg_log_q.shape[-1] - positions = torch.arange(block_size, dtype=neg_log_q.dtype).view(1, 1, -1) - prefix = prefix_lengths.unsqueeze(-1).to(dtype=neg_log_q.dtype) - decay = torch.exp(-(positions - prefix).clamp(min=0) / gamma) - weight = weight * decay - return (neg_log_q * weight).sum() / (weight.sum() + 1e-6) - - class TestDFlashLosses(unittest.TestCase): def setUp(self): ( @@ -297,36 +345,6 @@ def test_dflash_decay_gamma_is_preserved(self): want = _naive_dflash_loss(self.neg_log_q, self.binary_mask, gamma=gamma) torch.testing.assert_close(got, want, rtol=0, atol=1e-8) - def test_vp_drafter_masks_visible_prefix_and_decays_from_first_mask(self): - gamma = 7.0 - prefix_lengths = torch.tensor([[2, 3], [2, 4]], dtype=torch.long) - targets, binary_mask = _vp_targets_and_mask( - self.input_ids, - self.loss_mask, - self.anchors, - self.keep_mask, - prefix_lengths, - self.logits.shape[2], - ) - neg_log_q = _neg_log_q(self.logits, targets) - model = _make_model( - self.logits, - self.anchors, - self.keep_mask, - prefix_lengths=prefix_lengths, - loss_type="vp_drafter", - loss_decay_gamma=gamma, - ) - got, accuracy, metrics = model( - input_ids=self.input_ids, - hidden_states=self.hidden_states, - loss_mask=self.loss_mask, - ) - want = _naive_vp_drafter_loss(neg_log_q, binary_mask, prefix_lengths, gamma) - self.assertTrue(torch.isfinite(accuracy)) - torch.testing.assert_close(metrics["accuracy_denom"], binary_mask.sum()) - torch.testing.assert_close(got, want, rtol=0, atol=1e-8) - def test_dpace_full_matches_naive_reference(self): alpha = 0.5 got = self._forward_loss(loss_type="dpace", dpace_alpha=alpha) @@ -408,6 +426,113 @@ def test_dflash_draft_stub_does_not_leak_to_sys_modules(self): _stub_dflash_draft, ) + def test_dspark_ce_only_uses_next_token_labels_and_contiguous_mask(self): + model = _make_dspark_model( + self.logits, + self.anchors, + self.keep_mask, + dspark_ce_loss_alpha=1.0, + dspark_l1_loss_alpha=0.0, + dspark_confidence_head_alpha=0.0, + ) + loss, accuracy, _metrics = model( + input_ids=self.input_ids, + hidden_states=self.hidden_states, + loss_mask=self.loss_mask, + ) + self.assertTrue(torch.isfinite(accuracy)) + targets, eval_mask = _dspark_targets_and_mask( + self.input_ids, + self.loss_mask, + self.anchors, + self.keep_mask, + self.logits.shape[2], + ) + neg_log_q = _neg_log_q(self.logits, targets) + weights = eval_mask.double() + want = (neg_log_q * weights).sum() / (weights.sum() + 1e-6) + torch.testing.assert_close(loss, want, rtol=0, atol=1e-6) + + def test_dspark_l1_and_confidence_match_reference(self): + torch.manual_seed(321) + target_logits = torch.randn_like(self.logits) + confidence_logits = torch.randn( + self.logits.shape[:3], + dtype=torch.double, + ) + draft = _FixedDSparkDraft( + hidden_size=4, + confidence_logits=confidence_logits, + ).double() + head = _DualFixedHead(self.logits, target_logits).double() + model = _make_dspark_model( + self.logits, + self.anchors, + self.keep_mask, + draft_model=draft, + lm_head=head, + dspark_ce_loss_alpha=0.1, + dspark_l1_loss_alpha=0.9, + dspark_confidence_head_alpha=1.0, + ) + loss, _accuracy, metrics = model( + input_ids=self.input_ids, + hidden_states=self.hidden_states, + loss_mask=self.loss_mask, + target_last_hidden_states=torch.zeros_like(self.hidden_states), + ) + targets, eval_mask = _dspark_targets_and_mask( + self.input_ids, + self.loss_mask, + self.anchors, + self.keep_mask, + self.logits.shape[2], + ) + weights = eval_mask.double() + ce = (_neg_log_q(self.logits, targets) * weights).sum() / ( + weights.sum() + 1e-6 + ) + draft_probs = torch.softmax(self.logits.float(), dim=-1) + target_probs = torch.softmax(target_logits.float(), dim=-1) + l1_dist = (draft_probs - target_probs).abs().sum(dim=-1).double() + l1 = (l1_dist * weights).sum() / (weights.sum() + 1e-6) + accept_rate = (1.0 - 0.5 * l1_dist).clamp(0.0, 1.0) + conf = F.binary_cross_entropy_with_logits( + confidence_logits.float(), + accept_rate.float(), + reduction="none", + ).double() + conf = (conf * weights).sum() / (weights.sum() + 1e-6) + want = 0.1 * ce + 0.9 * l1 + conf + torch.testing.assert_close(loss, want, rtol=0, atol=1e-6) + torch.testing.assert_close(metrics["ce_loss"], ce, rtol=0, atol=1e-6) + torch.testing.assert_close( + metrics["l1_loss"], l1, rtol=0, atol=1e-6, check_dtype=False + ) + torch.testing.assert_close( + metrics["confidence_loss"], + conf, + rtol=0, + atol=1e-6, + check_dtype=False, + ) + + def test_dspark_requires_target_logits_for_l1_or_confidence(self): + model = _make_dspark_model( + self.logits, + self.anchors, + self.keep_mask, + dspark_ce_loss_alpha=0.1, + dspark_l1_loss_alpha=0.9, + dspark_confidence_head_alpha=0.0, + ) + with self.assertRaisesRegex(ValueError, "target_last_hidden_states"): + model( + input_ids=self.input_ids, + hidden_states=self.hidden_states, + loss_mask=self.loss_mask, + ) + if __name__ == "__main__": unittest.main() From b057d18576e08b38aebc45425883313650b0f251 Mon Sep 17 00:00:00 2001 From: jiapingW <1969554248@qq.com> Date: Thu, 9 Jul 2026 19:37:25 +0800 Subject: [PATCH 15/24] support train multi epochs --- specforge/launch.py | 131 ++++++++++++++---- specforge/runtime/control_plane/controller.py | 3 + 2 files changed, 106 insertions(+), 28 deletions(-) diff --git a/specforge/launch.py b/specforge/launch.py index 1fec11336..08a11a645 100644 --- a/specforge/launch.py +++ b/specforge/launch.py @@ -187,6 +187,35 @@ def _dp_barrier() -> None: dist.barrier() +def _normalize_prompt_epochs(prompt_epochs: int) -> int: + prompt_epochs = int(prompt_epochs or 1) + if prompt_epochs < 1: + raise ValueError(f"prompt_epochs must be >= 1, got {prompt_epochs}") + return prompt_epochs + + +def _epoch_online_prompts(prompts, epoch: int, prompt_epochs: int): + """Build one epoch's prompt tasks without expanding the full run upfront.""" + if prompt_epochs == 1: + return prompts + + out = [] + for idx, prompt in enumerate(prompts): + item = dict(prompt) + metadata = dict(prompt.get("metadata") or {}) + if "task_id" in prompt: + metadata.setdefault("base_task_id", str(prompt["task_id"])) + metadata["prompt_index"] = idx + metadata["epoch"] = epoch + metadata["prompt_epochs"] = prompt_epochs + item["metadata"] = metadata + # The online feature store is consume-once and commit dedups by + # sample_id, so every epoch pass must mint distinct task/sample ids. + item["task_id"] = f"epoch{epoch:04d}-prompt{idx:012d}" + out.append(item) + return out + + def _checkpoint_global_step(resume_from: str) -> int: """Read ``global_step`` from the shared payload of the checkpoint at ``resume_from`` (a checkpoint dir, its state file, or a ``file://`` URI).""" @@ -594,6 +623,7 @@ def build_disagg_online_producer( metadata_store: Optional[MetadataStore] = None, metadata_db_path: Optional[str] = None, sleep=None, + prompt_epochs: int = 1, ): """Producer side of an ONLINE disaggregated run (rollout pool). @@ -608,6 +638,10 @@ def build_disagg_online_producer( above ``in_flight_high_watermark``, and always closes the channel on exit (EOF terminates the consumer's loader). + ``prompt_epochs`` repeats the prompt stream on the producer side by minting + epoch-tagged task/sample ids. This keeps the consumer path consume-once while + preserving ``num_epochs`` semantics for online streams. + Failure semantics: a worker whose source raises (dead/unreachable server) has already failed its leases retryable — the surviving workers re-lease those prompts. After ``max_worker_failures`` *consecutive* failures the @@ -636,11 +670,16 @@ def elapsed(start: float) -> str: spec = resolve_strategy(strategy) sleep = sleep or time.sleep build_start = time.perf_counter() - prompt_count = len(prompts) if hasattr(prompts, "__len__") else "unknown" + prompt_epochs = _normalize_prompt_epochs(prompt_epochs) + if prompt_epochs > 1: + prompts = list(prompts) + base_prompt_count = len(prompts) if hasattr(prompts, "__len__") else "unknown" producer_timing( "build_disagg_online_producer enter " - f"strategy={strategy} prompts={prompt_count} lease={lease} " - f"workers={num_rollout_workers} watermark={in_flight_high_watermark}" + f"strategy={strategy} base_prompts={base_prompt_count} " + f"prompt_epochs={prompt_epochs} " + f"lease={lease} workers={num_rollout_workers} " + f"watermark={in_flight_high_watermark}" ) phase = time.perf_counter() controller = DataFlowController( @@ -649,15 +688,6 @@ def elapsed(start: float) -> str: max_prompt_attempts=max_prompt_attempts, ) producer_timing(f"DataFlowController created elapsed={elapsed(phase)}") - phase = time.perf_counter() - producer_timing(f"controller.ingest_prompts start prompts={prompt_count}") - task_ids = controller.ingest_prompts(prompts) - status = controller.status() - producer_timing( - "controller.ingest_prompts done " - f"tasks={len(task_ids)} pending={status['prompts_pending']} " - f"elapsed={elapsed(phase)}" - ) phase = time.perf_counter() producer_timing("assemble rollout workers start") @@ -828,17 +858,35 @@ def run_worker(w) -> None: # leases may yet fail back into the pool) — wait, retry. sleep(backpressure_poll_s) - fatal: list = [] # non-transport errors escaping a worker thread + def ingest_epoch(epoch: int) -> None: + epoch_prompts = _epoch_online_prompts(prompts, epoch, prompt_epochs) + epoch_count = ( + len(epoch_prompts) if hasattr(epoch_prompts, "__len__") else "unknown" + ) + phase = time.perf_counter() + producer_timing( + "controller.ingest_prompts start " + f"epoch={epoch + 1}/{prompt_epochs} prompts={epoch_count}" + ) + task_ids = controller.ingest_prompts(epoch_prompts) + status = controller.status() + producer_timing( + "controller.ingest_prompts done " + f"epoch={epoch + 1}/{prompt_epochs} tasks={len(task_ids)} " + f"pending={status['prompts_pending']} elapsed={elapsed(phase)}" + ) - def run_worker_guarded(w) -> None: - try: - run_worker(w) - except BaseException as exc: # e.g. a channel publish failure - fatal.append((w.worker_id, exc)) + def run_epoch_workers(live_workers) -> None: + fatal: list = [] # non-transport errors escaping a worker thread - try: - if len(workers) == 1: - run_worker(workers[0]) + def run_worker_guarded(w) -> None: + try: + run_worker(w) + except BaseException as exc: # e.g. a channel publish failure + fatal.append((w.worker_id, exc)) + + if len(live_workers) == 1: + run_worker(live_workers[0]) else: threads = [ threading.Thread( @@ -847,7 +895,7 @@ def run_worker_guarded(w) -> None: name=f"drive-{w.worker_id}", daemon=True, ) - for w in workers + for w in live_workers ] for t in threads: t.start() @@ -855,11 +903,37 @@ def run_worker_guarded(w) -> None: t.join() if fatal: raise fatal[0][1] - stopped = should_stop is not None and should_stop() - if dead and not stopped and not pool_drained(): - raise RuntimeError( - f"all rollout workers exited with {len(dead)} dropped as " - f"dead and prompts remaining — dead workers: {dead}" + + try: + live_workers = list(workers) + for epoch in range(prompt_epochs): + if should_stop is not None and should_stop(): + break + ingest_epoch(epoch) + if not live_workers: + raise RuntimeError( + f"all rollout workers were already dropped before " + f"epoch {epoch + 1}/{prompt_epochs} could run — " + f"dead workers: {dead}" + ) + run_epoch_workers(live_workers) + stopped = should_stop is not None and should_stop() + live_workers = [w for w in live_workers if w.worker_id not in dead] + if dead and not stopped and not pool_drained(): + raise RuntimeError( + f"all rollout workers exited with {len(dead)} dropped as " + f"dead and prompts remaining — dead workers: {dead}" + ) + if stopped: + break + st = controller.status() + producer_timing( + "epoch drained " + f"epoch={epoch + 1}/{prompt_epochs} " + f"produced={state['produced']} " + f"prompts_failed={st['prompts_failed']} " + f"pending={st['prompts_pending']} leased={st['prompts_leased']} " + f"elapsed={elapsed(drive_start)}" ) st = controller.status() if st["prompts_failed"]: @@ -1255,6 +1329,7 @@ def build_disagg_online_runtime( in_flight_high_watermark=in_flight_high_watermark, metadata_store=shared_store, metadata_db_path=metadata_db_path, + prompt_epochs=num_epochs, ) trainer, loader = build_disagg_online_consumer( strategy=strategy, @@ -1266,7 +1341,7 @@ def build_disagg_online_runtime( output_dir=output_dir, batch_size=batch_size, accumulation_steps=accumulation_steps, - num_epochs=num_epochs, + num_epochs=1, max_steps=max_steps, total_steps=total_steps, save_interval=save_interval, diff --git a/specforge/runtime/control_plane/controller.py b/specforge/runtime/control_plane/controller.py index 35f2624f1..955b7c47c 100644 --- a/specforge/runtime/control_plane/controller.py +++ b/specforge/runtime/control_plane/controller.py @@ -212,8 +212,10 @@ def fail_prompt_tasks( self._prompt_failed[task_id] = ( f"{reason} (attempts exhausted: {task.attempt + 1})" ) + self._prompts.pop(task_id, None) else: self._prompt_failed[task_id] = reason + self._prompts.pop(task_id, None) def commit_samples(self, worker_id: str, refs: List[SampleRef]) -> None: fresh: List[SampleRef] = [] @@ -224,6 +226,7 @@ def commit_samples(self, worker_id: str, refs: List[SampleRef]) -> None: if ref.source_task_id is not None: with self._lock: self._prompt_leased.pop(ref.source_task_id, None) + self._prompts.pop(ref.source_task_id, None) fresh.append(ref) if fresh: self.sample_queue.put(fresh) From ea7d5295cc3e893335ef528d588a524d335710e4 Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Thu, 9 Jul 2026 12:17:17 -0700 Subject: [PATCH 16/24] disagg logging: DP-average loss/acc + log train/accuracy & train/lr Match stock train_dflash.py's logging so the disagg (online/DP) curves overlay the in-process trainer: - controller._result: all_reduce(loss)/world and all_reduce(acc)/world before logging (was: single rank's local-batch value -> ~sqrt(world) noisier, spiky). - _dflash_family_disagg.py logger: log accuracy under train/accuracy (was train/acc) and add train/lr from the optimizer. Co-Authored-By: Claude Opus 4.8 --- examples/disagg/_dflash_family_disagg.py | 28 +++++++++++++++----- specforge/training/controller.py | 33 ++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/examples/disagg/_dflash_family_disagg.py b/examples/disagg/_dflash_family_disagg.py index 73555c712..9995f775a 100644 --- a/examples/disagg/_dflash_family_disagg.py +++ b/examples/disagg/_dflash_family_disagg.py @@ -404,34 +404,46 @@ def build_consumer_parts( ) -def _optimizer_factory(args, total_steps: int): +def _optimizer_factory(args, total_steps: int, holder: Optional[Dict] = None): def factory(draft_module): - return BF16Optimizer( + opt = BF16Optimizer( draft_module, lr=args.learning_rate, max_grad_norm=args.max_grad_norm, warmup_ratio=args.warmup_ratio, total_steps=total_steps, ) + if holder is not None: + holder["opt"] = opt + return opt return factory -def _make_logger(args): +def _make_logger(args, holder: Optional[Dict] = None): tracker = create_tracker(args, args.output_dir) def logger(metrics, step): logdict = {} for key, value in metrics.items(): + # Log accuracy under `train/accuracy` to match stock train_dflash.py. + k = "accuracy" if key == "acc" else key try: - logdict[f"train/{key}"] = float(value) + logdict[f"train/{k}"] = float(value) except (TypeError, ValueError): try: seq = [float(x) for x in value] except (TypeError, ValueError): continue if seq: - logdict[f"train/{key}_mean"] = sum(seq) / len(seq) + logdict[f"train/{k}_mean"] = sum(seq) / len(seq) + # Log the current learning rate (like stock train_dflash.py -> train/lr). + opt = (holder or {}).get("opt") + if opt is not None: + try: + logdict["train/lr"] = float(opt.get_learning_rate()) + except Exception: + pass if logdict: tracker.log(logdict, step=step) summary = {k.split("/")[-1]: round(v, 4) for k, v in logdict.items()} @@ -456,12 +468,14 @@ def fit_online_consumer( f"(producer-looped epochs={args.num_epochs})", flush=True, ) + # Shared holder so the logger can read the live optimizer's learning rate. + opt_holder: Dict = {} trainer, loader = build_disagg_online_consumer( strategy=strategy, feature_store=mooncake_store(run_id), channel=ref_channel(), eagle3_model=composite_model, # legacy param name = composite draft model - optimizer_factory=_optimizer_factory(args, total_steps), + optimizer_factory=_optimizer_factory(args, total_steps, opt_holder), run_id=run_id, output_dir=args.output_dir, batch_size=args.batch_size, @@ -472,7 +486,7 @@ def fit_online_consumer( save_interval=args.save_interval, tp_size=args.tp_size, metadata_db_path=os.environ.get("DISAGG_DB") or None, - logger=_make_logger(args), + logger=_make_logger(args, opt_holder), log_interval=env_int("DISAGG_LOG_INTERVAL", 1), strategy_kwargs=strategy_kwargs, inbox_dir=os.environ.get("DISAGG_INBOX_DIR") or None, diff --git a/specforge/training/controller.py b/specforge/training/controller.py index 8c292a347..9cef7f065 100644 --- a/specforge/training/controller.py +++ b/specforge/training/controller.py @@ -66,6 +66,31 @@ def _scalar(x: Any) -> float: return float(x) +def _dp_mean(x: Any) -> Any: + """Average a scalar metric tensor across all ranks before it is logged. + + Matches stock ``train_dflash.py`` (``dist.all_reduce(acc); acc /= world``): + without it the disagg consumer logs a single rank's local-batch accuracy + (~1 rank x batch x anchors), which is ~sqrt(world) noisier and spikes because + each rank's few round-robin refs can be all-easy or all-hard. Reducing across + ranks recovers the ~world x larger effective sample the stock path logs. + No-op when torch.distributed is unavailable / single-rank / x is not a + tensor. Called every step on every rank, so the collective stays in lockstep. + """ + import torch.distributed as dist + + if not isinstance(x, torch.Tensor): + return x + if not (dist.is_available() and dist.is_initialized()): + return x + world = dist.get_world_size() + if world <= 1: + return x + x = x.detach().clone() + dist.all_reduce(x) + return x / world + + class TrainerCore: """One step: forward/loss (strategy) -> backward (backend) -> optimizer boundary.""" @@ -153,14 +178,18 @@ def train_step( # count tensors that correct acc-len aggregation needs. def _result(self, out: StepOutput, grad_norm, stepped: bool) -> StepResult: - metrics: Dict[str, Any] = {"loss": _scalar(out.loss)} + # DP-average loss AND accuracy across ranks before logging, matching stock + # train_dflash.py (all_reduce(loss)/world, all_reduce(acc)/world). + metrics: Dict[str, Any] = {"loss": _scalar(_dp_mean(out.loss))} for key in ("acces", "acceptance_rates", "plosses"): if key in out.metrics: metrics[key.rstrip("es") if key == "acces" else key] = _scalar( out.metrics[key] ) if "accuracy" in out.metrics: - metrics["acc"] = _scalar(out.metrics["accuracy"]) + # DP-average the accuracy across ranks (matches stock train_dflash.py) + # so the logged curve is smooth, not a single rank's noisy local batch. + metrics["acc"] = _scalar(_dp_mean(out.metrics["accuracy"])) gn = _scalar(grad_norm) if grad_norm is not None else None if gn is not None: metrics["grad_norm"] = gn From 372b857e9070f56120c2546c2472f24880d9a6cf Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Thu, 9 Jul 2026 12:28:49 -0700 Subject: [PATCH 17/24] examples: dflash 1-server + DP=7 disagg launcher (auto LR-schedule steps) Adds the DFlash counterpart of the Domino 1srv+DP7 launcher so DFlash disagg training is reproducible from the repo: - run_disagg_dflash.py entry, qwen3-8b-dflash.json, drops domino-only --lambda-base-* args, NUM_ANCHORS/WANDB_NAME overridable. - auto-derives DISAGG_TOTAL_STEPS = ceil(NUM_EPOCHS * prompts / (TRAIN_DP * BATCH_SIZE)) from the dataset when unset, so only --num-epochs is needed. Co-Authored-By: Claude Opus 4.8 --- .../run_qwen3_8b_dflash_disagg_1srv_dp7.sh | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100755 examples/disagg/run_qwen3_8b_dflash_disagg_1srv_dp7.sh diff --git a/examples/disagg/run_qwen3_8b_dflash_disagg_1srv_dp7.sh b/examples/disagg/run_qwen3_8b_dflash_disagg_1srv_dp7.sh new file mode 100755 index 000000000..170f3f53e --- /dev/null +++ b/examples/disagg/run_qwen3_8b_dflash_disagg_1srv_dp7.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# Qwen3-8B Domino, ONLINE disaggregated, SINGLE inference server + DP=7 trainer: +# mooncake master -> CPU +# patched SGLang server 0 -> SERVER0_GPUS (1 GPU: frozen Qwen3-8B -> mooncake) +# producer (HTTP driver) -> CPU +# consumer (Domino trainer) -> CONSUMER_GPUS (DP=TRAIN_DP, 7 GPUs) +# Epochs: producer replicates the prompt pool x NUM_EPOCHS (servers are idle, so +# re-capturing is free) because the streaming channel is consume-once. +set -euxo pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +ROOT_DIR=$(dirname "$(dirname "$SCRIPT_DIR")") +export TORCHINDUCTOR_CACHE_DIR=$ROOT_DIR/cache/compiled_kernels +export FLASHINFER_DISABLE_VERSION_CHECK=1 +export PYTHONPATH="$ROOT_DIR:$ROOT_DIR/scripts:${PYTHONPATH:-}" +cd "$ROOT_DIR" + +# --- topology: 1 server x TP=1 + DP=7 trainer (override via env) --- +SERVER0_GPUS=${SERVER0_GPUS:-"0"} +SERVER_TP=${SERVER_TP:-1} +SERVER0_PORT=${SERVER0_PORT:-30000} +TRAIN_DP=${TRAIN_DP:-7} +CONSUMER_GPUS=${CONSUMER_GPUS:-"1,2,3,4,5,6,7"} + +TARGET_MODEL_PATH=${TARGET_MODEL_PATH:-Qwen/Qwen3-8B} +DRAFT_CONFIG_PATH=${DRAFT_CONFIG_PATH:-$ROOT_DIR/configs/qwen3-8b-dflash.json} +TRAIN_DATA_PATH=${TRAIN_DATA_PATH:-/sgl-workspace/SpecForge/cache/dataset/perfectblend_train_regen_temperature0_no_think_20w.jsonl} +CHAT_TEMPLATE=${CHAT_TEMPLATE:-qwen} + +AUX_LAYER_IDS=${AUX_LAYER_IDS:-"1 9 17 25 33"} + +MOONCAKE_HOST=${MOONCAKE_HOST:-127.0.0.1} +MOONCAKE_RPC_PORT=${MOONCAKE_RPC_PORT:-35551} +MOONCAKE_HTTP_PORT=${MOONCAKE_HTTP_PORT:-35880} +MOONCAKE_METRICS_PORT=${MOONCAKE_METRICS_PORT:-35903} + +export MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_HOST +export MOONCAKE_MASTER_SERVER_ADDR=$MOONCAKE_HOST:$MOONCAKE_RPC_PORT +export MOONCAKE_METADATA_SERVER=http://$MOONCAKE_HOST:$MOONCAKE_HTTP_PORT/metadata +export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-tcp} +export MOONCAKE_GLOBAL_SEGMENT_SIZE=${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((32 << 30))} +export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-0} +export DISAGG_CLIENT_BUFFER_SIZE=${DISAGG_CLIENT_BUFFER_SIZE:-$((256 << 20))} +if [[ "$DISAGG_CLIENT_SEGMENT_SIZE" -ne 0 ]]; then + echo "DISAGG_CLIENT_SEGMENT_SIZE must be 0 for server-owned captures" >&2 + exit 2 +fi + +export DISAGG_STORE_ID=${DISAGG_STORE_ID:-qwen3-8b-dflash-1srv-dp7} +export DISAGG_SERVER_URLS="${DISAGG_SERVER_URLS:-http://127.0.0.1:$SERVER0_PORT}" +export DISAGG_REF_CHANNEL=${DISAGG_REF_CHANNEL:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/refs.jsonl} +DISAGG_DB=${DISAGG_DB:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/run.db} +DISAGG_INBOX_DIR=${DISAGG_INBOX_DIR:-$ROOT_DIR/outputs/$DISAGG_STORE_ID/inboxes} +# 0 = no cap; with NUM_EPOCHS the producer replicates the pool, so leave this +# uncapped and let epochs control the sample volume. +export DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-0} +export DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-0} +export DISAGG_LOG_INTERVAL=${DISAGG_LOG_INTERVAL:-10} +NUM_EPOCHS=${NUM_EPOCHS:-10} +SAVE_INTERVAL=${SAVE_INTERVAL:-800} +BATCH_SIZE=${BATCH_SIZE:-2} +# LR-schedule horizon = ceil(NUM_EPOCHS * prompts / (TRAIN_DP * BATCH_SIZE)). +# Auto-derived from the dataset line count when DISAGG_TOTAL_STEPS is unset (a +# slight over-estimate vs the post-filter prompt count, which only makes the LR +# decay a hair slower -- safe). Set DISAGG_TOTAL_STEPS to override. A too-small +# value decays the LR to ~0 before training finishes. +if [[ -z "${DISAGG_TOTAL_STEPS:-}" ]]; then + _n_prompts=$(wc -l < "$TRAIN_DATA_PATH") + DISAGG_TOTAL_STEPS=$(python3 -c "import math,sys; print(math.ceil($NUM_EPOCHS*int(sys.argv[1])/($TRAIN_DP*$BATCH_SIZE)))" "$_n_prompts") + echo "[launcher] auto DISAGG_TOTAL_STEPS=$DISAGG_TOTAL_STEPS (epochs=$NUM_EPOCHS prompts=$_n_prompts dp=$TRAIN_DP batch=$BATCH_SIZE)" +fi +export DISAGG_TOTAL_STEPS +REPORT_TO=${REPORT_TO:-none} +WANDB_PROJECT=${WANDB_PROJECT:-qwen3-8b-dflash-disagg} + +python - <<'PY' +import torch +try: + from mooncake.store import MooncakeDistributedStore, ReplicateConfig +except Exception as exc: + raise SystemExit(f"Mooncake preflight failed: {type(exc).__name__}: {exc}") from exc +PY +if [[ "$REPORT_TO" == "wandb" ]]; then + python - <<'PY' +import wandb +required = ("login", "init", "log", "finish") +if not all(callable(getattr(wandb, name, None)) for name in required): + raise SystemExit("REPORT_TO=wandb requires a complete W&B client: pip install wandb") +PY +fi + +rm -rf "$(dirname "$DISAGG_REF_CHANNEL")" "$DISAGG_DB" +mkdir -p "$(dirname "$DISAGG_REF_CHANNEL")" +: > "$DISAGG_REF_CHANNEL" +MOONCAKE_LOG=${MOONCAKE_LOG:-$(dirname "$DISAGG_REF_CHANNEL")/mooncake.log} + +cleanup() { + kill "${SERVER0_PID:-}" "${PRODUCER_PID:-}" 2>/dev/null || true + if [[ -n "${MASTER_PID:-}" ]]; then + kill -- "-$MASTER_PID" 2>/dev/null || kill "$MASTER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# --- mooncake master --- +setsid mooncake_master \ + --enable_http_metadata_server=true \ + --rpc_port="$MOONCAKE_RPC_PORT" \ + --http_metadata_server_port="$MOONCAKE_HTTP_PORT" \ + --metrics_port="$MOONCAKE_METRICS_PORT" \ + >"$MOONCAKE_LOG" 2>&1 & +MASTER_PID=$! + +wait_for_mooncake() { + for _ in $(seq 1 30); do + if ! kill -0 "$MASTER_PID" 2>/dev/null; then + echo "Mooncake master exited during startup; see $MOONCAKE_LOG" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + return 1 + fi + if curl -sS --max-time 1 -o /dev/null \ + "$MOONCAKE_METADATA_SERVER?key=specforge-health-check" \ + && timeout 1 bash -c \ + "/dev/null; then + return 0 + fi + sleep 1 + done + echo "Mooncake master did not become ready; see $MOONCAKE_LOG" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + return 1 +} +wait_for_mooncake + +# --- single patched SGLang server: frozen target, spec-capture on --- +launch_server() { # $1=gpus $2=port + CUDA_VISIBLE_DEVICES=$1 MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_LOCAL_HOSTNAME \ + python -m sglang.launch_server \ + --model-path "$TARGET_MODEL_PATH" \ + --trust-remote-code \ + --skip-tokenizer-init \ + --tp-size "$SERVER_TP" \ + --mem-fraction-static "${SERVER_MEM_FRACTION:-0.85}" \ + --chunked-prefill-size -1 \ + --disable-radix-cache \ + --enable-spec-capture \ + --spec-capture-method dflash \ + --spec-capture-aux-layer-ids $AUX_LAYER_IDS \ + --port "$2" & +} +launch_server "$SERVER0_GPUS" "$SERVER0_PORT" +SERVER0_PID=$! + +until curl -sf "http://127.0.0.1:$SERVER0_PORT/health" > /dev/null; do + if ! kill -0 "$MASTER_PID" 2>/dev/null; then + echo "Mooncake master died while SGLang server was starting" >&2 + tail -n 100 "$MOONCAKE_LOG" >&2 || true + exit 1 + fi + if ! kill -0 "$SERVER0_PID" 2>/dev/null; then echo "server on :$SERVER0_PORT died"; exit 1; fi + sleep 5 +done + +ARGS=( + --target-model-path "$TARGET_MODEL_PATH" + --target-model-backend hf + --trust-remote-code + --draft-config-path "$DRAFT_CONFIG_PATH" + --mask-token-id 151669 + --train-data-path "$TRAIN_DATA_PATH" + --chat-template "$CHAT_TEMPLATE" + --max-length 3072 + --batch-size ${BATCH_SIZE} + --accumulation-steps ${ACCUM:-1} + --learning-rate 6e-4 + --warmup-ratio 0.04 + --max-grad-norm 1.0 + --attention-backend flex_attention + --block-size 16 + --num-anchors ${NUM_ANCHORS:-512} + --loss-decay-gamma 7.0 + --num-epochs ${NUM_EPOCHS} + --seed 42 + --save-interval ${SAVE_INTERVAL} +) + +LAUNCHER=$SCRIPT_DIR/run_disagg_dflash.py + +DISAGG_ROLE=producer CUDA_VISIBLE_DEVICES="" \ + python "$LAUNCHER" "${ARGS[@]}" \ + --output-dir "$ROOT_DIR/outputs/$DISAGG_STORE_ID-producer" & +PRODUCER_PID=$! + +CUDA_VISIBLE_DEVICES=$CONSUMER_GPUS DISAGG_ROLE=consumer \ + DISAGG_DB=$DISAGG_DB DISAGG_INBOX_DIR=$DISAGG_INBOX_DIR \ + torchrun --rdzv-backend c10d --rdzv-endpoint localhost:29702 \ + --nnodes 1 --nproc_per_node "$TRAIN_DP" "$LAUNCHER" "${ARGS[@]}" \ + --output-dir "$ROOT_DIR/outputs/$DISAGG_STORE_ID-consumer" \ + --report-to "$REPORT_TO" \ + --wandb-project "$WANDB_PROJECT" \ + --wandb-name "${WANDB_NAME:-qwen3-8b-dflash-1srv-dp$TRAIN_DP}" + +wait $PRODUCER_PID +echo "QWEN3-8B-DFLASH-1SRV-DP7-DONE" From 83ec0c3dc358a65261286a21e962c548727ad875 Mon Sep 17 00:00:00 2001 From: maocheng23 Date: Thu, 9 Jul 2026 15:27:59 -0700 Subject: [PATCH 18/24] examples: add two-node DFlash launcher Add a reusable two-node Qwen3-8B DFlash launcher with rank-specific inference and training roles, cross-node Mooncake coordination, allocated-GPU UUID handling, and symmetric failure cleanup. Document the RCLI launch flow and apply the formatter changes required by PR 660's lint job. --- examples/disagg/README.md | 34 + .../run_qwen3_8b_dflash_disagg_2node.sh | 613 ++++++++++++++++++ scripts/train_dflash.py | 4 +- specforge/core/dspark.py | 21 +- tests/test_utils/test_dflash_losses.py | 10 +- 5 files changed, 660 insertions(+), 22 deletions(-) create mode 100755 examples/disagg/run_qwen3_8b_dflash_disagg_2node.sh diff --git a/examples/disagg/README.md b/examples/disagg/README.md index e2ae6480e..0992d7f33 100644 --- a/examples/disagg/README.md +++ b/examples/disagg/README.md @@ -154,6 +154,40 @@ train step, with aux parity vs an HF reference) in (`SPECFORGE_RUN_SERVER_CAPTURE_TESTS=1`, GPU); the contract/ref/adapter logic is covered on CPU in `tests/test_runtime/test_server_capture.py`. +## Run it (two physical nodes with RCLI) + +`run_qwen3_8b_dflash_disagg_2node.sh` separates the online Qwen3-8B workload by +role: node rank 0 runs Mooncake, one TP=1 capture server, and the CPU producer; +node rank 1 runs the DP=4 consumer/trainer. The two nodes exchange feature +tensors through Mooncake. A shared filesystem is still required for the small +ref channel, logs, and lifecycle markers under `DISAGG_RUN_ROOT`. + +Create a unique run ID and start one detached session per rank with identical +environment settings (replace the job, repository, and dataset paths): + +```bash +JOB=your-two-node-job +RUN_ID=qwen3-8b-dflash-$(date +%Y%m%d-%H%M%S) +REMOTE_ROOT=/workspace/SpecForge +DATA_PATH=/workspace/data/perfectblend_train.jsonl +REMOTE_CMD="cd $REMOTE_ROOT && DISAGG_STORE_ID=$RUN_ID TRAIN_DP=4 REPORT_TO=none TRAIN_DATA_PATH=$DATA_PATH bash examples/disagg/run_qwen3_8b_dflash_disagg_2node.sh" + +rcli exec -d --node-rank 0 --name dflash-inference "$JOB" "$REMOTE_CMD" +rcli exec -d --node-rank 1 --name dflash-training "$JOB" "$REMOTE_CMD" +``` + +The job scheduler must place the two pods on different physical hosts; +`--num-nodes 2` alone does not guarantee this, so verify physical placement +before launching. Both pods must share the repository/output path and have +direct network reachability for the Mooncake and SGLang ports. The launcher +consumes Kubernetes GPU UUIDs from `NVIDIA_VISIBLE_DEVICES`; override +`SERVER_GPUS`, `CONSUMER_GPUS`, or `TRAIN_DP` for other allocations. A +`CONSUMER_GPUS` override must list exactly `TRAIN_DP` devices. The tested +topology allocates four GPUs per pod, uses one GPU on rank 0, and trains DP=4 on +rank 1. Set `REPORT_TO=wandb` and the usual W&B variables to enable remote +reporting. `DISAGG_IDLE_TIMEOUT` defaults to 600 seconds so a lost inference +pod cannot leave the consumer blocked forever. + ## Multi-server (scale the inference pool) ```bash diff --git a/examples/disagg/run_qwen3_8b_dflash_disagg_2node.sh b/examples/disagg/run_qwen3_8b_dflash_disagg_2node.sh new file mode 100755 index 000000000..61a4f9546 --- /dev/null +++ b/examples/disagg/run_qwen3_8b_dflash_disagg_2node.sh @@ -0,0 +1,613 @@ +#!/usr/bin/env bash +# Qwen3-8B DFlash, ONLINE disaggregated across two physical nodes: +# node rank 0: Mooncake master + patched SGLang server + CPU producer +# node rank 1: DP consumer/trainer +# +# Both nodes must see DISAGG_RUN_ROOT through a shared filesystem. Feature +# tensors travel through Mooncake; the shared path only carries refs, logs, and +# lifecycle markers. RCLI_NODE_RANK/RCLI_NUM_NODES/RCLI_HEAD_IP are used when +# available; set NODE_RANK/NUM_NODES/HEAD_IP explicitly on other launchers. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")" + +readonly NODE_RANK="${NODE_RANK:-${RCLI_NODE_RANK:-}}" +readonly NUM_NODES="${NUM_NODES:-${RCLI_NUM_NODES:-}}" +readonly HEAD_IP="${HEAD_IP:-${RCLI_HEAD_IP:-}}" +readonly RUN_ID="${DISAGG_STORE_ID:?Set DISAGG_STORE_ID to one unique value shared by both nodes}" +readonly RUN_ROOT="${DISAGG_RUN_ROOT:-${ROOT_DIR}/outputs/${RUN_ID}}" +readonly REF_CHANNEL="${DISAGG_REF_CHANNEL:-${RUN_ROOT}/refs.jsonl}" + +readonly TARGET_MODEL_PATH="${TARGET_MODEL_PATH:-Qwen/Qwen3-8B}" +readonly DRAFT_CONFIG_PATH="${DRAFT_CONFIG_PATH:-${ROOT_DIR}/configs/qwen3-8b-dflash.json}" +readonly TRAIN_DATA_PATH="${TRAIN_DATA_PATH:-${ROOT_DIR}/cache/dataset/perfectblend_train.jsonl}" +readonly CHAT_TEMPLATE="${CHAT_TEMPLATE:-qwen}" + +readonly TRAIN_DP="${TRAIN_DP:-4}" +readonly BATCH_SIZE="${BATCH_SIZE:-2}" +readonly ACCUM="${ACCUM:-1}" +readonly NUM_EPOCHS="${NUM_EPOCHS:-10}" +readonly SAVE_INTERVAL="${SAVE_INTERVAL:-800}" +readonly NUM_ANCHORS="${NUM_ANCHORS:-512}" +readonly REPORT_TO="${REPORT_TO:-none}" +readonly WANDB_PROJECT="${WANDB_PROJECT:-qwen3-8b-dflash-disagg}" + +readonly SERVER_TP="${SERVER_TP:-1}" +readonly SERVER_PORT="${SERVER_PORT:-30000}" +readonly SERVER_MEM_FRACTION="${SERVER_MEM_FRACTION:-0.85}" +readonly AUX_LAYER_IDS="${AUX_LAYER_IDS:-1 9 17 25 33}" +readonly MOONCAKE_RPC_PORT="${MOONCAKE_RPC_PORT:-35551}" +readonly MOONCAKE_HTTP_PORT="${MOONCAKE_HTTP_PORT:-35880}" +readonly MOONCAKE_METRICS_PORT="${MOONCAKE_METRICS_PORT:-35903}" +readonly SERVICE_START_TIMEOUT="${SERVICE_START_TIMEOUT:-1800}" +readonly CONSUMER_START_TIMEOUT="${CONSUMER_START_TIMEOUT:-1800}" + +TRAINING_CUDA_DEVICES="" +SERVER_CUDA_DEVICES="" +AUX_LAYER_ID_ARGS=() + +log() { + printf '[dflash-2node][rank=%s][%s] %s\n' \ + "${NODE_RANK:-unknown}" "$(date '+%Y-%m-%d %H:%M:%S')" "$*" +} + +fail() { + log "ERROR: $*" + exit 1 +} + +write_status() { + local path="$1" + local status="$2" + local temporary_path="${path}.tmp.$$" + + printf '%s\n' "$status" > "$temporary_path" + mv -f "$temporary_path" "$path" +} + +read_status() { + local path="$1" + tr -d '[:space:]' < "$path" +} + +kill_process_group() { + local pid="${1:-}" + [[ -n "$pid" ]] || return 0 + + kill -TERM -- "-${pid}" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true + local attempt + for attempt in $(seq 1 10); do + kill -0 "$pid" 2>/dev/null || return 0 + sleep 1 + done + kill -KILL -- "-${pid}" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true +} + +resolve_local_ip() { + local local_ip + local_ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" + if [[ -z "$local_ip" ]]; then + local_ip="$(hostname -i 2>/dev/null | awk '{print $1}' || true)" + fi + [[ -n "$local_ip" ]] || fail "could not resolve this node's routable IP" + printf '%s\n' "$local_ip" +} + +resolve_gpu_devices() { + local runtime_allocation="${NVIDIA_VISIBLE_DEVICES:-}" + local runtime_has_no_gpus=0 + if [[ "$runtime_allocation" == "none" || "$runtime_allocation" == "void" ]]; then + runtime_has_no_gpus=1 + fi + + if [[ -n "${CONSUMER_GPUS:-}" ]]; then + TRAINING_CUDA_DEVICES="$CONSUMER_GPUS" + elif [[ -n "${TRAIN_GPU_IDS:-}" ]]; then + TRAINING_CUDA_DEVICES="$TRAIN_GPU_IDS" + elif [[ "$runtime_has_no_gpus" == "1" ]]; then + TRAINING_CUDA_DEVICES="" + elif [[ -n "$runtime_allocation" && "$runtime_allocation" != "all" ]]; then + # Kubernetes commonly provides GPU UUIDs here. Using them directly + # avoids selecting unallocated host-visible devices in privileged pods. + TRAINING_CUDA_DEVICES="$runtime_allocation" + else + TRAINING_CUDA_DEVICES="$(seq -s, 0 $((TRAIN_DP - 1)))" + fi + + if [[ -n "${SERVER_GPUS:-}" ]]; then + SERVER_CUDA_DEVICES="$SERVER_GPUS" + elif [[ -n "${SERVER_GPU:-}" ]]; then + SERVER_CUDA_DEVICES="$SERVER_GPU" + elif [[ "$runtime_has_no_gpus" == "1" ]]; then + SERVER_CUDA_DEVICES="" + elif [[ -n "$runtime_allocation" && "$runtime_allocation" != "all" ]]; then + SERVER_CUDA_DEVICES="${runtime_allocation%%,*}" + else + SERVER_CUDA_DEVICES="0" + fi + + read -r -a AUX_LAYER_ID_ARGS <<< "$AUX_LAYER_IDS" +} + +validate_launch_identity() { + [[ "$NUM_NODES" == "2" ]] || fail "expected NUM_NODES=2, got '${NUM_NODES}'" + [[ "$NODE_RANK" == "0" || "$NODE_RANK" == "1" ]] || \ + fail "expected NODE_RANK=0 or 1, got '${NODE_RANK}'" + [[ -n "$HEAD_IP" ]] || fail "HEAD_IP/RCLI_HEAD_IP is required" + [[ "$RUN_ROOT" != "/" ]] || fail "DISAGG_RUN_ROOT must not be /" + [[ "$RUN_ID" =~ ^[A-Za-z0-9._-]+$ ]] || \ + fail "DISAGG_STORE_ID may contain only letters, digits, '.', '_', and '-': ${RUN_ID}" + [[ "$TRAIN_DP" =~ ^[1-9][0-9]*$ ]] || fail "TRAIN_DP must be positive: ${TRAIN_DP}" + [[ "$SERVER_TP" =~ ^[1-9][0-9]*$ ]] || fail "SERVER_TP must be positive: ${SERVER_TP}" + [[ "$BATCH_SIZE" =~ ^[1-9][0-9]*$ ]] || fail "BATCH_SIZE must be positive: ${BATCH_SIZE}" + [[ "$ACCUM" =~ ^[1-9][0-9]*$ ]] || fail "ACCUM must be positive: ${ACCUM}" +} + +count_gpu_devices() { + local devices="$1" + if [[ -z "$devices" ]]; then + printf '0\n' + else + awk -F, '{print NF}' <<< "$devices" + fi +} + +validate_workload_inputs() { + [[ -s "$TRAIN_DATA_PATH" ]] || fail \ + "training data is missing: ${TRAIN_DATA_PATH}; run scripts/prepare_data.py or set TRAIN_DATA_PATH" + [[ -f "$DRAFT_CONFIG_PATH" ]] || fail "draft config is missing: ${DRAFT_CONFIG_PATH}" + + local training_gpu_count + local server_gpu_count + training_gpu_count="$(count_gpu_devices "$TRAINING_CUDA_DEVICES")" + server_gpu_count="$(count_gpu_devices "$SERVER_CUDA_DEVICES")" + if [[ "$NODE_RANK" == "1" && "$training_gpu_count" != "$TRAIN_DP" ]]; then + fail "training needs ${TRAIN_DP} GPUs, got ${training_gpu_count}: ${TRAINING_CUDA_DEVICES}" + fi + if [[ "$NODE_RANK" == "0" && "$server_gpu_count" != "$SERVER_TP" ]]; then + fail "server TP=${SERVER_TP} needs ${SERVER_TP} GPUs, got ${server_gpu_count}: ${SERVER_CUDA_DEVICES}" + fi +} + +validate_common_runtime() { + python - <<'PY' +import sglang + +if not sglang.__version__.startswith("0.5.14"): + raise SystemExit(f"expected sglang 0.5.14, got {sglang.__version__}") +PY + python - <<'PY' +from mooncake.store import MooncakeDistributedStore, ReplicateConfig + +print("Mooncake preflight ok", flush=True) +PY + + if [[ "$REPORT_TO" == "wandb" ]]; then + python - <<'PY' +import wandb + +required = ("login", "init", "log", "finish") +if not all(callable(getattr(wandb, name, None)) for name in required): + raise SystemExit("REPORT_TO=wandb requires a complete W&B client: pip install wandb") +PY + fi +} + +prepare_inference_runtime() { + validate_common_runtime + "${ROOT_DIR}/scripts/apply_sglang_spec_capture_patch.sh" + python - <<'PY' +import pathlib +import shutil + +import sglang + +sink = pathlib.Path(sglang.__file__).parent / "srt" / "spec_capture_sink.py" +if not sink.is_file(): + raise SystemExit(f"spec-capture patch is missing: {sink}") +if shutil.which("mooncake_master") is None: + raise SystemExit("mooncake_master is not on PATH") +print(f"inference preflight ok: sglang={sglang.__version__} patch={sink}", flush=True) +PY +} + +configure_common_environment() { + export PYTHONPATH="${ROOT_DIR}:${ROOT_DIR}/scripts:${PYTHONPATH:-}" + export TORCHINDUCTOR_CACHE_DIR="${TORCHINDUCTOR_CACHE_DIR:-${ROOT_DIR}/cache/compiled_kernels}" + export FLASHINFER_DISABLE_VERSION_CHECK=1 + + export MOONCAKE_MASTER_SERVER_ADDR="${MOONCAKE_MASTER_SERVER_ADDR:-${HEAD_IP}:${MOONCAKE_RPC_PORT}}" + export MOONCAKE_METADATA_SERVER="${MOONCAKE_METADATA_SERVER:-http://${HEAD_IP}:${MOONCAKE_HTTP_PORT}/metadata}" + export MOONCAKE_PROTOCOL="${MOONCAKE_PROTOCOL:-tcp}" + export MOONCAKE_GLOBAL_SEGMENT_SIZE="${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((32 << 30))}" + export DISAGG_CLIENT_SEGMENT_SIZE="${DISAGG_CLIENT_SEGMENT_SIZE:-0}" + export DISAGG_CLIENT_BUFFER_SIZE="${DISAGG_CLIENT_BUFFER_SIZE:-$((256 << 20))}" + [[ "$DISAGG_CLIENT_SEGMENT_SIZE" == "0" ]] || \ + fail "DISAGG_CLIENT_SEGMENT_SIZE must be 0 for server-owned captures" + + export DISAGG_STORE_ID="$RUN_ID" + export DISAGG_SERVER_URLS="${DISAGG_SERVER_URLS:-http://${HEAD_IP}:${SERVER_PORT}}" + export DISAGG_REF_CHANNEL="$REF_CHANNEL" + export DISAGG_MAX_PROMPTS="${DISAGG_MAX_PROMPTS:-0}" + export DISAGG_MAX_STEPS="${DISAGG_MAX_STEPS:-0}" + export DISAGG_LOG_INTERVAL="${DISAGG_LOG_INTERVAL:-10}" + export DISAGG_PROMPT_LOG_EVERY="${DISAGG_PROMPT_LOG_EVERY:-10000}" + # Bounds a no-marker rank-0 failure (for example, pod loss/SIGKILL). + export DISAGG_IDLE_TIMEOUT="${DISAGG_IDLE_TIMEOUT:-600}" + + local dataset_rows + dataset_rows="$(wc -l < "$TRAIN_DATA_PATH")" + export DISAGG_TOTAL_STEPS="${DISAGG_TOTAL_STEPS:-$(python -c \ + 'import math, sys; print(math.ceil(int(sys.argv[1]) * int(sys.argv[2]) / (int(sys.argv[3]) * int(sys.argv[4]) * int(sys.argv[5]))))' \ + "$NUM_EPOCHS" "$dataset_rows" "$TRAIN_DP" "$BATCH_SIZE" "$ACCUM")}" + + mkdir -p "$TORCHINDUCTOR_CACHE_DIR" + log "configuration: run_id=${RUN_ID} rows=${dataset_rows} dp=${TRAIN_DP} batch=${BATCH_SIZE} accum=${ACCUM} epochs=${NUM_EPOCHS} steps=${DISAGG_TOTAL_STEPS}" +} + +build_common_args() { + COMMON_ARGS=( + --target-model-path "$TARGET_MODEL_PATH" + --target-model-backend hf + --trust-remote-code + --draft-config-path "$DRAFT_CONFIG_PATH" + --mask-token-id 151669 + --train-data-path "$TRAIN_DATA_PATH" + --chat-template "$CHAT_TEMPLATE" + --max-length 3072 + --batch-size "$BATCH_SIZE" + --accumulation-steps "$ACCUM" + --learning-rate 6e-4 + --warmup-ratio 0.04 + --max-grad-norm 1.0 + --attention-backend flex_attention + --block-size 16 + --num-anchors "$NUM_ANCHORS" + --loss-decay-gamma 7.0 + --num-epochs "$NUM_EPOCHS" + --seed 42 + --save-interval "$SAVE_INTERVAL" + ) +} + +wait_for_file() { + local path="$1" + local description="$2" + local timeout_seconds="$3" + local abort_status_path="${4:-}" + local started_at + + started_at="$(date +%s)" + while true; do + if [[ -n "$abort_status_path" && -f "$abort_status_path" ]]; then + fail "${description} aborted with peer status $(read_status "$abort_status_path")" + fi + if [[ -e "$path" ]]; then + if [[ -n "$abort_status_path" && -f "$abort_status_path" ]]; then + fail "${description} aborted with peer status $(read_status "$abort_status_path")" + fi + return 0 + fi + if (( $(date +%s) - started_at >= timeout_seconds )); then + fail "timed out waiting for ${description}: ${path}" + fi + sleep 2 + done +} + +run_inference_node() { + local master_pid="" + local server_pid="" + local producer_pid="" + local inference_rc=1 + + mkdir -p "$(dirname "$RUN_ROOT")" + mkdir "$RUN_ROOT" 2>/dev/null || \ + fail "run root already exists; choose a new DISAGG_STORE_ID: ${RUN_ROOT}" + + cleanup_inference() { + log "stopping inference services" + kill_process_group "$producer_pid" + kill_process_group "$server_pid" + kill_process_group "$master_pid" + } + record_inference_exit() { + write_status "${RUN_ROOT}/inference.done" "$inference_rc" + } + trap 'cleanup_inference; record_inference_exit' EXIT + trap 'inference_rc=129; exit 129' HUP + trap 'inference_rc=130; exit 130' INT + trap 'inference_rc=143; exit 143' TERM + + validate_workload_inputs + configure_common_environment + for channel_path in \ + "$DISAGG_REF_CHANNEL" \ + "${DISAGG_REF_CHANNEL}.closed" \ + "${DISAGG_REF_CHANNEL}.consumed_count"; do + [[ ! -e "$channel_path" ]] || fail \ + "ref channel state already exists; choose a new path: ${channel_path}" + done + mkdir -p "$(dirname "$DISAGG_REF_CHANNEL")" + : > "$DISAGG_REF_CHANNEL" + : > "${RUN_ROOT}/consumer.log" + + prepare_inference_runtime + touch "${RUN_ROOT}/initialized" + + export MOONCAKE_LOCAL_HOSTNAME="${INFERENCE_NODE_IP:-${HEAD_IP}}" + log "starting Mooncake master on ${HEAD_IP}" + setsid mooncake_master \ + --enable_http_metadata_server=true \ + --http_metadata_server_host=0.0.0.0 \ + --rpc_port="$MOONCAKE_RPC_PORT" \ + --http_metadata_server_port="$MOONCAKE_HTTP_PORT" \ + --metrics_port="$MOONCAKE_METRICS_PORT" \ + > "${RUN_ROOT}/mooncake.log" 2>&1 & + master_pid="$!" + + local master_ready=0 + local started_at + started_at="$(date +%s)" + while (( $(date +%s) - started_at < SERVICE_START_TIMEOUT )); do + kill -0 "$master_pid" 2>/dev/null || \ + fail "Mooncake master exited; see ${RUN_ROOT}/mooncake.log" + if curl -sS --max-time 1 -o /dev/null \ + "${MOONCAKE_METADATA_SERVER}?key=specforge-health-check" && \ + timeout 1 bash -c "/dev/null; then + master_ready=1 + break + fi + sleep 1 + done + [[ "$master_ready" == "1" ]] || fail "Mooncake master did not become ready" + + log "starting patched SGLang server on ${SERVER_CUDA_DEVICES}" + setsid env \ + CUDA_VISIBLE_DEVICES="$SERVER_CUDA_DEVICES" \ + MOONCAKE_LOCAL_HOSTNAME="$MOONCAKE_LOCAL_HOSTNAME" \ + python -m sglang.launch_server \ + --host 0.0.0.0 \ + --model-path "$TARGET_MODEL_PATH" \ + --trust-remote-code \ + --skip-tokenizer-init \ + --tp-size "$SERVER_TP" \ + --mem-fraction-static "$SERVER_MEM_FRACTION" \ + --chunked-prefill-size -1 \ + --disable-radix-cache \ + --enable-spec-capture \ + --spec-capture-method dflash \ + --spec-capture-aux-layer-ids "${AUX_LAYER_ID_ARGS[@]}" \ + --port "$SERVER_PORT" \ + > "${RUN_ROOT}/sglang-server.log" 2>&1 & + server_pid="$!" + + local server_ready=0 + started_at="$(date +%s)" + while (( $(date +%s) - started_at < SERVICE_START_TIMEOUT )); do + kill -0 "$master_pid" 2>/dev/null || fail "Mooncake master exited during server startup" + kill -0 "$server_pid" 2>/dev/null || \ + fail "SGLang server exited; see ${RUN_ROOT}/sglang-server.log" + if curl -fsS "http://${HEAD_IP}:${SERVER_PORT}/health" > /dev/null; then + server_ready=1 + break + fi + sleep 5 + done + [[ "$server_ready" == "1" ]] || fail "SGLang server did not become ready" + touch "${RUN_ROOT}/inference.ready" + log "inference services ready; waiting for the consumer model" + + wait_for_file \ + "${RUN_ROOT}/consumer.started" \ + "consumer process startup" \ + "$CONSUMER_START_TIMEOUT" \ + "${RUN_ROOT}/consumer.done" + started_at="$(date +%s)" + while ! grep -Fq "[consumer] training from mooncake://${RUN_ID}" \ + "${RUN_ROOT}/consumer.log" 2>/dev/null; do + kill -0 "$master_pid" 2>/dev/null || fail "Mooncake master exited before consumer readiness" + kill -0 "$server_pid" 2>/dev/null || fail "SGLang server exited before consumer readiness" + if [[ -f "${RUN_ROOT}/consumer.done" ]]; then + fail "consumer exited before readiness with code $(read_status "${RUN_ROOT}/consumer.done")" + fi + if (( $(date +%s) - started_at >= CONSUMER_START_TIMEOUT )); then + fail "timed out waiting for consumer model readiness" + fi + sleep 2 + done + + log "consumer is ready; starting the CPU producer" + setsid env DISAGG_ROLE=producer CUDA_VISIBLE_DEVICES="" \ + python "${SCRIPT_DIR}/run_disagg_dflash.py" \ + "${COMMON_ARGS[@]}" \ + --output-dir "${RUN_ROOT}/producer" \ + > >(tee "${RUN_ROOT}/producer.log") 2>&1 & + producer_pid="$!" + + local consumer_finished_early=0 + while kill -0 "$producer_pid" 2>/dev/null; do + kill -0 "$master_pid" 2>/dev/null || fail "Mooncake master exited during production" + kill -0 "$server_pid" 2>/dev/null || fail "SGLang server exited during production" + if [[ -f "${RUN_ROOT}/consumer.done" ]]; then + local early_consumer_rc + early_consumer_rc="$(read_status "${RUN_ROOT}/consumer.done")" + [[ "$early_consumer_rc" == "0" ]] || fail "consumer exited with code ${early_consumer_rc}" + consumer_finished_early=1 + break + fi + sleep 5 + done + + if [[ "$consumer_finished_early" == "1" ]]; then + if [[ "$DISAGG_MAX_STEPS" == "0" ]]; then + log "uncapped consumer completed early; allowing producer five seconds to close" + sleep 5 + fi + if [[ "$DISAGG_MAX_STEPS" == "0" ]] && kill -0 "$producer_pid" 2>/dev/null; then + kill_process_group "$producer_pid" + set +e + wait "$producer_pid" + local early_producer_rc="$?" + set -e + producer_pid="" + write_status "${RUN_ROOT}/producer.done" "$early_producer_rc" + fail "uncapped consumer completed before producer (producer code ${early_producer_rc})" + fi + + local stopped_producer_rc + if [[ "$DISAGG_MAX_STEPS" == "0" ]]; then + log "producer closed after the uncapped consumer drained its refs" + set +e + wait "$producer_pid" + stopped_producer_rc="$?" + set -e + producer_pid="" + write_status "${RUN_ROOT}/producer.done" "$stopped_producer_rc" + [[ "$stopped_producer_rc" == "0" ]] || \ + fail "producer exited with code ${stopped_producer_rc}" + else + log "capped consumer completed; stopping remaining production" + kill_process_group "$producer_pid" + set +e + wait "$producer_pid" + stopped_producer_rc="$?" + set -e + producer_pid="" + write_status "${RUN_ROOT}/producer.done" "$stopped_producer_rc" + fi + else + local producer_rc + set +e + wait "$producer_pid" + producer_rc="$?" + set -e + producer_pid="" + write_status "${RUN_ROOT}/producer.done" "$producer_rc" + [[ "$producer_rc" == "0" ]] || fail "producer exited with code ${producer_rc}" + + log "producer completed; retaining Mooncake objects until training exits" + while [[ ! -f "${RUN_ROOT}/consumer.done" ]]; do + kill -0 "$master_pid" 2>/dev/null || fail "Mooncake master exited during training" + kill -0 "$server_pid" 2>/dev/null || fail "SGLang server exited during training" + sleep 10 + done + local consumer_rc + consumer_rc="$(read_status "${RUN_ROOT}/consumer.done")" + [[ "$consumer_rc" == "0" ]] || fail "consumer exited with code ${consumer_rc}" + fi + + inference_rc=0 + log "training completed successfully" + cleanup_inference + record_inference_exit + trap - EXIT HUP INT TERM +} + +run_training_node() { + wait_for_file \ + "${RUN_ROOT}/initialized" \ + "rank-0 run initialization" \ + 600 \ + "${RUN_ROOT}/inference.done" + + local trainer_pid="" + local consumer_rc=1 + record_consumer_exit() { + write_status "${RUN_ROOT}/consumer.done" "$consumer_rc" + } + cleanup_training() { + kill_process_group "$trainer_pid" + } + trap 'cleanup_training; record_consumer_exit' EXIT + trap 'consumer_rc=129; exit 129' HUP + trap 'consumer_rc=130; exit 130' INT + trap 'consumer_rc=143; exit 143' TERM + + validate_workload_inputs + configure_common_environment + validate_common_runtime + wait_for_file \ + "${RUN_ROOT}/inference.ready" \ + "inference readiness" \ + "$SERVICE_START_TIMEOUT" \ + "${RUN_ROOT}/inference.done" + + export MOONCAKE_LOCAL_HOSTNAME="${TRAIN_NODE_IP:-$(resolve_local_ip)}" + local local_run_root="${DISAGG_LOCAL_RUN_ROOT:-/tmp/${RUN_ID}}" + [[ "$local_run_root" != "/" ]] || fail "DISAGG_LOCAL_RUN_ROOT must not be /" + mkdir -p "$(dirname "$local_run_root")" + mkdir "$local_run_root" 2>/dev/null || \ + fail "local run root already exists; choose a new DISAGG_STORE_ID: ${local_run_root}" + mkdir "${local_run_root}/inboxes" + + : > "${RUN_ROOT}/consumer.log" + touch "${RUN_ROOT}/consumer.started" + log "starting DP=${TRAIN_DP} DFlash consumer on ${TRAINING_CUDA_DEVICES}" + setsid env \ + CUDA_VISIBLE_DEVICES="$TRAINING_CUDA_DEVICES" \ + DISAGG_ROLE=consumer \ + DISAGG_DB="${local_run_root}/run.db" \ + DISAGG_INBOX_DIR="${local_run_root}/inboxes" \ + torchrun \ + --standalone \ + --nnodes 1 \ + --nproc_per_node "$TRAIN_DP" \ + "${SCRIPT_DIR}/run_disagg_dflash.py" \ + "${COMMON_ARGS[@]}" \ + --output-dir "${RUN_ROOT}/consumer" \ + --report-to "$REPORT_TO" \ + --wandb-project "$WANDB_PROJECT" \ + --wandb-name "${WANDB_NAME:-${RUN_ID}}" \ + > >(tee "${RUN_ROOT}/consumer.log") 2>&1 & + trainer_pid="$!" + + local inference_failed=0 + while kill -0 "$trainer_pid" 2>/dev/null; do + if [[ -f "${RUN_ROOT}/inference.done" ]]; then + local inference_rc + inference_rc="$(read_status "${RUN_ROOT}/inference.done")" + if [[ "$inference_rc" != "0" ]]; then + log "inference node exited with code ${inference_rc}; stopping trainer" + inference_failed=1 + consumer_rc="$inference_rc" + kill_process_group "$trainer_pid" + break + fi + fi + sleep 5 + done + + set +e + wait "$trainer_pid" + local trainer_rc="$?" + set -e + trainer_pid="" + if [[ "$inference_failed" == "0" ]]; then + consumer_rc="$trainer_rc" + fi + + if [[ "$consumer_rc" == "0" ]]; then + log "consumer completed successfully" + else + log "consumer exited with code ${consumer_rc}" + fi + record_consumer_exit + trap - EXIT HUP INT TERM + exit "$consumer_rc" +} + +main() { + validate_launch_identity + resolve_gpu_devices + build_common_args + cd "$ROOT_DIR" + + case "$NODE_RANK" in + 0) run_inference_node ;; + 1) run_training_node ;; + esac +} + +main "$@" diff --git a/scripts/train_dflash.py b/scripts/train_dflash.py index 4523679dd..987046c61 100755 --- a/scripts/train_dflash.py +++ b/scripts/train_dflash.py @@ -111,9 +111,7 @@ def _add_dflash_loss_args(parser: argparse.ArgumentParser) -> None: "dpace-cumulative-confidence-only", "dpace-continuation-value-only", ], - help=( - "Training objective for this DFlash-family entry point. " - ), + help=("Training objective for this DFlash-family entry point. "), ) loss_group.add_argument( diff --git a/specforge/core/dspark.py b/specforge/core/dspark.py index cfbf3df00..6deefd9d1 100644 --- a/specforge/core/dspark.py +++ b/specforge/core/dspark.py @@ -7,10 +7,7 @@ import torch.nn as nn import torch.nn.functional as F -from specforge.core.dflash import ( - FLEX_ATTENTION_AVAILABLE, - OnlineDFlashModel, -) +from specforge.core.dflash import FLEX_ATTENTION_AVAILABLE, OnlineDFlashModel from specforge.modeling.draft.dflash import DFlashDraftModel @@ -80,8 +77,8 @@ def _sample_anchor_positions( return anchors, keep_mask valid_counts = valid.sum(dim=1) - indices = torch.arange(num_candidates, device=device).unsqueeze(0).expand( - bsz, -1 + indices = ( + torch.arange(num_candidates, device=device).unsqueeze(0).expand(bsz, -1) ) masked_indices = torch.where( valid, @@ -152,9 +149,7 @@ def _loss_weight_mask( loss_weight_mask = eval_mask.to(torch.float32) if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: positions = torch.arange(self.block_size, device=device).view(1, 1, -1) - decay_weights = torch.exp( - -positions.float() / float(self.loss_decay_gamma) - ) + decay_weights = torch.exp(-positions.float() / float(self.loss_decay_gamma)) loss_weight_mask = loss_weight_mask * decay_weights return loss_weight_mask @@ -225,7 +220,9 @@ def _compute_loss( confidence_abs_error = ce_loss.new_zeros(()) if confidence_pred is not None: if accept_rate_3d is None: - raise ValueError("DSpark confidence head requires aligned target logits.") + raise ValueError( + "DSpark confidence head requires aligned target logits." + ) confidence_errors = F.binary_cross_entropy_with_logits( confidence_pred.float(), accept_rate_3d.detach(), @@ -274,9 +271,7 @@ def forward( logits = self.lm_head(output_hidden) num_blocks = anchor_positions.size(1) - output_hidden_4d = output_hidden.reshape( - bsz, num_blocks, self.block_size, -1 - ) + output_hidden_4d = output_hidden.reshape(bsz, num_blocks, self.block_size, -1) ( target_ids, eval_mask, diff --git a/tests/test_utils/test_dflash_losses.py b/tests/test_utils/test_dflash_losses.py index 068f1d06a..d6a065398 100644 --- a/tests/test_utils/test_dflash_losses.py +++ b/tests/test_utils/test_dflash_losses.py @@ -132,9 +132,9 @@ def forward(self, hidden_states): if hidden_states.ndim == 4: return self.target_logits.to(device=hidden_states.device) bsz, n_blocks, block_size, vocab_size = self.draft_logits.shape - return self.draft_logits.reshape( - bsz, n_blocks * block_size, vocab_size - ).to(device=hidden_states.device) + return self.draft_logits.reshape(bsz, n_blocks * block_size, vocab_size).to( + device=hidden_states.device + ) def _fixed_noise_embed(self, input_ids, anchor_positions, block_keep_mask): @@ -489,9 +489,7 @@ def test_dspark_l1_and_confidence_match_reference(self): self.logits.shape[2], ) weights = eval_mask.double() - ce = (_neg_log_q(self.logits, targets) * weights).sum() / ( - weights.sum() + 1e-6 - ) + ce = (_neg_log_q(self.logits, targets) * weights).sum() / (weights.sum() + 1e-6) draft_probs = torch.softmax(self.logits.float(), dim=-1) target_probs = torch.softmax(target_logits.float(), dim=-1) l1_dist = (draft_probs - target_probs).abs().sum(dim=-1).double() From 8b2ce6737b3bfe07e979b7ea97c79563a0e2de7b Mon Sep 17 00:00:00 2001 From: jiapingW <1969554248@qq.com> Date: Fri, 10 Jul 2026 12:42:10 +0800 Subject: [PATCH 19/24] refactor domino and dspark code --- bench_domino_mfu.py | 2 +- configs/qwen3-4b-dspark.json | 4 +- configs/qwen3-8b-domino.json | 4 +- configs/qwen3.5-4b-domino.json | 4 +- examples/disagg/_dflash_family_disagg.py | 9 +- examples/disagg/run_disagg_domino.py | 2 +- examples/disagg/run_disagg_dspark.py | 2 +- scripts/train_domino.py | 10 +- specforge/core/__init__.py | 4 +- specforge/core/dflash.py | 618 ++++++++++++++++++ specforge/core/domino.py | 480 -------------- specforge/core/dspark.py | 324 --------- specforge/modeling/auto.py | 7 + specforge/modeling/draft/__init__.py | 4 + specforge/modeling/draft/dflash.py | 110 ++-- specforge/modeling/draft/domino.py | 130 ++++ .../draft/{dspark_heads.py => dspark.py} | 87 ++- specforge/training/strategies/registry.py | 10 +- tests/test_modeling/test_draft_registry.py | 85 ++- tests/test_runtime/_fixtures.py | 12 +- tests/test_utils/test_dflash_losses.py | 31 +- 21 files changed, 1019 insertions(+), 920 deletions(-) delete mode 100644 specforge/core/domino.py delete mode 100644 specforge/core/dspark.py create mode 100644 specforge/modeling/draft/domino.py rename specforge/modeling/draft/{dspark_heads.py => dspark.py} (75%) diff --git a/bench_domino_mfu.py b/bench_domino_mfu.py index edfdcbeaa..d931175a8 100644 --- a/bench_domino_mfu.py +++ b/bench_domino_mfu.py @@ -17,7 +17,7 @@ from torch.utils.flop_counter import FlopCounterMode from transformers import Qwen3Config -from specforge.core.domino import OnlineDominoModel +from specforge.core.dflash import OnlineDominoModel from specforge.modeling.draft.dflash import DFlashDraftModel H200_BF16_TFLOPS = 989.5 # SXM, dense diff --git a/configs/qwen3-4b-dspark.json b/configs/qwen3-4b-dspark.json index c2ca099ff..f47281114 100644 --- a/configs/qwen3-4b-dspark.json +++ b/configs/qwen3-4b-dspark.json @@ -1,11 +1,11 @@ { "architectures": [ - "DFlashDraftModel" + "DSparkDraftModel" ], "attention_bias": false, "attention_dropout": 0.0, "auto_map": { - "AutoModel": "dflash.DFlashDraftModel" + "AutoModel": "dspark.DSparkDraftModel" }, "block_size": 7, "bos_token_id": 151643, diff --git a/configs/qwen3-8b-domino.json b/configs/qwen3-8b-domino.json index e9dc93840..67d456f73 100644 --- a/configs/qwen3-8b-domino.json +++ b/configs/qwen3-8b-domino.json @@ -1,12 +1,12 @@ { "emb_dim": 256, "architectures": [ - "DFlashDraftModel" + "DominoDraftModel" ], "attention_bias": false, "attention_dropout": 0.0, "auto_map": { - "AutoModel": "dflash.DFlashDraftModel" + "AutoModel": "domino.DominoDraftModel" }, "block_size": 16, "bos_token_id": 151643, diff --git a/configs/qwen3.5-4b-domino.json b/configs/qwen3.5-4b-domino.json index 2a77ddee6..49a887042 100644 --- a/configs/qwen3.5-4b-domino.json +++ b/configs/qwen3.5-4b-domino.json @@ -1,11 +1,11 @@ { "architectures": [ - "DFlashDraftModel" + "DominoDraftModel" ], "attention_bias": false, "attention_dropout": 0.0, "auto_map": { - "AutoModel": "dflash.DFlashDraftModel" + "AutoModel": "domino.DominoDraftModel" }, "block_size": 16, "bos_token_id": 248043, diff --git a/examples/disagg/_dflash_family_disagg.py b/examples/disagg/_dflash_family_disagg.py index 9995f775a..de532b144 100644 --- a/examples/disagg/_dflash_family_disagg.py +++ b/examples/disagg/_dflash_family_disagg.py @@ -21,6 +21,8 @@ from specforge.inference.adapters.server_capture import SGLangServerCaptureAdapter from specforge.launch import build_disagg_online_consumer, build_disagg_online_producer from specforge.modeling.draft.dflash import DFlashDraftModel +from specforge.modeling.draft.domino import DominoDraftModel +from specforge.modeling.draft.dspark import DSparkDraftModel from specforge.modeling.target.target_utils import TargetEmbeddingsAndHead from specforge.optimizer import BF16Optimizer from specforge.runtime.data_plane.mooncake_store import MooncakeFeatureStore @@ -359,7 +361,12 @@ def build_dflash_family_draft( draft_config._attn_implementation = args.attention_backend print_on_rank0(f"Using attention backend: {args.attention_backend}") - draft_model = DFlashDraftModel(draft_config).to(device=device, dtype=torch.bfloat16) + draft_cls = DFlashDraftModel + if expected_projector_type == "domino": + draft_cls = DominoDraftModel + elif expected_projector_type == "dspark": + draft_cls = DSparkDraftModel + draft_model = draft_cls(draft_config).to(device=device, dtype=torch.bfloat16) print_on_rank0( f"Draft config: block_size={draft_config.block_size}, " f"num_hidden_layers={draft_config.num_hidden_layers}, " diff --git a/examples/disagg/run_disagg_domino.py b/examples/disagg/run_disagg_domino.py index 367e23ccb..aae46cc2f 100644 --- a/examples/disagg/run_disagg_domino.py +++ b/examples/disagg/run_disagg_domino.py @@ -18,7 +18,7 @@ from accelerate.utils import set_seed from train_domino import parse_args -from specforge.core.domino import OnlineDominoModel +from specforge.core.dflash import OnlineDominoModel from specforge.distributed import destroy_distributed, init_distributed STRATEGY = "domino" diff --git a/examples/disagg/run_disagg_dspark.py b/examples/disagg/run_disagg_dspark.py index 174c1b499..faa1f9aa4 100644 --- a/examples/disagg/run_disagg_dspark.py +++ b/examples/disagg/run_disagg_dspark.py @@ -18,7 +18,7 @@ from accelerate.utils import set_seed from train_dflash import parse_dspark_disagg_args -from specforge.core.dspark import OnlineDSparkModel +from specforge.core.dflash import OnlineDSparkModel from specforge.distributed import destroy_distributed, init_distributed STRATEGY = "dspark" diff --git a/scripts/train_domino.py b/scripts/train_domino.py index fcbb3ebca..a6e578a68 100755 --- a/scripts/train_domino.py +++ b/scripts/train_domino.py @@ -22,14 +22,14 @@ from datasets import load_dataset from specforge.args import SGLangBackendArgs, TrackerArgs -from specforge.core.domino import OnlineDominoModel +from specforge.core.dflash import OnlineDominoModel from specforge.data import build_eagle3_dataset, prepare_dp_dataloaders from specforge.distributed import destroy_distributed, get_dp_group, init_distributed from specforge.inference.target_engine.dflash_target_model import ( DFlashTargetModel, get_dflash_target_model, ) -from specforge.modeling.draft.dflash import DFlashDraftModel +from specforge.modeling.draft.domino import DominoDraftModel from specforge.modeling.target.target_utils import TargetEmbeddingsAndHead from specforge.optimizer import BF16Optimizer from specforge.tracker import create_tracker @@ -163,7 +163,7 @@ def parse_args(): return parser.parse_args() -def build_models(args) -> Tuple[DFlashTargetModel, DFlashDraftModel]: +def build_models(args) -> Tuple[DFlashTargetModel, DominoDraftModel]: """Build target model (backend wrapper) and draft model.""" print_on_rank0( f"Loading target model from {args.target_model_path} using {args.target_model_backend} backend" @@ -240,7 +240,7 @@ def build_models(args) -> Tuple[DFlashTargetModel, DFlashDraftModel]: draft_config._attn_implementation = args.attention_backend print_on_rank0(f"Using attention backend: {args.attention_backend}") - draft_model = DFlashDraftModel(draft_config).to(device=device, dtype=torch.bfloat16) + draft_model = DominoDraftModel(draft_config).to(device=device, dtype=torch.bfloat16) target_model.set_capture_layers(draft_model.target_layer_ids) @@ -477,7 +477,7 @@ def main(): resume_state = None if draft_model_last_checkpoint: - loaded_model = DFlashDraftModel.from_pretrained( + loaded_model = DominoDraftModel.from_pretrained( draft_model_last_checkpoint, torch_dtype=torch.bfloat16 ) draft_model.load_state_dict(loaded_model.state_dict()) diff --git a/specforge/core/__init__.py b/specforge/core/__init__.py index b38e110d8..d08463c13 100644 --- a/specforge/core/__init__.py +++ b/specforge/core/__init__.py @@ -1,6 +1,4 @@ -from .dflash import OnlineDFlashModel -from .domino import OnlineDominoModel -from .dspark import OnlineDSparkModel +from .dflash import OnlineDFlashModel, OnlineDominoModel, OnlineDSparkModel from .eagle3 import OnlineEagle3Model, QwenVLOnlineEagle3Model from .peagle import OnlinePEagleModel diff --git a/specforge/core/dflash.py b/specforge/core/dflash.py index 319d8be7c..8acde394d 100644 --- a/specforge/core/dflash.py +++ b/specforge/core/dflash.py @@ -33,6 +33,17 @@ _DPACE_LOSS_TYPES = _VALID_LOSS_TYPES - {"dflash", "vp_drafter"} +def compute_accept_len( + pred_ids_4d: torch.Tensor, + target_ids_4d: torch.Tensor, + valid_mask_4d: torch.Tensor, +) -> torch.Tensor: + """Compute per-block acceptance length.""" + correct = (pred_ids_4d == target_ids_4d) | (~valid_mask_4d) + accept_prefix = correct.long().cumprod(dim=2) * valid_mask_4d.long() + return accept_prefix.sum(dim=2).float() + + def create_dflash_sdpa_mask(anchor_positions, block_keep_mask, S, block_size, device): B, N = anchor_positions.shape Q_LEN = N * block_size @@ -503,3 +514,610 @@ def forward( accuracy = correct.sum().float() / (accuracy_denom + 1e-6) return loss, accuracy, {"accuracy_denom": accuracy_denom} + + +class OnlineDominoModel(OnlineDFlashModel): + """Domino online training wrapper over DFlash block-parallel components.""" + + def __init__( + self, + draft_model: DFlashDraftModel, + target_lm_head: nn.Module, + target_embed_tokens: nn.Module, + mask_token_id: int, + block_size: int = 16, + attention_backend: str = "flex_attention", + num_anchors: int = 512, + loss_decay_gamma: Optional[float] = None, + shift_label: bool = False, + ): + super().__init__( + draft_model=draft_model, + target_lm_head=target_lm_head, + target_embed_tokens=target_embed_tokens, + mask_token_id=mask_token_id, + block_size=block_size, + attention_backend=attention_backend, + num_anchors=num_anchors, + loss_decay_gamma=loss_decay_gamma, + loss_type="dflash", + ) + self.shift_label = shift_label + + def _sample_anchor_positions( + self, seq_len: int, loss_mask: torch.Tensor, device: torch.device + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Randomly sample anchor positions per sample; returns (anchors, keep_mask).""" + bs = self.block_size + bsz = loss_mask.shape[0] + max_anchor = max(seq_len - bs, 0) + + valid = loss_mask[:, : max_anchor + 1] > 0.5 + valid_counts = valid.sum(dim=1) + max_n = max(1, min(self.num_anchors, int(valid_counts.max().item()) - 1)) + + indices = ( + torch.arange(max_anchor + 1, device=device).unsqueeze(0).expand(bsz, -1) + ) + masked_indices = torch.where( + valid, indices, torch.tensor(seq_len + 1, device=device) + ) + + random_vals = torch.rand(bsz, max_anchor + 1, device=device) + random_vals = torch.where(valid, random_vals, torch.tensor(2.0, device=device)) + + _, sorted_idx = random_vals.sort(dim=1) + gathered = torch.gather(masked_indices, 1, sorted_idx) + anchors = gathered[:, :max_n].sort(dim=1).values + + keep_mask = torch.arange(max_n, device=device).unsqueeze( + 0 + ) < valid_counts.unsqueeze(1).clamp(max=max_n) + anchors = torch.where( + keep_mask, anchors, torch.tensor(0, dtype=torch.long, device=device) + ) + + return anchors, keep_mask + + def _build_domino_head_inputs( + self, + input_ids: torch.Tensor, + anchor_positions: torch.Tensor, + target_ids: torch.Tensor, + output_hidden: torch.Tensor, + ) -> Tuple[torch.Tensor, torch.Tensor]: + bsz, n, bs = target_ids.shape + hidden4d = output_hidden.reshape(bsz, n, bs, output_hidden.shape[-1]) + + prev_ids = target_ids + if self.shift_label: + prev_offsets = torch.arange( + 0, self.block_size, device=input_ids.device + ).view(1, 1, -1) + prev_indices = (anchor_positions.unsqueeze(-1) + prev_offsets).clamp( + max=input_ids.size(1) - 1 + ) + prev_ids = torch.gather( + input_ids.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), + 2, + prev_indices, + ) + + return hidden4d, prev_ids + + def _apply_domino_head( + self, + base_logits4d: torch.Tensor, + hidden4d: torch.Tensor, + prev_ids: torch.Tensor, + target_ids: torch.Tensor, + ) -> torch.Tensor: + head_token_ids = prev_ids if self.shift_label else target_ids + head_token_embeddings = self.embed_tokens(head_token_ids) + return self.draft_model.apply_logits_head( + base_logits4d, + hidden_states=hidden4d, + prev_token_embeddings=head_token_embeddings, + ) + + def _compute_extra_metrics( + self, + pred_ids: torch.Tensor, + flat_base_logits: torch.Tensor, + flat_targets: torch.Tensor, + binary_eval_mask: torch.Tensor, + actual_token_count: torch.Tensor, + target_ids: torch.Tensor, + eval_weight_mask: torch.Tensor, + final_loss: torch.Tensor, + base_loss: torch.Tensor, + lambda_base: float, + ) -> Dict[str, torch.Tensor]: + bsz, n, bs = target_ids.shape + + base_pred_ids = torch.argmax(flat_base_logits, dim=-1) + base_correct = (base_pred_ids == flat_targets) & (binary_eval_mask > 0.5) + base_accuracy = base_correct.sum().float() / actual_token_count + + valid_mask_4d = (eval_weight_mask > 0).bool() + pred_accept_len = compute_accept_len( + pred_ids.view(bsz, n, bs), target_ids, valid_mask_4d + ) + base_accept_len = compute_accept_len( + base_pred_ids.view(bsz, n, bs), target_ids, valid_mask_4d + ) + + valid_block_mask = valid_mask_4d.any(dim=2) + num_valid_blocks = valid_block_mask.sum().float() + 1e-6 + avg_accept_len = ( + (pred_accept_len + 1.0) * valid_block_mask.float() + ).sum() / num_valid_blocks + base_avg_accept_len = ( + (base_accept_len + 1.0) * valid_block_mask.float() + ).sum() / num_valid_blocks + + return { + "final_loss": final_loss.detach(), + "base_loss": base_loss.detach(), + "base_accuracy": base_accuracy.detach(), + "accept_len": avg_accept_len.detach(), + "base_accept_len": base_avg_accept_len.detach(), + "lambda_base": torch.tensor(lambda_base, device=final_loss.device), + } + + def _compute_weighted_losses( + self, + final_logits: torch.Tensor, + base_logits: torch.Tensor, + target_ids: torch.Tensor, + weight_mask: torch.Tensor, + lambda_base: float, + ) -> Tuple[ + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, + torch.Tensor, + ]: + flat_logits = final_logits.reshape(-1, final_logits.size(-1)) + flat_base_logits = base_logits.reshape(-1, base_logits.size(-1)) + flat_targets = target_ids.reshape(-1) + flat_weights = weight_mask.reshape(-1) + + valid_token_count = flat_weights.sum() + 1e-6 + + final_loss_per_token = F.cross_entropy( + flat_logits, flat_targets, reduction="none" + ) + final_loss = (final_loss_per_token * flat_weights).sum() / valid_token_count + + base_loss_per_token = F.cross_entropy( + flat_base_logits, flat_targets, reduction="none" + ) + base_loss = (base_loss_per_token * flat_weights).sum() / valid_token_count + + loss = (1.0 - lambda_base) * final_loss + lambda_base * base_loss + + return loss, final_loss, base_loss, flat_logits, flat_base_logits, flat_targets + + def forward( + self, + input_ids: torch.Tensor, + hidden_states: torch.Tensor, + loss_mask: torch.Tensor, + lambda_base: float = 0.0, + ): + """Parallel Domino training forward pass.""" + if self.attention_backend == "flex_attention" and not FLEX_ATTENTION_AVAILABLE: + raise ValueError( + "flex_attention is not available on this device; use sdpa/eager." + ) + bsz, seq_len = input_ids.shape + device = input_ids.device + + anchor_positions, block_keep_mask, output_hidden = self._forward_draft_blocks( + input_ids=input_ids, + hidden_states=hidden_states, + loss_mask=loss_mask, + ) + + label_start = 1 if self.shift_label else 0 + label_offsets = torch.arange( + label_start, label_start + self.block_size, device=device + ).view(1, 1, -1) + label_indices = anchor_positions.unsqueeze(-1) + label_offsets + valid_label_mask = label_indices < seq_len + safe_target_indices = label_indices.clamp(max=seq_len - 1) + + target_ids = torch.gather( + input_ids.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), + 2, + safe_target_indices, + ) + + bsz, n, bs = target_ids.shape + base_logits = self.lm_head(output_hidden) + hidden4d, prev_ids = self._build_domino_head_inputs( + input_ids=input_ids, + anchor_positions=anchor_positions, + target_ids=target_ids, + output_hidden=output_hidden, + ) + base_logits4d = base_logits.reshape(bsz, n, bs, -1) + final_logits = self._apply_domino_head( + base_logits4d=base_logits4d, + hidden4d=hidden4d, + prev_ids=prev_ids, + target_ids=target_ids, + ).reshape(bsz, n * bs, -1) + + weight_mask = ( + block_keep_mask.unsqueeze(-1).expand(-1, -1, self.block_size).float() + ) + weight_mask = weight_mask * valid_label_mask.float() + + if not self.shift_label: + pos_in_block = torch.arange(self.block_size, device=device).view(1, 1, -1) + weight_mask = weight_mask * (pos_in_block > 0).float() + + original_loss_mask_gathered = torch.gather( + loss_mask.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), + 2, + safe_target_indices, + ) + weight_mask = weight_mask * original_loss_mask_gathered + + eval_weight_mask = weight_mask.clone() + binary_eval_mask = weight_mask.view(-1) + + if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: + k = torch.arange(self.block_size, device=device).view(1, 1, -1) + offset = 0 if self.shift_label else 1 + decay_weights = torch.exp( + -(k - offset).clamp(min=0).float() / self.loss_decay_gamma + ) + weight_mask = weight_mask * decay_weights + + loss, final_loss, base_loss, flat_logits, flat_base_logits, flat_targets = ( + self._compute_weighted_losses( + final_logits=final_logits, + base_logits=base_logits, + target_ids=target_ids, + weight_mask=weight_mask, + lambda_base=lambda_base, + ) + ) + + with torch.no_grad(): + pred_ids = torch.argmax(flat_logits, dim=-1) + correct = (pred_ids == flat_targets) & (binary_eval_mask > 0.5) + accuracy_denom = binary_eval_mask.sum() + actual_token_count = accuracy_denom + 1e-6 + accuracy = correct.sum().float() / actual_token_count + + metrics = self._compute_extra_metrics( + pred_ids=pred_ids, + flat_base_logits=flat_base_logits, + flat_targets=flat_targets, + binary_eval_mask=binary_eval_mask, + actual_token_count=actual_token_count, + target_ids=target_ids, + eval_weight_mask=eval_weight_mask, + final_loss=final_loss, + base_loss=base_loss, + lambda_base=lambda_base, + ) + metrics["accuracy_denom"] = accuracy_denom + + return loss, accuracy, metrics + + +class OnlineDSparkModel(OnlineDFlashModel): + """DSpark online training wrapper over DFlash block-parallel components.""" + + def __init__( + self, + draft_model: DFlashDraftModel, + target_lm_head: nn.Module, + target_embed_tokens: nn.Module, + mask_token_id: int, + block_size: int = 16, + attention_backend: str = "flex_attention", + num_anchors: int = 512, + loss_decay_gamma: Optional[float] = None, + dspark_ce_loss_alpha: float = 0.1, + dspark_l1_loss_alpha: float = 0.9, + dspark_confidence_head_alpha: float = 1.0, + ): + super().__init__( + draft_model=draft_model, + target_lm_head=target_lm_head, + target_embed_tokens=target_embed_tokens, + mask_token_id=mask_token_id, + block_size=block_size, + attention_backend=attention_backend, + num_anchors=num_anchors, + loss_decay_gamma=loss_decay_gamma, + loss_type="dflash", + ) + if dspark_ce_loss_alpha < 0: + raise ValueError("dspark_ce_loss_alpha must be >= 0") + if dspark_l1_loss_alpha < 0: + raise ValueError("dspark_l1_loss_alpha must be >= 0") + if dspark_confidence_head_alpha < 0: + raise ValueError("dspark_confidence_head_alpha must be >= 0") + + self.loss_type = "dspark" + self.dspark_ce_loss_alpha = float(dspark_ce_loss_alpha) + self.dspark_l1_loss_alpha = float(dspark_l1_loss_alpha) + self.dspark_confidence_head_alpha = float(dspark_confidence_head_alpha) + + def _build_anchor_candidate_mask( + self, + seq_len: int, + loss_mask: torch.Tensor, + ) -> torch.Tensor: + num_candidates = max(seq_len - 1, 0) + if num_candidates == 0: + return loss_mask[:, :0].bool() + anchor_valid = loss_mask[:, :num_candidates] > 0.5 + first_target_valid = loss_mask[:, 1 : num_candidates + 1] > 0.5 + return anchor_valid & first_target_valid + + def _sample_anchor_positions( + self, seq_len: int, loss_mask: torch.Tensor, device: torch.device + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Sample fixed-width DSpark anchors; invalid slots are masked out.""" + valid = self._build_anchor_candidate_mask(seq_len, loss_mask) + bsz = loss_mask.shape[0] + num_candidates = valid.shape[1] + max_n = int(self.num_anchors) + if num_candidates == 0: + anchors = torch.zeros(bsz, max_n, dtype=torch.long, device=device) + keep_mask = torch.zeros(bsz, max_n, dtype=torch.bool, device=device) + return anchors, keep_mask + + valid_counts = valid.sum(dim=1) + indices = ( + torch.arange(num_candidates, device=device).unsqueeze(0).expand(bsz, -1) + ) + masked_indices = torch.where( + valid, + indices, + torch.full_like(indices, seq_len + 1), + ) + random_vals = torch.rand(bsz, num_candidates, device=device) + random_vals = torch.where(valid, random_vals, torch.full_like(random_vals, 2.0)) + _, sorted_idx = random_vals.sort(dim=1) + gathered = torch.gather(masked_indices, 1, sorted_idx) + if num_candidates < max_n: + pad = torch.full( + (bsz, max_n - num_candidates), + seq_len + 1, + dtype=gathered.dtype, + device=device, + ) + gathered = torch.cat([gathered, pad], dim=1) + anchors = gathered[:, :max_n].sort(dim=1).values + keep_mask = torch.arange(max_n, device=device).unsqueeze(0) < ( + valid_counts.unsqueeze(1).clamp(max=max_n) + ) + anchors = torch.where(keep_mask, anchors, torch.zeros_like(anchors)) + return anchors, keep_mask + + def _build_dspark_labels_and_mask( + self, + input_ids: torch.Tensor, + loss_mask: torch.Tensor, + anchor_positions: torch.Tensor, + block_keep_mask: torch.Tensor, + ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + bsz, seq_len = input_ids.shape + del bsz + device = input_ids.device + label_offsets = torch.arange(1, self.block_size + 1, device=device).view( + 1, 1, -1 + ) + label_indices = anchor_positions.unsqueeze(-1) + label_offsets + safe_label_indices = label_indices.clamp(max=seq_len - 1) + safe_label_indices = torch.where( + block_keep_mask.unsqueeze(-1), + safe_label_indices, + torch.zeros_like(safe_label_indices), + ) + target_ids = torch.gather( + input_ids.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), + 2, + safe_label_indices, + ) + + target_valid = label_indices < seq_len + target_loss_mask = torch.gather( + loss_mask.unsqueeze(1).expand(-1, label_indices.size(1), -1), + 2, + safe_label_indices, + ) + eval_mask = target_valid & (target_loss_mask > 0.5) + eval_mask = eval_mask & block_keep_mask.unsqueeze(-1) + eval_mask = eval_mask.to(torch.int32).cumprod(dim=-1).bool() + return target_ids, eval_mask, label_indices, safe_label_indices + + def _dspark_loss_weight_mask( + self, + eval_mask: torch.Tensor, + device: torch.device, + ) -> torch.Tensor: + loss_weight_mask = eval_mask.to(torch.float32) + if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: + positions = torch.arange(self.block_size, device=device).view(1, 1, -1) + decay_weights = torch.exp(-positions.float() / float(self.loss_decay_gamma)) + loss_weight_mask = loss_weight_mask * decay_weights + return loss_weight_mask + + def _aligned_target_logits( + self, + target_last_hidden_states: Optional[torch.Tensor], + safe_label_indices: torch.Tensor, + ) -> Optional[torch.Tensor]: + if target_last_hidden_states is None: + return None + target_pred_indices = (safe_label_indices - 1).clamp(min=0) + aligned_target_hidden = torch.gather( + target_last_hidden_states.unsqueeze(1).expand( + -1, + safe_label_indices.size(1), + -1, + -1, + ), + 2, + target_pred_indices.unsqueeze(-1).expand( + -1, + -1, + -1, + target_last_hidden_states.size(-1), + ), + ) + return self.lm_head(aligned_target_hidden) + + def _compute_dspark_loss( + self, + *, + draft_logits: torch.Tensor, + target_ids: torch.Tensor, + eval_mask: torch.Tensor, + confidence_pred: Optional[torch.Tensor], + aligned_target_logits: Optional[torch.Tensor], + ) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]: + device = draft_logits.device + vocab_size = draft_logits.size(-1) + loss_weight_mask = self._dspark_loss_weight_mask(eval_mask, device) + flat_logits = draft_logits.reshape(-1, vocab_size) + flat_targets = target_ids.reshape(-1) + flat_weights = loss_weight_mask.reshape(-1) + + loss_per_token = F.cross_entropy(flat_logits, flat_targets, reduction="none") + ce_loss_num = (loss_per_token * flat_weights).sum() + ce_loss_den = flat_weights.sum() + ce_loss = ce_loss_num / (ce_loss_den + 1e-6) + + l1_loss = ce_loss.new_zeros(()) + accept_rate_3d = None + if aligned_target_logits is not None: + draft_probs = torch.softmax(draft_logits.float(), dim=-1) + target_probs = torch.softmax(aligned_target_logits.float(), dim=-1) + accept_rate_3d = 1.0 - 0.5 * (draft_probs - target_probs).abs().sum(dim=-1) + accept_rate_3d = accept_rate_3d.clamp_(0.0, 1.0) + if self.dspark_l1_loss_alpha > 0: + l1_dist = (draft_probs - target_probs).abs().sum(dim=-1) + l1_loss = (l1_dist * loss_weight_mask).sum() / (ce_loss_den + 1e-6) + elif self.dspark_l1_loss_alpha > 0 or self.dspark_confidence_head_alpha > 0: + raise ValueError( + "DSpark L1/confidence loss requires target_last_hidden_states. " + "Use the disaggregated DSpark server-capture path so the " + "consumer receives target_last_hidden_states." + ) + + confidence_loss = ce_loss.new_zeros(()) + confidence_abs_error = ce_loss.new_zeros(()) + if confidence_pred is not None: + if accept_rate_3d is None: + raise ValueError( + "DSpark confidence head requires aligned target logits." + ) + confidence_errors = F.binary_cross_entropy_with_logits( + confidence_pred.float(), + accept_rate_3d.detach(), + reduction="none", + ) + confidence_loss = (confidence_errors * loss_weight_mask).sum() / ( + ce_loss_den + 1e-6 + ) + with torch.no_grad(): + confidence_abs_error = ( + (confidence_pred.float().sigmoid() - accept_rate_3d).abs() + * loss_weight_mask + ).sum() / (ce_loss_den + 1e-6) + + loss = ( + self.dspark_ce_loss_alpha * ce_loss + + self.dspark_l1_loss_alpha * l1_loss + + self.dspark_confidence_head_alpha * confidence_loss + ) + metrics = { + "ce_loss": ce_loss.detach(), + "l1_loss": l1_loss.detach(), + "confidence_loss": confidence_loss.detach(), + "confidence_abs_error": confidence_abs_error.detach(), + } + return loss, metrics + + def forward( + self, + input_ids: torch.Tensor, + hidden_states: torch.Tensor, + loss_mask: torch.Tensor, + target_last_hidden_states: Optional[torch.Tensor] = None, + ) -> Tuple[torch.Tensor, torch.Tensor, Dict[str, torch.Tensor]]: + """Parallel DSpark training forward pass.""" + if self.attention_backend == "flex_attention" and not FLEX_ATTENTION_AVAILABLE: + raise ValueError( + "flex_attention is not available on this device; use sdpa/eager." + ) + bsz = input_ids.shape[0] + anchor_positions, block_keep_mask, output_hidden = self._forward_draft_blocks( + input_ids=input_ids, + hidden_states=hidden_states, + loss_mask=loss_mask, + ) + + logits = self.lm_head(output_hidden) + num_blocks = anchor_positions.size(1) + output_hidden_4d = output_hidden.reshape(bsz, num_blocks, self.block_size, -1) + ( + target_ids, + eval_mask, + _label_indices, + safe_label_indices, + ) = self._build_dspark_labels_and_mask( + input_ids=input_ids, + loss_mask=loss_mask, + anchor_positions=anchor_positions, + block_keep_mask=block_keep_mask, + ) + anchor_token_ids = torch.gather(input_ids, 1, anchor_positions) + prev_token_ids = torch.cat( + [anchor_token_ids.unsqueeze(-1), target_ids[:, :, :-1]], + dim=-1, + ) + draft_logits = logits.reshape(bsz, num_blocks, self.block_size, -1) + draft_logits = self.draft_model.apply_logits_head( + draft_logits, + prev_token_ids=prev_token_ids, + hidden_states=output_hidden_4d, + ) + confidence_pred = self.draft_model.predict_confidence( + output_hidden_4d, + prev_token_ids=prev_token_ids, + ) + aligned_target_logits = self._aligned_target_logits( + target_last_hidden_states, + safe_label_indices, + ) + loss, metrics = self._compute_dspark_loss( + draft_logits=draft_logits, + target_ids=target_ids, + eval_mask=eval_mask, + confidence_pred=confidence_pred, + aligned_target_logits=aligned_target_logits, + ) + flat_logits = draft_logits.reshape(-1, draft_logits.size(-1)) + flat_targets = target_ids.reshape(-1) + binary_eval_mask = eval_mask.reshape(-1) + with torch.no_grad(): + pred_ids = torch.argmax(flat_logits, dim=-1) + correct = (pred_ids == flat_targets) & binary_eval_mask + accuracy_denom = binary_eval_mask.to(torch.float32).sum() + accuracy = correct.sum().float() / (accuracy_denom + 1e-6) + metrics["accuracy_denom"] = accuracy_denom + return loss, accuracy, metrics diff --git a/specforge/core/domino.py b/specforge/core/domino.py deleted file mode 100644 index 22b405e2c..000000000 --- a/specforge/core/domino.py +++ /dev/null @@ -1,480 +0,0 @@ -# coding=utf-8 -"""Domino Training Wrapper.""" - -from typing import Dict, Optional, Tuple - -import torch -import torch.nn as nn -import torch.nn.functional as F - -from specforge.core.dflash import ( - FLEX_ATTENTION_AVAILABLE, - BlockMask, - create_dflash_block_mask, - create_dflash_sdpa_mask, -) -from specforge.modeling.draft.dflash import DFlashDraftModel -from specforge.utils import get_device_type - - -def compute_accept_len( - pred_ids_4d: torch.Tensor, - target_ids_4d: torch.Tensor, - valid_mask_4d: torch.Tensor, -) -> torch.Tensor: - """Compute per-block acceptance length. - - For each block, returns the number of consecutive correct predictions - starting from position 0 (or the first valid position). - """ - correct = (pred_ids_4d == target_ids_4d) | (~valid_mask_4d) - accept_prefix = correct.long().cumprod(dim=2) * valid_mask_4d.long() - return accept_prefix.sum(dim=2).float() - - -class OnlineDominoModel(nn.Module): - """Domino online training wrapper with block-wise CE loss.""" - - def __init__( - self, - draft_model: DFlashDraftModel, - target_lm_head: nn.Module, - target_embed_tokens: nn.Module, - mask_token_id: int, - block_size: int = 16, - attention_backend: str = "flex_attention", - num_anchors: int = 512, - loss_decay_gamma: Optional[float] = None, - shift_label: bool = False, - ): - super().__init__() - self.draft_model = draft_model - self.lm_head = target_lm_head - self.embed_tokens = target_embed_tokens - self.block_size = block_size - self.mask_token_id = mask_token_id - self.attention_backend = attention_backend - self.num_anchors = num_anchors - self.loss_decay_gamma = loss_decay_gamma - self.shift_label = shift_label - - self._cached_block_mask: Optional[BlockMask] = None - self._cached_seq_len: Optional[int] = None - self._cached_bsz: Optional[int] = None - - if self.attention_backend == "flex_attention" and not FLEX_ATTENTION_AVAILABLE: - raise ValueError( - "flex_attention is not available on this device; use sdpa/eager." - ) - - # NPU workaround: pre-create a float16 GRU module since Ascend - # DynamicGRU does not support bfloat16. - # Use object.__setattr__ to avoid registering it as a submodule, - # otherwise FSDP will complain about mixed dtypes (bfloat16 + float16). - gru_fp16 = None - if get_device_type() == "npu": - prefix_gru = draft_model.prefix_gru - gru_fp16 = nn.GRU( - input_size=prefix_gru.weight_ih_l0.shape[1], - hidden_size=prefix_gru.hidden_size, - num_layers=1, - batch_first=True, - bias=False, - ) - gru_fp16.to( - device=next(prefix_gru.parameters()).device, dtype=torch.float16 - ) - gru_fp16.weight_ih_l0.requires_grad = False - gru_fp16.weight_hh_l0.requires_grad = False - object.__setattr__(self, "_gru_fp16", gru_fp16) - - def _sample_anchor_positions( - self, seq_len: int, loss_mask: torch.Tensor, device: torch.device - ) -> Tuple[torch.Tensor, torch.Tensor]: - """Randomly sample anchor positions per sample; returns (anchors, keep_mask).""" - bs = self.block_size - bsz = loss_mask.shape[0] - max_anchor = max(seq_len - bs, 0) - - valid = loss_mask[:, : max_anchor + 1] > 0.5 - valid_counts = valid.sum(dim=1) - max_n = max(1, min(self.num_anchors, int(valid_counts.max().item()) - 1)) - - indices = ( - torch.arange(max_anchor + 1, device=device).unsqueeze(0).expand(bsz, -1) - ) - masked_indices = torch.where( - valid, indices, torch.tensor(seq_len + 1, device=device) - ) - - random_vals = torch.rand(bsz, max_anchor + 1, device=device) - random_vals = torch.where(valid, random_vals, torch.tensor(2.0, device=device)) - - _, sorted_idx = random_vals.sort(dim=1) - gathered = torch.gather(masked_indices, 1, sorted_idx) - anchors = gathered[:, :max_n].sort(dim=1).values - - keep_mask = torch.arange(max_n, device=device).unsqueeze( - 0 - ) < valid_counts.unsqueeze(1).clamp(max=max_n) - anchors = torch.where( - keep_mask, anchors, torch.tensor(0, dtype=torch.long, device=device) - ) - - return anchors, keep_mask - - def prepare_noise_input( - self, input_ids: torch.Tensor, block_ids: Optional[torch.Tensor] = None - ) -> torch.Tensor: - """Prepare noise input: first token of each block is real, rest are MASK.""" - bsz, seq_len = input_ids.shape - device = input_ids.device - - if block_ids is not None: - is_block_start = torch.ones(bsz, seq_len, dtype=torch.bool, device=device) - is_block_start[:, 1:] = block_ids[:, 1:] != block_ids[:, :-1] - else: - positions = torch.arange(seq_len, device=device) - is_block_start = (positions % self.block_size) == 0 - is_block_start = is_block_start.unsqueeze(0).expand(bsz, -1) - - noise_input_ids = torch.full_like(input_ids, self.mask_token_id) - noise_input_ids[is_block_start] = input_ids[is_block_start] - return noise_input_ids - - def _create_position_ids(self, anchor_positions: torch.Tensor) -> torch.Tensor: - """Create absolute position IDs for parallel draft blocks.""" - bsz, n_blocks = anchor_positions.shape - device = anchor_positions.device - offsets = torch.arange(self.block_size, device=device).view(1, 1, -1) - pos_ids = anchor_positions.unsqueeze(-1) + offsets - return pos_ids.view(bsz, -1) - - def _create_noise_embed(self, input_ids, anchor_positions, block_keep_mask): - bsz, seq_len = input_ids.shape - n = anchor_positions.shape[1] - bs = self.block_size - device = input_ids.device - - noise_ids = torch.full( - (bsz, n * bs), self.mask_token_id, dtype=torch.long, device=device - ) - - block_starts = torch.arange(n, device=device) * bs - block_starts = block_starts.unsqueeze(0).expand(bsz, -1) - - valid_anchor_positions = anchor_positions.clamp(0, seq_len - 1) - anchor_tokens = torch.gather(input_ids, 1, valid_anchor_positions) - - flat_batch_idx = torch.arange(bsz, device=device).unsqueeze(1).expand(bsz, n) - noise_ids[flat_batch_idx, block_starts] = torch.where( - block_keep_mask, - anchor_tokens, - torch.tensor(self.mask_token_id, dtype=torch.long, device=device), - ) - - return self.embed_tokens(noise_ids) - - @property - def _suffix_start(self) -> int: - """Return suffix_start index based on shift_label and pure_draft_prefix_len.""" - pure_prefix = getattr(self.draft_model, "pure_draft_prefix_len", 0) - return pure_prefix if self.shift_label else (1 + pure_prefix) - - def _build_domino_head_inputs( - self, - input_ids: torch.Tensor, - anchor_positions: torch.Tensor, - target_ids: torch.Tensor, - output_hidden: torch.Tensor, - ) -> Tuple[torch.Tensor, torch.Tensor]: - bsz, n, bs = target_ids.shape - hidden4d = output_hidden.reshape(bsz, n, bs, output_hidden.shape[-1]) - - prev_ids = target_ids - if self.shift_label: - prev_offsets = torch.arange( - 0, self.block_size, device=input_ids.device - ).view(1, 1, -1) - prev_indices = (anchor_positions.unsqueeze(-1) + prev_offsets).clamp( - max=input_ids.size(1) - 1 - ) - prev_ids = torch.gather( - input_ids.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), - 2, - prev_indices, - ) - - return hidden4d, prev_ids - - def _run_gru(self, gru_inputs: torch.Tensor) -> torch.Tensor: - """Run GRU with NPU-compatible dtype. - - Ascend NPU DynamicGRU does not support bfloat16; use the pre-created - float16 GRU module and cast outputs back to the original dtype. - """ - if self._gru_fp16 is not None and gru_inputs.dtype == torch.bfloat16: - self._gru_fp16.weight_ih_l0.data.copy_( - self.draft_model.prefix_gru.weight_ih_l0.data.half() - ) - self._gru_fp16.weight_hh_l0.data.copy_( - self.draft_model.prefix_gru.weight_hh_l0.data.half() - ) - out = self._gru_fp16(gru_inputs.half())[0] - return out.to(gru_inputs.dtype) - return self.draft_model.prefix_gru(gru_inputs)[0] - - def _apply_domino_head( - self, - base_logits4d: torch.Tensor, - hidden4d: torch.Tensor, - prev_ids: torch.Tensor, - target_ids: torch.Tensor, - ) -> torch.Tensor: - """Apply the Domino head: GRU causal state plus logit correction.""" - bsz, n, bs = target_ids.shape - if self.shift_label: - block_emb = self.embed_tokens(prev_ids) - gru_inputs = block_emb.reshape(bsz * n, bs, -1) - gru_out = self._run_gru(gru_inputs) - gru_out = gru_out.reshape(bsz, n, bs, -1) - prefix_states = gru_out[:, :, self._suffix_start :, :] - else: - block_emb = self.embed_tokens(target_ids) - gru_inputs = block_emb[:, :, : bs - 1, :].reshape(bsz * n, bs - 1, -1) - gru_out = self._run_gru(gru_inputs) - gru_out = gru_out.reshape(bsz, n, bs - 1, -1) - prefix_states = gru_out[:, :, self._suffix_start - 1 :, :] - z_n = hidden4d[:, :, self._suffix_start :, :] - concat_features = torch.cat([z_n, prefix_states], dim=-1) - logits_e = self.draft_model.embed_proj(concat_features) - - prefix_logits = base_logits4d[:, :, : self._suffix_start, :] - suffix_logits = base_logits4d[:, :, self._suffix_start :, :] + logits_e - return torch.cat([prefix_logits, suffix_logits], dim=2) - - def _compute_extra_metrics( - self, - pred_ids: torch.Tensor, - flat_base_logits: torch.Tensor, - flat_targets: torch.Tensor, - binary_eval_mask: torch.Tensor, - actual_token_count: torch.Tensor, - target_ids: torch.Tensor, - eval_weight_mask: torch.Tensor, - final_loss: torch.Tensor, - base_loss: torch.Tensor, - lambda_base: float, - ) -> Dict[str, torch.Tensor]: - """Compute auxiliary training metrics that do not affect gradients.""" - bsz, n, bs = target_ids.shape - - base_pred_ids = torch.argmax(flat_base_logits, dim=-1) - base_correct = (base_pred_ids == flat_targets) & (binary_eval_mask > 0.5) - base_accuracy = base_correct.sum().float() / actual_token_count - - valid_mask_4d = (eval_weight_mask > 0).bool() - pred_accept_len = compute_accept_len( - pred_ids.view(bsz, n, bs), target_ids, valid_mask_4d - ) - base_accept_len = compute_accept_len( - base_pred_ids.view(bsz, n, bs), target_ids, valid_mask_4d - ) - - valid_block_mask = valid_mask_4d.any(dim=2) - num_valid_blocks = valid_block_mask.sum().float() + 1e-6 - avg_accept_len = ( - (pred_accept_len + 1.0) * valid_block_mask.float() - ).sum() / num_valid_blocks - base_avg_accept_len = ( - (base_accept_len + 1.0) * valid_block_mask.float() - ).sum() / num_valid_blocks - - return { - "final_loss": final_loss.detach(), - "base_loss": base_loss.detach(), - "base_accuracy": base_accuracy.detach(), - "accept_len": avg_accept_len.detach(), - "base_accept_len": base_avg_accept_len.detach(), - "lambda_base": torch.tensor(lambda_base, device=final_loss.device), - } - - def _compute_weighted_losses( - self, - final_logits: torch.Tensor, - base_logits: torch.Tensor, - target_ids: torch.Tensor, - weight_mask: torch.Tensor, - lambda_base: float, - ) -> Tuple[ - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, - torch.Tensor, - ]: - flat_logits = final_logits.reshape(-1, final_logits.size(-1)) - flat_base_logits = base_logits.reshape(-1, base_logits.size(-1)) - flat_targets = target_ids.reshape(-1) - flat_weights = weight_mask.reshape(-1) - - valid_token_count = flat_weights.sum() + 1e-6 - - final_loss_per_token = F.cross_entropy( - flat_logits, flat_targets, reduction="none" - ) - final_loss = (final_loss_per_token * flat_weights).sum() / valid_token_count - - base_loss_per_token = F.cross_entropy( - flat_base_logits, flat_targets, reduction="none" - ) - base_loss = (base_loss_per_token * flat_weights).sum() / valid_token_count - - loss = (1.0 - lambda_base) * final_loss + lambda_base * base_loss - - return loss, final_loss, base_loss, flat_logits, flat_base_logits, flat_targets - - def forward( - self, - input_ids: torch.Tensor, - hidden_states: torch.Tensor, - loss_mask: torch.Tensor, - lambda_base: float = 0.0, - ): - """Parallel block-wise training forward pass.""" - bsz, seq_len = input_ids.shape - device = input_ids.device - - anchor_positions, block_keep_mask = self._sample_anchor_positions( - seq_len, loss_mask, device - ) - - noise_embedding = self._create_noise_embed( - input_ids, anchor_positions, block_keep_mask - ) - - context_position_ids = ( - torch.arange(seq_len, device=device).unsqueeze(0).expand(bsz, -1) - ) - draft_position_ids = self._create_position_ids(anchor_positions) - full_position_ids = torch.cat([context_position_ids, draft_position_ids], dim=1) - - if self.attention_backend == "flex_attention": - dflash_attn_mask = create_dflash_block_mask( - anchor_positions=anchor_positions, - block_keep_mask=block_keep_mask, - S=seq_len, - block_size=self.block_size, - device=device, - ) - else: - dflash_attn_mask = create_dflash_sdpa_mask( - anchor_positions=anchor_positions, - block_keep_mask=block_keep_mask, - S=seq_len, - block_size=self.block_size, - device=device, - ) - - output_hidden = self.draft_model( - position_ids=full_position_ids, - noise_embedding=noise_embedding, - target_hidden=hidden_states, - attention_mask=dflash_attn_mask, - ) - - # --- Labels --- - label_start = 1 if self.shift_label else 0 - label_offsets = torch.arange( - label_start, label_start + self.block_size, device=device - ).view(1, 1, -1) - label_indices = anchor_positions.unsqueeze(-1) + label_offsets - valid_label_mask = label_indices < seq_len - safe_target_indices = label_indices.clamp(max=seq_len - 1) - - target_ids = torch.gather( - input_ids.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), - 2, - safe_target_indices, - ) - - bsz, n, bs = target_ids.shape - base_logits = self.lm_head(output_hidden) - hidden4d, prev_ids = self._build_domino_head_inputs( - input_ids=input_ids, - anchor_positions=anchor_positions, - target_ids=target_ids, - output_hidden=output_hidden, - ) - base_logits4d = base_logits.reshape(bsz, n, bs, -1) - final_logits = self._apply_domino_head( - base_logits4d=base_logits4d, - hidden4d=hidden4d, - prev_ids=prev_ids, - target_ids=target_ids, - ).reshape(bsz, n * bs, -1) - - # --- Weight mask: block validity * bounds * exclude anchor (pos 0) * loss_mask --- - weight_mask = ( - block_keep_mask.unsqueeze(-1).expand(-1, -1, self.block_size).float() - ) - weight_mask = weight_mask * valid_label_mask.float() - - if not self.shift_label: - pos_in_block = torch.arange(self.block_size, device=device).view(1, 1, -1) - weight_mask = weight_mask * (pos_in_block > 0).float() - - original_loss_mask_gathered = torch.gather( - loss_mask.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), - 2, - safe_target_indices, - ) - weight_mask = weight_mask * original_loss_mask_gathered - - # Save eval mask before decay (for accept_len / accuracy stats) - eval_weight_mask = weight_mask.clone() - binary_eval_mask = weight_mask.view(-1) - - # --- Loss decay: first valid position gets weight 1.0 --- - if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: - k = torch.arange(self.block_size, device=device).view(1, 1, -1) - offset = 0 if self.shift_label else 1 - decay_weights = torch.exp( - -(k - offset).clamp(min=0).float() / self.loss_decay_gamma - ) - weight_mask = weight_mask * decay_weights - - loss, final_loss, base_loss, flat_logits, flat_base_logits, flat_targets = ( - self._compute_weighted_losses( - final_logits=final_logits, - base_logits=base_logits, - target_ids=target_ids, - weight_mask=weight_mask, - lambda_base=lambda_base, - ) - ) - - # --- Accuracy --- - with torch.no_grad(): - pred_ids = torch.argmax(flat_logits, dim=-1) - correct = (pred_ids == flat_targets) & (binary_eval_mask > 0.5) - accuracy_denom = binary_eval_mask.sum() - actual_token_count = accuracy_denom + 1e-6 - accuracy = correct.sum().float() / actual_token_count - - metrics = self._compute_extra_metrics( - pred_ids=pred_ids, - flat_base_logits=flat_base_logits, - flat_targets=flat_targets, - binary_eval_mask=binary_eval_mask, - actual_token_count=actual_token_count, - target_ids=target_ids, - eval_weight_mask=eval_weight_mask, - final_loss=final_loss, - base_loss=base_loss, - lambda_base=lambda_base, - ) - metrics["accuracy_denom"] = accuracy_denom - - return loss, accuracy, metrics diff --git a/specforge/core/dspark.py b/specforge/core/dspark.py deleted file mode 100644 index 6deefd9d1..000000000 --- a/specforge/core/dspark.py +++ /dev/null @@ -1,324 +0,0 @@ -# coding=utf-8 -"""DSpark Training Wrapper.""" - -from typing import Dict, Optional, Tuple - -import torch -import torch.nn as nn -import torch.nn.functional as F - -from specforge.core.dflash import FLEX_ATTENTION_AVAILABLE, OnlineDFlashModel -from specforge.modeling.draft.dflash import DFlashDraftModel - - -class OnlineDSparkModel(OnlineDFlashModel): - """DSpark online training wrapper over DFlash block-parallel components.""" - - def __init__( - self, - draft_model: DFlashDraftModel, - target_lm_head: nn.Module, - target_embed_tokens: nn.Module, - mask_token_id: int, - block_size: int = 16, - attention_backend: str = "flex_attention", - num_anchors: int = 512, - loss_decay_gamma: Optional[float] = None, - dspark_ce_loss_alpha: float = 0.1, - dspark_l1_loss_alpha: float = 0.9, - dspark_confidence_head_alpha: float = 1.0, - ): - super().__init__( - draft_model=draft_model, - target_lm_head=target_lm_head, - target_embed_tokens=target_embed_tokens, - mask_token_id=mask_token_id, - block_size=block_size, - attention_backend=attention_backend, - num_anchors=num_anchors, - loss_decay_gamma=loss_decay_gamma, - loss_type="dflash", - ) - if dspark_ce_loss_alpha < 0: - raise ValueError("dspark_ce_loss_alpha must be >= 0") - if dspark_l1_loss_alpha < 0: - raise ValueError("dspark_l1_loss_alpha must be >= 0") - if dspark_confidence_head_alpha < 0: - raise ValueError("dspark_confidence_head_alpha must be >= 0") - - self.loss_type = "dspark" - self.dspark_ce_loss_alpha = float(dspark_ce_loss_alpha) - self.dspark_l1_loss_alpha = float(dspark_l1_loss_alpha) - self.dspark_confidence_head_alpha = float(dspark_confidence_head_alpha) - - def _build_anchor_candidate_mask( - self, - seq_len: int, - loss_mask: torch.Tensor, - ) -> torch.Tensor: - num_candidates = max(seq_len - 1, 0) - if num_candidates == 0: - return loss_mask[:, :0].bool() - anchor_valid = loss_mask[:, :num_candidates] > 0.5 - first_target_valid = loss_mask[:, 1 : num_candidates + 1] > 0.5 - return anchor_valid & first_target_valid - - def _sample_anchor_positions( - self, seq_len: int, loss_mask: torch.Tensor, device: torch.device - ) -> Tuple[torch.Tensor, torch.Tensor]: - """Sample fixed-width DSpark anchors; invalid slots are masked out.""" - valid = self._build_anchor_candidate_mask(seq_len, loss_mask) - bsz = loss_mask.shape[0] - num_candidates = valid.shape[1] - max_n = int(self.num_anchors) - if num_candidates == 0: - anchors = torch.zeros(bsz, max_n, dtype=torch.long, device=device) - keep_mask = torch.zeros(bsz, max_n, dtype=torch.bool, device=device) - return anchors, keep_mask - - valid_counts = valid.sum(dim=1) - indices = ( - torch.arange(num_candidates, device=device).unsqueeze(0).expand(bsz, -1) - ) - masked_indices = torch.where( - valid, - indices, - torch.full_like(indices, seq_len + 1), - ) - random_vals = torch.rand(bsz, num_candidates, device=device) - random_vals = torch.where(valid, random_vals, torch.full_like(random_vals, 2.0)) - _, sorted_idx = random_vals.sort(dim=1) - gathered = torch.gather(masked_indices, 1, sorted_idx) - if num_candidates < max_n: - pad = torch.full( - (bsz, max_n - num_candidates), - seq_len + 1, - dtype=gathered.dtype, - device=device, - ) - gathered = torch.cat([gathered, pad], dim=1) - anchors = gathered[:, :max_n].sort(dim=1).values - keep_mask = torch.arange(max_n, device=device).unsqueeze(0) < ( - valid_counts.unsqueeze(1).clamp(max=max_n) - ) - anchors = torch.where(keep_mask, anchors, torch.zeros_like(anchors)) - return anchors, keep_mask - - def _build_labels_and_mask( - self, - input_ids: torch.Tensor, - loss_mask: torch.Tensor, - anchor_positions: torch.Tensor, - block_keep_mask: torch.Tensor, - ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: - bsz, seq_len = input_ids.shape - del bsz - device = input_ids.device - label_offsets = torch.arange(1, self.block_size + 1, device=device).view( - 1, 1, -1 - ) - label_indices = anchor_positions.unsqueeze(-1) + label_offsets - safe_label_indices = label_indices.clamp(max=seq_len - 1) - safe_label_indices = torch.where( - block_keep_mask.unsqueeze(-1), - safe_label_indices, - torch.zeros_like(safe_label_indices), - ) - target_ids = torch.gather( - input_ids.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), - 2, - safe_label_indices, - ) - - target_valid = label_indices < seq_len - target_loss_mask = torch.gather( - loss_mask.unsqueeze(1).expand(-1, label_indices.size(1), -1), - 2, - safe_label_indices, - ) - eval_mask = target_valid & (target_loss_mask > 0.5) - eval_mask = eval_mask & block_keep_mask.unsqueeze(-1) - eval_mask = eval_mask.to(torch.int32).cumprod(dim=-1).bool() - return target_ids, eval_mask, label_indices, safe_label_indices - - def _loss_weight_mask( - self, - eval_mask: torch.Tensor, - device: torch.device, - ) -> torch.Tensor: - loss_weight_mask = eval_mask.to(torch.float32) - if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: - positions = torch.arange(self.block_size, device=device).view(1, 1, -1) - decay_weights = torch.exp(-positions.float() / float(self.loss_decay_gamma)) - loss_weight_mask = loss_weight_mask * decay_weights - return loss_weight_mask - - def _aligned_target_logits( - self, - target_last_hidden_states: Optional[torch.Tensor], - safe_label_indices: torch.Tensor, - ) -> Optional[torch.Tensor]: - if target_last_hidden_states is None: - return None - target_pred_indices = (safe_label_indices - 1).clamp(min=0) - aligned_target_hidden = torch.gather( - target_last_hidden_states.unsqueeze(1).expand( - -1, - safe_label_indices.size(1), - -1, - -1, - ), - 2, - target_pred_indices.unsqueeze(-1).expand( - -1, - -1, - -1, - target_last_hidden_states.size(-1), - ), - ) - return self.lm_head(aligned_target_hidden) - - def _compute_loss( - self, - *, - draft_logits: torch.Tensor, - target_ids: torch.Tensor, - eval_mask: torch.Tensor, - confidence_pred: Optional[torch.Tensor], - aligned_target_logits: Optional[torch.Tensor], - ) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]: - device = draft_logits.device - vocab_size = draft_logits.size(-1) - loss_weight_mask = self._loss_weight_mask(eval_mask, device) - flat_logits = draft_logits.reshape(-1, vocab_size) - flat_targets = target_ids.reshape(-1) - flat_weights = loss_weight_mask.reshape(-1) - - loss_per_token = F.cross_entropy(flat_logits, flat_targets, reduction="none") - ce_loss_num = (loss_per_token * flat_weights).sum() - ce_loss_den = flat_weights.sum() - ce_loss = ce_loss_num / (ce_loss_den + 1e-6) - - l1_loss = ce_loss.new_zeros(()) - accept_rate_3d = None - if aligned_target_logits is not None: - draft_probs = torch.softmax(draft_logits.float(), dim=-1) - target_probs = torch.softmax(aligned_target_logits.float(), dim=-1) - accept_rate_3d = 1.0 - 0.5 * (draft_probs - target_probs).abs().sum(dim=-1) - accept_rate_3d = accept_rate_3d.clamp_(0.0, 1.0) - if self.dspark_l1_loss_alpha > 0: - l1_dist = (draft_probs - target_probs).abs().sum(dim=-1) - l1_loss = (l1_dist * loss_weight_mask).sum() / (ce_loss_den + 1e-6) - elif self.dspark_l1_loss_alpha > 0 or self.dspark_confidence_head_alpha > 0: - raise ValueError( - "DSpark L1/confidence loss requires target_last_hidden_states. " - "Use the disaggregated DSpark server-capture path so the " - "consumer receives target_last_hidden_states." - ) - - confidence_loss = ce_loss.new_zeros(()) - confidence_abs_error = ce_loss.new_zeros(()) - if confidence_pred is not None: - if accept_rate_3d is None: - raise ValueError( - "DSpark confidence head requires aligned target logits." - ) - confidence_errors = F.binary_cross_entropy_with_logits( - confidence_pred.float(), - accept_rate_3d.detach(), - reduction="none", - ) - confidence_loss = (confidence_errors * loss_weight_mask).sum() / ( - ce_loss_den + 1e-6 - ) - with torch.no_grad(): - confidence_abs_error = ( - (confidence_pred.float().sigmoid() - accept_rate_3d).abs() - * loss_weight_mask - ).sum() / (ce_loss_den + 1e-6) - - loss = ( - self.dspark_ce_loss_alpha * ce_loss - + self.dspark_l1_loss_alpha * l1_loss - + self.dspark_confidence_head_alpha * confidence_loss - ) - metrics = { - "ce_loss": ce_loss.detach(), - "l1_loss": l1_loss.detach(), - "confidence_loss": confidence_loss.detach(), - "confidence_abs_error": confidence_abs_error.detach(), - } - return loss, metrics - - def forward( - self, - input_ids: torch.Tensor, - hidden_states: torch.Tensor, - loss_mask: torch.Tensor, - target_last_hidden_states: Optional[torch.Tensor] = None, - ) -> Tuple[torch.Tensor, torch.Tensor, Dict[str, torch.Tensor]]: - """Parallel DSpark training forward pass.""" - if self.attention_backend == "flex_attention" and not FLEX_ATTENTION_AVAILABLE: - raise ValueError( - "flex_attention is not available on this device; use sdpa/eager." - ) - bsz = input_ids.shape[0] - anchor_positions, block_keep_mask, output_hidden = self._forward_draft_blocks( - input_ids=input_ids, - hidden_states=hidden_states, - loss_mask=loss_mask, - ) - - logits = self.lm_head(output_hidden) - num_blocks = anchor_positions.size(1) - output_hidden_4d = output_hidden.reshape(bsz, num_blocks, self.block_size, -1) - ( - target_ids, - eval_mask, - _label_indices, - safe_label_indices, - ) = self._build_labels_and_mask( - input_ids=input_ids, - loss_mask=loss_mask, - anchor_positions=anchor_positions, - block_keep_mask=block_keep_mask, - ) - anchor_token_ids = torch.gather(input_ids, 1, anchor_positions) - prev_token_ids = torch.cat( - [anchor_token_ids.unsqueeze(-1), target_ids[:, :, :-1]], - dim=-1, - ) - draft_logits = logits.reshape(bsz, num_blocks, self.block_size, -1) - if hasattr(self.draft_model, "apply_markov_logits"): - draft_logits = self.draft_model.apply_markov_logits( - draft_logits, - prev_token_ids=prev_token_ids, - hidden_states=output_hidden_4d, - ) - confidence_pred = None - if hasattr(self.draft_model, "predict_confidence"): - confidence_pred = self.draft_model.predict_confidence( - output_hidden_4d, - prev_token_ids=prev_token_ids, - ) - aligned_target_logits = self._aligned_target_logits( - target_last_hidden_states, - safe_label_indices, - ) - loss, metrics = self._compute_loss( - draft_logits=draft_logits, - target_ids=target_ids, - eval_mask=eval_mask, - confidence_pred=confidence_pred, - aligned_target_logits=aligned_target_logits, - ) - flat_logits = draft_logits.reshape(-1, draft_logits.size(-1)) - flat_targets = target_ids.reshape(-1) - binary_eval_mask = eval_mask.reshape(-1) - with torch.no_grad(): - pred_ids = torch.argmax(flat_logits, dim=-1) - correct = (pred_ids == flat_targets) & binary_eval_mask - accuracy_denom = binary_eval_mask.to(torch.float32).sum() - accuracy = correct.sum().float() / (accuracy_denom + 1e-6) - metrics["accuracy_denom"] = accuracy_denom - return loss, accuracy, metrics diff --git a/specforge/modeling/auto.py b/specforge/modeling/auto.py index 0b7d32d70..8391cca0e 100644 --- a/specforge/modeling/auto.py +++ b/specforge/modeling/auto.py @@ -54,6 +54,13 @@ def from_config(cls, config: PretrainedConfig, torch_dtype=None, **config_kwargs archs = getattr(config, "architectures", None) or [] if len(archs) == 1 and archs[0] in DRAFT_REGISTRY: _model_cls = DRAFT_REGISTRY[archs[0]] + if archs[0] == "DFlashDraftModel": + dflash_config = getattr(config, "dflash_config", {}) or {} + projector_type = dflash_config.get("projector_type", None) + if projector_type == "domino": + _model_cls = DRAFT_REGISTRY["DominoDraftModel"] + elif projector_type == "dspark": + _model_cls = DRAFT_REGISTRY["DSparkDraftModel"] else: _model_cls = cls._model_mapping[type(config)] model = _model_cls(config, **config_kwargs) diff --git a/specforge/modeling/draft/__init__.py b/specforge/modeling/draft/__init__.py index 7461d7781..fa0800f49 100644 --- a/specforge/modeling/draft/__init__.py +++ b/specforge/modeling/draft/__init__.py @@ -5,12 +5,16 @@ extract_context_feature, sample, ) +from .domino import DominoDraftModel +from .dspark import DSparkDraftModel from .llama3_eagle import LlamaForCausalLMEagle3 from .registry import DRAFT_REGISTRY, available_drafts, register_draft, resolve_draft __all__ = [ "Eagle3DraftModel", "DFlashDraftModel", + "DominoDraftModel", + "DSparkDraftModel", "LlamaForCausalLMEagle3", "build_target_layer_ids", "extract_context_feature", diff --git a/specforge/modeling/draft/dflash.py b/specforge/modeling/draft/dflash.py index 50dff51a0..b440616d3 100644 --- a/specforge/modeling/draft/dflash.py +++ b/specforge/modeling/draft/dflash.py @@ -19,7 +19,6 @@ ) from typing_extensions import Tuple, Unpack -from .dspark_heads import AcceptRatePredictor, build_markov_head from .registry import register_draft @@ -244,52 +243,54 @@ def __init__(self, config) -> None: self.projector_type = dflash_config.get("projector_type", None) self.pure_draft_prefix_len = dflash_config.get("pure_draft_prefix_len", 0) self.shift_label = dflash_config.get("shift_label", False) - - self.markov_head = None - self.enable_confidence_head = False - self.confidence_head_with_markov = False - self.confidence_head = None - - if self.projector_type == "domino": - self.emb_dim = dflash_config["emb_dim"] - self.gru_hidden_dim = dflash_config["gru_hidden_dim"] - self.prefix_gru = nn.GRU( - input_size=config.hidden_size, - hidden_size=self.gru_hidden_dim, - num_layers=1, - batch_first=True, - bias=False, - ) - in_dim = config.hidden_size + self.gru_hidden_dim - self.embed_proj = nn.Sequential( - nn.Linear(in_dim, self.emb_dim, bias=False), - nn.SiLU(), - nn.Linear(self.emb_dim, config.vocab_size, bias=False), - ) - elif self.projector_type == "dspark": - self.markov_head = build_markov_head(config, dflash_config) - confidence_alpha = float( - dflash_config.get("confidence_head_alpha", 0.0) or 0.0 - ) - self.enable_confidence_head = bool( - dflash_config.get("enable_confidence_head", confidence_alpha > 0.0) - ) - self.confidence_head_with_markov = bool( - dflash_config.get("confidence_head_with_markov", False) - ) - if self.confidence_head_with_markov and self.markov_head is None: - raise ValueError( - "confidence_head_with_markov=True requires markov_rank > 0" - ) - if self.enable_confidence_head: - input_dim = config.hidden_size - if self.confidence_head_with_markov: - input_dim += self.markov_head.markov_rank - self.confidence_head = AcceptRatePredictor(input_dim=input_dim) - elif self.projector_type is not None: - raise ValueError(f"Unknown draft projector_type: {self.projector_type}") + self._init_draft_head(config, dflash_config) + self.register_load_state_dict_pre_hook(self._remap_legacy_draft_head_keys) self.post_init() + @staticmethod + def _remap_legacy_draft_head_keys( + module, + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ): + del module, local_metadata, strict, missing_keys, unexpected_keys, error_msgs + legacy_prefixes = ( + ("logit_head.prefix_gru.", "prefix_gru."), + ("logit_head.embed_proj.", "embed_proj."), + ("logit_head.markov_head.", "markov_head."), + ("logit_head.confidence_head.", "confidence_head."), + ) + for key in list(state_dict.keys()): + if not key.startswith(prefix): + continue + local_key = key[len(prefix) :] + for old_prefix, new_prefix in legacy_prefixes: + if local_key.startswith(old_prefix): + new_key = prefix + new_prefix + local_key[len(old_prefix) :] + if new_key not in state_dict: + state_dict[new_key] = state_dict[key] + state_dict.pop(key) + break + + def _init_draft_head(self, config, dflash_config: dict) -> None: + del config, dflash_config + + def apply_logits_head( + self, + base_logits: torch.Tensor, + *, + prev_token_ids: Optional[torch.Tensor] = None, + prev_token_embeddings: Optional[torch.Tensor] = None, + hidden_states: torch.Tensor, + ) -> torch.Tensor: + del prev_token_ids, prev_token_embeddings, hidden_states + return base_logits + def apply_markov_logits( self, base_logits: torch.Tensor, @@ -297,11 +298,9 @@ def apply_markov_logits( prev_token_ids: torch.Tensor, hidden_states: torch.Tensor, ) -> torch.Tensor: - if self.markov_head is None: - return base_logits - return self.markov_head.apply_block_logits( + return self.apply_logits_head( base_logits, - token_ids=prev_token_ids, + prev_token_ids=prev_token_ids, hidden_states=hidden_states, ) @@ -311,17 +310,8 @@ def predict_confidence( *, prev_token_ids: Optional[torch.Tensor] = None, ) -> Optional[torch.Tensor]: - if self.confidence_head is None: - return None - if self.confidence_head_with_markov: - assert self.markov_head is not None - if prev_token_ids is None: - raise ValueError("prev_token_ids is required for Markov confidence") - prev_embeddings = self.markov_head.get_prev_embeddings(prev_token_ids).to( - hidden_states.dtype - ) - hidden_states = torch.cat([hidden_states, prev_embeddings], dim=-1) - return self.confidence_head(hidden_states).float() + del hidden_states, prev_token_ids + return None def forward( self, diff --git a/specforge/modeling/draft/domino.py b/specforge/modeling/draft/domino.py new file mode 100644 index 000000000..ea2e78b20 --- /dev/null +++ b/specforge/modeling/draft/domino.py @@ -0,0 +1,130 @@ +# coding=utf-8 +"""Domino draft model entry point.""" + +from __future__ import annotations + +from typing import Optional + +import torch +from torch import nn + +from specforge.utils import get_device_type + +from .dflash import DFlashDraftModel +from .registry import register_draft + + +@register_draft +class DominoDraftModel(DFlashDraftModel): + """DFlash backbone with Domino's GRU logits correction.""" + + expected_projector_type = "domino" + + def __init__(self, config) -> None: + dflash_config = getattr(config, "dflash_config", None) or {} + projector_type = dflash_config.get("projector_type") + if projector_type is None: + dflash_config["projector_type"] = self.expected_projector_type + config.dflash_config = dflash_config + elif projector_type != self.expected_projector_type: + raise ValueError( + "DominoDraftModel requires " "dflash_config.projector_type='domino'." + ) + super().__init__(config) + + def _init_draft_head(self, config, dflash_config: dict) -> None: + self.emb_dim = int(dflash_config["emb_dim"]) + self.gru_hidden_dim = int(dflash_config["gru_hidden_dim"]) + self.pure_draft_prefix_len = int(dflash_config.get("pure_draft_prefix_len", 0)) + self.shift_label = bool(dflash_config.get("shift_label", False)) + + self.prefix_gru = nn.GRU( + input_size=config.hidden_size, + hidden_size=self.gru_hidden_dim, + num_layers=1, + batch_first=True, + bias=False, + ) + in_dim = config.hidden_size + self.gru_hidden_dim + self.embed_proj = nn.Sequential( + nn.Linear(in_dim, self.emb_dim, bias=False), + nn.SiLU(), + nn.Linear(self.emb_dim, config.vocab_size, bias=False), + ) + + # Ascend NPU DynamicGRU does not support bfloat16. Keep the fp16 copy + # outside module registration so FSDP does not see mixed parameter dtypes. + object.__setattr__(self, "_gru_fp16", None) + + @property + def suffix_start(self) -> int: + return ( + self.pure_draft_prefix_len + if self.shift_label + else (1 + self.pure_draft_prefix_len) + ) + + def _get_gru_fp16(self, device: torch.device) -> nn.GRU: + gru_fp16 = self._gru_fp16 + if ( + gru_fp16 is None + or next(gru_fp16.parameters()).device != device + or gru_fp16.hidden_size != self.prefix_gru.hidden_size + ): + gru_fp16 = nn.GRU( + input_size=self.prefix_gru.weight_ih_l0.shape[1], + hidden_size=self.prefix_gru.hidden_size, + num_layers=1, + batch_first=True, + bias=False, + ) + gru_fp16.to(device=device, dtype=torch.float16) + gru_fp16.weight_ih_l0.requires_grad = False + gru_fp16.weight_hh_l0.requires_grad = False + object.__setattr__(self, "_gru_fp16", gru_fp16) + return gru_fp16 + + def _run_gru(self, gru_inputs: torch.Tensor) -> torch.Tensor: + if get_device_type() == "npu" and gru_inputs.dtype == torch.bfloat16: + gru_fp16 = self._get_gru_fp16(gru_inputs.device) + gru_fp16.weight_ih_l0.data.copy_(self.prefix_gru.weight_ih_l0.data.half()) + gru_fp16.weight_hh_l0.data.copy_(self.prefix_gru.weight_hh_l0.data.half()) + return gru_fp16(gru_inputs.half())[0].to(gru_inputs.dtype) + return self.prefix_gru(gru_inputs)[0] + + def apply_logits_head( + self, + base_logits: torch.Tensor, + *, + prev_token_ids: Optional[torch.Tensor] = None, + prev_token_embeddings: Optional[torch.Tensor] = None, + hidden_states: torch.Tensor, + ) -> torch.Tensor: + del prev_token_ids + if prev_token_embeddings is None: + raise ValueError("DominoDraftModel requires prev_token_embeddings") + + bsz, n_blocks, block_size = base_logits.shape[:3] + if self.shift_label: + gru_inputs = prev_token_embeddings.reshape(bsz * n_blocks, block_size, -1) + gru_out = self._run_gru(gru_inputs) + gru_out = gru_out.reshape(bsz, n_blocks, block_size, -1) + prefix_states = gru_out[:, :, self.suffix_start :, :] + else: + gru_inputs = prev_token_embeddings[:, :, : block_size - 1, :].reshape( + bsz * n_blocks, block_size - 1, -1 + ) + gru_out = self._run_gru(gru_inputs) + gru_out = gru_out.reshape(bsz, n_blocks, block_size - 1, -1) + prefix_states = gru_out[:, :, self.suffix_start - 1 :, :] + + z_n = hidden_states[:, :, self.suffix_start :, :] + concat_features = torch.cat([z_n, prefix_states], dim=-1) + logits_e = self.embed_proj(concat_features) + + prefix_logits = base_logits[:, :, : self.suffix_start, :] + suffix_logits = base_logits[:, :, self.suffix_start :, :] + logits_e + return torch.cat([prefix_logits, suffix_logits], dim=2) + + +__all__ = ["DominoDraftModel"] diff --git a/specforge/modeling/draft/dspark_heads.py b/specforge/modeling/draft/dspark.py similarity index 75% rename from specforge/modeling/draft/dspark_heads.py rename to specforge/modeling/draft/dspark.py index 6735e424c..c65e45929 100644 --- a/specforge/modeling/draft/dspark_heads.py +++ b/specforge/modeling/draft/dspark.py @@ -1,5 +1,5 @@ # coding=utf-8 -"""Small DSpark heads layered on top of the DFlash draft backbone.""" +"""DSpark draft model entry point and Markov heads.""" from __future__ import annotations @@ -8,6 +8,9 @@ import torch from torch import nn +from .dflash import DFlashDraftModel +from .registry import register_draft + def _sample(logits: torch.Tensor, temperature: float = 0.0) -> torch.Tensor: if temperature < 1e-5: @@ -228,7 +231,7 @@ def sample_block_tokens( state = torch.zeros( batch_size, self.markov_rank, - device=base_logits.device, + device=hidden_states.device, dtype=hidden_states.dtype, ) sampled_tokens = [] @@ -276,10 +279,88 @@ def build_markov_head(config, dspark_config: dict) -> Optional[nn.Module]: raise ValueError(f"Unsupported markov_head_type: {markov_head_type!r}") +@register_draft +class DSparkDraftModel(DFlashDraftModel): + """DFlash backbone with DSpark Markov/confidence heads.""" + + expected_projector_type = "dspark" + + def __init__(self, config) -> None: + dflash_config = getattr(config, "dflash_config", None) or {} + projector_type = dflash_config.get("projector_type") + if projector_type is None: + dflash_config["projector_type"] = self.expected_projector_type + config.dflash_config = dflash_config + elif projector_type != self.expected_projector_type: + raise ValueError( + "DSparkDraftModel requires " "dflash_config.projector_type='dspark'." + ) + super().__init__(config) + + def _init_draft_head(self, config, dflash_config: dict) -> None: + self.markov_head = build_markov_head(config, dflash_config) + confidence_alpha = float(dflash_config.get("confidence_head_alpha", 0.0) or 0.0) + self.enable_confidence_head = bool( + dflash_config.get("enable_confidence_head", confidence_alpha > 0.0) + ) + self.confidence_head_with_markov = bool( + dflash_config.get("confidence_head_with_markov", False) + ) + if self.confidence_head_with_markov and self.markov_head is None: + raise ValueError( + "confidence_head_with_markov=True requires markov_rank > 0" + ) + + self.confidence_head = None + if self.enable_confidence_head: + input_dim = config.hidden_size + if self.confidence_head_with_markov: + input_dim += self.markov_head.markov_rank + self.confidence_head = AcceptRatePredictor(input_dim=input_dim) + + def apply_logits_head( + self, + base_logits: torch.Tensor, + *, + prev_token_ids: Optional[torch.Tensor] = None, + prev_token_embeddings: Optional[torch.Tensor] = None, + hidden_states: torch.Tensor, + ) -> torch.Tensor: + del prev_token_embeddings + if self.markov_head is None: + return base_logits + if prev_token_ids is None: + raise ValueError("DSparkDraftModel requires prev_token_ids") + return self.markov_head.apply_block_logits( + base_logits, + token_ids=prev_token_ids, + hidden_states=hidden_states, + ) + + def predict_confidence( + self, + hidden_states: torch.Tensor, + *, + prev_token_ids: Optional[torch.Tensor] = None, + ) -> Optional[torch.Tensor]: + if self.confidence_head is None: + return None + if self.confidence_head_with_markov: + assert self.markov_head is not None + if prev_token_ids is None: + raise ValueError("prev_token_ids is required for Markov confidence") + prev_embeddings = self.markov_head.get_prev_embeddings(prev_token_ids).to( + hidden_states.dtype + ) + hidden_states = torch.cat([hidden_states, prev_embeddings], dim=-1) + return self.confidence_head(hidden_states).float() + + __all__ = [ "AcceptRatePredictor", - "VanillaMarkovHead", + "DSparkDraftModel", "GatedMarkovHead", "RNNMarkovHead", + "VanillaMarkovHead", "build_markov_head", ] diff --git a/specforge/training/strategies/registry.py b/specforge/training/strategies/registry.py index 4ef4c44a8..a9f9aaa6d 100644 --- a/specforge/training/strategies/registry.py +++ b/specforge/training/strategies/registry.py @@ -333,12 +333,10 @@ def _dspark_server_capture_only_adapter(*args, **kwargs): # --- Domino ----------------------------------------------------------------- -# Domino reuses DFlash's draft model (projector_type="domino" head), feature -# schema, offline transform/collate, and capture path (same DFLASH_FEATURE_SCHEMA -# -> hidden_states). The ONE difference is the loss: it blends a base loss with a -# step-decayed weight, so DominoTrainStrategy reads the StepContext -# (forward_loss(batch, ctx)). That is the whole reason a new algorithm needs -# anything beyond a spec entry here. +# Domino uses a DFlash-family draft model and reuses the DFlash feature schema, +# offline transform/collate, and capture path (same DFLASH_FEATURE_SCHEMA -> +# hidden_states). The loss blends a base loss with a step-decayed weight, so +# DominoTrainStrategy reads the StepContext (forward_loss(batch, ctx)). from specforge.training.strategies.base import DominoTrainStrategy diff --git a/tests/test_modeling/test_draft_registry.py b/tests/test_modeling/test_draft_registry.py index 673f6ab6d..0e0c39e16 100644 --- a/tests/test_modeling/test_draft_registry.py +++ b/tests/test_modeling/test_draft_registry.py @@ -17,6 +17,8 @@ from specforge.modeling.draft import ( DRAFT_REGISTRY, DFlashDraftModel, + DominoDraftModel, + DSparkDraftModel, LlamaForCausalLMEagle3, available_drafts, register_draft, @@ -54,7 +56,7 @@ TINY_DSPARK = { **TINY_DFLASH, - "architectures": ["DFlashDraftModel"], + "architectures": ["DSparkDraftModel"], "layer_types": ["full_attention"], "dflash_config": { "projector_type": "dspark", @@ -65,6 +67,29 @@ }, } +TINY_LEGACY_DSPARK = { + **TINY_DSPARK, + "architectures": ["DFlashDraftModel"], +} + +TINY_DOMINO = { + **TINY_DFLASH, + "architectures": ["DominoDraftModel"], + "layer_types": ["full_attention"], + "dflash_config": { + "projector_type": "domino", + "emb_dim": 16, + "gru_hidden_dim": 16, + "pure_draft_prefix_len": 0, + "shift_label": False, + }, +} + +TINY_LEGACY_DOMINO = { + **TINY_DOMINO, + "architectures": ["DFlashDraftModel"], +} + def _write(cfg: dict) -> str: fd, path = tempfile.mkstemp(suffix=".json") @@ -77,8 +102,12 @@ class DraftRegistryTest(unittest.TestCase): def test_builtin_architectures_registered(self): self.assertIn("LlamaForCausalLMEagle3", available_drafts()) self.assertIn("DFlashDraftModel", available_drafts()) + self.assertIn("DominoDraftModel", available_drafts()) + self.assertIn("DSparkDraftModel", available_drafts()) self.assertIs(resolve_draft("LlamaForCausalLMEagle3"), LlamaForCausalLMEagle3) self.assertIs(resolve_draft("DFlashDraftModel"), DFlashDraftModel) + self.assertIs(resolve_draft("DominoDraftModel"), DominoDraftModel) + self.assertIs(resolve_draft("DSparkDraftModel"), DSparkDraftModel) def test_unknown_architecture_raises_with_available_list(self): with self.assertRaises(KeyError) as ctx: @@ -125,16 +154,68 @@ def test_from_file_resolves_dflash_via_registry(self): config = AutoDraftModelConfig.from_file(path) self.assertIsInstance(config, Qwen3Config) - def test_from_config_builds_dspark_as_dflash_projector(self): + def test_from_config_builds_dspark_as_explicit_draft(self): path = _write(TINY_DSPARK) self.addCleanup(os.unlink, path) config = AutoDraftModelConfig.from_file(path) model = AutoEagle3DraftModel.from_config(config) + self.assertIsInstance(model, DSparkDraftModel) self.assertIsInstance(model, DFlashDraftModel) self.assertEqual(model.projector_type, "dspark") self.assertIsNotNone(model.markov_head) self.assertIsNotNone(model.confidence_head) + def test_from_config_maps_legacy_dspark_projector_to_explicit_draft(self): + path = _write(TINY_LEGACY_DSPARK) + self.addCleanup(os.unlink, path) + config = AutoDraftModelConfig.from_file(path) + model = AutoEagle3DraftModel.from_config(config) + self.assertIsInstance(model, DSparkDraftModel) + self.assertIsInstance(model, DFlashDraftModel) + self.assertEqual(model.projector_type, "dspark") + self.assertIsNotNone(model.markov_head) + + def test_from_config_maps_legacy_domino_projector_to_explicit_draft(self): + path = _write(TINY_LEGACY_DOMINO) + self.addCleanup(os.unlink, path) + config = AutoDraftModelConfig.from_file(path) + model = AutoEagle3DraftModel.from_config(config) + self.assertIsInstance(model, DominoDraftModel) + self.assertIsInstance(model, DFlashDraftModel) + self.assertEqual(model.projector_type, "domino") + self.assertIsNotNone(model.prefix_gru) + + def test_from_config_builds_domino_as_explicit_draft(self): + path = _write(TINY_DOMINO) + self.addCleanup(os.unlink, path) + config = AutoDraftModelConfig.from_file(path) + model = AutoEagle3DraftModel.from_config(config) + self.assertIsInstance(model, DominoDraftModel) + self.assertIsInstance(model, DFlashDraftModel) + self.assertEqual(model.projector_type, "domino") + self.assertIsNotNone(model.prefix_gru) + self.assertIsNotNone(model.embed_proj) + + def test_domino_uses_direct_state_dict_keys(self): + path = _write(TINY_DOMINO) + self.addCleanup(os.unlink, path) + config = AutoDraftModelConfig.from_file(path) + model = AutoEagle3DraftModel.from_config(config) + keys = set(model.state_dict()) + self.assertTrue(any(key.startswith("prefix_gru.") for key in keys)) + self.assertTrue(any(key.startswith("embed_proj.") for key in keys)) + self.assertFalse(any(key.startswith("logit_head.") for key in keys)) + + def test_dspark_uses_direct_state_dict_keys(self): + path = _write(TINY_DSPARK) + self.addCleanup(os.unlink, path) + config = AutoDraftModelConfig.from_file(path) + model = AutoEagle3DraftModel.from_config(config) + keys = set(model.state_dict()) + self.assertTrue(any(key.startswith("markov_head.") for key in keys)) + self.assertTrue(any(key.startswith("confidence_head.") for key in keys)) + self.assertFalse(any(key.startswith("logit_head.") for key in keys)) + def test_from_file_unknown_architecture_raises(self): path = _write({**TINY_EAGLE3, "architectures": ["NoSuchDraft"]}) self.addCleanup(os.unlink, path) diff --git a/tests/test_runtime/_fixtures.py b/tests/test_runtime/_fixtures.py index e38dda37d..752401d4b 100644 --- a/tests/test_runtime/_fixtures.py +++ b/tests/test_runtime/_fixtures.py @@ -317,14 +317,14 @@ def build_domino( ): """Build a tiny OnlineDominoModel on cuda, mirroring scripts/train_domino. - Domino reuses the DFlash draft model with a projector_type="domino" head (GRU - prefix + embed projection). Returns + Domino uses a DFlash-backed DominoDraftModel with a GRU prefix + embed + projection head. Returns (domino_model, hidden_states_width, target_dir, target_layer_ids). """ from transformers import AutoConfig, Qwen3Config, Qwen3ForCausalLM - from specforge.core.domino import OnlineDominoModel - from specforge.modeling.draft.dflash import DFlashDraftModel + from specforge.core.dflash import OnlineDominoModel + from specforge.modeling.draft.domino import DominoDraftModel from specforge.modeling.target.target_utils import TargetEmbeddingsAndHead tcfg = Qwen3Config( @@ -347,7 +347,7 @@ def build_domino( draft_config.block_size = block_size draft_config.num_target_layers = target_layers draft_config.dflash_config = { - "projector_type": "domino", # builds the GRU prefix + embed_proj head + "projector_type": "domino", "emb_dim": hidden, "gru_hidden_dim": hidden // 2, "pure_draft_prefix_len": 0, @@ -356,7 +356,7 @@ def build_domino( } draft_config._attn_implementation = attention_backend - draft_model = DFlashDraftModel(draft_config).to(device="cuda", dtype=torch.bfloat16) + draft_model = DominoDraftModel(draft_config).to(device="cuda", dtype=torch.bfloat16) draft_model.mask_token_id = mask_token_id target_components = TargetEmbeddingsAndHead.from_pretrained( diff --git a/tests/test_utils/test_dflash_losses.py b/tests/test_utils/test_dflash_losses.py index d6a065398..c1a3b3f14 100644 --- a/tests/test_utils/test_dflash_losses.py +++ b/tests/test_utils/test_dflash_losses.py @@ -56,26 +56,7 @@ class _DFlashDraftStub(nn.Module): ): _spec.loader.exec_module(_dflash_module) OnlineDFlashModel = _dflash_module.OnlineDFlashModel - -_spec_dspark = importlib.util.spec_from_file_location( - "specforge.core.dspark", REPO / "specforge" / "core" / "dspark.py" -) -_dspark_module = importlib.util.module_from_spec(_spec_dspark) - -with patch.dict( - sys.modules, - { - "specforge": _pkg_specforge, - "specforge.core": _pkg_core, - "specforge.core.dflash": _dflash_module, - "specforge.core.dspark": _dspark_module, - "specforge.modeling": _pkg_modeling, - "specforge.modeling.draft": _pkg_draft, - "specforge.modeling.draft.dflash": _stub_dflash_draft, - }, -): - _spec_dspark.loader.exec_module(_dspark_module) -OnlineDSparkModel = _dspark_module.OnlineDSparkModel +OnlineDSparkModel = _dflash_module.OnlineDSparkModel class _FixedDraft(nn.Module): @@ -93,6 +74,14 @@ def forward(self, position_ids, noise_embedding, target_hidden, attention_mask): device=noise_embedding.device, ) + def apply_logits_head(self, base_logits, **kwargs): + del kwargs + return base_logits + + def predict_confidence(self, hidden_states, prev_token_ids=None): + del hidden_states, prev_token_ids + return None + class _FixedDSparkDraft(_FixedDraft): def __init__(self, hidden_size: int, confidence_logits: torch.Tensor = None): @@ -102,7 +91,7 @@ def __init__(self, hidden_size: int, confidence_logits: torch.Tensor = None): else: self.confidence_logits = None - def apply_markov_logits(self, base_logits, prev_token_ids, hidden_states): + def apply_logits_head(self, base_logits, prev_token_ids, hidden_states): del prev_token_ids, hidden_states return base_logits From fe0ad18f3623f9681390920b12a3b85104abaa15 Mon Sep 17 00:00:00 2001 From: jiapingW <1969554248@qq.com> Date: Fri, 10 Jul 2026 13:15:34 +0800 Subject: [PATCH 20/24] fix some bugs --- specforge/core/dflash.py | 150 +++--------------- specforge/launch.py | 4 +- tests/test_runtime/test_disagg_multiserver.py | 16 ++ tests/test_utils/test_dflash_losses.py | 22 +++ 4 files changed, 60 insertions(+), 132 deletions(-) diff --git a/specforge/core/dflash.py b/specforge/core/dflash.py index 8acde394d..8e4305796 100644 --- a/specforge/core/dflash.py +++ b/specforge/core/dflash.py @@ -25,12 +25,11 @@ _VALID_LOSS_TYPES = { "dflash", - "vp_drafter", "dpace", "dpace-cumulative-confidence-only", "dpace-continuation-value-only", } -_DPACE_LOSS_TYPES = _VALID_LOSS_TYPES - {"dflash", "vp_drafter"} +_DPACE_LOSS_TYPES = _VALID_LOSS_TYPES - {"dflash"} def compute_accept_len( @@ -133,7 +132,6 @@ def __init__( loss_decay_gamma: Optional[float] = None, loss_type: str = "dflash", dpace_alpha: float = 0.5, - prefix_weight_base: float = 0.9, ): super().__init__() if loss_type not in _VALID_LOSS_TYPES: @@ -142,12 +140,6 @@ def __init__( ) if not 0.0 <= dpace_alpha <= 1.0: raise ValueError(f"dpace_alpha must be in [0, 1], got {dpace_alpha}") - if prefix_weight_base is None: - prefix_weight_base = 0.9 - if prefix_weight_base <= 0.0: - raise ValueError( - f"prefix_weight_base must be positive, got {prefix_weight_base}" - ) self.draft_model = draft_model self.lm_head = target_lm_head @@ -159,7 +151,6 @@ def __init__( self.loss_decay_gamma = loss_decay_gamma self.loss_type = loss_type self.dpace_alpha = dpace_alpha - self.prefix_weight_base = prefix_weight_base self._cached_block_mask: Optional[BlockMask] = None self._cached_seq_len: Optional[int] = None @@ -203,52 +194,6 @@ def _sample_anchor_positions( return anchors, keep_mask - def _sample_prefix_lengths( - self, bsz: int, n_blocks: int, device: torch.device - ) -> torch.Tensor: - """Sample visible prefix lengths for VP-Drafter training. - - A prefix length i means block positions [0, i) are visible real tokens and - positions [i, block_size) are masked prediction targets. The sampled - range follows D2SD's variable-prefix recipe while avoiding the degenerate - fixed-anchor DFlash case. - """ - min_prefix = min(2, self.block_size - 1) - max_prefix = self.block_size - 1 - if max_prefix <= min_prefix: - return torch.full( - (bsz, n_blocks), min_prefix, dtype=torch.long, device=device - ) - - prefix_ids = torch.arange(min_prefix, max_prefix + 1, device=device) - weights = torch.pow( - torch.full_like(prefix_ids, self.prefix_weight_base, dtype=torch.float32), - prefix_ids.float(), - ) - samples = torch.multinomial( - weights, num_samples=bsz * n_blocks, replacement=True - ).reshape(bsz, n_blocks) - return samples + min_prefix - - def prepare_noise_input( - self, input_ids: torch.Tensor, block_ids: Optional[torch.Tensor] = None - ) -> torch.Tensor: - """Prepare noise input: first token of each block is real, rest are MASK.""" - bsz, seq_len = input_ids.shape - device = input_ids.device - - if block_ids is not None: - is_block_start = torch.ones(bsz, seq_len, dtype=torch.bool, device=device) - is_block_start[:, 1:] = block_ids[:, 1:] != block_ids[:, :-1] - else: - positions = torch.arange(seq_len, device=device) - is_block_start = (positions % self.block_size) == 0 - is_block_start = is_block_start.unsqueeze(0).expand(bsz, -1) - - noise_input_ids = torch.full_like(input_ids, self.mask_token_id) - noise_input_ids[is_block_start] = input_ids[is_block_start] - return noise_input_ids - def _create_position_ids(self, anchor_positions: torch.Tensor) -> torch.Tensor: """Create absolute position IDs for parallel draft blocks.""" bsz, n_blocks = anchor_positions.shape @@ -282,36 +227,6 @@ def _create_noise_embed(self, input_ids, anchor_positions, block_keep_mask): return self.embed_tokens(noise_ids) - def _create_vp_noise_embed( - self, - input_ids: torch.Tensor, - anchor_positions: torch.Tensor, - block_keep_mask: torch.Tensor, - prefix_lengths: torch.Tensor, - ) -> torch.Tensor: - """Prepare VP-Drafter inputs with variable visible prefixes.""" - bsz, seq_len = input_ids.shape - n = anchor_positions.shape[1] - bs = self.block_size - device = input_ids.device - - offsets = torch.arange(bs, device=device).view(1, 1, -1) - token_positions = anchor_positions.unsqueeze(-1) + offsets - safe_positions = token_positions.clamp(0, seq_len - 1) - - real_tokens = torch.gather( - input_ids.unsqueeze(1).expand(-1, n, -1), - 2, - safe_positions, - ) - visible_prefix = offsets < prefix_lengths.unsqueeze(-1) - valid_positions = token_positions < seq_len - fill_mask = visible_prefix & block_keep_mask.unsqueeze(-1) & valid_positions - - mask_tokens = torch.full_like(real_tokens, self.mask_token_id) - noise_ids = torch.where(fill_mask, real_tokens, mask_tokens) - return self.embed_tokens(noise_ids.reshape(bsz, n * bs)) - def _dpace_weight( self, prob: torch.Tensor, @@ -357,18 +272,9 @@ def _forward_draft_blocks( seq_len, loss_mask, device ) - prefix_lengths = None - if self.loss_type == "vp_drafter": - prefix_lengths = self._sample_prefix_lengths( - bsz, anchor_positions.shape[1], device - ) - noise_embedding = self._create_vp_noise_embed( - input_ids, anchor_positions, block_keep_mask, prefix_lengths - ) - else: - noise_embedding = self._create_noise_embed( - input_ids, anchor_positions, block_keep_mask - ) + noise_embedding = self._create_noise_embed( + input_ids, anchor_positions, block_keep_mask + ) context_position_ids = ( torch.arange(seq_len, device=device).unsqueeze(0).expand(bsz, -1) @@ -443,12 +349,7 @@ def forward( weight_mask = weight_mask * valid_label_mask.float() pos_in_block = torch.arange(self.block_size, device=device).view(1, 1, -1) - if self.loss_type == "vp_drafter": - weight_mask = ( - weight_mask * (pos_in_block >= prefix_lengths.unsqueeze(-1)).float() - ) - else: - weight_mask = weight_mask * (pos_in_block > 0).float() + weight_mask = weight_mask * (pos_in_block > 0).float() original_loss_mask_gathered = torch.gather( loss_mask.unsqueeze(1).expand(-1, anchor_positions.size(1), -1), @@ -475,19 +376,6 @@ def forward( ) loss_weights = loss_weights * decay_weights - flat_weights = loss_weights.view(-1) - valid_token_count = flat_weights.sum() + 1e-6 - loss = (loss_per_token * flat_weights).sum() / valid_token_count - elif self.loss_type == "vp_drafter": - loss_weights = weight_mask - if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: - k = torch.arange(self.block_size, device=device).view(1, 1, -1) - effective_pos = ( - k.float() - prefix_lengths.unsqueeze(-1).float() - ).clamp(min=0) - decay_weights = torch.exp(-effective_pos / self.loss_decay_gamma) - loss_weights = loss_weights * decay_weights - flat_weights = loss_weights.view(-1) valid_token_count = flat_weights.sum() + 1e-6 loss = (loss_per_token * flat_weights).sum() / valid_token_count @@ -912,9 +800,8 @@ def _build_dspark_labels_and_mask( loss_mask: torch.Tensor, anchor_positions: torch.Tensor, block_keep_mask: torch.Tensor, - ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: - bsz, seq_len = input_ids.shape - del bsz + ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + seq_len = input_ids.shape[1] device = input_ids.device label_offsets = torch.arange(1, self.block_size + 1, device=device).view( 1, 1, -1 @@ -941,16 +828,17 @@ def _build_dspark_labels_and_mask( eval_mask = target_valid & (target_loss_mask > 0.5) eval_mask = eval_mask & block_keep_mask.unsqueeze(-1) eval_mask = eval_mask.to(torch.int32).cumprod(dim=-1).bool() - return target_ids, eval_mask, label_indices, safe_label_indices + return target_ids, eval_mask, safe_label_indices def _dspark_loss_weight_mask( self, eval_mask: torch.Tensor, - device: torch.device, ) -> torch.Tensor: loss_weight_mask = eval_mask.to(torch.float32) if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: - positions = torch.arange(self.block_size, device=device).view(1, 1, -1) + positions = torch.arange( + self.block_size, device=eval_mask.device + ).view(1, 1, -1) decay_weights = torch.exp(-positions.float() / float(self.loss_decay_gamma)) loss_weight_mask = loss_weight_mask * decay_weights return loss_weight_mask @@ -989,27 +877,28 @@ def _compute_dspark_loss( confidence_pred: Optional[torch.Tensor], aligned_target_logits: Optional[torch.Tensor], ) -> Tuple[torch.Tensor, Dict[str, torch.Tensor]]: - device = draft_logits.device vocab_size = draft_logits.size(-1) - loss_weight_mask = self._dspark_loss_weight_mask(eval_mask, device) + loss_weight_mask = self._dspark_loss_weight_mask(eval_mask) flat_logits = draft_logits.reshape(-1, vocab_size) flat_targets = target_ids.reshape(-1) flat_weights = loss_weight_mask.reshape(-1) loss_per_token = F.cross_entropy(flat_logits, flat_targets, reduction="none") - ce_loss_num = (loss_per_token * flat_weights).sum() ce_loss_den = flat_weights.sum() - ce_loss = ce_loss_num / (ce_loss_den + 1e-6) + ce_loss = (loss_per_token * flat_weights).sum() / (ce_loss_den + 1e-6) l1_loss = ce_loss.new_zeros(()) accept_rate_3d = None - if aligned_target_logits is not None: + needs_target_distribution = ( + self.dspark_l1_loss_alpha > 0 or confidence_pred is not None + ) + if aligned_target_logits is not None and needs_target_distribution: draft_probs = torch.softmax(draft_logits.float(), dim=-1) target_probs = torch.softmax(aligned_target_logits.float(), dim=-1) - accept_rate_3d = 1.0 - 0.5 * (draft_probs - target_probs).abs().sum(dim=-1) + l1_dist = (draft_probs - target_probs).abs().sum(dim=-1) + accept_rate_3d = 1.0 - 0.5 * l1_dist accept_rate_3d = accept_rate_3d.clamp_(0.0, 1.0) if self.dspark_l1_loss_alpha > 0: - l1_dist = (draft_probs - target_probs).abs().sum(dim=-1) l1_loss = (l1_dist * loss_weight_mask).sum() / (ce_loss_den + 1e-6) elif self.dspark_l1_loss_alpha > 0 or self.dspark_confidence_head_alpha > 0: raise ValueError( @@ -1077,7 +966,6 @@ def forward( ( target_ids, eval_mask, - _label_indices, safe_label_indices, ) = self._build_dspark_labels_and_mask( input_ids=input_ids, diff --git a/specforge/launch.py b/specforge/launch.py index 08a11a645..f7ac91e8e 100644 --- a/specforge/launch.py +++ b/specforge/launch.py @@ -767,8 +767,10 @@ def run_worker(w) -> None: for _ in range(max_rounds): if should_stop is not None and should_stop(): return + _t = time.monotonic() + _infl = channel.in_flight_remote() # backpressure: in_flight = published - consumer-acked - while channel.in_flight_remote() >= in_flight_high_watermark: + while _infl >= in_flight_high_watermark: now = time.perf_counter() if ( progress_interval > 0 diff --git a/tests/test_runtime/test_disagg_multiserver.py b/tests/test_runtime/test_disagg_multiserver.py index 056a8b687..6b4c32e0d 100644 --- a/tests/test_runtime/test_disagg_multiserver.py +++ b/tests/test_runtime/test_disagg_multiserver.py @@ -22,6 +22,7 @@ import threading import time import unittest +from unittest.mock import patch from specforge.inference.adapters.server_capture import SGLangServerCaptureAdapter from specforge.launch import build_disagg_online_producer @@ -121,6 +122,21 @@ def test_two_servers_disjoint_and_complete(self): self.assertEqual(by_server.get("http://server0:30000"), seen0) self.assertEqual(by_server.get("http://server1:30001"), seen1) + def test_producer_profiling_handles_first_round_without_backpressure(self): + backend = _FakeMooncakeStore() + stub = _StubCaptureServer(backend) + store = MooncakeFeatureStore(store=backend, store_id="run0") + channel = StreamingRefChannel(os.path.join(self._workdir(), "refs.jsonl")) + _workers, drive = _build( + [_adapter(store, stub)], _prompts(2), store, channel, lease=2 + ) + + with patch.dict(os.environ, {"PROFILE_PRODUCER": "1"}): + produced = drive() + + self.assertEqual(produced, 2) + self.assertTrue(channel.is_closed()) + def test_prompt_epochs_republish_with_unique_sample_ids(self): backend = _FakeMooncakeStore() stub = _StubCaptureServer(backend) diff --git a/tests/test_utils/test_dflash_losses.py b/tests/test_utils/test_dflash_losses.py index c1a3b3f14..2d2cedba3 100644 --- a/tests/test_utils/test_dflash_losses.py +++ b/tests/test_utils/test_dflash_losses.py @@ -442,6 +442,28 @@ def test_dspark_ce_only_uses_next_token_labels_and_contiguous_mask(self): want = (neg_log_q * weights).sum() / (weights.sum() + 1e-6) torch.testing.assert_close(loss, want, rtol=0, atol=1e-6) + def test_dspark_ce_only_skips_target_distribution(self): + target_logits = torch.randn_like(self.logits) + model = _make_dspark_model( + self.logits, + self.anchors, + self.keep_mask, + lm_head=_DualFixedHead(self.logits, target_logits).double(), + dspark_ce_loss_alpha=1.0, + dspark_l1_loss_alpha=0.0, + dspark_confidence_head_alpha=0.0, + ) + + with patch.object(torch, "softmax", side_effect=AssertionError("unexpected")): + loss, _accuracy, _metrics = model( + input_ids=self.input_ids, + hidden_states=self.hidden_states, + loss_mask=self.loss_mask, + target_last_hidden_states=torch.zeros_like(self.hidden_states), + ) + + self.assertTrue(torch.isfinite(loss)) + def test_dspark_l1_and_confidence_match_reference(self): torch.manual_seed(321) target_logits = torch.randn_like(self.logits) From 02bf0a7d0d669f9923650fae2e35ced6f40e85f1 Mon Sep 17 00:00:00 2001 From: jiapingW <1969554248@qq.com> Date: Fri, 10 Jul 2026 13:37:47 +0800 Subject: [PATCH 21/24] fix lint and grad norm clip bugs[PR663] --- specforge/core/dflash.py | 6 +- specforge/optimizer.py | 41 +++++- specforge/training/backend.py | 25 ++-- tests/test_optimizer/__init__.py | 0 .../test_bf16_optimizer_clip_grad_norm.py | 133 ++++++++++++++++++ 5 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 tests/test_optimizer/__init__.py create mode 100644 tests/test_optimizer/test_bf16_optimizer_clip_grad_norm.py diff --git a/specforge/core/dflash.py b/specforge/core/dflash.py index 8e4305796..9765652ec 100644 --- a/specforge/core/dflash.py +++ b/specforge/core/dflash.py @@ -836,9 +836,9 @@ def _dspark_loss_weight_mask( ) -> torch.Tensor: loss_weight_mask = eval_mask.to(torch.float32) if self.loss_decay_gamma is not None and self.loss_decay_gamma > 0: - positions = torch.arange( - self.block_size, device=eval_mask.device - ).view(1, 1, -1) + positions = torch.arange(self.block_size, device=eval_mask.device).view( + 1, 1, -1 + ) decay_weights = torch.exp(-positions.float() / float(self.loss_decay_gamma)) loss_weight_mask = loss_weight_mask * decay_weights return loss_weight_mask diff --git a/specforge/optimizer.py b/specforge/optimizer.py index 76eba8f29..1d1cfd421 100644 --- a/specforge/optimizer.py +++ b/specforge/optimizer.py @@ -1,6 +1,7 @@ import logging import torch +import torch.distributed as dist from specforge.lr_scheduler import CosineAnnealingWarmupLR from specforge.utils import print_on_rank0 @@ -34,19 +35,57 @@ def __init__( self.fp32_params, lr=lr, weight_decay=weight_decay ) self.last_grad_norm = None + self._grad_norm_process_group = None + self._reduce_grad_norm_across_ranks = True self.scheduler = CosineAnnealingWarmupLR( self.optimizer, total_steps=total_steps, warmup_steps=int(warmup_ratio * total_steps), ) + def configure_grad_norm_reduction( + self, *, process_group=None, enabled: bool = True + ) -> None: + """Configure the group that owns disjoint gradient shards. + + FSDP backends disable the reduction for replicated/NO_SHARD parameters. + """ + self._grad_norm_process_group = process_group + self._reduce_grad_norm_across_ranks = enabled + + def _clip_grad_norm(self): + """Clip all FSDP shards with one global L2-norm coefficient.""" + grads = [mp.grad for mp in self.fp32_params if mp.grad is not None] + if grads: + total_norm_sq = torch.stack([grad.square().sum() for grad in grads]).sum() + else: + device = self.fp32_params[0].device if self.fp32_params else "cpu" + total_norm_sq = torch.zeros((), dtype=torch.float32, device=device) + + if ( + self._reduce_grad_norm_across_ranks + and dist.is_available() + and dist.is_initialized() + ): + dist.all_reduce( + total_norm_sq, + op=dist.ReduceOp.SUM, + group=self._grad_norm_process_group, + ) + + total_norm = total_norm_sq.sqrt() + clip_coef = torch.clamp(self.max_grad_norm / (total_norm + 1e-6), max=1.0) + for grad in grads: + grad.mul_(clip_coef) + return total_norm + def step(self): with torch.no_grad(): for p, mp in zip(self.model_params, self.fp32_params): mp.grad = ( p.grad.detach().to(torch.float32) if p.grad is not None else None ) - grad_norm = torch.nn.utils.clip_grad_norm_(self.fp32_params, self.max_grad_norm) + grad_norm = self._clip_grad_norm() self.last_grad_norm = grad_norm.detach() self.optimizer.step() self.optimizer.zero_grad() diff --git a/specforge/training/backend.py b/specforge/training/backend.py index 2845432d3..c52f36cb7 100644 --- a/specforge/training/backend.py +++ b/specforge/training/backend.py @@ -191,10 +191,23 @@ def prepare_model( if self._optimizer_factory is not None: target = optimizer_target if optimizer_target is not None else self.module self.optimizer = self._optimizer_factory(target) + self._configure_optimizer_grad_norm() return self.module def set_optimizer(self, optimizer) -> None: self.optimizer = optimizer + self._configure_optimizer_grad_norm() + + def _configure_optimizer_grad_norm(self) -> None: + configure = getattr(self.optimizer, "configure_grad_norm_reduction", None) + if configure is not None: + configure( + process_group=self.parallel_config.fsdp_process_group, + enabled=( + self._wrapped + and self.parallel_config.sharding_strategy != "NO_SHARD" + ), + ) def backward(self, loss: torch.Tensor, *, is_boundary: bool = True) -> None: """Backward one micro-step. FSDP reduce-scatters grads on every backward, @@ -208,20 +221,12 @@ def backward(self, loss: torch.Tensor, *, is_boundary: bool = True) -> None: loss.backward() def step(self) -> Optional[torch.Tensor]: - """Optimizer step + the distributed grad-norm reduction (run_backward_and_update).""" + """Run the optimizer step, which clips and returns the global grad norm.""" if self.optimizer is None: raise RuntimeError( "FSDPTrainingBackend.step called before optimizer is set" ) - grad_norm = self.optimizer.step() - if grad_norm is not None and dist.is_initialized(): - grad_norm = grad_norm.detach().float() - if torch.cuda.is_available(): - grad_norm = grad_norm.to(torch.cuda.current_device()) - grad_norm = grad_norm.pow(2) - dist.all_reduce(grad_norm, op=dist.ReduceOp.SUM) - grad_norm = grad_norm.sqrt() - return grad_norm + return self.optimizer.step() def state_dict(self) -> dict: """Full training state ``{"model", "optimizer", "rng"}`` for resume. diff --git a/tests/test_optimizer/__init__.py b/tests/test_optimizer/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_optimizer/test_bf16_optimizer_clip_grad_norm.py b/tests/test_optimizer/test_bf16_optimizer_clip_grad_norm.py new file mode 100644 index 000000000..40e4abec7 --- /dev/null +++ b/tests/test_optimizer/test_bf16_optimizer_clip_grad_norm.py @@ -0,0 +1,133 @@ +import os +import tempfile +import unittest + +import torch +import torch.distributed as dist +import torch.multiprocessing as mp + +from specforge.optimizer import BF16Optimizer +from specforge.training.backend import FSDPTrainingBackend, ParallelConfig + + +def _make_optimizer(seed=0): + torch.manual_seed(seed) + model = torch.nn.Linear(8, 8, bias=False) + return model, BF16Optimizer(model, lr=1e-3, max_grad_norm=0.5) + + +class TestClipGradNormSingleProcess(unittest.TestCase): + def test_matches_torch_reference(self): + model, optimizer = _make_optimizer() + grad = torch.randn(8, 8) + + reference = [ + param.detach().clone().requires_grad_(True) for param in model.parameters() + ] + for param in reference: + param.grad = grad.clone() + expected_norm = torch.nn.utils.clip_grad_norm_( + reference, optimizer.max_grad_norm + ) + + for master in optimizer.fp32_params: + master.grad = grad.clone() + actual_norm = optimizer._clip_grad_norm() + + torch.testing.assert_close(actual_norm, expected_norm) + for master, expected in zip(optimizer.fp32_params, reference): + torch.testing.assert_close(master.grad, expected.grad) + + def test_step_returns_pre_clip_norm(self): + model, optimizer = _make_optimizer() + for param in model.parameters(): + param.grad = torch.full_like(param, 0.1) + + norm = optimizer.step() + + expected = torch.full((8, 8), 0.1).norm() + torch.testing.assert_close(norm, expected) + + def test_backend_configures_sharded_and_replicated_optimizers(self): + class RecordingOptimizer: + def configure_grad_norm_reduction(self, **kwargs): + self.config = kwargs + + process_group = object() + backend = FSDPTrainingBackend( + ParallelConfig( + sharding_strategy="SHARD_GRAD_OP", + fsdp_process_group=process_group, + ) + ) + backend._wrapped = True + sharded = RecordingOptimizer() + backend.set_optimizer(sharded) + self.assertIs(sharded.config["process_group"], process_group) + self.assertTrue(sharded.config["enabled"]) + + backend._wrapped = False + replicated = RecordingOptimizer() + backend.set_optimizer(replicated) + self.assertFalse(replicated.config["enabled"]) + + +def _distributed_worker(rank, world_size, init_file, results): + dist.init_process_group( + backend="gloo", + init_method=f"file://{init_file}", + rank=rank, + world_size=world_size, + ) + try: + _, optimizer = _make_optimizer() + grad_value = 1.0 if rank == 0 else 2.0 + for master in optimizer.fp32_params: + master.grad = torch.full_like(master, grad_value) + + norm = optimizer._clip_grad_norm() + results[rank] = ( + norm.item(), + optimizer.fp32_params[0].grad.flatten()[0].item(), + ) + + optimizer.configure_grad_norm_reduction(enabled=False) + for master in optimizer.fp32_params: + master.grad = torch.ones_like(master) + replicated_norm = optimizer._clip_grad_norm() + results[f"replicated-{rank}"] = ( + replicated_norm.item(), + optimizer.fp32_params[0].grad.flatten()[0].item(), + ) + finally: + dist.destroy_process_group() + + +class TestClipGradNormDistributed(unittest.TestCase): + def test_disjoint_shards_use_same_global_clip_coefficient(self): + world_size = 2 + with tempfile.TemporaryDirectory() as tmpdir: + init_file = os.path.join(tmpdir, "init") + manager = mp.Manager() + results = manager.dict() + mp.spawn( + _distributed_worker, + args=(world_size, init_file, results), + nprocs=world_size, + join=True, + ) + + global_norm = (64 * 1.0**2 + 64 * 2.0**2) ** 0.5 + clip_coef = 0.5 / (global_norm + 1e-6) + for rank, grad_value in ((0, 1.0), (1, 2.0)): + norm, clipped_first = results[rank] + self.assertAlmostEqual(norm, global_norm, places=4) + self.assertAlmostEqual(clipped_first, grad_value * clip_coef, places=6) + + replicated_norm, replicated_first = results[f"replicated-{rank}"] + self.assertAlmostEqual(replicated_norm, 8.0, places=6) + self.assertAlmostEqual(replicated_first, 0.5 / (8.0 + 1e-6), places=6) + + +if __name__ == "__main__": + unittest.main() From 69e18e6bb7358ba6dddf0554629c2d0a04fed82a Mon Sep 17 00:00:00 2001 From: jianuo-huang <1170180988@qq.com> Date: Sun, 12 Jul 2026 23:44:01 +0800 Subject: [PATCH 22/24] feat(data): add validated Qwen ShareGPT regeneration pipelines Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> fix lint and update filename --- docs/basic_usage/data_preparation.md | 90 ++++ .../run_qwen3_8b_sharegpt_non_reasoning.sh | 5 + .../run_qwen_sharegpt_regeneration.sh | 100 ++++ scripts/conversation_validation.py | 61 +++ scripts/regenerate_train_data.py | 133 ++++- scripts/validate_regenerated_data.py | 186 +++++++ .../test_validate_regenerated_data.py | 478 ++++++++++++++++++ 7 files changed, 1037 insertions(+), 16 deletions(-) create mode 100755 examples/data_regeneration/run_qwen3_8b_sharegpt_non_reasoning.sh create mode 100755 examples/data_regeneration/run_qwen_sharegpt_regeneration.sh create mode 100644 scripts/conversation_validation.py create mode 100755 scripts/validate_regenerated_data.py create mode 100644 tests/test_scripts/test_validate_regenerated_data.py diff --git a/docs/basic_usage/data_preparation.md b/docs/basic_usage/data_preparation.md index 294e0bc13..d30b70b4b 100644 --- a/docs/basic_usage/data_preparation.md +++ b/docs/basic_usage/data_preparation.md @@ -59,6 +59,96 @@ For reasoning models, add `--reasoning save` to store `reasoning_content` in the For maximum performance, we recommend to scale the number of GPUs to regenerate the dataset in data parallel mode. To do this, you can simply add more server addresses to the `--server-address` argument, e.g. `--server-address localhost:30000 localhost:30001 localhost:30002 localhost:30003`. +### Qwen ShareGPT recipes + +The Qwen recipe wraps regeneration, complete-row accounting, and output +validation. It expects one or more SGLang servers to already be running; it does +not launch or stop the servers itself. + +For Qwen3-8B non-reasoning regeneration, start a target server in one terminal: + +```bash +python -m sglang.launch_server \ + --model-path Qwen/Qwen3-8B \ + --tp-size 1 \ + --dtype bfloat16 \ + --mem-fraction-static 0.8 \ + --host 0.0.0.0 \ + --port 30000 +``` + +After `curl --fail http://127.0.0.1:30000/health` succeeds, run the recipe from +the SpecForge repository root: + +```bash +MODEL_PROFILE=qwen3-8b \ +INPUT_FILE=./cache/dataset/sharegpt_train.jsonl \ +OUTPUT_FILE=./cache/dataset/sharegpt_train_regen_qwen3_8b_temperature0_non_reasoning.jsonl \ +SERVER_ADDRESSES="localhost:30000" \ +bash examples/data_regeneration/run_qwen_sharegpt_regeneration.sh +``` + +This profile sets temperature to zero, disables thinking through +`chat_template_kwargs.enable_thinking=false`, and rejects non-empty structured +reasoning in successful rows. + +For Qwen3.6-27B structured-reasoning regeneration, start SGLang with the Qwen3 +reasoning parser so the OpenAI-compatible response includes +`reasoning_content`: + +```bash +python -m sglang.launch_server \ + --model-path Qwen/Qwen3.6-27B \ + --tp-size 1 \ + --dtype bfloat16 \ + --mem-fraction-static 0.8 \ + --reasoning-parser qwen3 \ + --host 0.0.0.0 \ + --port 30000 +``` + +After `curl --fail http://127.0.0.1:30000/health` succeeds, run: + +```bash +MODEL_PROFILE=qwen3.6-27b \ +INPUT_FILE=./cache/dataset/sharegpt_train.jsonl \ +OUTPUT_FILE=./cache/dataset/sharegpt_train_regen_qwen3.6-27b_temperature0_reasoning.jsonl \ +SERVER_ADDRESSES="localhost:30000" \ +bash examples/data_regeneration/run_qwen_sharegpt_regeneration.sh +``` + +The default maximum completion lengths are 4096 tokens for Qwen3-8B and 32768 +tokens for Qwen3.6-27B. The server context length must accommodate both the +rendered prompt and `MAX_TOKENS`; override `MAX_TOKENS` when using a shorter +server context. + +`INPUT_FILE` may point to another dataset without changing the recipe as long +as it is JSONL in the conversation format documented below, with a non-empty +string `id` and alternating `user`/`assistant` messages. Always choose a fresh +`OUTPUT_FILE`: the recipe deliberately refuses to overwrite or resume an +existing run. + +For an output such as `dataset_regen.jsonl`, the recipe produces: + +- `dataset_regen.jsonl`: successful regenerated training rows; +- `dataset_regen_error.jsonl`: request or generation errors; +- `dataset_regen_skipped.jsonl`: schema-invalid inputs and outputs excluded by the + selected reasoning contract. + +The run passes accounting only when +`success + error + skipped == input`. It prints the success fraction without a +fixed minimum threshold, then strictly validates every successful row, +including its conversation structure, reasoning contract, and absence of raw +`` markers. + +To distribute regeneration across independently launched servers, provide all +addresses as a space-separated list, for example: + +```bash +SERVER_ADDRESSES="localhost:30000 localhost:30010" \ +CONCURRENCY=64 \ +bash examples/data_regeneration/run_qwen_sharegpt_regeneration.sh +``` ## 🤩 Prepare your own dataset diff --git a/examples/data_regeneration/run_qwen3_8b_sharegpt_non_reasoning.sh b/examples/data_regeneration/run_qwen3_8b_sharegpt_non_reasoning.sh new file mode 100755 index 000000000..11b156d27 --- /dev/null +++ b/examples/data_regeneration/run_qwen3_8b_sharegpt_non_reasoning.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODEL_PROFILE=qwen3-8b exec bash "${SCRIPT_DIR}/run_qwen_sharegpt_regeneration.sh" "$@" diff --git a/examples/data_regeneration/run_qwen_sharegpt_regeneration.sh b/examples/data_regeneration/run_qwen_sharegpt_regeneration.sh new file mode 100755 index 000000000..4d1c39d31 --- /dev/null +++ b/examples/data_regeneration/run_qwen_sharegpt_regeneration.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${ROOT_DIR}" + +MODEL_PROFILE="${MODEL_PROFILE:-qwen3-8b}" +case "${MODEL_PROFILE}" in + qwen3-8b) + DEFAULT_MODEL_PATH="Qwen/Qwen3-8B" + DEFAULT_INPUT_FILE="${ROOT_DIR}/cache/dataset/sharegpt_train.jsonl" + DEFAULT_OUTPUT_FILE="${ROOT_DIR}/cache/dataset/sharegpt_train_regen_qwen3_8b_temperature0_non_reasoning.jsonl" + REASONING_MODE=disable + DEFAULT_MAX_TOKENS=4096 + VALIDATION_MODE=(--expect-non-reasoning) + ;; + qwen3.6-27b) + DEFAULT_MODEL_PATH="Qwen/Qwen3.6-27B" + DEFAULT_INPUT_FILE="${ROOT_DIR}/cache/dataset/sharegpt_train.jsonl" + DEFAULT_OUTPUT_FILE="${ROOT_DIR}/cache/dataset/sharegpt_train_regen_qwen3.6-27b_temperature0_reasoning.jsonl" + REASONING_MODE=save + DEFAULT_MAX_TOKENS=32768 + VALIDATION_MODE=(--expect-reasoning) + ;; + *) + echo "Unsupported MODEL_PROFILE: ${MODEL_PROFILE}" >&2 + echo "Expected qwen3-8b or qwen3.6-27b." >&2 + exit 1 + ;; +esac + +PYTHON="${PYTHON:-python}" +MODEL_PATH="${MODEL_PATH:-${DEFAULT_MODEL_PATH}}" +INPUT_FILE="${INPUT_FILE:-${DEFAULT_INPUT_FILE}}" +OUTPUT_FILE="${OUTPUT_FILE:-${DEFAULT_OUTPUT_FILE}}" +SERVER_ADDRESSES="${SERVER_ADDRESSES:-localhost:30000}" +CONCURRENCY="${CONCURRENCY:-64}" +MAX_TOKENS="${MAX_TOKENS:-${DEFAULT_MAX_TOKENS}}" + +if [[ ! -f "${INPUT_FILE}" ]]; then + echo "Input dataset does not exist: ${INPUT_FILE}" >&2 + exit 1 +fi +if [[ "${OUTPUT_FILE}" != *.jsonl ]]; then + echo "OUTPUT_FILE must end in .jsonl: ${OUTPUT_FILE}" >&2 + exit 1 +fi + +ERROR_FILE="${OUTPUT_FILE%.jsonl}_error.jsonl" +SKIPPED_FILE="${OUTPUT_FILE%.jsonl}_skipped.jsonl" +for path in "${OUTPUT_FILE}" "${ERROR_FILE}" "${SKIPPED_FILE}"; do + if [[ -e "${path}" ]]; then + echo "Refusing to reuse an existing regeneration output: ${path}" >&2 + echo "Choose a fresh OUTPUT_FILE or remove the old run explicitly." >&2 + exit 1 + fi +done + +mkdir -p "$(dirname "${OUTPUT_FILE}")" +read -r -a server_args <<< "${SERVER_ADDRESSES}" + +"${PYTHON}" scripts/regenerate_train_data.py \ + --model "${MODEL_PATH}" \ + --reasoning "${REASONING_MODE}" \ + --temperature 0 \ + --concurrency "${CONCURRENCY}" \ + --max-tokens "${MAX_TOKENS}" \ + --server-address "${server_args[@]}" \ + --input-file-path "${INPUT_FILE}" \ + --output-file-path "${OUTPUT_FILE}" + +INPUT_ROWS=$(awk 'END {print NR}' "${INPUT_FILE}") +SUCCESS_ROWS=$(awk 'END {print NR}' "${OUTPUT_FILE}") +ERROR_ROWS=$(awk 'END {print NR}' "${ERROR_FILE}") +SKIPPED_ROWS=$(awk 'END {print NR}' "${SKIPPED_FILE}") + +"${PYTHON}" - \ + "${INPUT_ROWS}" "${SUCCESS_ROWS}" "${ERROR_ROWS}" "${SKIPPED_ROWS}" <<'PY' +import sys + +input_rows, success_rows, error_rows, skipped_rows = map(int, sys.argv[1:5]) +completed_rows = success_rows + error_rows + skipped_rows + +print(f"input rows: {input_rows}") +print(f"success rows: {success_rows}") +print(f"error rows: {error_rows}") +print(f"skipped rows: {skipped_rows}") +if completed_rows != input_rows: + raise SystemExit( + "regeneration did not account for every input row: " + f"completed={completed_rows}, input={input_rows}" + ) +success_fraction = success_rows / completed_rows if completed_rows else 0.0 +print(f"success fraction: {success_fraction:.2%}") +PY + +"${PYTHON}" scripts/validate_regenerated_data.py \ + --data-path "${OUTPUT_FILE}" \ + "${VALIDATION_MODE[@]}" \ + --strict-think-markers diff --git a/scripts/conversation_validation.py b/scripts/conversation_validation.py new file mode 100644 index 000000000..bde5550c7 --- /dev/null +++ b/scripts/conversation_validation.py @@ -0,0 +1,61 @@ +"""Shared conversation validation helpers for data regeneration scripts.""" + +from typing import Any + + +def has_think_marker(content: str) -> bool: + lowered = content.lower() + return "" in lowered or "" in lowered + + +def validate_conversation( + messages: Any, + *, + error_style: str = "validation", +) -> str | None: + if not isinstance(messages, list) or not messages: + if error_style == "regeneration": + return "Missing or empty conversations list" + return "conversations must be a non-empty list" + + expected_role = "user" + saw_user = False + for index, message in enumerate(messages): + if not isinstance(message, dict): + if error_style == "regeneration": + return f"Invalid message at position {index}: expected object" + return f"message {index} must be an object" + role = message.get("role") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + if error_style == "regeneration": + return ( + f"Invalid message content at position {index}: " + "expected non-empty string" + ) + return f"message {index} content must be a non-empty string" + + if role == "system" and not saw_user: + continue + if role not in {"user", "assistant"}: + if error_style == "regeneration": + return f"Invalid message role: {role}" + return f"message {index} has unsupported role {role!r}" + if role != expected_role: + if error_style == "regeneration": + if not saw_user and role == "assistant": + return "Data starts with an assistant message" + return ( + f"Invalid conversation role order at position {index}: " + f"expected {expected_role}, got {role}" + ) + return f"message {index} expected role {expected_role!r}, got {role!r}" + + saw_user = True + expected_role = "assistant" if role == "user" else "user" + + if not saw_user: + if error_style == "regeneration": + return "Data does not contain a user message" + return "conversation has no user message" + return None diff --git a/scripts/regenerate_train_data.py b/scripts/regenerate_train_data.py index 41e20b552..28fe6cd7a 100644 --- a/scripts/regenerate_train_data.py +++ b/scripts/regenerate_train_data.py @@ -40,6 +40,35 @@ from openai import OpenAI from tqdm import tqdm +try: + from scripts.conversation_validation import has_think_marker, validate_conversation +except ModuleNotFoundError: + from conversation_validation import has_think_marker, validate_conversation + + +def validate_regen_input(data: Any) -> str | None: + """Return why a ShareGPT row cannot be regenerated, or ``None``.""" + if not isinstance(data, dict): + return "Expected a JSON object" + + return validate_conversation( + data.get("conversations"), + error_style="regeneration", + ) + + +def set_skipped(data: Any, error: str) -> Dict[str, Any]: + if not isinstance(data, dict): + return {"status": "skipped", "error": error, "data": data} + data["status"] = "skipped" + data["error"] = error + return data + + +def count_lines(path: str) -> int: + with open(path, encoding="utf-8") as handle: + return sum(1 for _ in handle) + def parse_arguments(): """Parse command line arguments""" @@ -174,9 +203,18 @@ def compute_context_length(conversations: List[Dict[str, Any]]) -> int: def build_query_kwargs(args, messages, max_tokens=None): effective_max_tokens = max_tokens if max_tokens is not None else args.max_tokens + query_messages = messages + if args.reasoning == "save": + query_messages = [] + for message in messages: + query_message = dict(message) + if query_message.get("role") == "assistant": + query_message.pop("reasoning_content", None) + query_messages.append(query_message) + query_kwargs = dict( model=args.model, - messages=messages, + messages=query_messages, max_tokens=effective_max_tokens, temperature=args.temperature, stream=False, @@ -190,6 +228,8 @@ def build_query_kwargs(args, messages, max_tokens=None): extra_body["top_k"] = args.top_k if args.reasoning == "disable": extra_body["chat_template_kwargs"] = {"enable_thinking": False} + elif args.reasoning == "save": + extra_body["chat_template_kwargs"] = {"enable_thinking": True} if extra_body: query_kwargs["extra_body"] = extra_body if args.is_gpt_oss: @@ -232,14 +272,47 @@ def call_sglang( data["error"] = str(e) return data response_text = resp.choices[0].message.content + if args.reasoning == "disable" and ( + not isinstance(response_text, str) + or not response_text.strip() + or has_think_marker(response_text) + ): + return set_skipped( + data, + "Non-reasoning assistant response is empty or contains a thinking marker", + ) resp_msg = { "role": "assistant", "content": response_text, } if args.reasoning == "save": - resp_msg["reasoning_content"] = resp.choices[ - 0 - ].message.reasoning_content + response_message = resp.choices[0].message + reasoning_content = getattr(response_message, "reasoning_content", None) + if reasoning_content is None: + model_extra = getattr(response_message, "model_extra", None) + if isinstance(model_extra, dict): + reasoning_content = model_extra.get("reasoning_content") + if max_tokens is None and ( + not isinstance(response_text, str) + or not response_text.strip() + or not isinstance(reasoning_content, str) + or not reasoning_content.strip() + ): + data["status"] = "error" + data["error"] = ( + "Reasoning generation requires non-empty assistant content " + "and reasoning_content" + ) + return data + if max_tokens is None and ( + has_think_marker(response_text) + or has_think_marker(reasoning_content) + ): + return set_skipped( + data, + "Reasoning response contains a residual thinking marker", + ) + resp_msg["reasoning_content"] = reasoning_content regenerated_messages.append(resp_msg) else: data["status"] = "error" @@ -271,20 +344,25 @@ def main(): print(f" Output file: {args.output_file_path}") print(f" Resume mode: {args.resume}") print("-" * 50) - total_lines = sum(1 for _ in open(args.input_file_path)) + total_lines = count_lines(args.input_file_path) skip_lines = 0 error_file_path = args.output_file_path.replace(".jsonl", "_error.jsonl") + skipped_file_path = args.output_file_path.replace(".jsonl", "_skipped.jsonl") if args.resume and os.path.exists(args.output_file_path): - existing_success = sum(1 for _ in open(args.output_file_path)) + existing_success = count_lines(args.output_file_path) existing_error = 0 if os.path.exists(error_file_path): - existing_error = sum(1 for _ in open(error_file_path)) - skip_lines = existing_success + existing_error + existing_error = count_lines(error_file_path) + existing_skipped = 0 + if os.path.exists(skipped_file_path): + existing_skipped = count_lines(skipped_file_path) + skip_lines = existing_success + existing_error + existing_skipped print(f"Resume mode enabled:") print(f" Found {existing_success} successful samples in output file") print(f" Found {existing_error} error samples in error file") + print(f" Found {existing_skipped} skipped samples in skipped file") print(f" Skipping first {skip_lines} input samples") print("-" * 50) @@ -304,7 +382,7 @@ def main(): dummy_data, max_tokens=1, ) - if result is not None: + if result is not None and result.get("status") == "success": valid_server_addresses.append(server_address) else: print(f"Server {server_address} is not available") @@ -330,12 +408,15 @@ def main(): context_token_max = 0 success_samples = 0 error_samples = 0 + skipped_samples = 0 + submitted_samples = 0 # Create progress bar with ( open(args.input_file_path, "r") as input_file, open(args.output_file_path, file_mode) as output_file_handle, open(error_file_path, file_mode) as error_file_handle, + open(skipped_file_path, file_mode, encoding="utf-8") as skipped_file_handle, ): executor = ThreadPoolExecutor( max_workers=args.concurrency * len(valid_server_addresses) @@ -353,13 +434,19 @@ def main(): print(f"Resuming from sample {skip_lines + 1}") for line in input_file: - if ( - args.num_samples is not None - and success_samples + error_samples >= args.num_samples - ): + if args.num_samples is not None and submitted_samples >= args.num_samples: break data = json.loads(line.strip()) + invalid_reason = validate_regen_input(data) + if invalid_reason is not None: + skipped_file_handle.write( + json.dumps(set_skipped(data, invalid_reason), ensure_ascii=False) + + "\n" + ) + skipped_samples += 1 + pbar.update(1) + continue # find server address with the least waiting requests server_address = valid_server_addresses[start_server_index] @@ -378,6 +465,11 @@ def main(): json.dumps(regen_data, ensure_ascii=False) + "\n" ) error_samples += 1 + elif regen_data["status"] == "skipped": + skipped_file_handle.write( + json.dumps(regen_data, ensure_ascii=False) + "\n" + ) + skipped_samples += 1 else: ctx_len = compute_context_length( regen_data.get("conversations", []) @@ -406,6 +498,7 @@ def main(): data, ) waiting_queue[server_address].append(req_future) + submitted_samples += 1 pbar.update(1) # deal with all the remaining requests @@ -417,6 +510,11 @@ def main(): json.dumps(regen_data, ensure_ascii=False) + "\n" ) error_samples += 1 + elif regen_data["status"] == "skipped": + skipped_file_handle.write( + json.dumps(regen_data, ensure_ascii=False) + "\n" + ) + skipped_samples += 1 else: ctx_len = compute_context_length( regen_data.get("conversations", []) @@ -444,17 +542,20 @@ def main(): else: print("No successful examples to compute context length statistics.") - total_processed = success_samples + error_samples + total_processed = success_samples + error_samples + skipped_samples if skip_lines > 0: print(f"\nResume processing completed!") print(f" Previously processed: {skip_lines}") print( - f" Newly processed: {total_processed} ({success_samples} success, {error_samples} failed)" + f" Newly processed: {total_processed} " + f"({success_samples} success, {error_samples} failed, " + f"{skipped_samples} skipped)" ) print(f" Total: {skip_lines + total_processed}") else: print( - f"\nProcessing completed! {success_samples} samples regenerated, {error_samples} samples failed." + f"\nProcessing completed! {success_samples} samples regenerated, " + f"{error_samples} samples failed, {skipped_samples} samples skipped." ) diff --git a/scripts/validate_regenerated_data.py b/scripts/validate_regenerated_data.py new file mode 100755 index 000000000..ea8d065d8 --- /dev/null +++ b/scripts/validate_regenerated_data.py @@ -0,0 +1,186 @@ +"""Validate regenerated ShareGPT JSONL before training.""" + +import argparse +import json +import warnings +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable + +try: + from scripts.conversation_validation import has_think_marker, validate_conversation +except ModuleNotFoundError: + from conversation_validation import has_think_marker, validate_conversation + + +@dataclass(frozen=True) +class ValidationSummary: + rows: int + assistant_messages: int + duplicate_rows: int + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate regenerated ShareGPT JSONL before training." + ) + parser.add_argument("--data-path", required=True, type=Path) + reasoning_group = parser.add_mutually_exclusive_group() + reasoning_group.add_argument( + "--expect-non-reasoning", + action="store_true", + help="Reject non-empty assistant reasoning_content.", + ) + reasoning_group.add_argument( + "--expect-reasoning", + action="store_true", + help="Require non-empty assistant reasoning_content.", + ) + parser.add_argument( + "--strict-think-markers", + action="store_true", + help="Reject and in assistant content or reasoning_content.", + ) + return parser.parse_args() + + +def iter_jsonl(path: Path) -> Iterable[tuple[int, Dict[str, Any]]]: + with path.open(encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + try: + row = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"line {line_number}: invalid JSON: {exc}") from exc + if not isinstance(row, dict): + raise ValueError(f"line {line_number}: expected a JSON object") + yield line_number, row + + +def validate_row( + row: Dict[str, Any], + *, + expect_non_reasoning: bool, + expect_reasoning: bool = False, + strict_think_markers: bool, +) -> int: + """Validate one generated training row and return its assistant count.""" + row_id = row.get("id") + if not isinstance(row_id, str) or not row_id.strip(): + raise ValueError("id must be a non-empty string") + if row.get("status") != "success": + raise ValueError("status must be 'success'") + + messages = row.get("conversations") + conversation_error = validate_conversation(messages) + if conversation_error is not None: + raise ValueError(conversation_error) + + assistant_count = 0 + for index, message in enumerate(messages): + role = message.get("role") + if role != "assistant": + continue + + content = message["content"] + assistant_count += 1 + reasoning = message.get("reasoning_content") + if reasoning is not None and not isinstance(reasoning, str): + raise ValueError( + f"assistant message {index} reasoning_content must be a string or null" + ) + if expect_non_reasoning and reasoning and reasoning.strip(): + raise ValueError( + f"assistant message {index} has non-empty reasoning_content" + ) + if expect_reasoning and ( + not isinstance(reasoning, str) or not reasoning.strip() + ): + raise ValueError(f"assistant message {index} has empty reasoning_content") + if strict_think_markers and has_think_marker(content): + raise ValueError(f"assistant message {index} contains a thinking marker") + if ( + strict_think_markers + and isinstance(reasoning, str) + and has_think_marker(reasoning) + ): + raise ValueError( + f"assistant message {index} reasoning_content contains a thinking marker" + ) + + if assistant_count == 0: + raise ValueError("conversation has no assistant message") + if messages[-1].get("role") != "assistant": + raise ValueError("conversation must end with an assistant message") + return assistant_count + + +def validate_dataset( + path: Path, + *, + expect_non_reasoning: bool = False, + expect_reasoning: bool = False, + strict_think_markers: bool = False, +) -> ValidationSummary: + if expect_non_reasoning and expect_reasoning: + raise ValueError( + "expect_non_reasoning and expect_reasoning are mutually exclusive" + ) + seen_ids = set() + duplicate_rows = 0 + rows = 0 + assistant_messages = 0 + + for line_number, row in iter_jsonl(path): + try: + assistant_messages += validate_row( + row, + expect_non_reasoning=expect_non_reasoning, + expect_reasoning=expect_reasoning, + strict_think_markers=strict_think_markers, + ) + except ValueError as exc: + raise ValueError(f"line {line_number}: {exc}") from exc + + row_id = row["id"] + if row_id in seen_ids: + duplicate_rows += 1 + else: + seen_ids.add(row_id) + rows += 1 + + if rows == 0: + raise ValueError(f"{path} contains no data rows") + if duplicate_rows: + warnings.warn( + f"found {duplicate_rows} rows with duplicate ids; duplicates are allowed", + stacklevel=2, + ) + return ValidationSummary( + rows=rows, + assistant_messages=assistant_messages, + duplicate_rows=duplicate_rows, + ) + + +def main() -> None: + args = parse_args() + try: + summary = validate_dataset( + args.data_path, + expect_non_reasoning=args.expect_non_reasoning, + expect_reasoning=args.expect_reasoning, + strict_think_markers=args.strict_think_markers, + ) + except (OSError, ValueError) as exc: + raise SystemExit(f"validation failed: {exc}") from exc + + print(f"rows: {summary.rows}") + print(f"assistant messages: {summary.assistant_messages}") + print(f"duplicate rows (allowed): {summary.duplicate_rows}") + print("validation passed") + + +if __name__ == "__main__": + main() diff --git a/tests/test_scripts/test_validate_regenerated_data.py b/tests/test_scripts/test_validate_regenerated_data.py new file mode 100644 index 000000000..dd576c1a2 --- /dev/null +++ b/tests/test_scripts/test_validate_regenerated_data.py @@ -0,0 +1,478 @@ +import json +import os +import subprocess +import sys +import warnings +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import patch + +from scripts.regenerate_train_data import call_sglang +from scripts.regenerate_train_data import main as regenerate_main +from scripts.regenerate_train_data import set_skipped, validate_regen_input +from scripts.validate_regenerated_data import validate_dataset, validate_row + + +def make_row(row_id="row-1", content="answer"): + return { + "id": row_id, + "status": "success", + "conversations": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": content}, + ], + } + + +class TestValidateRegeneratedData(TestCase): + def test_valid_non_reasoning_dataset_allows_duplicate_ids_with_warning(self): + with TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "data.jsonl" + path.write_text( + "".join(json.dumps(make_row("same")) + "\n" for _ in range(2)), + encoding="utf-8", + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + summary = validate_dataset( + path, + expect_non_reasoning=True, + strict_think_markers=True, + ) + + self.assertEqual(summary.rows, 2) + self.assertEqual(summary.assistant_messages, 2) + self.assertEqual(summary.duplicate_rows, 1) + self.assertEqual(len(caught), 1) + self.assertIn("duplicates are allowed", str(caught[0].message)) + + def test_rejects_incomplete_training_conversation(self): + row = make_row() + row["conversations"].append({"role": "user", "content": "follow-up"}) + + with self.assertRaisesRegex(ValueError, "must end with an assistant"): + validate_row( + row, + expect_non_reasoning=True, + strict_think_markers=True, + ) + + def test_rejects_nonempty_reasoning_content(self): + row = make_row() + row["conversations"][-1]["reasoning_content"] = "hidden reasoning" + + with self.assertRaisesRegex(ValueError, "reasoning_content"): + validate_row( + row, + expect_non_reasoning=True, + strict_think_markers=False, + ) + + def test_reasoning_mode_requires_reasoning_content(self): + row = make_row() + + with self.assertRaisesRegex(ValueError, "empty reasoning_content"): + validate_row( + row, + expect_non_reasoning=False, + expect_reasoning=True, + strict_think_markers=True, + ) + + row["conversations"][-1]["reasoning_content"] = "structured reasoning" + self.assertEqual( + validate_row( + row, + expect_non_reasoning=False, + expect_reasoning=True, + strict_think_markers=True, + ), + 1, + ) + + def test_strict_mode_rejects_thinking_markers_in_reasoning(self): + row = make_row() + row["conversations"][-1]["reasoning_content"] = "hidden" + + with self.assertRaisesRegex(ValueError, "reasoning_content"): + validate_row( + row, + expect_non_reasoning=False, + expect_reasoning=True, + strict_think_markers=True, + ) + + def test_strict_mode_rejects_thinking_markers(self): + row = make_row(content="hidden visible") + + with self.assertRaisesRegex(ValueError, "thinking marker"): + validate_row( + row, + expect_non_reasoning=True, + strict_think_markers=True, + ) + + def test_rejects_non_success_rows(self): + row = make_row() + row["status"] = "error" + + with self.assertRaisesRegex(ValueError, "status must be 'success'"): + validate_row( + row, + expect_non_reasoning=False, + strict_think_markers=False, + ) + + +class TestRegenerationGuards(TestCase): + def test_input_precheck_rejects_bad_role_order_and_content(self): + bad_order = { + "conversations": [ + {"role": "user", "content": "one"}, + {"role": "user", "content": "two"}, + ] + } + empty_content = {"conversations": [{"role": "user", "content": " "}]} + + self.assertIn("role order", validate_regen_input(bad_order)) + self.assertIn("non-empty string", validate_regen_input(empty_content)) + + def test_non_object_input_can_be_recorded_as_skipped(self): + skipped = set_skipped([], "Expected a JSON object") + + self.assertEqual(skipped["status"], "skipped") + self.assertEqual(skipped["data"], []) + + def test_disable_reasoning_skips_residual_think_output(self): + args = SimpleNamespace( + model="Qwen/Qwen3-8B", + max_tokens=32, + temperature=0, + top_p=None, + repetition_penalty=None, + top_k=None, + reasoning="disable", + is_gpt_oss=False, + ) + response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content="hiddenanswer") + ) + ] + ) + client = SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace(create=lambda **kwargs: response) + ) + ) + + with patch("scripts.regenerate_train_data.OpenAI", return_value=client): + result = call_sglang( + args, + "localhost:30000", + {"conversations": [{"role": "user", "content": "question"}]}, + ) + + self.assertEqual(result["status"], "skipped") + self.assertIn("thinking marker", result["error"]) + + def test_save_reasoning_sets_contract_and_strips_history_reasoning(self): + args = SimpleNamespace( + model="Qwen/Qwen3.6-27B", + max_tokens=32, + temperature=0, + top_p=None, + repetition_penalty=None, + top_k=None, + reasoning="save", + is_gpt_oss=False, + ) + calls = [] + responses = iter( + [ + SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content="first answer", + reasoning_content="first reasoning", + ) + ) + ] + ), + SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content="second answer", + reasoning_content="second reasoning", + ) + ) + ] + ), + ] + ) + + def create(**kwargs): + calls.append(kwargs) + return next(responses) + + client = SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=create)) + ) + data = { + "conversations": [ + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "old first answer"}, + {"role": "user", "content": "second question"}, + ] + } + + with patch("scripts.regenerate_train_data.OpenAI", return_value=client): + result = call_sglang(args, "localhost:30000", data) + + self.assertEqual(result["status"], "success") + self.assertEqual( + calls[0]["extra_body"]["chat_template_kwargs"]["enable_thinking"], + True, + ) + history_assistant = calls[1]["messages"][1] + self.assertEqual(history_assistant["content"], "first answer") + self.assertNotIn("reasoning_content", history_assistant) + self.assertEqual( + result["conversations"][-1]["reasoning_content"], "second reasoning" + ) + + def test_save_reasoning_rejects_missing_reasoning(self): + args = SimpleNamespace( + model="Qwen/Qwen3.6-27B", + max_tokens=32, + temperature=0, + top_p=None, + repetition_penalty=None, + top_k=None, + reasoning="save", + is_gpt_oss=False, + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="answer"))] + ) + client = SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace(create=lambda **kwargs: response) + ) + ) + + with patch("scripts.regenerate_train_data.OpenAI", return_value=client): + result = call_sglang( + args, + "localhost:30000", + {"conversations": [{"role": "user", "content": "question"}]}, + ) + + self.assertEqual(result["status"], "error") + self.assertIn("reasoning_content", result["error"]) + + def test_num_samples_limits_submitted_jobs_under_concurrency(self): + with TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "input.jsonl" + output_path = Path(tmpdir) / "output.jsonl" + rows = [[]] + [ + { + "id": str(index), + "conversations": [{"role": "user", "content": f"question {index}"}], + } + for index in range(4) + ] + input_path.write_text( + "".join(json.dumps(row) + "\n" for row in rows), + encoding="utf-8", + ) + submitted = 0 + + def fake_call(args, server_address, data, max_tokens=None): + nonlocal submitted + if max_tokens is not None: + return {"status": "success", "conversations": []} + submitted += 1 + return { + **data, + "status": "success", + "conversations": [ + *data["conversations"], + {"role": "assistant", "content": "answer"}, + ], + } + + argv = [ + "regenerate_train_data.py", + "--model", + "Qwen/Qwen3-8B", + "--server-address", + "localhost:30000", + "--input-file-path", + str(input_path), + "--output-file-path", + str(output_path), + "--num-samples", + "2", + "--concurrency", + "8", + ] + with ( + patch("sys.argv", argv), + patch("scripts.regenerate_train_data.call_sglang", fake_call), + ): + regenerate_main() + + skipped_path = Path(str(output_path).replace(".jsonl", "_skipped.jsonl")) + self.assertEqual(submitted, 2) + self.assertEqual(len(output_path.read_text().splitlines()), 2) + self.assertEqual(len(skipped_path.read_text().splitlines()), 1) + + +class TestQwenRegenerationRecipe(TestCase): + def _make_fake_python(self, directory: Path) -> Path: + fake_python = directory / "fake_python" + fake_python.write_text( + f"""#!{sys.executable} +import json +import os +import sys +from pathlib import Path + +args = sys.argv[1:] +if args and args[0].endswith("regenerate_train_data.py"): + def value(flag): + return args[args.index(flag) + 1] + + input_path = Path(value("--input-file-path")) + output_path = Path(value("--output-file-path")) + rows = [json.loads(line) for line in input_path.read_text().splitlines() if line] + error_rows = int(os.environ.get("FAKE_ERROR_ROWS", "0")) + drop_rows = int(os.environ.get("FAKE_DROP_ROWS", "0")) + reasoning = value("--reasoning") + success_end = len(rows) - drop_rows if drop_rows else len(rows) + successes = rows[error_rows:success_end] + output_path.write_text("".join( + json.dumps({{ + **row, + "status": "success", + "conversations": [ + *row["conversations"], + {{ + "role": "assistant", + "content": "answer", + **({{"reasoning_content": "reasoning"}} if reasoning == "save" else {{}}), + }}, + ], + }}) + "\\n" for row in successes + )) + Path(str(output_path).replace(".jsonl", "_error.jsonl")).write_text( + "".join(json.dumps({{"status": "error"}}) + "\\n" for _ in rows[:error_rows]) + ) + Path(str(output_path).replace(".jsonl", "_skipped.jsonl")).write_text("") + Path(os.environ["CAPTURE_ARGS"]).write_text(json.dumps(args)) + raise SystemExit(0) + +os.execv(sys.executable, [sys.executable, *args]) +""", + encoding="utf-8", + ) + fake_python.chmod(0o755) + return fake_python + + def _run_recipe( + self, + tmpdir: str, + *, + error_rows: int = 0, + drop_rows: int = 0, + trailing_newline: bool = True, + entrypoint: str = "run_qwen_sharegpt_regeneration.sh", + ): + directory = Path(tmpdir) + input_path = directory / "input.jsonl" + output_path = directory / "output.jsonl" + capture_path = directory / "args.json" + input_text = "".join( + json.dumps( + { + "id": str(index), + "conversations": [{"role": "user", "content": f"question {index}"}], + } + ) + + "\n" + for index in range(2) + ) + input_path.write_text( + input_text if trailing_newline else input_text.rstrip("\n"), + encoding="utf-8", + ) + env = { + **os.environ, + "MODEL_PROFILE": "qwen3.6-27b", + "PYTHON": str(self._make_fake_python(directory)), + "INPUT_FILE": str(input_path), + "OUTPUT_FILE": str(output_path), + "CAPTURE_ARGS": str(capture_path), + "FAKE_ERROR_ROWS": str(error_rows), + "FAKE_DROP_ROWS": str(drop_rows), + } + result = subprocess.run( + ["bash", f"examples/data_regeneration/{entrypoint}"], + cwd=Path(__file__).resolve().parents[2], + env=env, + capture_output=True, + text=True, + ) + return result, json.loads(capture_path.read_text(encoding="utf-8")) + + def test_qwen36_profile_uses_reasoning_contract(self): + with TemporaryDirectory() as tmpdir: + result, args = self._run_recipe(tmpdir) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + args[args.index("--model") + 1], + "Qwen/Qwen3.6-27B", + ) + self.assertEqual(args[args.index("--reasoning") + 1], "save") + self.assertEqual(args[args.index("--max-tokens") + 1], "32768") + + def test_qwen3_8b_compatibility_wrapper_selects_non_reasoning_profile(self): + with TemporaryDirectory() as tmpdir: + result, args = self._run_recipe( + tmpdir, + entrypoint="run_qwen3_8b_sharegpt_non_reasoning.sh", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(args[args.index("--model") + 1], "Qwen/Qwen3-8B") + self.assertEqual(args[args.index("--reasoning") + 1], "disable") + self.assertEqual(args[args.index("--max-tokens") + 1], "4096") + + def test_recipe_allows_accounted_error_rows(self): + with TemporaryDirectory() as tmpdir: + result, _ = self._run_recipe(tmpdir, error_rows=1) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("success fraction: 50.00%", result.stdout) + + def test_recipe_counts_input_without_trailing_newline(self): + with TemporaryDirectory() as tmpdir: + result, _ = self._run_recipe(tmpdir, trailing_newline=False) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("input rows: 2", result.stdout) + + def test_recipe_fails_when_rows_are_unaccounted_for(self): + with TemporaryDirectory() as tmpdir: + result, _ = self._run_recipe(tmpdir, drop_rows=1) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("did not account for every input row", result.stderr) From 4f17f85e480d14405e7156f92c87f7100022d69c Mon Sep 17 00:00:00 2001 From: jianuo-huang <1170180988@qq.com> Date: Mon, 13 Jul 2026 03:40:40 +0800 Subject: [PATCH 23/24] feat(data): explode multi-turn reasoning into generation events feat(data): add validated Qwen ShareGPT regeneration pipelines Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> fix lint and update filename fix lint and reuse code in conversation_valiation.py --- docs/basic_usage/data_preparation.md | 25 + scripts/expand_reasoning_conversations.py | 200 +++++++ .../test_expand_reasoning_conversations.py | 495 ++++++++++++++++++ 3 files changed, 720 insertions(+) create mode 100644 scripts/expand_reasoning_conversations.py create mode 100644 tests/test_scripts/test_expand_reasoning_conversations.py diff --git a/docs/basic_usage/data_preparation.md b/docs/basic_usage/data_preparation.md index d30b70b4b..0a5a08d12 100644 --- a/docs/basic_usage/data_preparation.md +++ b/docs/basic_usage/data_preparation.md @@ -57,6 +57,31 @@ python scripts/regenerate_train_data.py \ For reasoning models, add `--reasoning save` to store `reasoning_content` in the regenerated dataset. To use a reasoning model with thinking disabled, add `--reasoning disable`, which forwards `chat_template_kwargs.enable_thinking=false` to the SGLang server and does not save `reasoning_content`. +### Multi-turn structured reasoning + +A regenerated multi-turn reasoning conversation stores several assistant +targets in one row. When training uses last-turn-only loss masking, only the +final assistant target is supervised. Convert the validated conversation-level +output into one generation-event row per assistant turn so every reasoning +target is trained with the visible history available at its serving boundary: + +```bash +python scripts/explode_generation_events.py \ + --input-file-path ./cache/dataset/sharegpt_train_regen_reasoning.jsonl \ + --output-file-path ./cache/dataset/sharegpt_train_regen_reasoning_exploded.jsonl +``` + +Each event ends at its current assistant target and preserves that turn's +`reasoning_content` and visible `content`. Historical assistant messages keep +only visible `content`; their hidden reasoning is removed from the event +context. Train the exploded output with the entry point's +`train_only_last_turn` option enabled. + +The converter accepts only successful rows with non-empty IDs, message content, +and assistant `reasoning_content`. Invalid input is written to a skipped JSONL; +if any turn is invalid, the entire source conversation is skipped. Output files +must be fresh and distinct from the input. + For maximum performance, we recommend to scale the number of GPUs to regenerate the dataset in data parallel mode. To do this, you can simply add more server addresses to the `--server-address` argument, e.g. `--server-address localhost:30000 localhost:30001 localhost:30002 localhost:30003`. ### Qwen ShareGPT recipes diff --git a/scripts/expand_reasoning_conversations.py b/scripts/expand_reasoning_conversations.py new file mode 100644 index 000000000..8d3bdcea6 --- /dev/null +++ b/scripts/expand_reasoning_conversations.py @@ -0,0 +1,200 @@ +"""Expand regenerated reasoning conversations into generation-event samples.""" + +import argparse +import json +from collections import Counter +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple + +try: + from scripts.conversation_validation import ( + validate_conversation as validate_basic_conversation, + ) +except ModuleNotFoundError: + from conversation_validation import ( + validate_conversation as validate_basic_conversation, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Convert conversation-level regenerated reasoning JSONL into one " + "training row per assistant generation event." + ) + ) + parser.add_argument("--input-file-path", required=True) + parser.add_argument("--output-file-path", required=True) + parser.add_argument( + "--skipped-file-path", + default=None, + help="Path for skipped invalid rows. Defaults to _skipped.jsonl.", + ) + return parser.parse_args() + + +def skipped_path(output_file_path: str) -> str: + if not output_file_path.endswith(".jsonl"): + raise ValueError("output file path must end in .jsonl") + return f"{output_file_path[:-len('.jsonl')]}_skipped.jsonl" + + +def validate_paths(input_path: str, output_path: str, skip_path: str) -> None: + paths = [Path(path).resolve() for path in (input_path, output_path, skip_path)] + if len(set(paths)) != len(paths): + raise ValueError("input, output, and skipped paths must be distinct") + if not paths[0].is_file(): + raise ValueError(f"input file does not exist: {input_path}") + for path in paths[1:]: + if path.suffix != ".jsonl": + raise ValueError(f"output path must end in .jsonl: {path}") + if path.exists(): + raise ValueError(f"refusing to overwrite existing output: {path}") + + +def iter_jsonl(path: str) -> Iterable[Tuple[int, Any]]: + with open(path, "r", encoding="utf-8") as handle: + for line_index, line in enumerate(handle): + if line.strip(): + yield line_index, json.loads(line) + + +def validate_conversation(messages: Any) -> Optional[str]: + if isinstance(messages, list): + expected_role = "user" + for index, message in enumerate(messages): + if not isinstance(message, dict): + break + role = message.get("role") + if role == "system": + continue + if role == "assistant": + return ( + f"Invalid conversation role order at position {index}: " + f"expected {expected_role}, got {role}" + ) + break + + invalid_reason = validate_basic_conversation( + messages, + error_style="regeneration", + ) + if invalid_reason is not None: + return invalid_reason + + saw_assistant = False + for index, message in enumerate(messages): + if message.get("role") != "assistant": + continue + + reasoning = message.get("reasoning_content") + if not isinstance(reasoning, str) or not reasoning.strip(): + return ( + f"Invalid assistant reasoning_content at position {index}: " + "expected non-empty string" + ) + saw_assistant = True + + if not saw_assistant: + return "Data does not contain an assistant message" + if messages[-1].get("role") != "assistant": + return "Conversation ends with a user message" + return None + + +def visible_history_message(message: Dict[str, Any]) -> Dict[str, Any]: + visible = dict(message) + if visible.get("role") == "assistant": + visible.pop("reasoning_content", None) + return visible + + +def expand_row(row: Any, source_row_index: int) -> List[Dict[str, Any]]: + if not isinstance(row, dict): + raise ValueError("Expected a JSON object") + if row.get("status") != "success": + raise ValueError("status must be 'success'") + source_id = row.get("id") + if not isinstance(source_id, str) or not source_id.strip(): + raise ValueError("id must be a non-empty string") + messages = row.get("conversations") + invalid_reason = validate_conversation(messages) + if invalid_reason is not None: + raise ValueError(invalid_reason) + + history: List[Dict[str, Any]] = [] + events: List[Dict[str, Any]] = [] + assistant_turn_index = 0 + + for message in messages: + role = message["role"] + if role != "assistant": + history.append(dict(message)) + continue + + event_messages = [dict(item) for item in history] + event_messages.append(dict(message)) + event = { + "id": f"{source_id}#turn{assistant_turn_index}", + "source_id": source_id, + "source_row_index": source_row_index, + "assistant_turn_index": assistant_turn_index, + "conversations": event_messages, + } + if "status" in row: + event["status"] = row["status"] + events.append(event) + + history.append(visible_history_message(message)) + assistant_turn_index += 1 + + return events + + +def main() -> None: + args = parse_args() + try: + skip_path = args.skipped_file_path or skipped_path(args.output_file_path) + validate_paths(args.input_file_path, args.output_file_path, skip_path) + except ValueError as exc: + raise SystemExit(f"invalid paths: {exc}") from exc + stats: Counter = Counter() + + with ( + open(args.output_file_path, "w", encoding="utf-8") as output_handle, + open(skip_path, "w", encoding="utf-8") as skipped_handle, + ): + for source_row_index, row in iter_jsonl(args.input_file_path): + stats["input_rows"] += 1 + try: + events = expand_row(row, source_row_index) + except ValueError as exc: + skipped = dict(row) if isinstance(row, dict) else {"data": row} + skipped["status"] = "skipped" + skipped["error"] = str(exc) + skipped["source_row_index"] = source_row_index + skipped_handle.write(json.dumps(skipped, ensure_ascii=False) + "\n") + stats["skipped_rows"] += 1 + continue + + assistant_turns = len(events) + if assistant_turns > 1: + stats["multi_turn_input_rows"] += 1 + stats["assistant_turns"] += assistant_turns + for event in events: + output_handle.write(json.dumps(event, ensure_ascii=False) + "\n") + stats["output_event_samples"] += 1 + + input_rows = stats["input_rows"] + avg_events = stats["output_event_samples"] / input_rows if input_rows else 0.0 + print(f"input rows: {stats['input_rows']}") + print(f"output event samples: {stats['output_event_samples']}") + print(f"skipped rows: {stats['skipped_rows']}") + print(f"assistant turns: {stats['assistant_turns']}") + print(f"multi-turn input rows: {stats['multi_turn_input_rows']}") + print(f"avg events per input row: {avg_events:.3f}") + print(f"skipped file: {skip_path}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_scripts/test_expand_reasoning_conversations.py b/tests/test_scripts/test_expand_reasoning_conversations.py new file mode 100644 index 000000000..5a687f326 --- /dev/null +++ b/tests/test_scripts/test_expand_reasoning_conversations.py @@ -0,0 +1,495 @@ +import importlib.util +import json +import sys +import types +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import torch + +from scripts.expand_reasoning_conversations import ( + expand_row, + main, + skipped_path, + validate_conversation, +) + + +def _load_preprocessing_stack(): + repo_root = Path(__file__).resolve().parents[2] + package_name = "_specforge_data_test" + root_package = types.ModuleType(package_name) + root_package.__path__ = [] + sys.modules[package_name] = root_package + + data_package_name = f"{package_name}.data" + data_package = types.ModuleType(data_package_name) + data_package.__path__ = [] + sys.modules[data_package_name] = data_package + + distributed_module = types.ModuleType(f"{package_name}.distributed") + distributed_module.get_draft_sp_group = lambda: None + distributed_module.get_sp_ring_group = lambda: None + sys.modules[f"{package_name}.distributed"] = distributed_module + + for module_name in ("template", "parse", "preprocessing"): + full_name = f"{data_package_name}.{module_name}" + module_path = repo_root / "specforge" / "data" / f"{module_name}.py" + spec = importlib.util.spec_from_file_location(full_name, module_path) + module = importlib.util.module_from_spec(spec) + sys.modules[full_name] = module + spec.loader.exec_module(module) + + template_module = sys.modules[f"{data_package_name}.template"] + preprocessing_module = sys.modules[f"{data_package_name}.preprocessing"] + return ( + preprocessing_module.preprocess_conversations, + template_module.TEMPLATE_REGISTRY, + ) + + +class _CharEncoding: + def __init__(self, text: str, max_length: int | None = None): + ids = [ord(char) for char in text] + if max_length is not None: + ids = ids[:max_length] + self.input_ids = torch.tensor([ids], dtype=torch.long) + + +class _CharTokenizer: + pad_token_id = 0 + unk_token_id = 0 + + def apply_chat_template( + self, + messages, + tokenize=False, + add_generation_prompt=False, + add_special_tokens=False, + tools=None, + **kwargs, + ): + del tokenize, add_generation_prompt, add_special_tokens, tools, kwargs + rendered = [] + for message in messages: + content = message.get("content", "") + if message["role"] == "assistant" and "reasoning_content" in message: + content = ( + f"\n{message['reasoning_content']}\n\n" f"{content}" + ) + rendered.append(f"<|im_start|>{message['role']}\n{content}<|im_end|>\n") + return "".join(rendered) + + def __call__( + self, + text, + max_length=None, + truncation=True, + return_tensors=None, + add_special_tokens=False, + **kwargs, + ): + del truncation, return_tensors, add_special_tokens, kwargs + return _CharEncoding(text, max_length=max_length) + + def encode( + self, + text, + add_special_tokens=False, + truncation=True, + max_length=None, + ): + del add_special_tokens, truncation + ids = [ord(char) for char in text] + return ids[:max_length] if max_length is not None else ids + + def decode(self, ids, skip_special_tokens=False): + del skip_special_tokens + return "".join(chr(int(token_id)) for token_id in ids) + + +class TestExpandGenerationEvents(unittest.TestCase): + def test_expand_reasoning_conversation_strips_history_reasoning(self): + row = { + "id": "abc", + "status": "success", + "conversations": [ + {"role": "system", "content": "be concise"}, + {"role": "user", "content": "u1"}, + { + "role": "assistant", + "reasoning_content": "r1", + "content": "a1", + }, + {"role": "user", "content": "u2"}, + { + "role": "assistant", + "reasoning_content": "r2", + "content": "a2", + }, + ], + } + + events = expand_row(row, source_row_index=7) + + self.assertEqual(len(events), 2) + self.assertEqual(events[0]["id"], "abc#turn0") + self.assertEqual(events[0]["source_id"], "abc") + self.assertEqual(events[0]["source_row_index"], 7) + self.assertEqual(events[0]["assistant_turn_index"], 0) + self.assertEqual( + events[0]["conversations"][-1], + { + "role": "assistant", + "reasoning_content": "r1", + "content": "a1", + }, + ) + + second_messages = events[1]["conversations"] + self.assertEqual( + second_messages[0], {"role": "system", "content": "be concise"} + ) + self.assertEqual( + second_messages, + [ + {"role": "system", "content": "be concise"}, + {"role": "user", "content": "u1"}, + {"role": "assistant", "content": "a1"}, + {"role": "user", "content": "u2"}, + { + "role": "assistant", + "reasoning_content": "r2", + "content": "a2", + }, + ], + ) + + def test_validate_rejects_trailing_user(self): + reason = validate_conversation( + [ + {"role": "user", "content": "u1"}, + { + "role": "assistant", + "content": "a1", + "reasoning_content": "r1", + }, + {"role": "user", "content": "u2"}, + ] + ) + + self.assertEqual(reason, "Conversation ends with a user message") + + def test_validate_rejects_missing_or_invalid_content(self): + for content in (None, "", []): + with self.subTest(content=content): + reason = validate_conversation( + [ + {"role": "user", "content": content}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": "reasoning", + }, + ] + ) + + self.assertIn("expected non-empty string", reason) + + def test_validate_rejects_missing_or_invalid_reasoning(self): + for reasoning in (None, "", []): + with self.subTest(reasoning=reasoning): + reason = validate_conversation( + [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": reasoning, + }, + ] + ) + + self.assertIn("assistant reasoning_content", reason) + self.assertIn("expected non-empty string", reason) + + def test_main_writes_events_and_skipped_rows(self): + with TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "input.jsonl" + output_path = Path(tmpdir) / "events.jsonl" + skipped_path = Path(tmpdir) / "skipped.jsonl" + rows = [ + { + "id": "ok", + "status": "success", + "conversations": [ + {"role": "user", "content": "u1"}, + { + "role": "assistant", + "reasoning_content": "r1", + "content": "a1", + }, + {"role": "user", "content": "u2"}, + { + "role": "assistant", + "reasoning_content": "r2", + "content": "a2", + }, + ], + }, + { + "id": "bad", + "status": "success", + "conversations": [ + {"role": "assistant", "content": "starts wrong"}, + ], + }, + ] + input_path.write_text( + "".join(json.dumps(row) + "\n" for row in rows), + encoding="utf-8", + ) + + argv = [ + "expand_reasoning_conversations.py", + "--input-file-path", + str(input_path), + "--output-file-path", + str(output_path), + "--skipped-file-path", + str(skipped_path), + ] + with patch("sys.argv", argv): + main() + + output_rows = [ + json.loads(line) + for line in output_path.read_text(encoding="utf-8").splitlines() + ] + skipped_rows = [ + json.loads(line) + for line in skipped_path.read_text(encoding="utf-8").splitlines() + ] + + self.assertEqual(len(output_rows), 2) + self.assertEqual(output_rows[1]["id"], "ok#turn1") + self.assertNotIn("reasoning_content", output_rows[1]["conversations"][1]) + self.assertEqual(len(skipped_rows), 1) + self.assertEqual(skipped_rows[0]["status"], "skipped") + self.assertIn("expected user", skipped_rows[0]["error"]) + + def test_expand_rejects_non_success_or_invalid_id(self): + conversations = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": "reasoning", + }, + ] + invalid_rows = [ + {"id": "row", "status": "error", "conversations": conversations}, + {"id": "", "status": "success", "conversations": conversations}, + {"id": None, "status": "success", "conversations": conversations}, + ] + + for row in invalid_rows: + with self.subTest(row=row): + with self.assertRaises(ValueError): + expand_row(row, source_row_index=0) + + def test_main_records_non_object_row_as_skipped(self): + with TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "input.jsonl" + output_path = Path(tmpdir) / "events.jsonl" + input_path.write_text("[]\n", encoding="utf-8") + + argv = [ + "expand_reasoning_conversations.py", + "--input-file-path", + str(input_path), + "--output-file-path", + str(output_path), + ] + with patch("sys.argv", argv): + main() + + skipped_rows = [ + json.loads(line) + for line in Path(skipped_path(str(output_path))) + .read_text(encoding="utf-8") + .splitlines() + ] + self.assertEqual(skipped_rows[0]["data"], []) + self.assertEqual(skipped_rows[0]["status"], "skipped") + self.assertIn("JSON object", skipped_rows[0]["error"]) + + def test_main_rejects_input_output_collision_without_truncating_input(self): + with TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "input.jsonl" + original = '{"id": "keep"}\n' + input_path.write_text(original, encoding="utf-8") + + argv = [ + "expand_reasoning_conversations.py", + "--input-file-path", + str(input_path), + "--output-file-path", + str(input_path), + ] + with patch("sys.argv", argv): + with self.assertRaisesRegex(SystemExit, "must be distinct"): + main() + + self.assertEqual(input_path.read_text(encoding="utf-8"), original) + + def test_main_refuses_existing_output(self): + with TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "input.jsonl" + output_path = Path(tmpdir) / "events.jsonl" + input_path.write_text("{}\n", encoding="utf-8") + output_path.write_text("keep\n", encoding="utf-8") + + argv = [ + "expand_reasoning_conversations.py", + "--input-file-path", + str(input_path), + "--output-file-path", + str(output_path), + ] + with patch("sys.argv", argv): + with self.assertRaisesRegex(SystemExit, "refusing to overwrite"): + main() + + self.assertEqual(output_path.read_text(encoding="utf-8"), "keep\n") + + def test_skipped_path_requires_jsonl_output(self): + with self.assertRaisesRegex(ValueError, "must end in .jsonl"): + skipped_path("events.txt") + + def test_expanded_event_qwen35_masks_only_current_generation(self): + preprocess_conversations, template_registry = _load_preprocessing_stack() + + row = { + "id": "abc", + "status": "success", + "conversations": [ + {"role": "user", "content": "u1"}, + { + "role": "assistant", + "reasoning_content": "r1", + "content": "a1", + }, + {"role": "user", "content": "u2"}, + { + "role": "assistant", + "reasoning_content": "r2", + "content": "a2", + }, + ], + } + second_event = expand_row(row, source_row_index=0)[1] + tokenizer = _CharTokenizer() + template = template_registry.get("qwen3.5") + + all_turns = preprocess_conversations( + tokenizer, + [second_event["conversations"]], + template, + max_length=1024, + train_only_last_turn=False, + ) + last_turn = preprocess_conversations( + tokenizer, + [second_event["conversations"]], + template, + max_length=1024, + train_only_last_turn=True, + ) + + def masked_text(processed): + input_ids = processed["input_ids"][0].view(-1) + mask = processed["loss_mask"][0].view(-1).bool() + return tokenizer.decode(input_ids[mask].tolist()) + + all_turns_text = masked_text(all_turns) + current_only_text = masked_text(last_turn) + self.assertEqual(all_turns_text, current_only_text) + self.assertNotIn("a1", all_turns_text) + self.assertIn("r2", all_turns_text) + self.assertIn("a2", all_turns_text) + self.assertNotIn("a1", current_only_text) + self.assertIn("r2", current_only_text) + self.assertIn("a2", current_only_text) + + def test_non_expanded_last_turn_matches_last_event_only_after_stripping_history_reasoning( + self, + ): + preprocess_conversations, template_registry = _load_preprocessing_stack() + + row = { + "id": "abc", + "status": "success", + "conversations": [ + {"role": "user", "content": "u1"}, + { + "role": "assistant", + "reasoning_content": "r1", + "content": "a1", + }, + {"role": "user", "content": "u2"}, + { + "role": "assistant", + "reasoning_content": "r2", + "content": "a2", + }, + {"role": "user", "content": "u3"}, + { + "role": "assistant", + "reasoning_content": "r3", + "content": "a3", + }, + ], + } + last_event = expand_row(row, source_row_index=0)[-1] + tokenizer = _CharTokenizer() + template = template_registry.get("qwen3.5") + + def render(messages): + processed = preprocess_conversations( + tokenizer, + [messages], + template, + max_length=1024, + train_only_last_turn=True, + ) + input_ids = processed["input_ids"][0].view(-1) + mask = processed["loss_mask"][0].view(-1).bool() + return ( + tokenizer.decode(input_ids.tolist()), + tokenizer.decode(input_ids[mask].tolist()), + ) + + original_input_text, original_loss_text = render(row["conversations"]) + event_input_text, event_loss_text = render(last_event["conversations"]) + + self.assertEqual(original_loss_text, event_loss_text) + self.assertIn("r3", event_loss_text) + self.assertIn("a3", event_loss_text) + self.assertNotEqual(original_input_text, event_input_text) + self.assertIn("r1", original_input_text) + self.assertIn("r2", original_input_text) + self.assertNotIn("r1", event_input_text) + self.assertNotIn("r2", event_input_text) + + stripped_history = last_event["conversations"] + stripped_input_text, stripped_loss_text = render(stripped_history) + + self.assertEqual(stripped_input_text, event_input_text) + self.assertEqual(stripped_loss_text, event_loss_text) + + +if __name__ == "__main__": + unittest.main() From 2963b5ee8af4f98a5fafa6def84702069a04649d Mon Sep 17 00:00:00 2001 From: jianuo-huang <1170180988@qq.com> Date: Mon, 13 Jul 2026 02:59:20 +0800 Subject: [PATCH 24/24] test(domino): add generic overfit and DFLASH serving gates Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> fix lint and upgrade comment and dir fix ckpt_sort --- .../qwen3.6-27b-domino-full-attention.json | 52 +++ .../domino-disagg-overfit-serving-gate.md | 146 +++++++++ examples/disagg/_dflash_family_disagg.py | 5 +- .../disagg/run_domino_dflash_serving_gate.sh | 154 +++++++++ .../disagg/run_domino_disagg_overfit_gate.sh | 273 ++++++++++++++++ scripts/gates/check_overfit_metrics.py | 110 +++++++ scripts/gates/run_dflash_chat_serving_gate.py | 193 ++++++++++++ scripts/gates/select_overfit_sample.py | 274 ++++++++++++++++ specforge/export/to_hf.py | 55 +++- .../test_scripts/test_dflash_serving_gate.py | 219 +++++++++++++ .../test_scripts/test_domino_overfit_gate.py | 295 ++++++++++++++++++ 11 files changed, 1774 insertions(+), 2 deletions(-) create mode 100644 configs/qwen3.6-27b-domino-full-attention.json create mode 100644 docs/examples/domino-disagg-overfit-serving-gate.md create mode 100755 examples/disagg/run_domino_dflash_serving_gate.sh create mode 100755 examples/disagg/run_domino_disagg_overfit_gate.sh create mode 100755 scripts/gates/check_overfit_metrics.py create mode 100644 scripts/gates/run_dflash_chat_serving_gate.py create mode 100644 scripts/gates/select_overfit_sample.py create mode 100644 tests/test_scripts/test_dflash_serving_gate.py create mode 100644 tests/test_scripts/test_domino_overfit_gate.py diff --git a/configs/qwen3.6-27b-domino-full-attention.json b/configs/qwen3.6-27b-domino-full-attention.json new file mode 100644 index 000000000..e9baefbfd --- /dev/null +++ b/configs/qwen3.6-27b-domino-full-attention.json @@ -0,0 +1,52 @@ +{ + "architectures": [ + "DFlashDraftModel" + ], + "attention_bias": false, + "attention_dropout": 0.0, + "auto_map": { + "AutoModel": "dflash.DFlashDraftModel" + }, + "block_size": 16, + "bos_token_id": null, + "dflash_config": { + "mask_token_id": 248070, + "target_layer_ids": [1, 16, 31, 46, 61], + "projector_type": "domino", + "pure_draft_prefix_len": 1, + "emb_dim": 256, + "gru_hidden_dim": 1024, + "shift_label": false + }, + "dtype": "bfloat16", + "eos_token_id": 248044, + "head_dim": 128, + "hidden_act": "silu", + "hidden_size": 5120, + "initializer_range": 0.02, + "intermediate_size": 17408, + "layer_types": [ + "full_attention", + "full_attention", + "full_attention", + "full_attention", + "full_attention" + ], + "max_position_embeddings": 262144, + "max_window_layers": 5, + "model_type": "qwen3", + "num_attention_heads": 32, + "num_hidden_layers": 5, + "num_key_value_heads": 8, + "num_target_layers": 64, + "pad_token_id": 248044, + "rms_norm_eps": 1e-06, + "rope_scaling": null, + "rope_theta": 10000000, + "sliding_window": null, + "tie_word_embeddings": false, + "transformers_version": "5.5.3", + "use_cache": true, + "use_sliding_window": false, + "vocab_size": 248320 +} diff --git a/docs/examples/domino-disagg-overfit-serving-gate.md b/docs/examples/domino-disagg-overfit-serving-gate.md new file mode 100644 index 000000000..9e0f914ab --- /dev/null +++ b/docs/examples/domino-disagg-overfit-serving-gate.md @@ -0,0 +1,146 @@ +# Domino disaggregated overfit and DFLASH serving gate + +This gate checks the smallest end-to-end Domino disaggregated training path: +one patched SGLang capture server captures target features for one ShareGPT +row, and a separate DP trainer repeatedly consumes that row. A companion +post-training gate exports the resulting checkpoint and loads it in real +SGLang DFLASH speculative serving. Every target model uses these same entry +points; model-specific paths, templates, tensor keys, and reasoning behavior +are inputs rather than separate test implementations. + +Domino is a DFLASH draft architecture with its own GRU logit correction, not a +second serving algorithm. Training therefore needs a Domino architecture +config, while SGLang serves the resulting draft through the common `DFLASH` +algorithm. + +| Example | Dataset contract | `CHAT_TEMPLATE` | Reasoning inputs | +| --- | --- | --- | --- | +| Qwen3-8B | non-reasoning | `qwen` | `REASONING_POLICY=forbidden`, `ENABLE_THINKING=0` | +| Qwen3.6-27B | structured reasoning | `qwen3.5` | `REASONING_POLICY=required`, `ENABLE_THINKING=1` | + +Mask token, capture layers, and block size are read from the selected draft +config so the launch command does not duplicate model metadata. + +`SOURCE_DATA_PATH` is the only data input. It must point to validated, +ShareGPT-compatible JSONL; data regeneration is intentionally outside this +gate. See [Data Preparation](../basic_usage/data_preparation.md) for the general +regeneration workflow. Before starting a server or trainer, this gate enforces +the requested reasoning contract and the real post-tokenization loss-mask +threshold. + +`DRAFT_CONFIG_PATH` describes the Domino draft architecture to initialize and +train, including its block size, mask token, and captured target layers. The +overfit stage does not require an existing draft checkpoint. The real serving +stage necessarily consumes the checkpoint produced by that overfit run because +DFLASH serving verifies proposals from the trained draft against the target. + +Run from the repository root on an eight-GPU host with Mooncake and a patched +SGLang capture server installed: + +```bash +SOURCE_DATA_PATH=/path/to/validated_sharegpt.jsonl \ +TARGET_MODEL_PATH=Qwen/Qwen3-8B \ +DRAFT_CONFIG_PATH=configs/qwen3-8b-domino.json \ +CHAT_TEMPLATE=qwen \ +EMBEDDING_KEY=model.embed_tokens.weight \ +REASONING_POLICY=forbidden ENABLE_THINKING=0 MAX_LENGTH=512 \ +SERVER_GPUS=0 \ +CONSUMER_GPUS=1,2,3,4,5,6,7 \ +TRAIN_DP=7 \ +bash examples/disagg/run_domino_disagg_overfit_gate.sh +``` + +For the validated Qwen3.6-27B reasoning dataset and full-attention Domino config: + +```bash +SOURCE_DATA_PATH=/path/to/validated_qwen36_reasoning.jsonl \ +TARGET_MODEL_PATH=Qwen/Qwen3.6-27B \ +DRAFT_CONFIG_PATH=configs/qwen3.6-27b-domino-full-attention.json \ +CHAT_TEMPLATE=qwen3.5 \ +EMBEDDING_KEY=model.language_model.embed_tokens.weight \ +REASONING_POLICY=required ENABLE_THINKING=1 MAX_LENGTH=2048 \ +SERVER_GPUS=0 SERVER_TP=1 \ +CONSUMER_GPUS=1,2,3,4,5,6,7 TRAIN_DP=7 \ +bash examples/disagg/run_domino_disagg_overfit_gate.sh +``` + +The launcher intentionally refuses an existing `WORK_DIR`; choose a new path +for every run. It selects one clean single-turn row into `single_sample.jsonl` +and runs the selected target tokenizer and SpecForge preprocessing used by the +producer. Selection succeeds only when the resulting loss mask has at least 32 +tokens, matching the producer's `2 * block_size` filter, and the rendered row +fits without truncation. Selection also writes `prompt_artifact.json`, which +records the exact rendered prompt/target suffix. +The serving client uses that artifact instead of reconstructing a target from +visible content; this preserves structured reasoning while stripping hidden +reasoning fields from request history. + +`DISAGG_MAX_PROMPTS=1` limits the producer input to that one source row. +`NUM_EPOCHS` is then set to `DISAGG_MAX_STEPS * TRAIN_DP`, so replay produces +exactly enough sample references for every DP rank at every bounded optimizer +step. The default gate stops at `DISAGG_MAX_STEPS=400`. + +Useful bounded overrides are: + +```bash +WORK_DIR=outputs/domino-overfit-$(date +%Y%m%dT%H%M%S) \ +DISAGG_MAX_STEPS=600 \ +bash examples/disagg/run_domino_disagg_overfit_gate.sh +``` + +The command exits successfully only when all three conditions hold: + +- the final logged loss is at most `0.0001` (displayed as approximately + `0.0000`); +- final token accuracy is exactly `1.0000` by default; +- a `training_state.pt` checkpoint exists under the fresh consumer output. + +The final JSON emitted by `scripts/gates/check_overfit_metrics.py` records the step, +loss, token accuracy, and checkpoint path. A nonzero exit means this gate did +not pass even if the trainer process itself exited cleanly. + +## Real DFLASH serving gate + +Run the strict serving check against the fresh overfit checkpoint and its +`prompt_artifact.json`: + +```bash +CHECKPOINT_PATH=/path/to/consumer/-step400 \ +PROMPT_ARTIFACT_PATH=/path/to/run/prompt_artifact.json \ +TARGET_MODEL_PATH=Qwen/Qwen3-8B \ +DRAFT_CONFIG_PATH=configs/qwen3-8b-domino.json \ +EMBEDDING_KEY=model.embed_tokens.weight \ +SERVED_MODEL=qwen3-8b \ +SGLANG_ROOT=/path/to/domino-capable/sglang \ +SGLANG_PYTHON=/path/to/sglang-env/bin/python \ +bash examples/disagg/run_domino_dflash_serving_gate.sh +``` + +Alternatively, set `RUN_REAL_SERVING_GATE=1` on the training wrapper and set +`SERVING_SGLANG_ROOT` / `SERVING_PYTHON` when serving uses a separate SGLang +environment. The wrapper releases the capture server and trainer GPUs before +starting the real serving process. + +The runner exports the runtime checkpoint with the target embedding, changes +the exported architecture to SGLang's `DFlashDraftModel`, and launches a real +server with `--speculative-algorithm DFLASH` and block size 16. Its client only +uses `/v1/chat/completions`, removes `reasoning_content` from request history, +and takes `enable_thinking` from the prompt artifact. It combines response +`reasoning_content + content` and compares that text with the artifact's +rendered target suffix. Reasoning parsers, TP, and serving context are ordinary +launcher inputs (`REASONING_PARSER`, `SERVING_TP`, and +`SERVING_CONTEXT_LENGTH`). + +This is a strict per-request gate. The SGLang checkout must support +`return_meta_info=true` on the OpenAI chat API and expose +`choices[0].meta_info.spec_accept_length`; aggregate `/server_info` acceptance +statistics are not accepted as a substitute. The result passes only when: + +- `/server_info.speculative_algorithm` is `DFLASH`; +- the request contains no `reasoning_content`; +- the choice reports `spec_accept_length >= 16`; +- the generated output matches at least the first 16 target tokens. + +The auditable result is written to `serving_gate.json`. An older SGLang checkout +that omits choice metadata fails explicitly instead of being reported as real +per-request serving success. diff --git a/examples/disagg/_dflash_family_disagg.py b/examples/disagg/_dflash_family_disagg.py index de532b144..8a4984f45 100644 --- a/examples/disagg/_dflash_family_disagg.py +++ b/examples/disagg/_dflash_family_disagg.py @@ -453,7 +453,10 @@ def logger(metrics, step): pass if logdict: tracker.log(logdict, step=step) - summary = {k.split("/")[-1]: round(v, 4) for k, v in logdict.items()} + # Keep machine-readable gate metrics at full float precision. Rounding + # here can turn loss=1.49e-4 into 1e-4 or accuracy=0.99996 into 1.0, + # allowing a strict overfit threshold to pass on the displayed value. + summary = {k.split("/")[-1]: v for k, v in logdict.items()} print(f"[consumer] step {step} {summary}", flush=True) return logger diff --git a/examples/disagg/run_domino_dflash_serving_gate.sh b/examples/disagg/run_domino_dflash_serving_gate.sh new file mode 100755 index 000000000..e842c7d13 --- /dev/null +++ b/examples/disagg/run_domino_dflash_serving_gate.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Export one Domino overfit checkpoint and gate it through real SGLang DFLASH. +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +ROOT_DIR=$(dirname "$(dirname "$SCRIPT_DIR")") + +: "${CHECKPOINT_PATH:?set CHECKPOINT_PATH to the overfit checkpoint directory}" +: "${TARGET_MODEL_PATH:?set TARGET_MODEL_PATH to the target model}" +: "${DRAFT_CONFIG_PATH:?set DRAFT_CONFIG_PATH to the matching Domino config}" +: "${EMBEDDING_KEY:?set EMBEDDING_KEY to the target embedding tensor key}" +SERVED_MODEL=${SERVED_MODEL:-domino-target} +SERVING_GPUS=${SERVING_GPUS:-${SERVING_GPU:-0}} +SERVING_TP=${SERVING_TP:-1} +REASONING_PARSER=${REASONING_PARSER:-} +SERVING_CONTEXT_LENGTH=${SERVING_CONTEXT_LENGTH:-512} +SERVING_MAX_TOTAL_TOKENS=${SERVING_MAX_TOTAL_TOKENS:-1024} +: "${PROMPT_ARTIFACT_PATH:?set PROMPT_ARTIFACT_PATH to prompt_artifact.json}" +SPECFORGE_PYTHON=${SPECFORGE_PYTHON:-python} +SGLANG_PYTHON=${SGLANG_PYTHON:-python} +SGLANG_ROOT=${SGLANG_ROOT:-} +SERVING_PORT=${SERVING_PORT:-30001} +WORK_DIR=${WORK_DIR:-$ROOT_DIR/outputs/domino-real-serving-$(date +%Y%m%dT%H%M%S)} +EXPORT_DIR=$WORK_DIR/draft_hf +RESULT_PATH=$WORK_DIR/serving_gate.json +SERVER_LOG=$WORK_DIR/server.log + +for path in "$CHECKPOINT_PATH" "$PROMPT_ARTIFACT_PATH" "$DRAFT_CONFIG_PATH"; do + if [[ ! -e "$path" ]]; then + echo "Required path does not exist: $path" >&2 + exit 2 + fi +done +if timeout 1 bash -c "/dev/null; then + echo "SERVING_PORT is already occupied; refusing to probe an existing server: $SERVING_PORT" >&2 + exit 2 +fi +if [[ -e "$WORK_DIR" ]]; then + echo "WORK_DIR already exists; serving gates require a fresh output: $WORK_DIR" >&2 + exit 2 +fi +mkdir -p "$WORK_DIR" + +mapfile -t DRAFT_VALUES < <("$SPECFORGE_PYTHON" - "$DRAFT_CONFIG_PATH" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + config = json.load(handle) +dflash = config.get("dflash_config") or {} +if dflash.get("projector_type") != "domino": + raise SystemExit("draft config must set dflash_config.projector_type='domino'") +print(config["block_size"]) +PY +) +BLOCK_SIZE=${BLOCK_SIZE:-${DRAFT_VALUES[0]}} + +PYTHONPATH="$ROOT_DIR${PYTHONPATH:+:$PYTHONPATH}" \ + "$SPECFORGE_PYTHON" -m specforge.export.to_hf \ + --checkpoint "$CHECKPOINT_PATH" \ + --draft-config "$DRAFT_CONFIG_PATH" \ + --output-dir "$EXPORT_DIR" \ + --embedding-source "$TARGET_MODEL_PATH" \ + --embedding-key "$EMBEDDING_KEY" + +# SGLang dispatches this compatible HF artifact through its DFLASH loader. +"$SPECFORGE_PYTHON" - "$EXPORT_DIR/config.json" "$BLOCK_SIZE" <<'PY' +import json +import sys + +path = sys.argv[1] +block_size = int(sys.argv[2]) +with open(path, encoding="utf-8") as handle: + config = json.load(handle) +config["architectures"] = ["DFlashDraftModel"] +config.pop("auto_map", None) +with open(path, "w", encoding="utf-8") as handle: + json.dump(config, handle, indent=2) + handle.write("\n") +dflash = config.get("dflash_config") or {} +if config.get("block_size") != block_size or dflash.get("projector_type") != "domino": + raise SystemExit(f"exported checkpoint is not a block-{block_size} Domino draft") +PY + +cleanup() { + if [[ -n "${SERVER_PID:-}" ]]; then + kill -TERM -- "-$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +SGLANG_PYTHONPATH=() +if [[ -n "$SGLANG_ROOT" ]]; then + SGLANG_PYTHONPATH=(PYTHONPATH="$SGLANG_ROOT/python${PYTHONPATH:+:$PYTHONPATH}") +fi +REASONING_PARSER_ARGS=() +if [[ -n "$REASONING_PARSER" ]]; then + REASONING_PARSER_ARGS=(--reasoning-parser "$REASONING_PARSER") +fi +env \\ + CUDA_VISIBLE_DEVICES="$SERVING_GPUS" \ + PYTHONUNBUFFERED=1 \ + "${SGLANG_PYTHONPATH[@]}" \ + "$SGLANG_PYTHON" -m sglang.launch_server \ + --model-path "$TARGET_MODEL_PATH" \ + --served-model-name "$SERVED_MODEL" \ + --tp-size "$SERVING_TP" \ + --dtype bfloat16 \ + --attention-backend "${SERVING_ATTENTION_BACKEND:-triton}" \ + --mem-fraction-static "${SERVING_MEM_FRACTION:-0.8}" \ + --context-length "$SERVING_CONTEXT_LENGTH" \ + --max-running-requests 1 \ + --max-total-tokens "$SERVING_MAX_TOTAL_TOKENS" \ + --chunked-prefill-size -1 \ + --disable-radix-cache \ + --disable-cuda-graph \ + --trust-remote-code \ + --host 127.0.0.1 \ + --port "$SERVING_PORT" \ + --speculative-algorithm DFLASH \ + --speculative-draft-model-path "$EXPORT_DIR" \ + --speculative-num-draft-tokens "$BLOCK_SIZE" \ + --speculative-dflash-block-size "$BLOCK_SIZE" \ + "${REASONING_PARSER_ARGS[@]}" \ + >"$SERVER_LOG" 2>&1 & +SERVER_PID=$! + +for _ in $(seq 1 300); do + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "SGLang server exited during startup; see $SERVER_LOG" >&2 + exit 1 + fi + if curl -fsS "http://127.0.0.1:$SERVING_PORT/v1/models" >/dev/null; then + break + fi + sleep 2 +done +if ! curl -fsS "http://127.0.0.1:$SERVING_PORT/v1/models" >/dev/null; then + echo "SGLang server did not become ready; see $SERVER_LOG" >&2 + exit 1 +fi + +PYTHONPATH="$ROOT_DIR${PYTHONPATH:+:$PYTHONPATH}" \ + "$SGLANG_PYTHON" "$ROOT_DIR/scripts/gates/run_dflash_chat_serving_gate.py" \ + --server-url "http://127.0.0.1:$SERVING_PORT" \ + --model-path "$TARGET_MODEL_PATH" \ + --served-model "$SERVED_MODEL" \ + --prompt-json-path "$PROMPT_ARTIFACT_PATH" \ + --output-path "$RESULT_PATH" \ + --block-size "$BLOCK_SIZE" \ + --max-tokens "$BLOCK_SIZE" + +echo "DOMINO-REAL-DFLASH-SERVING-PASSED: $RESULT_PATH" diff --git a/examples/disagg/run_domino_disagg_overfit_gate.sh b/examples/disagg/run_domino_disagg_overfit_gate.sh new file mode 100755 index 000000000..7c2d00c28 --- /dev/null +++ b/examples/disagg/run_domino_disagg_overfit_gate.sh @@ -0,0 +1,273 @@ +#!/bin/bash +# Strict model-parameterized, one-sample Domino disaggregated overfit gate: +# one SGLang capture server and one Domino trainer. +set -euo pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +ROOT_DIR=$(dirname "$(dirname "$SCRIPT_DIR")") +cd "$ROOT_DIR" + +export PYTHONPATH="$ROOT_DIR:$ROOT_DIR/scripts:${PYTHONPATH:-}" +export TORCHINDUCTOR_CACHE_DIR=${TORCHINDUCTOR_CACHE_DIR:-$ROOT_DIR/cache/compiled_kernels} +export FLASHINFER_DISABLE_VERSION_CHECK=1 + +PYTHON=${PYTHON:-python} +: "${TARGET_MODEL_PATH:?set TARGET_MODEL_PATH to the target model}" +: "${DRAFT_CONFIG_PATH:?set DRAFT_CONFIG_PATH to a Domino draft config}" +: "${SOURCE_DATA_PATH:?set SOURCE_DATA_PATH to validated ShareGPT JSONL}" +CHAT_TEMPLATE=${CHAT_TEMPLATE:-qwen} +EMBEDDING_KEY=${EMBEDDING_KEY:-model.embed_tokens.weight} +LM_HEAD_KEY=${LM_HEAD_KEY:-lm_head.weight} +REASONING_POLICY=${REASONING_POLICY:-allow} +ENABLE_THINKING=${ENABLE_THINKING:-0} +MAX_LENGTH=${MAX_LENGTH:-512} +SERVER_GPUS=${SERVER_GPUS:-0} +SERVER_TP=${SERVER_TP:-1} +REAL_SERVING_GPUS=${REAL_SERVING_GPUS:-0} +REAL_SERVING_TP=${REAL_SERVING_TP:-1} + +SERVER_PORT=${SERVER_PORT:-30000} +TRAIN_DP=${TRAIN_DP:-7} +CONSUMER_GPUS=${CONSUMER_GPUS:-"1,2,3,4,5,6,7"} + +RUN_TAG=${RUN_TAG:-$(date +%Y%m%dT%H%M%S)} +export DISAGG_STORE_ID=${DISAGG_STORE_ID:-domino-overfit-$RUN_TAG} +WORK_DIR=${WORK_DIR:-$ROOT_DIR/outputs/$DISAGG_STORE_ID} +PRODUCER_OUTPUT_DIR=$WORK_DIR/producer +CONSUMER_OUTPUT_DIR=$WORK_DIR/consumer +SINGLE_DATA_PATH=$WORK_DIR/single_sample.jsonl +PROMPT_ARTIFACT_PATH=$WORK_DIR/prompt_artifact.json +TRAIN_LOG=$WORK_DIR/train.log + +for path in "$SOURCE_DATA_PATH" "$DRAFT_CONFIG_PATH"; do + if [[ ! -e "$path" ]]; then + echo "Required path does not exist: $path" >&2 + exit 2 + fi +done +if timeout 1 bash -c "/dev/null; then + echo "SERVER_PORT is already occupied; refusing to use an existing capture server: $SERVER_PORT" >&2 + exit 2 +fi +if [[ -e "$WORK_DIR" ]]; then + echo "WORK_DIR already exists; overfit gates require a fresh output: $WORK_DIR" >&2 + exit 2 +fi +mkdir -p "$WORK_DIR" + +mapfile -t DRAFT_VALUES < <("$PYTHON" - "$DRAFT_CONFIG_PATH" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + config = json.load(handle) +dflash = config.get("dflash_config") or {} +if dflash.get("projector_type") != "domino": + raise SystemExit("draft config must set dflash_config.projector_type='domino'") +print(dflash["mask_token_id"]) +print(config["block_size"]) +print(" ".join(str(layer) for layer in dflash["target_layer_ids"])) +PY +) +MASK_TOKEN_ID=${MASK_TOKEN_ID:-${DRAFT_VALUES[0]}} +BLOCK_SIZE=${BLOCK_SIZE:-${DRAFT_VALUES[1]}} +AUX_LAYER_IDS=${AUX_LAYER_IDS:-${DRAFT_VALUES[2]}} +MIN_LOSS_TOKENS=$((2 * BLOCK_SIZE)) + +SELECT_ARGS=() +if [[ "$ENABLE_THINKING" == "1" || "$ENABLE_THINKING" == "true" ]]; then + SELECT_ARGS+=(--enable-thinking) +fi +"$PYTHON" "$ROOT_DIR/scripts/gates/select_overfit_sample.py" \ + --input-data-path "$SOURCE_DATA_PATH" \ + --output-data-path "$SINGLE_DATA_PATH" \ + --model-path "$TARGET_MODEL_PATH" \ + --chat-template "$CHAT_TEMPLATE" \ + --max-length "$MAX_LENGTH" \ + --min-loss-tokens "$MIN_LOSS_TOKENS" \ + --reasoning-policy "$REASONING_POLICY" \ + --prompt-output-path "$PROMPT_ARTIFACT_PATH" \ + --require-untruncated \ + "${SELECT_ARGS[@]}" + +export DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-1} +export DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-400} +if [[ "$DISAGG_MAX_PROMPTS" -ne 1 || "$DISAGG_MAX_STEPS" -le 0 ]]; then + echo "overfit requires DISAGG_MAX_PROMPTS=1 and DISAGG_MAX_STEPS>0" >&2 + exit 2 +fi +# Each optimizer step consumes one batch on every DP rank. Replaying the one +# prompt MAX_STEPS*TRAIN_DP times keeps the producer alive for the whole gate. +NUM_EPOCHS=$((DISAGG_MAX_STEPS * TRAIN_DP)) +export DISAGG_TOTAL_STEPS=$DISAGG_MAX_STEPS +export DISAGG_LOG_INTERVAL=${DISAGG_LOG_INTERVAL:-1} + +MAX_LOSS=${MAX_LOSS:-0.0001} +MIN_ACCURACY=${MIN_ACCURACY:-1.0} + +MOONCAKE_HOST=${MOONCAKE_HOST:-127.0.0.1} +MOONCAKE_RPC_PORT=${MOONCAKE_RPC_PORT:-35551} +MOONCAKE_HTTP_PORT=${MOONCAKE_HTTP_PORT:-35880} +MOONCAKE_METRICS_PORT=${MOONCAKE_METRICS_PORT:-35903} +export MOONCAKE_LOCAL_HOSTNAME=$MOONCAKE_HOST +export MOONCAKE_MASTER_SERVER_ADDR=$MOONCAKE_HOST:$MOONCAKE_RPC_PORT +export MOONCAKE_METADATA_SERVER=http://$MOONCAKE_HOST:$MOONCAKE_HTTP_PORT/metadata +export MOONCAKE_PROTOCOL=${MOONCAKE_PROTOCOL:-tcp} +export MOONCAKE_GLOBAL_SEGMENT_SIZE=${MOONCAKE_GLOBAL_SEGMENT_SIZE:-$((32 << 30))} +export DISAGG_CLIENT_SEGMENT_SIZE=${DISAGG_CLIENT_SEGMENT_SIZE:-0} +export DISAGG_CLIENT_BUFFER_SIZE=${DISAGG_CLIENT_BUFFER_SIZE:-$((256 << 20))} +if [[ "$DISAGG_CLIENT_SEGMENT_SIZE" -ne 0 ]]; then + echo "DISAGG_CLIENT_SEGMENT_SIZE must be 0 for server-owned captures" >&2 + exit 2 +fi + +export DISAGG_SERVER_URLS=http://127.0.0.1:$SERVER_PORT +export DISAGG_REF_CHANNEL=$WORK_DIR/refs.jsonl +DISAGG_DB=$WORK_DIR/run.db +DISAGG_INBOX_DIR=$WORK_DIR/inboxes +MOONCAKE_LOG=$WORK_DIR/mooncake.log +: > "$DISAGG_REF_CHANNEL" + +cleanup() { + kill "${SERVER_PID:-}" "${PRODUCER_PID:-}" 2>/dev/null || true + if [[ -n "${MASTER_PID:-}" ]]; then + kill -- "-$MASTER_PID" 2>/dev/null || kill "$MASTER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +setsid mooncake_master \ + --enable_http_metadata_server=true \ + --rpc_port="$MOONCAKE_RPC_PORT" \ + --http_metadata_server_port="$MOONCAKE_HTTP_PORT" \ + --metrics_port="$MOONCAKE_METRICS_PORT" \ + >"$MOONCAKE_LOG" 2>&1 & +MASTER_PID=$! + +for _ in $(seq 1 30); do + if ! kill -0 "$MASTER_PID" 2>/dev/null; then + echo "Mooncake master exited during startup; see $MOONCAKE_LOG" >&2 + exit 1 + fi + if curl -sS --max-time 1 -o /dev/null \ + "$MOONCAKE_METADATA_SERVER?key=specforge-health-check" \ + && timeout 1 bash -c \ + "/dev/null; then + break + fi + sleep 1 +done +if ! timeout 1 bash -c "/dev/null; then + echo "Mooncake master did not become ready; see $MOONCAKE_LOG" >&2 + exit 1 +fi + +CUDA_VISIBLE_DEVICES=$SERVER_GPUS \ + "$PYTHON" -m sglang.launch_server \ + --model-path "$TARGET_MODEL_PATH" \ + --trust-remote-code \ + --skip-tokenizer-init \ + --tp-size "$SERVER_TP" \ + --context-length "$MAX_LENGTH" \ + --mem-fraction-static "${SERVER_MEM_FRACTION:-0.85}" \ + --chunked-prefill-size -1 \ + --disable-radix-cache \ + --enable-spec-capture \ + --spec-capture-method dflash \ + --spec-capture-aux-layer-ids $AUX_LAYER_IDS \ + --port "$SERVER_PORT" & +SERVER_PID=$! + +until curl -sf "http://127.0.0.1:$SERVER_PORT/health" >/dev/null; do + if ! kill -0 "$MASTER_PID" 2>/dev/null; then + echo "Mooncake master died while SGLang was starting" >&2 + exit 1 + fi + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "SGLang server on :$SERVER_PORT died" >&2 + exit 1 + fi + sleep 5 +done + +ARGS=( + --target-model-path "$TARGET_MODEL_PATH" + --target-model-backend hf + --trust-remote-code + --draft-config-path "$DRAFT_CONFIG_PATH" + --embedding-key "$EMBEDDING_KEY" + --lm-head-key "$LM_HEAD_KEY" + --mask-token-id "$MASK_TOKEN_ID" + --train-data-path "$SINGLE_DATA_PATH" + --chat-template "$CHAT_TEMPLATE" + --max-length "$MAX_LENGTH" + --batch-size 1 + --accumulation-steps 1 + --learning-rate "${LEARNING_RATE:-6e-4}" + --warmup-ratio "${WARMUP_RATIO:-0.04}" + --max-grad-norm 1.0 + --attention-backend "${ATTENTION_BACKEND:-flex_attention}" + --block-size "$BLOCK_SIZE" + --num-anchors "${NUM_ANCHORS:-256}" + --loss-decay-gamma 7.0 + --num-epochs "$NUM_EPOCHS" + --seed 42 + --save-interval "$DISAGG_MAX_STEPS" + --lambda-base-start 1.0 + --lambda-base-decay-ratio 1.0 +) +LAUNCHER=$SCRIPT_DIR/run_disagg_domino.py + +DISAGG_ROLE=producer CUDA_VISIBLE_DEVICES="" \ + "$PYTHON" "$LAUNCHER" "${ARGS[@]}" \ + --output-dir "$PRODUCER_OUTPUT_DIR" & +PRODUCER_PID=$! + +CUDA_VISIBLE_DEVICES=$CONSUMER_GPUS DISAGG_ROLE=consumer \ + DISAGG_DB=$DISAGG_DB DISAGG_INBOX_DIR=$DISAGG_INBOX_DIR \ + torchrun --rdzv-backend c10d --rdzv-endpoint localhost:29702 \ + --nnodes 1 --nproc_per_node "$TRAIN_DP" "$LAUNCHER" "${ARGS[@]}" \ + --output-dir "$CONSUMER_OUTPUT_DIR" \ + --report-to none 2>&1 | tee "$TRAIN_LOG" + +wait "$PRODUCER_PID" +"$PYTHON" "$ROOT_DIR/scripts/gates/check_overfit_metrics.py" \ + --log-path "$TRAIN_LOG" \ + --checkpoint-root "$CONSUMER_OUTPUT_DIR" \ + --expected-step "$DISAGG_MAX_STEPS" \ + --max-loss "$MAX_LOSS" \ + --min-accuracy "$MIN_ACCURACY" + +LATEST_CHECKPOINT="$CONSUMER_OUTPUT_DIR/$DISAGG_STORE_ID-step$DISAGG_MAX_STEPS" +if [[ ! -f "$LATEST_CHECKPOINT/training_state.pt" ]]; then + echo "Expected final checkpoint is missing: $LATEST_CHECKPOINT/training_state.pt" >&2 + exit 1 +fi +if [[ "${RUN_REAL_SERVING_GATE:-0}" == "1" ]]; then + # Release the capture/training stack before loading target + draft serving. + kill "$SERVER_PID" 2>/dev/null || true + kill -- "-$MASTER_PID" 2>/dev/null || kill "$MASTER_PID" 2>/dev/null || true + wait "$SERVER_PID" "$MASTER_PID" 2>/dev/null || true + SERVER_PID="" + MASTER_PID="" + CHECKPOINT_PATH="$LATEST_CHECKPOINT" \ + PROMPT_ARTIFACT_PATH="$PROMPT_ARTIFACT_PATH" \ + TARGET_MODEL_PATH="$TARGET_MODEL_PATH" \ + DRAFT_CONFIG_PATH="$DRAFT_CONFIG_PATH" \ + EMBEDDING_KEY="$EMBEDDING_KEY" \ + SPECFORGE_PYTHON="$PYTHON" \ + SGLANG_PYTHON="${SERVING_PYTHON:-$PYTHON}" \ + SGLANG_ROOT="${SERVING_SGLANG_ROOT:-}" \ + SERVING_GPUS="$REAL_SERVING_GPUS" \ + SERVING_TP="$REAL_SERVING_TP" \ + SERVING_CONTEXT_LENGTH="$MAX_LENGTH" \ + WORK_DIR="$WORK_DIR/real_serving" \ + bash "$ROOT_DIR/examples/disagg/run_domino_dflash_serving_gate.sh" +else + echo "Real serving is a separate strict gate. Run it with:" + echo "CHECKPOINT_PATH=$LATEST_CHECKPOINT \\" + echo " PROMPT_ARTIFACT_PATH=$PROMPT_ARTIFACT_PATH \\" + echo " bash examples/disagg/run_domino_dflash_serving_gate.sh" +fi + +echo "DOMINO-DISAGG-OVERFIT-PASSED: $WORK_DIR" diff --git a/scripts/gates/check_overfit_metrics.py b/scripts/gates/check_overfit_metrics.py new file mode 100755 index 000000000..28e0a0a25 --- /dev/null +++ b/scripts/gates/check_overfit_metrics.py @@ -0,0 +1,110 @@ +"""Gate an overfit run on its final loss, accuracy, and checkpoint. + +Assumptions about the trainer log and output structure: + +- Metric lines match: ``[consumer] step {}`` + This is the disaggregated Domino consumer log format. A new method whose trainer + uses a different prefix or schema must either emit the same pattern or supply its + own checker; update ``METRIC_LINE`` accordingly. +- The checkpoint tree layout is: ``/-step/training_state.pt`` + If a new method saves checkpoints under a different layout, adjust + ``checkpoint_paths()`` to match. +- The gate thresholds ``--max-loss`` and ``--min-accuracy`` are method-agnostic + CLI arguments; every future overfit gate can pass the values appropriate for that + method without modifying this script. +""" + +from __future__ import annotations + +import argparse +import ast +import glob +import json +import os +import re +from typing import Dict, Tuple + +METRIC_LINE = re.compile(r"\[consumer\] step (\d+) (\{.*\})\s*$") + + +def final_metrics(log_path: str) -> Tuple[int, Dict[str, float]]: + matches = [] + with open(log_path, encoding="utf-8", errors="replace") as handle: + for line in handle: + match = METRIC_LINE.search(line) + if match: + metrics = ast.literal_eval(match.group(2)) + matches.append((int(match.group(1)), metrics)) + if not matches: + raise ValueError(f"no consumer metric lines found in {log_path}") + return matches[-1] + + +def checkpoint_paths(checkpoint_root: str): + pattern = os.path.join(checkpoint_root, "*-step*", "training_state.pt") + paths = glob.glob(pattern) + + def get_step(path: str) -> int: + match = re.search(r"-step(\\d+)", path) + return int(match.group(1)) if match else 0 + + return sorted(paths, key=get_step) + + +def check_overfit( + log_path: str, + checkpoint_root: str, + *, + expected_step: int, + max_loss: float, + min_accuracy: float, +) -> Dict[str, object]: + step, metrics = final_metrics(log_path) + loss = float(metrics["loss"]) + accuracy = float(metrics["accuracy"]) + checkpoints = checkpoint_paths(checkpoint_root) + errors = [] + if step != expected_step: + errors.append(f"final logged step {step} != expected {expected_step}") + if loss > max_loss: + errors.append(f"final loss {loss} > {max_loss}") + if accuracy < min_accuracy: + errors.append(f"final token accuracy {accuracy} < {min_accuracy}") + if not checkpoints: + errors.append(f"no training_state.pt checkpoint under {checkpoint_root}") + result = { + "step": step, + "loss": loss, + "token_accuracy": accuracy, + "checkpoint": checkpoints[-1] if checkpoints else None, + "passed": not errors, + } + if errors: + raise ValueError("; ".join(errors)) + return result + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--log-path", required=True) + parser.add_argument("--checkpoint-root", required=True) + parser.add_argument("--expected-step", type=int, required=True) + parser.add_argument("--max-loss", type=float, default=1e-4) + parser.add_argument("--min-accuracy", type=float, default=1.0) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + result = check_overfit( + args.log_path, + args.checkpoint_root, + expected_step=args.expected_step, + max_loss=args.max_loss, + min_accuracy=args.min_accuracy, + ) + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/gates/run_dflash_chat_serving_gate.py b/scripts/gates/run_dflash_chat_serving_gate.py new file mode 100644 index 000000000..770c776d4 --- /dev/null +++ b/scripts/gates/run_dflash_chat_serving_gate.py @@ -0,0 +1,193 @@ +"""Gate one overfit draft artifact through real SGLang DFLASH chat serving. + +This script is tied to the SGLang DFLASH serving API, not to a particular draft +architecture. A method-specific launcher should export its checkpoint into an +SGLang-compatible draft artifact, start the server with ``--speculative-algorithm +DFLASH``, then call this checker with the prompt artifact produced by +``select_overfit_sample.py``. + +Future DFLASH draft methods can reuse this checker when they expose the same +OpenAI chat endpoint behavior and per-choice ``spec_accept_length`` metadata. If a +method needs a different request shape, acceptance metric, or output comparison, +add a sibling checker under this directory instead of weakening this strict gate. +""" + +from __future__ import annotations + +import argparse +import json +import os +from typing import Any, Dict, List, Sequence + +import requests + + +def longest_prefix_match(left: Sequence[int], right: Sequence[int]) -> int: + for index, (left_id, right_id) in enumerate(zip(left, right)): + if left_id != right_id: + return index + return min(len(left), len(right)) + + +def load_prompt_artifact(path: str) -> Dict[str, Any]: + with open(path, encoding="utf-8") as handle: + artifact = json.load(handle) + messages = artifact.get("prompt_messages") + target = artifact.get("target_suffix") + if not isinstance(messages, list) or not messages: + raise ValueError("prompt artifact must contain nonempty prompt_messages") + if not isinstance(target, str) or not target: + raise ValueError("prompt artifact must contain nonempty target_suffix") + return artifact + + +def build_chat_payload( + artifact: Dict[str, Any], model: str, max_tokens: int +) -> Dict[str, Any]: + messages: List[Dict[str, Any]] = [] + for message in artifact["prompt_messages"]: + clean = dict(message) + clean.pop("reasoning_content", None) + messages.append(clean) + return { + "model": model, + "messages": messages, + "max_tokens": max_tokens, + "temperature": 0.0, + "top_p": 1.0, + "stream": False, + "return_meta_info": True, + "separate_reasoning": True, + "chat_template_kwargs": { + "enable_thinking": bool(artifact.get("enable_thinking", False)) + }, + } + + +def request_messages_with_reasoning_content(payload: Dict[str, Any]) -> int: + return sum("reasoning_content" in message for message in payload["messages"]) + + +def evaluate_response( + *, + response_json: Dict[str, Any], + server_info: Dict[str, Any], + payload: Dict[str, Any], + target_ids: Sequence[int], + encode, + block_size: int, +) -> Dict[str, Any]: + choices = response_json.get("choices") or [] + choice = choices[0] if choices and isinstance(choices[0], dict) else {} + message = choice.get("message") or {} + generated_reasoning = message.get("reasoning_content") or "" + generated_content = message.get("content") or "" + generated_text = generated_reasoning + generated_content + generated_ids = encode(generated_text) + + # This is intentionally choice metadata, not aggregate /server_info metrics. + choice_meta_info = choice.get("meta_info") + spec_accept_length = ( + choice_meta_info.get("spec_accept_length") + if isinstance(choice_meta_info, dict) + else None + ) + prefix_match = longest_prefix_match(generated_ids, target_ids) + algorithm = server_info.get("speculative_algorithm") + reasoning_count = request_messages_with_reasoning_content(payload) + + errors = [] + if algorithm != "DFLASH": + errors.append( + f"server speculative_algorithm is {algorithm!r}, expected 'DFLASH'" + ) + if reasoning_count: + errors.append("request history contains reasoning_content") + if spec_accept_length is None: + errors.append("missing choices[0].meta_info.spec_accept_length") + elif float(spec_accept_length) < block_size: + errors.append( + f"spec_accept_length {spec_accept_length} < block_size {block_size}" + ) + if prefix_match < block_size: + errors.append(f"target prefix match {prefix_match} < block_size {block_size}") + + return { + "passed": not errors, + "endpoint": "/v1/chat/completions", + "request_messages_with_reasoning_content": reasoning_count, + "sglang_server_info_before": server_info, + "choice_meta_info": choice_meta_info, + "spec_accept_length": spec_accept_length, + "target_prefix_match_tokens": prefix_match, + "generated_tokens": len(generated_ids), + "generated_reasoning": generated_reasoning, + "generated_content": generated_content, + "target_tokens": len(target_ids), + "clean_block_tokens": block_size if not errors else 0, + "errors": errors, + "request_payload": payload, + "sglang_response": response_json, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--server-url", required=True) + parser.add_argument("--model-path", required=True) + parser.add_argument("--served-model", required=True) + parser.add_argument("--prompt-json-path", required=True) + parser.add_argument("--output-path", required=True) + parser.add_argument("--block-size", type=int, default=16) + parser.add_argument("--max-tokens", type=int, default=16) + parser.add_argument("--timeout", type=float, default=1800.0) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.block_size <= 0 or args.max_tokens < args.block_size: + raise SystemExit("--block-size must be positive and --max-tokens >= block size") + + from transformers import AutoTokenizer + + artifact = load_prompt_artifact(args.prompt_json_path) + payload = build_chat_payload(artifact, args.served_model, args.max_tokens) + tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True) + + def encode(text): + return tokenizer.encode(text, add_special_tokens=False) + + target_ids = encode(artifact["target_suffix"]) + + server_url = args.server_url.rstrip("/") + info_response = requests.get(f"{server_url}/server_info", timeout=30) + info_response.raise_for_status() + response = requests.post( + f"{server_url}/v1/chat/completions", json=payload, timeout=args.timeout + ) + response.raise_for_status() + result = evaluate_response( + response_json=response.json(), + server_info=info_response.json(), + payload=payload, + target_ids=target_ids, + encode=encode, + block_size=args.block_size, + ) + result["server_url"] = server_url + result["endpoint"] = f"{server_url}/v1/chat/completions" + + os.makedirs(os.path.dirname(os.path.abspath(args.output_path)), exist_ok=True) + with open(args.output_path, "w", encoding="utf-8") as handle: + json.dump(result, handle, ensure_ascii=False, indent=2) + handle.write("\n") + print(json.dumps(result, ensure_ascii=False, indent=2)) + if not result["passed"]: + raise SystemExit( + "real SGLang DFLASH serving gate failed: " + "; ".join(result["errors"]) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/gates/select_overfit_sample.py b/scripts/gates/select_overfit_sample.py new file mode 100644 index 000000000..a8e34b39f --- /dev/null +++ b/scripts/gates/select_overfit_sample.py @@ -0,0 +1,274 @@ +"""Select one clean single-turn overfit sample and record its serving contract. + +This helper is method-agnostic: it only validates ShareGPT-style data, applies the +selected tokenizer/template preprocessing, and writes the prompt artifact consumed +by serving gates. New draft methods should reuse this script when they can train +from the same single-turn conversation and `target_suffix` contract; method-specific +constraints such as block size or minimum loss-token count should stay in the +launcher that calls this helper. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import sys +import types +from typing import Any, Dict, Iterable, Optional, Tuple + + +def iter_jsonl(path: str) -> Iterable[Tuple[int, Dict[str, Any]]]: + with open(path, encoding="utf-8") as handle: + for index, line in enumerate(handle): + if line.strip(): + yield index, json.loads(line) + + +def single_turn_candidate( + row: Dict[str, Any], + reasoning_policy: str = "allow", +) -> Optional[Dict[str, Any]]: + conversations = row.get("conversations") + if not isinstance(conversations, list) or len(conversations) != 2: + return None + user, assistant = conversations + if user.get("role") != "user" or assistant.get("role") != "assistant": + return None + user_content = user.get("content") + assistant_content = assistant.get("content") + if not isinstance(user_content, str) or not user_content.strip(): + return None + if not isinstance(assistant_content, str): + return None + reasoning_content = assistant.get("reasoning_content", "") + if not isinstance(reasoning_content, str): + return None + if not assistant_content.strip() and not reasoning_content.strip(): + return None + has_reasoning = bool(reasoning_content.strip()) + if reasoning_policy == "forbidden" and has_reasoning: + return None + if reasoning_policy == "required" and not has_reasoning: + return None + return { + "id": row.get("id"), + "conversations": [dict(user), dict(assistant)], + } + + +def load_processing_stack(model_path: str, chat_template: str): + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) + root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + package_name = "_specforge_domino_overfit" + root_package = types.ModuleType(package_name) + root_package.__path__ = [] + sys.modules[package_name] = root_package + + data_package_name = f"{package_name}.data" + data_package = types.ModuleType(data_package_name) + data_package.__path__ = [] + sys.modules[data_package_name] = data_package + + distributed_module = types.ModuleType(f"{package_name}.distributed") + distributed_module.get_draft_sp_group = lambda: None + distributed_module.get_sp_ring_group = lambda: None + sys.modules[f"{package_name}.distributed"] = distributed_module + + for module_name in ("template", "parse", "preprocessing"): + full_name = f"{data_package_name}.{module_name}" + module_path = os.path.join(root_dir, "specforge", "data", f"{module_name}.py") + spec = importlib.util.spec_from_file_location(full_name, module_path) + module = importlib.util.module_from_spec(spec) + sys.modules[full_name] = module + spec.loader.exec_module(module) + + template_module = sys.modules[f"{data_package_name}.template"] + preprocessing_module = sys.modules[f"{data_package_name}.preprocessing"] + return ( + tokenizer, + template_module.TEMPLATE_REGISTRY.get(chat_template), + preprocessing_module.preprocess_conversations, + ) + + +def loss_token_count( + candidate: Dict[str, Any], + processing_stack, + *, + max_length: int, +) -> int: + tokenizer, template, preprocess_conversations = processing_stack + processed = preprocess_conversations( + tokenizer, + [candidate["conversations"]], + template, + max_length=max_length, + ) + if len(processed["loss_mask"]) != 1: + raise ValueError("preprocessing did not return exactly one loss mask") + return int(processed["loss_mask"][0].sum().item()) + + +def build_prompt_artifact( + candidate: Dict[str, Any], + processing_stack, + *, + enable_thinking: bool, + source_row_index: int, + loss_tokens: int, +) -> Dict[str, Any]: + """Render the exact chat prompt/target boundary used by real serving.""" + tokenizer = processing_stack[0] + messages = candidate["conversations"] + prompt_messages = [] + for message in messages[:-1]: + clean = dict(message) + clean.pop("reasoning_content", None) + prompt_messages.append(clean) + template_kwargs = { + "tokenize": False, + "add_special_tokens": False, + "enable_thinking": enable_thinking, + } + flat_prompt = tokenizer.apply_chat_template( + prompt_messages, add_generation_prompt=True, **template_kwargs + ) + flat_train_text = tokenizer.apply_chat_template( + messages, add_generation_prompt=False, **template_kwargs + ) + if not flat_train_text.startswith(flat_prompt): + raise ValueError("flattened train text does not start with flattened prompt") + full_input_tokens = len(tokenizer.encode(flat_train_text, add_special_tokens=False)) + return { + "source_row_index": source_row_index, + "id": candidate.get("id"), + "prompt_messages": prompt_messages, + "target_suffix": flat_train_text[len(flat_prompt) :], + "enable_thinking": enable_thinking, + "loss_tokens": loss_tokens, + "full_input_tokens": full_input_tokens, + } + + +def create_single_sample( + input_path: str, + output_path: str, + *, + model_path: str, + chat_template: str = "qwen", + max_length: int = 512, + min_loss_tokens: int = 32, + reasoning_policy: str = "allow", + prompt_output_path: Optional[str] = None, + enable_thinking: bool = False, + require_untruncated: bool = False, + processing_stack=None, +) -> Tuple[int, Dict[str, Any], int]: + if os.path.exists(output_path): + raise FileExistsError(f"refusing to overwrite existing output: {output_path}") + if prompt_output_path and os.path.exists(prompt_output_path): + raise FileExistsError( + f"refusing to overwrite existing prompt artifact: {prompt_output_path}" + ) + if reasoning_policy not in {"forbidden", "required", "allow"}: + raise ValueError(f"unknown reasoning policy: {reasoning_policy}") + if processing_stack is None: + processing_stack = load_processing_stack(model_path, chat_template) + for index, row in iter_jsonl(input_path): + candidate = single_turn_candidate(row, reasoning_policy=reasoning_policy) + if candidate is None: + continue + loss_tokens = loss_token_count( + candidate, + processing_stack, + max_length=max_length, + ) + if loss_tokens < min_loss_tokens: + continue + artifact = None + if prompt_output_path: + artifact = build_prompt_artifact( + candidate, + processing_stack, + enable_thinking=enable_thinking, + source_row_index=index, + loss_tokens=loss_tokens, + ) + if require_untruncated and artifact["full_input_tokens"] > max_length: + continue + parent = os.path.dirname(os.path.abspath(output_path)) + os.makedirs(parent, exist_ok=True) + with open(output_path, "x", encoding="utf-8") as handle: + handle.write(json.dumps(candidate, ensure_ascii=False) + "\n") + if prompt_output_path and artifact is not None: + prompt_parent = os.path.dirname(os.path.abspath(prompt_output_path)) + os.makedirs(prompt_parent, exist_ok=True) + with open(prompt_output_path, "x", encoding="utf-8") as handle: + json.dump(artifact, handle, ensure_ascii=False, indent=2) + handle.write("\n") + return index, candidate, loss_tokens + raise ValueError( + "no trainable single-turn sample found; expected a user/assistant row " + f"with reasoning_policy={reasoning_policy!r} and at least " + f"{min_loss_tokens} post-preprocessing loss tokens" + + (f", without exceeding {max_length} tokens" if require_untruncated else "") + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input-data-path", required=True) + parser.add_argument("--output-data-path", required=True) + parser.add_argument("--model-path", required=True) + parser.add_argument("--chat-template", default="qwen") + parser.add_argument("--max-length", type=int, default=512) + parser.add_argument("--min-loss-tokens", type=int, default=32) + parser.add_argument( + "--reasoning-policy", + choices=["forbidden", "required", "allow"], + default="allow", + ) + parser.add_argument("--prompt-output-path") + parser.add_argument("--enable-thinking", action="store_true") + parser.add_argument("--require-untruncated", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + index, row, loss_tokens = create_single_sample( + args.input_data_path, + args.output_data_path, + model_path=args.model_path, + chat_template=args.chat_template, + max_length=args.max_length, + min_loss_tokens=args.min_loss_tokens, + reasoning_policy=args.reasoning_policy, + prompt_output_path=args.prompt_output_path, + enable_thinking=args.enable_thinking, + require_untruncated=args.require_untruncated, + ) + print( + json.dumps( + { + "source_row_index": index, + "id": row.get("id"), + "loss_tokens": loss_tokens, + "output_data_path": os.path.abspath(args.output_data_path), + "prompt_output_path": ( + os.path.abspath(args.prompt_output_path) + if args.prompt_output_path + else None + ), + }, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/specforge/export/to_hf.py b/specforge/export/to_hf.py index bf3d2270a..424515ae4 100644 --- a/specforge/export/to_hf.py +++ b/specforge/export/to_hf.py @@ -16,11 +16,50 @@ from __future__ import annotations import argparse +import glob +import json +import os from typing import Optional +import torch +from huggingface_hub import snapshot_download +from safetensors import safe_open + from specforge.export.checkpoint_io import materialize_draft, resolve_training_state +def _load_embedding_tensor(source: str, key: str) -> torch.Tensor: + """Read one target embedding without materializing the target lm_head.""" + root = source if os.path.exists(source) else snapshot_download(repo_id=source) + index_paths = glob.glob(os.path.join(root, "*.index.json")) + if len(index_paths) > 1: + raise FileNotFoundError(f"multiple index files found under {root}") + if index_paths: + with open(index_paths[0], encoding="utf-8") as handle: + weight_file = json.load(handle).get("weight_map", {}).get(key) + if not weight_file: + raise KeyError(f"embedding key {key!r} is absent from {index_paths[0]}") + path = os.path.join(root, weight_file) + else: + candidates = glob.glob(os.path.join(root, "*.safetensors")) + candidates += glob.glob(os.path.join(root, "*.bin")) + if len(candidates) != 1: + raise FileNotFoundError( + f"expected one model weight file under {root}, found {len(candidates)}" + ) + path = candidates[0] + + if path.endswith(".safetensors"): + with safe_open(path, framework="pt") as handle: + if key not in handle.keys(): + raise KeyError(f"embedding key {key!r} is absent from {path}") + return handle.get_tensor(key) + state = torch.load(path, map_location="cpu", weights_only=True) + if key not in state: + raise KeyError(f"embedding key {key!r} is absent from {path}") + return state[key] + + def export_to_hf( checkpoint_path: str, draft_config_path: str, @@ -50,8 +89,22 @@ def export_to_hf( "exclude the frozen embedding); pass embedding_source= so the export ships the real embedding" ) - model.load_embedding(embedding_source, embedding_key=embedding_key) + load_embedding = getattr(model, "load_embedding", None) + if load_embedding is not None: + load_embedding(embedding_source, embedding_key=embedding_key) full_state = dict(model.state_dict()) + if ( + "embed_tokens.weight" not in full_state + and "embed_tokens.weight" not in state["draft_state_dict"] + ): + if not embedding_source: + raise ValueError( + "checkpoint does not contain embed_tokens.weight; pass " + "embedding_source=" + ) + full_state["embed_tokens.weight"] = _load_embedding_tensor( + embedding_source, embedding_key + ) full_state.update(state["draft_state_dict"]) # trained keys win model.save_pretrained(output_dir, state_dict=full_state) return output_dir diff --git a/tests/test_scripts/test_dflash_serving_gate.py b/tests/test_scripts/test_dflash_serving_gate.py new file mode 100644 index 000000000..ef684b06f --- /dev/null +++ b/tests/test_scripts/test_dflash_serving_gate.py @@ -0,0 +1,219 @@ +import importlib.util +import json +import subprocess +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +import torch +from safetensors.torch import save_file + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "gates" / "run_dflash_chat_serving_gate.py" +SPEC = importlib.util.spec_from_file_location("dflash_serving_gate", SCRIPT) +gate = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(gate) + + +def load_hf_exporter(): + names = ("specforge", "specforge.export", "specforge.export.checkpoint_io") + saved = {name: sys.modules.get(name) for name in names} + try: + for name in names[:2]: + package = types.ModuleType(name) + package.__path__ = [] + sys.modules[name] = package + checkpoint_io = types.ModuleType(names[2]) + checkpoint_io.materialize_draft = None + checkpoint_io.resolve_training_state = None + sys.modules[names[2]] = checkpoint_io + path = ROOT / "specforge" / "export" / "to_hf.py" + spec = importlib.util.spec_from_file_location("_test_to_hf", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + for name, module in saved.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +hf_exporter = load_hf_exporter() + + +def encode(text): + return [ord(char) for char in text] + + +class TestDflashServingGate(unittest.TestCase): + def setUp(self): + self.target = "abcdefghijklmnop target continuation" + self.artifact = { + "prompt_messages": [ + { + "role": "user", + "content": "question", + "reasoning_content": "must not be sent", + } + ], + "target_suffix": self.target, + "enable_thinking": False, + } + self.payload = gate.build_chat_payload(self.artifact, "test-model", 16) + + def evaluate(self, response, server_info=None): + return gate.evaluate_response( + response_json=response, + server_info=server_info or {"speculative_algorithm": "DFLASH"}, + payload=self.payload, + target_ids=encode(self.target), + encode=encode, + block_size=16, + ) + + def test_payload_is_non_reasoning_chat_history(self): + self.assertEqual( + self.payload["chat_template_kwargs"], {"enable_thinking": False} + ) + self.assertTrue(self.payload["return_meta_info"]) + self.assertEqual(gate.request_messages_with_reasoning_content(self.payload), 0) + self.assertNotIn("reasoning_content", self.payload["messages"][0]) + + def test_passes_clean_choice_meta_info_block(self): + result = self.evaluate( + { + "choices": [ + { + "message": {"role": "assistant", "content": self.target[:16]}, + "meta_info": {"spec_accept_length": 16.0}, + } + ] + } + ) + self.assertTrue(result["passed"]) + self.assertEqual(result["target_prefix_match_tokens"], 16) + self.assertEqual(result["clean_block_tokens"], 16) + + def test_reasoning_and_content_are_combined_for_structured_target(self): + target = "reasoning answer" + payload = gate.build_chat_payload( + { + "prompt_messages": [{"role": "user", "content": "question"}], + "target_suffix": target, + "enable_thinking": True, + }, + "test-reasoning-model", + 16, + ) + result = gate.evaluate_response( + response_json={ + "choices": [ + { + "message": { + "reasoning_content": "reasoning ", + "content": "answer", + }, + "meta_info": {"spec_accept_length": 16.0}, + } + ] + }, + server_info={"speculative_algorithm": "DFLASH"}, + payload=payload, + target_ids=encode(target), + encode=encode, + block_size=16, + ) + self.assertTrue(result["passed"]) + self.assertTrue(payload["chat_template_kwargs"]["enable_thinking"]) + + def test_rejects_root_meta_info_instead_of_choice_meta_info(self): + result = self.evaluate( + { + "meta_info": {"spec_accept_length": 16.0}, + "choices": [ + {"message": {"role": "assistant", "content": self.target[:16]}} + ], + } + ) + self.assertFalse(result["passed"]) + self.assertIn( + "missing choices[0].meta_info.spec_accept_length", result["errors"] + ) + + def test_rejects_non_dflash_or_diverged_prefix(self): + result = self.evaluate( + { + "choices": [ + { + "message": {"role": "assistant", "content": "wrong"}, + "meta_info": {"spec_accept_length": 16.0}, + } + ] + }, + {"speculative_algorithm": None}, + ) + self.assertFalse(result["passed"]) + self.assertTrue(any("expected 'DFLASH'" in error for error in result["errors"])) + self.assertTrue( + any("target prefix match" in error for error in result["errors"]) + ) + + def test_shell_launcher_uses_matching_dflash_block_flags(self): + launcher = ROOT / "examples/disagg/run_domino_dflash_serving_gate.sh" + subprocess.run(["bash", "-n", str(launcher)], check=True) + text = launcher.read_text() + self.assertIn('--speculative-num-draft-tokens "$BLOCK_SIZE"', text) + self.assertIn('--speculative-dflash-block-size "$BLOCK_SIZE"', text) + self.assertIn('--tp-size "$SERVING_TP"', text) + self.assertIn("REASONING_PARSER_ARGS=(--reasoning-parser", text) + self.assertIn("SERVING_PORT is already occupied", text) + + +class TestDominoHfExport(unittest.TestCase): + def test_model_without_load_embedding_gets_target_embedding_in_state(self): + embedding = torch.arange(12, dtype=torch.bfloat16).reshape(4, 3) + source_key = "model.embed_tokens.weight" + state = {"draft_state_dict": {"draft.weight": torch.ones(1)}} + saved_state = {} + + class DominoLikeModel: + def state_dict(self): + return {"draft.weight": torch.zeros(1)} + + def save_pretrained(self, output_dir, *, state_dict): + saved_state.update(state_dict) + + with tempfile.TemporaryDirectory() as tmp: + shard = Path(tmp) / "model-00001-of-00001.safetensors" + save_file({source_key: embedding}, shard) + (Path(tmp) / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {source_key: shard.name}}), + encoding="utf-8", + ) + with ( + patch.object(hf_exporter, "resolve_training_state", return_value=state), + patch.object( + hf_exporter, + "materialize_draft", + return_value=DominoLikeModel(), + ), + ): + hf_exporter.export_to_hf( + "checkpoint", + "config", + str(Path(tmp) / "out"), + embedding_source=tmp, + embedding_key=source_key, + ) + + torch.testing.assert_close(saved_state["embed_tokens.weight"], embedding) + torch.testing.assert_close(saved_state["draft.weight"], torch.ones(1)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scripts/test_domino_overfit_gate.py b/tests/test_scripts/test_domino_overfit_gate.py new file mode 100644 index 000000000..cab55ab9e --- /dev/null +++ b/tests/test_scripts/test_domino_overfit_gate.py @@ -0,0 +1,295 @@ +import importlib.util +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +import torch + +ROOT = Path(__file__).resolve().parents[2] + + +def load_script(name): + path = ROOT / "scripts" / "gates" / name + spec = importlib.util.spec_from_file_location(name.removesuffix(".py"), path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +create_data = load_script("select_overfit_sample.py") +check_overfit = load_script("check_overfit_metrics.py") + + +def fake_processing_stack(loss_tokens=40): + class FakeTokenizer: + def encode(self, text, *, add_special_tokens): + del add_special_tokens + return list(text) + + def apply_chat_template(self, messages, *, add_generation_prompt, **_kwargs): + prompt = "PROMPT:" + if add_generation_prompt: + return prompt + assistant = messages[-1] + return ( + prompt + assistant.get("reasoning_content", "") + assistant["content"] + ) + + def preprocess( + _tokenizer, + _conversations, + _template, + *, + max_length, + ): + del max_length + return {"loss_mask": [torch.ones(1, loss_tokens)]} + + return FakeTokenizer(), object(), preprocess + + +class TestCreateDominoOverfitData(unittest.TestCase): + def test_selects_clean_single_turn_non_reasoning_sample(self): + rows = [ + { + "id": "multi", + "conversations": [ + {"role": "user", "content": "u"}, + {"role": "assistant", "content": "a" * 200}, + {"role": "user", "content": "u2"}, + {"role": "assistant", "content": "b" * 200}, + ], + }, + { + "id": "thinking", + "conversations": [ + {"role": "user", "content": "u"}, + { + "role": "assistant", + "content": "clean" * 40, + "reasoning_content": "hidden", + }, + ], + }, + { + "id": "selected", + "extra": "not copied", + "conversations": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "answer " * 30}, + ], + "status": "success", + }, + ] + with tempfile.TemporaryDirectory() as tmp: + source = os.path.join(tmp, "source.jsonl") + output = os.path.join(tmp, "one.jsonl") + with open(source, "w", encoding="utf-8") as handle: + for row in rows: + handle.write(json.dumps(row) + "\n") + index, selected, loss_tokens = create_data.create_single_sample( + source, + output, + model_path="unused", + reasoning_policy="forbidden", + processing_stack=fake_processing_stack(), + ) + self.assertEqual(index, 2) + self.assertEqual(selected["id"], "selected") + self.assertEqual(loss_tokens, 40) + self.assertNotIn("extra", selected) + with open(output, encoding="utf-8") as handle: + written = [json.loads(line) for line in handle] + self.assertEqual(written, [selected]) + + def test_rejects_empty_assistant_and_existing_output(self): + row = { + "conversations": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": ""}, + ] + } + self.assertIsNone(create_data.single_turn_candidate(row)) + with tempfile.TemporaryDirectory() as tmp: + source = os.path.join(tmp, "source.jsonl") + output = os.path.join(tmp, "one.jsonl") + Path(source).write_text("{}\n") + Path(output).write_text("keep\n") + with self.assertRaises(FileExistsError): + create_data.create_single_sample( + source, + output, + model_path="unused", + processing_stack=fake_processing_stack(), + ) + self.assertEqual(Path(output).read_text(), "keep\n") + + def test_skips_candidate_below_real_loss_token_contract(self): + rows = [ + { + "id": "too-few-loss-tokens", + "conversations": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "answer " * 30}, + ], + } + ] + with tempfile.TemporaryDirectory() as tmp: + source = os.path.join(tmp, "source.jsonl") + output = os.path.join(tmp, "one.jsonl") + Path(source).write_text(json.dumps(rows[0]) + "\n") + with self.assertRaisesRegex(ValueError, "at least 32.*loss tokens"): + create_data.create_single_sample( + source, + output, + model_path="unused", + min_loss_tokens=32, + processing_stack=fake_processing_stack(loss_tokens=31), + ) + self.assertFalse(os.path.exists(output)) + + def test_required_reasoning_is_preserved_in_prompt_artifact(self): + row = { + "id": "reasoning-profile", + "conversations": [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "reasoning_content": "reasoning ", + "content": "answer " * 30, + }, + ], + } + with tempfile.TemporaryDirectory() as tmp: + source = os.path.join(tmp, "source.jsonl") + output = os.path.join(tmp, "one.jsonl") + artifact = os.path.join(tmp, "prompt.json") + Path(source).write_text(json.dumps(row) + "\n") + create_data.create_single_sample( + source, + output, + model_path="unused", + reasoning_policy="required", + prompt_output_path=artifact, + enable_thinking=True, + processing_stack=fake_processing_stack(), + ) + selected = json.loads(Path(output).read_text()) + prompt = json.loads(Path(artifact).read_text()) + self.assertEqual( + selected["conversations"][-1]["reasoning_content"], "reasoning " + ) + self.assertEqual(prompt["target_suffix"], "reasoning " + "answer " * 30) + self.assertTrue(prompt["enable_thinking"]) + self.assertNotIn("reasoning_content", prompt["prompt_messages"][0]) + + def test_untruncated_gate_skips_oversized_candidate(self): + rows = [ + { + "id": "truncated", + "conversations": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "a" * 200}, + ], + }, + { + "id": "selected", + "conversations": [ + {"role": "user", "content": "question"}, + {"role": "assistant", "content": "b" * 128}, + ], + }, + ] + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source.jsonl" + output = Path(tmp) / "one.jsonl" + artifact = Path(tmp) / "prompt.json" + source.write_text("".join(json.dumps(row) + "\n" for row in rows)) + index, selected, _ = create_data.create_single_sample( + str(source), + str(output), + model_path="unused", + max_length=150, + prompt_output_path=str(artifact), + require_untruncated=True, + processing_stack=fake_processing_stack(), + ) + self.assertEqual(index, 1) + self.assertEqual(selected["id"], "selected") + self.assertLessEqual( + json.loads(artifact.read_text())["full_input_tokens"], 150 + ) + + +class TestDominoOverfitGate(unittest.TestCase): + def test_passes_on_final_zero_loss_full_accuracy_and_checkpoint(self): + with tempfile.TemporaryDirectory() as tmp: + log = os.path.join(tmp, "train.log") + Path(log).write_text( + "[consumer] step 1 {'loss': 1.2, 'accuracy': 0.2}\n" + "[consumer] step 10 {'loss': 0.0, 'accuracy': 1.0}\n" + ) + checkpoint = Path(tmp) / "consumer" / "run-step10" + checkpoint.mkdir(parents=True) + (checkpoint / "training_state.pt").touch() + result = check_overfit.check_overfit( + log, + str(Path(tmp) / "consumer"), + expected_step=10, + max_loss=1e-4, + min_accuracy=1.0, + ) + self.assertTrue(result["passed"]) + self.assertEqual( + result["checkpoint"], str(checkpoint / "training_state.pt") + ) + + def test_fails_when_any_gate_is_missing(self): + with tempfile.TemporaryDirectory() as tmp: + log = os.path.join(tmp, "train.log") + Path(log).write_text( + "[consumer] step 9 {'loss': 0.01, 'accuracy': 0.9999}\n" + ) + with self.assertRaisesRegex( + ValueError, "final logged step.*final loss.*token accuracy.*checkpoint" + ): + check_overfit.check_overfit( + log, + os.path.join(tmp, "consumer"), + expected_step=10, + max_loss=1e-4, + min_accuracy=1.0, + ) + + +class TestDominoOverfitLauncher(unittest.TestCase): + def test_shared_launcher_contract_and_syntax(self): + launcher = ROOT / "examples/disagg/run_domino_disagg_overfit_gate.sh" + subprocess.run(["bash", "-n", str(launcher)], check=True) + text = launcher.read_text() + for required in ( + "${TARGET_MODEL_PATH:?set TARGET_MODEL_PATH to the target model}", + "${DRAFT_CONFIG_PATH:?set DRAFT_CONFIG_PATH to a Domino draft config}", + "${SOURCE_DATA_PATH:?set SOURCE_DATA_PATH to validated ShareGPT JSONL}", + "DISAGG_MAX_PROMPTS=${DISAGG_MAX_PROMPTS:-1}", + "DISAGG_MAX_STEPS=${DISAGG_MAX_STEPS:-400}", + "MIN_LOSS_TOKENS=$((2 * BLOCK_SIZE))", + '--reasoning-policy "$REASONING_POLICY"', + '--embedding-key "$EMBEDDING_KEY"', + '--mask-token-id "$MASK_TOKEN_ID"', + '--block-size "$BLOCK_SIZE"', + '--context-length "$MAX_LENGTH"', + '--prompt-output-path "$PROMPT_ARTIFACT_PATH"', + 'if [[ -e "$WORK_DIR" ]]', + "scripts/gates/check_overfit_metrics.py", + 'LATEST_CHECKPOINT="$CONSUMER_OUTPUT_DIR/$DISAGG_STORE_ID-step$DISAGG_MAX_STEPS"', + '[[ ! -f "$LATEST_CHECKPOINT/training_state.pt" ]]', + ): + self.assertIn(required, text) + + +if __name__ == "__main__": + unittest.main()