8
9
from vllm.config import (
10
CacheConfig,
11
+ ECTransferConfig,
12
KVTransferConfig,
13
ModelConfig,
14
SchedulerConfig,
21
PlaceholderRange,
22
)
23
from vllm.sampling_params import SamplingParams, StructuredOutputsParams
24
+from vllm.utils.hashing import sha256
25
+from vllm.v1.core.encoder_cache_manager import EncoderCacheManager
26
+from vllm.v1.core.kv_cache_utils import get_request_block_hasher, init_none_hash
27
from vllm.v1.core.sched.output import CachedRequestData, SchedulerOutput
28
from vllm.v1.core.sched.scheduler import Scheduler
29
from vllm.v1.kv_cache_interface import (
876
for _, num_scheduled_tokens in output.num_scheduled_tokens.items():
877
# We should be in the decode phase now.
878
assert num_scheduled_tokens == 1
875
- assert len(output.kv_connector_metadata.requests) == 0
879
+ if scheduler.connector is not None:
880
+ assert len(output.kv_connector_metadata.requests) == 0
881
+ if scheduler.ec_connector is not None:
882
+ assert len(output.ec_connector_metadata.mm_datas) == 0
883
ecos = scheduler.update_from_output(output, model_runner_output)[0]
884
all_done = True
885
for eco in ecos.outputs:
1073
assert external_stats.preempted_requests == 0
1074
1075
1069
-def test_kv_connector_unable_to_allocate():
1076
+@pytest.mark.parametrize(
1077
+ "use_ec_connector, ec_role", [(False, None), (True, "ec_consumer")]
1078
+)
1079
+def test_kv_connector_unable_to_allocate(use_ec_connector, ec_role):
1080
"""
1081
Test whether scheduler with KVConnector is able to handle
1082
unable to allocate (run out of blocks in allocate_slots().
1090
use_kv_connector=True,
1091
block_size=BLOCK_SIZE,
1092
num_blocks=NUM_BLOCKS,
1093
+ # encoder connector should not affect test results
1094
+ use_ec_connector=use_ec_connector,
1095
+ ec_role=ec_role,
1096
)
1097
NUM_MATCHED_NEW_TOKENS = BLOCK_SIZE * 2
1098
scheduler.connector.get_num_new_matched_tokens = Mock(name="method")
1161
assert len(scheduler.waiting) == 0
1162
1163
1151
-def test_kv_connector_handles_preemption():
1164
+@pytest.mark.parametrize(
1165
+ "use_ec_connector, ec_role", [(False, None), (True, "ec_consumer")]
1166
+)
1167
+def test_kv_connector_handles_preemption(use_ec_connector, ec_role):
1168
"""
1169
Test whether scheduler with KVConnector is able to handle
1170
unable to allocate (run out of blocks in allocate_slots().
1179
use_kv_connector=True,
1180
block_size=BLOCK_SIZE,
1181
num_blocks=NUM_BLOCKS,
1182
+ # encoder connector should not affect test results
1183
+ use_ec_connector=use_ec_connector,
1184
+ ec_role=ec_role,
1185
)
1186
1187
NUM_MATCHED_NEW_TOKENS = BLOCK_SIZE
1398
block_size: int = 16,
1399
max_model_len: int | None = None,
1400
num_speculative_tokens: int | None = None,
1401
+ use_ec_connector: bool = False,
1402
+ ec_role: str | None = None,
1403
) -> Scheduler:
1404
"""Create scheduler with priority policy enabled.
1405
1460
model="ngram", num_speculative_tokens=num_speculative_tokens
1461
)
1462
1463
+ ec_transfer_config = (
1464
+ ECTransferConfig(
1465
+ ec_connector="ECSharedStorageConnector",
1466
+ ec_role=ec_role,
1467
+ ec_connector_extra_config={"shared_storage_path": "/tmp/ec_test"},
1468
+ )
1469
+ if use_ec_connector
1470
+ else None
1471
+ )
1472
+
1473
vllm_config = VllmConfig(
1474
scheduler_config=scheduler_config,
1475
model_config=model_config,
1476
cache_config=cache_config,
1477
kv_transfer_config=kv_transfer_config,
1478
speculative_config=speculative_config,
1479
+ ec_transfer_config=ec_transfer_config,
1480
)
1481
kv_cache_config = KVCacheConfig(
1482
num_blocks=num_blocks, # A large number of blocks to hold all requests
1497
)
1498
1499
1500
+_none_hash_initialized = False
1501
+
1502
+
1503
def create_requests_with_priority(
1504
num_requests: int,
1505
priorities: list[int],
1506
arrival_times: list[float] | None = None,
1507
num_tokens: int = 10,
1508
+ mm_hashes_list: list[list[str]] | None = None,
1509
mm_positions: list[list[PlaceholderRange]] | None = None,
1510
max_tokens: int = 16,
1511
stop_token_ids: list[int] | None = None,
1512
prompt_logprobs: int | None = None,
1513
starting_idx: int = 0,
1514
+ same_prompt: bool = False,
1515
+ block_size: int = 16,
1516
+ req_ids: list[str] | None = None,
1517
):
1518
"""Create requests with specified priorities and arrival times."""
1519
assert len(priorities) == num_requests
1522
else:
1523
arrival_times = [float(i) for i in range(num_requests)]
1524
1525
+ global _none_hash_initialized
1526
+ if not _none_hash_initialized:
1527
+ init_none_hash(sha256)
1528
+ _none_hash_initialized = True
1529
+
1530
+ block_hasher = get_request_block_hasher(block_size, sha256)
1531
sampling_params = SamplingParams(
1532
ignore_eos=False,
1533
max_tokens=max_tokens,
1535
prompt_logprobs=prompt_logprobs,
1536
)
1537
requests = []
1538
+
1539
+ if mm_hashes_list is not None:
1540
+ # NOTE: allow manual input; some mm items can have the same identifier
1541
+ # no. of mm_hashes and mm_positions for each request should be identical
1542
+ assert mm_positions is not None, (
1543
+ "mm_positions must be provided when mm_hashes_list is provided"
1544
+ )
1545
+ assert len(mm_hashes_list) == len(mm_positions) == num_requests
1546
+ assert [len(h) for h in mm_hashes_list] == [len(p) for p in mm_positions]
1547
+
1548
+ # Since same identifier would imply they are identical encoder output
1549
+ # Verify mm items with identical identifier are having mm_position.length
1550
+ seen_hashes: dict[str, int] = {}
1551
+
1552
+ if req_ids:
1553
+ assert len(req_ids) == num_requests
1554
+ else:
1555
+ req_ids = [f"{i + starting_idx}" for i in range(num_requests)]
1556
+
1557
for i in range(num_requests):
1558
mm_features = []
1495
- if mm_positions is not None:
1496
- mm_position = mm_positions[i]
1497
- for j, position in enumerate(mm_position):
1559
+
1560
+ for j, position in enumerate(
1561
+ mm_positions[i] if mm_positions is not None else []
1562
+ ):
1563
+ if mm_hashes_list is not None:
1564
+ identifier = mm_hashes_list[i][j]
1565
+
1566
+ # Verify if position length is identical
1567
+ position_length = position.length
1568
+ if identifier in seen_hashes:
1569
+ assert seen_hashes[identifier] == position_length, (
1570
+ f"mm_hash '{identifier}' has inconsistent position lengths: "
1571
+ f"previously {seen_hashes[identifier]}, now {position_length} "
1572
+ f"at request {i}, position {j}"
1573
+ )
1574
+ else:
1575
+ seen_hashes[identifier] = position_length
1576
+ else:
1577
+ # Unique dummy hash for each mm item
1578
identifier = f"hash{i}_{j}"
1499
- mm_feature = MultiModalFeatureSpec(
1500
- data=MultiModalKwargsItem.dummy("dummy_m"),
1501
- mm_position=position,
1502
- identifier=identifier,
1503
- modality="image",
1504
- )
1505
- mm_features.append(mm_feature)
1579
+ mm_feature = MultiModalFeatureSpec(
1580
+ data=MultiModalKwargsItem.dummy("dummy_m"),
1581
+ mm_position=position,
1582
+ identifier=identifier,
1583
+ modality="image",
1584
+ )
1585
+ mm_features.append(mm_feature)
1586
1587
+ prompt_token_ids = (
1588
+ [starting_idx] * num_tokens
1589
+ if same_prompt
1590
+ else [i + starting_idx] * num_tokens
1591
+ )
1592
request = Request(
1508
- request_id=f"{i + starting_idx}",
1509
- prompt_token_ids=[i + starting_idx] * num_tokens,
1593
+ request_id=req_ids[i],
1594
+ prompt_token_ids=prompt_token_ids,
1595
sampling_params=sampling_params,
1596
pooling_params=None,
1597
mm_features=mm_features if mm_features else None,
1598
eos_token_id=EOS_TOKEN_ID,
1599
arrival_time=arrival_times[i],
1600
priority=priorities[i],
1601
+ block_hasher=block_hasher,
1602
)
1603
requests.append(request)
1604
return requests
2085
assert len(scheduler.waiting) == 1
2086
2087
2002
-def test_priority_scheduling_preemption_and_resumption_when_out_of_kv():
2088
+@pytest.mark.parametrize(
2089
+ "use_ec_connector, ec_role", [(False, None), (True, "ec_consumer")]
2090
+)
2091
+def test_priority_scheduling_preemption_and_resumption_when_out_of_kv(
2092
+ use_ec_connector, ec_role
2093
+):
2094
"""Test that priority scheduling preempts lower priority requests
2095
when out of KV cache space."""
2096
# Create scheduler with very limited memory to force preemption
2100
num_blocks=5, # Can hold 64 tokens (first block is null)
2101
block_size=16, # Standard block size
2102
use_kv_connector=True,
2103
+ # encoder connector should not affect test results
2104
+ use_ec_connector=use_ec_connector,
2105
+ ec_role=ec_role,
2106
)
2107
2108
# Create a request and schedule it
2262
assert scheduler_config.disable_chunked_mm_input is not expect_enabled
2263
if is_encoder_decoder and not expect_enabled:
2264
assert scheduler_config.long_prefill_token_threshold == 0
2265
+
2266
+
2267
+# ==============================================================================
2268
+# EPD (Encoder-Prefill-Decode) Encoder-cache-specific tests start
2269
+# NOTE: In E->P->D disagg case, both KV and EC Connector works in P instance
2270
+# Unless specify, the existence of KV Connector should not affect any test results
2271
+# ==============================================================================
2272
+
2273
+
2274
+def _assert_right_encoder_cache_allocated(
2275
+ scheduler: Scheduler,
2276
+ hashes_to_check: list[str] | None = None,
2277
+ requests: list[Request] | None = None,
2278
+ expected_total_allocated: int | None = None,
2279
+):
2280
+ """Check whether encoder cache is allocated correctly."""
2281
+ encoder_cache_manager = scheduler.encoder_cache_manager
2282
+
2283
+ # Verify encoder cache manager exists
2284
+ assert encoder_cache_manager is not None, "Encoder cache manager should exist"
2285
+
2286
+ # Verify number of cache
2287
+ if expected_total_allocated is not None:
2288
+ assert len(encoder_cache_manager.cached) == expected_total_allocated
2289
+ if expected_total_allocated == 0:
2290
+ return
2291
+
2292
+ # Verify each request with MM data is in cache
2293
+ cached_hashes = set(encoder_cache_manager.cached.keys())
2294
+
2295
+ if hashes_to_check:
2296
+ missed_hashes = set(hashes_to_check) - cached_hashes
2297
+ assert not missed_hashes, (
2298
+ f"Miss hashes: {missed_hashes} "
2299
+ f"Existing encoder cache: {encoder_cache_manager.cached}"
2300
+ )
2301
+
2302
+ for req in requests if requests is not None else []:
2303
+ if req.mm_features:
2304
+ mm_hashes = [f.identifier for f in req.mm_features]
2305
+ req_hashes = set(mm_hashes) # unique hashes set
2306
+ missed_hashes = req_hashes - cached_hashes
2307
+ assert not missed_hashes, (
2308
+ f"Miss hashes in cache for request {req.request_id}: {missed_hashes} "
2309
+ f"Existing encoder cache: {encoder_cache_manager.cached}"
2310
+ )
2311
+
2312
+
2313
+def _assert_right_ec_connector_metadata(
2314
+ output: SchedulerOutput,
2315
+ mm_features_list: list[MultiModalFeatureSpec],
2316
+):
2317
+ """Verify that ECConnector metadata EXACTLY matches the input MM data"""
2318
+ # Get the connector metadata
2319
+ metadata = output.ec_connector_metadata
2320
+
2321
+ # Create lookup dictionaries for efficient access
2322
+ metadata_dict = {mm_data.mm_hash: mm_data for mm_data in metadata.mm_datas}
2323
+
2324
+ # Check all required identifiers exist in metadata; and no extra
2325
+ # In ECSharedStorageConnector format
2326
+ # NOTE: even having same identifier, the mm_features can be different
2327
+ # since their mm_position can be in different offsets, etc
2328
+ identifiers_dict = {f.identifier for f in mm_features_list}
2329
+ assert set(metadata_dict.keys()) == identifiers_dict
2330
+
2331
+ # Verify the info matches
2332
+ for i, mm_feature in enumerate(mm_features_list):
2333
+ identifier = mm_feature.identifier
2334
+ assert metadata_dict[identifier].mm_hash == identifier
2335
+ assert metadata_dict[identifier].num_token == mm_feature.mm_position.length
2336
+
2337
+
2338
+def _assert_right_encoder_inputs(
2339
+ output: SchedulerOutput,
2340
+ check_exist: bool | None = True,
2341
+ requests: list[Request] | None = None,
2342
+ expected_encoder_inputs: list[list[int]] | None = None,
2343
+ expected_total_reqs: int | None = None,
2344
+):
2345
+ """Verify that requests/mm_hashes should (not) in scheduled encoder input
2346
+ If check_exist is False, this function returns True
2347
+ if requests are NOT in encoder inputs"""
2348
+
2349
+ # Get the scheduled encoder inputs
2350
+ # NOTE: scheduled_encoder_inputs is a dictionary with request id as key
2351
+ scheduled_encoder_inputs = output.scheduled_encoder_inputs
2352
+
2353
+ # Check if scheduled_encoder_inputs is empty as expected
2354
+ if expected_total_reqs is not None:
2355
+ assert len(scheduled_encoder_inputs) == expected_total_reqs
2356
+ if expected_total_reqs == 0:
2357
+ return
2358
+
2359
+ # Number of expected enocder inputs should match number of requests
2360
+ if expected_encoder_inputs:
2361
+ assert check_exist and requests is not None # only support expect input exist
2362
+ assert len(requests) == len(expected_encoder_inputs)
2363
+
2364
+ # Check request (not) exist as expected
2365
+ for i, request in enumerate(requests if requests is not None else []):
2366
+ assert (request.request_id in scheduled_encoder_inputs) is check_exist, (
2367
+ f"Request {request.id} presence mismatch: expected {check_exist}, "
2368
+ f"got {request.id in scheduled_encoder_inputs}"
2369
+ )
2370
+ if expected_encoder_inputs:
2371
+ scheduled_encoder_input = scheduled_encoder_inputs[request.request_id]
2372
+ assert scheduled_encoder_input == expected_encoder_inputs[i]
2373
+
2374
+
2375
+def test_scheduler_no_ec_connector_by_default():
2376
+ """Test scheduler doesn't have EC connector by default."""
2377
+ scheduler = create_scheduler()
2378
+ assert scheduler.ec_connector is None
2379
+
2380
+
2381
+@pytest.mark.parametrize("use_kv_connector", [False, True])
2382
+def test_ec_connector_text_only_request(use_kv_connector):
2383
+ """Test text-only requests don't allocate encoder cache."""
2384
+ scheduler = create_scheduler(
2385
+ model="llava-hf/llava-1.5-7b-hf",
2386
+ use_kv_connector=use_kv_connector,
2387
+ use_ec_connector=True,
2388
+ ec_role="ec_consumer",
2389
+ )
2390
+
2391
+ NUM_PROMPT_TOKENS = 100
2392
+
2393
+ # Create text-only request (no mm_positions)
2394
+ requests = create_requests(
2395
+ num_requests=1,
2396
+ num_tokens=NUM_PROMPT_TOKENS,
2397
+ )
2398
+ assert not requests[0].mm_features # No MM data
2399
+
2400
+ scheduler.add_request(requests[0])
2401
+ output = scheduler.schedule()
2402
+
2403
+ # Should schedule
2404
+ assert len(output.scheduled_new_reqs) == 1
2405
+
2406
+ # Scheduled tokens should equal prompt tokens exactly
2407
+ scheduled = output.num_scheduled_tokens[requests[0].request_id]
2408
+ assert scheduled == NUM_PROMPT_TOKENS, (
2409
+ f"Text-only should schedule {NUM_PROMPT_TOKENS}, got {scheduled}"
2410
+ )
2411
+
2412
+ # Encoder cache should be empty
2413
+ _assert_right_encoder_cache_allocated(scheduler, expected_total_allocated=0)
2414
+
2415
+ # ECConnector should carry no metadata
2416
+ _assert_right_ec_connector_metadata(output, mm_features_list=[])
2417
+
2418
+ # Scheduled encoder input should be empty; no mm to compute
2419
+ _assert_right_encoder_inputs(output, expected_total_reqs=0)
2420
+
2421
+
2422
+@pytest.mark.parametrize("use_kv_connector", [False, True])
2423
+def test_ec_connector_cache_hit_external_load(use_kv_connector):
2424
+ """Test ec_consumer loads from external cache when hit.
2425
+ A normal basic operation for EPD disaggrgation"""
2426
+ scheduler = create_scheduler(
2427
+ model="llava-hf/llava-1.5-7b-hf",
2428
+ enable_prefix_caching=True,
2429
+ # kv connector should not effect test results
2430
+ use_kv_connector=use_kv_connector,
2431
+ use_ec_connector=True,
2432
+ ec_role="ec_consumer",
2433
+ )
2434
+
2435
+ # Create MM request
2436
+ NUM_TOKENS = 200 # NOTE: includes mm tokens
2437
+ NUM_ENCODER_TOKENS = 100
2438
+ mm_hashes_list = [["hash_test1"]]
2439
+ mm_positions = [[PlaceholderRange(offset=0, length=NUM_ENCODER_TOKENS)]]
2440
+
2441
+ request = create_requests(
2442
+ num_requests=1,
2443
+ num_tokens=NUM_TOKENS,
2444
+ mm_hashes_list=mm_hashes_list,
2445
+ mm_positions=mm_positions,
2446
+ )[0]
2447
+
2448
+ # Mock cache hit - encoder cache exists externally
2449
+ scheduler.ec_connector.has_caches = Mock(return_value=[True])
2450
+ scheduler.ec_connector.update_state_after_alloc = Mock(
2451
+ wraps=scheduler.ec_connector.update_state_after_alloc
2452
+ )
2453
+
2454
+ scheduler.add_request(request)
2455
+ output = scheduler.schedule()
2456
+
2457
+ # Should schedule prompt tokens
2458
+ scheduled_tokens = output.num_scheduled_tokens[request.request_id]
2459
+ assert scheduled_tokens == NUM_TOKENS
2460
+
2461
+ # Should called update_state_after_alloc for external load
2462
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(request, 0)
2463
+
2464
+ # Encoder cache should contain mm items from request
2465
+ _assert_right_encoder_cache_allocated(scheduler, requests=[request])
2466
+
2467
+ # ECConnector should carry metadata of request
2468
+ _assert_right_ec_connector_metadata(output, mm_features_list=request.mm_features)
2469
+
2470
+ # Scheduled encoder input should be empty; no mm to compute
2471
+ _assert_right_encoder_inputs(output, expected_total_reqs=0)
2472
+
2473
+
2474
+@pytest.mark.parametrize("use_kv_connector", [False, True])
2475
+def test_ec_connector_cache_miss_computes_locally(use_kv_connector):
2476
+ """Test consumer can compute encoder locally when cache miss (fallback)."""
2477
+ # encoder cache itself if it doesn't receive it from external storage
2478
+
2479
+ scheduler = create_scheduler(
2480
+ model="llava-hf/llava-1.5-7b-hf",
2481
+ enable_prefix_caching=True,
2482
+ use_kv_connector=use_kv_connector,
2483
+ use_ec_connector=True,
2484
+ ec_role="ec_consumer",
2485
+ )
2486
+
2487
+ # Verify consumer role
2488
+ assert scheduler.ec_connector is not None
2489
+ assert not scheduler.ec_connector.is_producer
2490
+
2491
+ # Create MM request
2492
+ request_mm_missed = create_requests(
2493
+ num_requests=1,
2494
+ num_tokens=200, # Total (including 100 MM)
2495
+ mm_positions=[[PlaceholderRange(offset=0, length=100)]], # 100 MM tokens
2496
+ )[0]
2497
+
2498
+ # Mock cache miss - encoder cache doesn't exist externally
2499
+ scheduler.ec_connector.has_caches = Mock(return_value=[False])
2500
+
2501
+ scheduler.add_request(request_mm_missed)
2502
+ output = scheduler.schedule()
2503
+
2504
+ # SCHEDULER should decide to compute encoder locally (fallback)
2505
+ assert len(output.scheduled_new_reqs) == 1
2506
+
2507
+ # Should schedule full prompt tokens
2508
+ scheduled_tokens = output.num_scheduled_tokens[request_mm_missed.request_id]
2509
+ assert scheduled_tokens == 200, (
2510
+ f"Expected 200 tokens on cache miss, got {scheduled_tokens}"
2511
+ )
2512
+
2513
+ # Encoder cache should contain mm items from request
2514
+ _assert_right_encoder_cache_allocated(scheduler, requests=[request_mm_missed])
2515
+
2516
+ # ECConnector should carry no metadata (missed cache)
2517
+ _assert_right_ec_connector_metadata(output, mm_features_list=[])
2518
+
2519
+ # Scheduled encoder input contain mm for request_mm_missed
2520
+ _assert_right_encoder_inputs(
2521
+ output,
2522
+ requests=[request_mm_missed],
2523
+ expected_encoder_inputs=[[0]], # index 0 of the mm item
2524
+ expected_total_reqs=1,
2525
+ )
2526
+
2527
+ # Then MODEL_RUNNER will execute the encoder and cache the result
2528
+
2529
+
2530
+@pytest.mark.parametrize("use_kv_connector", [False, True])
2531
+def test_ec_connector_with_partial_cache_hit_multi_round(use_kv_connector):
2532
+ """Test consumer with partial cache hit (local & connector) with 2 requests."""
2533
+ scheduler = create_scheduler(
2534
+ model="llava-hf/llava-1.5-7b-hf",
2535
+ enable_prefix_caching=True,
2536
+ use_kv_connector=use_kv_connector,
2537
+ use_ec_connector=True,
2538
+ ec_role="ec_consumer",
2539
+ )
2540
+
2541
+ # Create MM request
2542
+ NUM_TOKENS_1 = 300 # NOTE: includes mm tokens
2543
+ NUM_ENCODER_TOKENS_1 = 50
2544
+ mm_hashes_list_1 = [["hash1_A", "hash1_B", "hash1_A", "hash1_F"]]
2545
+ mm_positions_1 = [
2546
+ [
2547
+ PlaceholderRange(offset=0, length=NUM_ENCODER_TOKENS_1),
2548
+ PlaceholderRange(offset=100, length=NUM_ENCODER_TOKENS_1),
2549
+ PlaceholderRange(offset=200, length=NUM_ENCODER_TOKENS_1),
2550
+ PlaceholderRange(offset=250, length=NUM_ENCODER_TOKENS_1),
2551
+ ]
2552
+ ]
2553
+
2554
+ # Create request with 4 MM items, with 2 identical items
2555
+ request1 = create_requests(
2556
+ num_requests=1,
2557
+ num_tokens=NUM_TOKENS_1,
2558
+ mm_hashes_list=mm_hashes_list_1,
2559
+ mm_positions=mm_positions_1,
2560
+ max_tokens=1, # For simplicity
2561
+ )[0]
2562
+
2563
+ # Mock partial cache hit: 1st and 3rd missing, 2nd and 4th exist
2564
+ scheduler.ec_connector.has_caches = Mock(return_value=[False, True, False, True])
2565
+ scheduler.ec_connector.update_state_after_alloc = Mock(
2566
+ wraps=scheduler.ec_connector.update_state_after_alloc
2567
+ )
2568
+
2569
+ scheduler.add_request(request1)
2570
+ output = scheduler.schedule()
2571
+
2572
+ # Should schedule all tokens
2573
+ scheduled_tokens = output.num_scheduled_tokens[request1.request_id]
2574
+ assert scheduled_tokens == NUM_TOKENS_1
2575
+
2576
+ # Encoder cache should contain all mm items from request
2577
+ _assert_right_encoder_cache_allocated(scheduler, requests=[request1])
2578
+
2579
+ # Should have called update_state_after_alloc for external load
2580
+ scheduler.ec_connector.update_state_after_alloc.assert_called()
2581
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
2582
+
2583
+ # ECConnector should carry metadata for 2nd and 4th mm item
2584
+ _assert_right_ec_connector_metadata(
2585
+ output, mm_features_list=[request1.mm_features[1], request1.mm_features[3]]
2586
+ )
2587
+
2588
+ # Should schedule ONLY 1 encoder input (index 0), no repeat for identical items
2589
+ _assert_right_encoder_inputs(
2590
+ output,
2591
+ requests=[request1],
2592
+ expected_encoder_inputs=[[0]], # index 0 of the mm item ONLY
2593
+ expected_total_reqs=1,
2594
+ )
2595
+
2596
+ # Simulate model execution 1 step
2597
+ model_output = ModelRunnerOutput(
2598
+ req_ids=[request1.request_id],
2599
+ req_id_to_index={request1.request_id: 0},
2600
+ sampled_token_ids=[[100]],
2601
+ # spec_token_ids=None,
2602
+ logprobs=None,
2603
+ prompt_logprobs_dict={},
2604
+ pooler_output=[],
2605
+ )
2606
+ scheduler.update_from_output(output, model_output)
2607
+
2608
+ # request1 is finished after outputing 1 token
2609
+ # Finish request
2610
+ scheduler.finish_requests(request1.request_id, RequestStatus.FINISHED_LENGTH_CAPPED)
2611
+
2612
+ # Create another request with 4 MM items
2613
+ NUM_TOKENS_2 = 400
2614
+ NUM_ENCODER_TOKENS_2 = 50
2615
+ mm_hashes_list_2 = [["hash1_C", "hash1_D", "hash1_E", "hash1_A"]]
2616
+ mm_positions_2 = [
2617
+ [
2618
+ PlaceholderRange(offset=0, length=NUM_ENCODER_TOKENS_2),
2619
+ PlaceholderRange(offset=100, length=NUM_ENCODER_TOKENS_2),
2620
+ PlaceholderRange(offset=200, length=NUM_ENCODER_TOKENS_2),
2621
+ PlaceholderRange(offset=250, length=NUM_ENCODER_TOKENS_2),
2622
+ ]
2623
+ ]
2624
+
2625
+ request2 = create_requests(
2626
+ num_requests=1,
2627
+ num_tokens=NUM_TOKENS_2,
2628
+ mm_hashes_list=mm_hashes_list_2,
2629
+ mm_positions=mm_positions_2,
2630
+ max_tokens=1, # For simplicity
2631
+ )[0]
2632
+
2633
+ # Mock partial cache hit: only hash1_A and hash1_C exist in connector
2634
+ scheduler.ec_connector.has_caches = Mock(return_value=[True, False, False, True])
2635
+
2636
+ scheduler.add_request(request2)
2637
+ output = scheduler.schedule()
2638
+
2639
+ # Check
2640
+ # Should schedule all tokens
2641
+ scheduled_tokens = output.num_scheduled_tokens[request2.request_id]
2642
+ assert scheduled_tokens == 400
2643
+
2644
+ # Encoder cache should contain all mm items from request2
2645
+ _assert_right_encoder_cache_allocated(scheduler, requests=[request2])
2646
+
2647
+ # Should call update_state_after_alloc for hash1_C, ONLY
2648
+ # hash1_A should not be loaded from connector
2649
+ # since it's computed in last request & exist in local cache
2650
+ # Order of getting encoder cache should be: local cache -> connector-> compute
2651
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(request2, 0)
2652
+ scheduler.ec_connector.update_state_after_alloc.assert_called_once()
2653
+
2654
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
2655
+
2656
+ # ECConnector should carry metadata for hash1_C only (index 0)
2657
+ _assert_right_ec_connector_metadata(
2658
+ output, mm_features_list=[request2.mm_features[0]]
2659
+ )
2660
+
2661
+ # Should schedule 2 encoder input hash1_D and hash1_E (index 1, 2)
2662
+ _assert_right_encoder_inputs(
2663
+ output,
2664
+ requests=[request2],
2665
+ expected_encoder_inputs=[[1, 2]],
2666
+ expected_total_reqs=1,
2667
+ )
2668
+
2669
+
2670
+@pytest.mark.parametrize("cache_exist", ["local", "connector_only", "no_where"])
2671
+@pytest.mark.parametrize("use_kv_connector", [False, True])
2672
+def test_ec_connector_schedule_multiple_requests(cache_exist, use_kv_connector):
2673
+ scheduler = create_scheduler(
2674
+ model="llava-hf/llava-1.5-7b-hf",
2675
+ max_num_seqs=10, # allow multiple requests
2676
+ max_num_batched_tokens=2048,
2677
+ enable_prefix_caching=True,
2678
+ use_kv_connector=use_kv_connector,
2679
+ use_ec_connector=True,
2680
+ ec_role="ec_consumer",
2681
+ )
2682
+ mm_hashes_list = [[f"hash_{i}"] for i in range(10)]
2683
+ mm_positions = [[PlaceholderRange(offset=i, length=100)] for i in range(10)]
2684
+ requests = create_requests(
2685
+ num_requests=10,
2686
+ num_tokens=200,
2687
+ mm_hashes_list=mm_hashes_list,
2688
+ mm_positions=mm_positions,
2689
+ )
2690
+ for request in requests:
2691
+ scheduler.add_request(request)
2692
+
2693
+ # Set up to test different encoder cache exsistence scenario after preemption
2694
+ # Order of getting encoder cache should be: local cache -> connector-> compute
2695
+ scheduler.ec_connector.update_state_after_alloc = Mock(
2696
+ wraps=scheduler.ec_connector.update_state_after_alloc
2697
+ )
2698
+
2699
+ if cache_exist == "local":
2700
+ # Allocate cache to cache manager manually to mimick
2701
+ for req in requests:
2702
+ scheduler.encoder_cache_manager.allocate(req, 0)
2703
+ else:
2704
+ # Make sure local encoder cache empty
2705
+ scheduler.encoder_cache_manager.cached = {}
2706
+
2707
+ if cache_exist == "connector_only":
2708
+ # Cache exist in ec_connector
2709
+ scheduler.ec_connector.has_caches = Mock(return_value=[True])
2710
+ elif cache_exist == "no_where":
2711
+ scheduler.ec_connector.has_caches = Mock(return_value=[False])
2712
+
2713
+ output = scheduler.schedule()
2714
+ assert len(output.scheduled_new_reqs) == len(requests)
2715
+ assert output.scheduled_cached_reqs.num_reqs == 0
2716
+ assert len(output.finished_req_ids) == 0
2717
+ for req_id, num_tokens in output.num_scheduled_tokens.items():
2718
+ assert num_tokens == len(requests[int(req_id)].prompt_token_ids)
2719
+
2720
+ ## Encoder-cache-specific checks:
2721
+ # mm_hashes of requests exist in cache after scheduling for all scenario
2722
+ _assert_right_encoder_cache_allocated(scheduler, requests=requests)
2723
+
2724
+ # Should only call update_state_after_alloc when loaded externally
2725
+ if cache_exist == "connector_only":
2726
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(
2727
+ requests[-1], 0
2728
+ )
2729
+
2730
+ # Concat mm_features for the 10 requests together
2731
+ mm_features_list = [feature for req in requests for feature in req.mm_features]
2732
+
2733
+ # Check metadata should contain mm data for all 10 requests
2734
+ _assert_right_ec_connector_metadata(output, mm_features_list=mm_features_list)
2735
+ else:
2736
+ scheduler.ec_connector.update_state_after_alloc.assert_not_called()
2737
+ # ECConnector should carry no metadata
2738
+ _assert_right_ec_connector_metadata(output, mm_features_list=[])
2739
+
2740
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
2741
+
2742
+ # Should only schedule encoder input when cache is not found anywhere
2743
+ if cache_exist == "no_where":
2744
+ _assert_right_encoder_inputs(
2745
+ output,
2746
+ requests=requests,
2747
+ expected_encoder_inputs=[[0] for _ in range(10)],
2748
+ expected_total_reqs=10,
2749
+ )
2750
+ else:
2751
+ _assert_right_encoder_inputs(output, expected_total_reqs=0)
2752
+
2753
+
2754
+@pytest.mark.parametrize("use_kv_connector", [False, True])
2755
+def test_ec_connector_unable_to_allocate(use_kv_connector):
2756
+ """
2757
+ Test whether scheduler with ECConnector is able to handle
2758
+ unable to allocate (run out of blocks).
2759
+ """
2760
+
2761
+ # Setup Scheduler With Mock External Cache Hit.
2762
+ BLOCK_SIZE = 4
2763
+ NUM_BLOCKS = 10
2764
+ scheduler = create_scheduler(
2765
+ model="llava-hf/llava-1.5-7b-hf",
2766
+ enable_prefix_caching=True,
2767
+ use_kv_connector=use_kv_connector,
2768
+ block_size=BLOCK_SIZE,
2769
+ num_blocks=NUM_BLOCKS,
2770
+ use_ec_connector=True,
2771
+ ec_role="ec_consumer",
2772
+ )
2773
+
2774
+ # Mock ec_connector load external cache behavior
2775
+ scheduler.ec_connector.has_caches = Mock(return_value=[True])
2776
+ scheduler.ec_connector.update_state_after_alloc = Mock(
2777
+ wraps=scheduler.ec_connector.update_state_after_alloc
2778
+ )
2779
+
2780
+ # Create two requests. The second request will not be able to
2781
+ # allocate slots because it will not have enough blocks.
2782
+ NUM_REQUESTS = 2
2783
+ NUM_TOKENS = (NUM_BLOCKS // 2 + 1) * BLOCK_SIZE
2784
+ MAX_TOKENS = 2
2785
+ requests = create_requests(
2786
+ num_requests=NUM_REQUESTS,
2787
+ num_tokens=NUM_TOKENS,
2788
+ mm_hashes_list=[["hash_1"], ["hash_2"]],
2789
+ mm_positions=[
2790
+ [PlaceholderRange(offset=1, length=10)] for _ in range(NUM_REQUESTS)
2791
+ ],
2792
+ max_tokens=MAX_TOKENS,
2793
+ block_size=BLOCK_SIZE,
2794
+ )
2795
+ req_ids = []
2796
+ req_to_index = {}
2797
+ for i, request in enumerate(requests):
2798
+ scheduler.add_request(request)
2799
+ req_ids.append(request.request_id)
2800
+ req_to_index[request.request_id] = i
2801
+
2802
+ # Setup MODEL_RUNNER_OUTPUT to be run in _step_until_done later
2803
+ MODEL_RUNNER_OUTPUT = ModelRunnerOutput(
2804
+ req_ids=req_ids,
2805
+ req_id_to_index=req_to_index,
2806
+ sampled_token_ids=[[1000]] * len(req_ids),
2807
+ logprobs=None,
2808
+ prompt_logprobs_dict={},
2809
+ pooler_output=[],
2810
+ )
2811
+
2812
+ # Just one request should be running.
2813
+ output = scheduler.schedule()
2814
+ scheduled_tokens = output.num_scheduled_tokens[scheduler.running[0].request_id]
2815
+ assert scheduled_tokens == NUM_TOKENS
2816
+ assert len(scheduler.running) == 1
2817
+ assert len(scheduler.waiting) == 1
2818
+
2819
+ # Should have called update_state_after_alloc for external load
2820
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(
2821
+ scheduler.running[0], 0
2822
+ )
2823
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
2824
+
2825
+ # All memory should be freed, with one request waiting.
2826
+ _step_until_done(scheduler, output, MODEL_RUNNER_OUTPUT)
2827
+ assert scheduler.kv_cache_manager.block_pool.get_num_free_blocks() == NUM_BLOCKS - 1
2828
+ assert len(scheduler.running) == 0
2829
+ assert len(scheduler.waiting) == 1
2830
+
2831
+ # Just one request should be running.
2832
+ output = scheduler.schedule()
2833
+ scheduled_tokens = output.num_scheduled_tokens[scheduler.running[0].request_id]
2834
+ assert scheduled_tokens == NUM_TOKENS
2835
+ assert len(scheduler.running) == 1
2836
+ assert len(scheduler.waiting) == 0
2837
+
2838
+ # update_state_after_alloc should be called for loading external cache
2839
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(
2840
+ scheduler.running[0], 0
2841
+ )
2842
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
2843
+
2844
+ # All memory should be freed, with no requests waiting / running.
2845
+ _step_until_done(scheduler, output, MODEL_RUNNER_OUTPUT)
2846
+ assert scheduler.kv_cache_manager.block_pool.get_num_free_blocks() == NUM_BLOCKS - 1
2847
+ assert len(scheduler.running) == 0
2848
+ assert len(scheduler.waiting) == 0
2849
+
2850
+
2851
+@pytest.mark.parametrize("cache_exist", ["local", "connector_only", "no_where"])
2852
+@pytest.mark.parametrize("use_kv_connector", [False, True])
2853
+def test_priority_scheduling_ec_connector_preemption_and_resumption(
2854
+ cache_exist, use_kv_connector
2855
+):
2856
+ """Test that priority scheduling preempts lower priority requests
2857
+ when out of KV cache space."""
2858
+ # Create scheduler with very limited memory to force preemption
2859
+ scheduler = create_scheduler_with_priority(
2860
+ model="llava-hf/llava-1.5-7b-hf",
2861
+ enable_prefix_caching=True,
2862
+ max_num_seqs=2, # allow multiple requests
2863
+ # kv connector should not effect test results
2864
+ use_kv_connector=use_kv_connector,
2865
+ num_blocks=15, # can hold 244 tokens with 14 blocks (first block is null)
2866
+ block_size=16, # standard block size
2867
+ use_ec_connector=True,
2868
+ ec_role="ec_consumer",
2869
+ )
2870
+
2871
+ # Mock cache hit: Both cache exist in connector (at E->PD initially)
2872
+ scheduler.ec_connector.has_caches = Mock(return_value=[True])
2873
+ scheduler.ec_connector.update_state_after_alloc = Mock(
2874
+ wraps=scheduler.ec_connector.update_state_after_alloc
2875
+ )
2876
+
2877
+ # Create a request and schedule it (and to be preempted)
2878
+ request_low = create_requests_with_priority(
2879
+ num_requests=1,
2880
+ priorities=[1],
2881
+ arrival_times=[0.0],
2882
+ num_tokens=94,
2883
+ mm_hashes_list=[["hash_low"]],
2884
+ # NOTE: this test only preempt the last block.
2885
+ # Setting mm_position at the last block can force to recompute encoding
2886
+ mm_positions=[[PlaceholderRange(offset=82, length=10)]],
2887
+ starting_idx=0,
2888
+ )[0]
2889
+ scheduler.add_request(request_low)
2890
+ # 1st schedule
2891
+ output = scheduler.schedule()
2892
+
2893
+ assert len(output.scheduled_new_reqs) == 1
2894
+ scheduled_tokens = output.num_scheduled_tokens[request_low.request_id]
2895
+ assert scheduled_tokens == 94
2896
+ assert len(scheduler.waiting) == 0
2897
+ assert len(scheduler.running) == 1
2898
+
2899
+ ## Encoder-cache-specific checks:
2900
+ # Encoder cache should contain mm items from request
2901
+ _assert_right_encoder_cache_allocated(scheduler, requests=[request_low])
2902
+
2903
+ # Verify update_state_after_alloc called (external load)
2904
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(request_low, 0)
2905
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
2906
+
2907
+ # ECConnector should carry metadata of request
2908
+ _assert_right_ec_connector_metadata(
2909
+ output, mm_features_list=request_low.mm_features
2910
+ )
2911
+
2912
+ # Scheduled encoder input should be empty; no mm to compute
2913
+ _assert_right_encoder_inputs(output, expected_total_reqs=0)
2914
+
2915
+ # Simulate model execution - 1st decode
2916
+ model_output = ModelRunnerOutput(
2917
+ req_ids=[request_low.request_id],
2918
+ req_id_to_index={request_low.request_id: 0},
2919
+ sampled_token_ids=[[100]],
2920
+ # spec_token_ids=None,
2921
+ logprobs=None,
2922
+ prompt_logprobs_dict={},
2923
+ pooler_output=[],
2924
+ )
2925
+ scheduler.update_from_output(output, model_output)
2926
+
2927
+ # Create a high priority request and schedule it
2928
+ request_high = create_requests_with_priority(
2929
+ num_requests=1,
2930
+ priorities=[0],
2931
+ arrival_times=[1.0],
2932
+ num_tokens=128,
2933
+ mm_hashes_list=[["hash_high"]],
2934
+ mm_positions=[[PlaceholderRange(offset=1, length=10)]],
2935
+ max_tokens=2,
2936
+ starting_idx=1,
2937
+ )[0]
2938
+ scheduler.add_request(request_high)
2939
+ # 2nd schedule
2940
+ output = scheduler.schedule()
2941
+
2942
+ # KV cache should be full at this point
2943
+ assert scheduler.kv_cache_manager.block_pool.get_num_free_blocks() == 0
2944
+ assert len(output.scheduled_new_reqs) == 1
2945
+ assert output.scheduled_cached_reqs.num_reqs == 1
2946
+ assert len(scheduler.waiting) == 0
2947
+ assert len(scheduler.running) == 2
2948
+
2949
+ ## Encoder-cache-specific checks:
2950
+ # Encoder cache should contain mm items from request
2951
+ _assert_right_encoder_cache_allocated(scheduler, requests=[request_high])
2952
+
2953
+ # Verify update_state_after_alloc called (external load)
2954
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(request_high, 0)
2955
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
2956
+
2957
+ # ECConnector should carry metadata of request
2958
+ _assert_right_ec_connector_metadata(
2959
+ output, mm_features_list=request_high.mm_features
2960
+ )
2961
+
2962
+ # Scheduled encoder input should be empty; no mm to compute
2963
+ _assert_right_encoder_inputs(output, expected_total_reqs=0)
2964
+
2965
+ # Simulate model execution - 2nd decode
2966
+ requests = [request_low, request_high]
2967
+ model_output = ModelRunnerOutput(
2968
+ req_ids=[req.request_id for req in requests],
2969
+ req_id_to_index={req.request_id: i for i, req in enumerate(requests)},
2970
+ sampled_token_ids=[[100] for _ in requests],
2971
+ # spec_token_ids=None,
2972
+ logprobs=None,
2973
+ prompt_logprobs_dict={},
2974
+ pooler_output=[],
2975
+ )
2976
+ scheduler.update_from_output(output, model_output)
2977
+
2978
+ # 3rd schedule - - this should trigger preemption
2979
+ # req_low needs 96 tokens = 6 blocks
2980
+ # req_high needs 129 tokens = 9 blocks
2981
+ # so doesn't fit in 14 blocks.
2982
+ output = scheduler.schedule()
2983
+
2984
+ # Should have preempted req_low
2985
+ assert len(output.scheduled_new_reqs) == 0
2986
+ assert output.scheduled_cached_reqs.num_reqs == 1
2987
+ assert output.scheduled_cached_reqs.req_ids[0] == request_high.request_id
2988
+ assert scheduler.requests[request_low.request_id].status == RequestStatus.PREEMPTED
2989
+ assert len(scheduler.waiting) == 1
2990
+ assert len(scheduler.running) == 1
2991
+
2992
+ ## Encoder-cache-specific checks:
2993
+ # request_high is in decode phase now
2994
+ # ECConnector should carry no metadata
2995
+ _assert_right_ec_connector_metadata(output, mm_features_list=[])
2996
+
2997
+ # Scheduled encoder input should be empty; no mm to compute
2998
+ _assert_right_encoder_inputs(output, expected_total_reqs=0)
2999
+
3000
+ # Simulate model execution - 3rd decode, after req_low was preempted
3001
+ requests = [request_low, request_high]
3002
+ model_output = ModelRunnerOutput(
3003
+ req_ids=[req.request_id for req in requests],
3004
+ req_id_to_index={req.request_id: i for i, req in enumerate(requests)},
3005
+ sampled_token_ids=[[100], [100, 200]],
3006
+ # spec_token_ids=None,
3007
+ logprobs=None,
3008
+ prompt_logprobs_dict={},
3009
+ pooler_output=[],
3010
+ )
3011
+ # Finish the requests to make room for the preempted requests to resume
3012
+ # req_high is finished after outputing 2 tokens
3013
+ scheduler.update_from_output(output, model_output)
3014
+ scheduler.finish_requests(
3015
+ request_high.request_id, RequestStatus.FINISHED_LENGTH_CAPPED
3016
+ )
3017
+
3018
+ # Set up to test different encoder cache exsistence scenario after preemption
3019
+ # Order of getting encoder cache should be: local cache -> connector-> compute
3020
+ # By default, the cache should still exist in local in this test case
3021
+ if cache_exist != "local":
3022
+ # Make local encoder cache empty
3023
+ scheduler.encoder_cache_manager.cached = {}
3024
+
3025
+ if cache_exist == "connector_only":
3026
+ # Cache exist in ec_connector
3027
+ scheduler.ec_connector.has_caches = Mock(return_value=[True])
3028
+ elif cache_exist == "no_where":
3029
+ scheduler.ec_connector.has_caches = Mock(return_value=[False])
3030
+
3031
+ # 4th Schedule - this should trigger req_low resumption from waiting
3032
+ output = scheduler.schedule()
3033
+ scheduled_cached_reqs = output.scheduled_cached_reqs
3034
+ resumed_from_preemption = scheduled_cached_reqs.resumed_from_preemption
3035
+
3036
+ assert len(output.scheduled_new_reqs) == 0
3037
+ assert scheduled_cached_reqs.num_reqs == 1
3038
+ assert len(scheduler.waiting) == 0
3039
+ assert len(scheduler.running) == 1
3040
+
3041
+ # Preempted request resumed in scheduled_cached_reqs
3042
+ assert len(resumed_from_preemption) == 1
3043
+ assert len(scheduled_cached_reqs.resumed_req_token_ids) == 1
3044
+ assert resumed_from_preemption[0]
3045
+ assert scheduled_cached_reqs.req_ids[0] == request_low.request_id
3046
+ assert scheduled_cached_reqs.resumed_req_token_ids[0] is not None
3047
+ ## Resumed tokens include 94 prompt tokens and 2 decoded tokens
3048
+ assert len(scheduled_cached_reqs.resumed_req_token_ids[0]) == 96
3049
+ assert scheduled_cached_reqs.resumed_req_token_ids[0][95] == 100
3050
+ assert scheduler.running[0].request_id == request_low.request_id
3051
+ assert request_high.request_id in output.finished_req_ids
3052
+
3053
+ ## Encoder-cache-specific checks:
3054
+ # mm_hash of request_low exists in cache after scheduling for all scenario
3055
+ _assert_right_encoder_cache_allocated(scheduler, requests=[request_low])
3056
+
3057
+ # Should only call update_state_after_alloc when loaded externally
3058
+ if cache_exist == "connector_only":
3059
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(
3060
+ request_low, 0
3061
+ )
3062
+ _assert_right_ec_connector_metadata(
3063
+ output, mm_features_list=request_low.mm_features
3064
+ )
3065
+ else:
3066
+ scheduler.ec_connector.update_state_after_alloc.assert_not_called()
3067
+ # ECConnector should carry no metadata
3068
+ _assert_right_ec_connector_metadata(output, mm_features_list=[])
3069
+
3070
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
3071
+
3072
+ # Should only schedule encoder input when cache is not found anywhere
3073
+ if cache_exist == "no_where":
3074
+ _assert_right_encoder_inputs(
3075
+ output,
3076
+ requests=[request_low],
3077
+ expected_encoder_inputs=[[0]],
3078
+ expected_total_reqs=1,
3079
+ )
3080
+ else:
3081
+ _assert_right_encoder_inputs(output, expected_total_reqs=0)
3082
+
3083
+
3084
+@pytest.mark.parametrize("use_kv_connector", [False, True])
3085
+def test_ec_connector_allocate_encoder_tokens_with_external_load(use_kv_connector):
3086
+ """
3087
+ Scenario:
3088
+ - Encoder cache size: 32
3089
+ - Request A: 1 feature (12 tokens) → NOT cached remotely.
3090
+ - Request B: 3 features (3 x 10 tokens) → ALL cached remotely.
3091
+
3092
+ Steps:
3093
+ 1. Schedule Request A (locally uses 12 tokens).
3094
+ 2. Schedule Request B (remote cache) - only schedule 1st and 2nd
3095
+ 3. Free A's cache, then schedule B again (continuation) - schedule 3rd image
3096
+ """
3097
+ scheduler = create_scheduler(
3098
+ model="llava-hf/llava-1.5-7b-hf",
3099
+ max_num_batched_tokens=1024,
3100
+ enable_prefix_caching=True,
3101
+ use_kv_connector=use_kv_connector,
3102
+ block_size=16,
3103
+ num_blocks=11, # Can hold 160 tokens (first block is null)
3104
+ use_ec_connector=True,
3105
+ ec_role="ec_consumer",
3106
+ )
3107
+
3108
+ # Limit the number of availiable slots of EncoderCacheManager
3109
+ scheduler.encoder_cache_manager = EncoderCacheManager(cache_size=32)
3110
+
3111
+ # Create MM request1
3112
+ NUM_TOKENS_1 = 50 # NOTE: includes mm tokens
3113
+ NUM_ENCODER_TOKENS_1 = 12
3114
+ mm_hashes_list_1 = [["hash1_1"]]
3115
+ mm_positions_1 = [[PlaceholderRange(offset=0, length=NUM_ENCODER_TOKENS_1)]]
3116
+
3117
+ request1 = create_requests(
3118
+ num_requests=1,
3119
+ num_tokens=NUM_TOKENS_1,
3120
+ mm_hashes_list=mm_hashes_list_1,
3121
+ mm_positions=mm_positions_1,
3122
+ max_tokens=1, # For simplicity
3123
+ req_ids=["req1"],
3124
+ )[0]
3125
+
3126
+ # Create MM request1 with 3 MM items
3127
+ NUM_TOKENS_2 = 40
3128
+ NUM_ENCODER_TOKENS_2 = 10
3129
+ mm_hashes_list_2 = [["hash2_1", "hash2_2", "hash2_3"]]
3130
+ mm_positions_2 = [
3131
+ [
3132
+ PlaceholderRange(offset=0, length=NUM_ENCODER_TOKENS_2),
3133
+ PlaceholderRange(offset=12, length=NUM_ENCODER_TOKENS_2),
3134
+ PlaceholderRange(offset=24, length=NUM_ENCODER_TOKENS_2),
3135
+ ]
3136
+ ]
3137
+
3138
+ request2 = create_requests(
3139
+ num_requests=1,
3140
+ num_tokens=NUM_TOKENS_2,
3141
+ mm_hashes_list=mm_hashes_list_2,
3142
+ mm_positions=mm_positions_2,
3143
+ max_tokens=10,
3144
+ req_ids=["req2"],
3145
+ )[0]
3146
+
3147
+ # Mock cache hit: MM of request1 NOT cached remotely, request2 cached remotely
3148
+ scheduler.ec_connector.has_caches = Mock(
3149
+ side_effect=lambda req: [True, True, True] if req == request2 else [False]
3150
+ )
3151
+ scheduler.ec_connector.update_state_after_alloc = Mock(
3152
+ wraps=scheduler.ec_connector.update_state_after_alloc
3153
+ )
3154
+
3155
+ scheduler.add_request(request1)
3156
+ scheduler.add_request(request2)
3157
+ output = scheduler.schedule()
3158
+
3159
+ # Now, since encoder cache manager can only store 32 tokens
3160
+ # It should allocated mm item hash1_1, hash2_1 and hash2_2
3161
+ scheduled_tokens = output.num_scheduled_tokens[request1.request_id]
3162
+ assert scheduled_tokens == NUM_TOKENS_1
3163
+ assert scheduler.get_num_unfinished_requests() == 2
3164
+
3165
+ # Encoder cache should contain mm item from request1
3166
+ _assert_right_encoder_cache_allocated(
3167
+ scheduler, hashes_to_check=["hash1_1", "hash2_1", "hash2_2"]
3168
+ )
3169
+
3170
+ # request2's 2nd mm item is the last call of update_state_after_alloc
3171
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(request2, 1)
3172
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
3173
+
3174
+ # ECConnector should carry metadata of hash2_1 and hash2_2 ONLY
3175
+ _assert_right_ec_connector_metadata(
3176
+ output, mm_features_list=[request2.mm_features[0], request2.mm_features[1]]
3177
+ )
3178
+
3179
+ # Should schedule ONLY 1 encoder input
3180
+ _assert_right_encoder_inputs(
3181
+ output,
3182
+ requests=[request1],
3183
+ expected_encoder_inputs=[[0]], # index 0 of the mm item of request1
3184
+ expected_total_reqs=1,
3185
+ )
3186
+
3187
+ # Simulate model execution 1 step
3188
+ model_output = ModelRunnerOutput(
3189
+ req_ids=[request1.request_id, request2.request_id],
3190
+ req_id_to_index={request1.request_id: 0, request2.request_id: 1},
3191
+ sampled_token_ids=[[100], [121]],
3192
+ # spec_token_ids=None,
3193
+ logprobs=None,
3194
+ prompt_logprobs_dict={},
3195
+ pooler_output=[],
3196
+ )
3197
+ scheduler.update_from_output(output, model_output)
3198
+
3199
+ # request1 is finished after outputing 1 token
3200
+ # Finish request
3201
+ scheduler.finish_requests(request1.request_id, RequestStatus.FINISHED_LENGTH_CAPPED)
3202
+ assert scheduler.get_num_unfinished_requests() == 1
3203
+
3204
+ # Schedule again; Now request1's encoder cache should be freed
3205
+ # -> hash2_3 can be scheduled and allocated
3206
+ output = scheduler.schedule()
3207
+
3208
+ # Check
3209
+ # Should schedule all tokens
3210
+ scheduled_tokens = output.num_scheduled_tokens[request2.request_id]
3211
+ print(f"Hero: scheduled_tokens for req2: {scheduled_tokens}")
3212
+ print(f"hero: num_scheduled_tokens 2: {output.num_scheduled_tokens}")
3213
+
3214
+ # Encoder cache should contain all mm items from request2
3215
+ _assert_right_encoder_cache_allocated(scheduler, requests=[request2])
3216
+
3217
+ # request2's 3rd mm item is the ONLY call of update_state_after_alloc
3218
+ scheduler.ec_connector.update_state_after_alloc.assert_called_with(request2, 2)
3219
+ scheduler.ec_connector.update_state_after_alloc.assert_called_once()
3220
+
3221
+ scheduler.ec_connector.update_state_after_alloc.reset_mock()
3222
+
3223
+ # ECConnector should carry metadata for hash2_3 ONLY
3224
+ _assert_right_ec_connector_metadata(
3225
+ output, mm_features_list=[request2.mm_features[2]]
3226
+ )
3227
+
3228
+ # Should schedule no encoder input
3229
+ _assert_right_encoder_inputs(
3230
+ output,
3231
+ expected_total_reqs=0,
3232
+ )
3233
+
3234
+
3235
+# ==============================================================================
3236
+# EPD (Encoder-Prefill-Decode) Encoder-cache-specific tests end
3237
+# ==============================================================================