diff --git a/tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py b/tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py index 1d76d6bdc04..22eb1a86d4f 100644 --- a/tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py +++ b/tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py @@ -4,6 +4,7 @@ from collections.abc import Iterable from unittest.mock import MagicMock import pytest +import torch from tests.v1.kv_connector.unit.offloading_connector.utils import ( generate_store_output, @@ -15,6 +16,11 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import ( OffloadingConnectorScheduler, ) from vllm.v1.core.kv_cache_utils import BlockHash +from vllm.v1.kv_cache_interface import ( + FullAttentionSpec, + KVCacheGroupSpec, + SlidingWindowSpec, +) from vllm.v1.kv_offload.base import ( OffloadingEvent, OffloadingManager, @@ -26,32 +32,32 @@ from vllm.v1.request import RequestStatus @pytest.mark.parametrize("async_scheduling", [True, False]) def test_offloading_connector(request_runner, async_scheduling: bool): - offloaded_block_size = 12 - gpu_block_size = 4 + block_size = 4 + block_size_factor = 3 + offloaded_block_size = block_size * block_size_factor num_gpu_blocks = 100 - block_size_factor = offloaded_block_size // gpu_block_size runner = request_runner( - offloaded_block_size=offloaded_block_size, - gpu_block_size=gpu_block_size, + block_size=block_size, num_gpu_blocks=num_gpu_blocks, async_scheduling=async_scheduling, + block_size_factor=block_size_factor, ) # 3 blocks, store just the middle block (skip first and last) # blocks = [0, 1, 2], [3, 4, 5], [6, 7, 8] runner.new_request(token_ids=[0] * offloaded_block_size * 3) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(list(keys)[1:2]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(list(keys)[1:2]) ) runner.run(decoded_tokens=[0]) # add block missing 1 token -> no offload runner.run( decoded_tokens=[0] * (offloaded_block_size - 1), - expected_stored_gpu_block_indexes=(3, 4, 5), + expected_stored=(3, 4, 5), ) - runner.manager.prepare_store.assert_not_called() + runner.manager.touch.assert_not_called() # +1 token -> single block, fail prepare_store runner.manager.prepare_store.side_effect = lambda keys, req_context: None @@ -60,19 +66,19 @@ def test_offloading_connector(request_runner, async_scheduling: bool): # 1 more block (+ token for async scheduling) # now set block_hashes_to_store = [] - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output([]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) ) runner.run(decoded_tokens=[0] * (offloaded_block_size + 1)) # 1 more block (+ token for kicking off offloading) # now check touch was called with all 6 blocks - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) runner.run( decoded_tokens=[0] * (offloaded_block_size + 1), - expected_stored_gpu_block_indexes=(15, 16, 17), + expected_stored=(15, 16, 17), ) runner.manager.touch.assert_called() block_hashes1 = list(runner.manager.touch.call_args.args[0]) @@ -95,23 +101,23 @@ def test_offloading_connector(request_runner, async_scheduling: bool): # terminate request runner.run( decoded_tokens=[EOS_TOKEN_ID], - expected_stored_gpu_block_indexes=tuple(range(6 * block_size_factor)), + expected_stored=tuple(range(6 * block_size_factor)), ) # full_block_tokens - num_computed_tokens < offloaded_block_size runner.new_request( - token_ids=[0] * gpu_block_size + [1] * (offloaded_block_size - gpu_block_size) + token_ids=[0] * block_size + [1] * (offloaded_block_size - block_size) ) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output([]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) ) runner.run(decoded_tokens=[EOS_TOKEN_ID]) runner.manager.lookup.assert_not_called() # single block lookup with no hits runner.new_request(token_ids=[1] * offloaded_block_size) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output([]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) ) runner.run(decoded_tokens=[EOS_TOKEN_ID]) runner.manager.lookup.assert_called_once() @@ -119,25 +125,21 @@ def test_offloading_connector(request_runner, async_scheduling: bool): # single block lookup with a hit runner.scheduler.reset_prefix_cache() runner.new_request(token_ids=[0] * offloaded_block_size) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output([]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) ) runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1 - runner.run( - decoded_tokens=[EOS_TOKEN_ID], expected_loaded_gpu_block_indexes=(0, 1, 2) - ) + runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_loaded=(0, 1, 2)) # single block lookup with a hit in a middle block runner.new_request( token_ids=[0] * offloaded_block_size * 2 + [1] * offloaded_block_size ) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output([]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) ) runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1 - runner.run( - decoded_tokens=[EOS_TOKEN_ID], expected_loaded_gpu_block_indexes=(3, 4, 5) - ) + runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_loaded=(3, 4, 5)) # test take_events def to_hashes(int_hashes: list[int]) -> list[BlockHash]: @@ -167,15 +169,16 @@ def test_offloading_connector(request_runner, async_scheduling: bool): @pytest.mark.parametrize("async_scheduling", [True, False]) def test_request_preemption(request_runner, async_scheduling: bool): - offloaded_block_size = 12 - gpu_block_size = 4 + block_size = 4 + block_size_factor = 3 + offloaded_block_size = block_size * block_size_factor num_gpu_blocks = 100 runner = request_runner( - offloaded_block_size=offloaded_block_size, - gpu_block_size=gpu_block_size, + block_size=block_size, num_gpu_blocks=num_gpu_blocks, async_scheduling=async_scheduling, + block_size_factor=block_size_factor, ) free_block_queue = runner.scheduler.kv_cache_manager.block_pool.free_block_queue @@ -184,8 +187,8 @@ def test_request_preemption(request_runner, async_scheduling: bool): # 2 blocks, store all, without flushing # blocks = [0, 1, 2], [3, 4, 5] runner.new_request(token_ids=[0] * offloaded_block_size * 2) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) runner.run( decoded_tokens=[0], @@ -193,11 +196,11 @@ def test_request_preemption(request_runner, async_scheduling: bool): ) # decode 2 more blocks - 1 gpu block, storing [6, 7, 8] (no flush) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) runner.run( - decoded_tokens=[0] * (2 * offloaded_block_size - gpu_block_size), + decoded_tokens=[0] * (2 * offloaded_block_size - block_size), complete_transfers=False, ) @@ -208,8 +211,8 @@ def test_request_preemption(request_runner, async_scheduling: bool): runner.run( decoded_tokens=[], complete_transfers=False, - expected_flushed_gpu_block_indexes=(0, 1, 2, 3, 4, 5, 6, 7, 8), - expected_stored_gpu_block_indexes=(0, 1, 2, 3, 4, 5, 6, 7, 8), + expected_flushed=(0, 1, 2, 3, 4, 5, 6, 7, 8), + expected_stored=(0, 1, 2, 3, 4, 5, 6, 7, 8), ) # restore KV cache space and reset GPU prefix cache @@ -219,17 +222,17 @@ def test_request_preemption(request_runner, async_scheduling: bool): # request should now return from preemption # re-load [0, ..., 8] from the CPU and store [9, 10, 11] runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 3 - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) runner.run( - decoded_tokens=[0] * gpu_block_size, - expected_loaded_gpu_block_indexes=(0, 1, 2, 3, 4, 5, 6, 7, 8), + decoded_tokens=[0] * block_size, + expected_loaded=(0, 1, 2, 3, 4, 5, 6, 7, 8), ) runner.run( decoded_tokens=[EOS_TOKEN_ID], - expected_stored_gpu_block_indexes=(9, 10, 11), + expected_stored=(9, 10, 11), ) # All stores completed before request_finished -> fence index empty. @@ -238,25 +241,26 @@ def test_request_preemption(request_runner, async_scheduling: bool): @pytest.mark.parametrize("async_scheduling", [True, False]) def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling: bool): - offloaded_block_size = 12 - gpu_block_size = 4 + block_size = 4 + block_size_factor = 3 + offloaded_block_size = block_size * block_size_factor num_gpu_blocks = 100 runner = request_runner( - offloaded_block_size=offloaded_block_size, - gpu_block_size=gpu_block_size, + block_size=block_size, num_gpu_blocks=num_gpu_blocks, async_scheduling=async_scheduling, + block_size_factor=block_size_factor, ) # store 1 blocks runner.new_request(token_ids=[0] * offloaded_block_size) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) runner.run( decoded_tokens=[EOS_TOKEN_ID], - expected_stored_gpu_block_indexes=(0, 1, 2), + expected_stored=(0, 1, 2), ) # start a request to load the first block, but don't complete @@ -284,12 +288,12 @@ def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling: assert transfer_jobs == list(runner.offloading_spec.handler.transfer_specs) # complete transfers - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output([]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) ) runner.run( decoded_tokens=[EOS_TOKEN_ID], - expected_loaded_gpu_block_indexes=(0, 1, 2), + expected_loaded=(0, 1, 2), ) # second request will use the GPU prefix cache @@ -301,25 +305,26 @@ def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling: @pytest.mark.parametrize("async_scheduling", [True, False]) def test_abort_loading_requests(request_runner, async_scheduling: bool): - offloaded_block_size = 12 - gpu_block_size = 4 + block_size = 4 + block_size_factor = 3 + offloaded_block_size = block_size * block_size_factor num_gpu_blocks = 100 runner = request_runner( - offloaded_block_size=offloaded_block_size, - gpu_block_size=gpu_block_size, + block_size=block_size, num_gpu_blocks=num_gpu_blocks, async_scheduling=async_scheduling, + block_size_factor=block_size_factor, ) # store 1 blocks runner.new_request(token_ids=[0] * offloaded_block_size) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) runner.run( decoded_tokens=[EOS_TOKEN_ID], - expected_stored_gpu_block_indexes=(0, 1, 2), + expected_stored=(0, 1, 2), ) # start a request to load the first block, but don't complete @@ -345,13 +350,264 @@ def test_abort_loading_requests(request_runner, async_scheduling: bool): # complete loading request runner.run( decoded_tokens=[], - expected_loaded_gpu_block_indexes=(0, 1, 2), + expected_loaded=(0, 1, 2), ) # assert request is deleted assert req_id not in runner.scheduler.requests +@pytest.mark.parametrize("async_scheduling", [True, False]) +def test_two_groups_full_and_sliding_window(request_runner, async_scheduling: bool): + block_size = 4 + num_gpu_blocks = 100 + # sliding_window=8 -> 2 offloaded blocks (block_size_factor=1) + sliding_window = 8 + + kv_cache_groups = [ + KVCacheGroupSpec( + ["layer0"], + FullAttentionSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + ), + ), + KVCacheGroupSpec( + ["layer1"], + SlidingWindowSpec( + block_size=block_size, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + sliding_window=sliding_window, + ), + ), + ] + + runner = request_runner( + block_size=block_size, + num_gpu_blocks=num_gpu_blocks, + async_scheduling=async_scheduling, + kv_cache_groups=kv_cache_groups, + ) + + # Verify group configs: group 0 = full attention, group 1 = sliding window + kv_group_configs = runner.connector_scheduler.config.kv_group_configs + assert len(kv_group_configs) == 2 + assert kv_group_configs[0].sliding_window_size_in_blocks is None + assert kv_group_configs[1].sliding_window_size_in_blocks == 2 + + # Blocks [0, 1, 2] miss + runner.new_request(token_ids=[0] * block_size * 3) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) + ) + runner.run(decoded_tokens=[0]) + # _touch called from get_num_new_matched_tokens (2 groups) and + # _get_reqs_to_store (2 groups) → 4 touch calls total. + touch_calls = runner.manager.touch.call_args_list + assert len(touch_calls) == 4 + assert len(touch_calls[0].args[0]) == 3 + assert len(touch_calls[1].args[0]) == 3 + assert len(touch_calls[2].args[0]) == 3 + assert len(touch_calls[3].args[0]) == 3 + + # store 3 more block + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) + ) + runner.run( + decoded_tokens=[0] * (block_size * 3 + 2), + expected_stored=(0, 1, 2, 3, 4, 5), + ) + + # touch called from _get_reqs_to_store * 3 blocks, once for each group + touch_calls = runner.manager.touch.call_args_list + assert len(touch_calls) == 6 + + runner.run(decoded_tokens=[EOS_TOKEN_ID]) + + runner.scheduler.reset_prefix_cache() + + # full 3 blocks hit [0, 1, 2] + runner.new_request(token_ids=[0] * (block_size * 3 + 1)) + runner.manager.lookup.return_value = True + runner.run( + decoded_tokens=[EOS_TOKEN_ID], + # Group 0 (full attn): prefix lookup hits 3 → loads blocks 0,1,2 + # Group 1 (sliding window, window=2): only the last 2 blocks + # are within the window → loads blocks 1,2 + expected_loaded=((0, 0), (0, 1), (0, 2), (1, 1), (1, 2)), + ) + + # one touch in get_num_new_matched_tokens x 2 groups + touch_calls = runner.manager.touch.call_args_list + assert len(touch_calls) == 2 + # full attention group touched all 3 blocks + assert len(touch_calls[0].args[0]) == 3 + # sliding window group touched just the last 2 blocks + assert len(touch_calls[1].args[0]) == 2 + + # 3 blocks are hit on GPU [0, 1, 2] + # 1 block loaded [3,] + runner.new_request(token_ids=[0] * (block_size * 4 + 1)) + runner.manager.lookup.return_value = True + runner.run( + decoded_tokens=[EOS_TOKEN_ID], + # Group 0 (full attn): prefix lookup hits 3 → loads blocks 0,1,2 + # Group 1 (sliding window, window=2): only the last 2 blocks + # are within the window → loads blocks 1,2 + expected_loaded=((0, 3), (1, 3)), + ) + + +@pytest.mark.parametrize("async_scheduling", [True, False]) +def test_two_groups_different_block_sizes(request_runner, async_scheduling: bool): + hash_block_size = 4 + num_gpu_blocks = 100 + + # Group 0: block_size=12 (offloaded_block_size=12) + # Group 1: block_size=16 (offloaded_block_size=16) + kv_cache_groups = [ + KVCacheGroupSpec( + ["layer0"], + FullAttentionSpec( + block_size=hash_block_size * 3, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + ), + ), + KVCacheGroupSpec( + ["layer1"], + FullAttentionSpec( + block_size=hash_block_size * 4, + num_kv_heads=1, + head_size=1, + dtype=torch.float32, + ), + ), + ] + + runner = request_runner( + block_size=hash_block_size, + num_gpu_blocks=num_gpu_blocks, + async_scheduling=async_scheduling, + kv_cache_groups=kv_cache_groups, + ) + + # Verify group configs + kv_group_configs = runner.connector_scheduler.config.kv_group_configs + assert len(kv_group_configs) == 2 + assert kv_group_configs[0].gpu_block_size == 12 + assert kv_group_configs[0].offloaded_block_size == 12 + assert kv_group_configs[1].gpu_block_size == 16 + assert kv_group_configs[1].offloaded_block_size == 16 + + # Prompt: 25 tokens, unaligned to both block sizes. + # Group 0 blocks: [0, 1], ending_token_offset = 24 + # Group 1 blocks: [0,], ending_token_offset = 16 + runner.new_request(token_ids=[0] * 25) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) + ) + runner.run(decoded_tokens=[0]) + # _touch called from get_num_new_matched_tokens (2 groups) and + # _get_reqs_to_store (2 groups) → 4 touch calls total. + # Group 0 has 2 offload keys, group 1 has 1. + touch_calls = runner.manager.touch.call_args_list + assert len(touch_calls) == 4 + assert len(touch_calls[0].args[0]) == 2 + assert len(touch_calls[1].args[0]) == 1 + assert len(touch_calls[2].args[0]) == 2 + assert len(touch_calls[3].args[0]) == 1 + + # Get to 31 tokens + # No further blocks offloaded + runner.run(decoded_tokens=[0] * 6, expected_stored=((0, 0), (0, 1), (1, 0))) + + # Get to 32 tokens + # Group 0 blocks: [0, 1], ending_token_offset = 24 + # Group 1 blocks: [0, 1], ending_token_offset = 32 + runner.run(decoded_tokens=[0]) + # _get_reqs_to_store touch: only group 1 has a new block to store + touch_calls = runner.manager.touch.call_args_list + assert len(touch_calls) == 2 + assert len(touch_calls[0].args[0]) == 2 + assert len(touch_calls[1].args[0]) == 2 + + # Get to 35 tokens + # No further blocks offloaded + runner.run(decoded_tokens=[0] * 3, expected_stored=((1, 1),)) + + # Get to 36 tokens + # Group 0 blocks: [0, 1, 2], ending_token_offset = 36 + # Group 1 blocks: [0, 1], ending_token_offset = 32 + runner.run(decoded_tokens=[0]) + # _get_reqs_to_store touch: only group 0 has a new block to store + touch_calls = runner.manager.touch.call_args_list + assert len(touch_calls) == 2 + assert len(touch_calls[0].args[0]) == 3 + assert len(touch_calls[1].args[0]) == 2 + + # Get to 47 tokens + # No further blocks offloaded + runner.run(decoded_tokens=[0] * 11, expected_stored=((0, 2),)) + + # Get to 48 tokens + # Group 0 blocks: [0, 1, 2, 3], ending_token_offset = 4 + # Group 1 blocks: [0, 1, 2], ending_token_offset = 48 + runner.run(decoded_tokens=[0]) + # _get_reqs_to_store touch: both groups have a new block, each with 1 key + touch_calls = runner.manager.touch.call_args_list + assert len(touch_calls) == 2 + assert len(touch_calls[0].args[0]) == 4 + assert len(touch_calls[1].args[0]) == 3 + + runner.run(decoded_tokens=[0], expected_stored=((0, 3), (1, 2))) + + # Get to 96 tokens + runner.run( + decoded_tokens=[0] * 47 + [EOS_TOKEN_ID], + expected_stored=((0, 4), (0, 5), (0, 6), (0, 7), (1, 3), (1, 4), (1, 5)), + ) + + runner.scheduler.reset_prefix_cache() + + # Request with 48 matching tokens + # will match 48 tokens (4 block) from the first group + # 48 tokens (3 block) from the second group + # Total 48 tokens can be loaded + runner.new_request(token_ids=[0] * 48) + runner.manager.lookup.return_value = True + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) + ) + runner.run( + decoded_tokens=[0], + expected_loaded=((0, 0), (0, 1), (0, 2), (0, 3), (1, 0), (1, 1), (1, 2)), + ) + runner.run(decoded_tokens=[EOS_TOKEN_ID]) + + # Request with 48+37 matching tokens + # 48 tokens will be hit on GPU + # extra 32 tokens will be loaded + # extra tokens [0, 36] (blocks [4, 5, 6]) from the first group + # extra tokens [0, 32] (block [3, 4]) from the second group + runner.new_request(token_ids=[0] * (48 + 37)) + runner.manager.lookup.return_value = True + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) + ) + runner.run( + decoded_tokens=[0], + expected_loaded=((0, 4), (0, 5), (0, 6), (1, 3), (1, 4)), + ) + runner.run(decoded_tokens=[EOS_TOKEN_ID]) + + # --------------------------------------------------------------------------- # Unit tests for _maximal_prefix_lookup / _sliding_window_lookup # --------------------------------------------------------------------------- @@ -493,26 +749,24 @@ def test_do_remote_decode_stores_all_blocks(request_runner, async_scheduling: bo This supports P/D disaggregation where the prefill instance offloads the complete KV cache so a remote decode node can consume it.""" - offloaded_block_size = 12 gpu_block_size = 4 + block_size_factor = 3 + offloaded_block_size = gpu_block_size * block_size_factor num_gpu_blocks = 100 runner = request_runner( - offloaded_block_size=offloaded_block_size, - gpu_block_size=gpu_block_size, + block_size_factor=block_size_factor, + block_size=gpu_block_size, num_gpu_blocks=num_gpu_blocks, async_scheduling=async_scheduling, ) # Store 1 offloaded block (3 GPU blocks) via a normal request. runner.new_request(token_ids=[0] * offloaded_block_size) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) - ) - runner.run( - decoded_tokens=[EOS_TOKEN_ID], - expected_stored_gpu_block_indexes=(0, 1, 2), + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) + runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0, 1, 2)) # Reset GPU prefix cache so the next request must load from CPU. runner.scheduler.reset_prefix_cache() @@ -524,22 +778,17 @@ def test_do_remote_decode_stores_all_blocks(request_runner, async_scheduling: bo kv_transfer_params={"do_remote_decode": True}, ) runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1 - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) # Load the first offloaded block from CPU. - runner.run( - decoded_tokens=[0], - expected_loaded_gpu_block_indexes=(0, 1, 2), - ) + runner.run(decoded_tokens=[0], expected_loaded=(0, 1, 2)) # Store must include ALL 6 GPU blocks (both the loaded prefix and # the newly computed block), not just the 3 new ones. - runner.run( - decoded_tokens=[EOS_TOKEN_ID], - expected_stored_gpu_block_indexes=(0, 1, 2, 3, 4, 5), - ) + runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_stored=(0, 1, 2, 3, 4, 5)) + # All stores completed before request_finished -> fence index empty. assert runner.connector_scheduler._block_id_to_pending_jobs == {} @@ -553,8 +802,8 @@ def test_loads_do_not_populate_fence_index(request_runner): """Loads don't populate _block_id_to_pending_jobs (protected by delay_free_blocks while in flight).""" runner = request_runner( - offloaded_block_size=12, - gpu_block_size=4, + block_size_factor=3, + block_size=4, num_gpu_blocks=100, async_scheduling=False, ) @@ -572,15 +821,15 @@ def test_fence_at_update_state_after_alloc(request_runner): req1 just freed. """ runner = request_runner( - offloaded_block_size=4, - gpu_block_size=4, + block_size_factor=1, + block_size=4, num_gpu_blocks=2, async_scheduling=False, ) runner.new_request(token_ids=[0] * 4) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) runner.run(decoded_tokens=[EOS_TOKEN_ID], complete_transfers=False) assert runner.connector_scheduler._block_id_to_pending_jobs @@ -588,14 +837,14 @@ def test_fence_at_update_state_after_alloc(request_runner): runner.scheduler.reset_prefix_cache() runner.new_request(token_ids=[0] * 4) runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1 - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output([]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) ) runner.run( decoded_tokens=[], complete_transfers=False, - expected_stored_gpu_block_indexes=(0,), - expected_flushed_gpu_block_indexes=(0,), + expected_stored=(0,), + expected_flushed=(0,), ) assert runner.connector_scheduler._block_id_to_pending_jobs == {} @@ -605,15 +854,15 @@ def test_fence_at_build_store_jobs(request_runner): reusing a finished request's pending-store block is flushed by _build_store_jobs's fence.""" runner = request_runner( - offloaded_block_size=4, - gpu_block_size=4, + block_size_factor=1, + block_size=4, num_gpu_blocks=2, async_scheduling=False, ) runner.new_request(token_ids=[0] * 4) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) runner.run(decoded_tokens=[EOS_TOKEN_ID], complete_transfers=False) assert runner.connector_scheduler._block_id_to_pending_jobs @@ -621,13 +870,13 @@ def test_fence_at_build_store_jobs(request_runner): runner.scheduler.reset_prefix_cache() runner.new_request(token_ids=[1] * 4) runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 0 - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output([]) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output([]) ) runner.run( decoded_tokens=[EOS_TOKEN_ID], - expected_stored_gpu_block_indexes=(0,), - expected_flushed_gpu_block_indexes=(0,), + expected_stored=(0,), + expected_flushed=(0,), ) assert runner.connector_scheduler._block_id_to_pending_jobs == {} @@ -636,20 +885,22 @@ def test_fence_at_build_store_jobs(request_runner): def test_complete_store_called_per_job(request_runner, async_scheduling: bool): """complete_store fires per-job, not deferred to request finish. Each call carries only that store's keys.""" - offloaded_block_size = 12 + gpu_block_size = 4 + block_size_factor = 3 + offloaded_block_size = gpu_block_size * block_size_factor runner = request_runner( - offloaded_block_size=offloaded_block_size, - gpu_block_size=4, + block_size_factor=block_size_factor, + block_size=gpu_block_size, num_gpu_blocks=100, async_scheduling=async_scheduling, ) runner.new_request(token_ids=[0] * offloaded_block_size) - runner.manager.prepare_store.side_effect = ( - lambda keys, req_context: generate_store_output(keys) + runner.manager.prepare_store.side_effect = lambda keys, req_context: ( + generate_store_output(keys) ) # First store: fires when block 0 is fully populated. - runner.run(decoded_tokens=[0, 0], expected_stored_gpu_block_indexes=(0, 1, 2)) + runner.run(decoded_tokens=[0, 0], expected_stored=(0, 1, 2)) assert runner.manager.complete_store.call_count == 1 first_call_keys = set(runner.manager.complete_store.call_args.args[0]) assert len(first_call_keys) == 1 @@ -658,7 +909,7 @@ def test_complete_store_called_per_job(request_runner, async_scheduling: bool): # Second store: fires when block 1 is fully populated, with different keys. runner.run( decoded_tokens=[0] * (offloaded_block_size + 1), - expected_stored_gpu_block_indexes=(3, 4, 5), + expected_stored=(3, 4, 5), ) assert runner.manager.complete_store.call_count == 1 second_call_keys = set(runner.manager.complete_store.call_args.args[0]) diff --git a/tests/v1/kv_connector/unit/offloading_connector/utils.py b/tests/v1/kv_connector/unit/offloading_connector/utils.py index 0268766eb7f..89f05f1053a 100644 --- a/tests/v1/kv_connector/unit/offloading_connector/utils.py +++ b/tests/v1/kv_connector/unit/offloading_connector/utils.py @@ -3,7 +3,7 @@ from collections.abc import Iterable, Iterator from dataclasses import dataclass from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest import torch @@ -150,46 +150,61 @@ class MockOffloadingSpec(OffloadingSpec): return specs +@dataclass(frozen=True) +class GPUBlock: + group_idx: int + request_block_offset: int + + @dataclass class TransferSummary: - gpu_block_indices: list[int] + gpu_blocks: list[GPUBlock] offload_addresses: list[Any] class RequestRunner: def __init__( self, - offloaded_block_size: int, - gpu_block_size: int, + block_size: int, num_gpu_blocks: int, + block_size_factor: int = 1, async_scheduling: bool = True, + kv_cache_groups: list[KVCacheGroupSpec] | None = None, ): - self.offloaded_block_size: int = offloaded_block_size - self.gpu_block_size: int = gpu_block_size + assert block_size_factor == 1 or kv_cache_groups is None, ( + "block_size_factor > 1 requires all groups to have the same " + "block size, so kv_cache_groups must be None (use default group)" + ) + + self.block_size_factor: int = block_size_factor + self.block_size: int = block_size self.num_gpu_blocks: int = num_gpu_blocks self.async_scheduling: bool = async_scheduling self.req_id: int = -1 vllm_config = create_vllm_config( - block_size=gpu_block_size, max_num_batched_tokens=1000 + block_size=block_size, + max_num_batched_tokens=1000, + disable_hybrid_kv_cache_manager=False, ) vllm_config.scheduler_config.async_scheduling = async_scheduling + + extra_config: dict[str, Any] = { + "spec_name": "MockOffloadingSpec", + "spec_module_path": "tests.v1.kv_connector.unit.offloading_connector.utils", # noqa: E501 + } + if block_size_factor > 1: + extra_config["block_size"] = block_size * block_size_factor + vllm_config.kv_transfer_config = KVTransferConfig( kv_connector="OffloadingConnector", kv_role="kv_both", - kv_connector_extra_config={ - "spec_name": "MockOffloadingSpec", - "spec_module_path": "tests.v1.kv_connector.unit.offloading_connector.utils", # noqa: E501 - "block_size": offloaded_block_size, - }, + kv_connector_extra_config=extra_config, ) - block_size = vllm_config.cache_config.block_size - kv_cache_config = KVCacheConfig( - num_blocks=num_gpu_blocks, - kv_cache_tensors=[], - kv_cache_groups=[ + if kv_cache_groups is None: + kv_cache_groups = [ KVCacheGroupSpec( ["layer"], FullAttentionSpec( @@ -199,7 +214,12 @@ class RequestRunner: dtype=torch.float32, ), ) - ], + ] + + kv_cache_config = KVCacheConfig( + num_blocks=num_gpu_blocks, + kv_cache_tensors=[], + kv_cache_groups=kv_cache_groups, ) vllm_config.cache_config.num_gpu_blocks = num_gpu_blocks self.num_kv_groups = len(kv_cache_config.kv_cache_groups) @@ -219,11 +239,38 @@ class RequestRunner: # register worker kv_caches to enable OffloadingWorker creations # set_current_vllm_config is needed for get_kv_cache_layout() to work - with set_current_vllm_config(vllm_config): - self.worker_connector.register_cross_layers_kv_cache( - kv_cache=torch.empty(0), - attn_backend=FlashAttentionBackend, - ) + # Mock get_layers_from_vllm_config so that mock layer names + # resolve to layers whose get_attn_backend() returns + # FlashAttentionBackend. + def _mock_get_layers(_vllm_config, _layer_type, layer_names): + mock_layer = MagicMock() + mock_layer.get_attn_backend.return_value = FlashAttentionBackend + return {name: mock_layer for name in layer_names} + + kv_caches: dict[str, torch.Tensor] = {} + for group in kv_cache_groups: + spec = group.kv_cache_spec + for layer_name in group.layer_names: + # Shape follows FlashAttention layout: + # (2, num_blocks, block_size, num_kv_heads, head_size) + kv_caches[layer_name] = torch.empty( + 2, + num_gpu_blocks, + spec.block_size, + spec.num_kv_heads, + spec.head_size, + dtype=spec.dtype, + ) + + with ( + set_current_vllm_config(vllm_config), + patch( + "vllm.distributed.kv_transfer.kv_connector.v1" + ".offloading.worker.get_layers_from_vllm_config", + side_effect=_mock_get_layers, + ), + ): + self.worker_connector.register_kv_caches(kv_caches) # extract connector of scheduler scheduler_connector = self.scheduler.connector @@ -238,10 +285,17 @@ class RequestRunner: assert isinstance(manager, MagicMock) self.manager: MagicMock = manager - assert len(self.connector_scheduler.config.kv_group_configs) == 1 - kv_group_config = self.connector_scheduler.config.kv_group_configs[0] - assert kv_group_config.gpu_block_size == gpu_block_size - assert kv_group_config.offloaded_block_size == offloaded_block_size + num_kv_groups = len(kv_cache_config.kv_cache_groups) + assert len(self.connector_scheduler.config.kv_group_configs) == num_kv_groups + for group_config, kv_cache_group in zip( + self.connector_scheduler.config.kv_group_configs, + kv_cache_config.kv_cache_groups, + ): + gpu_block_size = kv_cache_group.kv_cache_spec.block_size + assert group_config.gpu_block_size == gpu_block_size + assert ( + group_config.offloaded_block_size == gpu_block_size * block_size_factor + ) # extract OffloadingSpec of worker_connector connector_worker = self.worker_connector.connector_worker @@ -250,18 +304,18 @@ class RequestRunner: assert isinstance(offloading_spec, MockOffloadingSpec) self.offloading_spec: MockOffloadingSpec = offloading_spec - # mapping (offloading address) -> gpu_block_index - self.offloaded: dict[Any, int] = {} + # mapping (offloading address) -> GPUBlock + self.offloaded: dict[Any, GPUBlock] = {} self.completed_loads: list[TransferSummary] = [] self.completed_stores: list[TransferSummary] = [] - self.flushed_gpu_block_indexes: set[int] = set() + self.flushed_gpu_blocks: set[GPUBlock] = set() - # maps {block_id: block_offset} - self.gpu_block_index: dict[int, int] = {} + # block_id -> GPUBlock + self.gpu_blocks: dict[int, GPUBlock] = {} init_none_hash(sha256) - self._block_hasher = get_request_block_hasher(gpu_block_size, sha256) + self._block_hasher = get_request_block_hasher(block_size, sha256) self._dummy_ctx: ForwardContext = ForwardContext( no_compile_layers={}, @@ -297,11 +351,9 @@ class RequestRunner: assert isinstance(src_spec, GPULoadStoreSpec) for block_id in src_spec.block_ids: - self.flushed_gpu_block_indexes.add( - self.gpu_block_index[block_id.item()] - ) + self.flushed_gpu_blocks.add(self.gpu_blocks[block_id.item()]) - block_size_factor = self.offloaded_block_size // self.gpu_block_size + block_size_factor = self.block_size_factor for transfer_spec in self.offloading_spec.get_completed_transfers(): src_spec, dst_spec = transfer_spec @@ -317,10 +369,11 @@ class RequestRunner: assert isinstance(offload_spec, MockLoadStoreSpec) assert isinstance(gpu_spec, GPULoadStoreSpec) + assert len(gpu_spec.group_sizes) == self.num_kv_groups - gpu_block_indices: list[int] = [] + gpu_blocks: list[GPUBlock] = [] for block_id in gpu_spec.block_ids: - gpu_block_indices.append(self.gpu_block_index[block_id.item()]) + gpu_blocks.append(self.gpu_blocks[block_id.item()]) # list of (offload_key, sub_block_offset) offload_addresses: list[Any] = [] @@ -328,30 +381,49 @@ class RequestRunner: for sub_block_idx in range(block_size_factor): offload_addresses.append((offload_key, sub_block_idx)) + assert gpu_spec.block_indices is not None + assert len(gpu_spec.block_indices) == self.num_kv_groups + + gpu_block_offset = 0 + offload_address_offset = 0 + for group_size, logical_offset in zip( + gpu_spec.group_sizes, gpu_spec.block_indices + ): + gpu_block_end_offset = gpu_block_offset + group_size + assert gpu_block_end_offset <= len(gpu_blocks) + + offload_addresses_to_skip = logical_offset % block_size_factor + offload_addresses_end_offset = ( + offload_address_offset + offload_addresses_to_skip + group_size + ) + assert offload_addresses_end_offset <= len(offload_addresses) + + offload_addresses = ( + offload_addresses[:offload_address_offset] + + offload_addresses[ + offload_address_offset + offload_addresses_to_skip : + ] + ) + + gpu_block_offset += group_size + offload_address_offset += group_size + + assert gpu_block_offset == len(gpu_blocks) + assert offload_address_offset == len(offload_addresses) + + transfer_summary = TransferSummary(gpu_blocks, offload_addresses) if store: - assert len(gpu_block_indices) == len(offload_addresses) - - self.completed_stores.append( - TransferSummary(gpu_block_indices, offload_addresses) - ) + self.completed_stores.append(transfer_summary) else: - remainder_sub_block_count = len(offload_addresses) - len( - gpu_block_indices - ) - assert remainder_sub_block_count >= 0 - assert remainder_sub_block_count < block_size_factor - offload_addresses = offload_addresses[remainder_sub_block_count:] + self.completed_loads.append(transfer_summary) - self.completed_loads.append( - TransferSummary(gpu_block_indices, offload_addresses) - ) - - def _update_gpu_block_idx(self): - for blocks in self.scheduler.kv_cache_manager.coordinator.single_type_managers[ - 0 - ].req_to_blocks.values(): - for block_idx, block in enumerate(blocks): - self.gpu_block_index[block.block_id] = block_idx + def _update_gpu_blocks(self): + for group_idx, manager in enumerate( + self.scheduler.kv_cache_manager.coordinator.single_type_managers + ): + for blocks in manager.req_to_blocks.values(): + for block_idx, block in enumerate(blocks): + self.gpu_blocks[block.block_id] = GPUBlock(group_idx, block_idx) def _run(self, decoded_tokens: list[int], complete_transfers: bool): """ @@ -376,7 +448,7 @@ class RequestRunner: break scheduler_output = self.scheduler.schedule() - self._update_gpu_block_idx() + self._update_gpu_blocks() kv_connector_metadata = scheduler_output.kv_connector_metadata assert kv_connector_metadata is not None @@ -447,13 +519,30 @@ class RequestRunner: if EOS_TOKEN_ID in decoded_tokens: assert not self.scheduler.running + def _to_gpu_blocks( + self, blocks: tuple[int | tuple[int, int], ...] + ) -> list[GPUBlock]: + gpu_blocks: list[GPUBlock] = [] + for block in blocks: + if isinstance(block, int): + for group_idx in range(self.num_kv_groups): + gpu_blocks.append( + GPUBlock(group_idx=group_idx, request_block_offset=block) + ) + else: + group_idx, offset = block + gpu_blocks.append( + GPUBlock(group_idx=group_idx, request_block_offset=offset) + ) + return gpu_blocks + def run( self, decoded_tokens: list[int], complete_transfers: bool = True, - expected_stored_gpu_block_indexes: tuple[int, ...] = (), - expected_loaded_gpu_block_indexes: tuple[int, ...] = (), - expected_flushed_gpu_block_indexes: tuple[int, ...] = (), + expected_stored: tuple[int | tuple[int, int], ...] = (), + expected_loaded: tuple[int | tuple[int, int], ...] = (), + expected_flushed: tuple[int | tuple[int, int], ...] = (), ): """ Runs multiple engine (scheduler + worker) steps. @@ -462,41 +551,49 @@ class RequestRunner: Args: decoded_tokens: the tokens to yield at each step. complete_transfers: complete transfers immediately - expected_stored_gpu_block_indexes: GPU block indexes + expected_stored: GPU blocks that are expected to be written during the run. - expected_loaded_gpu_block_indexes: GPU block indexes + expected_loaded: GPU blocks that are expected to be loaded during the run. - expected_flushed_gpu_block_indexes: GPU block indexes + expected_flushed: GPU blocks that are expected to be flushed during the run. + + A GPU block is either a (group_idx: int, request_block_offset: int) + or just request_block_offset: int. + The latter case is a convenience for representing all groups. """ + expected_stored_gpu_blocks = self._to_gpu_blocks(expected_stored) + expected_loaded_gpu_blocks = self._to_gpu_blocks(expected_loaded) + expected_flushed_gpu_blocks = self._to_gpu_blocks(expected_flushed) + self.manager.reset_mock() self._run(decoded_tokens, complete_transfers) - loaded_gpu_block_indexes: set[int] = set() + loaded_gpu_blocks: set[GPUBlock] = set() for transfer in self.completed_loads: - for gpu_block_idx, offloaded_address in zip( - transfer.gpu_block_indices, transfer.offload_addresses + for gpu_block, offloaded_address in zip( + transfer.gpu_blocks, transfer.offload_addresses ): - loaded_gpu_block_indexes.add(gpu_block_idx) - assert gpu_block_idx == self.offloaded[offloaded_address] + loaded_gpu_blocks.add(gpu_block) + assert gpu_block == self.offloaded[offloaded_address] - assert set(expected_loaded_gpu_block_indexes) == loaded_gpu_block_indexes + assert set(expected_loaded_gpu_blocks) == loaded_gpu_blocks self.completed_loads.clear() - stored_gpu_block_indexes: set[int] = set() + stored_gpu_blocks: set[GPUBlock] = set() for transfer in self.completed_stores: - for gpu_block_idx, offloaded_address in zip( - transfer.gpu_block_indices, transfer.offload_addresses + for gpu_block, offloaded_address in zip( + transfer.gpu_blocks, transfer.offload_addresses ): - stored_gpu_block_indexes.add(gpu_block_idx) - self.offloaded[offloaded_address] = gpu_block_idx + stored_gpu_blocks.add(gpu_block) + self.offloaded[offloaded_address] = gpu_block - assert set(expected_stored_gpu_block_indexes) == stored_gpu_block_indexes + assert set(expected_stored_gpu_blocks) == stored_gpu_blocks self.completed_stores.clear() - assert set(expected_flushed_gpu_block_indexes) == self.flushed_gpu_block_indexes - self.flushed_gpu_block_indexes.clear() + assert set(expected_flushed_gpu_blocks) == self.flushed_gpu_blocks + self.flushed_gpu_blocks.clear() @pytest.fixture @@ -504,13 +601,18 @@ def request_runner(): runners = [] def runner_factory( - offloaded_block_size, gpu_block_size, num_gpu_blocks, async_scheduling + block_size, + num_gpu_blocks, + async_scheduling, + block_size_factor=1, + kv_cache_groups=None, ): runner = RequestRunner( - offloaded_block_size=offloaded_block_size, - gpu_block_size=gpu_block_size, + block_size=block_size, num_gpu_blocks=num_gpu_blocks, + block_size_factor=block_size_factor, async_scheduling=async_scheduling, + kv_cache_groups=kv_cache_groups, ) runners.append(runner) return runner diff --git a/tests/v1/kv_connector/unit/test_offloading_connector.py b/tests/v1/kv_connector/unit/test_offloading_connector.py index b2ab21d4be7..555daea50e3 100644 --- a/tests/v1/kv_connector/unit/test_offloading_connector.py +++ b/tests/v1/kv_connector/unit/test_offloading_connector.py @@ -14,13 +14,36 @@ from vllm.config import KVEventsConfig, KVTransferConfig from vllm.distributed.kv_events import BlockStored, KVEventBatch from vllm.platforms import current_platform -CPU_BLOCK_SIZES = [48] -ATTN_BACKENDS = [] - +_ATTN_BACKENDS: list[str] = [] if current_platform.is_cuda(): - ATTN_BACKENDS = ["FLASH_ATTN", "FLASHINFER", "TRITON_ATTN"] + _ATTN_BACKENDS = ["FLASH_ATTN", "FLASHINFER", "TRITON_ATTN"] elif current_platform.is_rocm(): - ATTN_BACKENDS = ["TRITON_ATTN"] + _ATTN_BACKENDS = ["TRITON_ATTN"] + +# (model, attn_backend | None, block_size | None, uses_hma) +# +# - Llama: tested with each attention backend and a custom block_size. +# - Gemma-3: HMA (sliding window + full attention), default backend. +# - Mamba-130m: HMA (attention-free, acts like sliding_window=1), +# default backend. Prefix caching must be force-enabled. +# - Falcon-H1-0.5B-Instruct: HMA (parallel SSM/attention in every layer). +# After page-size unification the mamba and attention groups have +# different block sizes. +MODEL_PARAMS: list[tuple[str, str | None, int | None, bool]] = [ + ("meta-llama/Llama-3.2-1B-Instruct", backend, 48, False) + for backend in _ATTN_BACKENDS +] +# HMA / Mamba models are only tested on CUDA (not ROCm). +if current_platform.is_cuda(): + MODEL_PARAMS += [ + ("google/gemma-3-1b-it", None, 48, True), + ("state-spaces/mamba-130m-hf", None, 48, True), + # Falcon-H1: parallel hybrid (every layer has both attention and SSM). + # The mamba and attention groups end up with different GPU block sizes + # after page-size unification, so we leave cpu_block_size=None + # (block_size_factor stays 1). + ("tiiuae/Falcon-H1-0.5B-Instruct", None, None, True), + ] # Maximum time (seconds) to wait for the async CPU offload transfer # to complete before giving up. @@ -110,7 +133,7 @@ def _wait_for_prefix_cache_reset(llm: LLM) -> None: ) -def _latency_test(llm: LLM, subscriber: MockSubscriber): +def _latency_test(llm: LLM, subscriber: MockSubscriber | None): sampling_params = SamplingParams(max_tokens=1) num_times_cpu_better_than_cold = 0 @@ -118,7 +141,10 @@ def _latency_test(llm: LLM, subscriber: MockSubscriber): total_cold_time = 0.0 total_gpu_hit_time = 0.0 total_cpu_hit_time = 0.0 - prompt_token_ids = [0] * 10001 + max_model_len = llm.llm_engine.vllm_config.model_config.max_model_len + # Use a long prompt that fits within the model's context window. + prompt_len = min(10001, max_model_len - 1) + prompt_token_ids = [0] * prompt_len for i in tqdm(range(num_tests), desc="Running tests"): prompt_token_ids[0] = i prompts = [TokensPrompt(prompt_token_ids=prompt_token_ids)] @@ -141,10 +167,11 @@ def _latency_test(llm: LLM, subscriber: MockSubscriber): # Verify CPU stored events arrived (offload is done before we # attempt to load from CPU). - assert subscriber.get_new_cpu_stored_events(), ( - f"No CPU stored events received on iteration {i}; " - "async offload may not have completed in time" - ) + if subscriber is not None: + assert subscriber.get_new_cpu_stored_events(), ( + f"No CPU stored events received on iteration {i}; " + "async offload may not have completed in time" + ) # run generation again - this should trigger loading from CPU start_time = time.time() @@ -163,84 +190,105 @@ def _latency_test(llm: LLM, subscriber: MockSubscriber): assert num_times_cpu_better_than_cold >= 0.8 * num_tests -def _accuracy_test(llm: LLM, subscriber: MockSubscriber): +def _accuracy_test(llm: LLM, subscriber: MockSubscriber | None): sampling_params = SamplingParams(max_tokens=1) - cpu_block_size = ( - llm.llm_engine.vllm_config.kv_transfer_config.kv_connector_extra_config[ - "block_size" - ] + extra_config = ( + llm.llm_engine.vllm_config.kv_transfer_config.kv_connector_extra_config ) + cpu_block_size = extra_config.get("block_size") + if cpu_block_size is None: + # No custom offloaded block_size: offloaded blocks match GPU blocks. + # Use the hash block_size (cache_config.block_size) for alignment. + cpu_block_size = llm.llm_engine.vllm_config.cache_config.block_size - subscriber.get_new_cpu_stored_events() + if subscriber is not None: + subscriber.get_new_cpu_stored_events() - # prepend prompt to be cpu block aligned + # Pad prompt so its token count is a multiple of cpu_block_size. + # Use the tokenizer directly to avoid expensive llm.generate() calls. + tokenizer = llm.get_tokenizer() prompt = "Let's count to 10. One, two, three, four," - while ( - len(llm.generate(prompt, use_tqdm=False)[0].prompt_token_ids) % cpu_block_size - != 0 - ): + while len(tokenizer.encode(prompt)) % cpu_block_size != 0: prompt = ". " + prompt - assert subscriber.get_new_cpu_stored_events() + # Seed the CPU cache with the prompt. + llm.generate(prompt, sampling_params, use_tqdm=False) - test_count = 100 - success_count = 0 - for i in range(test_count): - if ( - llm.generate(prompt, sampling_params, use_tqdm=False)[0].outputs[0].text - == " five" - ): - success_count += 1 + if subscriber is not None: + assert subscriber.get_new_cpu_stored_events() + test_count = 20 + results = llm.generate([prompt] * test_count, sampling_params, use_tqdm=False) + success_count = sum(1 for r in results if r.outputs[0].text == " five") assert success_count >= 0.5 * test_count -@pytest.mark.parametrize("cpu_block_size", CPU_BLOCK_SIZES) -@pytest.mark.parametrize("attn_backend", ATTN_BACKENDS) -def test_cpu_offloading(cpu_block_size: int, attn_backend: str) -> None: +@pytest.mark.parametrize("model, attn_backend, cpu_block_size, uses_hma", MODEL_PARAMS) +def test_cpu_offloading( + model: str, + attn_backend: str | None, + cpu_block_size: int | None, + uses_hma: bool, +) -> None: """ Tests OffloadingConnector with CPUOffloadingSpec. """ - # configure OffloadingConnector (spec_name=CPUOffloadingSpec by default) + extra_config: dict = {"cpu_bytes_to_use": 500 << 20} + if cpu_block_size is not None: + extra_config["block_size"] = cpu_block_size kv_transfer_config = KVTransferConfig( kv_connector="OffloadingConnector", kv_role="kv_both", - kv_connector_extra_config={ - "cpu_bytes_to_use": 500 << 20, - "block_size": cpu_block_size, - }, + kv_connector_extra_config=extra_config, ) - port: int - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("0.0.0.0", 0)) - port = s.getsockname()[1] + # KV events are incompatible with HMA (setting kv_events_config + # would force HMA off), so only enable them for non-HMA models. + subscriber: MockSubscriber | None = None + kv_events_config: KVEventsConfig | None = None + if not uses_hma: + port: int + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("0.0.0.0", 0)) + port = s.getsockname()[1] - events_endpoint = f"tcp://*:{port}" - kv_events_config = KVEventsConfig( - enable_kv_cache_events=True, - publisher="zmq", - endpoint=events_endpoint, - topic="test", - ) + events_endpoint = f"tcp://*:{port}" + kv_events_config = KVEventsConfig( + enable_kv_cache_events=True, + publisher="zmq", + endpoint=events_endpoint, + topic="test", + ) + + # Attention-free / hybrid models disable prefix caching by default + # (ModelConfig.is_prefix_caching_supported returns False). Without it, + # mamba_block_size falls back to max_model_len, making GPU blocks too + # large for any reasonable offloaded block_size. Force-enable it. + force_prefix_caching = uses_hma llm = LLM( - model="meta-llama/Llama-3.2-1B-Instruct", + model=model, + max_model_len=4096, gpu_memory_utilization=0.5, kv_events_config=kv_events_config, kv_transfer_config=kv_transfer_config, - attention_config={"backend": attn_backend}, + **({"attention_config": {"backend": attn_backend}} if attn_backend else {}), + # HMA models need explicit opt-in when kv_transfer_config is set + **({"disable_hybrid_kv_cache_manager": False} if uses_hma else {}), + **({"enable_prefix_caching": True} if force_prefix_caching else {}), # ROCm: batch size 1 to reduce variability **({"max_num_seqs": 1} if current_platform.is_rocm() else {}), ) - events_endpoint = events_endpoint.replace("*", "127.0.0.1") - subscriber = MockSubscriber(events_endpoint, topic=kv_events_config.topic) + if kv_events_config is not None: + events_endpoint = events_endpoint.replace("*", "127.0.0.1") + subscriber = MockSubscriber(events_endpoint, topic=kv_events_config.topic) try: _latency_test(llm, subscriber) _accuracy_test(llm, subscriber) finally: - subscriber.close() + if subscriber is not None: + subscriber.close() del llm diff --git a/tests/v1/kv_connector/unit/utils.py b/tests/v1/kv_connector/unit/utils.py index 0710ffa63a8..8e4e1cae067 100644 --- a/tests/v1/kv_connector/unit/utils.py +++ b/tests/v1/kv_connector/unit/utils.py @@ -103,6 +103,7 @@ def create_vllm_config( kv_load_failure_policy: Literal["recompute", "fail"] = "fail", kv_connector: str = "NixlConnector", kv_role: str = "kv_both", + disable_hybrid_kv_cache_manager: bool | None = None, ) -> VllmConfig: """Initialize VllmConfig For Testing.""" model_config = ModelConfig( @@ -118,6 +119,7 @@ def create_vllm_config( max_model_len=max_model_len, enable_chunked_prefill=enable_chunked_prefill, is_encoder_decoder=model_config.is_encoder_decoder, + disable_hybrid_kv_cache_manager=disable_hybrid_kv_cache_manager, ) # Cache config, optionally force APC cache_config = CacheConfig( diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py b/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py index 7b049361089..773fe8f056a 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py @@ -829,7 +829,6 @@ class OffloadingConnectorScheduler: def request_finished( self, request: Request, - block_ids: list[int], ) -> tuple[bool, dict[str, Any] | None]: """ Called when a request has finished, before its blocks are freed. diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py b/vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py index 05b835572c9..8b264dd726e 100644 --- a/vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py +++ b/vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py @@ -10,6 +10,7 @@ from vllm.distributed.kv_events import KVCacheEvent from vllm.distributed.kv_transfer.kv_connector.v1 import ( KVConnectorBase_V1, KVConnectorRole, + SupportsHMA, ) from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata from vllm.distributed.kv_transfer.kv_connector.v1.metrics import ( @@ -42,7 +43,7 @@ from vllm.v1.outputs import KVConnectorOutput from vllm.v1.request import Request -class OffloadingConnector(KVConnectorBase_V1): +class OffloadingConnector(KVConnectorBase_V1, SupportsHMA): @property def prefer_cross_layer_blocks(self) -> bool: return True @@ -149,7 +150,15 @@ class OffloadingConnector(KVConnectorBase_V1): block_ids: list[int], ) -> tuple[bool, dict[str, Any] | None]: assert self.connector_scheduler is not None - return self.connector_scheduler.request_finished(request, block_ids) + return self.connector_scheduler.request_finished(request) + + def request_finished_all_groups( + self, + request: "Request", + block_ids: tuple[list[int], ...], + ) -> tuple[bool, dict[str, Any] | None]: + assert self.connector_scheduler is not None + return self.connector_scheduler.request_finished(request) def take_events(self) -> Iterable[KVCacheEvent]: assert self.connector_scheduler is not None