Files
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

324 lines
12 KiB
Python

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Multimodal RoPE (M-RoPE) position computation.
The SMG gateway ships precomputed multimodal inputs but does not compute the
3-axis M-RoPE position_ids that MRoPE-aware models (the Qwen-VL family) need.
The engine computes them here on the un-padded input_ids, from the model config
plus the image/video ``grid_thw`` carried on the multimodal items. Non-MRoPE
models (e.g. Kimi-K2.5) return ``(None, None)``.
This replaces the former per-model ``BaseMultimodalProcessor`` hierarchy +
``processor_registry``, whose only remaining live use after the SMG migration
was this single computation.
"""
from __future__ import annotations
import torch
from tokenspeed.runtime.layers.rotary_embedding import MRotaryEmbedding
# Architectures whose HF configs follow the Qwen-VL M-RoPE layout
# (vision_config.spatial_merge_size, image/video/vision_start token ids, etc.).
_MROPE_ARCHITECTURES = {
"Qwen3_5ForConditionalGeneration",
"Qwen3_5MoeForConditionalGeneration",
"Qwen3OmniMoeForConditionalGeneration",
}
_QWEN3_OMNI_ARCHITECTURES = {
"Qwen3OmniMoeForConditionalGeneration",
}
def _modality_name(item) -> str:
modality = item.modality
return getattr(modality, "name", str(modality)).lower()
def _as_grid_rows(value, key: str) -> torch.Tensor:
grid = torch.as_tensor(value, dtype=torch.long).reshape(-1, 3).cpu()
if torch.any(grid <= 0):
raise ValueError(f"{key} must contain positive [T, H, W] values")
return grid
def _per_grid_seconds(item, num_grids: int, default: float) -> list[float]:
data = item.model_specific_data
value = data.get("video_second_per_grid")
if value is None:
return [default] * num_grids
values = torch.as_tensor(value, dtype=torch.float32).flatten().tolist()
if len(values) == 1:
return values * num_grids
if len(values) != num_grids:
raise ValueError(
"video_second_per_grid must be scalar or have one value per video grid"
)
return values
def _omni_media_segments(thinker_config, input_len: int, mm_items):
"""Return authored media spans with one position descriptor per span."""
merge = thinker_config.vision_config.spatial_merge_size
default_seconds = float(getattr(thinker_config, "seconds_per_chunk", 2.0))
segments = []
for item in mm_items:
offsets = list(item.offsets or [])
if not offsets:
continue
modality = _modality_name(item)
if modality == "audio":
descriptors = [("audio", None, None)] * len(offsets)
elif modality in ("image", "video"):
key = f"{modality}_grid_thw"
if key not in item.model_specific_data:
raise ValueError(f"Qwen3-Omni {modality} item is missing {key}")
if modality == "video" and "use_audio_in_video" in item.model_specific_data:
interleaved = torch.as_tensor(
item.model_specific_data["use_audio_in_video"]
)
if bool(interleaved.any().item()):
raise ValueError(
"Qwen3-Omni use_audio_in_video=true is not supported"
)
grids = _as_grid_rows(item.model_specific_data[key], key)
if len(grids) != len(offsets):
raise ValueError(
f"Qwen3-Omni {modality} has {len(offsets)} placeholder spans "
f"but {len(grids)} grid rows"
)
seconds = (
_per_grid_seconds(item, len(grids), default_seconds)
if modality == "video"
else [None] * len(grids)
)
descriptors = [
(modality, grid, second)
for grid, second in zip(grids, seconds, strict=True)
]
else:
raise ValueError(f"Unsupported Qwen3-Omni modality: {modality}")
for offset, descriptor in zip(offsets, descriptors, strict=True):
start, end = map(int, offset)
if start < 0 or end < start or end >= input_len:
raise ValueError(
f"Invalid Qwen3-Omni media offset [{start}, {end}] for "
f"input length {input_len}"
)
segments.append((start, end, *descriptor))
segments.sort(key=lambda segment: segment[0])
return segments, merge
def _compute_qwen3_omni_mrope_positions(hf_config, input_ids, mm_items):
"""Compute Qwen3-Omni M-RoPE for independent image/audio/video inputs.
The gateway's inclusive item offsets are authoritative. Audio advances all
three axes linearly. Vision uses T/H/W axes; video T is expressed on the
model's ``position_id_per_seconds`` clock. Audio extracted from a video is
intentionally not interleaved here (``use_audio_in_video=false``).
"""
thinker_config = getattr(hf_config, "thinker_config", hf_config)
position_id_per_seconds = float(
getattr(thinker_config, "position_id_per_seconds", 13)
)
input_len = len(input_ids)
segments, spatial_merge_size = _omni_media_segments(
thinker_config, input_len, mm_items
)
position_chunks = []
cursor = 0
next_position = 0
def append_linear(length: int) -> None:
nonlocal next_position
if length <= 0:
return
positions = torch.arange(
next_position, next_position + length, dtype=torch.long
)
position_chunks.append(positions.unsqueeze(0).expand(3, -1))
next_position += length
for start, end, modality, grid, seconds_per_grid in segments:
if start < cursor:
raise ValueError("Qwen3-Omni media placeholder spans overlap")
append_linear(start - cursor)
span = end - start + 1
if modality == "audio":
append_linear(span)
else:
t, h, w = (int(value) for value in grid)
if h % spatial_merge_size or w % spatial_merge_size:
raise ValueError(
"Qwen3-Omni vision grids must be divisible by spatial_merge_size"
)
h //= spatial_merge_size
w //= spatial_merge_size
expected_span = t * h * w
if span != expected_span:
raise ValueError(
f"Qwen3-Omni {modality} placeholder span has {span} tokens; "
f"grid requires {expected_span}"
)
temporal = torch.arange(t, dtype=torch.float32)
if modality == "video":
temporal *= float(seconds_per_grid) * position_id_per_seconds
else:
temporal *= position_id_per_seconds
temporal = temporal.to(torch.long)
temporal = temporal.view(-1, 1).expand(-1, h * w).flatten()
height = (
torch.arange(h, dtype=torch.long)
.view(1, -1, 1)
.expand(t, -1, w)
.flatten()
)
width = (
torch.arange(w, dtype=torch.long)
.view(1, 1, -1)
.expand(t, h, -1)
.flatten()
)
media_positions = (
torch.stack((temporal, height, width), dim=0) + next_position
)
position_chunks.append(media_positions)
next_position = int(media_positions.max().item()) + 1
cursor = end + 1
append_linear(input_len - cursor)
positions = (
torch.cat(position_chunks, dim=1)
if position_chunks
else torch.empty((3, 0), dtype=torch.long)
)
if positions.shape[1] != input_len:
raise RuntimeError(
"Qwen3-Omni M-RoPE position count does not match the input length"
)
delta = torch.tensor([[next_position - input_len]], dtype=torch.long)
return positions, delta
def compute_mrope_positions(hf_config, input_ids, mm_items):
"""Compute ``(mrope_positions, mrope_position_delta)`` for MRoPE models.
``mm_items`` are the precomputed ``MultimodalDataItem``s (their
``model_specific_data`` carries ``image_grid_thw`` / ``video_grid_thw``).
Returns ``(None, None)`` for non-MRoPE models.
"""
architectures = getattr(hf_config, "architectures", None) or []
if not any(arch in _MROPE_ARCHITECTURES for arch in architectures):
return None, None
if any(arch in _QWEN3_OMNI_ARCHITECTURES for arch in architectures):
return _compute_qwen3_omni_mrope_positions(hf_config, input_ids, mm_items)
image_grids = [
item.model_specific_data["image_grid_thw"]
for item in mm_items
if "image_grid_thw" in item.model_specific_data
]
video_grids = [
item.model_specific_data["video_grid_thw"]
for item in mm_items
if "video_grid_thw" in item.model_specific_data
]
image_grid_thw = torch.cat(image_grids, dim=0) if image_grids else None
video_grid_thw = torch.cat(video_grids, dim=0) if video_grids else None
# Qwen3.5 models compute M-RoPE with one video segment per temporal grid.
# The vision encoder still consumes the original grid [T, H, W], but the
# text prompt contains T separate <|video_pad|> runs. Split only the RoPE
# grid to match HuggingFace's Qwen3.5 get_rope_index behavior.
if video_grid_thw is not None and getattr(hf_config, "model_type", None) in (
"qwen3_5",
"qwen3_5_moe",
):
video_grid_thw = torch.repeat_interleave(
video_grid_thw, video_grid_thw[:, 0].to(torch.long), dim=0
).clone()
video_grid_thw[:, 0] = 1
input_ids_tensor = torch.tensor(input_ids, dtype=torch.long).unsqueeze(0)
mrope_positions, mrope_position_delta = MRotaryEmbedding.get_rope_index(
spatial_merge_size=hf_config.vision_config.spatial_merge_size,
image_token_id=hf_config.image_token_id,
video_token_id=hf_config.video_token_id,
vision_start_token_id=hf_config.vision_start_token_id,
model_type=hf_config.model_type,
tokens_per_second=getattr(hf_config.vision_config, "tokens_per_second", None),
input_ids=input_ids_tensor,
image_grid_thw=image_grid_thw,
video_grid_thw=video_grid_thw,
)
return mrope_positions.squeeze(1), mrope_position_delta
def extend_mrope_positions_for_retracted_request(
mrope_positions: torch.Tensor, output_ids_len: int
) -> torch.Tensor:
"""Extend ``mrope_positions`` to cover already-generated output tokens.
When a request carrying M-RoPE positions is retracted, the positions must be
extended over the output_ids generated so far. Output tokens are pure text,
so all three axes share the same incremental sequence.
Args:
mrope_positions: original positions, shape ``(3, origin_input_ids_len)``.
output_ids_len: number of output tokens to generate positions for.
Returns:
Extended positions, shape ``(3, origin_input_ids_len + output_ids_len)``.
"""
if output_ids_len <= 0:
return mrope_positions
# Continue the incremental sequence from the last input position.
last_position = mrope_positions[:, -1] # (3,)
start_pos = last_position[0] + 1
output_positions = (
torch.arange(
start_pos,
start_pos + output_ids_len,
dtype=torch.int64,
device=mrope_positions.device,
)
.unsqueeze(0)
.expand(3, -1)
) # (3, output_ids_len)
return torch.cat([mrope_positions, output_positions], dim=1)