main
py 816 lines 28 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from collections.abc import Iterator
4 from unittest.mock import MagicMock
5
6 import pytest
7
8 from vllm.distributed.kv_events import BlockStored
9 from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata
10 from vllm.distributed.kv_transfer.kv_connector.v1.lmcache_connector import (
11 LMCacheConnectorV1,
12 LMCacheKVEvents,
13 )
14 from vllm.v1.outputs import KVConnectorOutput
15
16
17 @pytest.fixture
18 def mock_lmcache_engine_event():
19 """Create a mock event object that mimics what the lmcache engine returns."""
20
21 class MockEvent:
22 def __init__(
23 self,
24 block_hashes,
25 parent_block_hash,
26 token_ids,
27 lora_id,
28 block_size,
29 medium,
30 lora_name,
31 ):
32 self.block_hashes = block_hashes
33 self.parent_block_hash = parent_block_hash
34 self.token_ids = token_ids
35 self.lora_id = lora_id
36 self.block_size = block_size
37 self.medium = medium
38 self.lora_name = lora_name
39
40 return MockEvent(
41 block_hashes=["hash1", "hash2"],
42 parent_block_hash="parent_hash",
43 token_ids=[1, 2, 3, 4],
44 lora_id=None,
45 block_size=16,
46 medium="GPU",
47 lora_name=None,
48 )
49
50
51 @pytest.fixture
52 def mock_connector():
53 """Create a mock LMCacheConnectorV1 instance with mocked dependencies."""
54 connector = MagicMock(spec=LMCacheConnectorV1)
55 connector._kv_cache_events = None
56 connector._lmcache_engine = MagicMock()
57
58 # Make the methods use the real implementation
59 connector.get_kv_connector_kv_cache_events = (
60 LMCacheConnectorV1.get_kv_connector_kv_cache_events.__get__(
61 connector, LMCacheConnectorV1
62 )
63 )
64 connector.update_connector_output = (
65 LMCacheConnectorV1.update_connector_output.__get__(
66 connector, LMCacheConnectorV1
67 )
68 )
69 connector.take_events = LMCacheConnectorV1.take_events.__get__(
70 connector, LMCacheConnectorV1
71 )
72
73 return connector
74
75
76 class TestGetKVConnectorKVCacheEvents:
77 """Test get_kv_connector_kv_cache_events method."""
78
79 def test_returns_none_when_no_events(self, mock_connector):
80 """Test that None is returned when lmcache engine has no events."""
81 mock_connector._lmcache_engine.get_kv_events.return_value = None
82
83 result = mock_connector.get_kv_connector_kv_cache_events()
84
85 assert result is None
86 mock_connector._lmcache_engine.get_kv_events.assert_called_once()
87
88 def test_returns_none_when_empty_list(self, mock_connector):
89 """Test that None is returned when lmcache engine returns empty list."""
90 mock_connector._lmcache_engine.get_kv_events.return_value = []
91
92 result = mock_connector.get_kv_connector_kv_cache_events()
93
94 assert result is None
95
96 def test_converts_single_event(self, mock_connector, mock_lmcache_engine_event):
97 """Test conversion of a single event from lmcache engine format."""
98 mock_connector._lmcache_engine.get_kv_events.return_value = [
99 mock_lmcache_engine_event
100 ]
101
102 result = mock_connector.get_kv_connector_kv_cache_events()
103
104 assert result is not None
105 assert isinstance(result, LMCacheKVEvents)
106 assert result.get_number_of_workers() == 1
107
108 events = result.get_all_events()
109 assert len(events) == 1
110 assert isinstance(events[0], BlockStored)
111 assert events[0].block_hashes == ["hash1", "hash2"]
112 assert events[0].parent_block_hash == "parent_hash"
113 assert events[0].token_ids == [1, 2, 3, 4]
114 assert events[0].lora_id is None
115 assert events[0].block_size == 16
116 assert events[0].medium == "GPU"
117 assert events[0].lora_name is None
118
119 def test_converts_multiple_events(self, mock_connector):
120 """Test conversion of multiple events from lmcache engine format."""
121
122 class MockEvent:
123 def __init__(self, i):
124 self.block_hashes = [f"hash{i}"]
125 self.parent_block_hash = f"parent{i}"
126 self.token_ids = [i]
127 self.lora_id = None
128 self.block_size = 16
129 self.medium = "GPU"
130 self.lora_name = None
131
132 events = [MockEvent(i) for i in range(5)]
133 mock_connector._lmcache_engine.get_kv_events.return_value = events
134
135 result = mock_connector.get_kv_connector_kv_cache_events()
136
137 assert result is not None
138 assert isinstance(result, LMCacheKVEvents)
139
140 converted_events = result.get_all_events()
141 assert len(converted_events) == 5
142
143 for i, event in enumerate(converted_events):
144 assert isinstance(event, BlockStored)
145 assert event.block_hashes == [f"hash{i}"]
146 assert event.parent_block_hash == f"parent{i}"
147 assert event.token_ids == [i]
148
149 def test_preserves_event_attributes(self, mock_connector):
150 """Test that all event attributes are correctly preserved."""
151
152 class MockEventWithLora:
153 def __init__(self):
154 self.block_hashes = ["hash_a", "hash_b", "hash_c"]
155 self.parent_block_hash = "parent_xyz"
156 self.token_ids = [100, 200, 300]
157 self.lora_id = 42
158 self.block_size = 32
159 self.medium = "DISK"
160 self.lora_name = "lora_example"
161
162 mock_connector._lmcache_engine.get_kv_events.return_value = [
163 MockEventWithLora()
164 ]
165
166 result = mock_connector.get_kv_connector_kv_cache_events()
167
168 events = result.get_all_events()
169 event = events[0]
170
171 assert event.block_hashes == ["hash_a", "hash_b", "hash_c"]
172 assert event.parent_block_hash == "parent_xyz"
173 assert event.token_ids == [100, 200, 300]
174 assert event.lora_id == 42
175 assert event.block_size == 32
176 assert event.medium == "DISK"
177 assert event.lora_name == "lora_example"
178
179 def test_handles_none_parent_block_hash(self, mock_connector):
180 """Test handling of events with None parent_block_hash."""
181
182 class MockEventNoParent:
183 def __init__(self):
184 self.block_hashes = ["hash1"]
185 self.parent_block_hash = None
186 self.token_ids = [1, 2]
187 self.lora_id = None
188 self.block_size = 16
189 self.medium = "GPU"
190 self.lora_name = None
191
192 mock_connector._lmcache_engine.get_kv_events.return_value = [
193 MockEventNoParent()
194 ]
195
196 result = mock_connector.get_kv_connector_kv_cache_events()
197
198 events = result.get_all_events()
199 assert events[0].parent_block_hash is None
200
201
202 class TestUpdateConnectorOutput:
203 """Test update_connector_output method."""
204
205 def test_does_nothing_when_kv_cache_events_is_none(self, mock_connector):
206 """Test that method returns early when kv_cache_events is None."""
207 connector_output = KVConnectorOutput(kv_cache_events=None)
208
209 mock_connector.update_connector_output(connector_output)
210
211 assert mock_connector._kv_cache_events is None
212
213 def test_does_nothing_when_kv_cache_events_is_not_lmcache_kv_events(
214 self, mock_connector
215 ):
216 """Test that method returns early when kv_cache_events is not
217 LMCacheKVEvents."""
218 # Create a mock object that is not LMCacheKVEvents
219 fake_events = MagicMock()
220 connector_output = KVConnectorOutput(kv_cache_events=fake_events)
221
222 mock_connector.update_connector_output(connector_output)
223
224 assert mock_connector._kv_cache_events is None
225
226 def test_sets_kv_cache_events_when_none(self, mock_connector):
227 """Test that _kv_cache_events is set when it was None."""
228 kv_events = LMCacheKVEvents(num_workers=1)
229 event = BlockStored(
230 block_hashes=["hash1"],
231 parent_block_hash=None,
232 token_ids=[1, 2],
233 block_size=16,
234 lora_id=None,
235 medium="GPU",
236 lora_name=None,
237 )
238 kv_events.add_events([event])
239
240 connector_output = KVConnectorOutput(kv_cache_events=kv_events)
241
242 mock_connector.update_connector_output(connector_output)
243
244 assert mock_connector._kv_cache_events is kv_events
245
246 def test_adds_events_when_kv_cache_events_already_exists(self, mock_connector):
247 """Test that events are added when _kv_cache_events already exists."""
248 # Set up existing events
249 existing_events = LMCacheKVEvents(num_workers=2)
250 event1 = BlockStored(
251 block_hashes=["hash1"],
252 parent_block_hash=None,
253 token_ids=[1],
254 block_size=16,
255 lora_id=None,
256 medium="GPU",
257 lora_name=None,
258 )
259 existing_events.add_events([event1])
260 existing_events.add_events([event1]) # Simulate 2 workers reporting
261
262 mock_connector._kv_cache_events = existing_events
263
264 # Create new events to add
265 new_events = LMCacheKVEvents(num_workers=1)
266 event2 = BlockStored(
267 block_hashes=["hash2"],
268 parent_block_hash=None,
269 token_ids=[2],
270 block_size=16,
271 lora_id=None,
272 medium="GPU",
273 lora_name=None,
274 )
275 new_events.add_events([event2])
276
277 connector_output = KVConnectorOutput(kv_cache_events=new_events)
278
279 mock_connector.update_connector_output(connector_output)
280
281 # Check that events were added
282 all_events = mock_connector._kv_cache_events.get_all_events()
283 assert len(all_events) == 3 # 2 from existing + 1 from new
284 assert event1 in all_events
285 assert event2 in all_events
286
287 def test_increments_workers_when_kv_cache_events_already_exists(
288 self, mock_connector
289 ):
290 """Test that worker count is incremented correctly."""
291 # Set up existing events with 2 workers
292 existing_events = LMCacheKVEvents(num_workers=2)
293 mock_connector._kv_cache_events = existing_events
294
295 # Create new events from 3 workers
296 new_events = LMCacheKVEvents(num_workers=3)
297 event = BlockStored(
298 block_hashes=["hash1"],
299 parent_block_hash=None,
300 token_ids=[1],
301 block_size=16,
302 lora_id=None,
303 medium="GPU",
304 lora_name=None,
305 )
306 new_events.add_events([event])
307
308 connector_output = KVConnectorOutput(kv_cache_events=new_events)
309
310 mock_connector.update_connector_output(connector_output)
311
312 # Worker count should be 2 + 3 = 5
313 assert mock_connector._kv_cache_events.get_number_of_workers() == 5
314
315 def test_multiple_updates(self, mock_connector):
316 """Test multiple consecutive updates."""
317 # First update
318 events1 = LMCacheKVEvents(num_workers=1)
319 event1 = BlockStored(
320 block_hashes=["hash1"],
321 parent_block_hash=None,
322 token_ids=[1],
323 block_size=16,
324 lora_id=None,
325 medium="GPU",
326 lora_name=None,
327 )
328 events1.add_events([event1])
329 output1 = KVConnectorOutput(kv_cache_events=events1)
330 mock_connector.update_connector_output(output1)
331
332 # Second update
333 events2 = LMCacheKVEvents(num_workers=2)
334 event2 = BlockStored(
335 block_hashes=["hash2"],
336 parent_block_hash=None,
337 token_ids=[2],
338 block_size=16,
339 lora_id=None,
340 medium="GPU",
341 lora_name=None,
342 )
343 events2.add_events([event2])
344 output2 = KVConnectorOutput(kv_cache_events=events2)
345 mock_connector.update_connector_output(output2)
346
347 # Third update
348 events3 = LMCacheKVEvents(num_workers=1)
349 event3 = BlockStored(
350 block_hashes=["hash3"],
351 parent_block_hash=None,
352 token_ids=[3],
353 block_size=16,
354 lora_id=None,
355 medium="GPU",
356 lora_name=None,
357 )
358 events3.add_events([event3])
359 output3 = KVConnectorOutput(kv_cache_events=events3)
360 mock_connector.update_connector_output(output3)
361
362 # Check final state
363 all_events = mock_connector._kv_cache_events.get_all_events()
364 assert len(all_events) == 3
365 assert mock_connector._kv_cache_events.get_number_of_workers() == 4 # 1+2+1
366
367 def test_updates_with_empty_events(self, mock_connector):
368 """Test updating with empty event lists."""
369 # First update with actual events
370 events1 = LMCacheKVEvents(num_workers=1)
371 event1 = BlockStored(
372 block_hashes=["hash1"],
373 parent_block_hash=None,
374 token_ids=[1],
375 block_size=16,
376 lora_id=None,
377 medium="GPU",
378 lora_name=None,
379 )
380 events1.add_events([event1])
381 output1 = KVConnectorOutput(kv_cache_events=events1)
382 mock_connector.update_connector_output(output1)
383
384 # Second update with empty events
385 events2 = LMCacheKVEvents(num_workers=2)
386 # No events added
387 output2 = KVConnectorOutput(kv_cache_events=events2)
388 mock_connector.update_connector_output(output2)
389
390 # Should still have the original event
391 all_events = mock_connector._kv_cache_events.get_all_events()
392 assert len(all_events) == 1
393 assert mock_connector._kv_cache_events.get_number_of_workers() == 3
394
395
396 class TestTakeEvents:
397 """Test take_events method."""
398
399 def test_yields_nothing_when_kv_cache_events_is_none(self, mock_connector):
400 """Test that nothing is yielded when _kv_cache_events is None."""
401 mock_connector._kv_cache_events = None
402
403 events = list(mock_connector.take_events())
404
405 assert events == []
406
407 def test_yields_events_and_clears(self, mock_connector):
408 """Test that events are yielded and then cleared."""
409 # Set up events
410 kv_events = LMCacheKVEvents(num_workers=1)
411 event1 = BlockStored(
412 block_hashes=["hash1"],
413 parent_block_hash=None,
414 token_ids=[1],
415 block_size=16,
416 lora_id=None,
417 medium="GPU",
418 lora_name=None,
419 )
420 event2 = BlockStored(
421 block_hashes=["hash2"],
422 parent_block_hash=None,
423 token_ids=[2],
424 block_size=16,
425 lora_id=None,
426 medium="GPU",
427 lora_name=None,
428 )
429 kv_events.add_events([event1, event2])
430 mock_connector._kv_cache_events = kv_events
431
432 # Take events
433 events = list(mock_connector.take_events())
434
435 # Check that events were yielded
436 assert len(events) == 2
437 assert event1 in events
438 assert event2 in events
439
440 # Check that _kv_cache_events was cleared
441 assert mock_connector._kv_cache_events is None
442
443 def test_aggregates_before_yielding(self, mock_connector):
444 """Test that events are aggregated before yielding."""
445 # Set up events from multiple workers
446 kv_events = LMCacheKVEvents(num_workers=3)
447 common_event = BlockStored(
448 block_hashes=["hash_common"],
449 parent_block_hash=None,
450 token_ids=[1],
451 block_size=16,
452 lora_id=None,
453 medium="GPU",
454 lora_name=None,
455 )
456 uncommon_event = BlockStored(
457 block_hashes=["hash_uncommon"],
458 parent_block_hash=None,
459 token_ids=[2],
460 block_size=16,
461 lora_id=None,
462 medium="GPU",
463 lora_name=None,
464 )
465
466 # All 3 workers report common_event
467 kv_events.add_events([common_event])
468 kv_events.add_events([common_event])
469 kv_events.add_events([common_event])
470
471 # Only 1 worker reports uncommon_event
472 kv_events.add_events([uncommon_event])
473
474 mock_connector._kv_cache_events = kv_events
475
476 # Take events
477 events = list(mock_connector.take_events())
478
479 # Only the common event should be yielded
480 assert len(events) == 1
481 assert events[0] == common_event
482
483 def test_multiple_take_events_calls(self, mock_connector):
484 """Test calling take_events multiple times."""
485 # First call with events
486 kv_events1 = LMCacheKVEvents(num_workers=1)
487 event1 = BlockStored(
488 block_hashes=["hash1"],
489 parent_block_hash=None,
490 token_ids=[1],
491 block_size=16,
492 lora_id=None,
493 medium="GPU",
494 lora_name=None,
495 )
496 kv_events1.add_events([event1])
497 mock_connector._kv_cache_events = kv_events1
498
499 events1 = list(mock_connector.take_events())
500 assert len(events1) == 1
501 assert events1[0] == event1
502 assert mock_connector._kv_cache_events is None
503
504 # Second call with no events
505 events2 = list(mock_connector.take_events())
506 assert events2 == []
507
508 # Third call after adding new events
509 kv_events2 = LMCacheKVEvents(num_workers=1)
510 event2 = BlockStored(
511 block_hashes=["hash2"],
512 parent_block_hash=None,
513 token_ids=[2],
514 block_size=16,
515 lora_id=None,
516 medium="GPU",
517 lora_name=None,
518 )
519 kv_events2.add_events([event2])
520 mock_connector._kv_cache_events = kv_events2
521
522 events3 = list(mock_connector.take_events())
523 assert len(events3) == 1
524 assert events3[0] == event2
525
526 def test_yields_empty_after_aggregation_removes_all(self, mock_connector):
527 """Test that nothing is yielded if aggregation removes all events."""
528 # Set up events from 2 workers with no common events
529 kv_events = LMCacheKVEvents(num_workers=2)
530 event1 = BlockStored(
531 block_hashes=["hash1"],
532 parent_block_hash=None,
533 token_ids=[1],
534 block_size=16,
535 lora_id=None,
536 medium="GPU",
537 lora_name=None,
538 )
539 event2 = BlockStored(
540 block_hashes=["hash2"],
541 parent_block_hash=None,
542 token_ids=[2],
543 block_size=16,
544 lora_id=None,
545 medium="GPU",
546 lora_name=None,
547 )
548
549 # Worker 1 reports event1
550 kv_events.add_events([event1])
551 # Worker 2 reports event2
552 kv_events.add_events([event2])
553
554 mock_connector._kv_cache_events = kv_events
555
556 # Take events
557 events = list(mock_connector.take_events())
558
559 # No common events, so nothing should be yielded
560 assert events == []
561 assert mock_connector._kv_cache_events is None
562
563
564 class TestIntegrationScenarios:
565 """Test integration scenarios."""
566
567 def test_full_workflow(self, mock_connector, mock_lmcache_engine_event):
568 """Test a complete workflow from getting events to taking them."""
569 # Step 1: Get events from lmcache engine
570 mock_connector._lmcache_engine.get_kv_events.return_value = [
571 mock_lmcache_engine_event
572 ]
573 kv_events = mock_connector.get_kv_connector_kv_cache_events()
574
575 assert kv_events is not None
576 assert len(kv_events.get_all_events()) == 1
577
578 # Step 2: Update connector output (simulate receiving from worker)
579 output1 = KVConnectorOutput(kv_cache_events=kv_events)
580 mock_connector.update_connector_output(output1)
581
582 assert mock_connector._kv_cache_events is not None
583
584 # Step 3: Take events
585 taken_events = list(mock_connector.take_events())
586
587 assert len(taken_events) == 1
588 assert mock_connector._kv_cache_events is None
589
590 def test_multiple_workers_workflow(self, mock_connector):
591 """Test workflow with multiple workers."""
592
593 class MockEvent:
594 def __init__(self, hash_val):
595 self.block_hashes = [hash_val]
596 self.parent_block_hash = None
597 self.token_ids = [1]
598 self.lora_id = None
599 self.block_size = 16
600 self.medium = "GPU"
601 self.lora_name = None
602
603 # Worker 1
604 mock_connector._lmcache_engine.get_kv_events.return_value = [
605 MockEvent("hash_common"),
606 MockEvent("hash_worker1"),
607 ]
608 kv_events1 = mock_connector.get_kv_connector_kv_cache_events()
609 output1 = KVConnectorOutput(kv_cache_events=kv_events1)
610 mock_connector.update_connector_output(output1)
611
612 # Worker 2
613 mock_connector._lmcache_engine.get_kv_events.return_value = [
614 MockEvent("hash_common"),
615 MockEvent("hash_worker2"),
616 ]
617 kv_events2 = mock_connector.get_kv_connector_kv_cache_events()
618 output2 = KVConnectorOutput(kv_cache_events=kv_events2)
619 mock_connector.update_connector_output(output2)
620
621 # Take events (should only get common events)
622 taken_events = list(mock_connector.take_events())
623
624 # With aggregation, only events reported by both workers should be present
625 # In this case, hash_common was reported by both
626 event_hashes = [e.block_hashes[0] for e in taken_events]
627 assert "hash_common" in event_hashes
628
629 def test_empty_workflow(self, mock_connector):
630 """Test workflow when there are no events at any stage."""
631 # Get events returns None
632 mock_connector._lmcache_engine.get_kv_events.return_value = None
633 kv_events = mock_connector.get_kv_connector_kv_cache_events()
634
635 assert kv_events is None
636
637 # Update with None
638 output = KVConnectorOutput(kv_cache_events=None)
639 mock_connector.update_connector_output(output)
640
641 # Take events
642 taken_events = list(mock_connector.take_events())
643
644 assert taken_events == []
645 assert mock_connector._kv_cache_events is None
646
647 def test_repeated_cycles(self, mock_connector):
648 """Test multiple cycles of the complete workflow."""
649
650 class MockEvent:
651 def __init__(self, cycle_num):
652 self.block_hashes = [f"hash_cycle_{cycle_num}"]
653 self.parent_block_hash = None
654 self.token_ids = [cycle_num]
655 self.lora_id = None
656 self.block_size = 16
657 self.medium = "GPU"
658 self.lora_name = None
659
660 for cycle in range(3):
661 # Get events
662 mock_connector._lmcache_engine.get_kv_events.return_value = [
663 MockEvent(cycle)
664 ]
665 kv_events = mock_connector.get_kv_connector_kv_cache_events()
666
667 # Update
668 output = KVConnectorOutput(kv_cache_events=kv_events)
669 mock_connector.update_connector_output(output)
670
671 # Take
672 taken_events = list(mock_connector.take_events())
673
674 # Verify
675 assert len(taken_events) == 1
676 assert taken_events[0].block_hashes[0] == f"hash_cycle_{cycle}"
677 assert mock_connector._kv_cache_events is None
678
679 def test_lmcache_kv_events_aggregation(self):
680 """Test LMCacheKVEvents aggregation across TP ranks using
681 KVOutputAggregator (used by MultiprocExecutor).
682 """
683 from vllm.distributed.kv_transfer.kv_connector.utils import KVOutputAggregator
684 from vllm.v1.outputs import ModelRunnerOutput
685
686 # Create KVOutputAggregator for 3 workers (simulating TP=3)
687 aggregator = KVOutputAggregator(expected_finished_count=3)
688
689 # Define common and unique events
690 common_event = BlockStored(
691 block_hashes=["hash_common"],
692 parent_block_hash="parent_common",
693 token_ids=[1, 2, 3],
694 block_size=16,
695 lora_id=None,
696 medium="GPU",
697 lora_name=None,
698 )
699
700 worker1_unique_event = BlockStored(
701 block_hashes=["hash_worker1"],
702 parent_block_hash="parent_w1",
703 token_ids=[4, 5],
704 block_size=16,
705 lora_id=None,
706 medium="GPU",
707 lora_name=None,
708 )
709
710 worker2_unique_event = BlockStored(
711 block_hashes=["hash_worker2"],
712 parent_block_hash="parent_w2",
713 token_ids=[6, 7],
714 block_size=16,
715 lora_id=None,
716 medium="GPU",
717 lora_name=None,
718 )
719
720 worker3_unique_event = BlockStored(
721 block_hashes=["hash_worker3"],
722 parent_block_hash="parent_w3",
723 token_ids=[8, 9],
724 block_size=16,
725 lora_id=None,
726 medium="GPU",
727 lora_name=None,
728 )
729
730 # Create events for each worker
731 # Worker 0: reports common event and its unique event
732 worker0_events = LMCacheKVEvents(num_workers=1)
733 worker0_events.add_events([common_event, worker1_unique_event])
734
735 # Worker 1: reports common event and its unique event
736 worker1_events = LMCacheKVEvents(num_workers=1)
737 worker1_events.add_events([common_event, worker2_unique_event])
738
739 # Worker 2: reports common event and its unique event
740 worker2_events = LMCacheKVEvents(num_workers=1)
741 worker2_events.add_events([common_event, worker3_unique_event])
742
743 # Create ModelRunnerOutput instances for each worker
744 worker_outputs = []
745 for i, worker_events in enumerate(
746 [worker0_events, worker1_events, worker2_events]
747 ):
748 output = ModelRunnerOutput(
749 req_ids=[f"req_{i}"],
750 req_id_to_index={f"req_{i}": 0},
751 sampled_token_ids=[[123]], # dummy token
752 logprobs=None,
753 prompt_logprobs_dict={},
754 pooler_output=[None],
755 kv_connector_output=KVConnectorOutput(
756 finished_sending=set([f"req_{i}_send"])
757 if i < 2
758 else None, # Workers 0,1 finished sending
759 finished_recving=set([f"req_{i}_recv"])
760 if i > 0
761 else None, # Workers 1,2 finished receiving
762 kv_cache_events=worker_events,
763 ),
764 )
765 worker_outputs.append(output)
766
767 # Use the real aggregation mechanism (like MultiprocExecutor.execute_model)
768 aggregated_output = aggregator.aggregate(worker_outputs, output_rank=0)
769 kv_cache_events = aggregated_output.kv_connector_output.kv_cache_events
770
771 assert isinstance(kv_cache_events, LMCacheKVEvents)
772
773 # After aggregation, events should be combined from all workers
774 # The aggregator doesn't automatically aggregate events, so we need to call
775 # aggregate() to get only common events
776 kv_cache_events.aggregate()
777 aggregated_events = kv_cache_events.get_all_events()
778
779 # Only the common event should remain after aggregation
780 # because it's the only event reported by all 3 workers
781 assert len(aggregated_events) == 1
782 assert aggregated_events[0] == common_event
783
784 # Verify the common event properties
785 assert aggregated_events[0].block_hashes == ["hash_common"]
786 assert aggregated_events[0].parent_block_hash == "parent_common"
787 assert aggregated_events[0].token_ids == [1, 2, 3]
788
789
790 def test_legacy_layerwise_engine_does_not_reuse_storers_on_empty_steps():
791 """Empty steps must not advance generators exhausted by the last forward."""
792
793 class LegacyLayerwiseEngine:
794 layerwise_storers: list[Iterator[None]]
795
796 def save_kv_layer(self, *args, **kwargs):
797 self.layerwise_storers.append(iter([None]))
798
799 def wait_for_save(self):
800 for storer in self.layerwise_storers:
801 next(storer)
802
803 connector = LMCacheConnectorV1.__new__(LMCacheConnectorV1)
804 connector._lmcache_engine = LegacyLayerwiseEngine()
805 metadata = KVConnectorMetadata()
806 connector.bind_connector_metadata(metadata)
807 connector.save_kv_layer("layer", MagicMock(), MagicMock())
808 connector.wait_for_save()
809 connector.clear_connector_metadata()
810
811 for _ in range(2):
812 connector.bind_connector_metadata(metadata)
813 connector.wait_for_save()
814 connector.clear_connector_metadata()
815
816 assert connector._lmcache_engine.layerwise_storers == []