vlm: cache kimi-k3 per-image processor artifacts (#34404)
This commit is contained in:
@@ -33,6 +33,7 @@ from sglang.srt.environ import envs
|
||||
from sglang.srt.managers.io_struct import GenerateReqInput, TokenizedGenerateReqInput
|
||||
from sglang.srt.managers.multimodal_processor import get_mm_processor, import_processors
|
||||
from sglang.srt.managers.schedule_batch import Modality, Req
|
||||
from sglang.srt.multimodal.cache import media_preprocess_kwargs
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.srt.utils import ImageData
|
||||
from sglang.srt.utils.common import safe_pickle_loads
|
||||
@@ -725,6 +726,16 @@ def extract_original_req_id(part_req_id: str) -> str:
|
||||
return part_req_id
|
||||
|
||||
|
||||
def _encoder_media_item(mm_item: dict):
|
||||
"""Keep per-media options aligned while preserving the legacy URL shape."""
|
||||
item = {
|
||||
key: value
|
||||
for key, value in mm_item.items()
|
||||
if key != "modality" and value is not None
|
||||
}
|
||||
return item["url"] if set(item) == {"url"} else item
|
||||
|
||||
|
||||
def calculate_modality_num_parts(modalities, num_items_assigned):
|
||||
"""
|
||||
Calculate total number of parts and number of parts per modality.
|
||||
@@ -1102,7 +1113,7 @@ class WaitingImageRDMARequest(WaitingImageRequest):
|
||||
{
|
||||
"encoder_idx": idx,
|
||||
"mm_items": [
|
||||
d["url"]
|
||||
_encoder_media_item(d)
|
||||
for d in mm_data_modality[
|
||||
cum_num_items : cum_num_items + assigned_num
|
||||
]
|
||||
@@ -2171,7 +2182,7 @@ class MMReceiverBase(ABC):
|
||||
|
||||
return num_items_assigned
|
||||
|
||||
def _extract_url_data(self, request_obj) -> List[Dict]:
|
||||
def _extract_url_data(self, request_obj: GenerateReqInput) -> List[Dict]:
|
||||
def flatten_mm_items(items):
|
||||
if not isinstance(items, list):
|
||||
return [items]
|
||||
@@ -2193,21 +2204,47 @@ class MMReceiverBase(ABC):
|
||||
return mm_item
|
||||
|
||||
mm_data = []
|
||||
for attr, modality in [
|
||||
("image_data", Modality.IMAGE),
|
||||
("video_data", Modality.VIDEO),
|
||||
("audio_data", Modality.AUDIO),
|
||||
image_hashes = request_obj.mm_content_hashes
|
||||
image_index = 0
|
||||
for mm_items, modality in [
|
||||
(request_obj.image_data, Modality.IMAGE),
|
||||
(request_obj.video_data, Modality.VIDEO),
|
||||
(request_obj.audio_data, Modality.AUDIO),
|
||||
]:
|
||||
mm_items = getattr(request_obj, attr, None)
|
||||
if mm_items:
|
||||
mm_items = flatten_mm_items(mm_items)
|
||||
for mm_item in mm_items:
|
||||
mm_data.append(
|
||||
{
|
||||
"url": to_raw_url(mm_item),
|
||||
"modality": modality,
|
||||
}
|
||||
entry = {
|
||||
"url": to_raw_url(mm_item),
|
||||
"modality": modality,
|
||||
}
|
||||
entry.update(
|
||||
media_preprocess_kwargs(mm_item, defaults={"detail": "auto"})
|
||||
)
|
||||
if modality == Modality.IMAGE:
|
||||
inline_hash = (
|
||||
mm_item.content_hash
|
||||
if isinstance(mm_item, ImageData)
|
||||
else (
|
||||
mm_item.get("content_hash")
|
||||
if isinstance(mm_item, dict)
|
||||
else None
|
||||
)
|
||||
)
|
||||
explicit_hash = (
|
||||
image_hashes[image_index]
|
||||
if image_hashes is not None
|
||||
and image_index < len(image_hashes)
|
||||
else None
|
||||
)
|
||||
entry["content_hash"] = explicit_hash or inline_hash
|
||||
image_index += 1
|
||||
mm_data.append(entry)
|
||||
if image_hashes is not None and image_index != len(image_hashes):
|
||||
raise ValueError(
|
||||
f"mm_content_hashes has {len(image_hashes)} entries for "
|
||||
f"{image_index} images"
|
||||
)
|
||||
return mm_data
|
||||
|
||||
|
||||
@@ -2329,7 +2366,7 @@ class MMReceiverHTTP(MMReceiverBase):
|
||||
"encoder_idx": idx,
|
||||
"encoder_url": effective_urls[idx],
|
||||
"mm_items": [
|
||||
mm_item.get("url")
|
||||
_encoder_media_item(mm_item)
|
||||
for mm_item in mm_data_modality[
|
||||
cum_num_items : cum_num_items + assigned_num
|
||||
]
|
||||
|
||||
@@ -58,10 +58,12 @@ from sglang.srt.model_executor.model_runner_components.load_model_utils import (
|
||||
maybe_precompile_model_kernels_after_loading,
|
||||
)
|
||||
from sglang.srt.model_loader import get_model
|
||||
from sglang.srt.multimodal.cache import parse_content_hash, snapshot_media
|
||||
from sglang.srt.multimodal.encoder_preprocessing import (
|
||||
EncoderPreprocessOutput,
|
||||
get_encoder_preprocessed_items,
|
||||
invoke_encoder_preprocessor,
|
||||
resolve_encoder_media_processor_config,
|
||||
)
|
||||
from sglang.srt.multimodal.processors.qwen_vl import preprocess_video
|
||||
from sglang.srt.observability.metrics_collector import EncoderMetricsCollector
|
||||
@@ -369,6 +371,9 @@ class MMEncoder:
|
||||
load_config=self.load_config,
|
||||
device_config=self.device_config,
|
||||
)
|
||||
self.encoder_media_processor_config = resolve_encoder_media_processor_config(
|
||||
self.model
|
||||
)
|
||||
maybe_precompile_model_kernels_after_loading(self.model, self.device)
|
||||
|
||||
self.context = zmq.asyncio.Context(2)
|
||||
@@ -670,13 +675,27 @@ class MMEncoder:
|
||||
Load a single multimodal data.
|
||||
If data is precomputed, returns directly.
|
||||
Static method that can be pickled for multiprocessing"""
|
||||
media_metadata = {}
|
||||
content_hash = None
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
if "url" not in data:
|
||||
return data
|
||||
media_metadata = {key: value for key, value in data.items() if key != "url"}
|
||||
content_hash = parse_content_hash(data.get("content_hash"))
|
||||
data = data["url"]
|
||||
try:
|
||||
if modality == Modality.IMAGE:
|
||||
if content_hash is not None:
|
||||
snapshot = snapshot_media(data)
|
||||
if snapshot.content_digest != content_hash:
|
||||
raise BadRequestError(
|
||||
"Encoder media content hash mismatch: "
|
||||
f"expected {content_hash}, got {snapshot.content_digest}"
|
||||
)
|
||||
data = snapshot.data
|
||||
gpu_image_decode = (
|
||||
"nvjpeg_fancy"
|
||||
if self.use_image_processor_gpu and self.model_type == "kimi_k3"
|
||||
self.encoder_media_processor_config.image_decode_mode
|
||||
if self.use_image_processor_gpu
|
||||
else False
|
||||
)
|
||||
img, _ = load_image(data, gpu_image_decode)
|
||||
@@ -687,12 +706,23 @@ class MMEncoder:
|
||||
):
|
||||
# Needed only when `img` is a PIL image
|
||||
img = img.convert("RGB")
|
||||
if (
|
||||
media_metadata
|
||||
and self.encoder_media_processor_config.preserve_media_metadata
|
||||
):
|
||||
return {
|
||||
"type": "image",
|
||||
"image": img,
|
||||
**media_metadata,
|
||||
}
|
||||
return img
|
||||
elif modality == Modality.VIDEO:
|
||||
return load_video(data, frame_count_limit)
|
||||
elif modality == Modality.AUDIO:
|
||||
return load_audio(data, self.model_audio_sr)
|
||||
|
||||
except MMError:
|
||||
raise
|
||||
except CLIENT_MEDIA_EXCEPTIONS as e:
|
||||
# Not ValueError: the DP envelope classifies by `.code`, which only MMError carries.
|
||||
raise BadRequestError(f"Error while loading data {data}: {e}") from e
|
||||
|
||||
@@ -378,21 +378,13 @@ class MultimodalDataItem:
|
||||
if self.pad_value is not None:
|
||||
return
|
||||
|
||||
from sglang.srt.managers.mm_utils import hash_feature
|
||||
from sglang.srt.multimodal.cache import resolve_multimodal_item_hash
|
||||
|
||||
if envs.SGLANG_MM_SKIP_COMPUTE_HASH.get():
|
||||
import uuid
|
||||
|
||||
self.hash = uuid.uuid4().int
|
||||
self.pad_value = _compute_pad_value(self.hash)
|
||||
return
|
||||
if self.hash is None:
|
||||
if self.feature is not None:
|
||||
hashed_feature = self.feature
|
||||
else:
|
||||
hashed_feature = self.precomputed_embeddings
|
||||
self.hash = hash_feature(hashed_feature)
|
||||
assert self.hash is not None
|
||||
self.hash = resolve_multimodal_item_hash(
|
||||
existing_hash=self.hash,
|
||||
feature=self.feature,
|
||||
precomputed_embeddings=self.precomputed_embeddings,
|
||||
)
|
||||
self.pad_value = _compute_pad_value(self.hash)
|
||||
|
||||
def is_modality(self, modality: Modality) -> bool:
|
||||
|
||||
@@ -106,6 +106,7 @@ from sglang.srt.models.kimi_k3_vl import (
|
||||
)
|
||||
from sglang.srt.models.transformers import maybe_prefix
|
||||
from sglang.srt.models.utils import WeightsMapper
|
||||
from sglang.srt.multimodal.encoder_preprocessing import EncoderMediaProcessorConfig
|
||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||
DEFERRED_PREPROCESSING_KEY,
|
||||
fill_transparent_bg,
|
||||
@@ -3075,6 +3076,10 @@ class KimiK3ForConditionalGeneration(nn.Module):
|
||||
"""K3 multimodal wrapper: MoonViT3d tower + KimiK3LinearForCausalLM."""
|
||||
|
||||
supports_cuda_vmm_feature_transport = True
|
||||
encoder_media_processor_config = EncoderMediaProcessorConfig(
|
||||
image_decode_mode="nvjpeg_fancy",
|
||||
preserve_media_metadata=True,
|
||||
)
|
||||
|
||||
# Raw HF checkpoint prefixes, before hf_to_sglang_mapper is applied.
|
||||
encoder_only_safetensors_weight_prefixes = (
|
||||
@@ -3259,49 +3264,79 @@ class KimiK3ForConditionalGeneration(nn.Module):
|
||||
for item in selected_items
|
||||
]
|
||||
if any(config is not None for config in deferred):
|
||||
if not all(config is not None for config in deferred):
|
||||
raise ValueError(
|
||||
"Kimi-K3 cannot mix deferred and preprocessed image features"
|
||||
)
|
||||
first_config = deferred[0]
|
||||
backend = first_config.backend
|
||||
if any(config.backend != backend for config in deferred):
|
||||
raise ValueError(
|
||||
"Kimi-K3 cannot mix deferred preprocessing backends"
|
||||
)
|
||||
if backend == "gpu":
|
||||
from sglang.srt.multimodal.processors.kimi_k25 import (
|
||||
_gpu_preprocess_images,
|
||||
)
|
||||
materialized = [None] * len(selected_items)
|
||||
deferred_by_backend = {}
|
||||
for index, (item, config) in enumerate(zip(selected_items, deferred)):
|
||||
if config is None:
|
||||
if not isinstance(item.feature, torch.Tensor):
|
||||
raise TypeError(
|
||||
"Kimi-K3 image feature must be a torch.Tensor, "
|
||||
f"got {type(item.feature)}"
|
||||
)
|
||||
materialized[index] = item.feature
|
||||
else:
|
||||
deferred_by_backend.setdefault(config.backend, []).append(index)
|
||||
|
||||
image_scale, image_bias = normalization_tensors(
|
||||
first_config.image_mean, first_config.image_std, device
|
||||
)
|
||||
pixel_values, _ = _gpu_preprocess_images(
|
||||
[item.feature for item in selected_items],
|
||||
[config.resize_config for config in deferred],
|
||||
image_scale,
|
||||
image_bias,
|
||||
self.vision_tower.patch_size,
|
||||
to_chw=lambda image: to_chw_uint8(image, device=device),
|
||||
post_resize=lambda x: fill_transparent_bg(
|
||||
x, first_config.transparent_bg_config
|
||||
),
|
||||
)
|
||||
elif backend == "cpu":
|
||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||
materialize_kimi_k3_cpu_features,
|
||||
)
|
||||
for backend, indices in deferred_by_backend.items():
|
||||
group_items = [selected_items[index] for index in indices]
|
||||
group_configs = [deferred[index] for index in indices]
|
||||
first_config = group_configs[0]
|
||||
if backend == "gpu":
|
||||
from sglang.srt.multimodal.processors.kimi_k25 import (
|
||||
_gpu_preprocess_images,
|
||||
)
|
||||
|
||||
pixel_values = materialize_kimi_k3_cpu_features(
|
||||
selected_items, self._encoder_image_processor
|
||||
)
|
||||
pixel_values = pixel_values.to(device, non_blocking=True)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported Kimi-K3 deferred preprocessing backend: {backend}"
|
||||
)
|
||||
return pixel_values.to(dtype=target_dtype)
|
||||
image_scale, image_bias = normalization_tensors(
|
||||
first_config.image_mean,
|
||||
first_config.image_std,
|
||||
device,
|
||||
)
|
||||
pixel_values, produced_grids = _gpu_preprocess_images(
|
||||
[item.feature for item in group_items],
|
||||
[config.resize_config for config in group_configs],
|
||||
image_scale,
|
||||
image_bias,
|
||||
self.vision_tower.patch_size,
|
||||
to_chw=lambda image: to_chw_uint8(image, device=device),
|
||||
post_resize=lambda x: fill_transparent_bg(
|
||||
x, first_config.transparent_bg_config
|
||||
),
|
||||
)
|
||||
expected_grids = grid_thws_host[indices]
|
||||
if not torch.equal(produced_grids.cpu(), expected_grids):
|
||||
raise ValueError(
|
||||
"Kimi-K3 deferred GPU preprocessing produced wrong grids"
|
||||
)
|
||||
elif backend == "cpu":
|
||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||
materialize_kimi_k3_cpu_features,
|
||||
)
|
||||
|
||||
pixel_values = materialize_kimi_k3_cpu_features(
|
||||
group_items, self._encoder_image_processor
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported Kimi-K3 deferred preprocessing backend: {backend}"
|
||||
)
|
||||
|
||||
patch_counts = [
|
||||
int(grid_thws_host[index].prod().item()) for index in indices
|
||||
]
|
||||
if sum(patch_counts) != pixel_values.shape[0]:
|
||||
raise ValueError(
|
||||
"Kimi-K3 deferred feature length does not match image grids"
|
||||
)
|
||||
for index, feature in zip(
|
||||
indices, pixel_values.split(patch_counts), strict=True
|
||||
):
|
||||
materialized[index] = feature
|
||||
|
||||
return materialize_multimodal_features(
|
||||
materialized,
|
||||
device=device,
|
||||
dtype=target_dtype,
|
||||
)
|
||||
|
||||
features = []
|
||||
for item in selected_items:
|
||||
|
||||
+10
-2
@@ -3,27 +3,35 @@
|
||||
from sglang.srt.multimodal.cache.identity import (
|
||||
CONTENT_HASH_PREFIX,
|
||||
MediaSnapshot,
|
||||
PreprocessFingerprintProvider,
|
||||
build_artifact_key,
|
||||
build_feature_hash,
|
||||
build_processor_fingerprint,
|
||||
media_preprocess_kwargs,
|
||||
parse_content_hash,
|
||||
resolve_multimodal_item_hash,
|
||||
snapshot_media,
|
||||
)
|
||||
from sglang.srt.multimodal.cache.preprocess_cache import (
|
||||
CacheLookup,
|
||||
CacheMiss,
|
||||
CacheSizeProvider,
|
||||
MultimodalPreprocessCache,
|
||||
estimate_cache_size_bytes,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CONTENT_HASH_PREFIX",
|
||||
"CacheSizeProvider",
|
||||
"CacheLookup",
|
||||
"CacheMiss",
|
||||
"MediaSnapshot",
|
||||
"MultimodalPreprocessCache",
|
||||
"PreprocessFingerprintProvider",
|
||||
"build_artifact_key",
|
||||
"build_feature_hash",
|
||||
"build_processor_fingerprint",
|
||||
"estimate_cache_size_bytes",
|
||||
"media_preprocess_kwargs",
|
||||
"parse_content_hash",
|
||||
"resolve_multimodal_item_hash",
|
||||
"snapshot_media",
|
||||
]
|
||||
|
||||
+104
-28
@@ -9,7 +9,7 @@ import struct
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Optional
|
||||
from typing import TYPE_CHECKING, Any, Mapping, Optional, Protocol, runtime_checkable
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import numpy as np
|
||||
@@ -17,8 +17,21 @@ import torch
|
||||
import transformers
|
||||
from PIL import Image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
|
||||
CONTENT_HASH_PREFIX = "sha256:"
|
||||
_SHA256_HEX_LENGTH = 64
|
||||
_MEDIA_ENVELOPE_FIELDS = frozenset(
|
||||
{"type", "format", "url", "image", "video", "audio", "content_hash"}
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PreprocessFingerprintProvider(Protocol):
|
||||
"""Explicit source for settings that can change processor artifacts."""
|
||||
|
||||
def preprocess_fingerprint_payload(self) -> Any: ...
|
||||
|
||||
|
||||
def parse_content_hash(value: Optional[str]) -> Optional[str]:
|
||||
@@ -157,6 +170,43 @@ def snapshot_media(media: Any) -> MediaSnapshot:
|
||||
raise TypeError(f"Unsupported media identity input: {type(media).__name__}")
|
||||
|
||||
|
||||
def media_preprocess_kwargs(
|
||||
source: Any, *, defaults: Optional[Mapping[str, Any]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""Conservatively capture per-request options that can affect an artifact.
|
||||
|
||||
Unknown options are included instead of allow-listed. This may create a safe
|
||||
false miss for a metadata-only option, but it prevents a new model option
|
||||
from silently creating a false cache hit.
|
||||
"""
|
||||
defaults = defaults or {}
|
||||
if dataclasses.is_dataclass(source):
|
||||
values = {
|
||||
field.name: value
|
||||
for field, value in zip(
|
||||
dataclasses.fields(source), dataclasses.astuple(source)
|
||||
)
|
||||
if field.name not in _MEDIA_ENVELOPE_FIELDS
|
||||
}
|
||||
elif isinstance(source, Mapping):
|
||||
values = {
|
||||
key: value
|
||||
for key, value in source.items()
|
||||
if key not in _MEDIA_ENVELOPE_FIELDS
|
||||
}
|
||||
else:
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
for key, value in values.items():
|
||||
if value is None or (isinstance(value, Mapping) and not value):
|
||||
continue
|
||||
if key in defaults and _canonicalize(value) == _canonicalize(defaults[key]):
|
||||
continue
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _qualified_type_name(value: Any) -> str:
|
||||
value_type = type(value)
|
||||
return f"{value_type.__module__}.{value_type.__qualname__}"
|
||||
@@ -173,8 +223,10 @@ def _canonicalize(value: Any) -> Any:
|
||||
"type": "dataclass",
|
||||
"class": _qualified_type_name(value),
|
||||
"fields": [
|
||||
[field.name, _canonicalize(getattr(value, field.name))]
|
||||
for field in dataclasses.fields(value)
|
||||
[field.name, _canonicalize(field_value)]
|
||||
for field, field_value in zip(
|
||||
dataclasses.fields(value), dataclasses.astuple(value)
|
||||
)
|
||||
],
|
||||
}
|
||||
if isinstance(value, Enum):
|
||||
@@ -273,24 +325,49 @@ def build_artifact_key(
|
||||
return _digest_bytes(_canonical_json(payload))
|
||||
|
||||
|
||||
def build_feature_hash(artifact_key: str, processor_output_hash: int) -> int:
|
||||
"""Namespace a processor-output hash by its complete artifact identity."""
|
||||
artifact_key = parse_content_hash(artifact_key)
|
||||
if (
|
||||
isinstance(processor_output_hash, bool)
|
||||
or not isinstance(processor_output_hash, int)
|
||||
or processor_output_hash < 0
|
||||
):
|
||||
raise ValueError("processor_output_hash must be a non-negative integer")
|
||||
output_hash_bytes = processor_output_hash.to_bytes(
|
||||
max(1, (processor_output_hash.bit_length() + 7) // 8),
|
||||
byteorder="big",
|
||||
signed=False,
|
||||
def resolve_multimodal_item_hash(
|
||||
*,
|
||||
existing_hash: Optional[int] = None,
|
||||
feature: Any = None,
|
||||
precomputed_embeddings: Any = None,
|
||||
namespace: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Unified helper for resolving a hash for MultimodalDataItem cache, optionally scoped to an artifact identity.
|
||||
|
||||
Args:
|
||||
namespace: Optional SHA-256 identity covering every input that can change the preprocessing result.
|
||||
It scopes the feature hash so downstream caches cannot reuse embeddings across different preprocessing settings.
|
||||
"""
|
||||
from sglang.srt.environ import envs
|
||||
|
||||
if envs.SGLANG_MM_SKIP_COMPUTE_HASH.get():
|
||||
import uuid
|
||||
|
||||
item_hash = uuid.uuid4().int
|
||||
elif existing_hash is not None:
|
||||
# if exists, reuse
|
||||
item_hash = existing_hash
|
||||
else:
|
||||
# hash from feature
|
||||
from sglang.srt.managers.mm_utils import hash_feature
|
||||
|
||||
value = feature if feature is not None else precomputed_embeddings
|
||||
item_hash = hash_feature(value)
|
||||
|
||||
if namespace is None:
|
||||
return item_hash
|
||||
|
||||
if isinstance(item_hash, bool) or not isinstance(item_hash, int) or item_hash < 0:
|
||||
raise ValueError("item hash must be a non-negative integer")
|
||||
namespace = parse_content_hash(namespace)
|
||||
assert namespace is not None
|
||||
hash_bytes = item_hash.to_bytes(
|
||||
max(1, (item_hash.bit_length() + 7) // 8), byteorder="big", signed=False
|
||||
)
|
||||
digest = _hash_parts(
|
||||
b"multimodal-feature-v1",
|
||||
bytes.fromhex(artifact_key[len(CONTENT_HASH_PREFIX) :]),
|
||||
output_hash_bytes,
|
||||
bytes.fromhex(namespace[len(CONTENT_HASH_PREFIX) :]),
|
||||
hash_bytes,
|
||||
)
|
||||
return int.from_bytes(
|
||||
bytes.fromhex(digest[len(CONTENT_HASH_PREFIX) :])[:8],
|
||||
@@ -302,27 +379,26 @@ def build_feature_hash(artifact_key: str, processor_output_hash: int) -> int:
|
||||
def build_processor_fingerprint(
|
||||
processor: Any,
|
||||
hf_config: Any,
|
||||
server_args: Any,
|
||||
server_args: ServerArgs,
|
||||
*,
|
||||
extra: Optional[Mapping[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Fingerprint preprocessing choices that can change processor output."""
|
||||
processor_payload = (
|
||||
processor.preprocess_fingerprint_payload()
|
||||
if hasattr(processor, "preprocess_fingerprint_payload")
|
||||
if isinstance(processor, PreprocessFingerprintProvider)
|
||||
else {}
|
||||
)
|
||||
hf_payload = hf_config.to_dict()
|
||||
payload = {
|
||||
"transformers": transformers.__version__,
|
||||
"processor_class": f"{type(processor).__module__}.{type(processor).__qualname__}",
|
||||
"model_type": getattr(hf_config, "model_type", None),
|
||||
"architectures": getattr(hf_config, "architectures", None),
|
||||
"model_revision": getattr(server_args, "revision", None),
|
||||
"tokenizer_revision": getattr(server_args, "tokenizer_revision", None),
|
||||
"disable_fast_image_processor": getattr(
|
||||
server_args, "disable_fast_image_processor", False
|
||||
),
|
||||
"mm_process_config": getattr(server_args, "mm_process_config", None) or {},
|
||||
"model_type": hf_payload.get("model_type"),
|
||||
"architectures": hf_payload.get("architectures"),
|
||||
"model_revision": server_args.revision,
|
||||
"processor_revision": server_args.revision,
|
||||
"disable_fast_image_processor": server_args.disable_fast_image_processor,
|
||||
"mm_process_config": server_args.mm_process_config or {},
|
||||
"processor": processor_payload,
|
||||
"extra": extra or {},
|
||||
}
|
||||
|
||||
+202
-14
@@ -1,15 +1,29 @@
|
||||
"""Bounded CPU cache and single-flight coordination for MM preprocessing."""
|
||||
"""Bounded CPU storage and single-flight coordination for MM preprocessing.
|
||||
|
||||
Model processors store prompt-independent ``MediaArtifact`` values here. This
|
||||
module knows nothing about a model or media format: it provides byte-accounted
|
||||
LRU storage and ensures concurrent misses for one key share one computation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import dataclasses
|
||||
import sys
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Generic, Optional, TypeVar
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Generic,
|
||||
Optional,
|
||||
Protocol,
|
||||
TypeVar,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -17,21 +31,45 @@ from PIL import Image
|
||||
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
_USE_RESULT = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CacheLookup(Generic[V]):
|
||||
"""A resolved value returned immediately or after shared computation."""
|
||||
|
||||
value: V
|
||||
hit: bool
|
||||
joined: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CacheMiss(Generic[K, V]):
|
||||
"""Handle for one in-flight cache miss.
|
||||
|
||||
Exactly one handle has ``should_compute=True`` and must publish the result.
|
||||
Other handles for the same key wait on the shared ``future``.
|
||||
"""
|
||||
|
||||
key: K
|
||||
future: concurrent.futures.Future[V]
|
||||
generation: int
|
||||
should_compute: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Entry(Generic[V]):
|
||||
value: V
|
||||
size_bytes: int
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CacheSizeProvider(Protocol):
|
||||
"""Explicitly expose the owned values that count against a cache budget."""
|
||||
|
||||
def cache_size_items(self) -> Sequence[Any]: ...
|
||||
|
||||
|
||||
def estimate_cache_size_bytes(value: Any) -> Optional[int]:
|
||||
"""Estimate owned CPU bytes, returning None for GPU-backed artifacts."""
|
||||
seen: set[int] = set()
|
||||
@@ -56,9 +94,9 @@ def estimate_cache_size_bytes(value: Any) -> Optional[int]:
|
||||
return len(item)
|
||||
if isinstance(item, str):
|
||||
return len(item.encode())
|
||||
if dataclasses.is_dataclass(item):
|
||||
return visit(dataclasses.asdict(item))
|
||||
if isinstance(item, dict):
|
||||
if isinstance(item, CacheSizeProvider):
|
||||
return visit(item.cache_size_items())
|
||||
if isinstance(item, Mapping):
|
||||
total = 0
|
||||
for key, child in item.items():
|
||||
key_size = visit(key)
|
||||
@@ -81,7 +119,13 @@ def estimate_cache_size_bytes(value: Any) -> Optional[int]:
|
||||
|
||||
|
||||
class MultimodalPreprocessCache(Generic[K, V]):
|
||||
"""Thread-safe byte-accounted LRU with per-key async single-flight."""
|
||||
"""Thread-safe CPU LRU with per-key async single-flight.
|
||||
|
||||
``max_size_bytes`` and ``max_entries`` bound retained values. In-flight
|
||||
computations are tracked separately and are not part of the LRU budget.
|
||||
``clear()`` invalidates cache writes from old computations so a flush cannot
|
||||
be undone by work that started before it.
|
||||
"""
|
||||
|
||||
def __init__(self, max_size_bytes: int, max_entries: int = 8192):
|
||||
if max_size_bytes < 0:
|
||||
@@ -103,6 +147,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether values can be retained; zero bytes is the cache kill switch."""
|
||||
return self.max_size_bytes > 0
|
||||
|
||||
def __len__(self) -> int:
|
||||
@@ -114,6 +159,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
return key in self._entries
|
||||
|
||||
def get(self, key: K) -> Optional[V]:
|
||||
"""Read and touch an LRU entry, recording a hit or miss."""
|
||||
with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
@@ -123,6 +169,31 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
self.hits += 1
|
||||
return entry.value
|
||||
|
||||
def get_if_present(
|
||||
self,
|
||||
key: K,
|
||||
predicate: Callable[[V], bool],
|
||||
*,
|
||||
evict_on_reject: bool = False,
|
||||
) -> Optional[V]:
|
||||
"""Use a compatible entry without recording an absent speculative miss.
|
||||
|
||||
The predicate runs while holding the cache lock, so a caller cannot use
|
||||
an entry that another thread replaces between validation and lookup.
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if not predicate(entry.value):
|
||||
if evict_on_reject:
|
||||
self._entries.pop(key)
|
||||
self.current_size_bytes -= entry.size_bytes
|
||||
return None
|
||||
self._entries.move_to_end(key)
|
||||
self.hits += 1
|
||||
return entry.value
|
||||
|
||||
def put(
|
||||
self,
|
||||
key: K,
|
||||
@@ -131,6 +202,12 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
*,
|
||||
_generation: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Insert a value if it is CPU-sizeable and fits the configured budget.
|
||||
|
||||
With automatic sizing, returns ``False`` when caching is disabled, the
|
||||
value contains a GPU tensor, the value is too large, or its generation
|
||||
predates ``clear()``.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
if size_bytes is None:
|
||||
@@ -139,6 +216,8 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
return False
|
||||
|
||||
with self._lock:
|
||||
# A pre-flush computation may finish, but it must not repopulate the
|
||||
# new cache generation.
|
||||
if _generation is not None and _generation != self._generation:
|
||||
return False
|
||||
old = self._entries.pop(key, None)
|
||||
@@ -156,6 +235,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
return True
|
||||
|
||||
def pop(self, key: K) -> Optional[V]:
|
||||
"""Remove and return one entry without changing hit/miss counters."""
|
||||
with self._lock:
|
||||
entry = self._entries.pop(key, None)
|
||||
if entry is None:
|
||||
@@ -164,6 +244,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
return entry.value
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Drop values and prevent older in-flight work from repopulating them."""
|
||||
with self._lock:
|
||||
self._entries.clear()
|
||||
self.current_size_bytes = 0
|
||||
@@ -178,6 +259,14 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
*,
|
||||
size_bytes: Optional[Callable[[V], Optional[int]]] = None,
|
||||
) -> CacheLookup[V]:
|
||||
"""Return a cached value or share one async computation for ``key``.
|
||||
|
||||
Cancellation affects only the caller that is awaiting the result. The
|
||||
shared computation remains alive for other callers.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return CacheLookup(await compute(), hit=False)
|
||||
|
||||
cached = self.get(key)
|
||||
if cached is not None:
|
||||
return CacheLookup(cached, hit=True)
|
||||
@@ -188,25 +277,25 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
future = concurrent.futures.Future()
|
||||
generation = self._generation
|
||||
self._inflight[key] = (future, generation)
|
||||
owner = True
|
||||
should_compute = True
|
||||
else:
|
||||
future, generation = inflight
|
||||
self.singleflight_joins += 1
|
||||
owner = False
|
||||
should_compute = False
|
||||
|
||||
if owner:
|
||||
if should_compute:
|
||||
self.create_background_task(
|
||||
self._compute_owned_value(
|
||||
self._compute_shared_value(
|
||||
key, future, generation, compute, size_bytes=size_bytes
|
||||
)
|
||||
)
|
||||
|
||||
# The cache owns the shared computation. Cancelling either its first
|
||||
# caller or a later joiner ends only that caller's local await.
|
||||
# computing caller or a later waiter ends only that caller's local await.
|
||||
value = await asyncio.shield(asyncio.wrap_future(future))
|
||||
return CacheLookup(value, hit=False, joined=not owner)
|
||||
return CacheLookup(value, hit=False, joined=not should_compute)
|
||||
|
||||
async def _compute_owned_value(
|
||||
async def _compute_shared_value(
|
||||
self,
|
||||
key: K,
|
||||
future: concurrent.futures.Future[V],
|
||||
@@ -215,6 +304,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
*,
|
||||
size_bytes: Optional[Callable[[V], Optional[int]]],
|
||||
) -> None:
|
||||
"""Compute once, cache the result, and wake every caller for this key."""
|
||||
try:
|
||||
value = await compute()
|
||||
measured = size_bytes(value) if size_bytes is not None else None
|
||||
@@ -246,7 +336,105 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
||||
task.add_done_callback(self._background_task_done)
|
||||
return task
|
||||
|
||||
def lookup_or_claim_many(
|
||||
self,
|
||||
keys: list[K],
|
||||
*,
|
||||
predicate: Optional[Callable[[K, V], bool]] = None,
|
||||
) -> list[CacheLookup[V] | CacheMiss[K, V]]:
|
||||
"""Return hits and single-flight miss handles in input-key order.
|
||||
|
||||
For each missing key, one result has ``should_compute=True``. Repeated
|
||||
keys or concurrent callers receive handles with ``should_compute=False``
|
||||
and should call ``wait_for_miss`` instead of recomputing the value.
|
||||
"""
|
||||
results: list[CacheLookup[V] | CacheMiss[K, V]] = []
|
||||
with self._lock:
|
||||
for key in keys:
|
||||
if not self.enabled:
|
||||
future: concurrent.futures.Future[V] = concurrent.futures.Future()
|
||||
results.append(
|
||||
CacheMiss(key, future, self._generation, should_compute=True)
|
||||
)
|
||||
continue
|
||||
|
||||
entry = self._entries.get(key)
|
||||
if entry is not None and (
|
||||
predicate is None or predicate(key, entry.value)
|
||||
):
|
||||
self._entries.move_to_end(key)
|
||||
self.hits += 1
|
||||
results.append(CacheLookup(entry.value, hit=True))
|
||||
continue
|
||||
if entry is not None:
|
||||
self._entries.pop(key)
|
||||
self.current_size_bytes -= entry.size_bytes
|
||||
|
||||
self.misses += 1
|
||||
inflight = self._inflight.get(key)
|
||||
if inflight is None or inflight[1] != self._generation:
|
||||
future: concurrent.futures.Future[V] = concurrent.futures.Future()
|
||||
generation = self._generation
|
||||
self._inflight[key] = (future, generation)
|
||||
results.append(
|
||||
CacheMiss(key, future, generation, should_compute=True)
|
||||
)
|
||||
else:
|
||||
future, generation = inflight
|
||||
self.singleflight_joins += 1
|
||||
results.append(
|
||||
CacheMiss(key, future, generation, should_compute=False)
|
||||
)
|
||||
return results
|
||||
|
||||
def complete_miss(
|
||||
self,
|
||||
miss: CacheMiss[K, V],
|
||||
value: V,
|
||||
*,
|
||||
cache_value: V | object = _USE_RESULT,
|
||||
size_bytes: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Publish a computed miss to waiters and optionally retain a copy.
|
||||
|
||||
``value`` is returned to current waiters. ``cache_value`` may be a
|
||||
smaller representation retained for future requests.
|
||||
"""
|
||||
if not miss.should_compute:
|
||||
raise ValueError("Only the caller computing a cache miss can complete it")
|
||||
self.put(
|
||||
miss.key,
|
||||
value if cache_value is _USE_RESULT else cache_value,
|
||||
size_bytes,
|
||||
_generation=miss.generation,
|
||||
)
|
||||
miss.future.set_result(value)
|
||||
with self._lock:
|
||||
if self._inflight.get(miss.key) == (
|
||||
miss.future,
|
||||
miss.generation,
|
||||
):
|
||||
self._inflight.pop(miss.key, None)
|
||||
|
||||
def fail_miss(self, miss: CacheMiss[K, V], error: BaseException) -> None:
|
||||
"""Publish a computation failure to every waiter for this miss."""
|
||||
if not miss.should_compute:
|
||||
raise ValueError("Only the caller computing a cache miss can fail it")
|
||||
miss.future.set_exception(error)
|
||||
miss.future.exception()
|
||||
with self._lock:
|
||||
if self._inflight.get(miss.key) == (
|
||||
miss.future,
|
||||
miss.generation,
|
||||
):
|
||||
self._inflight.pop(miss.key, None)
|
||||
|
||||
async def wait_for_miss(self, miss: CacheMiss[K, V]) -> V:
|
||||
"""Wait for another caller's computation without cancelling it."""
|
||||
return await asyncio.shield(asyncio.wrap_future(miss.future))
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
"""Return a lock-consistent snapshot of cache and single-flight state."""
|
||||
with self._lock:
|
||||
return {
|
||||
"entries": len(self._entries),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import hashlib
|
||||
import inspect
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Iterable, Protocol, Sequence, runtime_checkable
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -11,6 +12,30 @@ from sglang.srt.managers.schedule_batch import MultimodalDataItem
|
||||
LOCAL_PREPROCESSED_KEY = "encoder_local_preprocessed"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncoderMediaProcessorConfig:
|
||||
"""Optional model-declared media loading behavior for encoder mode."""
|
||||
|
||||
image_decode_mode: bool | str = False
|
||||
preserve_media_metadata: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EncoderMediaProcessorConfigProvider(Protocol):
|
||||
"""Model contract for optional encoder-side media preprocessing."""
|
||||
|
||||
encoder_media_processor_config: EncoderMediaProcessorConfig
|
||||
|
||||
|
||||
def resolve_encoder_media_processor_config(
|
||||
model: object,
|
||||
) -> EncoderMediaProcessorConfig:
|
||||
"""Resolve a model-declared capability without model-name dispatch."""
|
||||
if isinstance(model, EncoderMediaProcessorConfigProvider):
|
||||
return model.encoder_media_processor_config
|
||||
return EncoderMediaProcessorConfig()
|
||||
|
||||
|
||||
def hash_raw_encoder_item(value: Any) -> int:
|
||||
"""Hash raw CPU media including layout metadata, before owner materialization."""
|
||||
if isinstance(value, torch.Tensor):
|
||||
|
||||
@@ -46,7 +46,13 @@ def prepare_kimi_k3_encoder_inputs(
|
||||
navit_resize_config,
|
||||
)
|
||||
|
||||
media_proc_cfg = getattr(image_processor, "media_proc_cfg", None)
|
||||
try:
|
||||
media_proc_cfg = image_processor.media_proc_cfg
|
||||
except AttributeError as exc:
|
||||
raise ValueError(
|
||||
"Kimi-K3 EPD owner-side preprocessing requires "
|
||||
"image_processor.media_proc_cfg"
|
||||
) from exc
|
||||
if not isinstance(media_proc_cfg, dict):
|
||||
raise ValueError(
|
||||
"Kimi-K3 EPD owner-side preprocessing requires "
|
||||
@@ -69,12 +75,16 @@ def prepare_kimi_k3_encoder_inputs(
|
||||
)
|
||||
|
||||
concrete_images = []
|
||||
content_digests = []
|
||||
for image in images:
|
||||
content_digest = None
|
||||
if isinstance(image, dict):
|
||||
if image.get("type") != "image" or "image" not in image:
|
||||
raise ValueError(f"Unsupported Kimi-K3 encoder media item: {image}")
|
||||
content_digest = image.get("content_hash")
|
||||
image = image["image"]
|
||||
concrete_images.append(image)
|
||||
content_digests.append(content_digest)
|
||||
|
||||
patch_size = int(media_proc_cfg["patch_size"])
|
||||
merge_kernel_size = int(media_proc_cfg["merge_kernel_size"])
|
||||
@@ -89,7 +99,7 @@ def prepare_kimi_k3_encoder_inputs(
|
||||
items = []
|
||||
grids = []
|
||||
original_image_sizes = []
|
||||
for image in concrete_images:
|
||||
for image, content_digest in zip(concrete_images, content_digests):
|
||||
width, height = (
|
||||
(int(image.shape[-1]), int(image.shape[-2]))
|
||||
if isinstance(image, torch.Tensor)
|
||||
@@ -106,15 +116,18 @@ def prepare_kimi_k3_encoder_inputs(
|
||||
)
|
||||
grid_thw = _grid_thw_from_resize_config(resize_config, patch_size)
|
||||
grid_tensor = torch.tensor([grid_thw], dtype=torch.int64)
|
||||
model_specific_data = {
|
||||
"grid_thws": grid_tensor,
|
||||
DEFERRED_PREPROCESSING_KEY: deferred_preprocessing(
|
||||
resize_config=resize_config
|
||||
),
|
||||
}
|
||||
if content_digest is not None:
|
||||
model_specific_data["content_digest"] = content_digest
|
||||
item = MultimodalDataItem(
|
||||
modality=Modality.IMAGE,
|
||||
feature=to_chw_uint8(image) if use_gpu_preprocessing else image,
|
||||
model_specific_data={
|
||||
"grid_thws": grid_tensor,
|
||||
DEFERRED_PREPROCESSING_KEY: deferred_preprocessing(
|
||||
resize_config=resize_config
|
||||
),
|
||||
},
|
||||
model_specific_data=model_specific_data,
|
||||
)
|
||||
if not use_gpu_preprocessing:
|
||||
item.set_hash(hash_raw_encoder_item(image))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Bridge multimodal processors and the shared preprocess cache.
|
||||
|
||||
A model processor turns raw media into model-specific ``MediaArtifact``
|
||||
objects and later combines those artifacts with the current prompt. The
|
||||
``MultimodalPreprocessCache`` stores cache-safe copies of the artifacts so a
|
||||
later request can skip model preprocessing. With a trusted caller-provided
|
||||
content hash, a hot hit can skip loading the media source as well.
|
||||
|
||||
This package defines that boundary:
|
||||
|
||||
* ``MediaArtifact`` is the common contract for model-specific cache items.
|
||||
* ``MediaArtifactInput`` carries a decoded cache miss into a model processor.
|
||||
* ``MediaArtifactCacheMixin`` owns lookup, single-flight miss handling, and
|
||||
result ordering around the model's ``prepare_artifact_batch`` method.
|
||||
|
||||
The package does not implement model preprocessing or a second cache. Model
|
||||
logic stays in ``multimodal.processors``; storage and concurrency stay in
|
||||
``multimodal.cache``.
|
||||
"""
|
||||
|
||||
from sglang.srt.multimodal.media_artifacts.base import (
|
||||
MediaArtifact,
|
||||
MediaArtifactCacheMixin,
|
||||
MediaArtifactInput,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"MediaArtifact",
|
||||
"MediaArtifactCacheMixin",
|
||||
"MediaArtifactInput",
|
||||
]
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Shared contracts and coordination for reusable multimodal artifacts.
|
||||
|
||||
A media artifact is the model-specific, prompt-independent state produced from
|
||||
one media input. It keeps the metadata needed to rebuild a request (for example,
|
||||
image size, token count, and encoder grid) and, when cacheable on CPU, the
|
||||
processor feature itself. Prompt tokens and offsets are deliberately excluded.
|
||||
|
||||
The artifact connects the media preprocessor to request composition::
|
||||
|
||||
raw media -> identity/cache lookup -> MediaArtifact
|
||||
cache miss: MediaArtifactInput -> prepare_artifact_batch()
|
||||
cache hit: reuse the stored artifact
|
||||
MediaArtifact + current prompt -> MultimodalDataItem -> encoder/ViT
|
||||
|
||||
The current request uses the full artifact returned by preprocessing. The
|
||||
preprocess cache stores ``artifact.cache_value()``, which may omit a CUDA feature
|
||||
and retain only reusable metadata. Such a featureless artifact is usable only
|
||||
when the downstream embedding cache already contains the encoded feature.
|
||||
|
||||
An artifact is therefore the logical preprocess-cache item. It is not the raw
|
||||
media, a prompt-specific ``MultimodalDataItem``, or a ViT embedding-cache entry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Protocol, runtime_checkable
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from sglang.srt.managers.schedule_batch import Modality
|
||||
from sglang.srt.multimodal.cache import (
|
||||
CacheLookup,
|
||||
CacheMiss,
|
||||
MediaSnapshot,
|
||||
build_artifact_key,
|
||||
media_preprocess_kwargs,
|
||||
parse_content_hash,
|
||||
snapshot_media,
|
||||
)
|
||||
from sglang.srt.utils import load_image
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MediaArtifact(Protocol):
|
||||
"""Common contract implemented by each model's preprocess artifact.
|
||||
|
||||
``content_digest`` identifies the media contents. ``artifact_key`` also
|
||||
includes every preprocessing choice that can change the artifact.
|
||||
``feature_hash`` becomes ``MultimodalDataItem.hash`` and identifies the
|
||||
corresponding encoder embedding.
|
||||
"""
|
||||
|
||||
content_digest: str
|
||||
artifact_key: str
|
||||
feature_hash: int
|
||||
|
||||
@property
|
||||
def has_feature(self) -> bool: ...
|
||||
|
||||
def cache_value(self) -> MediaArtifact:
|
||||
"""Return the cache-safe representation, possibly without a feature."""
|
||||
...
|
||||
|
||||
def cache_size_items(self) -> Sequence[Any]:
|
||||
"""Return owned values counted against the preprocess-cache budget."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaArtifactInput:
|
||||
"""One decoded raw multimodal input that missed the preprocess cache.
|
||||
|
||||
The shared cache layer has already validated its content digest, derived
|
||||
its artifact key, and claimed the cache miss. The model-specific artifact
|
||||
builder preprocesses ``media`` into a reusable ``MediaArtifact`` without
|
||||
loading or hashing the source again. This object is a transient handoff;
|
||||
it is not itself stored in the cache.
|
||||
"""
|
||||
|
||||
# hash of the media content
|
||||
content_digest: str
|
||||
# cache key containing the processor fingerprint, preprocess kwargs, media content
|
||||
artifact_key: str
|
||||
modality: Modality
|
||||
# the original media input that has been loaded and decoded (e.g., PIL.Image)
|
||||
media: Any
|
||||
|
||||
|
||||
class MediaArtifactCacheMixin:
|
||||
"""Turn media inputs into ordered artifacts, reusing cached work per item.
|
||||
|
||||
The shared layer owns identity, cache lookup, single-flight, partial hits,
|
||||
and result ordering. A model adapter implements ``prepare_artifact_batch``
|
||||
and later composes the returned artifacts with the current prompt. Models
|
||||
can override snapshot/decode/key hooks for each modality without copying the
|
||||
cache algorithm. Every artifact-producing setting must be exposed through
|
||||
``preprocess_fingerprint_payload``.
|
||||
"""
|
||||
|
||||
artifact_modality: Optional[Modality] = None
|
||||
artifact_option_defaults: Mapping[str, Any] = {"detail": "auto"}
|
||||
|
||||
def artifact_preprocess_kwargs(
|
||||
self, source: Any, modality: Modality
|
||||
) -> Mapping[str, Any]:
|
||||
"""Return request options that can change this media's artifact.
|
||||
|
||||
These options become part of the artifact key. Model adapters can
|
||||
override this hook when their request schema has additional knobs.
|
||||
"""
|
||||
return media_preprocess_kwargs(source, defaults=self.artifact_option_defaults)
|
||||
|
||||
def _resolve_artifact_modality(self, modality: Optional[Modality]) -> Modality:
|
||||
modality = modality or self.artifact_modality
|
||||
if modality is None:
|
||||
raise ValueError("A modality is required for artifact caching")
|
||||
return modality
|
||||
|
||||
def _artifact_key(
|
||||
self,
|
||||
content_digest: str,
|
||||
source: Any,
|
||||
*,
|
||||
modality: Optional[Modality] = None,
|
||||
) -> str:
|
||||
"""Identify one artifact by media content and all preprocess choices."""
|
||||
if self.processor_fingerprint is None:
|
||||
raise RuntimeError("Artifact caching requires a processor fingerprint")
|
||||
modality = self._resolve_artifact_modality(modality)
|
||||
return build_artifact_key(
|
||||
content_digest,
|
||||
modality=modality.name.lower(),
|
||||
processor_fingerprint=self.processor_fingerprint,
|
||||
preprocess_kwargs=self.artifact_preprocess_kwargs(source, modality),
|
||||
)
|
||||
|
||||
def decode_media_snapshot(self, snapshot: MediaSnapshot, modality: Modality) -> Any:
|
||||
"""Decode an immutable snapshot for the model adapter.
|
||||
|
||||
Image is the shared default. Future video/audio adapters must make their
|
||||
decode/sampling contract explicit by overriding this hook.
|
||||
"""
|
||||
if modality != Modality.IMAGE:
|
||||
raise NotImplementedError(
|
||||
f"{modality.name.lower()} artifact decoding " "requires a model adapter"
|
||||
)
|
||||
data = snapshot.data
|
||||
if isinstance(data, torch.Tensor):
|
||||
return data
|
||||
if isinstance(data, np.ndarray):
|
||||
return torch.from_numpy(data)
|
||||
if isinstance(data, Image.Image):
|
||||
data.load()
|
||||
return data
|
||||
image, _ = load_image(data, self.gpu_image_decode)
|
||||
if isinstance(image, Image.Image):
|
||||
image.load()
|
||||
return image
|
||||
|
||||
def snapshot_media_source(self, source: Any, modality: Modality) -> MediaSnapshot:
|
||||
"""Capture immutable media content before decode and preprocessing.
|
||||
|
||||
The shared implementation covers images. Video/audio adapters can add
|
||||
streaming or frame-sampling identities here without changing the cache
|
||||
coordinator.
|
||||
"""
|
||||
if modality != Modality.IMAGE:
|
||||
raise NotImplementedError(
|
||||
f"{modality.name.lower()} artifact identity " "requires a model adapter"
|
||||
)
|
||||
return snapshot_media(source)
|
||||
|
||||
def prepare_artifact_batch(
|
||||
self, entries: Sequence[MediaArtifactInput]
|
||||
) -> list[MediaArtifact]:
|
||||
"""Preprocess raw multimodal inputs that missed the preprocess cache.
|
||||
|
||||
Each entry is one unique, decoded cache miss. Implementations must
|
||||
return one reusable artifact (the preprocess-cache item) per entry, in
|
||||
the same order, while preserving its content digest and artifact key.
|
||||
The shared layer uses the artifact for the current request and stores
|
||||
``artifact.cache_value()`` for reuse.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def artifact_usable(
|
||||
self, artifact: MediaArtifact, *, allow_featureless: bool
|
||||
) -> bool:
|
||||
"""Whether this request can use an artifact that may omit its feature.
|
||||
|
||||
A metadata-only artifact is valid only after the scheduler has confirmed
|
||||
that the corresponding encoder embedding is already cached.
|
||||
"""
|
||||
return artifact.has_feature or allow_featureless
|
||||
|
||||
@staticmethod
|
||||
def validate_artifact(artifact: MediaArtifact, entry: MediaArtifactInput) -> None:
|
||||
"""Enforce identity invariants shared by every model adapter."""
|
||||
if artifact.content_digest != entry.content_digest:
|
||||
raise ValueError("prepare_artifact_batch changed the media content digest")
|
||||
if artifact.artifact_key != entry.artifact_key:
|
||||
raise ValueError("prepare_artifact_batch changed the media artifact key")
|
||||
if (
|
||||
isinstance(artifact.feature_hash, bool)
|
||||
or not isinstance(artifact.feature_hash, int)
|
||||
or artifact.feature_hash < 0
|
||||
):
|
||||
raise ValueError(
|
||||
"Media artifact feature_hash must be a non-negative integer"
|
||||
)
|
||||
|
||||
async def _run_preprocess_and_build_artifact_batch(
|
||||
self, entries: Sequence[MediaArtifactInput]
|
||||
) -> list[MediaArtifact]:
|
||||
"""Run model preprocessing locally or on the processor worker pool, return the artifact"""
|
||||
if self.mm_processor_executor is None:
|
||||
return self.prepare_artifact_batch(entries)
|
||||
return await self.mm_processor_executor.run(
|
||||
self.prepare_artifact_batch, entries
|
||||
)
|
||||
|
||||
def _get_cached_artifact(
|
||||
self,
|
||||
key: str,
|
||||
content_digest: str,
|
||||
modality: Modality,
|
||||
*,
|
||||
allow_featureless: bool,
|
||||
) -> Optional[MediaArtifact]:
|
||||
"""Return a compatible cached artifact without recording a cold miss.
|
||||
|
||||
Identity mismatches are corrupt entries and are evicted. A featureless
|
||||
entry that this request cannot use is left for the miss path, which
|
||||
removes it temporarily and verifies the recomputed feature hash.
|
||||
"""
|
||||
artifact = self.mm_preprocess_cache.get_if_present(
|
||||
key,
|
||||
lambda value: (
|
||||
isinstance(value, MediaArtifact)
|
||||
and value.artifact_key == key
|
||||
and value.content_digest == content_digest
|
||||
),
|
||||
evict_on_reject=True,
|
||||
)
|
||||
if artifact is not None:
|
||||
self.validate_artifact(
|
||||
artifact,
|
||||
MediaArtifactInput(content_digest, key, modality, None),
|
||||
)
|
||||
if not self.artifact_usable(artifact, allow_featureless=allow_featureless):
|
||||
return None
|
||||
return artifact
|
||||
|
||||
async def prepare_media_artifacts(
|
||||
self,
|
||||
media_data: Sequence[Any],
|
||||
*,
|
||||
content_hashes: Optional[Sequence[Optional[str]]] = None,
|
||||
featureless_hit_mask: Optional[Sequence[bool]] = None,
|
||||
modality: Optional[Modality] = None,
|
||||
) -> list[MediaArtifact]:
|
||||
"""Try resolving one preprocess-cache artifact for each processor input.
|
||||
|
||||
Each media input is looked up independently, and results preserve the
|
||||
input order. A cache hit returns the stored artifact (the cache item).
|
||||
A miss snapshots and decodes the raw input, runs
|
||||
``prepare_artifact_batch``, stores its cache-safe artifact, and returns
|
||||
the prepared artifact to the current request. Duplicate and concurrent
|
||||
misses share the same preprocessing work.
|
||||
|
||||
This stage is prompt-independent. It does not create prompt tokens,
|
||||
offsets, or ``MultimodalDataItem`` objects; the model processor uses the
|
||||
returned artifacts to compose those request-specific values afterward.
|
||||
"""
|
||||
modality = self._resolve_artifact_modality(modality)
|
||||
media_count = len(media_data)
|
||||
if content_hashes is None:
|
||||
content_hashes = [None] * media_count
|
||||
if len(content_hashes) != media_count:
|
||||
raise ValueError(
|
||||
f"mm_content_hashes has {len(content_hashes)} entries for "
|
||||
f"{media_count} {modality.name.lower()} items"
|
||||
)
|
||||
content_hashes = [parse_content_hash(value) for value in content_hashes]
|
||||
|
||||
if featureless_hit_mask is None:
|
||||
featureless_hit_mask = [False] * media_count
|
||||
if len(featureless_hit_mask) != media_count:
|
||||
raise ValueError("featureless_hit_mask must align with media_data")
|
||||
|
||||
# keep per-input state aligned for duplicates and partial hits
|
||||
artifacts: list[Optional[MediaArtifact]] = [None] * media_count
|
||||
snapshots: list[Optional[MediaSnapshot]] = [None] * media_count
|
||||
keys: list[Optional[str]] = [None] * media_count
|
||||
|
||||
# 1. fast path: resolve trusted provided hash hits without reading media
|
||||
# e.g., an image could be submitted with a provided hash:
|
||||
# "image_url": {
|
||||
# "url": "https://example.com/image.jpg",
|
||||
# "content_hash": "sha256:<64-hex>"
|
||||
# }
|
||||
load_indices = []
|
||||
for index, (source, caller_hash, allow_featureless) in enumerate(
|
||||
zip(media_data, content_hashes, featureless_hit_mask)
|
||||
):
|
||||
if self.trust_mm_content_hashes and caller_hash is not None:
|
||||
key = self._artifact_key(caller_hash, source, modality=modality)
|
||||
keys[index] = key
|
||||
artifact = self._get_cached_artifact(
|
||||
key,
|
||||
caller_hash,
|
||||
modality,
|
||||
allow_featureless=allow_featureless,
|
||||
)
|
||||
if artifact is not None:
|
||||
artifacts[index] = artifact
|
||||
continue
|
||||
load_indices.append(index)
|
||||
|
||||
# 2. read cache: build artifact key from media snapshot then try reading cache
|
||||
snapshot_futures = {
|
||||
index: self.io_executor.submit(
|
||||
self.snapshot_media_source, media_data[index], modality
|
||||
)
|
||||
for index in load_indices
|
||||
}
|
||||
for index, future in snapshot_futures.items():
|
||||
snapshot = await asyncio.wrap_future(future)
|
||||
caller_hash = content_hashes[index]
|
||||
if caller_hash is not None and caller_hash != snapshot.content_digest:
|
||||
raise ValueError(
|
||||
f"content hash mismatch for media_data[{index}]: "
|
||||
f"expected {caller_hash}, got {snapshot.content_digest}"
|
||||
)
|
||||
snapshots[index] = snapshot
|
||||
key = self._artifact_key(
|
||||
snapshot.content_digest, media_data[index], modality=modality
|
||||
)
|
||||
keys[index] = key
|
||||
artifacts[index] = self._get_cached_artifact(
|
||||
key,
|
||||
snapshot.content_digest,
|
||||
modality,
|
||||
allow_featureless=featureless_hit_mask[index],
|
||||
)
|
||||
|
||||
# 3. deduplicate misses before decode
|
||||
first_index_by_key: dict[str, int] = {}
|
||||
previous_metadata: dict[str, MediaArtifact] = {}
|
||||
for index in load_indices:
|
||||
if artifacts[index] is not None:
|
||||
continue
|
||||
key = keys[index]
|
||||
assert key is not None
|
||||
if key not in first_index_by_key:
|
||||
first_index_by_key[key] = index
|
||||
previous = self.mm_preprocess_cache.pop(key)
|
||||
if previous is not None:
|
||||
previous_metadata[key] = previous
|
||||
|
||||
unique_keys = list(first_index_by_key)
|
||||
|
||||
# 4. submit one computation (preprocess) for each unique miss
|
||||
cache_results = self.mm_preprocess_cache.lookup_or_claim_many(
|
||||
unique_keys,
|
||||
predicate=lambda key, artifact: self.artifact_usable(
|
||||
artifact,
|
||||
allow_featureless=featureless_hit_mask[first_index_by_key[key]],
|
||||
),
|
||||
)
|
||||
resolved_by_key: dict[str, MediaArtifact] = {}
|
||||
misses_to_compute: list[CacheMiss[str, MediaArtifact]] = []
|
||||
for key, result in zip(unique_keys, cache_results):
|
||||
if isinstance(result, CacheLookup):
|
||||
resolved_by_key[key] = result.value
|
||||
elif result.should_compute:
|
||||
misses_to_compute.append(result)
|
||||
|
||||
if misses_to_compute:
|
||||
missed_task = self.mm_preprocess_cache.create_background_task(
|
||||
self._compute_cache_misses(
|
||||
misses_to_compute,
|
||||
first_index_by_key,
|
||||
snapshots,
|
||||
previous_metadata,
|
||||
resolved_by_key,
|
||||
modality,
|
||||
)
|
||||
)
|
||||
# shared work outlives cancellation of this request
|
||||
await asyncio.shield(missed_task)
|
||||
|
||||
# 5. wait for misses already claimed by another request
|
||||
for key, result in zip(unique_keys, cache_results):
|
||||
if isinstance(result, CacheMiss) and not result.should_compute:
|
||||
resolved_by_key[key] = await self.mm_preprocess_cache.wait_for_miss(
|
||||
result
|
||||
)
|
||||
|
||||
# 6. restore the original processor-input order
|
||||
for index, artifact in enumerate(artifacts):
|
||||
if artifact is None:
|
||||
key = keys[index]
|
||||
assert key is not None
|
||||
artifacts[index] = resolved_by_key[key]
|
||||
if any(artifact is None for artifact in artifacts):
|
||||
raise RuntimeError("Artifact cache did not resolve every media item")
|
||||
return [artifact for artifact in artifacts if artifact is not None]
|
||||
|
||||
async def _compute_cache_misses(
|
||||
self,
|
||||
misses_to_compute: Sequence[CacheMiss[str, MediaArtifact]],
|
||||
first_index_by_key: Mapping[str, int],
|
||||
snapshots: Sequence[Optional[MediaSnapshot]],
|
||||
previous_metadata: Mapping[str, MediaArtifact],
|
||||
resolved_by_key: dict[str, MediaArtifact],
|
||||
modality: Modality,
|
||||
) -> None:
|
||||
"""Decode and preprocess claimed misses, then wake concurrent waiters.
|
||||
|
||||
The full artifact is returned to requests waiting on the miss. A
|
||||
possibly smaller ``artifact.cache_value()`` is retained in the bounded
|
||||
CPU cache. The two values differ when a CUDA feature must not be cached.
|
||||
"""
|
||||
try:
|
||||
# 1. decode (load media) each unique miss
|
||||
missed_media = []
|
||||
for missed in misses_to_compute:
|
||||
index = first_index_by_key[missed.key]
|
||||
snapshot = snapshots[index]
|
||||
assert snapshot is not None
|
||||
media = await asyncio.wrap_future(
|
||||
self.io_executor.submit(
|
||||
self.decode_media_snapshot, snapshot, modality
|
||||
)
|
||||
)
|
||||
missed_media.append(
|
||||
MediaArtifactInput(
|
||||
content_digest=snapshot.content_digest,
|
||||
artifact_key=missed.key,
|
||||
modality=modality,
|
||||
media=media,
|
||||
)
|
||||
)
|
||||
|
||||
# 2. preprocess all decoded misses as one model batch
|
||||
missed_artifacts = await self._run_preprocess_and_build_artifact_batch(
|
||||
missed_media
|
||||
)
|
||||
if len(missed_artifacts) != len(misses_to_compute):
|
||||
raise ValueError(
|
||||
"prepare_artifact_batch must return one artifact per cache miss"
|
||||
)
|
||||
for missed, entry, artifact in zip(
|
||||
misses_to_compute, missed_media, missed_artifacts
|
||||
):
|
||||
self.validate_artifact(artifact, entry)
|
||||
previous = previous_metadata.get(missed.key)
|
||||
if (
|
||||
previous is not None
|
||||
and previous.feature_hash != artifact.feature_hash
|
||||
):
|
||||
raise ValueError(
|
||||
"Cached media artifact feature hash changed for identical "
|
||||
f"identity {missed.key}"
|
||||
)
|
||||
cache_value = artifact.cache_value()
|
||||
self.validate_artifact(cache_value, entry)
|
||||
# 3. return full artifacts and retain cache-safe copies
|
||||
self.mm_preprocess_cache.complete_miss(
|
||||
missed,
|
||||
artifact,
|
||||
cache_value=cache_value,
|
||||
)
|
||||
resolved_by_key[missed.key] = artifact
|
||||
except BaseException as error:
|
||||
for missed in misses_to_compute:
|
||||
if not missed.future.done():
|
||||
self.mm_preprocess_cache.fail_miss(missed, error)
|
||||
raise
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Prompt-independent Kimi-K3 image preprocessing artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||
KimiK3DeferredPreprocessing,
|
||||
)
|
||||
|
||||
|
||||
class KimiK3MediaProcessorConfigProvider(Protocol):
|
||||
"""Typed view of the HF media processor state consumed by this adapter."""
|
||||
|
||||
media_proc_cfg: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiK3PreprocessConfig:
|
||||
"""The single source of truth for K3 artifact-producing choices."""
|
||||
|
||||
patch_size: int
|
||||
merge_kernel_size: int
|
||||
in_patch_limit: int
|
||||
patch_limit_on_one_side: int
|
||||
fixed_output_tokens: Optional[int]
|
||||
image_mean: tuple[float, ...]
|
||||
image_std: tuple[float, ...]
|
||||
transparent_bg_config: Optional[dict]
|
||||
|
||||
@classmethod
|
||||
def from_media_processor(
|
||||
cls, media_processor: KimiK3MediaProcessorConfigProvider
|
||||
) -> KimiK3PreprocessConfig:
|
||||
config = media_processor.media_proc_cfg
|
||||
return cls(
|
||||
patch_size=int(config["patch_size"]),
|
||||
merge_kernel_size=int(config["merge_kernel_size"]),
|
||||
in_patch_limit=int(config["in_patch_limit"]),
|
||||
patch_limit_on_one_side=int(config["patch_limit_on_one_side"]),
|
||||
fixed_output_tokens=(
|
||||
None
|
||||
if config.get("fixed_output_tokens") is None
|
||||
else int(config["fixed_output_tokens"])
|
||||
),
|
||||
image_mean=tuple(float(value) for value in config["image_mean"]),
|
||||
image_std=tuple(float(value) for value in config["image_std"]),
|
||||
transparent_bg_config=deepcopy(config.get("transparent_bg_config")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiK3ResizeConfig:
|
||||
num_tokens: int
|
||||
new_width: int
|
||||
new_height: int
|
||||
pad_width: int
|
||||
pad_height: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: dict) -> KimiK3ResizeConfig:
|
||||
return cls(
|
||||
num_tokens=int(value["num_tokens"]),
|
||||
new_width=int(value["new_width"]),
|
||||
new_height=int(value["new_height"]),
|
||||
pad_width=int(value["pad_width"]),
|
||||
pad_height=int(value["pad_height"]),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, int]:
|
||||
return {
|
||||
"num_tokens": self.num_tokens,
|
||||
"new_width": self.new_width,
|
||||
"new_height": self.new_height,
|
||||
"pad_width": self.pad_width,
|
||||
"pad_height": self.pad_height,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KimiK3ImagePreprocessArtifact:
|
||||
"""K3's prompt-independent preprocess result for one image, containing the feature and everything
|
||||
|
||||
``original_size`` and ``resize_config`` rebuild the K3 image tokens for
|
||||
each prompt; ``grid_thw`` becomes encoder metadata; ``feature`` is either
|
||||
the prepared encoder input or a raw tensor paired with deferred GPU
|
||||
preprocessing. ``feature_hash`` links the artifact to the embedding cache.
|
||||
"""
|
||||
|
||||
content_digest: str
|
||||
artifact_key: str
|
||||
feature_hash: int
|
||||
original_size: tuple[int, int]
|
||||
resize_config: KimiK3ResizeConfig
|
||||
grid_thw: tuple[int, int, int]
|
||||
feature: Optional[torch.Tensor]
|
||||
deferred: Optional[KimiK3DeferredPreprocessing] = None
|
||||
|
||||
@property
|
||||
def has_feature(self) -> bool:
|
||||
return self.feature is not None
|
||||
|
||||
def cache_value(self) -> KimiK3ImagePreprocessArtifact:
|
||||
"""Return the CPU-cacheable copy; never retain a CUDA tensor."""
|
||||
if self.feature is None or self.feature.device.type == "cpu":
|
||||
return self
|
||||
return replace(self, feature=None)
|
||||
|
||||
def cache_size_items(self) -> tuple:
|
||||
"""Return every owned value that contributes to the CPU cache budget."""
|
||||
deferred = None
|
||||
if self.deferred is not None:
|
||||
deferred = (
|
||||
self.deferred.backend,
|
||||
self.deferred.image_mean,
|
||||
self.deferred.image_std,
|
||||
self.deferred.transparent_bg_config,
|
||||
self.deferred.resize_config,
|
||||
)
|
||||
return (
|
||||
self.content_digest,
|
||||
self.artifact_key,
|
||||
self.feature_hash,
|
||||
self.original_size,
|
||||
(
|
||||
self.resize_config.num_tokens,
|
||||
self.resize_config.new_width,
|
||||
self.resize_config.new_height,
|
||||
self.resize_config.pad_width,
|
||||
self.resize_config.pad_height,
|
||||
),
|
||||
self.grid_thw,
|
||||
self.feature,
|
||||
deferred,
|
||||
)
|
||||
@@ -6,7 +6,15 @@ import multiprocessing as mp
|
||||
import os
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -21,6 +29,7 @@ from sglang.srt.managers.schedule_batch import (
|
||||
)
|
||||
from sglang.srt.multimodal.cache import (
|
||||
MultimodalPreprocessCache,
|
||||
PreprocessFingerprintProvider,
|
||||
build_processor_fingerprint,
|
||||
)
|
||||
from sglang.srt.multimodal.processors.executor import MultimodalProcessorExecutor
|
||||
@@ -190,6 +199,8 @@ class BaseMultimodalProcessor(ABC):
|
||||
preserve_processor_input_ids = False
|
||||
auto_mm_processor_worker_num = 1
|
||||
auto_mm_io_worker_num = 4
|
||||
# Models opt in by assigning a non-zero default. A user-provided server
|
||||
# argument overrides this value; zero disables storage and cache-key work.
|
||||
auto_mm_preprocess_cache_size_mb = 0
|
||||
supports_mm_processor_concurrency = False
|
||||
|
||||
@@ -204,9 +215,7 @@ class BaseMultimodalProcessor(ABC):
|
||||
server_args.allowed_media_domains,
|
||||
server_args.media_url_max_file_size_mb,
|
||||
)
|
||||
configured_mm_feature_transport = getattr(
|
||||
server_args, "mm_feature_transport", "cpu"
|
||||
)
|
||||
configured_mm_feature_transport = server_args.mm_feature_transport
|
||||
self.mm_feature_transport = (
|
||||
configured_mm_feature_transport
|
||||
if configured_mm_feature_transport in ("cpu", "cuda_ipc", "cuda_vmm")
|
||||
@@ -216,10 +225,8 @@ class BaseMultimodalProcessor(ABC):
|
||||
self.use_ipc_pool_handle_cache = (
|
||||
self.use_cuda_ipc and envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.get()
|
||||
)
|
||||
self.image_processor_backend = getattr(
|
||||
server_args, "image_processor_backend", "auto"
|
||||
)
|
||||
if getattr(server_args, "disable_fast_image_processor", False):
|
||||
self.image_processor_backend = server_args.image_processor_backend
|
||||
if server_args.disable_fast_image_processor:
|
||||
self.image_processor_backend = "pil"
|
||||
self.disable_fast_image_processor = self.image_processor_backend == "pil"
|
||||
self.skip_tokenizer_init = server_args.skip_tokenizer_init
|
||||
@@ -229,25 +236,24 @@ class BaseMultimodalProcessor(ABC):
|
||||
self.video_config = mm_process_config.get("video", {})
|
||||
self.audio_config = mm_process_config.get("audio", {})
|
||||
|
||||
requested_cache_mb = getattr(
|
||||
self.server_args, "mm_preprocess_cache_size_mb", None
|
||||
)
|
||||
# Each tokenizer worker is a separate process with its own CPU cache.
|
||||
# Split the requested service-wide budget so increasing worker count
|
||||
# does not silently multiply host-memory usage.
|
||||
requested_cache_mb = self.server_args.mm_preprocess_cache_size_mb
|
||||
total_cache_mb = (
|
||||
self.auto_mm_preprocess_cache_size_mb
|
||||
if requested_cache_mb is None
|
||||
else requested_cache_mb
|
||||
)
|
||||
tokenizer_worker_num = max(
|
||||
int(getattr(self.server_args, "tokenizer_worker_num", 1)), 1
|
||||
)
|
||||
tokenizer_worker_num = max(int(self.server_args.tokenizer_worker_num), 1)
|
||||
worker_cache_bytes = total_cache_mb * 1024 * 1024 // tokenizer_worker_num
|
||||
self.mm_preprocess_cache = MultimodalPreprocessCache(
|
||||
max_size_bytes=worker_cache_bytes,
|
||||
max_entries=8192,
|
||||
)
|
||||
self.trust_mm_content_hashes = bool(
|
||||
getattr(self.server_args, "trust_mm_content_hashes", False)
|
||||
)
|
||||
self.trust_mm_content_hashes = bool(self.server_args.trust_mm_content_hashes)
|
||||
# The fingerprint is needed only to build artifact keys. Avoid inspecting
|
||||
# processor state when this processor will never retain artifacts.
|
||||
self.processor_fingerprint = (
|
||||
build_processor_fingerprint(self, hf_config, server_args)
|
||||
if self.mm_preprocess_cache.enabled
|
||||
@@ -418,26 +424,46 @@ class BaseMultimodalProcessor(ABC):
|
||||
|
||||
@property
|
||||
def keep_mm_features_on_device(self) -> bool:
|
||||
"""Whether feature transport expects processor outputs to stay on GPU."""
|
||||
return self.mm_feature_transport in ("cuda_ipc", "cuda_vmm")
|
||||
|
||||
def preprocess_fingerprint_payload(self) -> dict[str, Any]:
|
||||
"""Stable processor choices that may change per-media artifacts."""
|
||||
"""Return every stable setting that can change a media artifact.
|
||||
|
||||
The payload is hashed once at startup and becomes part of every
|
||||
artifact key. Model processors must extend this method when they add an
|
||||
output-affecting option. The wrapped HF processor can expose its own
|
||||
typed payload through ``PreprocessFingerprintProvider``.
|
||||
"""
|
||||
wrapped_processor = (
|
||||
self._processor.preprocess_fingerprint_payload()
|
||||
if isinstance(self._processor, PreprocessFingerprintProvider)
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"wrapper_class": (
|
||||
f"{type(self._processor).__module__}."
|
||||
f"{type(self._processor).__qualname__}"
|
||||
),
|
||||
"gpu_image_decode": self.gpu_image_decode,
|
||||
"image_processor_backend": self.image_processor_backend,
|
||||
"feature_transport": self.mm_feature_transport,
|
||||
"image_config": self.image_config,
|
||||
"video_config": self.video_config,
|
||||
"audio_config": self.audio_config,
|
||||
"wrapped_processor": wrapped_processor,
|
||||
}
|
||||
|
||||
def clear_preprocess_cache(self) -> None:
|
||||
"""Drop artifacts and reject cache writes from pre-flush work.
|
||||
|
||||
Active requests continue and still receive their preprocessing result;
|
||||
they simply cannot repopulate the freshly cleared cache.
|
||||
"""
|
||||
self.mm_preprocess_cache.clear()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Release executor resources and cached CPU artifacts."""
|
||||
"""Drop cached artifacts and stop every processor-side executor."""
|
||||
self.clear_preprocess_cache()
|
||||
self.io_executor.shutdown(wait=False, cancel_futures=True)
|
||||
self.cpu_executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
@@ -9,8 +9,9 @@ at load time.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import math
|
||||
import re
|
||||
from typing import Dict, List, Union
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -22,6 +23,7 @@ from sglang.srt.managers.schedule_batch import (
|
||||
MultimodalProcessorOutput,
|
||||
)
|
||||
from sglang.srt.models.kimi_k3 import KimiK3ForConditionalGeneration
|
||||
from sglang.srt.multimodal.cache import resolve_multimodal_item_hash
|
||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||
DEFERRED_PREPROCESSING_KEY,
|
||||
KimiK3DeferredPreprocessing,
|
||||
@@ -32,6 +34,15 @@ from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||
to_chw_uint8,
|
||||
)
|
||||
from sglang.srt.multimodal.media_artifacts import (
|
||||
MediaArtifactCacheMixin,
|
||||
MediaArtifactInput,
|
||||
)
|
||||
from sglang.srt.multimodal.media_artifacts.kimi_k3 import (
|
||||
KimiK3ImagePreprocessArtifact,
|
||||
KimiK3PreprocessConfig,
|
||||
KimiK3ResizeConfig,
|
||||
)
|
||||
from sglang.srt.multimodal.processors.base_processor import (
|
||||
BaseMultimodalProcessor as SGLangBaseProcessor,
|
||||
)
|
||||
@@ -152,9 +163,24 @@ def _k3_to_cuda_chw(image: Union[torch.Tensor, Image.Image]) -> torch.Tensor:
|
||||
|
||||
|
||||
class KimiK3GPUProcessorWrapper(KimiGPUProcessorWrapper):
|
||||
def __init__(self, *args, transparent_bg_config=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._transparent_bg_config = transparent_bg_config
|
||||
def __init__(self, hf_processor, image_token, image_token_id, config):
|
||||
self.preprocess_config = config
|
||||
super().__init__(
|
||||
hf_processor,
|
||||
image_token=image_token,
|
||||
image_token_id=image_token_id,
|
||||
patch_size=config.patch_size,
|
||||
merge_kernel_size=config.merge_kernel_size,
|
||||
in_patch_limit=config.in_patch_limit,
|
||||
patch_limit_on_one_side=config.patch_limit_on_one_side,
|
||||
fixed_output_tokens=config.fixed_output_tokens,
|
||||
image_mean=config.image_mean,
|
||||
image_std=config.image_std,
|
||||
)
|
||||
self._transparent_bg_config = config.transparent_bg_config
|
||||
|
||||
def preprocess_fingerprint_payload(self):
|
||||
return self.preprocess_config
|
||||
|
||||
def _prepare_input_ids(
|
||||
self, input_text, resize_configs, original_input_ids, image_sizes
|
||||
@@ -285,9 +311,65 @@ class KimiK3GPUProcessorWrapper(KimiGPUProcessorWrapper):
|
||||
)
|
||||
return input_ids, resize_configs, deferred_preprocessing
|
||||
|
||||
def prepare_image_features(self, images):
|
||||
"""Prepare prompt-independent, per-image features in one processor call."""
|
||||
image_sizes = [_get_image_dimensions(image) for image in images]
|
||||
resize_configs = [
|
||||
navit_resize_config(
|
||||
width,
|
||||
height,
|
||||
self._patch_size,
|
||||
self._merge_kernel_size,
|
||||
self._in_patch_limit,
|
||||
self._patch_limit_on_one_side,
|
||||
self._fixed_output_tokens,
|
||||
)
|
||||
for width, height in image_sizes
|
||||
]
|
||||
|
||||
class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
||||
if images and torch.cuda.is_available():
|
||||
image_scale, image_bias = self._get_gpu_norm_tensors()
|
||||
pixel_values, grid_thws = _gpu_preprocess_images(
|
||||
images,
|
||||
resize_configs,
|
||||
image_scale,
|
||||
image_bias,
|
||||
self._patch_size,
|
||||
to_chw=_k3_to_cuda_chw,
|
||||
post_resize=lambda x: _fill_transparent_bg(
|
||||
x, self._transparent_bg_config
|
||||
),
|
||||
)
|
||||
else:
|
||||
# The checkpoint CPU processor couples prompt composition with media
|
||||
# preprocessing. A synthetic prompt keeps that API but is discarded;
|
||||
# image features and grids are independent of its text.
|
||||
output = self._cpu_call(self._image_token * len(images), images)
|
||||
pixel_values = output["pixel_values"]
|
||||
grid_thws = output["image_grid_thw"]
|
||||
|
||||
grids = [tuple(int(value) for value in grid) for grid in grid_thws.tolist()]
|
||||
patch_counts = [math.prod(grid) for grid in grids]
|
||||
if sum(patch_counts) != pixel_values.shape[0]:
|
||||
raise ValueError(
|
||||
"Kimi-K3 processor feature length does not match image grids: "
|
||||
f"{pixel_values.shape[0]} != {sum(patch_counts)}"
|
||||
)
|
||||
return (
|
||||
list(pixel_values.split(patch_counts)),
|
||||
image_sizes,
|
||||
resize_configs,
|
||||
grids,
|
||||
)
|
||||
|
||||
|
||||
class KimiK3ImageProcessor(
|
||||
KimiGridMMDataMixin,
|
||||
MediaArtifactCacheMixin,
|
||||
SGLangBaseProcessor,
|
||||
):
|
||||
models = [KimiK3ForConditionalGeneration]
|
||||
artifact_modality = Modality.IMAGE
|
||||
# K3 accuracy is sensitive to the chroma upsampling used for common 4:2:0
|
||||
# JPEG inputs. This mode uses interpolated nvJPEG upsampling when the K3
|
||||
# image dependency is installed and otherwise falls back to PIL.
|
||||
@@ -307,25 +389,23 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
||||
image_token_regex=re.compile(r"(?:<\|media_pad\|>)+"),
|
||||
).build(_processor)
|
||||
|
||||
media_proc_cfg = _processor.media_processor.media_proc_cfg
|
||||
preprocess_config = KimiK3PreprocessConfig.from_media_processor(
|
||||
_processor.media_processor
|
||||
)
|
||||
|
||||
processor = KimiK3GPUProcessorWrapper(
|
||||
_processor,
|
||||
image_token=mm_tokens.image_token,
|
||||
image_token_id=mm_tokens.image_token_id,
|
||||
patch_size=media_proc_cfg["patch_size"],
|
||||
merge_kernel_size=media_proc_cfg["merge_kernel_size"],
|
||||
in_patch_limit=media_proc_cfg["in_patch_limit"],
|
||||
patch_limit_on_one_side=media_proc_cfg["patch_limit_on_one_side"],
|
||||
fixed_output_tokens=media_proc_cfg.get("fixed_output_tokens"),
|
||||
image_mean=media_proc_cfg["image_mean"],
|
||||
image_std=media_proc_cfg["image_std"],
|
||||
transparent_bg_config=media_proc_cfg.get("transparent_bg_config"),
|
||||
config=preprocess_config,
|
||||
)
|
||||
super().__init__(hf_config, server_args, processor, *args, **kwargs)
|
||||
self.mm_tokens = mm_tokens
|
||||
|
||||
def _should_defer_gpu_preprocessing(self, images) -> bool:
|
||||
"""
|
||||
when raw_bytes <= processed_bytes, preprocess first would introduce larger payload, so deferring gpu preprocessing would benefit
|
||||
"""
|
||||
if (
|
||||
not images
|
||||
or self.mm_feature_transport != "cpu"
|
||||
@@ -340,17 +420,18 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
||||
|
||||
raw_bytes = 0
|
||||
processed_bytes = 0
|
||||
patch_size = self._processor._patch_size
|
||||
config = self._processor.preprocess_config
|
||||
patch_size = config.patch_size
|
||||
for image in images:
|
||||
width, height = _get_image_dimensions(image)
|
||||
resize_config = navit_resize_config(
|
||||
width,
|
||||
height,
|
||||
patch_size,
|
||||
self._processor._merge_kernel_size,
|
||||
self._processor._in_patch_limit,
|
||||
self._processor._patch_limit_on_one_side,
|
||||
self._processor._fixed_output_tokens,
|
||||
config.merge_kernel_size,
|
||||
config.in_patch_limit,
|
||||
config.patch_limit_on_one_side,
|
||||
config.fixed_output_tokens,
|
||||
)
|
||||
if isinstance(image, torch.Tensor):
|
||||
channels = (
|
||||
@@ -391,7 +472,7 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
||||
base_output.images, resize_configs, offsets
|
||||
):
|
||||
grid_thw = _grid_thw_from_resize_config(
|
||||
resize_config, self._processor._patch_size
|
||||
resize_config, self._processor.preprocess_config.patch_size
|
||||
)
|
||||
item = MultimodalDataItem(
|
||||
modality=Modality.IMAGE,
|
||||
@@ -413,6 +494,217 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
||||
im_token_id=self.mm_tokens.image_token_id,
|
||||
)
|
||||
|
||||
def _make_artifact(
|
||||
self,
|
||||
*,
|
||||
content_digest: str,
|
||||
artifact_key: str,
|
||||
original_size: tuple[int, int],
|
||||
resize_config: dict,
|
||||
grid_thw: tuple[int, int, int],
|
||||
feature: torch.Tensor,
|
||||
deferred: Optional[KimiK3DeferredPreprocessing] = None,
|
||||
) -> KimiK3ImagePreprocessArtifact:
|
||||
"""Freeze one image's prompt-independent preprocessing result."""
|
||||
# Use the same feature-hash contract as MultimodalDataItem.
|
||||
feature_hash = resolve_multimodal_item_hash(
|
||||
feature=feature, namespace=artifact_key
|
||||
)
|
||||
if not self.keep_mm_features_on_device and feature.device.type != "cpu":
|
||||
feature = feature.cpu()
|
||||
return KimiK3ImagePreprocessArtifact(
|
||||
content_digest=content_digest,
|
||||
artifact_key=artifact_key,
|
||||
feature_hash=feature_hash,
|
||||
original_size=original_size,
|
||||
resize_config=KimiK3ResizeConfig.from_dict(resize_config),
|
||||
grid_thw=grid_thw,
|
||||
feature=feature,
|
||||
deferred=deferred,
|
||||
)
|
||||
|
||||
def prepare_artifact_batch(
|
||||
self,
|
||||
entries: list[MediaArtifactInput],
|
||||
*,
|
||||
processor=None,
|
||||
) -> list[KimiK3ImagePreprocessArtifact]:
|
||||
"""Preprocess raw cache misses into reusable per-image cache items.
|
||||
|
||||
Each entry is a confirmed cache miss. It is either processed now or
|
||||
stored with the metadata needed for deferred GPU preprocessing.
|
||||
"""
|
||||
processor = processor or self._processor
|
||||
artifacts: list[Optional[KimiK3ImagePreprocessArtifact]] = [None] * len(entries)
|
||||
# 1. collect inputs that must be preprocessed now instead of deferred
|
||||
eager_entry_indices = []
|
||||
eager_images = []
|
||||
|
||||
config = processor.preprocess_config
|
||||
for index, entry in enumerate(entries):
|
||||
image = entry.media
|
||||
if not self._should_defer_gpu_preprocessing([image]):
|
||||
eager_entry_indices.append(index)
|
||||
eager_images.append(image)
|
||||
continue
|
||||
|
||||
width, height = _get_image_dimensions(image)
|
||||
resize_config = navit_resize_config(
|
||||
width,
|
||||
height,
|
||||
config.patch_size,
|
||||
config.merge_kernel_size,
|
||||
config.in_patch_limit,
|
||||
config.patch_limit_on_one_side,
|
||||
config.fixed_output_tokens,
|
||||
)
|
||||
grid_thw = _grid_thw_from_resize_config(resize_config, config.patch_size)
|
||||
feature = to_chw_uint8(image).cpu().contiguous()
|
||||
artifacts[index] = self._make_artifact(
|
||||
content_digest=entry.content_digest,
|
||||
artifact_key=entry.artifact_key,
|
||||
original_size=(width, height),
|
||||
resize_config=resize_config,
|
||||
grid_thw=grid_thw,
|
||||
feature=feature,
|
||||
deferred=KimiK3DeferredPreprocessing(
|
||||
backend="gpu",
|
||||
image_mean=list(config.image_mean),
|
||||
image_std=list(config.image_std),
|
||||
transparent_bg_config=config.transparent_bg_config,
|
||||
resize_config=resize_config,
|
||||
),
|
||||
)
|
||||
|
||||
# 2. preprocess CPU eager inputs as one batch
|
||||
if eager_images:
|
||||
features, sizes, configs, grids = processor.prepare_image_features(
|
||||
eager_images
|
||||
)
|
||||
for index, feature, size, resize_config, grid in zip(
|
||||
eager_entry_indices, features, sizes, configs, grids
|
||||
):
|
||||
entry = entries[index]
|
||||
artifacts[index] = self._make_artifact(
|
||||
content_digest=entry.content_digest,
|
||||
artifact_key=entry.artifact_key,
|
||||
original_size=size,
|
||||
resize_config=resize_config,
|
||||
grid_thw=grid,
|
||||
feature=feature,
|
||||
)
|
||||
|
||||
# 3. return artifacts in the original processor-input order
|
||||
if any(artifact is None for artifact in artifacts):
|
||||
raise RuntimeError("Kimi-K3 artifact batch did not produce every image")
|
||||
return [artifact for artifact in artifacts if artifact is not None]
|
||||
|
||||
def compose_request(
|
||||
self,
|
||||
input_text,
|
||||
artifacts: list[KimiK3ImagePreprocessArtifact],
|
||||
) -> MultimodalProcessorOutput:
|
||||
"""Compose the current request from its prompt and ordered artifacts.
|
||||
|
||||
``prepare_media_artifacts`` has already returned one artifact for each
|
||||
processor input, either from the preprocess cache or from fresh
|
||||
preprocessing. This method expands the current prompt's image tokens
|
||||
and converts each artifact into its request-specific
|
||||
``MultimodalDataItem`` with offsets, grid metadata, feature, and feature
|
||||
hash. It does not read raw media or access the preprocess cache.
|
||||
"""
|
||||
# 1. rebuild prompt-specific tokens and offsets
|
||||
original_ids = (
|
||||
input_text
|
||||
if isinstance(input_text, (list, torch.Tensor))
|
||||
else _encode_k3_special_tokens(self._tokenizer, input_text)
|
||||
)
|
||||
input_ids = _expand_k3_image_prompt_token_ids(
|
||||
original_ids,
|
||||
self.mm_tokens.image_token_id,
|
||||
[artifact.resize_config.num_tokens for artifact in artifacts],
|
||||
[artifact.original_size for artifact in artifacts],
|
||||
self._tokenizer,
|
||||
).flatten()
|
||||
offsets = self.get_mm_items_offset(input_ids, self.mm_tokens.image_token_id)
|
||||
if len(offsets) != len(artifacts):
|
||||
raise ValueError("Expected one Kimi-K3 image span for each image")
|
||||
|
||||
# 2. build request-owned items from prompt-independent artifacts
|
||||
items = []
|
||||
for artifact, offset in zip(artifacts, offsets):
|
||||
model_specific_data = {
|
||||
"image_grid_thw": torch.tensor([artifact.grid_thw], dtype=torch.int64)
|
||||
}
|
||||
if artifact.deferred is not None:
|
||||
model_specific_data[DEFERRED_PREPROCESSING_KEY] = artifact.deferred
|
||||
item = MultimodalDataItem(
|
||||
modality=Modality.IMAGE,
|
||||
feature=artifact.feature,
|
||||
offsets=[offset],
|
||||
model_specific_data=model_specific_data,
|
||||
)
|
||||
item.set_hash(artifact.feature_hash)
|
||||
if self.use_cuda_ipc and isinstance(item.feature, torch.Tensor):
|
||||
item.feature = self._wrap_tensor_for_cuda_ipc(item.feature)
|
||||
if self.keep_mm_features_on_device and item.feature is not None:
|
||||
item.model_specific_data[DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY] = (
|
||||
True
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
return MultimodalProcessorOutput(
|
||||
input_ids=input_ids.tolist(),
|
||||
mm_items=items,
|
||||
im_token_id=self.mm_tokens.image_token_id,
|
||||
)
|
||||
|
||||
async def _process_mm_data_uncached(
|
||||
self, image_data, input_text, request_obj, **kwargs
|
||||
):
|
||||
"""Compatibility path for precomputed inputs and lightweight test stubs."""
|
||||
expected_image_count = len(image_data or [])
|
||||
placeholder_count = self.count_image_placeholders(
|
||||
input_text, self.mm_tokens.image_token_id
|
||||
)
|
||||
if placeholder_count is not None:
|
||||
base_output = await self.fast_load_mm_data(
|
||||
prompt=input_text,
|
||||
image_data=image_data,
|
||||
multimodal_tokens=self.mm_tokens,
|
||||
discard_alpha_channel=False,
|
||||
input_ids=input_text,
|
||||
)
|
||||
else:
|
||||
base_output = await self.load_mm_data(
|
||||
prompt=input_text,
|
||||
image_data=image_data,
|
||||
multimodal_tokens=self.mm_tokens,
|
||||
discard_alpha_channel=False,
|
||||
)
|
||||
if len(base_output.images) != expected_image_count:
|
||||
raise ValueError(
|
||||
"Kimi image placeholders must map one-to-one to image data: "
|
||||
f"expected {expected_image_count}, loaded {len(base_output.images)}"
|
||||
)
|
||||
if self._should_defer_gpu_preprocessing(base_output.images):
|
||||
return self._build_deferred_output(base_output)
|
||||
mm_items, input_ids, _ = await self.process_and_combine_mm_data_async(
|
||||
base_output,
|
||||
self.mm_tokens,
|
||||
sglang_original_input_ids=base_output.input_ids,
|
||||
)
|
||||
if self.keep_mm_features_on_device:
|
||||
for item in mm_items:
|
||||
item.model_specific_data[DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY] = (
|
||||
True
|
||||
)
|
||||
return MultimodalProcessorOutput(
|
||||
input_ids=input_ids.tolist(),
|
||||
mm_items=mm_items,
|
||||
im_token_id=self.mm_tokens.image_token_id,
|
||||
)
|
||||
|
||||
async def process_mm_data_async(
|
||||
self,
|
||||
image_data: List[Union[str, bytes, Dict]],
|
||||
@@ -421,7 +713,7 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
if getattr(request_obj, "video_data", None) or kwargs.get("audio_data"):
|
||||
if request_obj.video_data or kwargs.get("audio_data"):
|
||||
raise ValueError("Kimi-K3 supports image input only")
|
||||
|
||||
expected_image_count = len(image_data or [])
|
||||
@@ -434,60 +726,21 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
||||
"Kimi image placeholders must map one-to-one to image data: "
|
||||
f"expected {expected_image_count}, found {placeholder_count} token(s)"
|
||||
)
|
||||
# Keep structural media tokens distinct from user text that happens to
|
||||
# spell ``<|media_pad|>``. Decoding the whole prompt and matching the
|
||||
# resulting string would lose that distinction and could bind an image
|
||||
# to user-provided text instead of the renderer-inserted token.
|
||||
base_output = await self.fast_load_mm_data(
|
||||
prompt=input_text,
|
||||
image_data=image_data,
|
||||
multimodal_tokens=self.mm_tokens,
|
||||
discard_alpha_channel=False,
|
||||
# Unlike load_mm_data, fast_load_mm_data does not derive
|
||||
# input_ids from the prompt. Without this the wrapper falls
|
||||
# back to re-encoding the decoded string, which is the loss of
|
||||
# the structural/user distinction described above.
|
||||
input_ids=input_text,
|
||||
)
|
||||
else:
|
||||
base_output = await self.load_mm_data(
|
||||
prompt=input_text,
|
||||
image_data=image_data,
|
||||
multimodal_tokens=self.mm_tokens,
|
||||
discard_alpha_channel=False,
|
||||
if (
|
||||
any(self._is_preprocessed_input(item) for item in image_data)
|
||||
or not self.mm_preprocess_cache.enabled
|
||||
):
|
||||
# 1. keep preprocessed inputs and cache-off requests on the legacy path
|
||||
return await self._process_mm_data_uncached(
|
||||
image_data, input_text, request_obj, **kwargs
|
||||
)
|
||||
|
||||
if len(base_output.images) != expected_image_count:
|
||||
raise ValueError(
|
||||
"Kimi image placeholders must map one-to-one to image data: "
|
||||
f"expected {expected_image_count}, loaded {len(base_output.images)}"
|
||||
)
|
||||
|
||||
if self._should_defer_gpu_preprocessing(base_output.images):
|
||||
return self._build_deferred_output(base_output)
|
||||
|
||||
mm_items, input_ids, _ = await self.process_and_combine_mm_data_async(
|
||||
base_output,
|
||||
self.mm_tokens,
|
||||
sglang_original_input_ids=base_output.input_ids,
|
||||
)
|
||||
|
||||
# K3's tower is unconditionally image-wise data-parallel (each image
|
||||
# is consumed by exactly one TP rank), so keep IPC proxies lazy until
|
||||
# that assignment is known: one tokenizer/scheduler crossing per
|
||||
# image instead of one per rank. K2.5 gates this on
|
||||
# --mm-enable-dp-encoder; K3 needs no flag.
|
||||
if self.keep_mm_features_on_device:
|
||||
for item in mm_items:
|
||||
item.model_specific_data[DEFER_CUDA_IPC_FEATURE_RECONSTRUCTION_KEY] = (
|
||||
True
|
||||
)
|
||||
|
||||
return MultimodalProcessorOutput(
|
||||
input_ids=input_ids.tolist(),
|
||||
mm_items=mm_items,
|
||||
im_token_id=self.mm_tokens.image_token_id,
|
||||
# 2. resolve per-image artifacts before composing the current prompt
|
||||
artifacts = await self.prepare_media_artifacts(
|
||||
image_data,
|
||||
content_hashes=request_obj.mm_content_hashes,
|
||||
)
|
||||
return self.compose_request(input_text, artifacts)
|
||||
|
||||
def get_mm_data(self, prompt, embeddings, **kwargs):
|
||||
img_grid_thw = kwargs.get("img_grid_thw", None)
|
||||
|
||||
Reference in New Issue
Block a user