[Bugfix] OffloadingConnector: stop zeroing offload hits under MTP/EAGLE spec decode (#52771)

Signed-off-by: Kam Basra <kameldipbasra@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>

Kam Basra committed Sep 7, 2026 at 13:16 UTC 4a806d08ee94b35356dd749bf814ee91ee93f8d0
3 files changed +297 -21
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+261 -12
@@ -50,6 +50,7 @@ from vllm.v1.kv_cache_interface import (
50 FullAttentionSpec,
51 KVCacheConfig,
52 KVCacheGroupSpec,
53 + MambaSpec,
54 SlidingWindowSpec,
55 )
56 from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
@@ -3070,8 +3071,9 @@ class TestEagle:
3071
3072 Groups: 0=non-eagle full-attn, 1=eagle full-attn.
3073 Group 0 has only 1 hit (out of 3 keys) → max_hit tightens to 4.
3073 - This clears eagle_verified. Group 1 runs with max_hit=4 → only 1
3074 - key queried, 1 hit, pop to 0 → returns 0.
3074 + This clears eagle_verified. Group 1 runs with max_hit=4 → widened
3075 + query of 2 keys, 2 hits, pop to 1 → the confirmed boundary holds
3076 + at 4 tokens.
3077 """
3078 block_size = 4
3079 groups = [
@@ -3116,9 +3118,12 @@ class TestEagle:
3118 offload_keys_per_group=[[10, 11, 12], [1, 2, 3]],
3119 )
3120 # Group 0 (non-eagle FA): prefix finds 1 hit → max_hit=4, num_hit=4
3119 - # Group 1 (eagle FA): max_hit=4 → num_blocks=1, keys=[1].
3120 - # Finds 1 hit, pop to 0 → new_num_hit = 0 < block_size → return 0
3121 - assert sched._lookup(req_status) == 0
3121 + # Group 1 (eagle FA): max_hit=4, widened query → keys=[1, 2].
3122 + # Finds 2 hits, pop to 1 → boundary stays at 4 tokens. Before the
3123 + # #52735 fix the query was not widened for full-attention groups,
3124 + # so the pop landed on the only queried chunk and zeroed the
3125 + # whole request.
3126 + assert sched._lookup(req_status) == 4
3127
3128 def test_eagle_verified_survives_eagle_tighten(self, request_runner):
3129 """Eagle group tightening does NOT clear eagle_verified.
@@ -3234,10 +3239,12 @@ class TestEagle:
3239 runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3240 generate_store_output(keys)
3241 )
3237 - # 4 decoded tokens fill block 3 entirely with decode tokens (one
3238 - # extra token so the block is stored under async scheduling too).
3242 + # 4 decoded tokens fill block 3; the extra decode steps let its store
3243 + # job complete within this run under both scheduling modes. The
3244 + # eagle group holds back its volatile trailing block (1, 3) while the
3245 + # request is still decoding, while the normal group stores (0, 3).
3246 runner.run(
3240 - decoded_tokens=[1, 1, 1, 1, 1, EOS_TOKEN_ID],
3247 + decoded_tokens=[1, 1, 1, 1, 1, 1, 1],
3248 expected_stored=(
3249 (0, 0),
3250 (0, 1),
@@ -3248,6 +3255,13 @@ class TestEagle:
3255 (1, 2),
3256 ),
3257 )
3258 + # Once the request finishes, no spec rejection can rewrite the tail,
3259 + # so the exclusion is lifted (issue #52735): the held-back (1, 3) and
3260 + # the just-completed block 4 are stored for both groups.
3261 + runner.run(
3262 + decoded_tokens=[EOS_TOKEN_ID],
3263 + expected_stored=((1, 3),),
3264 + )
3265
3266 @pytest.mark.parametrize("async_scheduling", [True, False])
3267 def test_sw_store_excludes_trailing_decode_block(
@@ -3289,11 +3303,17 @@ class TestEagle:
3303 runner.manager.prepare_store.side_effect = lambda keys, req_context: (
3304 generate_store_output(keys)
3305 )
3292 - # 4 decoded tokens fill block 3 entirely with decode tokens.
3306 + # 4 decoded tokens fill block 3 entirely with decode tokens. The
3307 + # eagle group holds back its volatile trailing block while decoding.
3308 runner.run(
3294 - decoded_tokens=[1, 1, 1, 1, EOS_TOKEN_ID],
3309 + decoded_tokens=[1, 1, 1, 1],
3310 expected_stored=((0, 0), (0, 1), (0, 2)),
3311 )
3312 + # Finish lifts the exclusion; the tail block is stored (issue #52735).
3313 + runner.run(
3314 + decoded_tokens=[EOS_TOKEN_ID],
3315 + expected_stored=((0, 3),),
3316 + )
3317
3318 @pytest.mark.parametrize("async_scheduling", [True, False])
3319 def test_single_block_stored_at_end_of_prefill(
@@ -3569,8 +3589,8 @@ class TestEagle:
3589 )
3590 spec = MockOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
3591 scheduler = OffloadingConnectorScheduler(spec, vllm_config, kv_cache_config)
3572 - scheduler.manager.prepare_store.side_effect = (
3573 - lambda keys, req_context: generate_store_output(keys)
3592 + scheduler.manager.prepare_store.side_effect = lambda keys, req_context: (
3593 + generate_store_output(keys)
3594 )
3595
3596 request = MagicMock()
@@ -4171,3 +4191,232 @@ def test_retention_interval_zero_stores_only_replay_boundary(
4191 (1, 11),
4192 ),
4193 )
4194 +
4195 +
4196 +def _shared_kv_mtp_config():
4197 + """Speculative config for a shared-group MTP model: eagle-family method
4198 + whose drafter layer merges into a target KV-cache group, so no group
4199 + self-identifies as a drafter group (issue #52735)."""
4200 + spec = MagicMock(name="shared_kv_mtp_spec")
4201 + spec.use_eagle.return_value = True
4202 + spec.use_eagle_block_drop.return_value = True
4203 + spec.use_multi_module_mtp.return_value = False
4204 + spec.num_speculative_tokens_per_batch_size = None
4205 + spec.max_num_new_slots_for_drafting = 0
4206 + spec.num_speculative_tokens = 1
4207 + return spec
4208 +
4209 +
4210 +class TestSharedGroupMTPOffload:
4211 + """Regression tests for issue #52735: OffloadingConnector must keep
4212 + serving when speculative decoding is enabled but no KV-cache group is
4213 + annotated as a drafter group (shared-group MTP models)."""
4214 +
4215 + def test_no_annotation_marks_no_groups(self, request_runner):
4216 + """Spec decode on + zero annotated groups must NOT mark every group
4217 + as a drafter group; the full store->load roundtrip must match the
4218 + non-speculative behavior of test_two_groups_full_and_sliding_window."""
4219 + block_size = 4
4220 + kv_cache_groups = [
4221 + KVCacheGroupSpec(
4222 + ["layer0"],
4223 + FullAttentionSpec(
4224 + block_size=block_size,
4225 + num_kv_heads=1,
4226 + head_size=1,
4227 + dtype=torch.float32,
4228 + ),
4229 + ),
4230 + KVCacheGroupSpec(
4231 + ["layer1"],
4232 + SlidingWindowSpec(
4233 + block_size=block_size,
4234 + num_kv_heads=1,
4235 + head_size=1,
4236 + dtype=torch.float32,
4237 + sliding_window=8,
4238 + ),
4239 + ),
4240 + ]
4241 + runner = request_runner(
4242 + block_size=block_size,
4243 + num_gpu_blocks=100,
4244 + async_scheduling=True,
4245 + kv_cache_groups=kv_cache_groups,
4246 + speculative_config=_shared_kv_mtp_config(),
4247 + )
4248 + kv_group_configs = runner.connector_scheduler.config.kv_group_configs
4249 + assert [c.is_eagle_group for c in kv_group_configs] == [False, False]
4250 +
4251 + runner.new_request(token_ids=[0] * block_size * 3)
4252 + runner.manager.prepare_store.side_effect = lambda keys, req_context: (
4253 + generate_store_output(keys)
4254 + )
4255 + runner.run(decoded_tokens=[0])
4256 + runner.run(
4257 + decoded_tokens=[0] * (block_size * 3 + 2),
4258 + expected_stored=(0, 1, 2, 3, 4, 5),
4259 + )
4260 + runner.run(decoded_tokens=[EOS_TOKEN_ID])
4261 +
4262 + runner.scheduler.reset_prefix_cache()
4263 +
4264 + runner.new_request(token_ids=[0] * (block_size * 3 + 1))
4265 + runner.manager.lookup.return_value = LookupResult.HIT
4266 + runner.run(
4267 + decoded_tokens=[EOS_TOKEN_ID],
4268 + expected_loaded=((0, 0), (0, 1), (0, 2), (1, 1), (1, 2)),
4269 + )
4270 +
4271 +
4272 +class TestMambaHybridOffloadServing:
4273 + """Store->finish->lookup flows on a full-attention + mamba-align hybrid,
4274 + with a manager that only HITs keys that were actually stored. Guards the
4275 + two collapse routes of issue #52735."""
4276 +
4277 + BLOCK = 4
4278 + MAMBA_BLOCK = 16
4279 + PROMPT_TOKENS = 28 # 7 hash blocks; 1 full mamba chunk
4280 +
4281 + def _make_scheduler(self, speculative_config, mamba_eagle=False):
4282 + vllm_config = _make_vllm_config(
4283 + extra_config={"self_describing_kv_events": True}
4284 + )
4285 + vllm_config.cache_config.block_size = self.BLOCK
4286 + vllm_config.cache_config.prefix_match_unit = self.BLOCK
4287 + vllm_config.speculative_config = speculative_config
4288 + vllm_config.kv_events_config = KVEventsConfig(
4289 + enable_kv_cache_events=True, publisher="null"
4290 + )
4291 + kv_cache_config = KVCacheConfig(
4292 + num_blocks=64,
4293 + kv_cache_tensors=[],
4294 + kv_cache_groups=[
4295 + KVCacheGroupSpec(
4296 + ["full_layer"],
4297 + FullAttentionSpec(
4298 + block_size=self.BLOCK,
4299 + num_kv_heads=1,
4300 + head_size=1,
4301 + dtype=torch.float32,
4302 + ),
4303 + is_eagle_group=mamba_eagle,
4304 + ),
4305 + KVCacheGroupSpec(
4306 + ["mamba_layer"],
4307 + MambaSpec(
4308 + block_size=self.MAMBA_BLOCK,
4309 + shapes=((1, 1),),
4310 + dtypes=(torch.float32,),
4311 + mamba_cache_mode="align",
4312 + ),
4313 + ),
4314 + ],
4315 + )
4316 + spec = MockOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
4317 + return OffloadingConnectorScheduler(spec, vllm_config, kv_cache_config)
4318 +
4319 + def _roundtrip_served_tokens(self, scheduler) -> int:
4320 + """Request A: prompt-only store, finish; request A2: served tokens."""
4321 + stored: set = set()
4322 + scheduler.manager.lookup.side_effect = lambda key, ctx: (
4323 + LookupResult.HIT if key in stored else LookupResult.MISS
4324 + )
4325 + scheduler.manager.prepare_store.side_effect = lambda keys, ctx: (
4326 + generate_store_output(list(keys))
4327 + )
4328 +
4329 + def complete_all_jobs():
4330 + job_ids = list(scheduler._jobs.keys())
4331 + for jid in job_ids:
4332 + stored.update(scheduler._jobs[jid].keys)
4333 + if job_ids:
4334 + scheduler.update_connector_output(
4335 + KVConnectorOutput(
4336 + kv_connector_worker_meta=OffloadingWorkerMetadata(
4337 + completed_jobs={jid: 1 for jid in job_ids}
4338 + )
4339 + )
4340 + )
4341 +
4342 + def make_request(req_id):
4343 + request = MagicMock()
4344 + request.request_id = req_id
4345 + request.kv_transfer_params = None
4346 + request.num_prompt_tokens = self.PROMPT_TOKENS
4347 + request.num_tokens = self.PROMPT_TOKENS
4348 + request.num_computed_tokens = 0
4349 + request.block_hashes = [
4350 + BlockHash(f"h{i}".encode())
4351 + for i in range(self.PROMPT_TOKENS // self.BLOCK)
4352 + ]
4353 + request.all_token_ids = list(range(self.PROMPT_TOKENS))
4354 + request.lora_request = None
4355 + request.is_finished.return_value = False
4356 + request.status = None
4357 + scheduler.on_new_request(request)
4358 + return request
4359 +
4360 + req = make_request("A")
4361 + req_status = scheduler._req_status["A"]
4362 + req_status.num_locally_computed_tokens = 0
4363 + req_status.update_offload_keys()
4364 + assert scheduler._lookup(req_status) == 0 # cold
4365 +
4366 + n_full = self.PROMPT_TOKENS // self.BLOCK
4367 + n_mamba = self.PROMPT_TOKENS // self.MAMBA_BLOCK
4368 + req_status.group_states[0].block_ids[:] = list(range(1, n_full + 1))
4369 + req_status.group_states[1].block_ids[:] = list(range(101, 101 + n_mamba + 1))
4370 +
4371 + out = SimpleNamespace(
4372 + num_scheduled_tokens={"A": self.PROMPT_TOKENS},
4373 + finished_req_ids=set(),
4374 + kv_connector_block_state=KVConnectorBlockState(
4375 + block_ids={},
4376 + boundary_state_offloads={"A": [(1, 101, self.MAMBA_BLOCK)]},
4377 + ),
4378 + )
4379 + scheduler._build_partial_tail_store_jobs(out)
4380 + scheduler._build_store_jobs(out)
4381 + complete_all_jobs()
4382 +
4383 + req.num_computed_tokens = self.PROMPT_TOKENS
4384 + req.num_tokens = self.PROMPT_TOKENS + 1
4385 + req.is_finished.return_value = True
4386 + req_status.update_offload_keys()
4387 + scheduler.request_finished(req)
4388 + out2 = SimpleNamespace(num_scheduled_tokens={}, finished_req_ids={"A"})
4389 + scheduler._build_store_jobs(out2)
4390 + complete_all_jobs()
4391 +
4392 + make_request("A2")
4393 + rs2 = scheduler._req_status["A2"]
4394 + rs2.num_locally_computed_tokens = 0
4395 + rs2.update_offload_keys()
4396 + served = scheduler._lookup(rs2)
4397 + return served if served is not None else 0
4398 +
4399 + def test_shared_group_mtp_serves_like_non_speculative(self):
4400 + """Route 1 of #52735: with spec decode on and no drafter annotation,
4401 + serving must equal the non-speculative baseline (was 0 before the
4402 + fix: the all-groups fallback marked the mamba group as a drafter and
4403 + the volatile-tail pop consumed its only servable chunk)."""
4404 + baseline = self._roundtrip_served_tokens(self._make_scheduler(None))
4405 + assert baseline == 16
4406 + served = self._roundtrip_served_tokens(
4407 + self._make_scheduler(_shared_kv_mtp_config())
4408 + )
4409 + assert served == baseline
4410 +
4411 + def test_annotated_eagle_group_does_not_starve_coarse_sibling(self):
4412 + """Route 2 of #52735 (F1): a genuinely annotated eagle full-attention
4413 + group must not drag the confirmed boundary below a coarser sibling's
4414 + chunk granularity. The widened query makes the volatile-tail pop
4415 + land on an extra queried chunk, holding the boundary at 16 tokens
4416 + (was 0 before the fix: 4 hits -> pop -> 12 tokens < mamba chunk)."""
4417 + scheduler = self._make_scheduler(None, mamba_eagle=True)
4418 + assert [c.is_eagle_group for c in scheduler.config.kv_group_configs] == [
4419 + True,
4420 + False,
4421 + ]
4422 + assert self._roundtrip_served_tokens(scheduler) == 16
tests/v1/kv_connector/unit/offloading_connector/utils.py
+5
@@ -181,6 +181,7 @@ class RequestRunner:
181 extra_config_overrides: dict[str, Any] | None = None,
182 worker_count: int = 1,
183 retention_interval: int | None = None,
184 + speculative_config: Any | None = None,
185 ):
186 assert blocks_per_chunk == 1 or kv_cache_groups is None, (
187 "blocks_per_chunk > 1 requires all groups to have the same "
@@ -202,6 +203,8 @@ class RequestRunner:
203 vllm_config.scheduler_config.async_scheduling = async_scheduling
204 vllm_config.parallel_config.world_size = worker_count
205 vllm_config.cache_config.prefix_cache_retention_interval = retention_interval
206 + if speculative_config is not None:
207 + vllm_config.speculative_config = speculative_config
208
209 extra_config: dict[str, Any] = {
210 "spec_name": "MockOffloadingSpec",
@@ -679,6 +682,7 @@ def request_runner():
682 extra_config_overrides=None,
683 worker_count=1,
684 retention_interval=None,
685 + speculative_config=None,
686 ):
687 runner = RequestRunner(
688 block_size=block_size,
@@ -689,6 +693,7 @@ def request_runner():
693 extra_config_overrides=extra_config_overrides,
694 worker_count=worker_count,
695 retention_interval=retention_interval,
696 + speculative_config=speculative_config,
697 )
698 runners.append(runner)
699 return runner
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+31 -9
@@ -208,7 +208,21 @@ class SchedulerOffloadConfig(NamedTuple):
208 and vllm_config.speculative_config.use_eagle_block_drop()
209 )
210 if use_eagle_block_drop and not eagle_groups:
211 - eagle_groups = set(range(len(kv_cache_config.kv_cache_groups)))
211 + # No group is annotated as holding drafter layers. This happens
212 + # for shared-group MTP models (e.g. Qwen3.5-style), whose drafter
213 + # is a regular decoder layer merged into the target's
214 + # full-attention group, and for separate-draft models that miss
215 + # annotation. Treating every group as a drafter group here makes
216 + # the volatile-tail pop apply to ALL groups, which can zero the
217 + # whole request's offload hit whenever any group's servable
218 + # window is a single chunk (issue #52735). Drafter KV served one
219 + # chunk stale can only lower speculative acceptance rates — the
220 + # target model verifies every draft — so fail toward serving.
221 + logger.info_once(
222 + "KV offloading: speculative decoding is enabled but no "
223 + "KV-cache group is annotated as a drafter group; treating "
224 + "all groups as non-draft for offloading."
225 + )
226
227 if eagle_groups:
228 logger.info(
@@ -395,15 +409,19 @@ class RequestOffloadState:
409 accepted position may be rewritten after spec-token rejection. During
410 prefill the trailing chunk is stable (the draft input for a chunk's
411 last position is the next prompt token), so it is stored immediately.
398 - The exclusion must be applied consistently everywhere
399 - ``next_stored_chunk_idx`` is derived: otherwise the trailing chunk of
400 - each step is skipped on collection but jumped over by
401 - ``next_stored_chunk_idx``, so it is never re-considered and a
402 - permanent hole breaks prefix-reuse lookup.
412 + Once the request has finished, no further spec-token rejection can
413 + rewrite the tail, so the exclusion is lifted and the final chunk
414 + becomes storable (issue #52735). The exclusion must be applied
415 + consistently everywhere ``next_stored_chunk_idx`` is derived:
416 + otherwise the trailing chunk of each step is skipped on collection but
417 + jumped over by ``next_stored_chunk_idx``, so it is never re-considered
418 + and a permanent hole breaks prefix-reuse lookup. ``is_finished`` is
419 + monotonic, so the finish-time calls all see the lifted exclusion.
420 """
421 num_chunks = num_offloadable_tokens // group_config.tokens_per_chunk
422 is_decoding = num_offloadable_tokens > self.req.num_prompt_tokens
406 - if group_config.is_eagle_group and is_decoding:
423 + # Finished requests have no pending speculation.
424 + if group_config.is_eagle_group and is_decoding and not self.req.is_finished():
425 num_chunks = max(0, num_chunks - 1)
426 num_allocated_chunks = (
427 len(group_state.block_ids) // self.config.blocks_per_chunk
@@ -761,9 +779,13 @@ class OffloadingConnectorScheduler:
779 )
780
781 # For eagle groups, query one extra chunk that will be popped.
764 - # We only need to increase the query size for sliding window groups.
782 + # Widening applies to every group type: without it, the pop
783 + # below shrinks max_hit_size_tokens past what was queried,
784 + # which can push the confirmed boundary under a coarser
785 + # sibling group's chunk granularity and zero the whole
786 + # request's hit (issue #52735).
787 query_max = max_hit_size_tokens
766 - if is_eagle_unverified and sliding_window_size_in_chunks is not None:
788 + if is_eagle_unverified:
789 query_max = min(
790 max_hit_size_tokens + tokens_per_chunk,
791 len(offload_keys) * tokens_per_chunk,