[Bugfix][Multimodal] Fix reference counting of the SHM processor cache (#59695)
Signed-off-by: tenotek <50898317+hdq66666@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
tenotek committed
Oct 2, 2026 at 22:14 UTC
443dccd3c9448f586634bfdb7079acd7eab35ead
5 files changed
+124
-29
tests/distributed/test_shm_storage.py
+87
@@ -291,6 +291,93 @@ class TestSingleWriterShmObjectStorage(unittest.TestCase):
291
reader.touch("key", address, monotonic_id, signature)
292
293
294
+class TestShmObjectStorageReferenceCounting(unittest.TestCase):
295
+ """One writer and four readers, as with tensor-parallel workers."""
296
+
297
+ def setUp(self):
298
+ ring_buffer = SingleWriterShmRingBuffer(
299
+ data_buffer_size=1024 * 100, create=True
300
+ )
301
+ self.writer = SingleWriterShmObjectStorage(
302
+ max_object_size=1024 * 10,
303
+ n_readers=4,
304
+ ring_buffer=ring_buffer,
305
+ serde_class=MsgpackSerde,
306
+ )
307
+ self.addCleanup(self.writer.close)
308
+ handle = self.writer.handle()
309
+ handle.reader_lock = Lock()
310
+ self.readers = [
311
+ SingleWriterShmObjectStorage.create_from_handle(handle) for _ in range(4)
312
+ ]
313
+ for reader in self.readers:
314
+ self.addCleanup(reader.close)
315
+
316
+ def _reference(self, key: str) -> tuple[int, int, list[int], str]:
317
+ """Take one writer reference to the key, as one request item does."""
318
+ if self.writer.is_cached(key):
319
+ address, monotonic_id = self.writer.get_cached(key)
320
+ else:
321
+ address, monotonic_id = self.writer.put(key, "value")
322
+ return address, monotonic_id, self.writer.get_signature(key), key
323
+
324
+ @staticmethod
325
+ def _touch(reader: SingleWriterShmObjectStorage, reference) -> None:
326
+ address, monotonic_id, signature, key = reference
327
+ reader.touch(key, address, monotonic_id, signature)
328
+
329
+ def _is_evictable(self, key: str) -> bool:
330
+ self.writer.free_unused()
331
+ return not self.writer.is_cached(key)
332
+
333
+ def test_item_referenced_again_before_every_reader_fetched_it(self):
334
+ """A second request must not leave the item unevictable forever."""
335
+ first = self._reference("image")
336
+ self.writer.touch("image")
337
+ second = self._reference("image")
338
+ self.writer.release_touches()
339
+
340
+ # Every reader starts on the first request; the ones that finish it
341
+ # go on to the second while the others are still fetching.
342
+ for reader in self.readers:
343
+ self._touch(reader, first)
344
+ for reader in self.readers:
345
+ reader.get(*first)
346
+ self._touch(reader, second)
347
+ reader.get(*second)
348
+
349
+ self.assertTrue(self._is_evictable("image"))
350
+
351
+ def test_new_item_twice_in_one_request_is_not_evicted_early(self):
352
+ """The item must stay until the last reader has fetched it."""
353
+ self.writer.touch("image")
354
+ references = [self._reference("image"), self._reference("image")]
355
+ self.writer.release_touches()
356
+
357
+ for reader in self.readers[:-1]:
358
+ for reference in references:
359
+ self._touch(reader, reference)
360
+ for reference in references:
361
+ reader.get(*reference)
362
+ for reference in references:
363
+ self._touch(self.readers[-1], reference)
364
+ self.assertFalse(self._is_evictable("image"))
365
+
366
+ for reference in references:
367
+ self.readers[-1].get(*reference)
368
+ self.assertTrue(self._is_evictable("image"))
369
+
370
+ def test_touch_protects_item_until_released(self):
371
+ reference = self._reference("image")
372
+ for reader in self.readers:
373
+ reader.get(*reference)
374
+
375
+ self.writer.touch("image")
376
+ self.assertFalse(self._is_evictable("image"))
377
+ self.writer.release_touches()
378
+ self.assertTrue(self._is_evictable("image"))
379
+
380
+
381
# Reader process function
382
def reader_process(process_id, storage_handle, items_to_read):
383
"""Reader process that connects to existing shared memory and reads data."""
vllm/distributed/device_communicators/shm_object_storage.py
+17
-18
@@ -527,6 +527,9 @@ class SingleWriterShmObjectStorage:
527
self.id_index: dict[int, str] = {}
528
# Writer flag to track in-use status: monotonic_id -> count
529
self.writer_flag: dict[int, int] = {}
530
+ # Items touch() keeps from being freed until they are looked up
531
+ # or release_touches() is called
532
+ self._touched: set[int] = set()
533
else:
534
if reader_lock is None:
535
raise ValueError("Lock must be provided for readers.")
@@ -540,6 +543,7 @@ class SingleWriterShmObjectStorage:
543
self.key_index.clear()
544
self.id_index.clear()
545
self.writer_flag.clear()
546
+ self._touched.clear()
547
logger.debug("Object storage cleared and reinitialized.")
548
549
def copy_to_buffer(
@@ -664,8 +668,13 @@ class SingleWriterShmObjectStorage:
668
"""Get the cached object by key if it exists."""
669
address, monotonic_id = self.key_index[key]
670
self.increment_writer_flag(monotonic_id)
671
+ self._touched.discard(monotonic_id)
672
return address, monotonic_id
673
674
+ def release_touches(self) -> None:
675
+ """Stop protecting touched items that were not looked up."""
676
+ self._touched.clear()
677
+
678
def get_signature(self, key: str) -> list[int]:
679
"""Sign the handle of a cached object so readers can verify it."""
680
address, monotonic_id = self.key_index[key]
@@ -756,10 +765,11 @@ class SingleWriterShmObjectStorage:
765
monotonic_id: int = 0,
766
signature: list[int] | None = None,
767
) -> None:
759
- """Touch an existing cached item to update its eviction status.
768
+ """Touch an existing cached item.
769
761
- For writers (ShmObjectStoreSenderCache): Increment writer_flag
762
- For readers (ShmObjectStoreReceiverCache): Increment reader_count
770
+ For writers (ShmObjectStoreSenderCache): Protect the item from
771
+ eviction until get_cached or release_touches
772
+ For readers (ShmObjectStoreReceiverCache): Validate the handle
773
774
Args:
775
key: String key of the object to touch
@@ -771,25 +781,12 @@ class SingleWriterShmObjectStorage:
781
if self._reader_lock is None:
782
if key not in self.key_index:
783
return None
774
- address, monotonic_id = self.key_index[key]
775
- # Writer side: increment writer_flag to raise eviction threshold
776
- self.increment_writer_flag(monotonic_id)
784
+ self._touched.add(self.key_index[key][1])
785
else:
786
# Reject forged handles before dereferencing the supplied address.
787
self.verify_signature(key, address, monotonic_id, signature)
780
- with (
781
- self._reader_lock,
782
- self.ring_buffer.access_buf(address) as (data_view, buf_metadata),
783
- ):
788
+ with self.ring_buffer.access_buf(address) as (_, buf_metadata):
789
self._validate_monotonic_id(address, monotonic_id, buf_metadata)
785
- reader_count = self.ring_buffer.byte2int(data_view[: self.flag_bytes])
786
-
787
- # NOTE(Long):
788
- # Avoid increasing flag on newly added item (sync with sender)
789
- # Since when a new item is added
790
- # pre-touch has no effect on writer side
791
- if reader_count >= self.n_readers:
792
- self.increment_reader_flag(data_view[: self.flag_bytes])
790
791
def close(self) -> None:
792
"""Close the shared memory."""
@@ -823,6 +820,8 @@ class SingleWriterShmObjectStorage:
820
"""Default is_free function that checks if the first 4 bytes are zero.
821
This indicates that the buffer is free.
822
"""
823
+ if id in self._touched:
824
+ return False
825
reader_count = int.from_bytes(buf[0:4], "little", signed=True)
826
writer_count = self.writer_flag[id]
827
return reader_count >= writer_count * self.n_readers
vllm/multimodal/cache/base.py
+4
@@ -377,6 +377,10 @@ class BaseMultiModalProcessorCache(
377
"""
378
raise NotImplementedError
379
380
+ def release_sender_touches(self) -> None:
381
+ """Release the items that were touched but not updated afterwards."""
382
+ return None
383
+
384
@abstractmethod
385
def make_stats(self, *, delta: bool = False) -> CacheInfo:
386
"""Get (and reset) the multi-modal cache stats.
vllm/multimodal/cache/shm.py
+6
-4
@@ -170,10 +170,13 @@ class ShmObjectStoreSenderCache(BaseMultiModalProcessorCache):
170
171
@override
172
def touch_sender_cache_item(self, mm_hash: str) -> None:
173
- """Touch the item in shared memory cache to prevent eviction.
174
- Increments writer_flag on sender side."""
173
+ """Touch the item in shared memory cache to prevent eviction."""
174
self._shm_cache.touch(mm_hash)
175
176
+ @override
177
+ def release_sender_touches(self) -> None:
178
+ self._shm_cache.release_touches()
179
+
180
@override
181
def validate_input_item(
182
self,
@@ -305,8 +308,7 @@ class ShmObjectStoreReceiverCache(BaseMultiModalReceiverCache):
308
mm_hash: str,
309
mm_item: MultiModalKwargsItem | None = None,
310
) -> None:
308
- """Touch the item in shared memory cache to prevent eviction.
309
- Increments reader_count on receiver side."""
311
+ """Validate the item's handle in shared memory cache."""
312
assert mm_item is not None
313
if (handle := _get_shm_handle(mm_item)) is not None:
314
address, monotonic_id, signature = handle
vllm/multimodal/processing/processor.py
+10
-7
@@ -1534,13 +1534,16 @@ class BaseMultiModalProcessor(ABC, Generic[_I]):
1534
)
1535
1536
with timing_ctx.record("merge_mm_kwargs"):
1537
- mm_kwargs, mm_prompt_updates = self._merge_mm_kwargs(
1538
- cache,
1539
- mm_hashes=mm_hashes,
1540
- mm_is_cached=mm_is_cached,
1541
- mm_missing_kwargs=mm_missing_kwargs,
1542
- mm_missing_prompt_updates=mm_missing_prompt_updates,
1543
- )
1537
+ try:
1538
+ mm_kwargs, mm_prompt_updates = self._merge_mm_kwargs(
1539
+ cache,
1540
+ mm_hashes=mm_hashes,
1541
+ mm_is_cached=mm_is_cached,
1542
+ mm_missing_kwargs=mm_missing_kwargs,
1543
+ mm_missing_prompt_updates=mm_missing_prompt_updates,
1544
+ )
1545
+ finally:
1546
+ cache.release_sender_touches()
1547
1548
return MultiModalProcessingResult(
1549
prompt_ids=self._postprocess_prompt(inputs.prompt),