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.io_struct import GenerateReqInput, TokenizedGenerateReqInput
|
||||||
from sglang.srt.managers.multimodal_processor import get_mm_processor, import_processors
|
from sglang.srt.managers.multimodal_processor import get_mm_processor, import_processors
|
||||||
from sglang.srt.managers.schedule_batch import Modality, Req
|
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.server_args import ServerArgs
|
||||||
from sglang.srt.utils import ImageData
|
from sglang.srt.utils import ImageData
|
||||||
from sglang.srt.utils.common import safe_pickle_loads
|
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
|
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):
|
def calculate_modality_num_parts(modalities, num_items_assigned):
|
||||||
"""
|
"""
|
||||||
Calculate total number of parts and number of parts per modality.
|
Calculate total number of parts and number of parts per modality.
|
||||||
@@ -1102,7 +1113,7 @@ class WaitingImageRDMARequest(WaitingImageRequest):
|
|||||||
{
|
{
|
||||||
"encoder_idx": idx,
|
"encoder_idx": idx,
|
||||||
"mm_items": [
|
"mm_items": [
|
||||||
d["url"]
|
_encoder_media_item(d)
|
||||||
for d in mm_data_modality[
|
for d in mm_data_modality[
|
||||||
cum_num_items : cum_num_items + assigned_num
|
cum_num_items : cum_num_items + assigned_num
|
||||||
]
|
]
|
||||||
@@ -2171,7 +2182,7 @@ class MMReceiverBase(ABC):
|
|||||||
|
|
||||||
return num_items_assigned
|
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):
|
def flatten_mm_items(items):
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
return [items]
|
return [items]
|
||||||
@@ -2193,21 +2204,47 @@ class MMReceiverBase(ABC):
|
|||||||
return mm_item
|
return mm_item
|
||||||
|
|
||||||
mm_data = []
|
mm_data = []
|
||||||
for attr, modality in [
|
image_hashes = request_obj.mm_content_hashes
|
||||||
("image_data", Modality.IMAGE),
|
image_index = 0
|
||||||
("video_data", Modality.VIDEO),
|
for mm_items, modality in [
|
||||||
("audio_data", Modality.AUDIO),
|
(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:
|
if mm_items:
|
||||||
mm_items = flatten_mm_items(mm_items)
|
mm_items = flatten_mm_items(mm_items)
|
||||||
for mm_item in mm_items:
|
for mm_item in mm_items:
|
||||||
mm_data.append(
|
entry = {
|
||||||
{
|
"url": to_raw_url(mm_item),
|
||||||
"url": to_raw_url(mm_item),
|
"modality": modality,
|
||||||
"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
|
return mm_data
|
||||||
|
|
||||||
|
|
||||||
@@ -2329,7 +2366,7 @@ class MMReceiverHTTP(MMReceiverBase):
|
|||||||
"encoder_idx": idx,
|
"encoder_idx": idx,
|
||||||
"encoder_url": effective_urls[idx],
|
"encoder_url": effective_urls[idx],
|
||||||
"mm_items": [
|
"mm_items": [
|
||||||
mm_item.get("url")
|
_encoder_media_item(mm_item)
|
||||||
for mm_item in mm_data_modality[
|
for mm_item in mm_data_modality[
|
||||||
cum_num_items : cum_num_items + assigned_num
|
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,
|
maybe_precompile_model_kernels_after_loading,
|
||||||
)
|
)
|
||||||
from sglang.srt.model_loader import get_model
|
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 (
|
from sglang.srt.multimodal.encoder_preprocessing import (
|
||||||
EncoderPreprocessOutput,
|
EncoderPreprocessOutput,
|
||||||
get_encoder_preprocessed_items,
|
get_encoder_preprocessed_items,
|
||||||
invoke_encoder_preprocessor,
|
invoke_encoder_preprocessor,
|
||||||
|
resolve_encoder_media_processor_config,
|
||||||
)
|
)
|
||||||
from sglang.srt.multimodal.processors.qwen_vl import preprocess_video
|
from sglang.srt.multimodal.processors.qwen_vl import preprocess_video
|
||||||
from sglang.srt.observability.metrics_collector import EncoderMetricsCollector
|
from sglang.srt.observability.metrics_collector import EncoderMetricsCollector
|
||||||
@@ -369,6 +371,9 @@ class MMEncoder:
|
|||||||
load_config=self.load_config,
|
load_config=self.load_config,
|
||||||
device_config=self.device_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)
|
maybe_precompile_model_kernels_after_loading(self.model, self.device)
|
||||||
|
|
||||||
self.context = zmq.asyncio.Context(2)
|
self.context = zmq.asyncio.Context(2)
|
||||||
@@ -670,13 +675,27 @@ class MMEncoder:
|
|||||||
Load a single multimodal data.
|
Load a single multimodal data.
|
||||||
If data is precomputed, returns directly.
|
If data is precomputed, returns directly.
|
||||||
Static method that can be pickled for multiprocessing"""
|
Static method that can be pickled for multiprocessing"""
|
||||||
|
media_metadata = {}
|
||||||
|
content_hash = None
|
||||||
if isinstance(data, dict):
|
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:
|
try:
|
||||||
if modality == Modality.IMAGE:
|
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 = (
|
gpu_image_decode = (
|
||||||
"nvjpeg_fancy"
|
self.encoder_media_processor_config.image_decode_mode
|
||||||
if self.use_image_processor_gpu and self.model_type == "kimi_k3"
|
if self.use_image_processor_gpu
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
img, _ = load_image(data, gpu_image_decode)
|
img, _ = load_image(data, gpu_image_decode)
|
||||||
@@ -687,12 +706,23 @@ class MMEncoder:
|
|||||||
):
|
):
|
||||||
# Needed only when `img` is a PIL image
|
# Needed only when `img` is a PIL image
|
||||||
img = img.convert("RGB")
|
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
|
return img
|
||||||
elif modality == Modality.VIDEO:
|
elif modality == Modality.VIDEO:
|
||||||
return load_video(data, frame_count_limit)
|
return load_video(data, frame_count_limit)
|
||||||
elif modality == Modality.AUDIO:
|
elif modality == Modality.AUDIO:
|
||||||
return load_audio(data, self.model_audio_sr)
|
return load_audio(data, self.model_audio_sr)
|
||||||
|
|
||||||
|
except MMError:
|
||||||
|
raise
|
||||||
except CLIENT_MEDIA_EXCEPTIONS as e:
|
except CLIENT_MEDIA_EXCEPTIONS as e:
|
||||||
# Not ValueError: the DP envelope classifies by `.code`, which only MMError carries.
|
# Not ValueError: the DP envelope classifies by `.code`, which only MMError carries.
|
||||||
raise BadRequestError(f"Error while loading data {data}: {e}") from e
|
raise BadRequestError(f"Error while loading data {data}: {e}") from e
|
||||||
|
|||||||
@@ -378,21 +378,13 @@ class MultimodalDataItem:
|
|||||||
if self.pad_value is not None:
|
if self.pad_value is not None:
|
||||||
return
|
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():
|
self.hash = resolve_multimodal_item_hash(
|
||||||
import uuid
|
existing_hash=self.hash,
|
||||||
|
feature=self.feature,
|
||||||
self.hash = uuid.uuid4().int
|
precomputed_embeddings=self.precomputed_embeddings,
|
||||||
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.pad_value = _compute_pad_value(self.hash)
|
self.pad_value = _compute_pad_value(self.hash)
|
||||||
|
|
||||||
def is_modality(self, modality: Modality) -> bool:
|
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.transformers import maybe_prefix
|
||||||
from sglang.srt.models.utils import WeightsMapper
|
from sglang.srt.models.utils import WeightsMapper
|
||||||
|
from sglang.srt.multimodal.encoder_preprocessing import EncoderMediaProcessorConfig
|
||||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||||
DEFERRED_PREPROCESSING_KEY,
|
DEFERRED_PREPROCESSING_KEY,
|
||||||
fill_transparent_bg,
|
fill_transparent_bg,
|
||||||
@@ -3075,6 +3076,10 @@ class KimiK3ForConditionalGeneration(nn.Module):
|
|||||||
"""K3 multimodal wrapper: MoonViT3d tower + KimiK3LinearForCausalLM."""
|
"""K3 multimodal wrapper: MoonViT3d tower + KimiK3LinearForCausalLM."""
|
||||||
|
|
||||||
supports_cuda_vmm_feature_transport = True
|
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.
|
# Raw HF checkpoint prefixes, before hf_to_sglang_mapper is applied.
|
||||||
encoder_only_safetensors_weight_prefixes = (
|
encoder_only_safetensors_weight_prefixes = (
|
||||||
@@ -3259,49 +3264,79 @@ class KimiK3ForConditionalGeneration(nn.Module):
|
|||||||
for item in selected_items
|
for item in selected_items
|
||||||
]
|
]
|
||||||
if any(config is not None for config in deferred):
|
if any(config is not None for config in deferred):
|
||||||
if not all(config is not None for config in deferred):
|
materialized = [None] * len(selected_items)
|
||||||
raise ValueError(
|
deferred_by_backend = {}
|
||||||
"Kimi-K3 cannot mix deferred and preprocessed image features"
|
for index, (item, config) in enumerate(zip(selected_items, deferred)):
|
||||||
)
|
if config is None:
|
||||||
first_config = deferred[0]
|
if not isinstance(item.feature, torch.Tensor):
|
||||||
backend = first_config.backend
|
raise TypeError(
|
||||||
if any(config.backend != backend for config in deferred):
|
"Kimi-K3 image feature must be a torch.Tensor, "
|
||||||
raise ValueError(
|
f"got {type(item.feature)}"
|
||||||
"Kimi-K3 cannot mix deferred preprocessing backends"
|
)
|
||||||
)
|
materialized[index] = item.feature
|
||||||
if backend == "gpu":
|
else:
|
||||||
from sglang.srt.multimodal.processors.kimi_k25 import (
|
deferred_by_backend.setdefault(config.backend, []).append(index)
|
||||||
_gpu_preprocess_images,
|
|
||||||
)
|
|
||||||
|
|
||||||
image_scale, image_bias = normalization_tensors(
|
for backend, indices in deferred_by_backend.items():
|
||||||
first_config.image_mean, first_config.image_std, device
|
group_items = [selected_items[index] for index in indices]
|
||||||
)
|
group_configs = [deferred[index] for index in indices]
|
||||||
pixel_values, _ = _gpu_preprocess_images(
|
first_config = group_configs[0]
|
||||||
[item.feature for item in selected_items],
|
if backend == "gpu":
|
||||||
[config.resize_config for config in deferred],
|
from sglang.srt.multimodal.processors.kimi_k25 import (
|
||||||
image_scale,
|
_gpu_preprocess_images,
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
pixel_values = materialize_kimi_k3_cpu_features(
|
image_scale, image_bias = normalization_tensors(
|
||||||
selected_items, self._encoder_image_processor
|
first_config.image_mean,
|
||||||
)
|
first_config.image_std,
|
||||||
pixel_values = pixel_values.to(device, non_blocking=True)
|
device,
|
||||||
else:
|
)
|
||||||
raise ValueError(
|
pixel_values, produced_grids = _gpu_preprocess_images(
|
||||||
f"Unsupported Kimi-K3 deferred preprocessing backend: {backend}"
|
[item.feature for item in group_items],
|
||||||
)
|
[config.resize_config for config in group_configs],
|
||||||
return pixel_values.to(dtype=target_dtype)
|
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 = []
|
features = []
|
||||||
for item in selected_items:
|
for item in selected_items:
|
||||||
|
|||||||
+10
-2
@@ -3,27 +3,35 @@
|
|||||||
from sglang.srt.multimodal.cache.identity import (
|
from sglang.srt.multimodal.cache.identity import (
|
||||||
CONTENT_HASH_PREFIX,
|
CONTENT_HASH_PREFIX,
|
||||||
MediaSnapshot,
|
MediaSnapshot,
|
||||||
|
PreprocessFingerprintProvider,
|
||||||
build_artifact_key,
|
build_artifact_key,
|
||||||
build_feature_hash,
|
|
||||||
build_processor_fingerprint,
|
build_processor_fingerprint,
|
||||||
|
media_preprocess_kwargs,
|
||||||
parse_content_hash,
|
parse_content_hash,
|
||||||
|
resolve_multimodal_item_hash,
|
||||||
snapshot_media,
|
snapshot_media,
|
||||||
)
|
)
|
||||||
from sglang.srt.multimodal.cache.preprocess_cache import (
|
from sglang.srt.multimodal.cache.preprocess_cache import (
|
||||||
CacheLookup,
|
CacheLookup,
|
||||||
|
CacheMiss,
|
||||||
|
CacheSizeProvider,
|
||||||
MultimodalPreprocessCache,
|
MultimodalPreprocessCache,
|
||||||
estimate_cache_size_bytes,
|
estimate_cache_size_bytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CONTENT_HASH_PREFIX",
|
"CONTENT_HASH_PREFIX",
|
||||||
|
"CacheSizeProvider",
|
||||||
"CacheLookup",
|
"CacheLookup",
|
||||||
|
"CacheMiss",
|
||||||
"MediaSnapshot",
|
"MediaSnapshot",
|
||||||
"MultimodalPreprocessCache",
|
"MultimodalPreprocessCache",
|
||||||
|
"PreprocessFingerprintProvider",
|
||||||
"build_artifact_key",
|
"build_artifact_key",
|
||||||
"build_feature_hash",
|
|
||||||
"build_processor_fingerprint",
|
"build_processor_fingerprint",
|
||||||
"estimate_cache_size_bytes",
|
"estimate_cache_size_bytes",
|
||||||
|
"media_preprocess_kwargs",
|
||||||
"parse_content_hash",
|
"parse_content_hash",
|
||||||
|
"resolve_multimodal_item_hash",
|
||||||
"snapshot_media",
|
"snapshot_media",
|
||||||
]
|
]
|
||||||
|
|||||||
+104
-28
@@ -9,7 +9,7 @@ import struct
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
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
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -17,8 +17,21 @@ import torch
|
|||||||
import transformers
|
import transformers
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sglang.srt.server_args import ServerArgs
|
||||||
|
|
||||||
CONTENT_HASH_PREFIX = "sha256:"
|
CONTENT_HASH_PREFIX = "sha256:"
|
||||||
_SHA256_HEX_LENGTH = 64
|
_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]:
|
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__}")
|
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:
|
def _qualified_type_name(value: Any) -> str:
|
||||||
value_type = type(value)
|
value_type = type(value)
|
||||||
return f"{value_type.__module__}.{value_type.__qualname__}"
|
return f"{value_type.__module__}.{value_type.__qualname__}"
|
||||||
@@ -173,8 +223,10 @@ def _canonicalize(value: Any) -> Any:
|
|||||||
"type": "dataclass",
|
"type": "dataclass",
|
||||||
"class": _qualified_type_name(value),
|
"class": _qualified_type_name(value),
|
||||||
"fields": [
|
"fields": [
|
||||||
[field.name, _canonicalize(getattr(value, field.name))]
|
[field.name, _canonicalize(field_value)]
|
||||||
for field in dataclasses.fields(value)
|
for field, field_value in zip(
|
||||||
|
dataclasses.fields(value), dataclasses.astuple(value)
|
||||||
|
)
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
if isinstance(value, Enum):
|
if isinstance(value, Enum):
|
||||||
@@ -273,24 +325,49 @@ def build_artifact_key(
|
|||||||
return _digest_bytes(_canonical_json(payload))
|
return _digest_bytes(_canonical_json(payload))
|
||||||
|
|
||||||
|
|
||||||
def build_feature_hash(artifact_key: str, processor_output_hash: int) -> int:
|
def resolve_multimodal_item_hash(
|
||||||
"""Namespace a processor-output hash by its complete artifact identity."""
|
*,
|
||||||
artifact_key = parse_content_hash(artifact_key)
|
existing_hash: Optional[int] = None,
|
||||||
if (
|
feature: Any = None,
|
||||||
isinstance(processor_output_hash, bool)
|
precomputed_embeddings: Any = None,
|
||||||
or not isinstance(processor_output_hash, int)
|
namespace: Optional[str] = None,
|
||||||
or processor_output_hash < 0
|
) -> int:
|
||||||
):
|
"""Unified helper for resolving a hash for MultimodalDataItem cache, optionally scoped to an artifact identity.
|
||||||
raise ValueError("processor_output_hash must be a non-negative integer")
|
|
||||||
output_hash_bytes = processor_output_hash.to_bytes(
|
Args:
|
||||||
max(1, (processor_output_hash.bit_length() + 7) // 8),
|
namespace: Optional SHA-256 identity covering every input that can change the preprocessing result.
|
||||||
byteorder="big",
|
It scopes the feature hash so downstream caches cannot reuse embeddings across different preprocessing settings.
|
||||||
signed=False,
|
"""
|
||||||
|
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(
|
digest = _hash_parts(
|
||||||
b"multimodal-feature-v1",
|
b"multimodal-feature-v1",
|
||||||
bytes.fromhex(artifact_key[len(CONTENT_HASH_PREFIX) :]),
|
bytes.fromhex(namespace[len(CONTENT_HASH_PREFIX) :]),
|
||||||
output_hash_bytes,
|
hash_bytes,
|
||||||
)
|
)
|
||||||
return int.from_bytes(
|
return int.from_bytes(
|
||||||
bytes.fromhex(digest[len(CONTENT_HASH_PREFIX) :])[:8],
|
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(
|
def build_processor_fingerprint(
|
||||||
processor: Any,
|
processor: Any,
|
||||||
hf_config: Any,
|
hf_config: Any,
|
||||||
server_args: Any,
|
server_args: ServerArgs,
|
||||||
*,
|
*,
|
||||||
extra: Optional[Mapping[str, Any]] = None,
|
extra: Optional[Mapping[str, Any]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fingerprint preprocessing choices that can change processor output."""
|
"""Fingerprint preprocessing choices that can change processor output."""
|
||||||
processor_payload = (
|
processor_payload = (
|
||||||
processor.preprocess_fingerprint_payload()
|
processor.preprocess_fingerprint_payload()
|
||||||
if hasattr(processor, "preprocess_fingerprint_payload")
|
if isinstance(processor, PreprocessFingerprintProvider)
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
|
hf_payload = hf_config.to_dict()
|
||||||
payload = {
|
payload = {
|
||||||
"transformers": transformers.__version__,
|
"transformers": transformers.__version__,
|
||||||
"processor_class": f"{type(processor).__module__}.{type(processor).__qualname__}",
|
"processor_class": f"{type(processor).__module__}.{type(processor).__qualname__}",
|
||||||
"model_type": getattr(hf_config, "model_type", None),
|
"model_type": hf_payload.get("model_type"),
|
||||||
"architectures": getattr(hf_config, "architectures", None),
|
"architectures": hf_payload.get("architectures"),
|
||||||
"model_revision": getattr(server_args, "revision", None),
|
"model_revision": server_args.revision,
|
||||||
"tokenizer_revision": getattr(server_args, "tokenizer_revision", None),
|
"processor_revision": server_args.revision,
|
||||||
"disable_fast_image_processor": getattr(
|
"disable_fast_image_processor": server_args.disable_fast_image_processor,
|
||||||
server_args, "disable_fast_image_processor", False
|
"mm_process_config": server_args.mm_process_config or {},
|
||||||
),
|
|
||||||
"mm_process_config": getattr(server_args, "mm_process_config", None) or {},
|
|
||||||
"processor": processor_payload,
|
"processor": processor_payload,
|
||||||
"extra": extra or {},
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import dataclasses
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
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 numpy as np
|
||||||
import torch
|
import torch
|
||||||
@@ -17,21 +31,45 @@ from PIL import Image
|
|||||||
|
|
||||||
K = TypeVar("K")
|
K = TypeVar("K")
|
||||||
V = TypeVar("V")
|
V = TypeVar("V")
|
||||||
|
_USE_RESULT = object()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CacheLookup(Generic[V]):
|
class CacheLookup(Generic[V]):
|
||||||
|
"""A resolved value returned immediately or after shared computation."""
|
||||||
|
|
||||||
value: V
|
value: V
|
||||||
hit: bool
|
hit: bool
|
||||||
joined: bool = False
|
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
|
@dataclass
|
||||||
class _Entry(Generic[V]):
|
class _Entry(Generic[V]):
|
||||||
value: V
|
value: V
|
||||||
size_bytes: int
|
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]:
|
def estimate_cache_size_bytes(value: Any) -> Optional[int]:
|
||||||
"""Estimate owned CPU bytes, returning None for GPU-backed artifacts."""
|
"""Estimate owned CPU bytes, returning None for GPU-backed artifacts."""
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
@@ -56,9 +94,9 @@ def estimate_cache_size_bytes(value: Any) -> Optional[int]:
|
|||||||
return len(item)
|
return len(item)
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
return len(item.encode())
|
return len(item.encode())
|
||||||
if dataclasses.is_dataclass(item):
|
if isinstance(item, CacheSizeProvider):
|
||||||
return visit(dataclasses.asdict(item))
|
return visit(item.cache_size_items())
|
||||||
if isinstance(item, dict):
|
if isinstance(item, Mapping):
|
||||||
total = 0
|
total = 0
|
||||||
for key, child in item.items():
|
for key, child in item.items():
|
||||||
key_size = visit(key)
|
key_size = visit(key)
|
||||||
@@ -81,7 +119,13 @@ def estimate_cache_size_bytes(value: Any) -> Optional[int]:
|
|||||||
|
|
||||||
|
|
||||||
class MultimodalPreprocessCache(Generic[K, V]):
|
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):
|
def __init__(self, max_size_bytes: int, max_entries: int = 8192):
|
||||||
if max_size_bytes < 0:
|
if max_size_bytes < 0:
|
||||||
@@ -103,6 +147,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def enabled(self) -> bool:
|
def enabled(self) -> bool:
|
||||||
|
"""Whether values can be retained; zero bytes is the cache kill switch."""
|
||||||
return self.max_size_bytes > 0
|
return self.max_size_bytes > 0
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
@@ -114,6 +159,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
return key in self._entries
|
return key in self._entries
|
||||||
|
|
||||||
def get(self, key: K) -> Optional[V]:
|
def get(self, key: K) -> Optional[V]:
|
||||||
|
"""Read and touch an LRU entry, recording a hit or miss."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
entry = self._entries.get(key)
|
entry = self._entries.get(key)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
@@ -123,6 +169,31 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
self.hits += 1
|
self.hits += 1
|
||||||
return entry.value
|
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(
|
def put(
|
||||||
self,
|
self,
|
||||||
key: K,
|
key: K,
|
||||||
@@ -131,6 +202,12 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
*,
|
*,
|
||||||
_generation: Optional[int] = None,
|
_generation: Optional[int] = None,
|
||||||
) -> bool:
|
) -> 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:
|
if not self.enabled:
|
||||||
return False
|
return False
|
||||||
if size_bytes is None:
|
if size_bytes is None:
|
||||||
@@ -139,6 +216,8 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
with self._lock:
|
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:
|
if _generation is not None and _generation != self._generation:
|
||||||
return False
|
return False
|
||||||
old = self._entries.pop(key, None)
|
old = self._entries.pop(key, None)
|
||||||
@@ -156,6 +235,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def pop(self, key: K) -> Optional[V]:
|
def pop(self, key: K) -> Optional[V]:
|
||||||
|
"""Remove and return one entry without changing hit/miss counters."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
entry = self._entries.pop(key, None)
|
entry = self._entries.pop(key, None)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
@@ -164,6 +244,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
return entry.value
|
return entry.value
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
|
"""Drop values and prevent older in-flight work from repopulating them."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._entries.clear()
|
self._entries.clear()
|
||||||
self.current_size_bytes = 0
|
self.current_size_bytes = 0
|
||||||
@@ -178,6 +259,14 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
*,
|
*,
|
||||||
size_bytes: Optional[Callable[[V], Optional[int]]] = None,
|
size_bytes: Optional[Callable[[V], Optional[int]]] = None,
|
||||||
) -> CacheLookup[V]:
|
) -> 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)
|
cached = self.get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return CacheLookup(cached, hit=True)
|
return CacheLookup(cached, hit=True)
|
||||||
@@ -188,25 +277,25 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
future = concurrent.futures.Future()
|
future = concurrent.futures.Future()
|
||||||
generation = self._generation
|
generation = self._generation
|
||||||
self._inflight[key] = (future, generation)
|
self._inflight[key] = (future, generation)
|
||||||
owner = True
|
should_compute = True
|
||||||
else:
|
else:
|
||||||
future, generation = inflight
|
future, generation = inflight
|
||||||
self.singleflight_joins += 1
|
self.singleflight_joins += 1
|
||||||
owner = False
|
should_compute = False
|
||||||
|
|
||||||
if owner:
|
if should_compute:
|
||||||
self.create_background_task(
|
self.create_background_task(
|
||||||
self._compute_owned_value(
|
self._compute_shared_value(
|
||||||
key, future, generation, compute, size_bytes=size_bytes
|
key, future, generation, compute, size_bytes=size_bytes
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# The cache owns the shared computation. Cancelling either its first
|
# 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))
|
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,
|
self,
|
||||||
key: K,
|
key: K,
|
||||||
future: concurrent.futures.Future[V],
|
future: concurrent.futures.Future[V],
|
||||||
@@ -215,6 +304,7 @@ class MultimodalPreprocessCache(Generic[K, V]):
|
|||||||
*,
|
*,
|
||||||
size_bytes: Optional[Callable[[V], Optional[int]]],
|
size_bytes: Optional[Callable[[V], Optional[int]]],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Compute once, cache the result, and wake every caller for this key."""
|
||||||
try:
|
try:
|
||||||
value = await compute()
|
value = await compute()
|
||||||
measured = size_bytes(value) if size_bytes is not None else None
|
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)
|
task.add_done_callback(self._background_task_done)
|
||||||
return task
|
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]:
|
def stats(self) -> dict[str, int]:
|
||||||
|
"""Return a lock-consistent snapshot of cache and single-flight state."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return {
|
return {
|
||||||
"entries": len(self._entries),
|
"entries": len(self._entries),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Mapping
|
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 numpy as np
|
||||||
import torch
|
import torch
|
||||||
@@ -11,6 +12,30 @@ from sglang.srt.managers.schedule_batch import MultimodalDataItem
|
|||||||
LOCAL_PREPROCESSED_KEY = "encoder_local_preprocessed"
|
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:
|
def hash_raw_encoder_item(value: Any) -> int:
|
||||||
"""Hash raw CPU media including layout metadata, before owner materialization."""
|
"""Hash raw CPU media including layout metadata, before owner materialization."""
|
||||||
if isinstance(value, torch.Tensor):
|
if isinstance(value, torch.Tensor):
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ def prepare_kimi_k3_encoder_inputs(
|
|||||||
navit_resize_config,
|
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):
|
if not isinstance(media_proc_cfg, dict):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Kimi-K3 EPD owner-side preprocessing requires "
|
"Kimi-K3 EPD owner-side preprocessing requires "
|
||||||
@@ -69,12 +75,16 @@ def prepare_kimi_k3_encoder_inputs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
concrete_images = []
|
concrete_images = []
|
||||||
|
content_digests = []
|
||||||
for image in images:
|
for image in images:
|
||||||
|
content_digest = None
|
||||||
if isinstance(image, dict):
|
if isinstance(image, dict):
|
||||||
if image.get("type") != "image" or "image" not in image:
|
if image.get("type") != "image" or "image" not in image:
|
||||||
raise ValueError(f"Unsupported Kimi-K3 encoder media item: {image}")
|
raise ValueError(f"Unsupported Kimi-K3 encoder media item: {image}")
|
||||||
|
content_digest = image.get("content_hash")
|
||||||
image = image["image"]
|
image = image["image"]
|
||||||
concrete_images.append(image)
|
concrete_images.append(image)
|
||||||
|
content_digests.append(content_digest)
|
||||||
|
|
||||||
patch_size = int(media_proc_cfg["patch_size"])
|
patch_size = int(media_proc_cfg["patch_size"])
|
||||||
merge_kernel_size = int(media_proc_cfg["merge_kernel_size"])
|
merge_kernel_size = int(media_proc_cfg["merge_kernel_size"])
|
||||||
@@ -89,7 +99,7 @@ def prepare_kimi_k3_encoder_inputs(
|
|||||||
items = []
|
items = []
|
||||||
grids = []
|
grids = []
|
||||||
original_image_sizes = []
|
original_image_sizes = []
|
||||||
for image in concrete_images:
|
for image, content_digest in zip(concrete_images, content_digests):
|
||||||
width, height = (
|
width, height = (
|
||||||
(int(image.shape[-1]), int(image.shape[-2]))
|
(int(image.shape[-1]), int(image.shape[-2]))
|
||||||
if isinstance(image, torch.Tensor)
|
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_thw = _grid_thw_from_resize_config(resize_config, patch_size)
|
||||||
grid_tensor = torch.tensor([grid_thw], dtype=torch.int64)
|
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(
|
item = MultimodalDataItem(
|
||||||
modality=Modality.IMAGE,
|
modality=Modality.IMAGE,
|
||||||
feature=to_chw_uint8(image) if use_gpu_preprocessing else image,
|
feature=to_chw_uint8(image) if use_gpu_preprocessing else image,
|
||||||
model_specific_data={
|
model_specific_data=model_specific_data,
|
||||||
"grid_thws": grid_tensor,
|
|
||||||
DEFERRED_PREPROCESSING_KEY: deferred_preprocessing(
|
|
||||||
resize_config=resize_config
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if not use_gpu_preprocessing:
|
if not use_gpu_preprocessing:
|
||||||
item.set_hash(hash_raw_encoder_item(image))
|
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 os
|
||||||
import re
|
import re
|
||||||
from abc import ABC, abstractmethod
|
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 numpy as np
|
||||||
import torch
|
import torch
|
||||||
@@ -21,6 +29,7 @@ from sglang.srt.managers.schedule_batch import (
|
|||||||
)
|
)
|
||||||
from sglang.srt.multimodal.cache import (
|
from sglang.srt.multimodal.cache import (
|
||||||
MultimodalPreprocessCache,
|
MultimodalPreprocessCache,
|
||||||
|
PreprocessFingerprintProvider,
|
||||||
build_processor_fingerprint,
|
build_processor_fingerprint,
|
||||||
)
|
)
|
||||||
from sglang.srt.multimodal.processors.executor import MultimodalProcessorExecutor
|
from sglang.srt.multimodal.processors.executor import MultimodalProcessorExecutor
|
||||||
@@ -190,6 +199,8 @@ class BaseMultimodalProcessor(ABC):
|
|||||||
preserve_processor_input_ids = False
|
preserve_processor_input_ids = False
|
||||||
auto_mm_processor_worker_num = 1
|
auto_mm_processor_worker_num = 1
|
||||||
auto_mm_io_worker_num = 4
|
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
|
auto_mm_preprocess_cache_size_mb = 0
|
||||||
supports_mm_processor_concurrency = False
|
supports_mm_processor_concurrency = False
|
||||||
|
|
||||||
@@ -204,9 +215,7 @@ class BaseMultimodalProcessor(ABC):
|
|||||||
server_args.allowed_media_domains,
|
server_args.allowed_media_domains,
|
||||||
server_args.media_url_max_file_size_mb,
|
server_args.media_url_max_file_size_mb,
|
||||||
)
|
)
|
||||||
configured_mm_feature_transport = getattr(
|
configured_mm_feature_transport = server_args.mm_feature_transport
|
||||||
server_args, "mm_feature_transport", "cpu"
|
|
||||||
)
|
|
||||||
self.mm_feature_transport = (
|
self.mm_feature_transport = (
|
||||||
configured_mm_feature_transport
|
configured_mm_feature_transport
|
||||||
if configured_mm_feature_transport in ("cpu", "cuda_ipc", "cuda_vmm")
|
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_ipc_pool_handle_cache = (
|
||||||
self.use_cuda_ipc and envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.get()
|
self.use_cuda_ipc and envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.get()
|
||||||
)
|
)
|
||||||
self.image_processor_backend = getattr(
|
self.image_processor_backend = server_args.image_processor_backend
|
||||||
server_args, "image_processor_backend", "auto"
|
if server_args.disable_fast_image_processor:
|
||||||
)
|
|
||||||
if getattr(server_args, "disable_fast_image_processor", False):
|
|
||||||
self.image_processor_backend = "pil"
|
self.image_processor_backend = "pil"
|
||||||
self.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
|
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.video_config = mm_process_config.get("video", {})
|
||||||
self.audio_config = mm_process_config.get("audio", {})
|
self.audio_config = mm_process_config.get("audio", {})
|
||||||
|
|
||||||
requested_cache_mb = getattr(
|
# Each tokenizer worker is a separate process with its own CPU cache.
|
||||||
self.server_args, "mm_preprocess_cache_size_mb", None
|
# 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 = (
|
total_cache_mb = (
|
||||||
self.auto_mm_preprocess_cache_size_mb
|
self.auto_mm_preprocess_cache_size_mb
|
||||||
if requested_cache_mb is None
|
if requested_cache_mb is None
|
||||||
else requested_cache_mb
|
else requested_cache_mb
|
||||||
)
|
)
|
||||||
tokenizer_worker_num = max(
|
tokenizer_worker_num = max(int(self.server_args.tokenizer_worker_num), 1)
|
||||||
int(getattr(self.server_args, "tokenizer_worker_num", 1)), 1
|
|
||||||
)
|
|
||||||
worker_cache_bytes = total_cache_mb * 1024 * 1024 // tokenizer_worker_num
|
worker_cache_bytes = total_cache_mb * 1024 * 1024 // tokenizer_worker_num
|
||||||
self.mm_preprocess_cache = MultimodalPreprocessCache(
|
self.mm_preprocess_cache = MultimodalPreprocessCache(
|
||||||
max_size_bytes=worker_cache_bytes,
|
max_size_bytes=worker_cache_bytes,
|
||||||
max_entries=8192,
|
max_entries=8192,
|
||||||
)
|
)
|
||||||
self.trust_mm_content_hashes = bool(
|
self.trust_mm_content_hashes = bool(self.server_args.trust_mm_content_hashes)
|
||||||
getattr(self.server_args, "trust_mm_content_hashes", False)
|
# The fingerprint is needed only to build artifact keys. Avoid inspecting
|
||||||
)
|
# processor state when this processor will never retain artifacts.
|
||||||
self.processor_fingerprint = (
|
self.processor_fingerprint = (
|
||||||
build_processor_fingerprint(self, hf_config, server_args)
|
build_processor_fingerprint(self, hf_config, server_args)
|
||||||
if self.mm_preprocess_cache.enabled
|
if self.mm_preprocess_cache.enabled
|
||||||
@@ -418,26 +424,46 @@ class BaseMultimodalProcessor(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def keep_mm_features_on_device(self) -> bool:
|
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")
|
return self.mm_feature_transport in ("cuda_ipc", "cuda_vmm")
|
||||||
|
|
||||||
def preprocess_fingerprint_payload(self) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"wrapper_class": (
|
"wrapper_class": (
|
||||||
f"{type(self._processor).__module__}."
|
f"{type(self._processor).__module__}."
|
||||||
f"{type(self._processor).__qualname__}"
|
f"{type(self._processor).__qualname__}"
|
||||||
),
|
),
|
||||||
"gpu_image_decode": self.gpu_image_decode,
|
"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,
|
"image_config": self.image_config,
|
||||||
"video_config": self.video_config,
|
"video_config": self.video_config,
|
||||||
"audio_config": self.audio_config,
|
"audio_config": self.audio_config,
|
||||||
|
"wrapped_processor": wrapped_processor,
|
||||||
}
|
}
|
||||||
|
|
||||||
def clear_preprocess_cache(self) -> None:
|
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()
|
self.mm_preprocess_cache.clear()
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
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.clear_preprocess_cache()
|
||||||
self.io_executor.shutdown(wait=False, cancel_futures=True)
|
self.io_executor.shutdown(wait=False, cancel_futures=True)
|
||||||
self.cpu_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 functools
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Union
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
@@ -22,6 +23,7 @@ from sglang.srt.managers.schedule_batch import (
|
|||||||
MultimodalProcessorOutput,
|
MultimodalProcessorOutput,
|
||||||
)
|
)
|
||||||
from sglang.srt.models.kimi_k3 import KimiK3ForConditionalGeneration
|
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 (
|
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||||
DEFERRED_PREPROCESSING_KEY,
|
DEFERRED_PREPROCESSING_KEY,
|
||||||
KimiK3DeferredPreprocessing,
|
KimiK3DeferredPreprocessing,
|
||||||
@@ -32,6 +34,15 @@ from sglang.srt.multimodal.kimi_k3_image_processing import (
|
|||||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||||
to_chw_uint8,
|
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 (
|
from sglang.srt.multimodal.processors.base_processor import (
|
||||||
BaseMultimodalProcessor as SGLangBaseProcessor,
|
BaseMultimodalProcessor as SGLangBaseProcessor,
|
||||||
)
|
)
|
||||||
@@ -152,9 +163,24 @@ def _k3_to_cuda_chw(image: Union[torch.Tensor, Image.Image]) -> torch.Tensor:
|
|||||||
|
|
||||||
|
|
||||||
class KimiK3GPUProcessorWrapper(KimiGPUProcessorWrapper):
|
class KimiK3GPUProcessorWrapper(KimiGPUProcessorWrapper):
|
||||||
def __init__(self, *args, transparent_bg_config=None, **kwargs):
|
def __init__(self, hf_processor, image_token, image_token_id, config):
|
||||||
super().__init__(*args, **kwargs)
|
self.preprocess_config = config
|
||||||
self._transparent_bg_config = transparent_bg_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(
|
def _prepare_input_ids(
|
||||||
self, input_text, resize_configs, original_input_ids, image_sizes
|
self, input_text, resize_configs, original_input_ids, image_sizes
|
||||||
@@ -285,9 +311,65 @@ class KimiK3GPUProcessorWrapper(KimiGPUProcessorWrapper):
|
|||||||
)
|
)
|
||||||
return input_ids, resize_configs, deferred_preprocessing
|
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]
|
models = [KimiK3ForConditionalGeneration]
|
||||||
|
artifact_modality = Modality.IMAGE
|
||||||
# K3 accuracy is sensitive to the chroma upsampling used for common 4:2:0
|
# 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
|
# JPEG inputs. This mode uses interpolated nvJPEG upsampling when the K3
|
||||||
# image dependency is installed and otherwise falls back to PIL.
|
# 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\|>)+"),
|
image_token_regex=re.compile(r"(?:<\|media_pad\|>)+"),
|
||||||
).build(_processor)
|
).build(_processor)
|
||||||
|
|
||||||
media_proc_cfg = _processor.media_processor.media_proc_cfg
|
preprocess_config = KimiK3PreprocessConfig.from_media_processor(
|
||||||
|
_processor.media_processor
|
||||||
|
)
|
||||||
|
|
||||||
processor = KimiK3GPUProcessorWrapper(
|
processor = KimiK3GPUProcessorWrapper(
|
||||||
_processor,
|
_processor,
|
||||||
image_token=mm_tokens.image_token,
|
image_token=mm_tokens.image_token,
|
||||||
image_token_id=mm_tokens.image_token_id,
|
image_token_id=mm_tokens.image_token_id,
|
||||||
patch_size=media_proc_cfg["patch_size"],
|
config=preprocess_config,
|
||||||
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"),
|
|
||||||
)
|
)
|
||||||
super().__init__(hf_config, server_args, processor, *args, **kwargs)
|
super().__init__(hf_config, server_args, processor, *args, **kwargs)
|
||||||
self.mm_tokens = mm_tokens
|
self.mm_tokens = mm_tokens
|
||||||
|
|
||||||
def _should_defer_gpu_preprocessing(self, images) -> bool:
|
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 (
|
if (
|
||||||
not images
|
not images
|
||||||
or self.mm_feature_transport != "cpu"
|
or self.mm_feature_transport != "cpu"
|
||||||
@@ -340,17 +420,18 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
|||||||
|
|
||||||
raw_bytes = 0
|
raw_bytes = 0
|
||||||
processed_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:
|
for image in images:
|
||||||
width, height = _get_image_dimensions(image)
|
width, height = _get_image_dimensions(image)
|
||||||
resize_config = navit_resize_config(
|
resize_config = navit_resize_config(
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
patch_size,
|
patch_size,
|
||||||
self._processor._merge_kernel_size,
|
config.merge_kernel_size,
|
||||||
self._processor._in_patch_limit,
|
config.in_patch_limit,
|
||||||
self._processor._patch_limit_on_one_side,
|
config.patch_limit_on_one_side,
|
||||||
self._processor._fixed_output_tokens,
|
config.fixed_output_tokens,
|
||||||
)
|
)
|
||||||
if isinstance(image, torch.Tensor):
|
if isinstance(image, torch.Tensor):
|
||||||
channels = (
|
channels = (
|
||||||
@@ -391,7 +472,7 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
|||||||
base_output.images, resize_configs, offsets
|
base_output.images, resize_configs, offsets
|
||||||
):
|
):
|
||||||
grid_thw = _grid_thw_from_resize_config(
|
grid_thw = _grid_thw_from_resize_config(
|
||||||
resize_config, self._processor._patch_size
|
resize_config, self._processor.preprocess_config.patch_size
|
||||||
)
|
)
|
||||||
item = MultimodalDataItem(
|
item = MultimodalDataItem(
|
||||||
modality=Modality.IMAGE,
|
modality=Modality.IMAGE,
|
||||||
@@ -413,6 +494,217 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
|||||||
im_token_id=self.mm_tokens.image_token_id,
|
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(
|
async def process_mm_data_async(
|
||||||
self,
|
self,
|
||||||
image_data: List[Union[str, bytes, Dict]],
|
image_data: List[Union[str, bytes, Dict]],
|
||||||
@@ -421,7 +713,7 @@ class KimiK3ImageProcessor(KimiGridMMDataMixin, SGLangBaseProcessor):
|
|||||||
*args,
|
*args,
|
||||||
**kwargs,
|
**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")
|
raise ValueError("Kimi-K3 supports image input only")
|
||||||
|
|
||||||
expected_image_count = len(image_data or [])
|
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: "
|
"Kimi image placeholders must map one-to-one to image data: "
|
||||||
f"expected {expected_image_count}, found {placeholder_count} token(s)"
|
f"expected {expected_image_count}, found {placeholder_count} token(s)"
|
||||||
)
|
)
|
||||||
# Keep structural media tokens distinct from user text that happens to
|
if (
|
||||||
# spell ``<|media_pad|>``. Decoding the whole prompt and matching the
|
any(self._is_preprocessed_input(item) for item in image_data)
|
||||||
# resulting string would lose that distinction and could bind an image
|
or not self.mm_preprocess_cache.enabled
|
||||||
# to user-provided text instead of the renderer-inserted token.
|
):
|
||||||
base_output = await self.fast_load_mm_data(
|
# 1. keep preprocessed inputs and cache-off requests on the legacy path
|
||||||
prompt=input_text,
|
return await self._process_mm_data_uncached(
|
||||||
image_data=image_data,
|
image_data, input_text, request_obj, **kwargs
|
||||||
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 len(base_output.images) != expected_image_count:
|
# 2. resolve per-image artifacts before composing the current prompt
|
||||||
raise ValueError(
|
artifacts = await self.prepare_media_artifacts(
|
||||||
"Kimi image placeholders must map one-to-one to image data: "
|
image_data,
|
||||||
f"expected {expected_image_count}, loaded {len(base_output.images)}"
|
content_hashes=request_obj.mm_content_hashes,
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
return self.compose_request(input_text, artifacts)
|
||||||
|
|
||||||
def get_mm_data(self, prompt, embeddings, **kwargs):
|
def get_mm_data(self, prompt, embeddings, **kwargs):
|
||||||
img_grid_thw = kwargs.get("img_grid_thw", None)
|
img_grid_thw = kwargs.get("img_grid_thw", None)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from sglang.srt.disaggregation.encode_receiver import (
|
|||||||
EmbeddingData,
|
EmbeddingData,
|
||||||
MMReceiverHTTP,
|
MMReceiverHTTP,
|
||||||
MultiModalEmbeddingData,
|
MultiModalEmbeddingData,
|
||||||
|
_encoder_media_item,
|
||||||
_select_mm_processor_prompt,
|
_select_mm_processor_prompt,
|
||||||
)
|
)
|
||||||
from sglang.srt.disaggregation.encode_server import MMEncoder, _get_mm_grid_dim
|
from sglang.srt.disaggregation.encode_server import MMEncoder, _get_mm_grid_dim
|
||||||
@@ -27,8 +28,10 @@ from sglang.srt.managers.tokenizer_manager import (
|
|||||||
_reject_missing_dispatched_encoder_embedding,
|
_reject_missing_dispatched_encoder_embedding,
|
||||||
)
|
)
|
||||||
from sglang.srt.models.kimi_k3 import KimiK3ForConditionalGeneration
|
from sglang.srt.models.kimi_k3 import KimiK3ForConditionalGeneration
|
||||||
|
from sglang.srt.multimodal.cache import snapshot_media
|
||||||
from sglang.srt.multimodal.encoder_preprocessing import (
|
from sglang.srt.multimodal.encoder_preprocessing import (
|
||||||
LOCAL_PREPROCESSED_KEY,
|
LOCAL_PREPROCESSED_KEY,
|
||||||
|
EncoderMediaProcessorConfig,
|
||||||
EncoderPreprocessOutput,
|
EncoderPreprocessOutput,
|
||||||
get_encoder_preprocessed_items,
|
get_encoder_preprocessed_items,
|
||||||
hash_raw_encoder_item,
|
hash_raw_encoder_item,
|
||||||
@@ -41,6 +44,7 @@ from sglang.srt.multimodal.kimi_k3_image_processing import (
|
|||||||
)
|
)
|
||||||
from sglang.srt.runtime_context import get_context
|
from sglang.srt.runtime_context import get_context
|
||||||
from sglang.srt.server_args import resolve_encoder_transfer_backend
|
from sglang.srt.server_args import resolve_encoder_transfer_backend
|
||||||
|
from sglang.srt.utils import ImageData
|
||||||
from sglang.test.ci.ci_register import register_cpu_ci
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
|
||||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||||
@@ -208,6 +212,11 @@ def _encoder(model_type="kimi_k3"):
|
|||||||
vision_config=SimpleNamespace(merge_kernel_size=(2, 2))
|
vision_config=SimpleNamespace(merge_kernel_size=(2, 2))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
encoder.encoder_media_processor_config = (
|
||||||
|
KimiK3ForConditionalGeneration.encoder_media_processor_config
|
||||||
|
if model_type == "kimi_k3"
|
||||||
|
else EncoderMediaProcessorConfig()
|
||||||
|
)
|
||||||
return encoder
|
return encoder
|
||||||
|
|
||||||
|
|
||||||
@@ -296,6 +305,19 @@ def test_kimi_k3_epd_preprocess_preserves_raw_per_image_items():
|
|||||||
assert deferred.image_std == [0.5, 0.5, 0.5]
|
assert deferred.image_std == [0.5, 0.5, 0.5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_epd_preserves_verified_content_identity():
|
||||||
|
image = Image.new("RGB", (8, 6), color=(1, 2, 3))
|
||||||
|
digest = "sha256:" + "ab" * 32
|
||||||
|
|
||||||
|
output = prepare_kimi_k3_encoder_inputs(
|
||||||
|
[{"type": "image", "image": image, "content_hash": digest}],
|
||||||
|
_kimi_k3_image_processor(),
|
||||||
|
)
|
||||||
|
|
||||||
|
item = get_encoder_preprocessed_items(output)[0]
|
||||||
|
assert item.model_specific_data["content_digest"] == digest
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k3_epd_model_preprocessor_receives_image_processor():
|
def test_kimi_k3_epd_model_preprocessor_receives_image_processor():
|
||||||
image = Image.new("RGB", (8, 6), color=(1, 2, 3))
|
image = Image.new("RGB", (8, 6), color=(1, 2, 3))
|
||||||
image_processor = _kimi_k3_image_processor()
|
image_processor = _kimi_k3_image_processor()
|
||||||
@@ -458,6 +480,67 @@ def test_kimi_k3_epd_selects_matching_jpeg_decode_mode(
|
|||||||
load.assert_called_once_with(b"jpeg", expected_decode_mode)
|
load.assert_called_once_with(b"jpeg", expected_decode_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_epd_verifies_content_hash_before_decode():
|
||||||
|
payload = b"jpeg"
|
||||||
|
digest = snapshot_media(payload).content_digest
|
||||||
|
expected = torch.zeros((3, 2, 3), dtype=torch.uint8)
|
||||||
|
encoder = _encoder()
|
||||||
|
encoder.use_image_processor_gpu = False
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sglang.srt.disaggregation.encode_server.load_image",
|
||||||
|
return_value=(expected, None),
|
||||||
|
) as load:
|
||||||
|
output = encoder._load_single_item(
|
||||||
|
{"url": payload, "content_hash": digest}, Modality.IMAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
assert output == {
|
||||||
|
"type": "image",
|
||||||
|
"image": expected,
|
||||||
|
"content_hash": digest,
|
||||||
|
}
|
||||||
|
load.assert_called_once_with(payload, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_epd_receiver_keeps_content_hash_aligned_with_image():
|
||||||
|
digest = "sha256:" + "cd" * 32
|
||||||
|
receiver = MMReceiverHTTP.__new__(MMReceiverHTTP)
|
||||||
|
request = SimpleNamespace(
|
||||||
|
image_data=[
|
||||||
|
ImageData(
|
||||||
|
url="image",
|
||||||
|
detail="high",
|
||||||
|
max_dynamic_patch=12,
|
||||||
|
preprocess_kwargs={"crop": False},
|
||||||
|
content_hash=digest,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
video_data=None,
|
||||||
|
audio_data=None,
|
||||||
|
mm_content_hashes=[digest],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert receiver._extract_url_data(request) == [
|
||||||
|
{
|
||||||
|
"url": "image",
|
||||||
|
"modality": Modality.IMAGE,
|
||||||
|
"detail": "high",
|
||||||
|
"max_dynamic_patch": 12,
|
||||||
|
"preprocess_kwargs": {"crop": False},
|
||||||
|
"content_hash": digest,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert _encoder_media_item(receiver._extract_url_data(request)[0]) == {
|
||||||
|
"url": "image",
|
||||||
|
"detail": "high",
|
||||||
|
"max_dynamic_patch": 12,
|
||||||
|
"preprocess_kwargs": {"crop": False},
|
||||||
|
"content_hash": digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k3_epd_aggregates_original_image_sizes_in_part_order():
|
def test_kimi_k3_epd_aggregates_original_image_sizes_in_part_order():
|
||||||
first = EmbeddingData(
|
first = EmbeddingData(
|
||||||
req_id="request",
|
req_id="request",
|
||||||
|
|||||||
@@ -127,12 +127,14 @@ class TestBaseProcessorConfigExtraction(CustomTestCase):
|
|||||||
|
|
||||||
with patch.dict(os.environ, {}, clear=False):
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
os.environ.pop("SGLANG_IO_WORKERS", None)
|
os.environ.pop("SGLANG_IO_WORKERS", None)
|
||||||
with patch.object(
|
with (
|
||||||
BaseMultimodalProcessor, "auto_mm_processor_worker_num", 4
|
patch.object(
|
||||||
), patch.object(
|
BaseMultimodalProcessor, "auto_mm_processor_worker_num", 4
|
||||||
BaseMultimodalProcessor, "auto_mm_io_worker_num", 16
|
),
|
||||||
), patch.object(
|
patch.object(BaseMultimodalProcessor, "auto_mm_io_worker_num", 16),
|
||||||
BaseMultimodalProcessor, "supports_mm_processor_concurrency", True
|
patch.object(
|
||||||
|
BaseMultimodalProcessor, "supports_mm_processor_concurrency", True
|
||||||
|
),
|
||||||
):
|
):
|
||||||
proc = self._make_processor({})
|
proc = self._make_processor({})
|
||||||
try:
|
try:
|
||||||
@@ -172,14 +174,18 @@ class TestMultimodalFeatureTransportRuntime(CustomTestCase):
|
|||||||
def _server_args(mm_feature_transport):
|
def _server_args(mm_feature_transport):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
mm_feature_transport=mm_feature_transport,
|
mm_feature_transport=mm_feature_transport,
|
||||||
|
image_processor_backend="auto",
|
||||||
disable_fast_image_processor=False,
|
disable_fast_image_processor=False,
|
||||||
skip_tokenizer_init=False,
|
skip_tokenizer_init=False,
|
||||||
mm_process_config={},
|
mm_process_config={},
|
||||||
|
mm_preprocess_cache_size_mb=0,
|
||||||
|
trust_mm_content_hashes=False,
|
||||||
mm_processor_worker_num=0,
|
mm_processor_worker_num=0,
|
||||||
mm_io_worker_num=0,
|
mm_io_worker_num=0,
|
||||||
tokenizer_worker_num=1,
|
tokenizer_worker_num=1,
|
||||||
base_gpu_id=2,
|
base_gpu_id=2,
|
||||||
tp_size=8,
|
tp_size=8,
|
||||||
|
rl_on_policy_target=None,
|
||||||
allowed_media_domains=[],
|
allowed_media_domains=[],
|
||||||
media_url_max_file_size_mb=64,
|
media_url_max_file_size_mb=64,
|
||||||
)
|
)
|
||||||
@@ -195,9 +201,13 @@ class TestMultimodalFeatureTransportRuntime(CustomTestCase):
|
|||||||
# transport policy must still resolve from the instance's ServerArgs.
|
# transport policy must still resolve from the instance's ServerArgs.
|
||||||
from sglang.srt.multimodal.processors import base_processor
|
from sglang.srt.multimodal.processors import base_processor
|
||||||
|
|
||||||
with envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.override(True), patch.object(
|
with (
|
||||||
base_processor.BaseMultimodalProcessor, "__abstractmethods__", set()
|
envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.override(True),
|
||||||
), patch.object(base_processor, "MmItemMemoryPool") as memory_pool:
|
patch.object(
|
||||||
|
base_processor.BaseMultimodalProcessor, "__abstractmethods__", set()
|
||||||
|
),
|
||||||
|
patch.object(base_processor, "MmItemMemoryPool") as memory_pool,
|
||||||
|
):
|
||||||
processor = base_processor.BaseMultimodalProcessor(
|
processor = base_processor.BaseMultimodalProcessor(
|
||||||
hf_config=MagicMock(),
|
hf_config=MagicMock(),
|
||||||
server_args=self._server_args("cuda_ipc"),
|
server_args=self._server_args("cuda_ipc"),
|
||||||
@@ -213,9 +223,13 @@ class TestMultimodalFeatureTransportRuntime(CustomTestCase):
|
|||||||
def test_cuda_ipc_pool_handle_cache_can_be_disabled(self):
|
def test_cuda_ipc_pool_handle_cache_can_be_disabled(self):
|
||||||
from sglang.srt.multimodal.processors import base_processor
|
from sglang.srt.multimodal.processors import base_processor
|
||||||
|
|
||||||
with envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.override(False), patch.object(
|
with (
|
||||||
base_processor.BaseMultimodalProcessor, "__abstractmethods__", set()
|
envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.override(False),
|
||||||
), patch.object(base_processor, "MmItemMemoryPool") as memory_pool:
|
patch.object(
|
||||||
|
base_processor.BaseMultimodalProcessor, "__abstractmethods__", set()
|
||||||
|
),
|
||||||
|
patch.object(base_processor, "MmItemMemoryPool") as memory_pool,
|
||||||
|
):
|
||||||
processor = base_processor.BaseMultimodalProcessor(
|
processor = base_processor.BaseMultimodalProcessor(
|
||||||
hf_config=MagicMock(),
|
hf_config=MagicMock(),
|
||||||
server_args=self._server_args("cuda_ipc"),
|
server_args=self._server_args("cuda_ipc"),
|
||||||
@@ -230,9 +244,13 @@ class TestMultimodalFeatureTransportRuntime(CustomTestCase):
|
|||||||
def test_cpu_transport_does_not_allocate_ipc_pool(self):
|
def test_cpu_transport_does_not_allocate_ipc_pool(self):
|
||||||
from sglang.srt.multimodal.processors import base_processor
|
from sglang.srt.multimodal.processors import base_processor
|
||||||
|
|
||||||
with envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.override(True), patch.object(
|
with (
|
||||||
base_processor.BaseMultimodalProcessor, "__abstractmethods__", set()
|
envs.SGLANG_USE_IPC_POOL_HANDLE_CACHE.override(True),
|
||||||
), patch.object(base_processor, "MmItemMemoryPool") as memory_pool:
|
patch.object(
|
||||||
|
base_processor.BaseMultimodalProcessor, "__abstractmethods__", set()
|
||||||
|
),
|
||||||
|
patch.object(base_processor, "MmItemMemoryPool") as memory_pool,
|
||||||
|
):
|
||||||
processor = base_processor.BaseMultimodalProcessor(
|
processor = base_processor.BaseMultimodalProcessor(
|
||||||
hf_config=MagicMock(),
|
hf_config=MagicMock(),
|
||||||
server_args=self._server_args("cpu"),
|
server_args=self._server_args("cpu"),
|
||||||
@@ -251,9 +269,12 @@ class TestMultimodalFeatureTransportRuntime(CustomTestCase):
|
|||||||
hf_processor = self._processor()
|
hf_processor = self._processor()
|
||||||
feature = torch.empty(1, device="meta")
|
feature = torch.empty(1, device="meta")
|
||||||
hf_processor.return_value = {"pixel_values": feature}
|
hf_processor.return_value = {"pixel_values": feature}
|
||||||
with patch.object(
|
with (
|
||||||
base_processor.BaseMultimodalProcessor, "__abstractmethods__", set()
|
patch.object(
|
||||||
), patch.object(base_processor, "MmItemMemoryPool") as memory_pool:
|
base_processor.BaseMultimodalProcessor, "__abstractmethods__", set()
|
||||||
|
),
|
||||||
|
patch.object(base_processor, "MmItemMemoryPool") as memory_pool,
|
||||||
|
):
|
||||||
processor = base_processor.BaseMultimodalProcessor(
|
processor = base_processor.BaseMultimodalProcessor(
|
||||||
hf_config=MagicMock(),
|
hf_config=MagicMock(),
|
||||||
server_args=self._server_args("cuda_vmm"),
|
server_args=self._server_args("cuda_vmm"),
|
||||||
@@ -365,9 +386,10 @@ class TestPrecomputeHashBeforeCpuTransfer(CustomTestCase):
|
|||||||
BaseMultimodalProcessor,
|
BaseMultimodalProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(
|
with (
|
||||||
BaseMultimodalProcessor, "__abstractmethods__", set()
|
patch.object(BaseMultimodalProcessor, "__abstractmethods__", set()),
|
||||||
), patch.object(BaseMultimodalProcessor, "__init__", lambda self: None):
|
patch.object(BaseMultimodalProcessor, "__init__", lambda self: None),
|
||||||
|
):
|
||||||
processor = BaseMultimodalProcessor()
|
processor = BaseMultimodalProcessor()
|
||||||
processor.precompute_hash_before_cpu_transfer = enabled
|
processor.precompute_hash_before_cpu_transfer = enabled
|
||||||
processor.use_cuda_ipc = False
|
processor.use_cuda_ipc = False
|
||||||
@@ -409,9 +431,10 @@ class TestMultimodalProcessorConcurrency(unittest.IsolatedAsyncioTestCase):
|
|||||||
MultimodalProcessorExecutor,
|
MultimodalProcessorExecutor,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(
|
with (
|
||||||
BaseMultimodalProcessor, "__abstractmethods__", set()
|
patch.object(BaseMultimodalProcessor, "__abstractmethods__", set()),
|
||||||
), patch.object(BaseMultimodalProcessor, "__init__", lambda self: None):
|
patch.object(BaseMultimodalProcessor, "__init__", lambda self: None),
|
||||||
|
):
|
||||||
processor = BaseMultimodalProcessor()
|
processor = BaseMultimodalProcessor()
|
||||||
|
|
||||||
processor.mm_processor_executor = MultimodalProcessorExecutor(
|
processor.mm_processor_executor = MultimodalProcessorExecutor(
|
||||||
@@ -438,9 +461,10 @@ class TestMultimodalProcessorConcurrency(unittest.IsolatedAsyncioTestCase):
|
|||||||
BaseMultimodalProcessor,
|
BaseMultimodalProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(
|
with (
|
||||||
BaseMultimodalProcessor, "__abstractmethods__", set()
|
patch.object(BaseMultimodalProcessor, "__abstractmethods__", set()),
|
||||||
), patch.object(BaseMultimodalProcessor, "__init__", lambda self: None):
|
patch.object(BaseMultimodalProcessor, "__init__", lambda self: None),
|
||||||
|
):
|
||||||
processor = BaseMultimodalProcessor()
|
processor = BaseMultimodalProcessor()
|
||||||
|
|
||||||
processor.mm_processor_executor = None
|
processor.mm_processor_executor = None
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
"""CPU coverage for Kimi-K2.5/K2.7 encoder-DP wiring."""
|
"""CPU coverage for Kimi-K2.5/K2.7 encoder-DP wiring."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import functools
|
import functools
|
||||||
|
import io
|
||||||
|
import pickle
|
||||||
|
import tempfile
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
@@ -18,11 +25,26 @@ from sglang.srt.managers.schedule_batch import (
|
|||||||
MultimodalInputs,
|
MultimodalInputs,
|
||||||
MultimodalProcessorOutput,
|
MultimodalProcessorOutput,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.models.kimi_k3 import KimiK3ForConditionalGeneration
|
||||||
from sglang.srt.models.kimi_k25 import (
|
from sglang.srt.models.kimi_k25 import (
|
||||||
KimiK25ForConditionalGeneration,
|
KimiK25ForConditionalGeneration,
|
||||||
mm_projection_auto,
|
mm_projection_auto,
|
||||||
)
|
)
|
||||||
from sglang.srt.models.kimi_vl_moonvit import tpool_patch_merger
|
from sglang.srt.models.kimi_vl_moonvit import tpool_patch_merger
|
||||||
|
from sglang.srt.multimodal.cache import (
|
||||||
|
MultimodalPreprocessCache,
|
||||||
|
resolve_multimodal_item_hash,
|
||||||
|
snapshot_media,
|
||||||
|
)
|
||||||
|
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
||||||
|
DEFERRED_PREPROCESSING_KEY,
|
||||||
|
KimiK3DeferredPreprocessing,
|
||||||
|
)
|
||||||
|
from sglang.srt.multimodal.media_artifacts.kimi_k3 import (
|
||||||
|
KimiK3ImagePreprocessArtifact,
|
||||||
|
KimiK3PreprocessConfig,
|
||||||
|
KimiK3ResizeConfig,
|
||||||
|
)
|
||||||
from sglang.srt.multimodal.mm_utils import run_dp_sharded_mrope_vision_model
|
from sglang.srt.multimodal.mm_utils import run_dp_sharded_mrope_vision_model
|
||||||
from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor
|
from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor
|
||||||
from sglang.srt.multimodal.processors.kimi_common import KimiGridMMDataMixin
|
from sglang.srt.multimodal.processors.kimi_common import KimiGridMMDataMixin
|
||||||
@@ -45,6 +67,8 @@ from sglang.srt.multimodal.transport.cuda_ipc import (
|
|||||||
CudaIpcTensorTransportProxy,
|
CudaIpcTensorTransportProxy,
|
||||||
)
|
)
|
||||||
from sglang.srt.runtime_context import get_context, get_parallel
|
from sglang.srt.runtime_context import get_context, get_parallel
|
||||||
|
from sglang.srt.server_args import ServerArgs
|
||||||
|
from sglang.srt.utils import ImageData
|
||||||
from sglang.test.ci.ci_register import register_cpu_ci
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
|
||||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||||
@@ -320,8 +344,9 @@ def test_dp_helper_supports_moonvit3d_packed_embeddings_on_tp1():
|
|||||||
|
|
||||||
# The IPC consumer count asks for the *configured* TP size (matching
|
# The IPC consumer count asks for the *configured* TP size (matching
|
||||||
# MmItemMemoryPool.try_to_recycle), so the double publishes one too.
|
# MmItemMemoryPool.try_to_recycle), so the double publishes one too.
|
||||||
with get_context().override_server_args(tp_size=1), get_parallel().override(
|
with (
|
||||||
tp_size=1, tp_rank=0, attn_tp_size=1, attn_tp_rank=0
|
get_context().override_server_args(tp_size=1),
|
||||||
|
get_parallel().override(tp_size=1, tp_rank=0, attn_tp_size=1, attn_tp_rank=0),
|
||||||
):
|
):
|
||||||
output = run_dp_sharded_mrope_vision_model(
|
output = run_dp_sharded_mrope_vision_model(
|
||||||
tower, pixel_values, [[1, 2, 2]], rope_type="rope_2d_packed"
|
tower, pixel_values, [[1, 2, 2]], rope_type="rope_2d_packed"
|
||||||
@@ -338,8 +363,9 @@ def test_dp_helper_can_lazily_load_kimi_features_on_tp1():
|
|||||||
|
|
||||||
# The IPC consumer count asks for the *configured* TP size (matching
|
# The IPC consumer count asks for the *configured* TP size (matching
|
||||||
# MmItemMemoryPool.try_to_recycle), so the double publishes one too.
|
# MmItemMemoryPool.try_to_recycle), so the double publishes one too.
|
||||||
with get_context().override_server_args(tp_size=1), get_parallel().override(
|
with (
|
||||||
tp_size=1, tp_rank=0, attn_tp_size=1, attn_tp_rank=0
|
get_context().override_server_args(tp_size=1),
|
||||||
|
get_parallel().override(tp_size=1, tp_rank=0, attn_tp_size=1, attn_tp_rank=0),
|
||||||
):
|
):
|
||||||
output = run_dp_sharded_mrope_vision_model(
|
output = run_dp_sharded_mrope_vision_model(
|
||||||
tower,
|
tower,
|
||||||
@@ -490,8 +516,9 @@ def test_kimi_non_dp_keeps_grid_thws_on_the_host():
|
|||||||
|
|
||||||
# The IPC consumer count asks for the *configured* TP size (matching
|
# The IPC consumer count asks for the *configured* TP size (matching
|
||||||
# MmItemMemoryPool.try_to_recycle), so the double publishes one too.
|
# MmItemMemoryPool.try_to_recycle), so the double publishes one too.
|
||||||
with get_context().override_server_args(tp_size=1), get_parallel().override(
|
with (
|
||||||
tp_size=1, tp_rank=0, attn_tp_size=1, attn_tp_rank=0
|
get_context().override_server_args(tp_size=1),
|
||||||
|
get_parallel().override(tp_size=1, tp_rank=0, attn_tp_size=1, attn_tp_rank=0),
|
||||||
):
|
):
|
||||||
model.get_image_feature(items)
|
model.get_image_feature(items)
|
||||||
|
|
||||||
@@ -582,6 +609,30 @@ class _HFProcessor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _AnySizeTokenizer:
|
||||||
|
def encode(self, text, allowed_special=None):
|
||||||
|
if text.startswith("<|media_begin|>image "):
|
||||||
|
return [10, 11]
|
||||||
|
if text == "<|media_end|>":
|
||||||
|
return [14]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _k3_preprocess_config(
|
||||||
|
*, patch_size=14, in_patch_limit=16384
|
||||||
|
) -> KimiK3PreprocessConfig:
|
||||||
|
return KimiK3PreprocessConfig(
|
||||||
|
patch_size=patch_size,
|
||||||
|
merge_kernel_size=2,
|
||||||
|
in_patch_limit=in_patch_limit,
|
||||||
|
patch_limit_on_one_side=512,
|
||||||
|
fixed_output_tokens=None,
|
||||||
|
image_mean=(0.5, 0.5, 0.5),
|
||||||
|
image_std=(0.5, 0.5, 0.5),
|
||||||
|
transparent_bg_config=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("processor_cls", "wrapper_cls"),
|
("processor_cls", "wrapper_cls"),
|
||||||
[
|
[
|
||||||
@@ -592,13 +643,17 @@ class _HFProcessor:
|
|||||||
def test_kimi_processor_workers_clone_the_gpu_wrapper(processor_cls, wrapper_cls):
|
def test_kimi_processor_workers_clone_the_gpu_wrapper(processor_cls, wrapper_cls):
|
||||||
server_args = SimpleNamespace(
|
server_args = SimpleNamespace(
|
||||||
mm_feature_transport="cpu",
|
mm_feature_transport="cpu",
|
||||||
|
image_processor_backend="auto",
|
||||||
disable_fast_image_processor=False,
|
disable_fast_image_processor=False,
|
||||||
skip_tokenizer_init=False,
|
skip_tokenizer_init=False,
|
||||||
mm_process_config={},
|
mm_process_config={},
|
||||||
mm_io_worker_num=0,
|
mm_io_worker_num=0,
|
||||||
mm_processor_worker_num=0,
|
mm_processor_worker_num=0,
|
||||||
tokenizer_worker_num=1,
|
tokenizer_worker_num=1,
|
||||||
|
mm_preprocess_cache_size_mb=0,
|
||||||
|
trust_mm_content_hashes=False,
|
||||||
base_gpu_id=0,
|
base_gpu_id=0,
|
||||||
|
rl_on_policy_target=None,
|
||||||
allowed_media_domains=[],
|
allowed_media_domains=[],
|
||||||
media_url_max_file_size_mb=64,
|
media_url_max_file_size_mb=64,
|
||||||
)
|
)
|
||||||
@@ -615,6 +670,12 @@ def test_kimi_processor_workers_clone_the_gpu_wrapper(processor_cls, wrapper_cls
|
|||||||
assert isinstance(processor._processor, wrapper_cls)
|
assert isinstance(processor._processor, wrapper_cls)
|
||||||
assert isinstance(worker_processor, wrapper_cls)
|
assert isinstance(worker_processor, wrapper_cls)
|
||||||
assert worker_processor is not processor._processor
|
assert worker_processor is not processor._processor
|
||||||
|
if processor_cls is KimiK3ImageProcessor:
|
||||||
|
fingerprint_config = processor.preprocess_fingerprint_payload()[
|
||||||
|
"wrapped_processor"
|
||||||
|
]
|
||||||
|
assert isinstance(fingerprint_config, KimiK3PreprocessConfig)
|
||||||
|
assert fingerprint_config.patch_size == 14
|
||||||
finally:
|
finally:
|
||||||
processor.mm_processor_executor.shutdown()
|
processor.mm_processor_executor.shutdown()
|
||||||
processor.io_executor.shutdown()
|
processor.io_executor.shutdown()
|
||||||
@@ -690,18 +751,498 @@ def test_kimi_k3_epd_rebuild_uses_the_same_media_contract():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k3_cpu_transport_defers_gpu_preprocessing():
|
def _cached_k3_artifact(content_digest, artifact_key, value=1):
|
||||||
from sglang.srt.multimodal.kimi_k3_image_processing import (
|
return KimiK3ImagePreprocessArtifact(
|
||||||
DEFERRED_PREPROCESSING_KEY,
|
content_digest=content_digest,
|
||||||
KimiK3DeferredPreprocessing,
|
artifact_key=artifact_key,
|
||||||
|
feature_hash=123,
|
||||||
|
original_size=(1536, 1024),
|
||||||
|
resize_config=KimiK3ResizeConfig(
|
||||||
|
num_tokens=3,
|
||||||
|
new_width=6,
|
||||||
|
new_height=2,
|
||||||
|
pad_width=0,
|
||||||
|
pad_height=0,
|
||||||
|
),
|
||||||
|
grid_thw=(1, 2, 6),
|
||||||
|
feature=torch.full((12, 2), value, dtype=torch.float32),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_cached_artifact_is_composed_per_prompt():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.mm_tokens = SimpleNamespace(image_token_id=99)
|
||||||
|
processor._tokenizer = _Tokenizer()
|
||||||
|
processor.mm_feature_transport = "cpu"
|
||||||
|
processor.use_cuda_ipc = False
|
||||||
|
artifact = _cached_k3_artifact("sha256:" + "ab" * 32, "artifact")
|
||||||
|
|
||||||
|
first = processor.compose_request([1, 99, 2], [artifact])
|
||||||
|
second = processor.compose_request([3, 4, 99, 5], [artifact])
|
||||||
|
|
||||||
|
assert first.input_ids != second.input_ids
|
||||||
|
assert first.mm_items[0].offsets == [(3, 5)]
|
||||||
|
assert second.mm_items[0].offsets == [(4, 6)]
|
||||||
|
assert first.mm_items[0].hash == second.mm_items[0].hash == 123
|
||||||
|
torch.testing.assert_close(first.mm_items[0].feature, second.mm_items[0].feature)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_cached_deferred_artifact_has_model_contract():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.mm_feature_transport = "cpu"
|
||||||
|
feature = torch.zeros((3, 2, 2), dtype=torch.uint8)
|
||||||
|
|
||||||
|
artifact = processor._make_artifact(
|
||||||
|
content_digest="sha256:" + "ab" * 32,
|
||||||
|
artifact_key="sha256:" + "cd" * 32,
|
||||||
|
original_size=(2, 2),
|
||||||
|
resize_config={
|
||||||
|
"num_tokens": 1,
|
||||||
|
"new_width": 2,
|
||||||
|
"new_height": 2,
|
||||||
|
"pad_width": 0,
|
||||||
|
"pad_height": 0,
|
||||||
|
},
|
||||||
|
grid_thw=(1, 1, 1),
|
||||||
|
feature=feature,
|
||||||
|
deferred=KimiK3DeferredPreprocessing(
|
||||||
|
backend="gpu",
|
||||||
|
image_mean=[0.5, 0.5, 0.5],
|
||||||
|
image_std=[0.5, 0.5, 0.5],
|
||||||
|
transparent_bg_config=None,
|
||||||
|
resize_config={
|
||||||
|
"num_tokens": 1,
|
||||||
|
"new_width": 2,
|
||||||
|
"new_height": 2,
|
||||||
|
"pad_width": 0,
|
||||||
|
"pad_height": 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
config = artifact.deferred
|
||||||
|
assert config.backend == "gpu"
|
||||||
|
assert config.resize_config["new_width"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_normal_cache_path_connects_real_producer_to_model_consumer():
|
||||||
|
hf_processor = _HFProcessor()
|
||||||
|
hf_processor.tokenizer = _AnySizeTokenizer()
|
||||||
|
hf_config = SimpleNamespace(
|
||||||
|
media_placeholder_token_id=42,
|
||||||
|
to_dict=lambda: {
|
||||||
|
"model_type": "kimi_k3",
|
||||||
|
"architectures": ["KimiK3ForConditionalGeneration"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
server_args = ServerArgs(
|
||||||
|
model_path="dummy",
|
||||||
|
mm_feature_transport="cpu",
|
||||||
|
mm_process_config={},
|
||||||
|
mm_io_worker_num=1,
|
||||||
|
mm_processor_worker_num=0,
|
||||||
|
tokenizer_worker_num=1,
|
||||||
|
mm_preprocess_cache_size_mb=1,
|
||||||
|
)
|
||||||
|
processor = KimiK3ImageProcessor(
|
||||||
|
hf_config=hf_config,
|
||||||
|
server_args=server_args,
|
||||||
|
_processor=hf_processor,
|
||||||
|
transport_mode=None,
|
||||||
|
)
|
||||||
|
image = Image.new("RGB", (28, 28), color=(1, 2, 3))
|
||||||
|
encoded_image = io.BytesIO()
|
||||||
|
image.save(encoded_image, format="PNG")
|
||||||
|
image_data = ImageData(
|
||||||
|
url="data:image/png;base64,"
|
||||||
|
+ base64.b64encode(encoded_image.getvalue()).decode()
|
||||||
|
)
|
||||||
|
request = SimpleNamespace(video_data=None, mm_content_hashes=None)
|
||||||
|
|
||||||
|
class _Tower(nn.Module):
|
||||||
|
device = torch.device("cpu")
|
||||||
|
patch_size = 14
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.patch_embed = SimpleNamespace(
|
||||||
|
proj=SimpleNamespace(weight=torch.empty(1, dtype=torch.float32))
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(self, pixel_values, _grid_thws):
|
||||||
|
return pixel_values
|
||||||
|
|
||||||
|
model = KimiK3ForConditionalGeneration.__new__(KimiK3ForConditionalGeneration)
|
||||||
|
nn.Module.__init__(model)
|
||||||
|
model.use_data_parallel = False
|
||||||
|
model.vision_tower = _Tower()
|
||||||
|
model.mm_projector = _Projector()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"sglang.srt.multimodal.processors.kimi_k3.is_cuda", return_value=True
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
processor,
|
||||||
|
"prepare_artifact_batch",
|
||||||
|
wraps=processor.prepare_artifact_batch,
|
||||||
|
) as prepare_artifacts,
|
||||||
|
):
|
||||||
|
cold = asyncio.run(
|
||||||
|
processor.process_mm_data_async([image_data], [1, 42, 2], request)
|
||||||
|
)
|
||||||
|
hot = asyncio.run(
|
||||||
|
processor.process_mm_data_async([image_data], [3, 42, 4], request)
|
||||||
|
)
|
||||||
|
cold_items = pickle.loads(pickle.dumps(cold.mm_items))
|
||||||
|
hot_items = pickle.loads(pickle.dumps(hot.mm_items))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("sglang.srt.models.kimi_k3.configured_tp_size", return_value=1),
|
||||||
|
patch(
|
||||||
|
"sglang.srt.multimodal.processors.kimi_k25._gpu_preprocess_images",
|
||||||
|
return_value=(
|
||||||
|
torch.ones((4, 3), dtype=torch.float32),
|
||||||
|
torch.tensor([[1, 2, 2]], dtype=torch.int64),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
cold_features = model.get_image_feature(cold_items)
|
||||||
|
hot_features = model.get_image_feature(hot_items)
|
||||||
|
finally:
|
||||||
|
processor.shutdown()
|
||||||
|
|
||||||
|
assert prepare_artifacts.call_count == 1
|
||||||
|
assert cold.mm_items[0].hash == hot.mm_items[0].hash
|
||||||
|
assert cold.mm_items[0].offsets == hot.mm_items[0].offsets == [(3, 3)]
|
||||||
|
assert (
|
||||||
|
cold_items[0].model_specific_data[DEFERRED_PREPROCESSING_KEY].backend == "gpu"
|
||||||
|
)
|
||||||
|
torch.testing.assert_close(cold_features, hot_features)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_model_accepts_mixed_cached_eager_and_deferred_artifacts():
|
||||||
|
class _Tower(nn.Module):
|
||||||
|
device = torch.device("cpu")
|
||||||
|
patch_size = 2
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.patch_embed = SimpleNamespace(
|
||||||
|
proj=SimpleNamespace(weight=torch.empty(1, dtype=torch.float32))
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(self, pixel_values, _grid_thws):
|
||||||
|
return pixel_values
|
||||||
|
|
||||||
|
model = KimiK3ForConditionalGeneration.__new__(KimiK3ForConditionalGeneration)
|
||||||
|
nn.Module.__init__(model)
|
||||||
|
model.use_data_parallel = False
|
||||||
|
model.vision_tower = _Tower()
|
||||||
|
model.mm_projector = _Projector()
|
||||||
|
eager = _image_item(torch.ones((1, 3)), [[1, 1, 1]])
|
||||||
|
deferred = _image_item(torch.zeros((3, 2, 2), dtype=torch.uint8), [[1, 1, 1]])
|
||||||
|
deferred.model_specific_data[DEFERRED_PREPROCESSING_KEY] = (
|
||||||
|
KimiK3DeferredPreprocessing(
|
||||||
|
backend="gpu",
|
||||||
|
image_mean=[0.5, 0.5, 0.5],
|
||||||
|
image_std=[0.5, 0.5, 0.5],
|
||||||
|
transparent_bg_config=None,
|
||||||
|
resize_config={
|
||||||
|
"num_tokens": 1,
|
||||||
|
"new_width": 2,
|
||||||
|
"new_height": 2,
|
||||||
|
"pad_width": 0,
|
||||||
|
"pad_height": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("sglang.srt.models.kimi_k3.configured_tp_size", return_value=1),
|
||||||
|
patch(
|
||||||
|
"sglang.srt.multimodal.processors.kimi_k25._gpu_preprocess_images",
|
||||||
|
return_value=(torch.full((1, 3), 2.0), torch.tensor([[1, 1, 1]])),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
output = model.get_image_feature([eager, deferred])
|
||||||
|
|
||||||
|
torch.testing.assert_close(output, torch.tensor([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_trusted_hot_hit_skips_media_read():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.processor_fingerprint = "processor"
|
||||||
|
processor.trust_mm_content_hashes = True
|
||||||
|
processor.mm_preprocess_cache = MultimodalPreprocessCache(1024 * 1024)
|
||||||
|
processor.io_executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
digest = "sha256:" + "ab" * 32
|
||||||
|
key = processor._artifact_key(digest, "unread-source")
|
||||||
|
artifact = _cached_k3_artifact(digest, key)
|
||||||
|
processor.mm_preprocess_cache.put(key, artifact)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"sglang.srt.multimodal.media_artifacts.base.snapshot_media",
|
||||||
|
side_effect=AssertionError("trusted cache hit must not read media"),
|
||||||
|
):
|
||||||
|
result = asyncio.run(
|
||||||
|
processor.prepare_media_artifacts(
|
||||||
|
["unread-source"],
|
||||||
|
content_hashes=[digest],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
processor.io_executor.shutdown()
|
||||||
|
|
||||||
|
assert result == [artifact]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_default_media_options_share_one_artifact_key():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.processor_fingerprint = "processor"
|
||||||
|
digest = "sha256:" + "ab" * 32
|
||||||
|
|
||||||
|
keys = {
|
||||||
|
processor._artifact_key(digest, "image.png"),
|
||||||
|
processor._artifact_key(digest, ImageData(url="image.png")),
|
||||||
|
processor._artifact_key(digest, {"url": "image.png", "detail": "auto"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert len(keys) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_output_affecting_media_options_do_not_share_artifacts():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.processor_fingerprint = "processor"
|
||||||
|
digest = "sha256:" + "ab" * 32
|
||||||
|
base = processor._artifact_key(digest, ImageData(url="image.png"))
|
||||||
|
|
||||||
|
assert base != processor._artifact_key(
|
||||||
|
digest, ImageData(url="image.png", detail="low")
|
||||||
|
)
|
||||||
|
assert base != processor._artifact_key(
|
||||||
|
digest, ImageData(url="image.png", max_dynamic_patch=4)
|
||||||
|
)
|
||||||
|
assert base != processor._artifact_key(
|
||||||
|
digest,
|
||||||
|
ImageData(url="image.png", preprocess_kwargs={"max_pixels": 1024}),
|
||||||
|
)
|
||||||
|
assert base != processor._artifact_key(
|
||||||
|
digest,
|
||||||
|
{"url": "image.png", "future_model_option": "new-behavior"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_rejects_changed_feature_hash_for_same_artifact():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.processor_fingerprint = "processor"
|
||||||
|
processor.trust_mm_content_hashes = False
|
||||||
|
processor.mm_preprocess_cache = MultimodalPreprocessCache(1024 * 1024)
|
||||||
|
processor.mm_processor_executor = None
|
||||||
|
processor.io_executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
image = Image.new("RGB", (2, 2), color=(1, 2, 3))
|
||||||
|
digest = snapshot_media(image).content_digest
|
||||||
|
key = processor._artifact_key(digest, image)
|
||||||
|
old = replace(_cached_k3_artifact(digest, key), feature=None)
|
||||||
|
new = replace(_cached_k3_artifact(digest, key), feature_hash=old.feature_hash + 1)
|
||||||
|
processor.mm_preprocess_cache.put(key, old)
|
||||||
|
|
||||||
|
async def prepare(_entries):
|
||||||
|
return [new]
|
||||||
|
|
||||||
|
processor._run_preprocess_and_build_artifact_batch = prepare
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError, match="feature hash changed"):
|
||||||
|
asyncio.run(processor.prepare_media_artifacts([image]))
|
||||||
|
finally:
|
||||||
|
processor.io_executor.shutdown()
|
||||||
|
|
||||||
|
assert key not in processor.mm_preprocess_cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_artifact_and_data_item_share_hash_resolution():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.mm_feature_transport = "cpu"
|
||||||
|
processor.mm_tokens = SimpleNamespace(image_token_id=99)
|
||||||
|
processor._tokenizer = _Tokenizer()
|
||||||
|
processor.use_cuda_ipc = False
|
||||||
|
feature = torch.zeros((4, 3), dtype=torch.float32)
|
||||||
|
digest = "sha256:" + "ab" * 32
|
||||||
|
|
||||||
|
artifact = processor._make_artifact(
|
||||||
|
content_digest=digest,
|
||||||
|
artifact_key="sha256:" + "01" * 32,
|
||||||
|
original_size=(2, 2),
|
||||||
|
resize_config={
|
||||||
|
"num_tokens": 1,
|
||||||
|
"new_width": 2,
|
||||||
|
"new_height": 2,
|
||||||
|
"pad_width": 0,
|
||||||
|
"pad_height": 0,
|
||||||
|
},
|
||||||
|
grid_thw=(1, 1, 1),
|
||||||
|
feature=feature,
|
||||||
|
)
|
||||||
|
direct_item = MultimodalDataItem(modality=Modality.IMAGE, feature=feature)
|
||||||
|
direct_item.set_pad_value()
|
||||||
|
composed_item = processor.compose_request([1, 99, 2], [artifact]).mm_items[0]
|
||||||
|
|
||||||
|
expected_hash = resolve_multimodal_item_hash(
|
||||||
|
existing_hash=direct_item.hash,
|
||||||
|
namespace=artifact.artifact_key,
|
||||||
|
)
|
||||||
|
expected_item = MultimodalDataItem(modality=Modality.IMAGE, hash=expected_hash)
|
||||||
|
expected_item.set_pad_value()
|
||||||
|
assert artifact.feature_hash == composed_item.hash == expected_hash
|
||||||
|
assert composed_item.pad_value == expected_item.pad_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_untrusted_path_change_is_a_cache_miss():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.processor_fingerprint = "processor"
|
||||||
|
processor.trust_mm_content_hashes = False
|
||||||
|
processor.mm_preprocess_cache = MultimodalPreprocessCache(1024 * 1024)
|
||||||
|
processor.mm_processor_executor = None
|
||||||
|
processor.io_executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
|
||||||
|
async def prepare(entries):
|
||||||
|
return [
|
||||||
|
_cached_k3_artifact(
|
||||||
|
entry.content_digest,
|
||||||
|
entry.artifact_key,
|
||||||
|
entry.media.getpixel((0, 0))[0],
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
processor._run_preprocess_and_build_artifact_batch = prepare
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "mutable.png"
|
||||||
|
Image.new("RGB", (2, 2), color=(1, 0, 0)).save(path)
|
||||||
|
first = asyncio.run(processor.prepare_media_artifacts([str(path)]))[0]
|
||||||
|
Image.new("RGB", (2, 2), color=(2, 0, 0)).save(path)
|
||||||
|
second = asyncio.run(processor.prepare_media_artifacts([str(path)]))[0]
|
||||||
|
finally:
|
||||||
|
processor.io_executor.shutdown()
|
||||||
|
|
||||||
|
assert first.content_digest != second.content_digest
|
||||||
|
assert first.artifact_key != second.artifact_key
|
||||||
|
assert first.feature[0, 0].item() == 1
|
||||||
|
assert second.feature[0, 0].item() == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_partial_hits_deduplicate_misses_and_preserve_order():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.processor_fingerprint = "processor"
|
||||||
|
processor.trust_mm_content_hashes = False
|
||||||
|
processor.mm_preprocess_cache = MultimodalPreprocessCache(1024 * 1024)
|
||||||
|
processor.mm_processor_executor = None
|
||||||
|
processor.io_executor = ThreadPoolExecutor(max_workers=4)
|
||||||
|
cached_image = Image.new("RGB", (2, 2), color=(1, 0, 0))
|
||||||
|
missed_image = Image.new("RGB", (2, 2), color=(2, 0, 0))
|
||||||
|
cached_digest = snapshot_media(cached_image).content_digest
|
||||||
|
missed_digest = snapshot_media(missed_image).content_digest
|
||||||
|
cached_key = processor._artifact_key(cached_digest, cached_image)
|
||||||
|
cached = _cached_k3_artifact(cached_digest, cached_key, value=1)
|
||||||
|
processor.mm_preprocess_cache.put(cached_key, cached)
|
||||||
|
batches = []
|
||||||
|
|
||||||
|
async def prepare(entries):
|
||||||
|
batches.append(entries)
|
||||||
|
return [
|
||||||
|
replace(
|
||||||
|
_cached_k3_artifact(
|
||||||
|
entry.content_digest,
|
||||||
|
entry.artifact_key,
|
||||||
|
entry.media.getpixel((0, 0))[0],
|
||||||
|
),
|
||||||
|
feature_hash=456,
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
processor._run_preprocess_and_build_artifact_batch = prepare
|
||||||
|
try:
|
||||||
|
artifacts = asyncio.run(
|
||||||
|
processor.prepare_media_artifacts(
|
||||||
|
[cached_image, missed_image, missed_image, cached_image],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
processor.io_executor.shutdown()
|
||||||
|
|
||||||
|
assert len(batches) == 1
|
||||||
|
assert len(batches[0]) == 1
|
||||||
|
assert batches[0][0].content_digest == missed_digest
|
||||||
|
assert batches[0][0].artifact_key == processor._artifact_key(
|
||||||
|
missed_digest, missed_image
|
||||||
|
)
|
||||||
|
assert [artifact.content_digest for artifact in artifacts] == [
|
||||||
|
cached_digest,
|
||||||
|
missed_digest,
|
||||||
|
missed_digest,
|
||||||
|
cached_digest,
|
||||||
|
]
|
||||||
|
assert artifacts[0] is artifacts[3]
|
||||||
|
assert artifacts[1] is artifacts[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_cancelled_artifact_owner_does_not_fail_joiner():
|
||||||
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.processor_fingerprint = "processor"
|
||||||
|
processor.trust_mm_content_hashes = False
|
||||||
|
processor.mm_preprocess_cache = MultimodalPreprocessCache(1024 * 1024)
|
||||||
|
processor.mm_processor_executor = None
|
||||||
|
processor.io_executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
processor._preprocess_metrics_callback = None
|
||||||
|
image = Image.new("RGB", (2, 2), color=(1, 2, 3))
|
||||||
|
started = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
|
||||||
|
async def prepare(entries):
|
||||||
|
started.set()
|
||||||
|
await release.wait()
|
||||||
|
return [
|
||||||
|
_cached_k3_artifact(
|
||||||
|
entry.content_digest,
|
||||||
|
entry.artifact_key,
|
||||||
|
entry.media.getpixel((0, 0))[0],
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
processor._run_preprocess_and_build_artifact_batch = prepare
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
owner = asyncio.create_task(processor.prepare_media_artifacts([image]))
|
||||||
|
await started.wait()
|
||||||
|
joiner = asyncio.create_task(processor.prepare_media_artifacts([image]))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
owner.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await owner
|
||||||
|
|
||||||
|
release.set()
|
||||||
|
artifacts = await joiner
|
||||||
|
assert len(artifacts) == 1
|
||||||
|
assert artifacts[0].feature[0, 0].item() == 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
processor.io_executor.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kimi_k3_cpu_transport_defers_gpu_preprocessing():
|
||||||
processor = object.__new__(KimiK3ImageProcessor)
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
processor.mm_tokens = SimpleNamespace(image_token_id=99)
|
processor.mm_tokens = SimpleNamespace(image_token_id=99)
|
||||||
processor.mm_feature_transport = "cpu"
|
processor.mm_feature_transport = "cpu"
|
||||||
processor.use_cuda_ipc = False
|
processor.use_cuda_ipc = False
|
||||||
processor._processor = SimpleNamespace(
|
processor._processor = SimpleNamespace(
|
||||||
_patch_size=2,
|
preprocess_config=_k3_preprocess_config(patch_size=2),
|
||||||
prepare_deferred=Mock(
|
prepare_deferred=Mock(
|
||||||
return_value=(
|
return_value=(
|
||||||
torch.tensor([[1, 99, 99, 2, 99, 3]]),
|
torch.tensor([[1, 99, 99, 2, 99, 3]]),
|
||||||
@@ -776,11 +1317,7 @@ def test_kimi_k3_defers_only_when_raw_transport_is_smaller(
|
|||||||
processor = object.__new__(KimiK3ImageProcessor)
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
processor.mm_feature_transport = "cpu"
|
processor.mm_feature_transport = "cpu"
|
||||||
processor._processor = SimpleNamespace(
|
processor._processor = SimpleNamespace(
|
||||||
_patch_size=14,
|
preprocess_config=_k3_preprocess_config(in_patch_limit=in_patch_limit),
|
||||||
_merge_kernel_size=2,
|
|
||||||
_in_patch_limit=in_patch_limit,
|
|
||||||
_patch_limit_on_one_side=512,
|
|
||||||
_fixed_output_tokens=None,
|
|
||||||
)
|
)
|
||||||
image = torch.zeros(image_shape, dtype=torch.uint8)
|
image = torch.zeros(image_shape, dtype=torch.uint8)
|
||||||
|
|
||||||
@@ -817,7 +1354,7 @@ def test_kimi_k3_eager_preprocessing_preserves_float_tensor_support():
|
|||||||
assert output.shape == (3, 4, 4)
|
assert output.shape == (3, 4, 4)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("transport", ["cuda_ipc", "fabric"])
|
@pytest.mark.parametrize("transport", ["cuda_ipc", "cuda_vmm"])
|
||||||
def test_kimi_k3_keeps_gpu_transport_preprocessing_eager(transport):
|
def test_kimi_k3_keeps_gpu_transport_preprocessing_eager(transport):
|
||||||
processor = object.__new__(KimiK3ImageProcessor)
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
processor.mm_feature_transport = transport
|
processor.mm_feature_transport = transport
|
||||||
@@ -831,6 +1368,7 @@ def test_kimi_k3_keeps_gpu_transport_preprocessing_eager(transport):
|
|||||||
def test_kimi_k3_rejects_silently_dropped_images():
|
def test_kimi_k3_rejects_silently_dropped_images():
|
||||||
processor = object.__new__(KimiK3ImageProcessor)
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
processor.mm_tokens = Mock()
|
processor.mm_tokens = Mock()
|
||||||
|
processor.mm_preprocess_cache = MultimodalPreprocessCache(0)
|
||||||
processor.load_mm_data = AsyncMock(return_value=SimpleNamespace(images=[object()]))
|
processor.load_mm_data = AsyncMock(return_value=SimpleNamespace(images=[object()]))
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="expected 2, loaded 1"):
|
with pytest.raises(ValueError, match="expected 2, loaded 1"):
|
||||||
@@ -838,13 +1376,14 @@ def test_kimi_k3_rejects_silently_dropped_images():
|
|||||||
processor.process_mm_data_async(
|
processor.process_mm_data_async(
|
||||||
image_data=["image-1", "image-2"],
|
image_data=["image-1", "image-2"],
|
||||||
input_text="<|media_pad|><|media_pad|>",
|
input_text="<|media_pad|><|media_pad|>",
|
||||||
request_obj=SimpleNamespace(video_data=None),
|
request_obj=SimpleNamespace(video_data=None, mm_content_hashes=None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_kimi_k3_uses_token_ids_to_preserve_media_boundaries():
|
def test_kimi_k3_uses_token_ids_to_preserve_media_boundaries():
|
||||||
processor = object.__new__(KimiK3ImageProcessor)
|
processor = object.__new__(KimiK3ImageProcessor)
|
||||||
|
processor.mm_preprocess_cache = MultimodalPreprocessCache(0)
|
||||||
processor.mm_feature_transport = "cpu"
|
processor.mm_feature_transport = "cpu"
|
||||||
processor.mm_tokens = SimpleNamespace(image_token_id=99)
|
processor.mm_tokens = SimpleNamespace(image_token_id=99)
|
||||||
processor.mm_feature_transport = "cuda_ipc"
|
processor.mm_feature_transport = "cuda_ipc"
|
||||||
@@ -863,7 +1402,7 @@ def test_kimi_k3_uses_token_ids_to_preserve_media_boundaries():
|
|||||||
processor.process_mm_data_async(
|
processor.process_mm_data_async(
|
||||||
image_data=["image-1", "image-2"],
|
image_data=["image-1", "image-2"],
|
||||||
input_text=[1, 99, 2, 99, 3],
|
input_text=[1, 99, 2, 99, 3],
|
||||||
request_obj=SimpleNamespace(video_data=None),
|
request_obj=SimpleNamespace(video_data=None, mm_content_hashes=None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,11 @@ def make_processor(config, image_processor_cls=None):
|
|||||||
model_impl="sglang",
|
model_impl="sglang",
|
||||||
keep_mm_feature_on_device=False,
|
keep_mm_feature_on_device=False,
|
||||||
mm_feature_transport="cpu",
|
mm_feature_transport="cpu",
|
||||||
|
image_processor_backend="auto",
|
||||||
disable_fast_image_processor=True,
|
disable_fast_image_processor=True,
|
||||||
skip_tokenizer_init=False,
|
skip_tokenizer_init=False,
|
||||||
|
mm_preprocess_cache_size_mb=0,
|
||||||
|
trust_mm_content_hashes=False,
|
||||||
# Read by NativeMmHost._use_feature_shm (single-rank fixture → the
|
# Read by NativeMmHost._use_feature_shm (single-rank fixture → the
|
||||||
# inline zero-copy transport, like the 1-GPU e2e).
|
# inline zero-copy transport, like the 1-GPU e2e).
|
||||||
tp_size=1,
|
tp_size=1,
|
||||||
@@ -80,6 +83,7 @@ def make_processor(config, image_processor_cls=None):
|
|||||||
mm_processor_worker_num=1,
|
mm_processor_worker_num=1,
|
||||||
tokenizer_worker_num=1,
|
tokenizer_worker_num=1,
|
||||||
base_gpu_id=0,
|
base_gpu_id=0,
|
||||||
|
rl_on_policy_target=None,
|
||||||
allowed_media_domains=[],
|
allowed_media_domains=[],
|
||||||
media_url_max_file_size_mb=64,
|
media_url_max_file_size_mb=64,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sglang.srt.managers.schedule_batch import Modality
|
||||||
|
from sglang.srt.multimodal.cache import MultimodalPreprocessCache, snapshot_media
|
||||||
|
from sglang.srt.multimodal.media_artifacts import (
|
||||||
|
MediaArtifactCacheMixin,
|
||||||
|
MediaArtifactInput,
|
||||||
|
)
|
||||||
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
|
||||||
|
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Artifact:
|
||||||
|
content_digest: str
|
||||||
|
artifact_key: str
|
||||||
|
feature_hash: int
|
||||||
|
feature: Optional[bytes]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_feature(self) -> bool:
|
||||||
|
return self.feature is not None
|
||||||
|
|
||||||
|
def cache_value(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def cache_size_items(self):
|
||||||
|
return (
|
||||||
|
self.content_digest,
|
||||||
|
self.artifact_key,
|
||||||
|
self.feature_hash,
|
||||||
|
self.feature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _FutureMediaInput:
|
||||||
|
url: str
|
||||||
|
content_hash: Optional[str] = None
|
||||||
|
frame_sampling: int = 2
|
||||||
|
|
||||||
|
|
||||||
|
class _Processor(MediaArtifactCacheMixin):
|
||||||
|
artifact_modality = Modality.IMAGE
|
||||||
|
artifact_option_defaults = {"detail": "auto", "frame_sampling": 2}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processor_fingerprint = "processor"
|
||||||
|
self.trust_mm_content_hashes = False
|
||||||
|
self.mm_preprocess_cache = MultimodalPreprocessCache(1024 * 1024)
|
||||||
|
self.mm_processor_executor = None
|
||||||
|
self.io_executor = ThreadPoolExecutor(max_workers=4)
|
||||||
|
self.batches = []
|
||||||
|
|
||||||
|
def decode_media_snapshot(self, snapshot, modality):
|
||||||
|
self.assert_artifact_modality(modality)
|
||||||
|
return snapshot.data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def assert_artifact_modality(modality):
|
||||||
|
if modality != Modality.IMAGE:
|
||||||
|
raise AssertionError(f"unexpected modality: {modality}")
|
||||||
|
|
||||||
|
def prepare_artifact_batch(
|
||||||
|
self, entries: list[MediaArtifactInput]
|
||||||
|
) -> list[_Artifact]:
|
||||||
|
self.batches.append(entries)
|
||||||
|
return [
|
||||||
|
_Artifact(
|
||||||
|
content_digest=entry.content_digest,
|
||||||
|
artifact_key=entry.artifact_key,
|
||||||
|
feature_hash=int(entry.content_digest[-16:], 16),
|
||||||
|
feature=entry.media,
|
||||||
|
)
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.io_executor.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMediaArtifactProcessor(unittest.TestCase):
|
||||||
|
def test_unknown_model_option_is_part_of_artifact_identity(self):
|
||||||
|
processor = _Processor()
|
||||||
|
digest = snapshot_media(b"image").content_digest
|
||||||
|
try:
|
||||||
|
base = processor._artifact_key(digest, _FutureMediaInput(url="image.png"))
|
||||||
|
self.assertEqual(
|
||||||
|
base,
|
||||||
|
processor._artifact_key(
|
||||||
|
digest,
|
||||||
|
{
|
||||||
|
"url": "image.png",
|
||||||
|
"content_hash": digest,
|
||||||
|
"frame_sampling": 2,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
base,
|
||||||
|
processor._artifact_key(
|
||||||
|
digest,
|
||||||
|
{"url": "image.png", "future_model_knob": "different"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
base,
|
||||||
|
processor._artifact_key(
|
||||||
|
digest,
|
||||||
|
_FutureMediaInput(url="image.png"),
|
||||||
|
modality=Modality.VIDEO,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
processor.close()
|
||||||
|
|
||||||
|
def test_non_image_models_can_override_identity_and_decode_hooks(self):
|
||||||
|
class _VideoProcessor(_Processor):
|
||||||
|
artifact_modality = Modality.VIDEO
|
||||||
|
|
||||||
|
def snapshot_media_source(self, source, modality):
|
||||||
|
self.assert_video_modality(modality)
|
||||||
|
return snapshot_media(source.encode())
|
||||||
|
|
||||||
|
def decode_media_snapshot(self, snapshot, modality):
|
||||||
|
self.assert_video_modality(modality)
|
||||||
|
return snapshot.data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def assert_video_modality(modality):
|
||||||
|
if modality != Modality.VIDEO:
|
||||||
|
raise AssertionError(f"unexpected modality: {modality}")
|
||||||
|
|
||||||
|
processor = _VideoProcessor()
|
||||||
|
try:
|
||||||
|
artifacts = asyncio.run(processor.prepare_media_artifacts(["clip.mp4"]))
|
||||||
|
finally:
|
||||||
|
processor.close()
|
||||||
|
|
||||||
|
self.assertEqual(len(artifacts), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
artifacts[0].content_digest, snapshot_media(b"clip.mp4").content_digest
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_partial_hits_and_duplicate_misses_are_shared_by_contract(self):
|
||||||
|
processor = _Processor()
|
||||||
|
first_digest = snapshot_media(b"first").content_digest
|
||||||
|
first_key = processor._artifact_key(first_digest, b"first")
|
||||||
|
first = _Artifact(first_digest, first_key, 1, b"first")
|
||||||
|
processor.mm_preprocess_cache.put(first_key, first)
|
||||||
|
|
||||||
|
try:
|
||||||
|
artifacts = asyncio.run(
|
||||||
|
processor.prepare_media_artifacts(
|
||||||
|
[b"first", b"second", b"second", b"first"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
processor.close()
|
||||||
|
|
||||||
|
self.assertEqual(len(processor.batches), 1)
|
||||||
|
self.assertEqual(len(processor.batches[0]), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
[artifact.content_digest for artifact in artifacts],
|
||||||
|
[
|
||||||
|
first_digest,
|
||||||
|
snapshot_media(b"second").content_digest,
|
||||||
|
snapshot_media(b"second").content_digest,
|
||||||
|
first_digest,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertIs(artifacts[0], artifacts[3])
|
||||||
|
self.assertIs(artifacts[1], artifacts[2])
|
||||||
|
|
||||||
|
def test_adapter_cannot_change_validated_artifact_identity(self):
|
||||||
|
processor = _Processor()
|
||||||
|
|
||||||
|
async def wrong_identity(entries):
|
||||||
|
artifact = processor.prepare_artifact_batch(entries)[0]
|
||||||
|
return [replace(artifact, artifact_key="sha256:" + "0" * 64)]
|
||||||
|
|
||||||
|
processor._run_preprocess_and_build_artifact_batch = wrong_identity
|
||||||
|
try:
|
||||||
|
with self.assertRaisesRegex(ValueError, "changed the media artifact key"):
|
||||||
|
asyncio.run(processor.prepare_media_artifacts([b"image"]))
|
||||||
|
finally:
|
||||||
|
processor.close()
|
||||||
|
|
||||||
|
def test_trusted_hit_uses_identity_without_reading_source(self):
|
||||||
|
processor = _Processor()
|
||||||
|
processor.trust_mm_content_hashes = True
|
||||||
|
digest = snapshot_media(b"cached").content_digest
|
||||||
|
key = processor._artifact_key(digest, "unread-source")
|
||||||
|
artifact = _Artifact(digest, key, 1, b"cached")
|
||||||
|
processor.mm_preprocess_cache.put(key, artifact)
|
||||||
|
|
||||||
|
try:
|
||||||
|
artifacts = asyncio.run(
|
||||||
|
processor.prepare_media_artifacts(
|
||||||
|
["unread-source"], content_hashes=[digest]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
processor.close()
|
||||||
|
|
||||||
|
self.assertEqual(artifacts, [artifact])
|
||||||
|
self.assertEqual(processor.batches, [])
|
||||||
|
|
||||||
|
def test_cached_artifact_must_match_content_identity(self):
|
||||||
|
processor = _Processor()
|
||||||
|
digest = snapshot_media(b"fresh").content_digest
|
||||||
|
key = processor._artifact_key(digest, b"fresh")
|
||||||
|
processor.mm_preprocess_cache.put(
|
||||||
|
key,
|
||||||
|
_Artifact(
|
||||||
|
snapshot_media(b"stale").content_digest,
|
||||||
|
key,
|
||||||
|
1,
|
||||||
|
b"stale",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
artifacts = asyncio.run(processor.prepare_media_artifacts([b"fresh"]))
|
||||||
|
finally:
|
||||||
|
processor.close()
|
||||||
|
|
||||||
|
self.assertEqual(artifacts[0].content_digest, digest)
|
||||||
|
self.assertEqual(artifacts[0].feature, b"fresh")
|
||||||
|
self.assertEqual(len(processor.batches), 1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -4,22 +4,24 @@ import os
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from sglang.srt.managers.schedule_batch import Modality, MultimodalDataItem
|
||||||
from sglang.srt.multimodal.cache import (
|
from sglang.srt.multimodal.cache import (
|
||||||
|
CacheMiss,
|
||||||
MultimodalPreprocessCache,
|
MultimodalPreprocessCache,
|
||||||
build_artifact_key,
|
build_artifact_key,
|
||||||
build_feature_hash,
|
|
||||||
build_processor_fingerprint,
|
build_processor_fingerprint,
|
||||||
estimate_cache_size_bytes,
|
estimate_cache_size_bytes,
|
||||||
parse_content_hash,
|
parse_content_hash,
|
||||||
|
resolve_multimodal_item_hash,
|
||||||
snapshot_media,
|
snapshot_media,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.server_args import ServerArgs
|
||||||
from sglang.test.ci.ci_register import register_cpu_ci
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
|
||||||
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
|
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
|
||||||
@@ -209,21 +211,25 @@ class TestMediaIdentity(unittest.TestCase):
|
|||||||
def preprocess_fingerprint_payload(self):
|
def preprocess_fingerprint_payload(self):
|
||||||
return {"backend": self.backend, "antialias": True}
|
return {"backend": self.backend, "antialias": True}
|
||||||
|
|
||||||
config = SimpleNamespace(model_type="vlm", architectures=["VLM"])
|
class Config:
|
||||||
args = SimpleNamespace(
|
def to_dict(self):
|
||||||
|
return {"model_type": "vlm", "architectures": ["VLM"]}
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
args = ServerArgs(
|
||||||
|
model_path="dummy",
|
||||||
revision="model-revision",
|
revision="model-revision",
|
||||||
tokenizer_revision="tokenizer-revision",
|
|
||||||
disable_fast_image_processor=False,
|
disable_fast_image_processor=False,
|
||||||
mm_process_config={"image": {"max_pixels": 1024}},
|
mm_process_config={"image": {"max_pixels": 1024}},
|
||||||
)
|
)
|
||||||
base = build_processor_fingerprint(Processor("gpu"), config, args)
|
base = build_processor_fingerprint(Processor("gpu"), config, args)
|
||||||
|
|
||||||
changed_backend = build_processor_fingerprint(Processor("cpu"), config, args)
|
changed_backend = build_processor_fingerprint(Processor("cpu"), config, args)
|
||||||
changed_args = SimpleNamespace(
|
changed_args = ServerArgs(
|
||||||
**{
|
model_path="dummy",
|
||||||
**vars(args),
|
revision="model-revision",
|
||||||
"mm_process_config": {"image": {"max_pixels": 2048}},
|
disable_fast_image_processor=False,
|
||||||
}
|
mm_process_config={"image": {"max_pixels": 2048}},
|
||||||
)
|
)
|
||||||
changed_config = build_processor_fingerprint(
|
changed_config = build_processor_fingerprint(
|
||||||
Processor("gpu"), config, changed_args
|
Processor("gpu"), config, changed_args
|
||||||
@@ -231,7 +237,7 @@ class TestMediaIdentity(unittest.TestCase):
|
|||||||
self.assertNotEqual(base, changed_backend)
|
self.assertNotEqual(base, changed_backend)
|
||||||
self.assertNotEqual(base, changed_config)
|
self.assertNotEqual(base, changed_config)
|
||||||
|
|
||||||
def test_feature_hash_includes_artifact_and_processor_output(self):
|
def test_item_hash_namespace_covers_identity_and_processor_output(self):
|
||||||
digest = snapshot_media(b"image").content_digest
|
digest = snapshot_media(b"image").content_digest
|
||||||
first = build_artifact_key(
|
first = build_artifact_key(
|
||||||
digest,
|
digest,
|
||||||
@@ -243,11 +249,25 @@ class TestMediaIdentity(unittest.TestCase):
|
|||||||
modality="image",
|
modality="image",
|
||||||
processor_fingerprint="processor-b",
|
processor_fingerprint="processor-b",
|
||||||
)
|
)
|
||||||
self.assertNotEqual(build_feature_hash(first, 1), build_feature_hash(second, 1))
|
self.assertNotEqual(
|
||||||
self.assertNotEqual(build_feature_hash(first, 1), build_feature_hash(first, 2))
|
resolve_multimodal_item_hash(existing_hash=1, namespace=first),
|
||||||
self.assertIsInstance(build_feature_hash(first, 1 << 128), int)
|
resolve_multimodal_item_hash(existing_hash=1, namespace=second),
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
resolve_multimodal_item_hash(existing_hash=1, namespace=first),
|
||||||
|
resolve_multimodal_item_hash(existing_hash=2, namespace=first),
|
||||||
|
)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
build_feature_hash(first, -1)
|
resolve_multimodal_item_hash(existing_hash=-1, namespace=first)
|
||||||
|
|
||||||
|
def test_multimodal_data_item_uses_shared_feature_hash(self):
|
||||||
|
feature = torch.arange(12, dtype=torch.float32).reshape(4, 3)
|
||||||
|
expected = resolve_multimodal_item_hash(feature=feature)
|
||||||
|
item = MultimodalDataItem(modality=Modality.IMAGE, feature=feature)
|
||||||
|
|
||||||
|
item.set_pad_value()
|
||||||
|
|
||||||
|
self.assertEqual(item.hash, expected)
|
||||||
|
|
||||||
|
|
||||||
class TestMultimodalPreprocessCache(unittest.TestCase):
|
class TestMultimodalPreprocessCache(unittest.TestCase):
|
||||||
@@ -262,6 +282,31 @@ class TestMultimodalPreprocessCache(unittest.TestCase):
|
|||||||
self.assertIn("c", cache)
|
self.assertIn("c", cache)
|
||||||
self.assertEqual(cache.current_size_bytes, 6)
|
self.assertEqual(cache.current_size_bytes, 6)
|
||||||
|
|
||||||
|
def test_compatible_lookup_is_atomic_and_does_not_count_bypass_as_miss(self):
|
||||||
|
cache = MultimodalPreprocessCache[str, bytes](max_size_bytes=1024)
|
||||||
|
cache.put("key", b"metadata-only")
|
||||||
|
|
||||||
|
self.assertIsNone(cache.get_if_present("key", lambda value: False))
|
||||||
|
self.assertEqual((cache.hits, cache.misses), (0, 0))
|
||||||
|
self.assertEqual(
|
||||||
|
cache.get_if_present("key", lambda value: value.startswith(b"metadata")),
|
||||||
|
b"metadata-only",
|
||||||
|
)
|
||||||
|
self.assertEqual((cache.hits, cache.misses), (1, 0))
|
||||||
|
|
||||||
|
def test_claimed_miss_rejects_an_incompatible_racing_entry(self):
|
||||||
|
cache = MultimodalPreprocessCache[str, bytes](max_size_bytes=1024)
|
||||||
|
cache.put("key", b"metadata-only")
|
||||||
|
|
||||||
|
miss = cache.lookup_or_claim_many(
|
||||||
|
["key"], predicate=lambda key, value: value == b"full-feature"
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertIsInstance(miss, CacheMiss)
|
||||||
|
self.assertTrue(miss.should_compute)
|
||||||
|
self.assertNotIn("key", cache)
|
||||||
|
self.assertEqual(cache.current_size_bytes, 0)
|
||||||
|
|
||||||
def test_gpu_backed_values_are_not_implicitly_copied(self):
|
def test_gpu_backed_values_are_not_implicitly_copied(self):
|
||||||
if not torch.cuda.is_available():
|
if not torch.cuda.is_available():
|
||||||
self.skipTest("CUDA is not available")
|
self.skipTest("CUDA is not available")
|
||||||
@@ -370,6 +415,61 @@ class TestMultimodalPreprocessCache(unittest.TestCase):
|
|||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_lookup_or_claim_many_batches_owned_and_joined_misses(self):
|
||||||
|
async def run():
|
||||||
|
cache = MultimodalPreprocessCache[str, bytes](max_size_bytes=1024)
|
||||||
|
results = cache.lookup_or_claim_many(["a", "b", "a"])
|
||||||
|
misses_to_compute = [
|
||||||
|
item
|
||||||
|
for item in results
|
||||||
|
if isinstance(item, CacheMiss) and item.should_compute
|
||||||
|
]
|
||||||
|
self.assertEqual([item.key for item in misses_to_compute], ["a", "b"])
|
||||||
|
|
||||||
|
cache.complete_miss(misses_to_compute[0], b"value-a")
|
||||||
|
cache.complete_miss(misses_to_compute[1], b"value-b")
|
||||||
|
self.assertEqual(await cache.wait_for_miss(results[2]), b"value-a")
|
||||||
|
self.assertEqual(cache.get("b"), b"value-b")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_cancelled_miss_waiter_does_not_cancel_computing_caller(self):
|
||||||
|
async def run():
|
||||||
|
cache = MultimodalPreprocessCache[str, bytes](max_size_bytes=1024)
|
||||||
|
computing_miss = cache.lookup_or_claim_many(["key"])[0]
|
||||||
|
waiting_miss = cache.lookup_or_claim_many(["key"])[0]
|
||||||
|
self.assertTrue(computing_miss.should_compute)
|
||||||
|
self.assertFalse(waiting_miss.should_compute)
|
||||||
|
|
||||||
|
waiter = asyncio.create_task(cache.wait_for_miss(waiting_miss))
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
waiter.cancel()
|
||||||
|
with self.assertRaises(asyncio.CancelledError):
|
||||||
|
await waiter
|
||||||
|
|
||||||
|
cache.complete_miss(computing_miss, b"artifact")
|
||||||
|
self.assertEqual(computing_miss.future.result(), b"artifact")
|
||||||
|
self.assertEqual(cache.get("key"), b"artifact")
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_disabled_cache_does_not_join_or_retain(self):
|
||||||
|
async def run():
|
||||||
|
cache = MultimodalPreprocessCache[str, bytes](max_size_bytes=0)
|
||||||
|
misses = cache.lookup_or_claim_many(["a", "a"])
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
isinstance(item, CacheMiss) and item.should_compute
|
||||||
|
for item in misses
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for item in misses:
|
||||||
|
cache.complete_miss(item, b"value")
|
||||||
|
self.assertEqual(len(cache), 0)
|
||||||
|
self.assertEqual(cache.stats()["singleflight_joins"], 0)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
def test_clear_starts_a_new_singleflight_generation(self):
|
def test_clear_starts_a_new_singleflight_generation(self):
|
||||||
async def run():
|
async def run():
|
||||||
cache = MultimodalPreprocessCache[str, bytes](max_size_bytes=1024)
|
cache = MultimodalPreprocessCache[str, bytes](max_size_bytes=1024)
|
||||||
@@ -397,6 +497,20 @@ class TestMultimodalPreprocessCache(unittest.TestCase):
|
|||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
def test_clear_starts_a_new_cache_miss_generation(self):
|
||||||
|
cache = MultimodalPreprocessCache[str, bytes](max_size_bytes=1024)
|
||||||
|
old = cache.lookup_or_claim_many(["key"])[0]
|
||||||
|
cache.clear()
|
||||||
|
new = cache.lookup_or_claim_many(["key"])[0]
|
||||||
|
|
||||||
|
self.assertTrue(old.should_compute)
|
||||||
|
self.assertTrue(new.should_compute)
|
||||||
|
self.assertIsNot(old.future, new.future)
|
||||||
|
cache.complete_miss(old, b"old")
|
||||||
|
self.assertNotIn("key", cache)
|
||||||
|
cache.complete_miss(new, b"new")
|
||||||
|
self.assertEqual(cache.get("key"), b"new")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user