diff --git a/docs/docs/advanced_features/server_arguments.mdx b/docs/docs/advanced_features/server_arguments.mdx index 608fe3166bfb..14bb5a3552fe 100644 --- a/docs/docs/advanced_features/server_arguments.mdx +++ b/docs/docs/advanced_features/server_arguments.mdx @@ -1648,6 +1648,12 @@ Combining `--enable-response-store` with `--disaggregation-mode=prefill` or `dec `None` Type: int + + `--speculative-draft-kv-ratio` + The draft KV pool as a fraction of the target's token capacity. At 1.0 the draft holds one slot per target token. Below 1.0 the draft's KV lives in a sliding-window pool. Requires DFLASH, `--speculative-draft-window-size`, and a draft whose every layer is sliding_attention, and is not supported yet on targets with sliding-window layers of their own. + `1.0` + Type: float + diff --git a/docs/docs/advanced_features/speculative_decoding.mdx b/docs/docs/advanced_features/speculative_decoding.mdx index 004e9ef75b81..ad67d65ac1b0 100644 --- a/docs/docs/advanced_features/speculative_decoding.mdx +++ b/docs/docs/advanced_features/speculative_decoding.mdx @@ -565,6 +565,11 @@ Relevant parameters: Draft KV sliding-window size. Must be >= speculative-num-draft-tokens when set. None + + --speculative-draft-kv-ratio + The draft KV pool as a fraction of the target's token capacity. Below 1.0 the draft's KV lives in a sliding-window pool and needs an all-SWA draft. Not supported yet on targets with sliding-window layers of their own. + 1.0 + diff --git a/python/sglang/srt/arg_groups/field_order.py b/python/sglang/srt/arg_groups/field_order.py index 6b104eb4affb..930d47589b2e 100644 --- a/python/sglang/srt/arg_groups/field_order.py +++ b/python/sglang/srt/arg_groups/field_order.py @@ -287,6 +287,7 @@ "speculative_dsa_topk_backend", "speculative_draft_kv_cache_dtype", "speculative_draft_window_size", + "speculative_draft_kv_ratio", "speculative_moe_runner_backend", "speculative_moe_a2a_backend", "speculative_draft_model_quantization", diff --git a/python/sglang/srt/arg_groups/fields/spec.py b/python/sglang/srt/arg_groups/fields/spec.py index 5ae41ce085ed..e6ba4337d864 100644 --- a/python/sglang/srt/arg_groups/fields/spec.py +++ b/python/sglang/srt/arg_groups/fields/spec.py @@ -166,6 +166,10 @@ class Spec(msgspec.Struct): Optional[int], "Number of leading 'attention sink' tokens the draft always attends to, in addition to the --speculative-draft-window-size recent window (StreamingLLM-style). Honored only by the built-in EAGLE/MTP draft-decode path on the Triton and FlashInfer draft backends; the Llama EAGLE-3 and DFLASH windows ignore it. 0/unset => pure recent window. Requires --speculative-draft-window-size.", ] = None + speculative_draft_kv_ratio: A[ + float, + "The draft KV pool as a fraction of the target's token capacity. It should be between 0 and 1. At 1.0 (the default) the draft holds one slot per target token. Below 1.0 the draft's KV lives in a sliding-window pool whose out-of-window slots are freed and whose cached prefixes stay reusable while their trailing window survives. Requires DFLASH, --speculative-draft-window-size, and a draft whose every layer is sliding_attention, and is not supported yet on targets with sliding-window layers of their own. The pool must still cover the running requests, or startup fails with the smallest usable ratio.", + ] = 1.0 speculative_moe_runner_backend: A[ Optional[str], Arg( diff --git a/python/sglang/srt/arg_groups/speculative_hook.py b/python/sglang/srt/arg_groups/speculative_hook.py index 5afef4f83223..e50340a22862 100644 --- a/python/sglang/srt/arg_groups/speculative_hook.py +++ b/python/sglang/srt/arg_groups/speculative_hook.py @@ -388,6 +388,16 @@ def _handle_dflash(server_args: ServerArgs) -> None: f"window_size={cfg.speculative_draft_window_size}, block_size={draft_tokens}." ) + ratio = cfg.speculative_draft_kv_ratio + if not 0 < ratio <= 1: + raise ValueError( + "--speculative-draft-kv-ratio must be in (0, 1], got " + f"{ratio}. Above 1 the draft would hold slots the target cannot " + "address." + ) + if ratio < 1: + _validate_dflash_draft_kv_ratio(server_args) + _resolve_dflash_draft_attention_backend(server_args) if cfg.max_running_requests is None: @@ -401,6 +411,53 @@ def _handle_dflash(server_args: ServerArgs) -> None: ) +def _validate_dflash_draft_kv_ratio(server_args: ServerArgs) -> None: + """We free a request's draft slots as they leave the window, so every draft + layer must be sliding-window attention.""" + cfg = resolving_view(server_args) + if cfg.speculative_draft_window_size is None: + raise ValueError( + "--speculative-draft-kv-ratio below 1 requires " + "--speculative-draft-window-size." + ) + if not cfg.device.startswith("cuda"): + raise ValueError( + "--speculative-draft-kv-ratio below 1 is only supported on CUDA." + ) + unsupported = [ + flag + for flag, enabled in ( + ("--enable-hierarchical-cache", cfg.enable_hierarchical_cache), + ("--enable-unified-memory", cfg.enable_unified_memory), + ("--enable-hisparse", cfg.enable_hisparse), + ("--disaggregation-mode", cfg.disaggregation_mode != "null"), + ("--dcp-size", cfg.dcp_size > 1), + ) + if enabled + ] + if unsupported: + raise ValueError( + "--speculative-draft-kv-ratio below 1 does not support " + f"{', '.join(unsupported)}." + ) + + from sglang.srt.speculative.dflash_utils import get_dflash_layer_types + from sglang.srt.utils.hf_transformers_utils import get_config + + draft_hf_config = get_config( + cfg.speculative_draft_model_path, + trust_remote_code=cfg.trust_remote_code, + revision=cfg.speculative_draft_model_revision, + model_override_args=json.loads(cfg.json_model_override_args), + ) + layer_types = get_dflash_layer_types(draft_hf_config) + if not layer_types or any(t != "sliding_attention" for t in layer_types): + raise ValueError( + "--speculative-draft-kv-ratio below 1 requires every draft layer to " + f"be sliding_attention, got layer_types={layer_types}." + ) + + def _handle_uno(server_args: ServerArgs) -> None: cfg = resolving_view(server_args) diff --git a/python/sglang/srt/mem_cache/allocator/swa.py b/python/sglang/srt/mem_cache/allocator/swa.py index cee82b123606..9ebb23f9815b 100644 --- a/python/sglang/srt/mem_cache/allocator/swa.py +++ b/python/sglang/srt/mem_cache/allocator/swa.py @@ -1,4 +1,5 @@ import logging +from typing import Optional import torch @@ -7,6 +8,7 @@ from sglang.srt.mem_cache.allocator.paged import PagedTokenToKVPoolAllocator from sglang.srt.mem_cache.allocator.token import TokenToKVPoolAllocator from sglang.srt.mem_cache.base_swa_memory_pool import BaseSWAKVPool +from sglang.srt.mem_cache.memory_pool import KVCache from sglang.srt.utils import is_npu from sglang.srt.utils.common import get_num_new_pages from sglang.srt.utils.invariants import Bucket, Invariant, IsTrue, expect @@ -866,3 +868,82 @@ def clear(self): def is_swa_req_ring(allocator) -> bool: return isinstance(allocator, SWATokenToKVPoolAllocator) and allocator.swa_req_ring + + +class _DraftSWAKVPoolBinding(BaseSWAKVPool): + """The target's pool with a speculative draft's pool as the SWA side. + + The draft runner builds its pool after the target allocator, so the SWA side + starts out None and takes the mapping when it is attached. + """ + + swa_req_ring_size = None + + def __init__(self, full_kv_pool: KVCache): + self.full_kv_pool = full_kv_pool + self.swa_kv_pool: Optional[BaseSWAKVPool] = None + self.full_to_swa_index_mapping: Optional[torch.Tensor] = None + + def attach_swa_kv_pool(self, swa_kv_pool: BaseSWAKVPool) -> None: + self.swa_kv_pool = swa_kv_pool + swa_kv_pool.register_mapping(self.full_to_swa_index_mapping) + + def register_mapping(self, full_to_swa_index_mapping: torch.Tensor) -> None: + self.full_to_swa_index_mapping = full_to_swa_index_mapping + if self.swa_kv_pool is not None: + self.swa_kv_pool.register_mapping(full_to_swa_index_mapping) + + def translate_loc_from_full_to_swa(self, kv_indices: torch.Tensor) -> torch.Tensor: + return self.full_to_swa_index_mapping[kv_indices] + + def get_state_buf_infos(self): + return self.swa_kv_pool.get_contiguous_buf_infos() + + def get_key_buffer(self, layer_id: int): + return self.full_kv_pool.get_key_buffer(layer_id) + + def get_value_buffer(self, layer_id: int): + return self.full_kv_pool.get_value_buffer(layer_id) + + def get_kv_buffer(self, layer_id: int): + return self.full_kv_pool.get_kv_buffer(layer_id) + + def set_kv_buffer(self, *args, **kwargs): + return self.full_kv_pool.set_kv_buffer(*args, **kwargs) + + +class DraftSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator): + """Target allocator whose SWA side is a bounded speculative draft KV pool. + + Every target layer is full attention. The SWA slot handed out with each + target slot indexes the draft pool. + """ + + def __init__( + self, + size: int, + size_swa: int, + page_size: int, + dtype: torch.dtype, + device: str, + kvcache: KVCache, + need_sort: bool, + req_to_token_pool=None, + ): + super().__init__( + size, + size_swa, + page_size, + dtype, + device, + _DraftSWAKVPoolBinding(kvcache), + need_sort, + req_to_token_pool=req_to_token_pool, + ) + + def attach_draft_kv_pool(self, draft_kv_pool: BaseSWAKVPool) -> None: + self._kvcache.attach_swa_kv_pool(draft_kv_pool) + + def get_kvcache(self): + # Backends check this to detect a hybrid-SWA target. + return self._kvcache.full_kv_pool diff --git a/python/sglang/srt/mem_cache/kv_cache_builder.py b/python/sglang/srt/mem_cache/kv_cache_builder.py index f403e7cd43b6..42ac470a94dd 100644 --- a/python/sglang/srt/mem_cache/kv_cache_builder.py +++ b/python/sglang/srt/mem_cache/kv_cache_builder.py @@ -55,6 +55,7 @@ class KVCacheBuildResult: get_memory, get_parallel, get_schedule, + get_spec, ) from sglang.srt.speculative.base_spec_worker import HiCacheDraftMode from sglang.srt.utils import is_hip @@ -277,16 +278,26 @@ def build_kv_cache( # Hybrid memory pool token_to_kv_pool = tp_worker.model_runner.token_to_kv_pool - is_hybrid_swa = tp_worker.is_hybrid_swa and ( - not isinstance(token_to_kv_pool, DeepSeekV4TokenToKVPool) - or token_to_kv_pool.needs_paged_swa_allocator + # The bounded draft's KV lives on the SWA side of the allocator, so the + # cache maintains it like a hybrid-SWA pool. + bounded_draft_kv = tp_worker.model_runner.draft_kv_ratio < 1 + is_hybrid_swa = bounded_draft_kv or ( + tp_worker.is_hybrid_swa + and ( + not isinstance(token_to_kv_pool, DeepSeekV4TokenToKVPool) + or token_to_kv_pool.needs_paged_swa_allocator + ) ) is_hybrid_ssm = uses_ssm_state(tp_worker.model_runner.model_config) is_dsa = is_deepseek_dsa(model_config.hf_config) sliding_window_size = None if is_hybrid_swa: - sliding_window_size = tp_worker.sliding_window_size + sliding_window_size = ( + get_spec().speculative_draft_window_size + if bounded_draft_kv + else tp_worker.sliding_window_size + ) full_tokens_per_layer, swa_tokens_per_layer = ( tp_worker.get_tokens_per_layer_info() ) diff --git a/python/sglang/srt/mem_cache/kv_cache_configurator.py b/python/sglang/srt/mem_cache/kv_cache_configurator.py index 97382225b680..08d434955ca8 100644 --- a/python/sglang/srt/mem_cache/kv_cache_configurator.py +++ b/python/sglang/srt/mem_cache/kv_cache_configurator.py @@ -45,6 +45,7 @@ HiSparseTokenToKVPoolAllocator, ) from sglang.srt.mem_cache.allocator.swa import ( + DraftSWATokenToKVPoolAllocator, PureSWATokenToKVPoolAllocator, SWATokenToKVPoolAllocator, is_swa_req_ring, @@ -289,10 +290,31 @@ class KVCacheConfigurator: hybrid_kda_config: Optional[Any] = field(init=False) is_hybrid_swa_mtp_draft: bool = field(init=False) draft_swa_full_capacity: bool = field(init=False) + draft_kv_ratio: float = field(init=False) def __post_init__(self) -> None: self.mambaish_config = mambaish_config(self.model_config) self.hybrid_gdn_config = hybrid_gdn_config(self.model_config) + # Below 1 the draft's KV moves to the SWA side of the target allocator. + if self.is_draft_worker: + self.draft_kv_ratio = ( + get_spec().speculative_draft_kv_ratio + if isinstance( + self.token_to_kv_pool_allocator, DraftSWATokenToKVPoolAllocator + ) + else 1.0 + ) + else: + self.draft_kv_ratio = ( + get_spec().speculative_draft_kv_ratio + if self.spec_algorithm.is_dflash_family() + else 1.0 + ) + if self.draft_kv_ratio < 1 and self.is_hybrid_swa: + raise ValueError( + "--speculative-draft-kv-ratio below 1 is not supported yet " + "on a target with sliding-window layers." + ) self.hybrid_kda_config = hybrid_kda_config(self.model_config) self.is_hybrid_swa_mtp_draft = ( self.is_draft_worker @@ -419,7 +441,7 @@ def _derive_pool_sizes(self, *, config: MemoryPoolConfig) -> _PoolSizes: max_running_requests = config.max_running_requests full_max_total_num_tokens = None swa_max_total_num_tokens = None - if self.is_hybrid_swa: + if self.is_hybrid_swa or self.draft_kv_ratio < 1: full_max_total_num_tokens = config.full_max_total_num_tokens swa_max_total_num_tokens = config.swa_max_total_num_tokens @@ -1302,6 +1324,12 @@ def _build_token_to_kv_pool( swa_max_total_num_tokens=sizes.swa_max_total_num_tokens, mha_pool_class=mha_pool_class, ) + elif self.is_draft_worker and self.draft_kv_ratio < 1: + token_to_kv_pool = self._build_draft_swa_kv_pool( + full_max_total_num_tokens=sizes.full_max_total_num_tokens, + swa_max_total_num_tokens=sizes.swa_max_total_num_tokens, + mha_pool_class=mha_pool_class, + ) elif is_minimax_sparse(self.model_config.hf_config): token_to_kv_pool = self._build_minimax_sparse_kv_pool( max_total_num_tokens=sizes.max_total_num_tokens, @@ -1827,6 +1855,38 @@ def _build_hybrid_swa_kv_pool( ) return token_to_kv_pool + def _build_draft_swa_kv_pool( + self, + *, + full_max_total_num_tokens: int, + swa_max_total_num_tokens: int, + mha_pool_class: type, + ) -> KVCache: + """Draft pool addressed through the target allocator's full->SWA mapping. + + Every draft layer sits on the SWA side. The full side holds no layers + and only spans the target's token space. + """ + return SWAKVPool( + size=full_max_total_num_tokens, + size_swa=swa_max_total_num_tokens, + page_size=self.pool_page_size, + dtype=self.kv_cache_dtype, + post_capture_active=self.post_capture_kv_active, + head_num=self.model_config.get_num_kv_heads( + get_parallel().attn_tp_size, get_parallel().attn_dcp_size + ), + head_dim=self.model_config.head_dim, + v_head_dim=self.model_config.v_head_dim, + swa_attention_layer_ids=list( + range(self.layer_info.start_layer, self.layer_info.end_layer) + ), + full_attention_layer_ids=[], + device=self.device, + enable_kv_cache_copy=True, + token_to_kv_pool_class=mha_pool_class, + ) + def _build_minimax_sparse_kv_pool(self, *, max_total_num_tokens: int) -> KVCache: from sglang.srt.server_args import m3_fp8_attn_gemm_enabled @@ -2114,6 +2174,17 @@ def _build_token_to_kv_pool_allocator( need_sort=need_sort, req_to_token_pool=req_to_token_pool, ) + elif self.draft_kv_ratio < 1: + token_to_kv_pool_allocator = DraftSWATokenToKVPoolAllocator( + sizes.full_max_total_num_tokens, + sizes.swa_max_total_num_tokens, + page_size=get_schedule().page_size, + dtype=self.kv_cache_dtype, + device=self.device, + kvcache=token_to_kv_pool, + need_sort=need_sort, + req_to_token_pool=req_to_token_pool, + ) else: if get_memory().enable_hisparse: from sglang.srt.mem_cache.sparsity import ( @@ -2199,6 +2270,8 @@ def _build_token_to_kv_pool_allocator( token_to_kv_pool.register_mapping( swa_allocator.full_to_swa_index_mapping ) + elif self.draft_kv_ratio < 1: + token_to_kv_pool_allocator.attach_draft_kv_pool(token_to_kv_pool) return token_to_kv_pool_allocator def _profile_available_bytes(self, pre_model_load_memory: int) -> int: diff --git a/python/sglang/srt/mem_cache/swa_memory_pool.py b/python/sglang/srt/mem_cache/swa_memory_pool.py index 1206168478dd..0efdb69b3e91 100644 --- a/python/sglang/srt/mem_cache/swa_memory_pool.py +++ b/python/sglang/srt/mem_cache/swa_memory_pool.py @@ -277,6 +277,33 @@ def set_kv_buffer( layer_id_override=layer_id_pool, ) + def set_kv_buffer_prefix_valid( + self, + layer: RadixAttention, + loc_info, + commit_lens: torch.Tensor, + cache_k: torch.Tensor, + cache_v: torch.Tensor, + k_scale: Optional[float] = None, + v_scale: Optional[float] = None, + ): + loc_2d, swa_loc_2d, _ = unwrap_write_loc(loc_info) + layer_id_pool, is_swa_layer = self.layers_mapping[layer.layer_id] + pool = self.swa_kv_pool if is_swa_layer else self.full_kv_pool + if is_swa_layer: + assert swa_loc_2d is not None + loc_2d = swa_loc_2d + pool.set_kv_buffer_prefix_valid( + None, + loc_2d, + commit_lens, + cache_k, + cache_v, + k_scale, + v_scale, + layer_id_override=layer_id_pool, + ) + def set_mla_kv_buffer( self, layer: RadixAttention, diff --git a/python/sglang/srt/model_executor/model_runner.py b/python/sglang/srt/model_executor/model_runner.py index 9f7a69c7b87f..423b5b1a6bb9 100644 --- a/python/sglang/srt/model_executor/model_runner.py +++ b/python/sglang/srt/model_executor/model_runner.py @@ -369,6 +369,9 @@ def __init__( self.mtp_draft_device_pools = () self.is_hybrid_swa = model_config.is_hybrid_swa self.is_hybrid_swa_compress = model_config.is_hybrid_swa_compress + # Below 1 the DFLASH draft's KV is the SWA side of the allocator. + # See KVCacheConfigurator.draft_kv_ratio. + self.draft_kv_ratio = 1.0 self.use_mla_backend = self.model_config.attention_arch == AttentionArch.MLA self.attention_chunk_size = model_config.attention_chunk_size self.enable_elastic_ep = get_exec().moe.elastic_ep_backend is not None @@ -907,7 +910,8 @@ def alloc_memory_pool(self, memory_pool_config: Optional[MemoryPoolConfig] = Non self.token_to_kv_pool = result.token_to_kv_pool self.token_to_kv_pool_allocator = result.token_to_kv_pool_allocator self.memory_pool_config = result.memory_pool_config - if self.is_hybrid_swa: + self.draft_kv_ratio = self.kv_cache_configurator.draft_kv_ratio + if self.is_hybrid_swa or self.draft_kv_ratio < 1: self.full_max_total_num_tokens = result.full_max_total_num_tokens self.swa_max_total_num_tokens = result.swa_max_total_num_tokens # Keep a reference so the shared byte buffer is not GC'd. diff --git a/python/sglang/srt/model_executor/pool_configurator.py b/python/sglang/srt/model_executor/pool_configurator.py index 49304cb48e14..9ee9c058281d 100644 --- a/python/sglang/srt/model_executor/pool_configurator.py +++ b/python/sglang/srt/model_executor/pool_configurator.py @@ -15,7 +15,7 @@ import logging from bisect import bisect_right -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Optional import torch @@ -119,6 +119,12 @@ def _dflash_draft_cell_size(kvc: KVCacheConfigurator) -> int: return int(cell_size) * get_parallel().attn_dcp_size +def _draft_swa_cell_size(kvc: KVCacheConfigurator) -> int: + """Bytes/token of the draft KV pool at ``draft_kv_ratio`` slots per target + token, floored at 1 byte for ratios that truncate to 0.""" + return max(1, int(_dflash_draft_cell_size(kvc) * kvc.draft_kv_ratio)) + + def _get_dsa_cache_layer_ids(kvc: KVCacheConfigurator, num_layers: int) -> list[int]: """Global layer ids represented by the local DSA pool's dense layer slots.""" if kvc.mambaish_config and not kvc.is_draft_worker: @@ -285,12 +291,15 @@ def __init__(self, kvc: KVCacheConfigurator): and int(draft_num_layers) > 0 and int(num_layers) > 0 ): + draft_cell_size = _dflash_draft_cell_size(kvc) or None + if draft_cell_size is not None and kvc.draft_kv_ratio < 1: + draft_cell_size = _draft_swa_cell_size(kvc) self._cell_size = scale_kv_cell_size_per_token_for_dflash( target_cell_size_per_token=self._cell_size, target_num_layers=int(num_layers), draft_num_layers=int(draft_num_layers) * get_parallel().attn_dcp_size, - draft_cell_size_per_token=_dflash_draft_cell_size(kvc) or None, + draft_cell_size_per_token=draft_cell_size, ) def _compute_cell_size(self, kvc: KVCacheConfigurator, num_layers: int) -> int: @@ -937,6 +946,94 @@ def calculate_pool_sizes_from_max_tokens( ) +class DFlashDraftSWAPoolConfigurator(DefaultPoolConfigurator): + """Sizes the draft KV pool at ``draft_kv_ratio`` slots per target token.""" + + def __init__(self, kvc: KVCacheConfigurator): + super().__init__(kvc) + self._draft_kv_ratio = kvc.draft_kv_ratio + self._page_size = kvc.page_size + self._window = get_spec().speculative_draft_window_size + self._draft_cell_size = _dflash_draft_cell_size(kvc) + if self._draft_cell_size <= 0: + # _dflash_draft_cell_size is 0 when the draft config could not be + # read, and the layer-count fallback below ignores the ratio. + raise ValueError( + "--speculative-draft-kv-ratio below 1 needs the " + "DFLASH draft's KV bytes/token, which could not be resolved " + "from the draft config (see the earlier warning)." + ) + self._target_cell_size = self._cell_size - _draft_swa_cell_size(kvc) + self._draft_cap = compute_swa_request_cap( + page_size=kvc.page_size, + window=self._window, + attn_dp_size=kvc.attn_dp_size, + ) + # A mamba target resumes a hit at the last tracked state, up to + # mamba_track_interval before the request end, and a cached prefix keeps + # its window back from there. + self._cached_prefix_cost = self._window + kvc.page_size + if mambaish_config(kvc.model_config) is not None: + self._cached_prefix_cost += get_exec().mamba.mamba_track_interval + + def _with_draft_pool( + self, config: MemoryPoolConfig, min_ratio: float + ) -> MemoryPoolConfig: + target_tokens = config.max_total_num_tokens + draft_tokens = ( + int(target_tokens * self._draft_kv_ratio) // self._page_size + ) * self._page_size + if draft_tokens < self._draft_cap: + raise RuntimeError( + f"--speculative-draft-kv-ratio {self._draft_kv_ratio} gives the " + f"draft {draft_tokens} slots, below the {self._draft_cap} its " + f"running requests need. Use at least {min_ratio}, or lower " + f"--max-running-requests or SGLANG_SWA_EVICTION_INTERVAL." + ) + logger.info( + f"DFLASH draft KV pool: window={self._window}, " + f"ratio={self._draft_kv_ratio}, tokens={draft_tokens} " + f"(request cap {self._draft_cap}, " + f"{(draft_tokens - self._draft_cap) // self._cached_prefix_cost} " + f"cached prefixes at max concurrency)" + ) + return replace( + config, + full_max_total_num_tokens=target_tokens, + swa_max_total_num_tokens=draft_tokens, + ) + + def calculate_pool_sizes( + self, available_bytes: int, page_size: int + ) -> MemoryPoolConfig: + config = super().calculate_pool_sizes(available_bytes, page_size) + # budget = target_tokens * (target_cell + ratio * draft_cell), so the + # smallest ratio covering the cap is + # cap * target_cell / (budget - cap * draft_cell). + budget = config.max_total_num_tokens * self._cell_size + return self._with_draft_pool( + config, + ceil_div( + self._draft_cap * self._target_cell_size * 1000, + max(1, budget - self._draft_cap * self._draft_cell_size), + ) + / 1000, + ) + + def calculate_pool_sizes_from_max_tokens( + self, max_total_num_tokens: int, page_size: int + ) -> MemoryPoolConfig: + config = super().calculate_pool_sizes_from_max_tokens( + max_total_num_tokens, page_size + ) + # A token cap fixes the target pool, so the ratio alone sets the draft's. + return self._with_draft_pool( + config, + ceil_div(self._draft_cap * 1000, max(1, config.max_total_num_tokens)) + / 1000, + ) + + # Used when --swa-full-tokens-ratio is at its default and cap mode is unusable. DSV4_DEFAULT_SWA_FULL_TOKENS_RATIO = 0.1 @@ -1539,5 +1636,9 @@ def create_memory_pool_configurator( if SWAChunkCapPoolConfigurator.is_applicable(kvc): return SWAChunkCapPoolConfigurator(kvc) return HybridSWAPoolConfigurator(kvc) + # Future: targets with sliding-window layers of their own, which need a + # second sliding-window component for the draft. + if kvc.draft_kv_ratio < 1: + return DFlashDraftSWAPoolConfigurator(kvc) # Future: MambaPoolConfigurator return DefaultPoolConfigurator(kvc) diff --git a/python/sglang/srt/speculative/dflash_worker_v2.py b/python/sglang/srt/speculative/dflash_worker_v2.py index c55007f3df14..bb757c07be59 100644 --- a/python/sglang/srt/speculative/dflash_worker_v2.py +++ b/python/sglang/srt/speculative/dflash_worker_v2.py @@ -25,6 +25,8 @@ from sglang.srt.managers.schedule_batch import ScheduleBatch from sglang.srt.managers.scheduler import GenerationBatchResult from sglang.srt.managers.tp_worker import TpModelWorker +from sglang.srt.mem_cache.memory_pool import KVWriteLoc +from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool from sglang.srt.model_executor.cuda_graph_config import Backend from sglang.srt.model_executor.forward_batch_info import ( CaptureHiddenMode, @@ -1722,6 +1724,15 @@ def _ensure_local_reduce_buffers( return out_tokens + def _draft_kv_write_loc(self, cache_loc: torch.Tensor): + """Add the draft-side loc for a bounded draft pool.""" + pool = self.draft_model_runner.token_to_kv_pool + if not isinstance(pool, SWAKVPool): + return cache_loc + return KVWriteLoc( + loc=cache_loc, swa_loc=pool.translate_loc_from_full_to_swa(cache_loc) + ) + def _append_target_hidden_to_draft_kv_by_loc( self, *, @@ -1857,6 +1868,7 @@ def _append_target_hidden_to_draft_kv_by_loc( self._use_fused_kv_materialize = False self._fused_kv_helper = None + write_loc_2d = self._draft_kv_write_loc(cache_loc_2d) for layer in self.draft_model.layers: attn = layer.self_attn layer_ctx_hidden = self.draft_model.prepare_context_hidden_for_kv( @@ -1870,7 +1882,7 @@ def _append_target_hidden_to_draft_kv_by_loc( self.draft_model_runner.token_to_kv_pool.set_kv_buffer_prefix_valid( attn.attn, - cache_loc_2d, + write_loc_2d, commit_lens, k, v, @@ -1907,6 +1919,7 @@ def _append_target_hidden_sequential( ctx_positions: torch.Tensor, ctx_cache_loc: torch.Tensor, ) -> None: + write_loc = self._draft_kv_write_loc(ctx_cache_loc) for layer in self.draft_model.layers: attn = layer.self_attn layer_ctx_hidden = self.draft_model.prepare_context_hidden_for_kv( @@ -1925,7 +1938,7 @@ def _append_target_hidden_sequential( v = v.view(-1, attn.num_kv_heads, attn.head_dim) self.draft_model_runner.token_to_kv_pool.set_kv_buffer( attn.attn, - ctx_cache_loc, + write_loc, k, v, attn.attn.k_scale, @@ -1944,6 +1957,12 @@ def _append_target_hidden_fused( token_to_kv_pool = self.draft_model_runner.token_to_kv_pool if self._fused_kv_helper is None: raise RuntimeError("DFLASH fused KV helper is not initialized.") + write_loc = self._draft_kv_write_loc(ctx_cache_loc) + write_loc_2d = ( + None + if ctx_cache_loc_2d is None + else self._draft_kv_write_loc(ctx_cache_loc_2d) + ) def _write_layer_kv( layer_idx: int, @@ -1951,10 +1970,10 @@ def _write_layer_kv( cache_v: torch.Tensor, ) -> None: attn = self.draft_model.layers[layer_idx].self_attn.attn - if ctx_cache_loc_2d is not None and commit_lens is not None: + if write_loc_2d is not None and commit_lens is not None: token_to_kv_pool.set_kv_buffer_prefix_valid( attn, - ctx_cache_loc_2d, + write_loc_2d, commit_lens, cache_k, cache_v, @@ -1964,7 +1983,7 @@ def _write_layer_kv( else: token_to_kv_pool.set_kv_buffer( attn, - ctx_cache_loc, + write_loc, cache_k, cache_v, attn.k_scale, diff --git a/test/registered/spec/dflash/test_dflash_draft_kv_ratio.py b/test/registered/spec/dflash/test_dflash_draft_kv_ratio.py new file mode 100644 index 000000000000..1d2614a12d3d --- /dev/null +++ b/test/registered/spec/dflash/test_dflash_draft_kv_ratio.py @@ -0,0 +1,219 @@ +"""End-to-end test for --speculative-draft-kv-ratio. + +Pairs a linear/full-attention target with an all-SWA DFLASH draft: +``Qwen/Qwen3.8-27B`` and ``z-lab/Qwen3.8-27B-DFlash2`` (5 ``sliding_attention`` +layers, ``sliding_window`` 2048, block size 8). Below 1.0 the draft's KV holds +a fraction of the target's tokens in a sliding-window pool instead of one slot +per target token, and a radix prefix hit still reuses the whole cached prefix. +""" + +import os +import re +import tempfile +import unittest +from contextlib import ExitStack + +import requests + +from sglang.srt.environ import envs +from sglang.srt.utils import kill_process_tree +from sglang.test.ci.ci_register import register_cuda_ci +from sglang.test.kits.eval_accuracy_kit import GSM8KMixin +from sglang.test.test_utils import ( + DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, + DEFAULT_URL_FOR_TEST, + CustomTestCase, + popen_launch_server, +) + +register_cuda_ci(est_time=500, stage="nightly", runner_config="1-gpu-large") + +TARGET_MODEL = "Qwen/Qwen3.8-27B" +DRAFT_MODEL = "z-lab/Qwen3.8-27B-DFlash2" +DRAFT_WINDOW = 2048 +DRAFT_BLOCK_SIZE = 8 +DRAFT_KV_RATIO = 0.25 +MAX_RUNNING_REQUESTS = 8 +# The target resumes a prefix hit at its last linear-attention checkpoint, +# which is chunk aligned; allow one chunk of slack before the prompt end. +CHECKPOINT_SLACK = 128 + +# Several draft windows long, so a repeated request is a prefix hit whose +# draft window lies well inside cached territory. +_LONG_PROMPT = " ".join( + f"Entry {i}: the quick brown fox number {i} jumps over the lazy dog number {i + 1}." + for i in range(700) +) +_ESSAY_QUESTION = ( + "\n\nWrite a detailed essay on how foxes and dogs are portrayed in folklore, " + "with examples from at least three cultures." +) + + +class TestDFlashDraftKVRatio(CustomTestCase, GSM8KMixin): + model = TARGET_MODEL + draft_model = DRAFT_MODEL + page_size = 1 + gsm8k_accuracy_thres = 0.80 + gsm8k_accept_length_thres = 3.0 + gsm8k_num_questions = 200 + + @classmethod + def setUpClass(cls): + cls.base_url = DEFAULT_URL_FOR_TEST + cls.log_dir = tempfile.mkdtemp(prefix="dflash_draft_kv_ratio_") + cls.stdout = open(os.path.join(cls.log_dir, "server_stdout.log"), "w") + cls.stderr = open(os.path.join(cls.log_dir, "server_stderr.log"), "w") + launch_args = [ + "--trust-remote-code", + "--speculative-algorithm", + "DFLASH", + "--speculative-draft-model-path", + cls.draft_model, + "--speculative-num-draft-tokens", + str(DRAFT_BLOCK_SIZE), + "--speculative-draft-window-size", + str(DRAFT_WINDOW), + "--speculative-draft-kv-ratio", + str(DRAFT_KV_RATIO), + "--max-running-requests", + str(MAX_RUNNING_REQUESTS), + "--page-size", + str(cls.page_size), + "--mem-fraction-static", + "0.8", + ] + with ExitStack() as stack: + for env, value in ( + (envs.SGLANG_ENABLE_STRICT_MEM_CHECK_DURING_BUSY, 1), + (envs.SGLANG_ENABLE_ASYNC_ASSERT, True), + ): + stack.enter_context(env.override(value)) + cls.process = popen_launch_server( + cls.model, + cls.base_url, + timeout=max(DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, 1800), + other_args=launch_args, + return_stdout_stderr=(cls.stdout, cls.stderr), + ) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "process") and cls.process: + kill_process_tree(cls.process.pid) + for f in (getattr(cls, "stdout", None), getattr(cls, "stderr", None)): + if f is not None: + f.close() + + def _server_log(self) -> str: + self.stdout.flush() + self.stderr.flush() + text = "" + for name in ("server_stdout.log", "server_stderr.log"): + with open(os.path.join(self.log_dir, name)) as f: + text += f.read() + return text + + def _generate(self, prompt: str, max_new_tokens: int) -> dict: + res = requests.post( + self.base_url + "/generate", + json={ + "text": prompt, + "sampling_params": {"max_new_tokens": max_new_tokens, "temperature": 0}, + }, + ) + res.raise_for_status() + return res.json() + + def _flush_cache(self): + requests.post(self.base_url + "/flush_cache").raise_for_status() + + def _pool_sizing(self) -> tuple: + """(window, ratio, draft tokens, request cap) from the startup log.""" + sizing = re.search( + r"DFLASH draft KV pool: window=(\d+), ratio=([0-9.]+), tokens=(\d+) " + r"\(request cap (\d+)", + self._server_log(), + ) + self.assertIsNotNone(sizing, "draft KV pool sizing not logged") + window, ratio, tokens, cap = sizing.groups() + return int(window), float(ratio), int(tokens), int(cap) + + def test_draft_pool_is_ratio_of_target(self): + window, ratio, draft_tokens, cap = self._pool_sizing() + self.assertEqual(window, DRAFT_WINDOW) + self.assertEqual(ratio, DRAFT_KV_RATIO) + self.assertGreaterEqual(draft_tokens, cap) + log = self._server_log() + target_tokens = int(re.search(r"max_total_num_tokens=(\d+)", log).group(1)) + self.assertEqual( + draft_tokens, + int(target_tokens * DRAFT_KV_RATIO) // self.page_size * self.page_size, + ) + # The draft pool is built with that many slots. + pools = re.findall(r"SWAKVPool .*swa size: (\d+), full size: (\d+)", log) + self.assertIn(draft_tokens, [int(swa) for swa, _ in pools]) + + def test_prefix_hit_reuses_whole_prompt(self): + """A cached prefix keeps its trailing window, so nothing is held back.""" + self._flush_cache() + cold = self._generate(_LONG_PROMPT, 8) + prompt_tokens = cold["meta_info"]["prompt_tokens"] + self.assertGreater(prompt_tokens, 2 * DRAFT_WINDOW) + warm = self._generate(_LONG_PROMPT, 8) + cached_tokens = warm["meta_info"]["cached_tokens"] + print( + f"prefix hit: prompt_tokens={prompt_tokens}, cached_tokens={cached_tokens}" + ) + self.assertGreater(cached_tokens, prompt_tokens - DRAFT_WINDOW) + self.assertGreaterEqual( + cached_tokens, prompt_tokens - CHECKPOINT_SLACK - self.page_size + ) + + def test_greedy_determinism_across_hit(self): + self._flush_cache() + prompt = _LONG_PROMPT + _ESSAY_QUESTION + cold = self._generate(prompt, 256) + warm = self._generate(prompt, 256) + self.assertGreater(warm["meta_info"]["cached_tokens"], 0) + self.assertEqual(cold["text"], warm["text"]) + self.assertIsNone(self.process.poll()) + + def test_evicted_window_falls_back(self): + """More cached prefixes than the draft pool holds: the oldest loses its + window, and a hit on it re-prefills instead of reading freed slots.""" + self._flush_cache() + _, _, draft_tokens, cap = self._pool_sizing() + # One more than the pool holds, at the window each cached prefix keeps. + num_prompts = (draft_tokens - cap) // DRAFT_WINDOW + 2 + prompts = [ + _LONG_PROMPT.replace("quick", f"quick-{i}") + _ESSAY_QUESTION + for i in range(num_prompts) + ] + cold = self._generate(prompts[0], 64) + prompt_tokens = cold["meta_info"]["prompt_tokens"] + for prompt in prompts[1:]: + self._generate(prompt, 8) + warm = self._generate(prompts[0], 64) + cached_tokens = warm["meta_info"]["cached_tokens"] + print( + f"after {num_prompts} prefixes: prompt_tokens={prompt_tokens}, " + f"cached_tokens={cached_tokens}" + ) + self.assertLessEqual(cached_tokens, prompt_tokens - DRAFT_WINDOW) + self.assertEqual(cold["text"], warm["text"]) + self.assertIsNone(self.process.poll()) + + +class TestDFlashDraftKVRatioPaged(TestDFlashDraftKVRatio): + """The same pool at page size 64, where the allocator pages both sides and + the draft backend builds its page table from translated slots.""" + + page_size = 64 + + def test_gsm8k(self): + self.skipTest("accuracy does not depend on the page size") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/registered/unit/model_executor/test_pool_configurator.py b/test/registered/unit/model_executor/test_pool_configurator.py index 26fc85b22ae2..3a6c61503385 100644 --- a/test/registered/unit/model_executor/test_pool_configurator.py +++ b/test/registered/unit/model_executor/test_pool_configurator.py @@ -96,6 +96,8 @@ def _make_model_runner( qk_rope_head_dim=64, swa_kv_lora_rank=128, swa_qk_rope_head_dim=32, + speculative_draft_window_size=None, + speculative_draft_kv_ratio=1.0, ): """Create a mock ModelRunner with the fields configurators need.""" mr = MagicMock() @@ -156,6 +158,8 @@ def _make_model_runner( speculative_algorithm=speculative_algorithm, speculative_num_steps=speculative_num_steps, speculative_eagle_topk=speculative_eagle_topk, + speculative_draft_window_size=speculative_draft_window_size, + speculative_draft_kv_ratio=speculative_draft_kv_ratio, disaggregation_mode=disaggregation_mode, max_running_requests=max_running_requests, disaggregation_decode_extra_slots=disaggregation_decode_extra_slots, @@ -180,11 +184,13 @@ def _make_model_runner( ) mr.attn_dp_size = 1 mr.pp_size = 1 + mr.draft_kv_ratio = speculative_draft_kv_ratio mr.pp_group = SimpleNamespace(rank_in_group=0) mr.spec_aux_config = SimpleNamespace( eagle_draft_num_layers=None, eagle_draft_swa_num_layers=None, dflash_draft_num_layers=None, + dflash_draft_cell_size_per_token=None, ) return mr @@ -1335,5 +1341,115 @@ def test_dsv4_fp8_mtp_swa_ring_keeps_spec_inflation(self): self.assertEqual(cfg._fixed_swa_bytes(mrr), int(target * cfg._spec_infl)) +class TestDFlashDraftKVRatioConfigurator(CustomTestCase): + """--speculative-draft-kv-ratio: the draft holds its share of the target's + tokens, priced into the cell size, and must still cover running requests.""" + + WINDOW = 64 + DRAFT_CELL = 48 # bytes/token of the draft KV + MAX_RUNNING = 2 + + def _make(self, ratio, **kwargs): + mr = _make_model_runner( + self, + num_layers=4, + speculative_algorithm="DFLASH", + speculative_num_steps=1, + speculative_eagle_topk=1, + speculative_num_draft_tokens=8, + disable_overlap_schedule=True, + max_running_requests=self.MAX_RUNNING, + chunked_prefill_size=32, + speculative_draft_window_size=self.WINDOW, + speculative_draft_kv_ratio=ratio, + **kwargs, + ) + mr.spec_algorithm.is_dflash_family.return_value = True + mr.spec_algorithm.is_none.return_value = False + mr.spec_aux_config.dflash_draft_num_layers = 5 + mr.spec_aux_config.dflash_draft_cell_size_per_token = self.DRAFT_CELL + return mr + + def _run(self, ratio, available_bytes=10_000_000, page_size=1, **kwargs): + mr = self._make(ratio, page_size=page_size, **kwargs) + with mock_cpu_env(): + from sglang.srt.model_executor.pool_configurator import ( + create_memory_pool_configurator, + ) + + cfg = create_memory_pool_configurator(mr) + config = cfg.calculate_pool_sizes(available_bytes, page_size) + return mr, cfg, config + + def test_draft_pool_is_ratio_of_target(self): + _, _, config = self._run(0.5) + self.assertEqual(config.full_max_total_num_tokens, config.max_total_num_tokens) + self.assertEqual( + config.swa_max_total_num_tokens, int(config.max_total_num_tokens * 0.5) + ) + + def test_ratio_is_priced_into_cell_size(self): + mr, cfg, _ = self._run(0.5) + target_cell = _full_per_token(mr) * 4 + self.assertEqual(cfg._cell_size, target_cell + self.DRAFT_CELL // 2) + + def test_memory_utilization(self): + available = 10_000_000 + mr, _, config = self._run(0.5, available_bytes=available) + used = ( + config.max_total_num_tokens * _full_per_token(mr) * 4 + + config.swa_max_total_num_tokens * self.DRAFT_CELL + ) + self.assertLessEqual(used, available) + self.assertGreater(used, available * 0.99) + + def test_page_alignment(self): + _, _, config = self._run(0.5, page_size=16) + self.assertEqual(config.max_total_num_tokens % 16, 0) + self.assertEqual(config.swa_max_total_num_tokens % 16, 0) + + def test_constraint_keeps_ratio(self): + _, cfg, _ = self._run(0.5) + with mock_cpu_env(): + constrained = cfg.calculate_pool_sizes_from_max_tokens(1000, page_size=1) + self.assertEqual(constrained.max_total_num_tokens, 1000) + self.assertEqual(constrained.swa_max_total_num_tokens, 500) + + def test_ratio_below_request_cap_raises_a_usable_minimum(self): + """The minimum the error names must itself pass, which the ratio the + target pool was sized at does not give. This ratio also rounds the + draft's share below one byte, which the pool still prices at one.""" + import re + + with self.assertRaises(RuntimeError) as raised: + self._run(0.001) + message = str(raised.exception) + self.assertRegex(message, r"below the \d+ its running requests need") + minimum = float(re.search(r"Use at least ([0-9.]+)", message).group(1)) + _, _, config = self._run(minimum) + self.assertGreaterEqual( + config.swa_max_total_num_tokens, + int(config.max_total_num_tokens * minimum) - 1, + ) + + def test_ratio_of_one_leaves_the_pool_config_alone(self): + mr, cfg, config = self._run(1.0) + self.assertIsNone(config.swa_max_total_num_tokens) + self.assertEqual(cfg._cell_size, _full_per_token(mr) * 4 + self.DRAFT_CELL) + + def test_unresolved_draft_cell_size_raises(self): + """Without the draft's bytes/token the budget scales by layer count, + which prices neither the draft's geometry nor the ratio.""" + mr = self._make(0.25, page_size=1) + mr.spec_aux_config.dflash_draft_cell_size_per_token = None + with mock_cpu_env(): + from sglang.srt.model_executor.pool_configurator import ( + create_memory_pool_configurator, + ) + + with self.assertRaisesRegex(ValueError, "KV bytes/token"): + create_memory_pool_configurator(mr) + + if __name__ == "__main__": unittest.main() diff --git a/test/registered/unit/server_args/test_server_args.py b/test/registered/unit/server_args/test_server_args.py index fed3493e58c6..d70a80bd35d0 100644 --- a/test/registered/unit/server_args/test_server_args.py +++ b/test/registered/unit/server_args/test_server_args.py @@ -1038,6 +1038,24 @@ def test_sm100_native_nvfp4_rejects_prefix_commit_spec_algorithms(self): with self.assertRaisesRegex(ValueError, algorithm): handle_kv4_compatibility(args) + def test_draft_kv_ratio_range(self): + """The draft pool is indexed by target slot, so a ratio above 1 would + hold slots the target cannot address.""" + from sglang.srt.arg_groups.speculative_hook import _handle_dflash + + for ratio in (0, -0.5, 1.5): + with self.subTest(ratio=ratio): + args = ServerArgs( + model_path="dummy", + device="cuda", + speculative_algorithm="DFLASH", + speculative_draft_model_path="dummy-draft", + speculative_num_draft_tokens=8, + speculative_draft_kv_ratio=ratio, + ) + with self.assertRaisesRegex(ValueError, r"must be in \(0, 1\]"): + _handle_dflash(args) + @override_platform(is_cuda=True, is_sm100=False, is_sm120=True) def test_sm120_xqa_keeps_existing_speculative_support(self): args = self._make_nvfp4_args(speculative_algorithm="EAGLE")