[Bugfix][Mooncake] Report request-level KV load failures under HMA (#56855)

Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai>

Nicolò Lucchesi committed Sep 16, 2026 at 14:55 UTC 8b1d188046034b42024d22ee52fa6c3948b4acbd
9 files changed +329 -377
tests/v1/kv_connector/unit/test_kv_load_failure_recovery.py
+13 -332
@@ -2,21 +2,12 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from collections.abc import Callable
5 -from typing import Literal
6 -from unittest.mock import Mock, patch
5 +from unittest.mock import Mock
6
7 import pytest
9 -import torch
8
11 -from vllm.v1.core.sched.output import SchedulerOutput
9 from vllm.v1.core.sched.scheduler import Scheduler
13 -from vllm.v1.kv_cache_interface import (
14 - KVCacheConfig,
15 - KVCacheGroupSpec,
16 - MLAAttentionSpec,
17 - SlidingWindowMLASpec,
18 -)
19 -from vllm.v1.request import FinishReason, Request, RequestStatus
10 +from vllm.v1.request import Request, RequestStatus
11
12 from .utils import (
13 create_model_runner_output,
@@ -44,18 +35,6 @@ def scheduler():
35 return create_scheduler(vllm_config)
36
37
47 -@pytest.fixture
48 -def hybrid_scheduler():
49 - """Scheduler for a hybrid model: full attention plus a Mamba group."""
50 - vllm_config = create_vllm_config(kv_load_failure_policy="recompute")
51 - kv_cache_config = make_kv_cache_config(
52 - block_size=vllm_config.cache_config.block_size,
53 - mamba_enabled=True,
54 - num_blocks=10000,
55 - )
56 - return create_scheduler(vllm_config, kv_cache_config=kv_cache_config)
57 -
58 -
38 @pytest.mark.parametrize(
39 "num_prompt_blocks,num_external_computed_blocks,invalid_block_idxs",
40 [
@@ -361,317 +340,19 @@ def test_async_progressive_load_failure(
340 assert scheduler.connector.get_num_new_matched_tokens.call_count == 1
341
342
364 -def _schedule_hybrid_async_load(
365 - scheduler: Scheduler, num_prompt_blocks: int, num_external_computed_blocks: int
366 -) -> Request:
367 - """Put one request into an async external load on a hybrid model."""
368 - request = create_request(num_tokens=num_prompt_blocks * scheduler.block_size)
369 - scheduler.add_request(request=request)
370 -
371 - scheduler.connector = Mock()
372 - scheduler.connector.get_num_new_matched_tokens.side_effect = (
373 - _make_get_num_new_matched_tokens(
374 - {request.request_id: num_external_computed_blocks * scheduler.block_size},
375 - async_load=True,
376 - )
377 - )
378 - scheduler.connector.take_events.return_value = ()
379 - return request
380 -
381 -
382 -def _report_invalid_blocks(
383 - scheduler: Scheduler, scheduler_output, invalid_block_ids: set[int]
384 -) -> None:
385 - scheduler.update_from_output(
386 - scheduler_output,
387 - create_model_runner_output(
388 - reqs=[],
389 - finished_recving=set(),
390 - invalid_block_ids=invalid_block_ids,
391 - use_eos=True,
392 - ),
393 - )
343 +@pytest.mark.cpu_test
344 +def test_block_level_load_failure_fails_closed_with_multiple_groups():
345 + """Block-level invalid-block reporting is rejected on multi-group layouts.
346
395 -
396 -@pytest.mark.parametrize("failed_group_idx", [0, 1])
397 -def test_hybrid_load_failure_recomputes_whole_request(
398 - hybrid_scheduler: Scheduler, failed_group_idx: int
399 -):
400 - """A hybrid model must recover, not crash.
401 -
402 - Its groups sit on different block grids, so there is no single truncation
403 - point; the request restarts from scratch instead.
347 + Block IDs are only unique within a group, so the scheduler cannot map
348 + them back to requests; connectors must report failed_recving instead.
349 """
405 - num_prompt_blocks, num_external_computed_blocks = 10, 9
406 - request = _schedule_hybrid_async_load(
407 - hybrid_scheduler, num_prompt_blocks, num_external_computed_blocks
408 - )
409 - scheduler_output = hybrid_scheduler.schedule()
410 -
411 - block_ids_per_group = hybrid_scheduler.kv_cache_manager.get_block_ids(
412 - request.request_id
413 - )
414 - assert len(block_ids_per_group) == 2, "expected a hybrid (multi-group) layout"
415 - null_block_id = hybrid_scheduler.kv_cache_manager.block_pool.null_block.block_id
416 - # The Mamba group backs only its aligned snapshot with a real block, so
417 - # pick a block that actually belongs to this request.
418 - failed_block = next(
419 - block_id
420 - for block_id in block_ids_per_group[failed_group_idx]
421 - if block_id != null_block_id
422 - )
423 -
424 - _report_invalid_blocks(hybrid_scheduler, scheduler_output, {failed_block})
425 -
426 - assert request.num_computed_tokens == 0
427 - assert hybrid_scheduler.failed_recving_kv_req_ids == {request.request_id}
428 - assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
429 -
430 -
431 -def test_hybrid_load_failure_ignores_null_block(hybrid_scheduler: Scheduler):
432 - """The null block is shared by every request and holds no KV, so reporting
433 - it must not restart anyone."""
434 - num_prompt_blocks, num_external_computed_blocks = 10, 9
435 - request = _schedule_hybrid_async_load(
436 - hybrid_scheduler, num_prompt_blocks, num_external_computed_blocks
437 - )
438 - scheduler_output = hybrid_scheduler.schedule()
439 -
440 - null_block_id = hybrid_scheduler.kv_cache_manager.block_pool.null_block.block_id
441 - _, mamba_block_ids = hybrid_scheduler.kv_cache_manager.get_block_ids(
442 - request.request_id
443 - )
444 - # The Mamba group parks its unaligned positions on the null block, so a
445 - # request that never failed would otherwise be restarted by this report.
446 - assert mamba_block_ids[0] == null_block_id
447 -
448 - num_computed_tokens = request.num_computed_tokens
449 - _report_invalid_blocks(hybrid_scheduler, scheduler_output, {null_block_id})
450 -
451 - assert request.num_computed_tokens == num_computed_tokens
452 - assert not hybrid_scheduler.failed_recving_kv_req_ids
453 -
454 -
455 -def _make_deepseek_v4_five_group_config(num_blocks: int) -> KVCacheConfig:
456 - """Build the scheduler-side projection of V4-Flash's packed KV layout."""
457 -
458 - def mla_spec() -> MLAAttentionSpec:
459 - return MLAAttentionSpec(
460 - block_size=256,
461 - num_kv_heads=1,
462 - head_size=512,
463 - dtype=torch.bfloat16,
464 - tokens_per_state=4,
465 - model_version="deepseek_v4",
466 - )
467 -
468 - def swa_spec(
469 - *, block_size: int, sliding_window: int, head_size: int
470 - ) -> SlidingWindowMLASpec:
471 - return SlidingWindowMLASpec(
472 - block_size=block_size,
473 - num_kv_heads=1,
474 - head_size=head_size,
475 - dtype=torch.float32,
476 - sliding_window=sliding_window,
477 - model_version="deepseek_v4",
478 - )
479 -
480 - # DeepSeek-V4 packs these into five physical groups. The two SWA groups
481 - # have identical scheduling semantics but are split to align layer tuples.
482 - groups = [
483 - KVCacheGroupSpec(["full_mla_and_indexer"], mla_spec()),
484 - KVCacheGroupSpec(
485 - ["c4_state"],
486 - swa_spec(block_size=4, sliding_window=8, head_size=2048),
487 - ),
488 - KVCacheGroupSpec(
489 - ["c128_state"],
490 - swa_spec(block_size=8, sliding_window=128, head_size=2048),
491 - ),
492 - KVCacheGroupSpec(
493 - ["swa.0"],
494 - swa_spec(block_size=64, sliding_window=512, head_size=512),
495 - ),
496 - KVCacheGroupSpec(
497 - ["swa.1"],
498 - swa_spec(block_size=64, sliding_window=512, head_size=512),
499 - ),
500 - ]
501 - return KVCacheConfig(
502 - num_blocks=num_blocks,
503 - kv_cache_tensors=[],
504 - kv_cache_groups=groups,
505 - )
506 -
507 -
508 -def _create_deepseek_v4_scheduler(
509 - kv_load_failure_policy: Literal["recompute", "fail"] = "recompute",
510 -) -> Scheduler:
511 - num_blocks = 10000
512 - vllm_config = create_vllm_config(
513 - block_size=256,
514 - max_num_batched_tokens=4096,
515 - max_model_len=8192,
516 - # max_model_len only reaches SchedulerConfig; opt-125m still caps the
517 - # model at 2048, which is below the 9-block requests used here.
518 - hf_overrides={"max_position_embeddings": 8192},
519 - kv_load_failure_policy=kv_load_failure_policy,
520 - )
521 - return create_scheduler(
350 + vllm_config = create_vllm_config()
351 + scheduler = create_scheduler(
352 vllm_config,
523 - num_blocks=num_blocks,
524 - kv_cache_config=_make_deepseek_v4_five_group_config(num_blocks),
525 - hash_block_size=4,
353 + kv_cache_config=make_kv_cache_config(block_size=16, swa_enabled=True),
354 )
355 + assert len(scheduler.kv_cache_config.kv_cache_groups) == 2
356
528 -
529 -@pytest.fixture
530 -def deepseek_v4_scheduler() -> Scheduler:
531 - return _create_deepseek_v4_scheduler()
532 -
533 -
534 -def _schedule_deepseek_v4_async_load(
535 - scheduler: Scheduler,
536 -) -> tuple[Request, SchedulerOutput]:
537 - num_external_tokens = 8 * scheduler.block_size
538 - request = create_request(num_tokens=9 * scheduler.block_size, block_size=4)
539 - scheduler.add_request(request)
540 -
541 - scheduler.connector = Mock()
542 - scheduler.connector.get_num_new_matched_tokens.side_effect = (
543 - _make_get_num_new_matched_tokens(
544 - {request.request_id: num_external_tokens}, async_load=True
545 - )
546 - )
547 - scheduler.connector.take_events.return_value = ()
548 - return request, scheduler.schedule()
549 -
550 -
551 -@pytest.mark.parametrize("failed_group_idx", range(5))
552 -def test_deepseek_v4_load_failure_recomputes_whole_request(
553 - deepseek_v4_scheduler: Scheduler, failed_group_idx: int
554 -):
555 - """A failed load in any V4-Flash KV group must not kill EngineCore."""
556 - request, scheduler_output = _schedule_deepseek_v4_async_load(deepseek_v4_scheduler)
557 -
558 - block_ids_per_group = deepseek_v4_scheduler.kv_cache_manager.get_block_ids(
559 - request.request_id
560 - )
561 - assert len(block_ids_per_group) == 5
562 - null_block_id = (
563 - deepseek_v4_scheduler.kv_cache_manager.block_pool.null_block.block_id
564 - )
565 - failed_block_id = next(
566 - block_id
567 - for block_id in block_ids_per_group[failed_group_idx]
568 - if block_id != null_block_id
569 - )
570 -
571 - deepseek_v4_scheduler.update_from_output(
572 - scheduler_output,
573 - create_model_runner_output(
574 - reqs=[],
575 - finished_recving=set(),
576 - invalid_block_ids={failed_block_id},
577 - use_eos=True,
578 - ),
579 - )
580 -
581 - assert request.num_computed_tokens == 0
582 - assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
583 - assert deepseek_v4_scheduler.failed_recving_kv_req_ids == {request.request_id}
584 -
585 -
586 -def test_deepseek_v4_load_failure_ignores_shared_null_block(
587 - deepseek_v4_scheduler: Scheduler,
588 -):
589 - """The shared null block contains no request KV and cannot fail a load."""
590 - request, scheduler_output = _schedule_deepseek_v4_async_load(deepseek_v4_scheduler)
591 - null_block_id = (
592 - deepseek_v4_scheduler.kv_cache_manager.block_pool.null_block.block_id
593 - )
594 - block_ids_per_group = deepseek_v4_scheduler.kv_cache_manager.get_block_ids(
595 - request.request_id
596 - )
597 - assert any(null_block_id in group for group in block_ids_per_group[1:])
598 - num_computed_tokens = request.num_computed_tokens
599 -
600 - deepseek_v4_scheduler.update_from_output(
601 - scheduler_output,
602 - create_model_runner_output(
603 - reqs=[],
604 - finished_recving=set(),
605 - invalid_block_ids={null_block_id},
606 - use_eos=True,
607 - ),
608 - )
609 -
610 - assert request.num_computed_tokens == num_computed_tokens
611 - assert not deepseek_v4_scheduler.failed_recving_kv_req_ids
612 -
613 -
614 -def test_deepseek_v4_sync_load_failure_fails_and_evicts_real_blocks():
615 - """Fail policy must terminate cleanly and never evict the null block."""
616 - scheduler = _create_deepseek_v4_scheduler(kv_load_failure_policy="fail")
617 - num_external_tokens = 8 * scheduler.block_size
618 - request = create_request(num_tokens=9 * scheduler.block_size, block_size=4)
619 - scheduler.add_request(request)
620 -
621 - scheduler.connector = Mock()
622 - scheduler.connector.get_num_new_matched_tokens.side_effect = (
623 - _make_get_num_new_matched_tokens(
624 - {request.request_id: num_external_tokens}, async_load=False
625 - )
626 - )
627 - scheduler.connector.request_finished.return_value = (False, None)
628 - scheduler.connector.take_events.return_value = ()
629 - scheduler_output = scheduler.schedule()
630 -
631 - block_ids_per_group = scheduler.kv_cache_manager.get_block_ids(request.request_id)
632 - assert len(block_ids_per_group) == 5
633 - null_block_id = scheduler.kv_cache_manager.block_pool.null_block.block_id
634 - real_block_ids = {
635 - block_id
636 - for group_block_ids in block_ids_per_group
637 - for block_id in group_block_ids
638 - if block_id != null_block_id
639 - }
640 - failed_block_id = next(
641 - block_id for block_id in block_ids_per_group[2] if block_id != null_block_id
642 - )
643 -
644 - evicted_block_ids: list[set[int]] = []
645 - original_evict_blocks = scheduler.kv_cache_manager.evict_blocks
646 -
647 - def evict_blocks_spy(block_ids: set[int]) -> None:
648 - evicted_block_ids.append(set(block_ids))
649 - original_evict_blocks(block_ids)
650 -
651 - with (
652 - patch.object(
653 - scheduler.kv_cache_manager,
654 - "evict_blocks",
655 - side_effect=evict_blocks_spy,
656 - ),
657 - patch.object(scheduler, "_connector_finished", return_value=(False, None)),
658 - ):
659 - outputs = scheduler.update_from_output(
660 - scheduler_output,
661 - create_model_runner_output(
662 - [request],
663 - invalid_block_ids={failed_block_id},
664 - use_eos=True,
665 - ),
666 - )
667 -
668 - assert request.status == RequestStatus.FINISHED_ERROR
669 - assert request.get_finished_reason() == FinishReason.ERROR
670 - assert request.request_id not in scheduler.requests
671 - assert evicted_block_ids == [real_block_ids]
672 - assert null_block_id not in evicted_block_ids[0]
673 -
674 - engine_outputs = next(iter(outputs.values()))
675 - assert len(engine_outputs.outputs) == 1
676 - assert engine_outputs.outputs[0].request_id == request.request_id
677 - assert engine_outputs.outputs[0].finish_reason == FinishReason.ERROR
357 + with pytest.raises(RuntimeError, match="failed_recving"):
358 + scheduler._handle_invalid_blocks({1}, {})
tests/v1/kv_connector/unit/test_mooncake_connector_hma.py
+77
@@ -18,6 +18,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector im
18 MooncakeConnectorMetadata,
19 MooncakeConnectorScheduler,
20 MooncakeXferMetadata,
21 + PullReqMeta,
22 SendBlockMeta,
23 TransferRegion,
24 )
@@ -436,3 +437,79 @@ def test_request_finished_with_hma_groups():
437 assert stored_blocks[0] == fa_blocks
438 # SW: clipped to last 9 blocks (sw_size=128, block_size=16 → 8+1=9)
439 assert stored_blocks[1] == sw_blocks[-9:]
440 +
441 +
442 +# ---------------------------------------------------------------------------
443 +# Worker-side load-failure reporting (HMA vs non-HMA)
444 +# ---------------------------------------------------------------------------
445 +def _make_kv_consumer_worker(swa_enabled: bool, disable_hma: bool = False):
446 + block_size = 16
447 + vllm_config = create_vllm_config(
448 + kv_connector="MooncakeConnector",
449 + kv_role="kv_consumer",
450 + block_size=block_size,
451 + )
452 + vllm_config.scheduler_config.disable_hybrid_kv_cache_manager = disable_hma
453 + kv_cache_config = make_kv_cache_config(
454 + block_size=block_size, swa_enabled=swa_enabled
455 + )
456 + with set_current_vllm_config(vllm_config), patch_worker_dependencies():
457 + connector = MooncakeConnector(
458 + vllm_config, KVConnectorRole.WORKER, kv_cache_config
459 + )
460 + return connector.connector_worker
461 +
462 +
463 +def _make_pull_meta(d_req_id: str, local_block_ids: list[list[int]]) -> PullReqMeta:
464 + return PullReqMeta(
465 + d_req_id=d_req_id,
466 + transfer_id="tx-1",
467 + local_block_ids=local_block_ids,
468 + remote_engine_id="engine-0",
469 + remote_bootstrap_addr="127.0.0.1:1234",
470 + )
471 +
472 +
473 +# The tests below construct a MooncakeConnectorWorker, whose __init__
474 +# requires an accelerator device, so (like the worker tests in
475 +# test_mooncake_connector.py) they run on the GPU KV-connectors job only.
476 +@pytest.mark.parametrize(
477 + "swa_enabled,disable_hma,expected_is_hma",
478 + [
479 + (True, False, True), # SWA group present, HMA enabled
480 + (True, True, False), # SWA group present, but HMA disabled
481 + (False, False, False), # FA only, HMA not needed
482 + ],
483 +)
484 +def test_worker_is_hma_required(swa_enabled, disable_hma, expected_is_hma):
485 + """Worker-side _is_hma_required mirrors the scheduler's derivation."""
486 + worker = _make_kv_consumer_worker(swa_enabled, disable_hma)
487 + assert worker._is_hma_required is expected_is_hma
488 +
489 +
490 +def test_worker_failed_recv_reports_request_level_failure_with_hma():
491 + """With HMA, load failures report the request, not ambiguous block IDs."""
492 + worker = _make_kv_consumer_worker(swa_enabled=True)
493 + assert worker._is_hma_required
494 +
495 + pull_meta = _make_pull_meta("d-req-1", [[1, 2], [3, 4]])
496 + worker._handle_failed_recv({"p-req-1": pull_meta}, ["p-req-1"], "boom")
497 +
498 + assert worker.get_block_ids_with_load_errors() == set()
499 + results = worker.get_transfer_results()
500 + assert results.failed_recving == {"d-req-1"}
501 + assert results.finished_recving == {"d-req-1"}
502 +
503 +
504 +def test_worker_failed_recv_reports_block_ids_without_hma():
505 + """Without HMA, load failures keep reporting block-level errors."""
506 + worker = _make_kv_consumer_worker(swa_enabled=False)
507 + assert not worker._is_hma_required
508 +
509 + pull_meta = _make_pull_meta("d-req-1", [[1, 2], [3, 4]])
510 + worker._handle_failed_recv({"p-req-1": pull_meta}, ["p-req-1"], "boom")
511 +
512 + assert worker.get_block_ids_with_load_errors() == {1, 2, 3, 4}
513 + results = worker.get_transfer_results()
514 + assert results.failed_recving == set()
515 + assert results.finished_recving == {"d-req-1"}
tests/v1/kv_connector/unit/test_mooncake_store_connector.py
+33
@@ -12,6 +12,7 @@ from vllm.config import set_current_vllm_config
12 from vllm.distributed.kv_events import BlockStored
13 from vllm.distributed.kv_transfer.kv_connector.v1.base import (
14 KVConnectorRole,
15 + KVConnectorTransferResults,
16 )
17 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store import (
18 connector as mooncake_store_connector,
@@ -245,6 +246,38 @@ def test_worker_methods_delegate_to_store_worker():
246 assert invalid_block_ids == {3, 4}
247
248
249 +def test_get_transfer_results_delegates_to_store_worker():
250 + vllm_config = _make_vllm_config()
251 + kv_cache_config = _make_kv_cache_config()
252 + metadata = MooncakeStoreConnectorMetadata(set(), set())
253 + finished_req_ids = {"req-1"}
254 +
255 + with (
256 + set_current_vllm_config(vllm_config),
257 + patch(
258 + "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
259 + "connector.MooncakeStoreWorker"
260 + ) as mock_worker_cls,
261 + ):
262 + connector = mooncake_store_connector.MooncakeStoreConnector(
263 + vllm_config, KVConnectorRole.WORKER, kv_cache_config
264 + )
265 +
266 + worker = mock_worker_cls.return_value
267 + transfer_results = KVConnectorTransferResults(
268 + finished_sending={"req-1"},
269 + finished_recving={"req-2"},
270 + failed_recving={"req-3"},
271 + )
272 + worker.get_transfer_results.return_value = transfer_results
273 + connector.bind_connector_metadata(metadata)
274 +
275 + result = connector.get_transfer_results(finished_req_ids)
276 +
277 + worker.get_transfer_results.assert_called_once_with(finished_req_ids, metadata)
278 + assert result is transfer_results
279 +
280 +
281 def test_get_kv_connector_kv_cache_events_returns_none_when_disabled():
282 vllm_config = _make_vllm_config()
283 kv_cache_config = _make_kv_cache_config()
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+82
@@ -177,6 +177,7 @@ def _make_store_recving_thread(
177 *,
178 tp_rank: int = 0,
179 disk_offload_buffer_budget_bytes: int | None = None,
180 + is_hma_required: bool = False,
181 ) -> mooncake_store_worker.KVCacheStoreRecvingThread:
182 from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheGroupSpec
183
@@ -199,6 +200,7 @@ def _make_store_recving_thread(
200 ready_event=threading.Event(),
201 coord=coord,
202 disk_offload_buffer_budget_bytes=disk_offload_buffer_budget_bytes,
203 + is_hma_required=is_hma_required,
204 )
205 thread.request_queue.task_done = MagicMock()
206 return thread
@@ -303,6 +305,7 @@ def _make_vllm_config(
305 kv_role: str = "kv_both",
306 pipeline_parallel_size: int = 1,
307 kv_cache_layout: KVCacheLayout = KVCacheLayout.LBHNC,
308 + disable_hybrid_kv_cache_manager: bool = True,
309 ) -> SimpleNamespace:
310 cache_config = SimpleNamespace(block_size=16, num_gpu_blocks=10)
311 cache_config.get_resolved_kv_cache_layout = lambda: kv_cache_layout
@@ -319,6 +322,9 @@ def _make_vllm_config(
322 kv_role=kv_role, extra_config=extra_config
323 ),
324 cache_config=cache_config,
325 + scheduler_config=SimpleNamespace(
326 + disable_hybrid_kv_cache_manager=disable_hybrid_kv_cache_manager
327 + ),
328 kv_events_config=SimpleNamespace(enable_kv_cache_events=False),
329 speculative_config=None,
330 )
@@ -2147,6 +2153,48 @@ def test_recv_thread_reports_unsplittable_key_larger_than_budget():
2153 assert thread.get_and_clear_block_ids_with_load_errors() == {0, 1, 2}
2154
2155
2156 +def test_recv_thread_partial_load_failure_reports_block_ids_without_hma():
2157 + store = MagicMock()
2158 + # First key fails, second succeeds.
2159 + store.batch_get_into_multi_buffers.return_value = [-1, 0]
2160 + thread = _make_store_recving_thread(store)
2161 +
2162 + thread._handle_request(_make_load_req("req-a", [b"h0", b"h1"], token_len=32))
2163 +
2164 + assert thread.get_and_clear_block_ids_with_load_errors() == {0}
2165 + assert thread.get_and_clear_failed_requests() == set()
2166 + assert thread.get_and_clear_finished_requests() == {"req-a"}
2167 +
2168 +
2169 +def test_recv_thread_partial_load_failure_reports_request_with_hma():
2170 + store = MagicMock()
2171 + store.batch_get_into_multi_buffers.return_value = [-1, 0]
2172 + thread = _make_store_recving_thread(store, is_hma_required=True)
2173 +
2174 + thread._handle_request(_make_load_req("req-a", [b"h0", b"h1"], token_len=32))
2175 +
2176 + assert thread.get_and_clear_block_ids_with_load_errors() == set()
2177 + assert thread.get_and_clear_failed_requests() == {"req-a"}
2178 + assert thread.get_and_clear_finished_requests() == {"req-a"}
2179 +
2180 +
2181 +def test_recv_thread_oversized_key_reports_request_with_hma():
2182 + store = MagicMock()
2183 + thread = _make_store_recving_thread(
2184 + store,
2185 + tp_rank=2,
2186 + disk_offload_buffer_budget_bytes=_DISK_OFFLOAD_BUDGET_TOO_SMALL,
2187 + is_hma_required=True,
2188 + )
2189 +
2190 + thread._handle_request(_make_load_req("req-a", [b"a0", b"a1", b"a2"], token_len=48))
2191 +
2192 + assert store.batch_get_into_multi_buffers.call_count == 0
2193 + assert thread.get_and_clear_block_ids_with_load_errors() == set()
2194 + assert thread.get_and_clear_failed_requests() == {"req-a"}
2195 + assert thread.get_and_clear_finished_requests() == {"req-a"}
2196 +
2197 +
2198 def test_worker_init_excludes_nonprefix_cache_groups(monkeypatch):
2199 store = MagicMock()
2200 store.setup.return_value = 0
@@ -2172,6 +2220,39 @@ def test_worker_init_excludes_nonprefix_cache_groups(monkeypatch):
2220 assert [db.block_size for db in store_worker.token_dbs] == [800, 800]
2221
2222
2223 +@pytest.mark.parametrize(
2224 + ("disable_hybrid_kv_cache_manager", "expected_is_hma_required"),
2225 + [
2226 + (True, False),
2227 + (False, True),
2228 + ],
2229 +)
2230 +def test_worker_is_hma_required_from_kv_cache_groups(
2231 + monkeypatch, disable_hybrid_kv_cache_manager, expected_is_hma_required
2232 +):
2233 + store = MagicMock()
2234 + store.setup.return_value = 0
2235 + _install_fake_mooncake(monkeypatch, store)
2236 + _patch_worker_runtime(monkeypatch)
2237 + monkeypatch.setattr(
2238 + worker.MooncakeStoreConfig,
2239 + "load_from_config",
2240 + staticmethod(lambda: _make_config()),
2241 + )
2242 + vllm_config = _make_vllm_config(
2243 + disable_hybrid_kv_cache_manager=disable_hybrid_kv_cache_manager
2244 + )
2245 + vllm_config.cache_config.block_size = 800
2246 + vllm_config.cache_config.enable_prefix_caching = True
2247 + vllm_config.cache_config.prefix_match_unit = None
2248 +
2249 + store_worker = worker.MooncakeStoreWorker(
2250 + vllm_config, _make_qsa_hybrid_kv_cache_config()
2251 + )
2252 +
2253 + assert store_worker._is_hma_required is expected_is_hma_required
2254 +
2255 +
2256 def test_requester_worker_init_uses_positional_setup(tmp_path, monkeypatch):
2257 store = MagicMock()
2258 store.setup.return_value = 0
@@ -3345,6 +3426,7 @@ def _make_bare_worker(
3426 worker.can_put = kv_role in ("kv_producer", "kv_both") or save_decode_cache
3427 worker._capacity_only = False
3428 worker.block_size = block_size
3429 + worker._is_hma_required = False
3430 worker.tp_rank = 0
3431 worker.enable_kv_events = False
3432 worker.load_async = True
tests/v1/kv_connector/unit/utils.py
-2
@@ -155,7 +155,6 @@ def create_scheduler(
155 vllm_config: VllmConfig,
156 num_blocks: int = 10000,
157 kv_cache_config: KVCacheConfig | None = None,
158 - hash_block_size: int | None = None,
158 ) -> Scheduler | AsyncScheduler:
159 """Initialize Scheduler For Testing."""
160 block_size = vllm_config.cache_config.block_size
@@ -186,7 +185,6 @@ def create_scheduler(
185 log_stats=True,
186 structured_output_manager=StructuredOutputManager(vllm_config),
187 block_size=block_size,
189 - hash_block_size=hash_block_size,
188 )
189
190
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py
+45 -1
@@ -30,6 +30,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
30 KVConnectorBase_V1,
31 KVConnectorMetadata,
32 KVConnectorRole,
33 + KVConnectorTransferResults,
34 SupportsHMA,
35 )
36 from vllm.distributed.kv_transfer.kv_connector.v1.metrics import KVConnectorStats
@@ -587,6 +588,13 @@ class MooncakeConnector(KVConnectorBase_V1, SupportsHMA):
588 assert self.connector_worker is not None
589 return self.connector_worker.get_finished()
590
591 + def get_transfer_results(
592 + self, finished_req_ids: set[str]
593 + ) -> KVConnectorTransferResults:
594 + """Get finished and failed sends/recvs from the worker."""
595 + assert self.connector_worker is not None
596 + return self.connector_worker.get_transfer_results()
597 +
598 def get_block_ids_with_load_errors(self) -> set[int]:
599 """Get block IDs whose remote KV load failed."""
600 assert self.connector_worker is not None
@@ -1046,6 +1054,16 @@ class MooncakeConnectorWorker:
1054 self.finished_recving_reqs: set[ReqId] = set()
1055 # Written from the receiver loop, drained from the worker thread.
1056 self._invalid_block_ids: queue.Queue[set[int]] = queue.Queue()
1057 + self._is_hma_required = (
1058 + not vllm_config.scheduler_config.disable_hybrid_kv_cache_manager
1059 + and any(
1060 + not isinstance(g.kv_cache_spec, FullAttentionSpec)
1061 + for g in kv_cache_config.transfer_groups
1062 + )
1063 + )
1064 + # Block IDs are only unique within a group; with HMA the scheduler
1065 + # tracks a single merged group, so failures are reported per request.
1066 + self._failed_recv_reqs: queue.Queue[ReqId] = queue.Queue()
1067
1068 self.xfer_stats = MooncakeKVConnectorStats()
1069
@@ -1866,6 +1884,29 @@ class MooncakeConnectorWorker:
1884
1885 return finished_sending_reqs or None, finished_recving_reqs or None
1886
1887 + def get_transfer_results(self) -> KVConnectorTransferResults:
1888 + """
1889 + Get transfers that completed on this specific worker, including
1890 + requests whose remote KV load failed.
1891 +
1892 + The scheduler process (via the MultiprocExecutor) will use this output
1893 + to track which workers are done.
1894 + """
1895 + finished_sending_reqs, finished_recving_reqs = self.get_finished()
1896 +
1897 + failed_recving_reqs: set[ReqId] = set()
1898 + while True:
1899 + try:
1900 + failed_recving_reqs.add(self._failed_recv_reqs.get_nowait())
1901 + except queue.Empty:
1902 + break
1903 +
1904 + return KVConnectorTransferResults(
1905 + finished_sending=set(finished_sending_reqs or ()),
1906 + finished_recving=set(finished_recving_reqs or ()),
1907 + failed_recving=failed_recving_reqs,
1908 + )
1909 +
1910 def get_kv_connector_stats(self) -> KVConnectorStats | None:
1911 """Return transfer stats collected since the last call, or None
1912 if nothing has been recorded in this interval."""
@@ -1959,7 +2000,10 @@ class MooncakeConnectorWorker:
2000 # request is waiting on a load, and reporting one here would trip
2001 # the scheduler's `assert req_id in self.requests`.
2002 continue
1962 - self._invalid_block_ids.put(invalid)
2003 + if self._is_hma_required:
2004 + self._failed_recv_reqs.put(pull_meta.d_req_id)
2005 + else:
2006 + self._invalid_block_ids.put(invalid)
2007 self.finished_recving_reqs.add(pull_meta.d_req_id)
2008
2009 if failed:
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+9
@@ -27,6 +27,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
27 KVConnectorBase_V1,
28 KVConnectorMetadata,
29 KVConnectorRole,
30 + KVConnectorTransferResults,
31 KVConnectorWorkerMetadata,
32 SupportsHMA,
33 )
@@ -359,6 +360,14 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
360 assert isinstance(metadata, MooncakeStoreConnectorMetadata)
361 return self.connector_worker.get_finished(finished_req_ids, metadata)
362
363 + def get_transfer_results(
364 + self, finished_req_ids: set[str]
365 + ) -> KVConnectorTransferResults:
366 + assert self.connector_worker is not None
367 + metadata = self._get_connector_metadata()
368 + assert isinstance(metadata, MooncakeStoreConnectorMetadata)
369 + return self.connector_worker.get_transfer_results(finished_req_ids, metadata)
370 +
371 def get_block_ids_with_load_errors(self) -> set[int]:
372 assert self.connector_worker is not None
373 return self.connector_worker.get_block_ids_with_load_errors()
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+56 -4
@@ -36,6 +36,9 @@ from vllm.distributed import (
36 get_tensor_model_parallel_world_size,
37 )
38 from vllm.distributed.kv_events import BlockStored
39 +from vllm.distributed.kv_transfer.kv_connector.v1.base import (
40 + KVConnectorTransferResults,
41 +)
42 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import rdma_utils
43 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.coordinator import ( # noqa: E501
44 ExternalCachedBlockPool,
@@ -1265,6 +1268,7 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1268 record_operation: Callable[..., None] | None = None,
1269 request_queue: queue.Queue[Any] | None = None,
1270 group_participates: Sequence[bool] | None = None,
1271 + is_hma_required: bool = False,
1272 ):
1273 super().__init__(
1274 store,
@@ -1284,6 +1288,10 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1288 # _invalid_block_ids can be access by both the Worker and RecvingThread
1289 self._invalid_block_ids_lock = threading.Lock()
1290 self._invalid_block_ids: set[int] = set()
1291 + # With HMA the scheduler tracks a single merged group while block IDs
1292 + # are only unique within a group, so failures are reported per request.
1293 + self._is_hma_required = is_hma_required
1294 + self._failed_requests: set[str] = set()
1295 self.disk_offload_buffer_budget_bytes = disk_offload_buffer_budget_bytes
1296 self.usable_disk_offload_buffer_budget_bytes = (
1297 None
@@ -1298,6 +1306,22 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1306 with self._invalid_block_ids_lock:
1307 self._invalid_block_ids.update(block_ids)
1308
1309 + def set_failed_request(self, req_id: str):
1310 + with self.done_task_lock:
1311 + self._failed_requests.add(req_id)
1312 +
1313 + def get_and_clear_failed_requests(self) -> set[str]:
1314 + with self.done_task_lock:
1315 + failed = self._failed_requests.copy()
1316 + self._failed_requests.clear()
1317 + return failed
1318 +
1319 + def _report_load_error(self, req_id: str, block_ids: list[int]) -> None:
1320 + if self._is_hma_required:
1321 + self.set_failed_request(req_id)
1322 + else:
1323 + self._add_load_error_block_ids(block_ids)
1324 +
1325 def get_and_clear_block_ids_with_load_errors(self) -> set[int]:
1326 with self._invalid_block_ids_lock:
1327 invalid_block_ids = self._invalid_block_ids.copy()
@@ -1392,7 +1416,7 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1416 # Mark every block: we skip the whole request, and the
1417 # tp_rank rotation means oversized_key isn't necessarily
1418 # the first block in the request's original order.
1395 - self._add_load_error_block_ids(block_id_list_c)
1419 + self._report_load_error(req_id, block_id_list_c)
1420 oversized_key_bytes = _estimate_disk_offload_staging_bytes(
1421 size_list_c[oversized_key_index]
1422 )
@@ -1465,8 +1489,8 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1489 num_failed_keys=len(failed),
1490 )
1491 if failed:
1468 - self._add_load_error_block_ids(
1469 - [block_id for _, _, block_id in failed]
1492 + self._report_load_error(
1493 + req_id, [block_id for _, _, block_id in failed]
1494 )
1495 logger.warning(
1496 "Failed to get %d Mooncake keys from sub-batch "
@@ -1477,7 +1501,7 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1501 )
1502 break
1503 except Exception as e:
1480 - self._add_load_error_block_ids(current_batch_block_ids)
1504 + self._report_load_error(req_id, current_batch_block_ids)
1505 self._record_operation(
1506 "load_get",
1507 load_get_start,
@@ -1551,6 +1575,13 @@ class MooncakeStoreWorker:
1575 and not self.can_put
1576 )
1577 self.cache_config = vllm_config.cache_config
1578 + self._is_hma_required = (
1579 + not vllm_config.scheduler_config.disable_hybrid_kv_cache_manager
1580 + and any(
1581 + not isinstance(g.kv_cache_spec, FullAttentionSpec)
1582 + for g in kv_cache_config.transfer_groups
1583 + )
1584 + )
1585 self.block_size, self.hash_block_size = resolve_kv_cache_block_sizes(
1586 kv_cache_config, vllm_config
1587 )
@@ -2096,6 +2127,7 @@ class MooncakeStoreWorker:
2127 group.kv_cache_spec.prefix_cacheable
2128 for group in self._kv_cache_groups
2129 ],
2130 + is_hma_required=self._is_hma_required,
2131 )
2132 recv_thread.name = f"KVCacheStoreRecvingThread-{i}"
2133 recv_thread.start()
@@ -2185,6 +2217,26 @@ class MooncakeStoreWorker:
2217 block_ids |= recv_thread.get_and_clear_block_ids_with_load_errors()
2218 return block_ids
2219
2220 + def get_transfer_results(
2221 + self, finished_req_ids: set[str], meta: MooncakeStoreConnectorMetadata
2222 + ) -> KVConnectorTransferResults:
2223 + """Get completed sends/recvs plus requests whose remote KV load failed."""
2224 + done_sending, done_recving = self.get_finished(finished_req_ids, meta)
2225 +
2226 + if self._capacity_only:
2227 + return KVConnectorTransferResults(done_sending, done_recving)
2228 +
2229 + failed_recving: set[str] = set()
2230 + if self.load_async:
2231 + for recv_thread in self.kv_recv_threads:
2232 + failed_recving |= recv_thread.get_and_clear_failed_requests()
2233 +
2234 + return KVConnectorTransferResults(
2235 + finished_sending=done_sending,
2236 + finished_recving=done_recving,
2237 + failed_recving=failed_recving,
2238 + )
2239 +
2240 def _record_kv_connector_operation(
2241 self,
2242 operation: str,
vllm/v1/core/sched/scheduler.py
+14 -38
@@ -3027,11 +3027,6 @@ class Scheduler(SchedulerInterface):
3027 For observability, it also accumulates the total number of tokens that
3028 will need to be recomputed across all affected requests.
3029
3030 - A hybrid model spreads one token span over KV cache groups that disagree
3031 - on block size, so a block index maps to a different token position in
3032 - each group and there is no single longest valid prefix. Those requests
3033 - recompute in full instead.
3034 -
3030 Args:
3031 requests: The set of requests to scan for invalid blocks.
3032 invalid_block_ids: IDs of invalid blocks.
@@ -3055,49 +3050,19 @@ class Scheduler(SchedulerInterface):
3050 # these requests must be rescheduled, but only the first will recompute
3051 # it. This set tracks blocks already marked for recomputation.
3052 marked_invalid_block_ids: set[int] = set()
3058 - # Positions a group holds no KV for -- skipped sliding-window blocks,
3059 - # and every Mamba block but the aligned snapshot -- all point at this
3060 - # one block, shared by every request. It carries no request's data, so
3061 - # it can neither fail for a request nor be evicted on its behalf.
3062 - null_block_id = self.kv_cache_manager.block_pool.null_block.block_id
3053 for request in requests:
3054 is_affected = False
3055 marked_invalid_block = False
3056 req_id = request.request_id
3067 - # One block-id list per KV cache group.
3068 - req_block_ids_per_group = self.kv_cache_manager.get_block_ids(req_id)
3057 + # One block-id list per KV cache group; single-group layouts are
3058 + # enforced by _handle_invalid_blocks.
3059 + (req_block_ids,) = self.kv_cache_manager.get_block_ids(req_id)
3060 # We iterate only over blocks that may contain externally computed
3061 # tokens
3062 req_num_computed_tokens = (
3063 request.num_computed_tokens - num_scheduled_tokens.get(req_id, 0)
3064 )
3065
3075 - if len(req_block_ids_per_group) > 1:
3076 - # No single truncation point exists across groups, so the whole
3077 - # request is recomputed. Blocks are still marked, so a request
3078 - # sharing one of them is rescheduled too.
3079 - request_invalid_block_ids = {
3080 - block_id
3081 - for req_block_ids in req_block_ids_per_group
3082 - for block_id in req_block_ids
3083 - if block_id != null_block_id and block_id in invalid_block_ids
3084 - }
3085 - if request_invalid_block_ids:
3086 - marked_invalid_block_ids |= request_invalid_block_ids
3087 - total_affected_tokens += req_num_computed_tokens
3088 - request.num_computed_tokens = 0
3089 - if evict_blocks:
3090 - for req_block_ids in req_block_ids_per_group:
3091 - blocks_to_evict.update(
3092 - block_id
3093 - for block_id in req_block_ids
3094 - if block_id != null_block_id
3095 - )
3096 - affected_req_ids.add(req_id)
3097 - continue
3098 -
3099 - (req_block_ids,) = req_block_ids_per_group
3100 -
3066 req_num_computed_blocks = (
3067 req_num_computed_tokens + self.block_size - 1
3068 ) // self.block_size
@@ -3175,6 +3140,17 @@ class Scheduler(SchedulerInterface):
3140 Returns:
3141 Set of affected request IDs to skip in update_from_output main loop.
3142 """
3143 + if len(self.kv_cache_config.kv_cache_groups) > 1:
3144 + # Block IDs are only unique within a group, so a flat set of
3145 + # invalid block IDs cannot be mapped back to requests.
3146 + raise RuntimeError(
3147 + "A KV connector reported block-level load failures "
3148 + "(invalid_block_ids) on a layout with multiple KV cache "
3149 + "groups, where block IDs are only unique within a group. "
3150 + "Connectors must report failed requests via "
3151 + "KVConnectorTransferResults.failed_recving instead."
3152 + )
3153 +
3154 should_fail = not self.recompute_kv_load_failures
3155
3156 # handle async KV loads (not cached yet, evict_blocks=False)