diff --git a/docs/docs/advanced_features/speculative_decoding.mdx b/docs/docs/advanced_features/speculative_decoding.mdx index a9b1386a52be..3dd7a2e5a868 100644 --- a/docs/docs/advanced_features/speculative_decoding.mdx +++ b/docs/docs/advanced_features/speculative_decoding.mdx @@ -101,7 +101,7 @@ SGLang provides several speculative decoding options, including EAGLE-2/EAGLE-3, DFlash draft model (linear block verification) Yes --speculative-algorithm DFLASH + --speculative-draft-model-path ... - No DP attention (--attn-dp-size > 1); pp_size == 1; disables overlap scheduler & mixed chunked prefill + CUDA DP attention requires --enable-dp-lm-head and attn_cp_size == 1; pp_size == 1 STANDALONE @@ -620,6 +620,37 @@ response = client.chat.completions.create( print(response.choices[0].message.content) ``` +### DFlash and DFlash2 with DP attention + +On NVIDIA CUDA, you can use attention DP with plain DFlash drafts and DFlash2 +candidate-selector checkpoints. Both use `--speculative-algorithm DFLASH`. +Set `--enable-dp-lm-head` so the target head has the same vocabulary shards as +its local draft replica. Pipeline parallelism and DP combined with context +parallelism are not supported. Domino and LiLiCorr heads are not supported +with CUDA DP attention. + +For example, on four NVIDIA GPUs, use TP4 with attention DP2 / TP2: + +```bash Command +python3 -m sglang.launch_server \ + --model-path Qwen/Qwen3-4B \ + --speculative-algorithm DFLASH \ + --speculative-draft-model-path z-lab/Qwen3-4B-DFlash-b16 \ + --tp-size 4 \ + --attn-dp-size 2 \ + --enable-dp-lm-head \ + --attention-backend flashinfer \ + --speculative-draft-attention-backend flashinfer \ + --cuda-graph-backend-prefill disabled +``` + +For DFlash2, use a matching target and selector checkpoint, such as +`Qwen/Qwen3.8-27B` and `incoai/Qwen3.8-27B-DFlash2`. +Each attention-DP group runs its draft within the local attention-TP group. +Draft execution stays eager; eligible target verify batches can use CUDA graphs. +The scheduler separates prefill and verify rounds across DP ranks. Keep the +experimental `SGLANG_ENABLE_DP_SPEC_PREFILL_COORDINATION` setting disabled. + ### LiLiCorr: reranked DFlash drafts A DFlash drafter is trained on per-position marginals rather than on the joint block distribution, so its drafted tokens are individually plausible yet jointly incoherent. **LiLiCorr** ([arXiv:2608.20530](https://arxiv.org/abs/2608.20530)) keeps the top-`k` candidates per block position, scores the whole lattice jointly with a small two-layer transformer, and commits a path through it greedily instead of taking the per-slot argmax. Verify is unchanged, so outputs remain distributionally identical to the target model's and only acceptance length moves. diff --git a/python/sglang/srt/arg_groups/speculative_hook.py b/python/sglang/srt/arg_groups/speculative_hook.py index 18ceaa1b581c..f4b03a662f1f 100644 --- a/python/sglang/srt/arg_groups/speculative_hook.py +++ b/python/sglang/srt/arg_groups/speculative_hook.py @@ -299,12 +299,21 @@ def _handle_dflash(server_args: ServerArgs) -> None: f"{type(current_platform).__name__} on device {cfg.device!r}." ) - # DFLASH + dp attention is validated on NPU only. - if attn_dp_enabled_of(cfg) and not cfg.device == "npu": - raise ValueError( - "Currently DFLASH speculative decoding does not support dp " - "attention on non-NPU devices." - ) + if attn_dp_enabled_of(cfg) and cfg.device != "npu": + if not cfg.device.startswith("cuda"): + raise ValueError("DFLASH with dp attention requires CUDA or NPU.") + # Both greedy projection and the selector borrow the target head. + if not cfg.enable_dp_lm_head: + raise ValueError("DFLASH with dp attention requires --enable-dp-lm-head.") + if cfg.attn_cp_size != 1: + raise ValueError( + "DFLASH with dp attention does not support context parallel." + ) + if envs.SGLANG_ENABLE_DP_SPEC_PREFILL_COORDINATION.get(): + raise ValueError( + "DFLASH with dp attention requires " + "SGLANG_ENABLE_DP_SPEC_PREFILL_COORDINATION=0." + ) if cfg.pp_size != 1: raise ValueError( diff --git a/python/sglang/srt/model_executor/forward_batch_info.py b/python/sglang/srt/model_executor/forward_batch_info.py index 41ca963bfee4..2d06f2e5b4e2 100644 --- a/python/sglang/srt/model_executor/forward_batch_info.py +++ b/python/sglang/srt/model_executor/forward_batch_info.py @@ -1169,6 +1169,9 @@ def init_new( if ret.forward_mode.is_idle(): ret.positions = torch.empty((0,), dtype=torch.int64, device=device) + if model_runner.model_config.model_is_mrope: + # DP padding can turn an idle rank into a target verify batch. + ret.mrope_positions = ret.positions.unsqueeze(0).expand(3, -1) if model_runner.lora_manager is not None: model_runner.lora_manager.reset_lora_batch() return ret diff --git a/python/sglang/srt/speculative/dflash_dp.py b/python/sglang/srt/speculative/dflash_dp.py new file mode 100644 index 000000000000..8e4905a91e8e --- /dev/null +++ b/python/sglang/srt/speculative/dflash_dp.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import torch +from torch import nn + +from sglang.srt.layers.quantization.unquant import UnquantizedEmbeddingMethod +from sglang.srt.layers.vocab_parallel_embedding import VocabParallelEmbedding + + +@torch.no_grad() +def gather_draft_embedding(embedding: nn.Module) -> torch.Tensor | None: + # Other modules may scale their output or own a checkpoint embedding. + if not isinstance(embedding, VocabParallelEmbedding): + return None + if isinstance(embedding.quant_method, UnquantizedEmbeddingMethod): + weight = embedding.weight + else: + # Packed weights are not embedding rows. Materialize the table through + # the same quantization method as an ordinary embedding lookup. + ids = torch.arange( + embedding.num_embeddings_per_partition, + device=next(embedding.parameters()).device, + ) + weight = embedding.quant_method.embedding(embedding, ids) + if embedding.output_dtype is not None: + weight = weight.to(embedding.output_dtype) + if embedding.tp_size == 1 and embedding.num_embeddings == embedding.org_vocab_size: + return weight[: embedding.num_embeddings] + + shards = ( + embedding.tp_group.all_gather(weight, dim=0).view( + embedding.tp_size, *weight.shape + ) + if embedding.tp_size > 1 + else weight.unsqueeze(0) + ) + full_weight = weight.new_empty((embedding.num_embeddings, weight.shape[1])) + for rank, shard in enumerate(shards): + indices = embedding._get_indices( + embedding.num_embeddings_padded, + embedding.org_vocab_size_padded, + embedding.num_embeddings, + embedding.org_vocab_size, + rank, + embedding.tp_size, + ) + full_weight[indices.org_vocab_start_index : indices.org_vocab_end_index].copy_( + shard[: indices.num_org_elements] + ) + offset = indices.num_org_elements_padded + full_weight[ + indices.added_vocab_start_index : indices.added_vocab_end_index + ].copy_(shard[offset : offset + indices.num_added_elements]) + return full_weight diff --git a/python/sglang/srt/speculative/dflash_info.py b/python/sglang/srt/speculative/dflash_info.py index 02becd4a228f..5826777d87d3 100644 --- a/python/sglang/srt/speculative/dflash_info.py +++ b/python/sglang/srt/speculative/dflash_info.py @@ -67,13 +67,14 @@ def prepare_for_verify( self, batch: ScheduleBatch, target_worker: TpModelWorker, + *, + defer_metadata_init: bool = False, ) -> tuple[ForwardBatch, bool]: """Prepare a DFLASH verify forward batch for overlap scheduling. The caller computes and stores `batch.out_cache_loc` before this - method is called. GPU keeps the original pre-planning path. NPU leaves - attention/graph metadata initialization to ModelRunner because DP/EP - padding can still change the compressor's runtime shapes. + method is called. Deferred callers leave attention/graph planning to + ModelRunner, after DP/EP padding has finalized the runtime shapes. """ from sglang.srt.speculative.spec_utils import prepare_mamba_track_for_verify @@ -116,11 +117,9 @@ def prepare_for_verify( verify_forward_batch ) ) - if _is_npu: - # Do not pre-plan target verify on NPU. DP/EP padding can change - # the compressor's logical batch without changing ForwardBatch's - # stale-plan shape fields. Let ModelRunner select graph/eager and - # initialize metadata after final batch preparation. + if _is_npu or defer_metadata_init: + # DP padding can change the live rows and graph eligibility. + # ModelRunner owns planning after final batch preparation. return verify_forward_batch, can_run_cuda_graph elif can_run_cuda_graph: target_worker.model_runner.decode_cuda_graph_runner.load_batch( diff --git a/python/sglang/srt/speculative/dflash_worker_v2.py b/python/sglang/srt/speculative/dflash_worker_v2.py index 71050f712978..fb15b49f62f1 100644 --- a/python/sglang/srt/speculative/dflash_worker_v2.py +++ b/python/sglang/srt/speculative/dflash_worker_v2.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, List, Optional, Tuple import torch -import torch.distributed as dist from sglang.kernels.ops.speculative.cache_locs import ( assign_extend_cache_locs_func, @@ -23,6 +22,7 @@ from sglang.srt.layers.dp_attention import get_dp_tp_group from sglang.srt.layers.logits_processor import ( LogitsProcessor, + LogitsProcessorOutput, should_apply_lm_head_quant_method, ) from sglang.srt.layers.logprob_processor import compute_spec_logprobs @@ -56,6 +56,7 @@ from sglang.srt.sampling.sampling_batch_info import SamplingBatchInfo from sglang.srt.server_args import ServerArgs from sglang.srt.speculative.base_spec_worker import BaseSpecWorker +from sglang.srt.speculative.dflash_dp import gather_draft_embedding from sglang.srt.speculative.dflash_info import DFlashVerifyInput from sglang.srt.speculative.dflash_info_v2 import DFlashDraftInputV2 from sglang.srt.speculative.dflash_utils import ( @@ -451,6 +452,12 @@ def __init__( draft_hf_config=self.draft_model_runner.model_config.hf_config ) self._is_domino = draft_config.is_domino + if get_parallel().attn_dp_enabled and not _is_npu: + if self._is_domino or self.lilicorr is not None: + raise ValueError( + "DFLASH with dp attention supports plain DFlash and DFlash2 " + "drafts; Domino and LiLiCorr heads are not supported." + ) self.domino_candidate_pool_size = int( get_spec().speculative_domino_candidate_pool_size ) @@ -689,6 +696,7 @@ def init_attention_backends(self): ) def init_cuda_graphs(self): + target_dp_enabled = get_parallel().attn_dp_enabled with ( draft_pp_context(), draft_tp_context(self.draft_parallel_layout), @@ -707,10 +715,9 @@ def init_cuda_graphs(self): "device graph capture.", type(current_platform).__name__, ) - if get_parallel().attn_dp_enabled and capture_decode_cuda_graph: - # Idle DP ranks skip the draft step, so they cannot join a - # shared graph capture/replay; keep the draft eager under dp - # attention. + if target_dp_enabled and capture_decode_cuda_graph: + # Keep the eager-draft policy for idle/unequal DP batches. + # The narrowed draft scope hides the target's DP flag. capture_decode_cuda_graph = False if self._target_tp_rank == 0: logger.warning( @@ -741,6 +748,25 @@ def init_cuda_graphs(self): self._draft_worker.init_cuda_graphs( capture_decode_cuda_graph=capture_decode_cuda_graph ) + if target_dp_enabled and not _is_npu and self.selector is not None: + self._warm_eager_selector() + + def _warm_eager_selector(self) -> None: + # Build selector kernels before serving rather than under the watchdog. + hidden = torch.zeros( + (int(self.block_size), int(self.draft_model.config.hidden_size)), + dtype=self.draft_model_runner.dtype, + device=self.device, + ) + self._propose_selector_block( + draft_logits_output=LogitsProcessorOutput( + next_token_logits=None, hidden_states=hidden + ), + bs=1, + lm_head=unwrap_lora_layer(self._target_worker.model_runner.model.lm_head), + anchor_token_ids=torch.zeros((1,), dtype=torch.int64, device=self.device), + sampling_info=None, + ) def _prewarm_batch_size(self, block_size: int) -> int: """Largest batch the non-greedy verify path can see in one step.""" @@ -1352,37 +1378,15 @@ def _maybe_merge_trained_mask_embedding(self) -> None: ) def _cache_full_embed_weight(self) -> None: - """Cache the full target embedding replicated on GPU during init. - - Under dp attention, idle DP ranks skip the draft step, so the attn-TP - all_reduce inside VocabParallelEmbedding gets mismatched calls. Gather - the full embedding once during init and keep it replicated, making - the draft block-id lookup a collective-free on-device F.embedding. - """ if not get_parallel().attn_dp_enabled: return - - tp_group = get_parallel().tp_group - tp_size = int(tp_group.world_size) - if tp_size <= 1: - return - - target_model = self._target_worker.model_runner.model - embed_module = target_model.get_input_embeddings() - local_w = embed_module.weight.data - shard = getattr(embed_module, "shard_indices", None) - num_org = int(shard.num_org_elements) if shard else local_w.shape[0] - vocab_size = int(self._target_worker.model_runner.model_config.vocab_size) - - shard_t = local_w[:num_org].contiguous() - parts = [torch.empty_like(shard_t) for _ in range(tp_size)] - dist.all_gather(parts, shard_t, group=tp_group.device_group) - self._full_embed_gpu = torch.cat(parts, dim=0)[:vocab_size] - if get_parallel().tp_rank == 0: - logger.info( - "DFLASH cached full embed on GPU for dp attention: shape=%s", - list(self._full_embed_gpu.shape), + embedding = unwrap_lora_layer( + _resolve_dflash_embedding_module( + self.draft_model, self._target_worker.model_runner.model ) + ) + # Reconstruct on the table's own group before DP ranks can go idle. + self._full_embed_gpu = gather_draft_embedding(embedding) def _resolve_mask_token_id( self, *, mask_token: str, mask_token_id: Optional[int] = None @@ -2441,7 +2445,7 @@ def forward_batch_generation( capture_hidden_mode=CaptureHiddenMode.FULL, ) idle_verify_forward_batch, _ = idle_verify_input.prepare_for_verify( - batch, self._target_worker + batch, self._target_worker, defer_metadata_init=True ) # Force eager: the active ranks run eager verify when any DP # rank is idle (see the idle-guard below); a graph-replaying @@ -2452,7 +2456,7 @@ def forward_batch_generation( batch=None, forward_batch=idle_verify_forward_batch, is_verify=True, - skip_attn_backend_init=True, + skip_attn_backend_init=True if _is_npu else None, ) empty_ids = torch.empty((0,), dtype=torch.int64, device=self.device) empty_lens = torch.empty((0,), dtype=torch.int32, device=self.device) @@ -2566,8 +2570,8 @@ def forward_batch_generation( verify_out_cache_loc_2d.copy_(verify_out_cache_loc.view(bs, block_size)) if self._full_embed_gpu is not None: - # Replicated lookup avoids the mismatched attn-TP all_reduce - # inside VocabParallelEmbedding under dp attention. + # A borrowed embedding may use target-wide collectives, while + # other DP replicas are idle or looking up different token IDs. noise_embedding = torch.nn.functional.embedding( block_ids, self._full_embed_gpu ) @@ -2843,7 +2847,9 @@ def forward_batch_generation( batch.seq_lens_sum = int(draft_input.nxt_kv_lens_sum) verify_forward_batch, _ = verify_input.prepare_for_verify( - batch, self.target_worker + batch, + self.target_worker, + defer_metadata_init=get_parallel().attn_dp_enabled, ) batch.seq_lens_cpu = seq_lens_cpu_backup batch.seq_lens_sum = seq_lens_sum_backup @@ -2869,7 +2875,9 @@ def forward_batch_generation( batch=None, forward_batch=verify_forward_batch, is_verify=True, - skip_attn_backend_init=True if not _is_npu else None, + skip_attn_backend_init=( + None if _is_npu or get_parallel().attn_dp_enabled else True + ), ) logits_output = target_out.logits_output can_run_cuda_graph = target_out.can_run_cuda_graph diff --git a/test/registered/e2e/speculative/test_dflash_dp_attention.py b/test/registered/e2e/speculative/test_dflash_dp_attention.py new file mode 100644 index 000000000000..200d0e484456 --- /dev/null +++ b/test/registered/e2e/speculative/test_dflash_dp_attention.py @@ -0,0 +1,149 @@ +"""DFLASH DP replicas must preserve target outputs with sharded vocabulary tables.""" + +import unittest +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager + +import requests + +from sglang.test.ci.ci_register import register_cuda_ci +from sglang.test.test_utils import ( + DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, + DEFAULT_URL_FOR_TEST, + CustomTestCase, + popen_launch_server, + terminate_and_kill_process_tree, +) + +# TP4 / DP2 exercises both local attention TP and idle attention-DP peers. +register_cuda_ci(est_time=480, stage="nightly", runner_config="4-gpu-h100") + + +class TestDFlashDPAttention(CustomTestCase): + @contextmanager + def _server(self, model, revision, draft=None, draft_revision=None): + args = [ + "--revision", + revision, + "--tp-size", + "4", + "--attn-dp-size", + "2", + "--enable-dp-lm-head", + "--attention-backend", + "fa3", + "--enable-deterministic-inference", + "--mem-fraction-static", + "0.6", + "--context-length", + "4096", + "--max-total-tokens", + "16384", + "--max-running-requests", + "8", + "--chunked-prefill-size", + "1024", + "--cuda-graph-backend-prefill", + "disabled", + "--cuda-graph-max-bs-decode", + "4", + "--random-seed", + "42", + ] + if draft is not None: + args += [ + "--speculative-algorithm", + "DFLASH", + "--speculative-draft-model-path", + draft, + "--speculative-draft-model-revision", + draft_revision, + "--speculative-draft-attention-backend", + "flashinfer", + ] + process = popen_launch_server( + model, + DEFAULT_URL_FOR_TEST, + timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, + other_args=args, + env={"SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY": "1"}, + ) + try: + yield + finally: + terminate_and_kill_process_tree(process) + + def _generate(self, prompt, rank, length, temperature=0): + response = requests.post( + DEFAULT_URL_FOR_TEST + "/generate", + json={ + "text": prompt, + "routed_dp_rank": rank, + "sampling_params": { + "temperature": temperature, + "max_new_tokens": length, + "ignore_eos": True, + "sampling_seed": 42, + }, + }, + timeout=180, + ) + response.raise_for_status() + result = response.json() + self.assertEqual(len(result["output_ids"]), length) + return result + + def _check_model(self, model, revision, draft, draft_revision): + prompts = [ + ("The capital of France is", 16), + ( + "Explain how a compiler translates source code into machine instructions. " + * 12, + 64, + ), + ] + with self._server(model, revision): + reference = [ + self._generate(text, 0, n)["output_ids"] for text, n in prompts + ] + with self._server(model, revision, draft, draft_revision): + # Isolated requests exercise idle peers, in both rank directions. + for rank in (0, 1): + for (text, n), expected in zip(prompts, reference): + result = self._generate(text, rank, n) + self.assertEqual(result["output_ids"], expected) + self.assertGreater(result["meta_info"]["spec_verify_ct"], 0) + # Unequal lengths transition between active and idle peers, with + # target graphs enabled and cached prefixes on both replicas. + for rank in (0, 1): + with ThreadPoolExecutor(2) as pool: + futures = [ + pool.submit(self._generate, text, (rank + i) % 2, n) + for i, (text, n) in enumerate(prompts) + ] + for future, expected in zip(futures, reference): + self.assertEqual(future.result()["output_ids"], expected) + sampled = self._generate( + "Write a short story about an astronomer.", 1, 64, 0.7 + ) + self.assertGreater(sampled["meta_info"]["spec_verify_ct"], 0) + + def test_dflash(self): + self._check_model( + "Qwen/Qwen3-4B", + "1cfa9a7208912126459214e8b04321603b3df60c", + "z-lab/Qwen3-4B-DFlash-b16", + "b74e3a329c4d963783143b1e970d95b002be72bd", + ) + + def test_dflash2_hybrid_target(self): + self._check_model( + "Qwen/Qwen3.8-27B", + "1d4bf0f2ff6012fd82039f2fa52739d0dd7c60c0", + "incoai/Qwen3.8-27B-DFlash2", + "015e795645c74b1a0eeef3b570031fb62e769bc5", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/spec/test_dflash_dp_attention.py b/test/registered/unit/spec/test_dflash_dp_attention.py new file mode 100644 index 000000000000..0c4372aee3cb --- /dev/null +++ b/test/registered/unit/spec/test_dflash_dp_attention.py @@ -0,0 +1,225 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import torch +from torch import nn + +from sglang.srt.arg_groups.overrides import resolution_result +from sglang.srt.arg_groups.speculative_hook import _handle_dflash +from sglang.srt.environ import envs +from sglang.srt.layers import linear, vocab_parallel_embedding +from sglang.srt.layers.vocab_parallel_embedding import VocabParallelEmbedding +from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode +from sglang.srt.server_args import ServerArgs +from sglang.srt.speculative import dflash_info +from sglang.srt.speculative.dflash_dp import gather_draft_embedding +from sglang.srt.speculative.dflash_info import DFlashVerifyInput +from sglang.test.ci.ci_register import register_cpu_ci +from sglang.test.test_utils import CustomTestCase + +register_cpu_ci(est_time=8, suite="base-a-test-cpu") + + +class TestDFlashDPAdmission(CustomTestCase): + def _args(self, **overrides): + fields = dict( + model_path="dummy", + device="cuda", + tp_size=4, + attn_dp_size=2, + attn_cp_size=1, + enable_dp_lm_head=True, + speculative_algorithm="DFLASH", + speculative_draft_model_path="draft", + speculative_num_draft_tokens=8, + speculative_draft_attention_backend="flashinfer", + ) + return ServerArgs(**(fields | overrides)) + + def test_cuda_dp_keeps_explicit_placement_and_raw_input(self): + args = self._args() + _handle_dflash(args) + self.assertTrue(args.enable_dp_lm_head) + self.assertEqual(resolution_result(args, "attn_dp_size"), 2) + self.assertIsNone(args.speculative_num_steps) + self.assertEqual(resolution_result(args, "speculative_num_steps"), 1) + + def test_incompatible_cuda_dp_layouts_fail(self): + for fields, message in [ + ({"enable_dp_lm_head": False}, "enable-dp-lm-head"), + ({"attn_cp_size": 2}, "context parallel"), + ({"pp_size": 2}, "pp_size"), + ({"device": "xpu"}, "CUDA or NPU"), + ]: + with ( + self.subTest(fields=fields), + self.assertRaisesRegex(ValueError, message), + ): + _handle_dflash(self._args(**fields)) + + def test_unimplemented_mixed_prefill_coordination_fails_at_startup(self): + with ( + envs.SGLANG_ENABLE_DP_SPEC_PREFILL_COORDINATION.override(True), + self.assertRaisesRegex(ValueError, "DP_SPEC_PREFILL_COORDINATION=0"), + ): + _handle_dflash(self._args()) + + def test_non_dp_does_not_require_dp_head(self): + args = self._args(attn_dp_size=1, enable_dp_lm_head=False) + _handle_dflash(args) + self.assertFalse(resolution_result(args, "enable_dp_lm_head")) + + +class _TableGroup: + def __init__(self, shards): + self.shards = shards + + def all_gather(self, weight, dim): + if not any(torch.equal(weight, shard) for shard in self.shards): + raise AssertionError("embedding used a different table's group") + return torch.cat(self.shards, dim=dim) + + +def _embedding_shards(org_vocab, added_vocab, tp_size): + embeddings = [] + for rank in range(tp_size): + parallel = SimpleNamespace(tp_size=tp_size, tp_rank=rank, tp_group=None) + with ( + patch.object( + vocab_parallel_embedding, "get_parallel", return_value=parallel + ), + patch.object(linear, "get_parallel", return_value=parallel), + ): + embedding = VocabParallelEmbedding( + num_embeddings=org_vocab + added_vocab, + org_num_embeddings=org_vocab, + embedding_dim=2, + padding_size=8, + ) + indices = embedding.shard_indices + with torch.no_grad(): + embedding.weight.fill_(-999) + for token in range( + indices.org_vocab_start_index, indices.org_vocab_end_index + ): + embedding.weight[token - indices.org_vocab_start_index].fill_(token) + for token in range( + indices.added_vocab_start_index, indices.added_vocab_end_index + ): + offset = ( + indices.num_org_elements_padded + + token + - indices.added_vocab_start_index + ) + embedding.weight[offset].fill_(token) + embeddings.append(embedding) + group = _TableGroup([embedding.weight for embedding in embeddings]) + for embedding in embeddings: + embedding.tp_group = group + return embeddings + + +class TestDFlashDPEmbedding(CustomTestCase): + def test_padding_and_added_vocabulary_keep_logical_token_ids(self): + """Uneven and empty vocabulary shards must not shift token IDs after gathering.""" + for org_vocab, added_vocab, tp_size in [(5, 0, 4), (9, 3, 4), (9, 3, 1)]: + with self.subTest( + org_vocab=org_vocab, added_vocab=added_vocab, tp_size=tp_size + ): + expected = ( + torch.arange(org_vocab + added_vocab) + .float() + .unsqueeze(1) + .expand(-1, 2) + ) + for embedding in _embedding_shards(org_vocab, added_vocab, tp_size): + # The table's retained group wins over any ambient draft/target scope. + actual = gather_draft_embedding(embedding) + torch.testing.assert_close(actual, expected) + + def test_replicated_table_keeps_storage(self): + embedding = _embedding_shards(9, 0, 1)[0] + actual = gather_draft_embedding(embedding) + self.assertEqual(actual.data_ptr(), embedding.weight.data_ptr()) + torch.testing.assert_close(actual[:, 0], torch.arange(9).float()) + + def test_materialized_embedding_preserves_quant_method_and_output_dtype(self): + embeddings = _embedding_shards(9, 3, 4) + group = _TableGroup([e.weight.double() * 2 for e in embeddings]) + for embedding in embeddings: + embedding.quant_method = SimpleNamespace( + embedding=lambda layer, ids: layer.weight[ids].double() * 2 + ) + embedding.output_dtype = torch.float64 + embedding.tp_group = group + actual = gather_draft_embedding(embedding) + expected = (torch.arange(12).double() * 2).unsqueeze(1).expand(-1, 2) + torch.testing.assert_close(actual, expected) + + def test_other_embedding_keeps_its_forward_semantics(self): + class ScaledEmbedding(nn.Embedding): + def forward(self, ids): + return super().forward(ids) * 2 + + embedding = ScaledEmbedding(8, 2) + self.assertIsNone(gather_draft_embedding(embedding)) + + +class TestDFlashDPMetadata(CustomTestCase): + def test_deferred_verify_preserves_live_inputs_until_final_planning(self): + """Early graph loading must not replace an idle or unequal DP batch's live rows.""" + for mode in (ForwardMode.IDLE, ForwardMode.DECODE): + for graph_eligible in (False, True): + with self.subTest(mode=mode, graph_eligible=graph_eligible): + n = 0 if mode.is_idle() else 4 + ids = torch.arange(n) + forward = ForwardBatch( + forward_mode=mode, + batch_size=0 if mode.is_idle() else 1, + input_ids=ids, + req_pool_indices=torch.zeros( + 0 if mode.is_idle() else 1, dtype=torch.long + ), + seq_lens=torch.ones( + 0 if mode.is_idle() else 1, dtype=torch.long + ), + out_cache_loc=ids, + seq_lens_sum=0 if mode.is_idle() else 1, + positions=ids, + ) + + def preplan(batch): + batch.input_ids = torch.full((8,), -1) + batch.mark_forward_metadata_ready() + + runner = SimpleNamespace( + decode_cuda_graph_runner=SimpleNamespace( + can_run_graph=lambda batch: graph_eligible, + load_batch=preplan, + ), + attn_backend=SimpleNamespace(init_forward_metadata=preplan), + ) + batch = SimpleNamespace(forward_mode=mode) + spec = DFlashVerifyInput( + draft_token=ids, positions=ids, draft_token_num=4 + ) + with ( + patch.object(dflash_info, "_is_npu", False), + patch.object(ForwardBatch, "init_new", return_value=forward), + patch( + "sglang.srt.speculative.spec_utils.prepare_mamba_track_for_verify" + ), + ): + result, can_graph = spec.prepare_for_verify( + batch, + SimpleNamespace(model_runner=runner), + defer_metadata_init=True, + ) + torch.testing.assert_close(result.input_ids, ids) + self.assertTrue(result.needs_forward_metadata_init()) + self.assertEqual(can_graph, graph_eligible) + + +if __name__ == "__main__": + unittest.main()