[EPD] feat: pipeline owner-only multimodal preprocessing (#34206)

This commit is contained in:
Mick
2026-08-11 09:08:05 +08:00
committed by GitHub
parent 03c942dfab
commit 418975ba64
6 changed files with 809 additions and 74 deletions
+137 -55
View File
@@ -58,6 +58,11 @@ from sglang.srt.model_executor.model_runner_components.load_model_utils import (
maybe_precompile_model_kernels_after_loading,
)
from sglang.srt.model_loader import get_model
from sglang.srt.multimodal.encoder_preprocessing import (
EncoderPreprocessOutput,
get_encoder_preprocessed_items,
invoke_encoder_preprocessor,
)
from sglang.srt.multimodal.processors.qwen_vl import preprocess_video
from sglang.srt.observability.metrics_collector import EncoderMetricsCollector
from sglang.srt.observability.req_time_stats import EncoderReqTimeStats
@@ -65,7 +70,13 @@ from sglang.srt.observability.trace import (
process_tracing_init,
trace_set_thread_info,
)
from sglang.srt.runtime_context import get_disagg, get_exec, get_mm, publish
from sglang.srt.runtime_context import (
get_disagg,
get_exec,
get_mm,
get_parallel,
publish,
)
from sglang.srt.server_args import (
PortArgs,
ServerArgs,
@@ -862,9 +873,24 @@ class MMEncoder:
return slices
def _calculate_hashes_from_features(
self, mm_feature, grid_thw: List, modality: Modality
self, mm_feature, grid_thw: List, modality: Modality, mm_inputs=None
) -> List[int]:
"""CPU Task: Compute hashes based on processed feature patches."""
preprocessed_items = (
get_encoder_preprocessed_items(mm_inputs) if mm_inputs is not None else None
)
if preprocessed_items is not None:
if len(preprocessed_items) != len(grid_thw):
raise ValueError(
"Encoder preprocess item/grid mismatch: "
f"{len(preprocessed_items)} items != {len(grid_thw)} grids"
)
hashes = []
for item in preprocessed_items:
item.set_pad_value()
hashes.append(item.hash)
return hashes
hashes = []
if modality == Modality.AUDIO and isinstance(mm_feature, list):
for feature in mm_feature:
@@ -884,21 +910,37 @@ class MMEncoder:
offset += num_patches
return hashes
def _encode_missing(
def _build_mm_data_items(
self,
mm_feature,
mm_inputs: dict,
indices: List[int],
modality: Modality = Modality.IMAGE,
get_feature_fn=None,
modality: Modality,
grid_thw: Optional[List] = None,
keep_on_gpu: bool = False,
) -> List[torch.Tensor]:
"""
GPU Task: Run ViT inference ONLY on the subset of mm items missing from the cache.
) -> List[MultimodalDataItem]:
"""Build the model-facing items selected for one encoder forward.
A model preprocessor can preserve an item-wise representation with
``EncoderPreprocessOutput``. This path avoids concatenating and then
re-slicing features before encoder-DP knows which rank owns each item.
Legacy Hugging Face processor outputs retain their existing aggregate
tensor behavior.
"""
if grid_thw is None:
grid_thw = _get_mm_grid_dim(mm_inputs, modality, self.model_type)
preprocessed_items = get_encoder_preprocessed_items(mm_inputs)
if preprocessed_items is not None:
if len(preprocessed_items) != len(grid_thw):
raise ValueError(
"Encoder preprocess item/grid mismatch: "
f"{len(preprocessed_items)} items != {len(grid_thw)} grids"
)
selected = [preprocessed_items[index] for index in indices]
if any(item.modality != modality for item in selected):
raise ValueError("Encoder preprocess output contains wrong modality")
return selected
split_kimi_k3_images = (
self.model_type == "kimi_k3" and modality == Modality.IMAGE
)
@@ -916,11 +958,11 @@ class MMEncoder:
sub_feature_list = []
offsets = [0]
curr = 0
for g in grid_thw:
curr += self.get_num_patches(g, modality)
for grid in grid_thw:
curr += self.get_num_patches(grid, modality)
offsets.append(curr)
for idx in indices:
sub_feature_list.append(mm_feature[offsets[idx] : offsets[idx + 1]])
for index in indices:
sub_feature_list.append(mm_feature[offsets[index] : offsets[index + 1]])
if not split_kimi_k3_images:
sub_feature = torch.cat(sub_feature_list, dim=0)
@@ -948,19 +990,39 @@ class MMEncoder:
)
]
for k, v in mm_inputs.items():
if k in _mm_feature_attrs.get(modality, []):
for key, value in mm_inputs.items():
if key in _mm_feature_attrs.get(modality, []):
continue
val = _convert(v)
if k in _mm_grid_attrs.get(modality, []):
value = _convert(value)
if key in _mm_grid_attrs.get(modality, []):
if split_kimi_k3_images:
for mm_item, idx in zip(mm_items, indices):
mm_item.set(k, val[idx : idx + 1])
for mm_item, index in zip(mm_items, indices):
mm_item.set(key, value[index : index + 1])
else:
mm_items[0].set(k, val[indices])
mm_items[0].set(key, value[indices])
else:
for mm_item in mm_items:
mm_item.set(k, val)
mm_item.set(key, value)
return mm_items
def _encode_missing(
self,
mm_feature,
mm_inputs: dict,
indices: List[int],
modality: Modality = Modality.IMAGE,
get_feature_fn=None,
grid_thw: Optional[List] = None,
keep_on_gpu: bool = False,
) -> List[torch.Tensor]:
"""
GPU Task: Run ViT inference ONLY on the subset of mm items missing from the cache.
"""
if grid_thw is None:
grid_thw = _get_mm_grid_dim(mm_inputs, modality, self.model_type)
mm_items = self._build_mm_data_items(
mm_feature, mm_inputs, indices, modality, grid_thw
)
forward_start = time.perf_counter()
with torch.inference_mode():
@@ -1002,7 +1064,7 @@ class MMEncoder:
if self.rank == 0:
if hashes is None:
mm_hashes = self._calculate_hashes_from_features(
mm_feature, grid_thw, modality
mm_feature, grid_thw, modality, mm_inputs
)
else:
mm_hashes = hashes
@@ -1651,12 +1713,31 @@ class MMEncoder:
if not (self.image_processor or model_preprocessor):
raise ValueError("No image processor available")
images = await self._flatten_and_load_images(mm_items)
if model_preprocessor:
return model_preprocessor(images, Modality.IMAGE, self.vision_config)
image_config = self.vision_config.get("image", {})
original_image_sizes = [_get_original_image_size(item) for item in images]
if self.model_type in ["kimi_k25", "kimi_k3", "kimi_vl"]:
images = self._normalize_kimi_encoder_images(images)
original_image_sizes = [_get_original_image_size(item) for item in images]
if model_preprocessor:
processor_output = invoke_encoder_preprocessor(
model_preprocessor,
images,
Modality.IMAGE,
self.vision_config,
image_processor=self.image_processor,
use_gpu_preprocessing=self.use_image_processor_gpu,
)
if (
isinstance(processor_output, EncoderPreprocessOutput)
and processor_output.materialize_local_items is not None
):
parallel = get_parallel()
await asyncio.get_running_loop().run_in_executor(
self.preproc_executor,
processor_output.materialize_for_rank,
parallel.attn_tp_rank,
parallel.attn_tp_size,
)
return processor_output
image_config = self.vision_config.get("image", {})
processor_input = await asyncio.get_running_loop().run_in_executor(
self.preproc_executor,
functools.partial(self.image_processor, images=images, **image_config),
@@ -1775,25 +1856,25 @@ class MMEncoder:
# support mm_cache
mm_embedding = None
mm_hash = None
mm_item = MultimodalDataItem.from_dict(
{
"modality": modality,
"feature": _convert(_get_mm_feature(mm_inputs, modality)),
}
mm_feature = _convert(_get_mm_feature(mm_inputs, modality))
grid_thw = _get_mm_grid_dim(mm_inputs, modality, self.model_type)
model_mm_items = self._build_mm_data_items(
mm_feature,
mm_inputs,
list(range(len(grid_thw))),
modality,
grid_thw,
)
for k, v in mm_inputs.items():
if k in _mm_feature_attrs[modality]:
continue
mm_item.set(k, _convert(v))
cache_hit = False
use_mm_cache = get_mm().enable_prefix_mm_cache and log_metrics
if use_mm_cache:
mm_item.set_pad_value()
mm_hash = MultiModalStaticCache.combine_hashes([mm_item.hash])
for item in model_mm_items:
item.set_pad_value()
item_hashes = [item.hash for item in model_mm_items]
mm_hash = MultiModalStaticCache.combine_hashes(item_hashes)
async with self.mm_cache_lock:
mm_cache = self.mm_cache.get([mm_item.hash])
mm_cache = self.mm_cache.get(item_hashes)
if mm_cache is not None:
mm_embedding = mm_cache.embedding
cache_hit = True
@@ -1801,7 +1882,7 @@ class MMEncoder:
if mm_embedding is None:
forward_start = time.perf_counter()
with torch.inference_mode():
mm_embedding: torch.Tensor = get_feature_fn([mm_item])
mm_embedding: torch.Tensor = get_feature_fn(model_mm_items)
mm_embedding = mm_embedding.cpu()
if len(mm_embedding.shape) != 2:
mm_embedding = mm_embedding.reshape(-1, mm_embedding.shape[-1])
@@ -1810,7 +1891,8 @@ class MMEncoder:
time.perf_counter() - forward_start, modality=modality_str
)
# Per-request cache hit metrics: tokens = embedding rows, files = 1 item.
# Per-request cache hit metrics: tokens = embedding rows, files =
# logical multimodal items (not the legacy aggregate tensor count).
if use_mm_cache and encoder_metrics_collector is not None:
total_tokens = int(mm_embedding.shape[0])
hit_tokens = total_tokens if cache_hit else 0
@@ -1818,7 +1900,9 @@ class MMEncoder:
hit_tokens, total_tokens, modality=modality_str
)
encoder_metrics_collector.record_cache_files(
1 if cache_hit else 0, 1, modality=modality_str
len(model_mm_items) if cache_hit else 0,
len(model_mm_items),
modality=modality_str,
)
if use_mm_cache:
@@ -1865,7 +1949,7 @@ class MMEncoder:
)
return (
_get_mm_grid_dim(mm_inputs, modality, self.model_type),
grid_thw,
mm_embedding,
aux_data,
)
@@ -2223,23 +2307,21 @@ class MMEncoder:
)
)
# Build mm_item (all ranks)
mm_item = MultimodalDataItem.from_dict(
{
"modality": modality,
"feature": _convert(_get_mm_feature(mm_inputs, modality)),
}
# Build model-facing items on all ranks. Owner-deferred processor
# outputs stay per-item until encoder-DP assigns them.
mm_feature = _convert(_get_mm_feature(mm_inputs, modality))
model_mm_items = self._build_mm_data_items(
mm_feature,
mm_inputs,
list(range(len(grid_thw))),
modality,
grid_thw,
)
for k, v in mm_inputs.items():
if k in _mm_feature_attrs.get(modality, []):
continue
val = _convert(v)
mm_item.set(k, val)
async def _run_forward():
try:
with torch.inference_mode():
emb = get_feature_fn([mm_item])
emb = get_feature_fn(model_mm_items)
if len(emb.shape) != 2:
emb = emb.reshape(-1, emb.shape[-1])
# mooncake's transfer_sync is a host-side
+82 -18
View File
@@ -3064,6 +3064,32 @@ class KimiK3ForConditionalGeneration(nn.Module):
)
self.language_model.set_dspark_layers_to_capture(layer_ids)
def preprocess_mm_for_encoder(
self,
mm_data,
modality,
config,
*,
image_processor=None,
use_gpu_preprocessing=False,
):
"""Prepare per-image raw inputs for owner-side EPD preprocessing."""
if modality != Modality.IMAGE:
raise ValueError("Kimi-K3 encoder mode supports image input only")
if image_processor is None:
raise ValueError("Kimi-K3 encoder preprocessing needs an image processor")
from sglang.srt.multimodal.kimi_k3_image_processing import (
prepare_kimi_k3_encoder_inputs,
)
self._encoder_image_processor = image_processor
return prepare_kimi_k3_encoder_inputs(
mm_data,
image_processor,
use_gpu_preprocessing=use_gpu_preprocessing,
)
def get_image_feature(self, items: List[MultimodalDataItem]) -> torch.Tensor:
device = self.vision_tower.device
target_dtype = self.vision_tower.patch_embed.proj.weight.dtype
@@ -3087,6 +3113,10 @@ class KimiK3ForConditionalGeneration(nn.Module):
def materialize_item_features(image_indices: List[int]) -> torch.Tensor:
"""Materialize only the images assigned to this vision-DP rank."""
from sglang.srt.multimodal.encoder_preprocessing import (
LOCAL_PREPROCESSED_KEY,
)
# Match the configured TP consumer count captured when the
# tokenizer creates MmItemMemoryPool. A live attention subgroup
# size could leave acknowledgements missing and strand the lease.
@@ -3104,6 +3134,21 @@ class KimiK3ForConditionalGeneration(nn.Module):
)
selected_items.append(item)
locally_preprocessed = [
item.model_specific_data.get(LOCAL_PREPROCESSED_KEY, False)
for item in selected_items
]
if any(locally_preprocessed):
if not all(locally_preprocessed):
raise ValueError(
"Kimi-K3 cannot mix local preprocessed and deferred images"
)
return materialize_multimodal_features(
[item.feature for item in selected_items],
device=device,
dtype=target_dtype,
)
deferred = [
item.model_specific_data.get(DEFERRED_PREPROCESSING_KEY)
for item in selected_items
@@ -3113,25 +3158,44 @@ class KimiK3ForConditionalGeneration(nn.Module):
raise ValueError(
"Kimi-K3 cannot mix deferred and preprocessed image features"
)
from sglang.srt.multimodal.processors.kimi_k25 import (
_gpu_preprocess_images,
)
first_config = deferred[0]
image_scale, image_bias = normalization_tensors(
first_config["image_mean"], first_config["image_std"], device
)
pixel_values, _ = _gpu_preprocess_images(
[item.feature for item in selected_items],
[config["resize_config"] for config in deferred],
image_scale,
image_bias,
self.vision_tower.patch_size,
to_chw=lambda image: to_chw_uint8(image, device=device),
post_resize=lambda x: fill_transparent_bg(
x, first_config["transparent_bg_config"]
),
)
backend = first_config["backend"]
if any(config["backend"] != backend for config in deferred):
raise ValueError(
"Kimi-K3 cannot mix deferred preprocessing backends"
)
if backend == "gpu":
from sglang.srt.multimodal.processors.kimi_k25 import (
_gpu_preprocess_images,
)
image_scale, image_bias = normalization_tensors(
first_config["image_mean"], first_config["image_std"], device
)
pixel_values, _ = _gpu_preprocess_images(
[item.feature for item in selected_items],
[config["resize_config"] for config in deferred],
image_scale,
image_bias,
self.vision_tower.patch_size,
to_chw=lambda image: to_chw_uint8(image, device=device),
post_resize=lambda x: fill_transparent_bg(
x, first_config["transparent_bg_config"]
),
)
elif backend == "cpu":
from sglang.srt.multimodal.kimi_k3_image_processing import (
materialize_kimi_k3_cpu_features,
)
pixel_values = materialize_kimi_k3_cpu_features(
selected_items, self._encoder_image_processor
)
pixel_values = pixel_values.to(device, non_blocking=True)
else:
raise ValueError(
f"Unsupported Kimi-K3 deferred preprocessing backend: {backend}"
)
return pixel_values.to(dtype=target_dtype)
features = []
@@ -0,0 +1,140 @@
import hashlib
import inspect
from collections.abc import Mapping
from typing import Any, Callable, Iterable, Sequence
import numpy as np
import torch
from sglang.srt.managers.schedule_batch import MultimodalDataItem
LOCAL_PREPROCESSED_KEY = "encoder_local_preprocessed"
def hash_raw_encoder_item(value: Any) -> int:
"""Hash raw CPU media including layout metadata, before owner materialization."""
if isinstance(value, torch.Tensor):
value = value.detach().cpu().contiguous().numpy()
elif not isinstance(value, np.ndarray):
from PIL import Image
if not isinstance(value, Image.Image):
raise TypeError(f"Unsupported raw encoder item: {type(value)}")
value = np.asarray(value)
value = np.ascontiguousarray(value)
hasher = hashlib.sha256()
hasher.update(value.dtype.str.encode())
hasher.update(repr(value.shape).encode())
hasher.update(memoryview(value))
return int.from_bytes(hasher.digest()[:8], byteorder="big", signed=False)
class EncoderPreprocessOutput(dict):
"""Processor output that preserves one feature object per multimodal item.
Most Hugging Face processors concatenate every image into one tensor before
the vision model decides its encoder-DP assignment. A model-specific
``preprocess_mm_for_encoder`` hook can return this mapping instead, allowing
the encoder to carry raw or partially processed items to the model. The
model can then materialize only the items owned by its local vision rank.
The mapping remains compatible with existing encoder metadata helpers;
``mm_items`` is an out-of-band, per-item representation used only by the
encoder forward/cache paths.
"""
def __init__(
self,
values: Mapping[str, Any] | None = None,
*,
mm_items: Iterable[MultimodalDataItem],
item_sizes: Sequence[int] | None = None,
materialize_local_items: (
Callable[[list[MultimodalDataItem]], Sequence[torch.Tensor]] | None
) = None,
) -> None:
super().__init__(values or {})
self.mm_items = list(mm_items)
if not self.mm_items:
raise ValueError("EncoderPreprocessOutput requires at least one item")
self.item_sizes = list(item_sizes) if item_sizes is not None else None
if self.item_sizes is not None and len(self.item_sizes) != len(self.mm_items):
raise ValueError("Encoder preprocess item_sizes must match mm_items")
self.materialize_local_items = materialize_local_items
def local_item_indices(self, rank: int, world_size: int) -> list[int]:
"""Return the same size-balanced owner assignment used by vision DP."""
if self.materialize_local_items is None:
return []
if world_size < 1 or not 0 <= rank < world_size:
raise ValueError(
f"Invalid encoder preprocess rank {rank} for world size {world_size}"
)
if world_size == 1:
return list(range(len(self.mm_items)))
if self.item_sizes is None:
raise ValueError(
"Owner-side encoder preprocessing requires per-item load sizes"
)
from sglang.srt.multimodal.mm_utils import get_dp_encoder_lb_assignment
shuffled, counts, _ = get_dp_encoder_lb_assignment(self.item_sizes, world_size)
start = sum(counts[:rank])
return shuffled[start : start + counts[rank]]
def materialize_for_rank(self, rank: int, world_size: int) -> None:
"""Materialize only this vision-DP rank's items in-place."""
indices = self.local_item_indices(rank, world_size)
if not indices:
return
items = [self.mm_items[index] for index in indices]
materialize = self.materialize_local_items
assert materialize is not None
features = list(materialize(items))
if len(features) != len(items):
raise ValueError(
"Encoder local materializer must return one feature per item"
)
for item, feature in zip(items, features):
item.feature = feature
item.model_specific_data[LOCAL_PREPROCESSED_KEY] = True
def get_encoder_preprocessed_items(
processor_output: Mapping[str, Any],
) -> list[MultimodalDataItem] | None:
if isinstance(processor_output, EncoderPreprocessOutput):
return processor_output.mm_items
return None
def invoke_encoder_preprocessor(
preprocessor,
mm_data,
modality,
config,
**available_context,
):
"""Call a model hook with only the optional context it declares.
Existing hooks keep their three-argument contract. New model integrations
can request shared processor state or backend policy by adding named
keyword-only parameters, without requiring encode-server model branches.
"""
parameters = inspect.signature(preprocessor).parameters
accepts_kwargs = any(
parameter.kind == inspect.Parameter.VAR_KEYWORD
for parameter in parameters.values()
)
context = (
available_context
if accepts_kwargs
else {
name: value
for name, value in available_context.items()
if name in parameters
}
)
return preprocessor(mm_data, modality, config, **context)
@@ -1,3 +1,5 @@
import functools
import math
from typing import Union
import numpy as np
@@ -7,6 +9,195 @@ from PIL import Image
DEFERRED_PREPROCESSING_KEY = "kimi_k3_deferred_preprocessing"
def prepare_kimi_k3_encoder_inputs(
images, image_processor, *, use_gpu_preprocessing=False
):
"""Keep K3 EPD images raw until the vision-DP owner is known.
The lightweight NaViT shape calculation runs on every encoder rank. The
expensive resize, normalization, and patchification remain deferred in each
item and are executed by ``KimiK3ForConditionalGeneration`` only for images
assigned to the local vision rank.
"""
from sglang.srt.managers.schedule_batch import Modality, MultimodalDataItem
from sglang.srt.multimodal.encoder_preprocessing import (
EncoderPreprocessOutput,
hash_raw_encoder_item,
)
from sglang.srt.multimodal.processors.kimi_k25 import (
_grid_thw_from_resize_config,
navit_resize_config,
)
media_proc_cfg = getattr(image_processor, "media_proc_cfg", None)
if not isinstance(media_proc_cfg, dict):
raise ValueError(
"Kimi-K3 EPD owner-side preprocessing requires "
"image_processor.media_proc_cfg"
)
required = (
"patch_size",
"merge_kernel_size",
"in_patch_limit",
"patch_limit_on_one_side",
"image_mean",
"image_std",
)
missing = [name for name in required if name not in media_proc_cfg]
if missing:
raise ValueError(
"Kimi-K3 image processor is missing deferred-preprocessing config: "
+ ", ".join(missing)
)
concrete_images = []
for image in images:
if isinstance(image, dict):
if image.get("type") != "image" or "image" not in image:
raise ValueError(f"Unsupported Kimi-K3 encoder media item: {image}")
image = image["image"]
concrete_images.append(image)
patch_size = int(media_proc_cfg["patch_size"])
merge_kernel_size = int(media_proc_cfg["merge_kernel_size"])
common_deferred_config = {
"backend": "gpu" if use_gpu_preprocessing else "cpu",
"image_mean": list(media_proc_cfg["image_mean"]),
"image_std": list(media_proc_cfg["image_std"]),
"transparent_bg_config": media_proc_cfg.get("transparent_bg_config"),
}
items = []
grids = []
original_image_sizes = []
for image in concrete_images:
width, height = (
(int(image.shape[-1]), int(image.shape[-2]))
if isinstance(image, torch.Tensor)
else image.size
)
resize_config = navit_resize_config(
width,
height,
patch_size,
merge_kernel_size,
int(media_proc_cfg["in_patch_limit"]),
int(media_proc_cfg["patch_limit_on_one_side"]),
media_proc_cfg.get("fixed_output_tokens"),
)
grid_thw = _grid_thw_from_resize_config(resize_config, patch_size)
grid_tensor = torch.tensor([grid_thw], dtype=torch.int64)
item = MultimodalDataItem(
modality=Modality.IMAGE,
feature=to_chw_uint8(image) if use_gpu_preprocessing else image,
model_specific_data={
"grid_thws": grid_tensor,
DEFERRED_PREPROCESSING_KEY: {
**common_deferred_config,
"feature_layout": "chw" if use_gpu_preprocessing else "raw",
"resize_config": resize_config,
},
},
)
if not use_gpu_preprocessing:
item.set_hash(hash_raw_encoder_item(image))
items.append(item)
grids.append(grid_thw)
original_image_sizes.append([width, height])
grid_thws = torch.tensor(grids, dtype=torch.int64)
return EncoderPreprocessOutput(
{
# Preserve the conventional feature key for cache/accounting code;
# EncoderPreprocessOutput.mm_items is the authoritative per-item view.
"pixel_values": [item.feature for item in items],
"grid_thws": grid_thws,
"original_image_sizes": original_image_sizes,
},
mm_items=items,
item_sizes=[math.prod(grid) for grid in grids],
materialize_local_items=(
None
if use_gpu_preprocessing
else functools.partial(
materialize_kimi_k3_cpu_item_features,
image_processor=image_processor,
)
),
)
def materialize_kimi_k3_cpu_features(items, image_processor) -> torch.Tensor:
"""Run the checkpoint's exact processor only on locally owned images."""
medias = []
for item in items:
image = item.feature
config = item.model_specific_data[DEFERRED_PREPROCESSING_KEY]
if config["feature_layout"] != "raw":
raise ValueError("Kimi-K3 deferred CPU preprocessing expects raw inputs")
if not isinstance(image, Image.Image):
if not isinstance(image, torch.Tensor) or image.dtype != torch.uint8:
raise TypeError(
"Kimi-K3 deferred CPU preprocessing expects PIL or uint8 tensors"
)
image = to_hwc_uint8(image).numpy()
channels = image.shape[-1]
if channels == 1:
image = Image.fromarray(image[..., 0], mode="L")
elif channels == 3:
image = Image.fromarray(image, mode="RGB")
elif channels == 4:
image = Image.fromarray(image, mode="RGBA")
else:
raise ValueError(f"Unsupported Kimi-K3 image channel count: {channels}")
medias.append({"type": "image", "image": image})
output = image_processor.preprocess(medias, return_tensors="pt")
expected_grids = torch.cat(
[item.model_specific_data["grid_thws"] for item in items], dim=0
)
if not torch.equal(output["grid_thws"].cpu(), expected_grids.cpu()):
raise ValueError("Kimi-K3 deferred CPU preprocessing produced wrong grids")
return output["pixel_values"]
def materialize_kimi_k3_cpu_item_features(items, image_processor) -> list[torch.Tensor]:
"""Return exact checkpoint-processor features split by logical image."""
pixel_values = materialize_kimi_k3_cpu_features(items, image_processor)
patch_counts = [
math.prod(item.model_specific_data["grid_thws"][0].tolist()) for item in items
]
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))
def to_hwc_uint8(image: Union[torch.Tensor, Image.Image]) -> torch.Tensor:
"""Stage an exact CPU image without doing resize/normalize work."""
if isinstance(image, Image.Image):
has_alpha = image.mode != "RGB" and (
"A" in image.getbands() or "transparency" in image.info
)
array = np.array(image.convert("RGBA" if has_alpha else "RGB"), copy=True)
return torch.from_numpy(array)
if image.dtype != torch.uint8:
raise ValueError(
f"Kimi-K3 preprocessing expects raw uint8 pixels, got {image.dtype}"
)
if image.dim() == 2:
image = image.unsqueeze(-1)
elif image.dim() == 3 and image.shape[0] in (1, 3, 4):
image = image.permute(1, 2, 0)
if image.shape[-1] == 1:
image = image.repeat(1, 1, 3)
return image.cpu().contiguous()
def to_chw_uint8(
image: Union[torch.Tensor, Image.Image],
device: torch.device | str | None = None,