| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import functools |
| 5 | import hashlib |
| 6 | import pickle |
| 7 | import uuid |
| 8 | from collections.abc import Callable, Iterable |
| 9 | |
| 10 | import numpy as np |
| 11 | import torch |
| 12 | from PIL import Image |
| 13 | |
| 14 | from vllm.config.multimodal import MMHasherAlgorithm |
| 15 | from vllm.logger import init_logger |
| 16 | |
| 17 | from .media import MediaWithBytes |
| 18 | |
| 19 | logger = init_logger(__name__) |
| 20 | |
| 21 | # Framing for the digest input. The hash is built by feeding a stream of byte |
| 22 | # chunks to `hasher.update`, so the stream must be uniquely decodable: without |
| 23 | # an explicit length in front of every chunk and a tag in front of every |
| 24 | # container, distinct inputs serialize to identical bytes and share a cache |
| 25 | # entry. See `test_hash_collision_*` in tests/multimodal/test_hasher.py. |
| 26 | _LENGTH_BYTES = 8 |
| 27 | _TAG_NONE = b"\x00" |
| 28 | _TAG_SEQUENCE = b"\x01" |
| 29 | _TAG_MAPPING = b"\x02" |
| 30 | _TAG_LEAF = b"\x03" |
| 31 | |
| 32 | |
| 33 | def _encode_length(value: int) -> bytes: |
| 34 | return value.to_bytes(_LENGTH_BYTES, "little") |
| 35 | |
| 36 | |
| 37 | def _framed(chunk: bytes | memoryview) -> Iterable[bytes | memoryview]: |
| 38 | """Yield *chunk* preceded by its size in bytes.""" |
| 39 | size = chunk.nbytes if isinstance(chunk, memoryview) else len(chunk) |
| 40 | yield _encode_length(size) |
| 41 | yield chunk |
| 42 | |
| 43 | |
| 44 | @functools.lru_cache(maxsize=3) |
| 45 | def _get_hasher_factory( |
| 46 | algorithm: MMHasherAlgorithm, |
| 47 | ) -> Callable[[], "hashlib._Hash"]: |
| 48 | """Get the hasher factory based on the configured algorithm. |
| 49 | |
| 50 | Args: |
| 51 | algorithm: Hash algorithm name (blake3, sha256, or sha512) |
| 52 | |
| 53 | Returns a callable that creates a new hasher instance. |
| 54 | Supports blake3 (default), sha256, and sha512 for FIPS compliance. |
| 55 | |
| 56 | See: https://github.com/vllm-project/vllm/issues/18334 |
| 57 | |
| 58 | """ |
| 59 | if algorithm == "blake3": |
| 60 | from blake3 import blake3 |
| 61 | |
| 62 | return blake3 |
| 63 | elif algorithm == "sha256": |
| 64 | return hashlib.sha256 |
| 65 | elif algorithm == "sha512": |
| 66 | return hashlib.sha512 |
| 67 | else: |
| 68 | # This should never happen due to config validation |
| 69 | raise ValueError(f"Unsupported hash algorithm: {algorithm}") |
| 70 | |
| 71 | |
| 72 | def _get_image_id_bytes(image: Image.Image) -> bytes | None: |
| 73 | try: |
| 74 | exif = image.getexif() |
| 75 | image_id = exif.get(Image.ExifTags.Base.ImageID) |
| 76 | if isinstance(image_id, uuid.UUID): |
| 77 | return image_id.bytes |
| 78 | except Exception: |
| 79 | # Tolerate malformed EXIF metadata (e.g. invalid TIFF header) |
| 80 | # and fall back to serializing raw image data or bytes. |
| 81 | pass |
| 82 | return None |
| 83 | |
| 84 | |
| 85 | class MultiModalHasher: |
| 86 | """Derives multi-modal cache keys. |
| 87 | |
| 88 | Every method here yields *framed* chunks: each chunk is preceded by its |
| 89 | length and each container by its kind, so that the concatenation fed to the |
| 90 | digest is uniquely decodable and distinct inputs cannot share a key. |
| 91 | """ |
| 92 | |
| 93 | @classmethod |
| 94 | def serialize_item(cls, obj: object) -> Iterable[bytes | memoryview]: |
| 95 | # Simple cases |
| 96 | if isinstance(obj, (bytes, memoryview)): |
| 97 | return _framed(obj) |
| 98 | if isinstance(obj, str): |
| 99 | return _framed(obj.encode("utf-8")) |
| 100 | if isinstance(obj, (int, float)): |
| 101 | return _framed(np.array(obj).tobytes()) |
| 102 | |
| 103 | if isinstance(obj, Image.Image): |
| 104 | image_id = _get_image_id_bytes(obj) |
| 105 | if image_id is not None: |
| 106 | return _framed(image_id) |
| 107 | |
| 108 | data = {"mode": obj.mode, "data": np.asarray(obj)} |
| 109 | palette = obj.palette |
| 110 | if palette is not None: |
| 111 | data["palette"] = palette.palette |
| 112 | if palette.rawmode is not None: |
| 113 | data["palette_rawmode"] = palette.rawmode |
| 114 | |
| 115 | return cls.iter_item_to_bytes("image", data) |
| 116 | |
| 117 | if isinstance(obj, MediaWithBytes) and isinstance(obj.media, Image.Image): |
| 118 | image_id = _get_image_id_bytes(obj.media) |
| 119 | if image_id is not None: |
| 120 | return _framed(image_id) |
| 121 | |
| 122 | if obj.io_config: |
| 123 | return cls.iter_item_to_bytes( |
| 124 | "image", |
| 125 | {"io_config": obj.io_config, "data": obj.original_bytes}, |
| 126 | ) |
| 127 | return cls.iter_item_to_bytes("image", obj.original_bytes) |
| 128 | |
| 129 | if isinstance(obj, MediaWithBytes) and isinstance( |
| 130 | obj.media, (np.ndarray, torch.Tensor) |
| 131 | ): |
| 132 | frames = obj.media |
| 133 | # Both np.ndarray and torch.Tensor expose .nbytes. |
| 134 | if frames.nbytes < len(obj.original_bytes): |
| 135 | return cls.iter_item_to_bytes("video", frames) |
| 136 | return cls.iter_item_to_bytes("video", obj.original_bytes) |
| 137 | |
| 138 | if isinstance(obj, torch.Tensor): |
| 139 | tensor_obj: torch.Tensor = obj.cpu() |
| 140 | tensor_dtype = tensor_obj.dtype |
| 141 | tensor_shape = tensor_obj.shape |
| 142 | |
| 143 | # NumPy does not support bfloat16. |
| 144 | # Workaround: View the tensor as a contiguous 1D array of bytes |
| 145 | if tensor_dtype == torch.bfloat16: |
| 146 | tensor_obj = tensor_obj.contiguous() |
| 147 | tensor_obj = tensor_obj.view((tensor_obj.numel(),)).view(torch.uint8) |
| 148 | |
| 149 | return cls.iter_item_to_bytes( |
| 150 | "tensor", |
| 151 | { |
| 152 | "original_dtype": str(tensor_dtype), |
| 153 | "original_shape": tuple(tensor_shape), |
| 154 | "data": tensor_obj.numpy(), |
| 155 | }, |
| 156 | ) |
| 157 | |
| 158 | return cls.iter_item_to_bytes("tensor", tensor_obj.numpy()) |
| 159 | |
| 160 | if isinstance(obj, np.ndarray): |
| 161 | if obj.ndim == 0: |
| 162 | arr_data = obj.item() |
| 163 | elif obj.flags.c_contiguous: |
| 164 | # Not valid for 0-D arrays |
| 165 | arr_data = obj.view(np.uint8).data |
| 166 | else: |
| 167 | # If the array is non-contiguous, we need to copy it first |
| 168 | arr_data = obj.tobytes() |
| 169 | |
| 170 | return cls.iter_item_to_bytes( |
| 171 | "ndarray", |
| 172 | { |
| 173 | "dtype": obj.dtype.str, |
| 174 | "shape": obj.shape, |
| 175 | "data": arr_data, |
| 176 | }, |
| 177 | ) |
| 178 | |
| 179 | logger.warning( |
| 180 | "No serialization method found for %s. Falling back to pickle.", type(obj) |
| 181 | ) |
| 182 | |
| 183 | return _framed(pickle.dumps(obj)) |
| 184 | |
| 185 | @classmethod |
| 186 | def iter_item_to_bytes( |
| 187 | cls, |
| 188 | key: str, |
| 189 | obj: object, |
| 190 | ) -> Iterable[bytes | memoryview]: |
| 191 | """Yield the digest input for a single ``key``/``obj`` pair.""" |
| 192 | yield from _framed(key.encode("utf-8")) |
| 193 | yield from cls.iter_value_to_bytes(obj) |
| 194 | |
| 195 | @classmethod |
| 196 | def iter_value_to_bytes( |
| 197 | cls, |
| 198 | obj: object, |
| 199 | ) -> Iterable[bytes | memoryview]: |
| 200 | """Yield the digest input for a value, tagged by its container kind. |
| 201 | |
| 202 | Containers carry their kind and length so that a nested structure can |
| 203 | never serialize to the same bytes as a differently shaped one (a list |
| 204 | and a mapping keyed by stringified indices, for example). |
| 205 | """ |
| 206 | if obj is None: |
| 207 | yield _TAG_NONE |
| 208 | elif isinstance(obj, (list, tuple)): |
| 209 | yield _TAG_SEQUENCE |
| 210 | yield _encode_length(len(obj)) |
| 211 | for elem in obj: |
| 212 | yield from cls.iter_value_to_bytes(elem) |
| 213 | elif isinstance(obj, dict): |
| 214 | yield _TAG_MAPPING |
| 215 | yield _encode_length(len(obj)) |
| 216 | for k, v in obj.items(): |
| 217 | yield from _framed(str(k).encode("utf-8")) |
| 218 | yield from cls.iter_value_to_bytes(v) |
| 219 | else: |
| 220 | yield _TAG_LEAF |
| 221 | yield from cls.serialize_item(obj) |
| 222 | |
| 223 | @classmethod |
| 224 | def hash_kwargs( |
| 225 | cls, |
| 226 | algorithm: MMHasherAlgorithm, |
| 227 | /, |
| 228 | **kwargs: object, |
| 229 | ) -> str: |
| 230 | hasher_factory = _get_hasher_factory(algorithm) |
| 231 | hasher = hasher_factory() |
| 232 | |
| 233 | for k, v in sorted(kwargs.items(), key=lambda kv: kv[0]): |
| 234 | for bytes_ in cls.iter_item_to_bytes(k, v): |
| 235 | hasher.update(bytes_) |
| 236 | |
| 237 | return hasher.hexdigest() |