perf: add --prefill-only-disable-kv-cache to skip KV pool allocation (#23675)

This commit is contained in:
Jia Guo
2026-05-11 13:10:24 -07:00
committed by GitHub
parent c7e53e68a2
commit da0eeb82f2
4 changed files with 347 additions and 1 deletions
+110
View File
@@ -1123,6 +1123,116 @@ class MHATokenToKVPool(KVCache):
)
class NoOpMHATokenToKVPool(MHATokenToKVPool):
"""KV cache pool that skips physical K/V buffer allocation.
Used in embedding-mode prefill-only workloads with the FA
fa_skip_kv_cache path, where no layer reads or writes KV cache because
attention uses raw K/V via flash_attn_varlen_func. Other prefill-only paths
such as scoring/MIS may benefit from the same idea later, but some still
stage K/V through paged cache today.
This class keeps the scheduler's view of pool capacity (self.size is
honored for admission) but allocates only (page_size, head_num, head_dim)
placeholder tensors per layer to satisfy any code paths that dereference
the buffers.
Callers MUST ensure no real set_kv_buffer/get_*_buffer calls happen against
this pool; those paths raise loudly so misuse is visible.
"""
def _create_buffers(self):
# Allocate minimal placeholder buffers. They exist purely so that code
# paths holding `k_buffer` / `v_buffer` references (pointer tables,
# layer-transfer counters, stride arithmetic) keep working without
# None-guards scattered across the codebase. Shape is
# [page_size, head_num, head_dim] per layer so that the unconditional
# `key_cache.view(-1, page_size, head_num, head_dim)` in the FA backend
# at the top of forward_extend succeeds regardless of --page-size.
# Total footprint is still on the order of KB vs GBs for a real pool.
with self.memory_saver_adapter.region(GPU_MEMORY_TYPE_KV_CACHE):
self.k_buffer = [
torch.zeros(
(self.page_size, self.head_num, self.head_dim),
dtype=self.store_dtype,
device=self.device,
)
for _ in range(self.layer_num)
]
self.v_buffer = [
torch.zeros(
(self.page_size, self.head_num, self.v_head_dim),
dtype=self.store_dtype,
device=self.device,
)
for _ in range(self.layer_num)
]
self.k_data_ptrs = torch.tensor(
[x.data_ptr() for x in self.k_buffer],
dtype=torch.uint64,
device=self.device,
)
self.v_data_ptrs = torch.tensor(
[x.data_ptr() for x in self.v_buffer],
dtype=torch.uint64,
device=self.device,
)
self.data_ptrs = torch.cat([self.k_data_ptrs, self.v_data_ptrs], dim=0)
self.data_strides = torch.tensor(
[
np.prod(x.shape[1:]) * x.dtype.itemsize
for x in self.k_buffer + self.v_buffer
],
device=self.device,
)
def _finalize_allocation_log(self, num_tokens: int):
self.mem_usage = 0.0
placeholder_bytes = (
2
* self.layer_num
* self.page_size
* self.head_num
* max(self.head_dim, self.v_head_dim)
* self.store_dtype.itemsize
)
logger.info(
f"KV Cache skipped (no-op pool). Logical #tokens: {num_tokens}, "
f"physical K/V size: ~{placeholder_bytes / 1024:.1f} KB placeholder"
)
def get_kv_size_bytes(self):
# Report zero so downstream memory accounting matches reality.
return (0, 0)
def set_kv_buffer(self, *args, **kwargs):
raise RuntimeError(
"NoOpMHATokenToKVPool.set_kv_buffer was called. This pool is only "
"valid in prefill-only modes (e.g. --is-embedding, scoring) with "
"the FA backend's fa_skip_kv_cache path active; the attention "
"backend must never write to it. Check that the workload truly "
"performs no decode and that the FA backend's fa_skip_kv_cache "
"preconditions are met."
)
def get_key_buffer(self, layer_id: int):
# Return the placeholder. The FA backend reads this before taking the
# fa_skip_kv_cache branch (which does not use it); the placeholder shape
# is (page_size, head_num, head_dim) so downstream .view() calls succeed.
return self.k_buffer[layer_id - self.start_layer]
def get_value_buffer(self, layer_id: int):
return self.v_buffer[layer_id - self.start_layer]
def get_kv_buffer(self, layer_id: int):
return self.get_key_buffer(layer_id), self.get_value_buffer(layer_id)
def move_kv_cache(self, tgt_loc: torch.Tensor, src_loc: torch.Tensor):
# no-op; embedding mode has no KV cache to move
return
class MHATokenToKVPoolFP4(MHATokenToKVPool):
def _create_buffers(self):
@@ -30,6 +30,7 @@ from sglang.srt.mem_cache.memory_pool import (
MHATokenToKVPoolFP4,
MLATokenToKVPool,
MLATokenToKVPoolFP4,
NoOpMHATokenToKVPool,
NSATokenToKVPool,
ReqToTokenPool,
)
@@ -196,6 +197,44 @@ class ModelRunnerKVCacheMixin:
return MAMBA_CACHE_SIZE_MAX_RUNNING_REQUESTS_RATIO + additional_ratio
def _validate_prefill_only_disable_kv_cache_pool_family(
self: ModelRunner,
is_nsa_model: bool,
is_dsv4_model: bool,
current_platform,
):
if not self.server_args.prefill_only_disable_kv_cache or self.is_draft_worker:
return
unsupported_pool_family = None
if is_dsv4_model:
unsupported_pool_family = "DeepSeekV4TokenToKVPool"
elif current_platform.is_out_of_tree() and not self.mambaish_config:
unsupported_pool_family = "out-of-tree platform KV pool"
elif (
self.server_args.attention_backend == "ascend" and not self.mambaish_config
):
unsupported_pool_family = "NPU/Ascend KV pool"
elif self.use_mla_backend and is_nsa_model:
unsupported_pool_family = "NSA/MLA KV pool"
elif self.use_mla_backend and not self.mambaish_config:
unsupported_pool_family = "MLA KV pool"
elif self.is_hybrid_swa:
unsupported_pool_family = "SWA KV pool"
elif self.mambaish_config:
unsupported_pool_family = "hybrid linear/Mamba KV pool"
elif is_float4_e2m1fn_x2(self.kv_cache_dtype):
unsupported_pool_family = "FP4 MHA KV pool"
if unsupported_pool_family is not None:
raise RuntimeError(
"--prefill-only-disable-kv-cache is not supported for "
f"{unsupported_pool_family}. Supported configurations today: plain MHA "
"models on CUDA with the FA (fa3/fa4) prefill backend, --is-embedding, "
"--chunked-prefill-size=-1, --disable-radix-cache, no context-parallel "
"attention, no HiSparse, and --kv-cache-dtype != fp4_e2m1."
)
def _init_pools(self: ModelRunner):
"""Initialize the memory pools."""
max_num_reqs = self.max_running_requests
@@ -292,6 +331,10 @@ class ModelRunnerKVCacheMixin:
# Out-of-tree platform plugin system — used by elif below
from sglang.srt.platforms import current_platform
self._validate_prefill_only_disable_kv_cache_pool_family(
is_nsa_model, is_dsv4_model, current_platform
)
if is_dsv4_model:
swa_page_size = self.page_size
assert swa_page_size == 256, "In paged swa mode, page_size must be 256."
@@ -591,7 +634,12 @@ class ModelRunnerKVCacheMixin:
),
)
else:
self.token_to_kv_pool = MHATokenToKVPool(
pool_cls = (
NoOpMHATokenToKVPool
if self.server_args.prefill_only_disable_kv_cache
else MHATokenToKVPool
)
self.token_to_kv_pool = pool_cls(
self.max_total_num_tokens,
page_size=self.page_size,
dtype=self.kv_cache_dtype,
@@ -719,6 +767,24 @@ class ModelRunnerKVCacheMixin:
swa_allocator.full_to_swa_index_mapping
)
# Defensive check: the explicit validation above should reject known
# unsupported pool families before allocation. Keep this guard here so
# future pool-selection refactors fail at boot instead of on first use.
if (
self.server_args.prefill_only_disable_kv_cache
and not self.is_draft_worker
and not isinstance(self.token_to_kv_pool, NoOpMHATokenToKVPool)
):
raise RuntimeError(
"--prefill-only-disable-kv-cache expected NoOpMHATokenToKVPool but the "
f"runtime pool is {type(self.token_to_kv_pool).__name__}. This pool "
"family is not yet supported by --prefill-only-disable-kv-cache. "
"Supported configurations today: plain MHA models on CUDA with the FA "
"(fa3/fa4) prefill backend, --is-embedding, --chunked-prefill-size=-1, "
"--disable-radix-cache, no context-parallel attention, no HiSparse, "
"and --kv-cache-dtype != fp4_e2m1."
)
def _apply_token_constraints(self: ModelRunner, token_capacity: int) -> int:
"""Apply external constraints to token capacity: user cap, PP sync.
+112
View File
@@ -365,6 +365,7 @@ class ServerArgs:
trust_remote_code: bool = False
context_length: Optional[int] = None
is_embedding: bool = False
prefill_only_disable_kv_cache: bool = False
enable_multimodal: Optional[bool] = None
revision: Optional[str] = None
model_impl: str = "auto"
@@ -846,6 +847,10 @@ class ServerArgs:
# Validate PD disaggregation flags early (before dummy-model short-circuit).
self._handle_pd_disaggregation()
# Validate --prefill-only-disable-kv-cache args early (before dummy-model
# short-circuit). The backend check is run later after backends settle.
self._validate_prefill_only_disable_kv_cache_args()
if self.model_path.lower() in ["none", "dummy"]:
# Skip for dummy models
return
@@ -914,6 +919,13 @@ class ServerArgs:
# the final attention backend and chunked_prefill_size are in effect.
self._handle_multi_item_scoring()
# Backend-dependent half of --prefill-only-disable-kv-cache validation.
# Must stay after _handle_attention_backend_compatibility() (above) and
# _handle_multi_item_scoring() so the resolved prefill backend is final;
# the flag/precondition half runs earlier in
# _validate_prefill_only_disable_kv_cache_args().
self._handle_prefill_only_disable_kv_cache()
# Handle Hicache settings.
self._handle_hicache()
@@ -3243,6 +3255,101 @@ class ServerArgs:
"Pipeline parallelism is incompatible with overlap schedule."
)
def _validate_prefill_only_disable_kv_cache_args(self):
"""Validate --prefill-only-disable-kv-cache flag/precondition constraints.
Runs before the dummy-model short-circuit so misuse is rejected even
for dummy models. Backend resolution is checked separately by
_handle_prefill_only_disable_kv_cache after backends settle.
"""
if not self.prefill_only_disable_kv_cache:
return
# This flag is intentionally scoped to embedding mode for now. Other
# prefill-only paths (for example scoring and MIS) can benefit from
# the same idea later, but some of them still stage K/V through the
# paged cache today.
if not self.is_embedding:
raise ValueError(
"--prefill-only-disable-kv-cache currently requires --is-embedding. "
"Other prefill-only workloads may be supported in a future change once "
"their attention paths stop reading or writing the paged KV cache."
)
if self.kv_cache_dtype == "fp4_e2m1":
raise ValueError(
"--prefill-only-disable-kv-cache does not currently support "
"--kv-cache-dtype=fp4_e2m1 because the FP4 pool uses a separate "
"allocation path."
)
# Structural preconditions for the FA backend's fa_skip_kv_cache path,
# which is the only embedding path that doesn't read or write the pool:
# - chunked_prefill_size == -1 keeps a request in a single forward,
# so K/V never has to be reused across prefill chunks.
# - disable_radix_cache stops the prefix cache from indexing pool
# slots that no longer hold real data.
if self.chunked_prefill_size != -1:
raise ValueError(
"--prefill-only-disable-kv-cache requires --chunked-prefill-size=-1 so the FA "
"backend takes the fa_skip_kv_cache path; otherwise the pool would be touched "
"between prefill chunks."
)
if not self.disable_radix_cache:
raise ValueError(
"--prefill-only-disable-kv-cache requires --disable-radix-cache because the "
"radix cache indexes KV pool slots that no longer hold real data."
)
# Context-parallel prefill stages K/V through cp_allgather_and_save_kv_cache,
# which writes to the pool via set_kv_buffer. NoOpMHATokenToKVPool intentionally
# raises on writes, so the engine would boot fine but fail on the first request.
if self.attn_cp_size > 1:
raise ValueError(
"--prefill-only-disable-kv-cache is incompatible with --attn-cp-size > 1: "
"the context-parallel attention path writes K/V to the pool via set_kv_buffer, "
"which the no-op pool intentionally rejects."
)
if self.enable_prefill_context_parallel:
raise ValueError(
"--prefill-only-disable-kv-cache is incompatible with "
"--enable-prefill-context-parallel: the prefill-CP path stages K/V through "
"the paged cache, which the no-op pool does not support."
)
# HiSparse selects a different pool class (HiSparseNSATokenToKVPool /
# HiSparseTokenToKVPoolAllocator) that is not the no-op pool.
if self.enable_hisparse:
raise ValueError(
"--prefill-only-disable-kv-cache is incompatible with --enable-hisparse: "
"HiSparse uses a dedicated pool family that is not the no-op MHA pool."
)
def _handle_prefill_only_disable_kv_cache(self):
"""Validate --prefill-only-disable-kv-cache backend constraint.
Must run after _handle_attention_backend_compatibility() (which fills
the default attention_backend if unset) and _handle_multi_item_scoring()
(which may further mutate it). The assertion below guards against
accidental call-site reordering: if attention_backend is still None,
backends haven't settled yet and get_attention_backends() would return
a stale (None, None).
"""
if not self.prefill_only_disable_kv_cache:
return
assert self.attention_backend is not None, (
"_handle_prefill_only_disable_kv_cache must run after "
"_handle_attention_backend_compatibility() so the prefill backend is resolved."
)
prefill_backend, _ = self.get_attention_backends()
if prefill_backend not in ("fa3", "fa4"):
raise ValueError(
"--prefill-only-disable-kv-cache currently requires the FA prefill backend "
f"(fa3/fa4), but got prefill backend {prefill_backend!r}. Other prefill-only "
"workloads and backends may be supported in a future change."
)
def _handle_hicache(self):
"""Normalize hicache-related knobs into a valid runtime configuration.
@@ -4383,6 +4490,11 @@ class ServerArgs:
action="store_true",
help="Whether to use a CausalLM as an embedding model.",
)
parser.add_argument(
"--prefill-only-disable-kv-cache",
action="store_true",
help="Skip the physical KV cache allocation for embedding-mode prefill-only workloads. Currently only valid with --is-embedding, --chunked-prefill-size=-1, --disable-radix-cache, an FA prefill backend, and non-FP4 KV cache so the fa_skip_kv_cache path is active (no layer reads or writes the cache). Other prefill-only workloads such as scoring/MIS may benefit from this later once their attention paths stop using paged KV. Scheduler admission accounting is unchanged; per-layer K/V tensors are sized to (page_size, head_num, head_dim) placeholders so GPU memory is not wasted.",
)
parser.add_argument(
"--enable-multimodal",
default=ServerArgs.enable_multimodal,
@@ -515,5 +515,63 @@ class TestNgramExternalSamArgs(CustomTestCase):
self.assertIn("external-corpus-max-tokens", str(context.exception))
class TestPrefillOnlyDisableKvCache(unittest.TestCase):
"""Validation for --prefill-only-disable-kv-cache.
The flag wires NoOpMHATokenToKVPool, which is only safe when:
- the engine is in embedding mode (fa_skip_kv_cache active in FA backend),
- chunked_prefill_size == -1 (no inter-chunk K/V reuse),
- disable_radix_cache (radix cache otherwise indexes empty pool slots),
- no context-parallel attention (CP writes to the pool via set_kv_buffer),
- no HiSparse (uses a different pool family),
- kv_cache_dtype != fp4_e2m1 (FP4 pool is a separate allocation path).
All other configurations must be rejected at __post_init__ time so users
get a clear error before model load.
"""
def _base_kwargs(self, **overrides):
kwargs = dict(
model_path="dummy",
is_embedding=True,
chunked_prefill_size=-1,
disable_radix_cache=True,
prefill_only_disable_kv_cache=True,
)
kwargs.update(overrides)
return kwargs
def test_valid_minimal_config_constructs(self):
sa = ServerArgs(**self._base_kwargs())
self.assertTrue(sa.prefill_only_disable_kv_cache)
def test_rejects_when_not_embedding(self):
with self.assertRaisesRegex(ValueError, "requires --is-embedding"):
ServerArgs(**self._base_kwargs(is_embedding=False))
def test_rejects_when_chunked_prefill_size_not_minus_one(self):
with self.assertRaisesRegex(ValueError, "--chunked-prefill-size=-1"):
ServerArgs(**self._base_kwargs(chunked_prefill_size=8192))
def test_rejects_when_radix_cache_enabled(self):
with self.assertRaisesRegex(ValueError, "--disable-radix-cache"):
ServerArgs(**self._base_kwargs(disable_radix_cache=False))
def test_rejects_attn_cp_size_greater_than_one(self):
with self.assertRaisesRegex(ValueError, "--attn-cp-size"):
ServerArgs(**self._base_kwargs(attn_cp_size=2, tp_size=2))
def test_rejects_prefill_context_parallel(self):
with self.assertRaisesRegex(ValueError, "--enable-prefill-context-parallel"):
ServerArgs(**self._base_kwargs(enable_prefill_context_parallel=True))
def test_rejects_hisparse(self):
with self.assertRaisesRegex(ValueError, "--enable-hisparse"):
ServerArgs(**self._base_kwargs(enable_hisparse=True))
def test_rejects_fp4_kv_cache(self):
with self.assertRaisesRegex(ValueError, "fp4_e2m1"):
ServerArgs(**self._base_kwargs(kv_cache_dtype="fp4_e2m1"))
if __name__ == "__main__":
unittest.main()