A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 5, 19:45 UTC
[Core][CI] Add opt-in media URL caching via VLLM_MEDIA_CACHE (#37123)
Signed-off-by: Andreas Karatzas <akaratza@amd.com>
Andreas Karatzas committed
Mar 30, 2026 at 06:58 UTC
677424c7acd9fb7477294017c99f798588002d4f
3 files changed
+266
tests/multimodal/media/test_connector.py
+112
@@ -4,6 +4,8 @@
4
import asyncio
5
import mimetypes
6
import os
7
+import shutil
8
+import time
9
from tempfile import NamedTemporaryFile, TemporaryDirectory
10
11
import aiohttp
@@ -375,3 +377,113 @@ async def test_ssrf_bypass_backslash_disallowed_domain():
377
378
with pytest.raises(ValueError, match="allowed domains"):
379
await connector.fetch_image_async(bypass_url)
380
+
381
+
382
+def _make_cached_connector(cache_dir, *, max_mb=10, ttl_hours=24):
383
+ """Create a MediaConnector with caching enabled via monkeypatched internals.
384
+
385
+ We bypass __init__'s env-var path and wire up the cache fields directly
386
+ so tests don't depend on environment variables. URLs in these tests are
387
+ only used as cache keys (hashed to derive filenames); no HTTP requests
388
+ are made.
389
+ """
390
+ connector = MediaConnector()
391
+ connector._media_cache_dir = cache_dir
392
+ connector._media_cache_max_bytes = max_mb * 1024 * 1024
393
+ connector._media_cache_ttl_secs = ttl_hours * 3600
394
+ return connector
395
+
396
+
397
+def test_cache_put_and_get():
398
+ """Basic round-trip: put bytes, get them back."""
399
+ with TemporaryDirectory() as cache_dir:
400
+ connector = _make_cached_connector(cache_dir)
401
+ url = "https://example.com/image.png"
402
+ data = b"fake-image-bytes"
403
+
404
+ connector._put_cached_bytes(url, data)
405
+ cached = connector._get_cached_bytes(url)
406
+ assert cached == data
407
+
408
+
409
+def test_cache_ttl_expiry():
410
+ """Entries older than TTL are evicted on read."""
411
+ with TemporaryDirectory() as cache_dir:
412
+ connector = _make_cached_connector(cache_dir, ttl_hours=24)
413
+ url = "https://example.com/old.png"
414
+ data = b"old-data"
415
+
416
+ connector._put_cached_bytes(url, data)
417
+
418
+ # Backdate the file's mtime so it appears expired
419
+ cache_path = connector._media_cache_path(url)
420
+ expired_time = time.time() - (25 * 3600) # 25 hours ago
421
+ os.utime(cache_path, (expired_time, expired_time))
422
+
423
+ assert connector._get_cached_bytes(url) is None
424
+ assert not cache_path.exists()
425
+
426
+
427
+def test_cache_lru_eviction():
428
+ """Oldest entries are evicted when cache exceeds size budget."""
429
+ with TemporaryDirectory() as cache_dir:
430
+ # Set a very small max size: 100 bytes
431
+ connector = _make_cached_connector(cache_dir, max_mb=0)
432
+ connector._media_cache_max_bytes = 100
433
+
434
+ # Write three 50-byte entries (total 150 > 100 budget)
435
+ urls = [f"https://example.com/{i}.png" for i in range(3)]
436
+ for i, url in enumerate(urls):
437
+ connector._put_cached_bytes(url, b"x" * 50)
438
+ # Stagger mtime so eviction order is deterministic
439
+ path = connector._media_cache_path(url)
440
+ os.utime(path, (time.time() + i, time.time() + i))
441
+
442
+ # The oldest entry (urls[0]) should have been evicted
443
+ assert connector._get_cached_bytes(urls[0]) is None
444
+ # The newest entries should still be present
445
+ assert connector._get_cached_bytes(urls[2]) == b"x" * 50
446
+
447
+
448
+def test_cache_ttl_eviction_during_write():
449
+ """_maybe_evict removes expired files even if under size budget."""
450
+ with TemporaryDirectory() as cache_dir:
451
+ connector = _make_cached_connector(cache_dir, ttl_hours=1)
452
+ url_old = "https://example.com/stale.png"
453
+ url_new = "https://example.com/fresh.png"
454
+
455
+ connector._put_cached_bytes(url_old, b"stale")
456
+ # Backdate old entry past TTL
457
+ old_path = connector._media_cache_path(url_old)
458
+ expired_time = time.time() - (2 * 3600)
459
+ os.utime(old_path, (expired_time, expired_time))
460
+
461
+ # Writing a new entry triggers _maybe_evict
462
+ connector._put_cached_bytes(url_new, b"fresh")
463
+
464
+ assert not old_path.exists()
465
+ assert connector._get_cached_bytes(url_new) == b"fresh"
466
+
467
+
468
+def test_put_cached_bytes_missing_dir():
469
+ """_put_cached_bytes does not crash when the cache dir disappears."""
470
+ with TemporaryDirectory() as cache_dir:
471
+ connector = _make_cached_connector(cache_dir)
472
+ # Remove the directory to simulate it disappearing at runtime
473
+ shutil.rmtree(cache_dir)
474
+
475
+ # Should not raise (graceful degradation)
476
+ connector._put_cached_bytes("https://example.com/x.png", b"data")
477
+
478
+
479
+def test_get_cached_bytes_file_deleted_before_read():
480
+ """_get_cached_bytes returns None if the file vanishes mid-read."""
481
+ with TemporaryDirectory() as cache_dir:
482
+ connector = _make_cached_connector(cache_dir)
483
+ url = "https://example.com/vanish.png"
484
+
485
+ connector._put_cached_bytes(url, b"data")
486
+ # Delete the file to simulate concurrent eviction
487
+ connector._media_cache_path(url).unlink()
488
+
489
+ assert connector._get_cached_bytes(url) is None
vllm/envs.py
+19
@@ -64,6 +64,9 @@ if TYPE_CHECKING:
64
VLLM_IMAGE_FETCH_TIMEOUT: int = 5
65
VLLM_VIDEO_FETCH_TIMEOUT: int = 30
66
VLLM_AUDIO_FETCH_TIMEOUT: int = 10
67
+ VLLM_MEDIA_CACHE: str = ""
68
+ VLLM_MEDIA_CACHE_MAX_SIZE_MB: int = 5120
69
+ VLLM_MEDIA_CACHE_TTL_HOURS: float = 24
70
VLLM_MEDIA_FETCH_MAX_RETRIES: int = 3
71
VLLM_MEDIA_URL_ALLOW_REDIRECTS: bool = True
72
VLLM_MEDIA_LOADING_THREAD_COUNT: int = 8
@@ -776,6 +779,19 @@ environment_variables: dict[str, Callable[[], Any]] = {
779
"VLLM_AUDIO_FETCH_TIMEOUT": lambda: int(
780
os.getenv("VLLM_AUDIO_FETCH_TIMEOUT", "10")
781
),
782
+ # Directory for caching media downloads (images, video, audio fetched
783
+ # from URLs during inference). Empty string disables caching.
784
+ "VLLM_MEDIA_CACHE": lambda: os.getenv("VLLM_MEDIA_CACHE", ""),
785
+ # Maximum cache size in MB. When exceeded, least-recently-used entries
786
+ # are evicted. Default is 5120 (5 GB).
787
+ "VLLM_MEDIA_CACHE_MAX_SIZE_MB": lambda: int(
788
+ os.getenv("VLLM_MEDIA_CACHE_MAX_SIZE_MB", "5120")
789
+ ),
790
+ # Time-to-live in hours for cached media files. Entries older than this
791
+ # are evicted regardless of cache size. Default is 24 hours.
792
+ "VLLM_MEDIA_CACHE_TTL_HOURS": lambda: float(
793
+ os.getenv("VLLM_MEDIA_CACHE_TTL_HOURS", "24")
794
+ ),
795
# Maximum number of retries for fetching media (images, audio, video)
796
# from URLs. Each retry quadruples the timeout. Default is 3.
797
"VLLM_MEDIA_FETCH_MAX_RETRIES": lambda: int(
@@ -1777,6 +1793,9 @@ def compile_factors() -> dict[str, object]:
1793
"VLLM_IMAGE_FETCH_TIMEOUT",
1794
"VLLM_VIDEO_FETCH_TIMEOUT",
1795
"VLLM_AUDIO_FETCH_TIMEOUT",
1796
+ "VLLM_MEDIA_CACHE",
1797
+ "VLLM_MEDIA_CACHE_MAX_SIZE_MB",
1798
+ "VLLM_MEDIA_CACHE_TTL_HOURS",
1799
"VLLM_MEDIA_FETCH_MAX_RETRIES",
1800
"VLLM_MEDIA_URL_ALLOW_REDIRECTS",
1801
"VLLM_MEDIA_LOADING_THREAD_COUNT",
vllm/multimodal/media/connector.py
+135
@@ -3,6 +3,11 @@
3
4
import asyncio
5
import atexit
6
+import contextlib
7
+import hashlib
8
+import os
9
+import tempfile
10
+import time
11
from concurrent.futures import ThreadPoolExecutor
12
from pathlib import Path
13
from typing import Any, TypeVar
@@ -16,6 +21,7 @@ from urllib3.util import Url, parse_url
21
22
import vllm.envs as envs
23
from vllm.connections import HTTPConnection, global_http_connection
24
+from vllm.logger import init_logger
25
from vllm.utils.registry import ExtensionManager
26
27
from .audio import AudioEmbeddingMediaIO, AudioMediaIO
@@ -23,6 +29,8 @@ from .base import MediaIO
29
from .image import ImageEmbeddingMediaIO, ImageMediaIO
30
from .video import VideoMediaIO
31
32
+logger = init_logger(__name__)
33
+
34
_M = TypeVar("_M")
35
36
global_thread_pool = ThreadPoolExecutor(
@@ -116,6 +124,115 @@ class MediaConnector:
124
allowed_media_domains = []
125
self.allowed_media_domains = allowed_media_domains
126
127
+ # Media download cache (opt-in via VLLM_MEDIA_CACHE)
128
+ self._media_cache_dir: str | None = None
129
+ self._media_cache_max_bytes: int = 0
130
+ self._media_cache_ttl_secs: float = 0
131
+ media_cache = envs.VLLM_MEDIA_CACHE
132
+ if media_cache:
133
+ try:
134
+ os.makedirs(media_cache, exist_ok=True)
135
+ # Verify the directory is writable before enabling caching
136
+ with tempfile.NamedTemporaryFile(dir=media_cache, delete=True):
137
+ pass
138
+ self._media_cache_dir = media_cache
139
+ self._media_cache_max_bytes = (
140
+ envs.VLLM_MEDIA_CACHE_MAX_SIZE_MB * 1024 * 1024
141
+ )
142
+ self._media_cache_ttl_secs = envs.VLLM_MEDIA_CACHE_TTL_HOURS * 3600
143
+ logger.info(
144
+ "Media cache enabled at %s (max %d MB, TTL %s hours)",
145
+ media_cache,
146
+ envs.VLLM_MEDIA_CACHE_MAX_SIZE_MB,
147
+ envs.VLLM_MEDIA_CACHE_TTL_HOURS,
148
+ )
149
+ except OSError:
150
+ logger.warning(
151
+ "VLLM_MEDIA_CACHE path %s is not writable, media caching disabled",
152
+ media_cache,
153
+ )
154
+
155
+ def _get_cached_bytes(self, url: str) -> bytes | None:
156
+ """Return cached bytes for a URL, or None if not cached/expired."""
157
+ if not self._media_cache_dir:
158
+ return None
159
+ cache_path = self._media_cache_path(url)
160
+ # Check TTL
161
+ try:
162
+ age = time.time() - cache_path.stat().st_mtime
163
+ except OSError:
164
+ return None
165
+ if age > self._media_cache_ttl_secs:
166
+ cache_path.unlink(missing_ok=True)
167
+ return None
168
+ # Touch mtime for LRU ordering
169
+ try:
170
+ cache_path.touch()
171
+ return cache_path.read_bytes()
172
+ except OSError:
173
+ return None
174
+
175
+ def _put_cached_bytes(self, url: str, data: bytes) -> None:
176
+ """Store downloaded bytes and evict if over budget."""
177
+ if not self._media_cache_dir:
178
+ return
179
+ cache_path = self._media_cache_path(url)
180
+ # Atomic write via temp file + rename
181
+ tmp_path = None
182
+ try:
183
+ with tempfile.NamedTemporaryFile(
184
+ mode="wb", dir=self._media_cache_dir, delete=False
185
+ ) as tmp_file:
186
+ tmp_file.write(data)
187
+ tmp_path = tmp_file.name
188
+ os.rename(tmp_path, str(cache_path))
189
+ except OSError:
190
+ # Another process beat us or disk issue
191
+ if tmp_path is not None:
192
+ with contextlib.suppress(OSError):
193
+ os.remove(tmp_path)
194
+ return
195
+ self._maybe_evict(exclude=cache_path)
196
+
197
+ def _maybe_evict(self, exclude: Path | None = None) -> None:
198
+ """Evict expired entries first, then LRU until under size limit."""
199
+ cache_dir = Path(self._media_cache_dir) # type: ignore[arg-type]
200
+ entries = []
201
+ expired = []
202
+ total_size = 0
203
+ now = time.time()
204
+ for f in cache_dir.iterdir():
205
+ if f.name.startswith("."):
206
+ continue
207
+ try:
208
+ stat = f.stat()
209
+ except OSError:
210
+ continue
211
+ age = now - stat.st_mtime
212
+ if age > self._media_cache_ttl_secs:
213
+ expired.append(f)
214
+ continue
215
+ total_size += stat.st_size
216
+ # Never evict the file we just wrote
217
+ if exclude is not None and f.name == exclude.name:
218
+ continue
219
+ entries.append((stat.st_mtime, stat.st_size, f))
220
+
221
+ # Evict items according to LRU policy
222
+ entries.sort(key=lambda e: e[0], reverse=True)
223
+ while total_size > self._media_cache_max_bytes and entries:
224
+ mtime, size, f = entries.pop()
225
+ expired.append(f)
226
+ total_size -= size
227
+
228
+ for f in expired:
229
+ f.unlink(missing_ok=True)
230
+
231
+ def _media_cache_path(self, url: str) -> Path:
232
+ url_hash = hashlib.sha256(url.encode()).hexdigest()[:20]
233
+ ext = Path(url.split("?", 1)[0]).suffix or ""
234
+ return Path(self._media_cache_dir) / f"{url_hash}{ext}" # type: ignore[arg-type]
235
+
236
def _load_data_url(
237
self,
238
url_spec: Url,
@@ -178,6 +295,10 @@ class MediaConnector:
295
if url_spec.scheme and url_spec.scheme.startswith("http"):
296
self._assert_url_in_allowed_media_domains(url_spec)
297
298
+ cached = self._get_cached_bytes(url)
299
+ if cached is not None:
300
+ return media_io.load_bytes(cached)
301
+
302
connection = self.connection
303
data = connection.get_bytes(
304
url_spec.url,
@@ -185,6 +306,7 @@ class MediaConnector:
306
allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
307
)
308
309
+ self._put_cached_bytes(url, data)
310
return media_io.load_bytes(data)
311
312
if url_spec.scheme == "data":
@@ -209,12 +331,25 @@ class MediaConnector:
331
if url_spec.scheme and url_spec.scheme.startswith("http"):
332
self._assert_url_in_allowed_media_domains(url_spec)
333
334
+ cached = await loop.run_in_executor(
335
+ global_thread_pool, self._get_cached_bytes, url
336
+ )
337
+ if cached is not None:
338
+ future = loop.run_in_executor(
339
+ global_thread_pool, media_io.load_bytes, cached
340
+ )
341
+ return await future
342
+
343
connection = self.connection
344
data = await connection.async_get_bytes(
345
url_spec.url,
346
timeout=fetch_timeout,
347
allow_redirects=envs.VLLM_MEDIA_URL_ALLOW_REDIRECTS,
348
)
349
+
350
+ await loop.run_in_executor(
351
+ global_thread_pool, self._put_cached_bytes, url, data
352
+ )
353
future = loop.run_in_executor(global_thread_pool, media_io.load_bytes, data)
354
return await future
355