[Feature][Spec] Support disabling trailing prefix-cache block dropping (#53388)
Signed-off-by: Ziming Huang <zelda.huanghuang@gmail.com> Signed-off-by: Ziming Huang <48115868+ZeldaHuang@users.noreply.github.com> Co-authored-by: OpenAI Codex <codex@openai.com>
Ziming Huang committed
Sep 1, 2026 at 19:12 UTC
481839ad9e5ebf87aecb54fa5c9d986bd5ea4b81
13 files changed
+112
-21
tests/test_config.py
+17
@@ -2152,6 +2152,23 @@ def test_draft_sample_method_probabilistic_is_accepted():
2152
assert speculative_config.draft_sample_method == "probabilistic"
2153
2154
2155
+@pytest.mark.parametrize("disable_eagle_block_drop", [False, True])
2156
+def test_eagle_block_drop_can_be_disabled_without_disabling_eagle(
2157
+ disable_eagle_block_drop: bool,
2158
+):
2159
+ # Start from an ngram config to avoid loading model metadata: these predicates
2160
+ # depend only on the speculative method and the new switch.
2161
+ speculative_config = SpeculativeConfig(
2162
+ method="ngram",
2163
+ num_speculative_tokens=3,
2164
+ disable_eagle_block_drop=disable_eagle_block_drop,
2165
+ )
2166
+ speculative_config.method = "eagle3"
2167
+
2168
+ assert speculative_config.use_eagle()
2169
+ assert speculative_config.use_eagle_block_drop() is not disable_eagle_block_drop
2170
+
2171
+
2172
def test_draft_sample_method_gumbel_is_rejected():
2173
with pytest.raises(ValidationError):
2174
SpeculativeConfig(
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
+3
-3
@@ -170,7 +170,7 @@ def test_mamba_align_split_partial_tail_schedule(dcp_world_size: int):
170
cache_config=SimpleNamespace(block_size=block_size),
171
max_num_scheduled_tokens=8192,
172
scheduler_config=SimpleNamespace(long_prefill_token_threshold=0),
173
- use_eagle=False,
173
+ use_eagle_block_drop=False,
174
hash_block_size=hash_block_size,
175
dcp_world_size=dcp_world_size,
176
scheduler_block_size=scheduler_block_size,
@@ -218,7 +218,7 @@ def test_mamba_align_split_when_block_exceeds_scheduling_budget():
218
cache_config=SimpleNamespace(block_size=block_size),
219
max_num_scheduled_tokens=token_budget,
220
scheduler_config=SimpleNamespace(long_prefill_token_threshold=0),
221
- use_eagle=False,
221
+ use_eagle_block_drop=False,
222
hash_block_size=32,
223
mamba_partial_cache_hit=False,
224
mamba_has_prefill_checkpoint_blocks=False,
@@ -257,7 +257,7 @@ def test_mamba_align_split_when_block_exceeds_long_prefill_threshold():
257
scheduler_config=SimpleNamespace(
258
long_prefill_token_threshold=long_prefill_threshold
259
),
260
- use_eagle=False,
260
+ use_eagle_block_drop=False,
261
hash_block_size=32,
262
mamba_partial_cache_hit=False,
263
mamba_has_prefill_checkpoint_blocks=False,
tests/v1/core/test_kv_cache_utils.py
+1
@@ -3296,6 +3296,7 @@ def _spec_decode_grouping_config(method="dspark", model_type=None):
3296
speculative_config=SimpleNamespace(
3297
method=method,
3298
use_eagle=lambda: True,
3299
+ use_eagle_block_drop=lambda: True,
3300
),
3301
)
3302
tests/v1/core/test_mamba_align_chunk_split.py
+14
-1
@@ -80,13 +80,16 @@ def _split(
80
request: Request,
81
num_new_tokens: int,
82
use_eagle: bool = True,
83
+ use_eagle_block_drop: bool | None = None,
84
partial_hit: bool = False,
85
num_prefill_checkpoint_blocks: int = 0,
86
) -> int:
87
"""Call the real `Scheduler._mamba_block_aligned_split` on a stub self."""
88
+ if use_eagle_block_drop is None:
89
+ use_eagle_block_drop = use_eagle
90
stub = SimpleNamespace(
91
cache_config=SimpleNamespace(block_size=MAMBA_BLOCK_SIZE),
89
- use_eagle=use_eagle,
92
+ use_eagle_block_drop=use_eagle_block_drop,
93
max_num_scheduled_tokens=16384,
94
scheduler_config=SimpleNamespace(long_prefill_token_threshold=0),
95
# `prefix_match_unit` finer than the block size (#46384).
@@ -128,6 +131,16 @@ def test_internal_checkpoint_split(
131
assert all(not block.is_null for block in blocks) # checkpoint + running state
132
133
134
+def test_disabling_eagle_block_drop_keeps_the_trailing_cache_boundary() -> None:
135
+ (request,) = create_requests(1, num_tokens=3602, block_size=ATTN_BLOCK_SIZE)
136
+
137
+ with_drop = _split(request, request.num_tokens, use_eagle_block_drop=True)
138
+ without_drop = _split(request, request.num_tokens, use_eagle_block_drop=False)
139
+
140
+ assert with_drop == MAMBA_BLOCK_SIZE
141
+ assert without_drop == 2 * MAMBA_BLOCK_SIZE
142
+
143
+
144
def _run_chunked_prefill(
145
manager: KVCacheManager, request: Request, budgets: list[int]
146
) -> dict[int, int]:
tests/v1/core/test_prefix_caching.py
+1
-1
@@ -1163,7 +1163,7 @@ def test_hybrid_cache_mamba_align_shared_prefix_detection():
1163
cache_config=SimpleNamespace(block_size=block_size),
1164
max_num_scheduled_tokens=3 * block_size,
1165
scheduler_config=SimpleNamespace(long_prefill_token_threshold=0),
1166
- use_eagle=False,
1166
+ use_eagle_block_drop=False,
1167
hash_block_size=block_size,
1168
mamba_partial_cache_hit=False,
1169
mamba_has_prefill_checkpoint_blocks=False,
tests/v1/core/test_single_type_kv_cache_manager.py
+41
@@ -399,6 +399,47 @@ def test_sliding_window_possible_cached_prefix():
399
)
400
401
402
+def test_sliding_window_cache_hit_with_finer_hash_alignment():
403
+ """Sliding-window lookup uses full blocks with finer hybrid-cache hashes."""
404
+ hash_block_size = 2
405
+ block_size = 4
406
+ sliding_window_spec = SlidingWindowSpec(
407
+ block_size=block_size,
408
+ num_kv_heads=1,
409
+ head_size=1,
410
+ dtype=torch.float32,
411
+ sliding_window=8,
412
+ )
413
+ block_pool = BlockPool(
414
+ num_gpu_blocks=100,
415
+ enable_caching=True,
416
+ hash_block_size=hash_block_size,
417
+ )
418
+ manager = get_sliding_window_manager(sliding_window_spec, block_pool)
419
+ block_hashes = [BlockHash(str(i).encode()) for i in range(4)]
420
+
421
+ # Each physical block uses the last chained hash in its block-size view.
422
+ for block_hash, block in zip(
423
+ (block_hashes[1], block_hashes[3]), block_pool.blocks[10:12]
424
+ ):
425
+ block_pool.cached_block_hash_to_block.insert(
426
+ make_block_hash_with_group_id(block_hash, 0), block
427
+ )
428
+
429
+ computed_blocks, hit_length = manager.find_longest_cache_hit(
430
+ block_hashes=block_hashes,
431
+ max_length=8,
432
+ kv_cache_group_ids=[0],
433
+ block_pool=block_pool,
434
+ kv_cache_spec=sliding_window_spec,
435
+ drop_eagle_block=False,
436
+ alignment_tokens=hash_block_size,
437
+ )
438
+
439
+ assert hit_length == 8
440
+ assert computed_blocks[0] == block_pool.blocks[10:12]
441
+
442
+
443
def test_chunked_local_attention_remove_skipped_blocks():
444
attention_spec = ChunkedLocalAttentionSpec(
445
block_size=2,
vllm/config/speculative.py
+9
@@ -436,6 +436,11 @@ class SpeculativeConfig:
436
speculative input batches can contain sequences of different lengths,
437
which may only be supported by certain attention backends. This currently
438
only affects the EAGLE method of speculation."""
439
+ disable_eagle_block_drop: bool = False
440
+ """Disable dropping the trailing prefix-cache block for EAGLE-like
441
+ speculative methods. This is an experimental option for measuring the
442
+ acceptance-rate impact of reusing that block. It does not disable the
443
+ speculative drafter itself."""
444
use_local_argmax_reduction: bool = False
445
"""Use vocab-parallel local argmax instead of all-gathering full logits
446
for draft token generation. Reduces communication from O(vocab_size) to
@@ -1846,6 +1851,10 @@ class SpeculativeConfig:
1851
# TODO(ben): Refactor this so the naming is clearer
1852
return self.method in ("eagle", "eagle3", "mtp", "dflash", "dspark")
1853
1854
+ def use_eagle_block_drop(self) -> bool:
1855
+ """Whether volatile trailing cache blocks should be discarded."""
1856
+ return self.use_eagle() and not self.disable_eagle_block_drop
1857
+
1858
def use_dflash(self) -> bool:
1859
return self.method == "dflash"
1860
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+5
-4
@@ -1593,16 +1593,17 @@ class MooncakeStoreWorker:
1593
for group in kv_cache_config.transfer_groups
1594
]
1595
spec_cfg = getattr(vllm_config, "speculative_config", None)
1596
- use_eagle = bool(
1597
- spec_cfg.use_eagle()
1598
- if spec_cfg is not None and callable(getattr(spec_cfg, "use_eagle", None))
1596
+ use_eagle_block_drop = bool(
1597
+ spec_cfg.use_eagle_block_drop()
1598
+ if spec_cfg is not None
1599
+ and callable(getattr(spec_cfg, "use_eagle_block_drop", None))
1600
else False
1601
)
1602
self.coord = MooncakeStoreCoordinator(
1603
self._kv_cache_groups,
1604
scheduler_block_size=self.block_size,
1605
hash_block_size=self.hash_block_size,
1605
- use_eagle=use_eagle,
1606
+ use_eagle=use_eagle_block_drop,
1607
retention_interval=kv_cache_config.prefix_cache_retention_interval,
1608
dcp_world_size=self.dcp_size,
1609
)
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+3
-3
@@ -223,11 +223,11 @@ class SchedulerOffloadConfig(NamedTuple):
223
if g.is_eagle_group
224
}
225
226
- use_eagle = (
226
+ use_eagle_block_drop = (
227
vllm_config.speculative_config is not None
228
- and vllm_config.speculative_config.use_eagle()
228
+ and vllm_config.speculative_config.use_eagle_block_drop()
229
)
230
- if use_eagle and not eagle_groups:
230
+ if use_eagle_block_drop and not eagle_groups:
231
eagle_groups = set(range(len(kv_cache_config.kv_cache_groups)))
232
233
if eagle_groups:
vllm/v1/core/kv_cache_utils.py
+1
-1
@@ -1841,7 +1841,7 @@ def _annotate_eagle_groups(
1841
use_deepseek_v4_fallback: Enable rule 2 for a DeepseekV4 packed group.
1842
"""
1843
spec_config = vllm_config.speculative_config
1844
- if spec_config is None or not spec_config.use_eagle():
1844
+ if spec_config is None or not spec_config.use_eagle_block_drop():
1845
return
1846
1847
for group in kv_cache_groups:
vllm/v1/core/sched/scheduler.py
+10
-2
@@ -255,6 +255,7 @@ class Scheduler(SchedulerInterface):
255
)
256
speculative_config = vllm_config.speculative_config
257
self.use_eagle = False
258
+ self.use_eagle_block_drop = False
259
self.num_spec_tokens = vllm_config.num_speculative_tokens
260
self.num_lookahead_tokens = vllm_config.num_lookahead_tokens
261
# Positions past the computed tokens that the drafter reads mid-prefill.
@@ -279,6 +280,13 @@ class Scheduler(SchedulerInterface):
280
if speculative_config.use_multi_module_mtp()
281
else 1
282
)
283
+ self.use_eagle_block_drop = speculative_config.use_eagle_block_drop()
284
+ if self.use_eagle and not self.use_eagle_block_drop:
285
+ logger.warning(
286
+ "EAGLE trailing prefix-cache block dropping is disabled. "
287
+ "This is experimental and may affect speculative-token "
288
+ "acceptance rates."
289
+ )
290
291
# Create the KV cache manager.
292
if hash_block_size is None:
@@ -289,7 +297,7 @@ class Scheduler(SchedulerInterface):
297
max_model_len=self.max_model_len,
298
max_in_flight_tokens=vllm_config.max_in_flight_tokens,
299
enable_caching=self.cache_config.enable_prefix_caching,
292
- use_eagle=self.use_eagle,
300
+ use_eagle=self.use_eagle_block_drop,
301
num_prefill_lookahead=self.num_prefill_lookahead,
302
log_stats=self.log_stats,
303
enable_kv_cache_events=self.enable_kv_cache_events,
@@ -414,7 +422,7 @@ class Scheduler(SchedulerInterface):
422
# Eagle, FullAttn prunes the last matching block, so back off one
423
# block to avoid a Mamba cache miss.
424
last_cache_position = request.num_tokens - request.num_tokens % block_size
417
- if self.use_eagle:
425
+ if self.use_eagle_block_drop:
426
last_cache_position = max(last_cache_position - block_size, 0)
427
428
end = start + num_new_tokens
vllm/v1/core/single_type_kv_cache_manager.py
+3
-4
@@ -925,10 +925,9 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
925
)
926
assert dcp_world_size == 1, "DCP not support sliding window attn now."
927
assert pcp_world_size == 1, "PCP not support sliding window attn now."
928
- # Fine-grained partial hits are not supported for sliding window now
929
- assert alignment_tokens % kv_cache_spec.block_size == 0, (
930
- "SlidingWindowManager does not support fine-grained (partial) cache hits"
931
- )
928
+ # Sliding-window cache hits must stay at the group's physical block
929
+ # granularity. resolve_block_hashes() converts finer-grained hashes to
930
+ # that view when the hybrid-cache alignment is smaller than block_size.
931
block_hashes = resolve_block_hashes(
932
block_hashes,
933
block_pool.hash_block_size,
vllm/v1/simple_kv_offload/manager.py
+4
-2
@@ -125,12 +125,14 @@ class SimpleCPUOffloadScheduler:
125
)
126
127
spec_config = vllm_config.speculative_config
128
- use_eagle = spec_config is not None and spec_config.use_eagle()
128
+ use_eagle_block_drop = (
129
+ spec_config is not None and spec_config.use_eagle_block_drop()
130
+ )
131
self.cpu_coordinator: KVCacheCoordinator = get_kv_cache_coordinator(
132
kv_cache_config=self.cpu_kv_cache_config,
133
max_model_len=vllm_config.model_config.max_model_len,
134
max_in_flight_tokens=vllm_config.max_in_flight_tokens,
133
- use_eagle=use_eagle,
135
+ use_eagle=use_eagle_block_drop,
136
enable_caching=True,
137
enable_kv_cache_events=self.enable_kv_cache_events,
138
dcp_world_size=dcp_world_size,