Compare commits

...
Author SHA1 Message Date
khluuandCodex e4417044fc test: force CI failure for retry validation
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: khluu <khluu000@gmail.com>
2026-07-29 02:47:44 -07:00
Maria GuevaraandGitHub c44e191b01 [Rust Frontend] Add --limit-mm-per-prompt support (#49604)
Signed-off-by: Maria Guevara <kawaiiplush14@gmail.com>
2026-07-29 17:21:41 +08:00
fxmarty-amdandGitHub 5b14019576 [CI] Fix MXFP8 MOE backend selection tests on gfx942 (#50222)
Signed-off-by: Felix Marty <Felix.Marty@amd.com>
2026-07-29 17:17:11 +08:00
omerpaz95andGitHub dad7a6383b [EC Connector] Add has_pending_push_work (#49582)
Signed-off-by: omerpaz95 <omerpaz95@gmail.com>
2026-07-29 11:04:22 +02:00
5b29c958c7 [XPU] upgrade to torch 2.13 (#48677)
Signed-off-by: Yan Ma <yan.ma@intel.com>
Signed-off-by: Kunshang Ji <kunshang.ji@intel.com>
Co-authored-by: Kunshang Ji <kunshang.ji@intel.com>
Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
2026-07-29 01:24:56 -07:00
df2735ea2e [Misc][Minimax-M3]add default video_processor (#50092)
Signed-off-by: rongfu.leng <lenronfu@gmail.com>
Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
2026-07-29 01:24:51 -07:00
Jared WenGitHubmergify[bot] <37929162+mergify[bot]@users.noreply.github.com>Cyrus Leung
32e657e689 [BugFix] eagle draft max position embeddings (#49343)
Signed-off-by: JaredforReal <w13431838023@gmail.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: Cyrus Leung <tlleungac@connect.ust.hk>
2026-07-29 01:24:47 -07:00
ad5d29db70 [Model] Support Qwen3.5 text-only dense and MoE models (#50210)
Signed-off-by: Perkz Zheng <PerkzZheng@users.noreply.github.com>
Co-authored-by: Perkz Zheng <PerkzZheng@users.noreply.github.com>
2026-07-29 08:21:57 +00:00
36 changed files with 873 additions and 126 deletions
@@ -369,7 +369,7 @@ export HF_TOKEN ZE_AFFINITY_MASK
-e CMDS \
--name "${container_name}" \
"${IMAGE}" \
bash -c 'set -e; source /opt/intel/oneapi/setvars.sh --force; source /opt/intel/oneapi/ccl/2021.15/env/vars.sh --force; echo "ZE_AFFINITY_MASK is ${ZE_AFFINITY_MASK:-}"; eval "$CMDS"' \
bash -c 'set -e; echo "ZE_AFFINITY_MASK is ${ZE_AFFINITY_MASK:-}"; eval "$CMDS"' \
>/dev/null
} 9>/tmp/docker-pull.lock
+2 -35
View File
@@ -63,21 +63,6 @@ RUN apt-get update -y && \
python3-pip && \
rm -rf /var/lib/apt/lists/*
# Add oneAPI repo, pin oneAPI to 2025.3, then install pinned packages in one layer.
RUN wget -O- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB | gpg --dearmor | tee /usr/share/keyrings/oneapi-archive-keyring.gpg > /dev/null && \
echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" | tee /etc/apt/sources.list.d/oneAPI.list && \
printf '%s\n' \
'Package: intel-oneapi-* intel-deep-learning-essentials* intel-pti*' \
'Pin: version 2025.3*' \
'Pin-Priority: 1001' \
> /etc/apt/preferences.d/oneapi-2025.3.pref && \
apt-get update -y && \
apt-get install -y --no-install-recommends \
intel-oneapi-compiler-dpcpp-cpp-2025.3 \
intel-oneapi-mkl-devel-2025.3 \
intel-oneapi-dnnl-devel-2025.3 && \
rm -rf /var/lib/apt/lists/*
# Install UMD
RUN mkdir neo && \
cd neo && \
@@ -100,22 +85,6 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& uv venv --python ${PYTHON_VERSION} --seed ${VIRTUAL_ENV}
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# This oneccl contains the BMG support which is not the case for default version of oneapi 2025.3.
ARG ONECCL_INSTALLER="intel-oneccl-2021.15.9.14_offline.sh"
RUN wget "https://github.com/uxlfoundation/oneCCL/releases/download/2021.15.9/${ONECCL_INSTALLER}" && \
bash "${ONECCL_INSTALLER}" -a --silent --eula accept && \
rm "${ONECCL_INSTALLER}" && \
echo "source /opt/intel/oneapi/setvars.sh --force" >> /root/.bashrc && \
echo "source /opt/intel/oneapi/ccl/2021.15/env/vars.sh --force" >> /root/.bashrc && \
rm -f /opt/intel/oneapi/ccl/latest && \
ln -s /opt/intel/oneapi/ccl/2021.15 /opt/intel/oneapi/ccl/latest && \
printf '%s\n' \
'/opt/intel/oneapi/ccl/2021.15/lib' \
'/opt/intel/oneapi/mpi/2021.15/lib' \
'/opt/intel/oneapi/compiler/2025.3/lib' \
> /etc/ld.so.conf.d/oneapi-ccl.conf && \
ldconfig
SHELL ["bash", "-c"]
CMD ["bash", "-c", "source /root/.bashrc && exec bash"]
@@ -135,8 +104,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --upgrade pip
ENV LD_LIBRARY_PATH=/opt/intel/oneapi/ccl/2021.15/lib:/opt/intel/oneapi/mpi/2021.15/lib:/opt/intel/oneapi/compiler/2025.3/lib:/usr/local/lib
ENV LD_LIBRARY_PATH=/opt/venv/lib:/usr/local/lib
CMD ["/bin/bash"]
######################### UCX + NIXL BUILD STAGE #########################
@@ -216,8 +184,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install -r /workspace/vllm/requirements/xpu.txt && \
uv pip install --no-build-isolation -r /workspace/vllm/requirements/test/xpu.txt && \
uv pip uninstall triton triton-xpu && \
uv pip install triton-xpu==3.7.1 && \
uv pip uninstall oneccl oneccl-devel
uv pip install triton-xpu==3.7.2
# Keep source-dependent layers near the end so frequent code-only changes
# don't invalidate heavy dependency and UCX/NIXL layers.
@@ -42,12 +42,12 @@ pip install -v -r requirements/xpu.txt
```bash
pip uninstall -y triton triton-xpu
pip install triton-xpu==3.7.1 --extra-index-url https://download.pytorch.org/whl/xpu
pip install triton-xpu==3.7.2 --extra-index-url https://download.pytorch.org/whl/xpu
```
!!! note
- `triton` (without suffix) is for NVIDIA GPUs only. On XPU, using it instead of `triton-xpu` can cause correctness or runtime issues.
- For torch 2.12 (the version used in `requirements/xpu.txt`), the matching package is `triton-xpu==3.7.1`. If you use a different version of torch, check the corresponding `triton-xpu` version in [docker/Dockerfile.xpu](https://github.com/vllm-project/vllm/blob/main/docker/Dockerfile.xpu).
- For torch 2.13 (the version used in `requirements/xpu.txt`), the matching package is `triton-xpu==3.7.2`. If you use a different version of torch, check the corresponding `triton-xpu` version in [docker/Dockerfile.xpu](https://github.com/vllm-project/vllm/blob/main/docker/Dockerfile.xpu).
- Finally, build and install vLLM XPU backend:
+26 -24
View File
@@ -140,7 +140,7 @@ docopt==0.6.2
# via num2words
docstring-parser==0.18.0
# via anthropic
dpcpp-cpp-rt==2025.3.2
dpcpp-cpp-rt==2026.0.0
# via
# onemkl-sycl-blas
# onemkl-sycl-dft
@@ -253,27 +253,27 @@ ijson==3.5.0
# via -r requirements/test/../common.txt
imageio==2.37.3
# via scikit-image
impi-rt==2021.17.2
impi-rt==2021.18.0
# via
# oneccl
# torch
iniconfig==2.3.0
# via pytest
intel-cmplr-lib-rt==2025.3.2
intel-cmplr-lib-rt==2026.0.0
# via
# intel-sycl-rt
# torch
intel-cmplr-lib-ur==2025.3.2
intel-cmplr-lib-ur==2026.0.0
# via
# intel-openmp
# intel-sycl-rt
# torch
intel-cmplr-lic-rt==2025.3.2
intel-cmplr-lic-rt==2026.0.0
# via
# intel-opencl-rt
# intel-sycl-rt
# torch
intel-opencl-rt==2025.3.2
intel-opencl-rt==2026.0.0
# via
# dpcpp-cpp-rt
# onemkl-sycl-blas
@@ -282,14 +282,14 @@ intel-opencl-rt==2025.3.2
# onemkl-sycl-rng
# onemkl-sycl-sparse
# torch
intel-openmp==2025.3.2
intel-openmp==2026.0.0
# via
# dpcpp-cpp-rt
# mkl
# torch
intel-pti==0.16.0
intel-pti==0.17.0
# via torch
intel-sycl-rt==2025.3.2
intel-sycl-rt==2026.0.0
# via
# dpcpp-cpp-rt
# oneccl
@@ -378,7 +378,7 @@ mistral-common==1.11.5
# -c requirements/common.txt
# -r requirements/test/../common.txt
# -r requirements/test/xpu.in
mkl==2025.3.1
mkl==2026.0.0
# via
# onemkl-sycl-blas
# onemkl-sycl-dft
@@ -453,28 +453,28 @@ numpy==2.2.6
# torchvision
# transformers
# xgrammar
oneccl==2021.17.2
oneccl==2022.0.0
# via
# oneccl-devel
# torch
oneccl-devel==2021.17.2
oneccl-devel==2022.0.0
# via torch
onemkl-license==2025.3.1
onemkl-license==2026.0.0
# via
# mkl
# torch
onemkl-sycl-blas==2025.3.1
onemkl-sycl-blas==2026.0.0
# via
# onemkl-sycl-lapack
# onemkl-sycl-sparse
# torch
onemkl-sycl-dft==2025.3.1
onemkl-sycl-dft==2026.0.0
# via torch
onemkl-sycl-lapack==2025.3.1
onemkl-sycl-lapack==2026.0.0
# via torch
onemkl-sycl-rng==2025.3.1
onemkl-sycl-rng==2026.0.0
# via torch
onemkl-sycl-sparse==2025.3.1
onemkl-sycl-sparse==2026.0.0
# via torch
openai==2.44.0
# via
@@ -719,6 +719,8 @@ pyyaml==6.0.3
# timm
# transformers
# uvicorn
pyzes==0.1.1
# via torch
pyzmq==27.1.0
# via
# -c requirements/common.txt
@@ -871,14 +873,14 @@ tabledata==1.3.4
# via pytablewriter
tabulate==0.10.0
# via sacrebleu
tbb==2022.3.1
tbb==2023.0.0
# via
# intel-opencl-rt
# mkl
# torch
tblib==3.1.0
# via -r requirements/test/xpu.in
tcmlib==1.4.1
tcmlib==1.5.0
# via
# tbb
# torch
@@ -910,7 +912,7 @@ tokenizers==0.22.2
# -c requirements/common.txt
# -r requirements/test/../common.txt
# transformers
torch==2.12.0+xpu
torch==2.13.0+xpu
# via
# -c requirements/xpu.txt
# accelerate
@@ -920,7 +922,7 @@ torch==2.12.0+xpu
# timm
# torchvision
# xgrammar
torchvision==0.27.0+xpu
torchvision==0.28.0+xpu
# via timm
tqdm==4.67.3
# via
@@ -946,7 +948,7 @@ transformers==5.14.1
# xgrammar
triton==3.7.1
# via xgrammar
triton-xpu==3.7.1
triton-xpu==3.7.2
# via torch
typepy==1.3.4
# via
@@ -1001,7 +1003,7 @@ typing-inspection==0.4.2
# mcp
# pydantic
# pydantic-settings
umf==1.0.3
umf==1.1.0
# via
# intel-cmplr-lib-ur
# torch
+3 -3
View File
@@ -12,10 +12,10 @@ jinja2>=3.1.6
datasets # for benchmark scripts
numba == 0.65.0 # Required for N-gram speculative decoding
--extra-index-url=https://download.pytorch.org/whl/xpu
torch==2.12.0
torch==2.13.0
torchaudio
torchvision
torchcodec >= 0.14 # Required for the torchcodec video decoding backend
auto_round_lib==0.14.1
vllm_xpu_kernels @ https://github.com/vllm-project/vllm-xpu-kernels/releases/download/v0.1.11.1/vllm_xpu_kernels-0.1.11.1-cp38-abi3-manylinux_2_28_x86_64.whl
auto_round_lib==0.14.2
vllm_xpu_kernels @ https://github.com/vllm-project/vllm-xpu-kernels/releases/download/v0.1.12/vllm_xpu_kernels-0.1.12-cp38-abi3-manylinux_2_28_x86_64.whl
+2
View File
@@ -57,6 +57,7 @@ impl HfChatBackend {
processor_config: files.processor_config_path.as_deref(),
},
tokenizer.clone(),
options.limit_mm_per_prompt.clone(),
)?
};
let multimodal_render_info = resolve_multimodal_render_info(multimodal_model_info.as_ref());
@@ -231,6 +232,7 @@ mod tests {
chat_template_content_format: Default::default(),
chat_template: None,
default_chat_template_kwargs: HashMap::new(),
limit_mm_per_prompt: HashMap::new(),
},
test_tokenizer(),
)
+4 -1
View File
@@ -8,7 +8,7 @@ use serde_json::Value;
use vllm_text::{DynTextBackend, TextBackend};
use crate::error::Result;
use crate::multimodal::MultimodalModelInfo;
use crate::multimodal::{MmLimitPerPrompt, MultimodalModelInfo};
use crate::output::DynChatOutputProcessor;
use crate::renderer::DynChatRenderer;
use crate::request::ChatRequest;
@@ -74,6 +74,9 @@ pub struct LoadModelBackendsOptions {
/// Optional server-default keyword arguments merged into every
/// chat-template render before request-level `chat_template_kwargs`.
pub default_chat_template_kwargs: HashMap<String, Value>,
/// Maximum number of input items allowed per prompt for each modality.
/// Unspecified modalities are unlimited.
pub limit_mm_per_prompt: MmLimitPerPrompt,
}
/// Shared backends loaded from a model id.
+4 -1
View File
@@ -23,6 +23,8 @@ pub enum Error {
UnsupportedMultimodalContent(&'static str),
#[error("`{modality}` input is not supported by this model")]
UnsupportedModality { modality: String },
#[error("At most {limit} {modality}(s) may be provided in one prompt.")]
MmLimitExceeded { modality: String, limit: usize },
#[error("multimodal preprocessing error: {0}")]
Multimodal(#[message] String),
#[error("{kind} parsing is not available for model `{model_id}`")]
@@ -87,7 +89,8 @@ impl Error {
Self::Text(error) => error.is_request_validation_error(),
Self::UnsupportedMultimodalRenderer
| Self::UnsupportedMultimodalContent(_)
| Self::UnsupportedModality { .. } => true,
| Self::UnsupportedModality { .. }
| Self::MmLimitExceeded { .. } => true,
_ => false,
}
+262 -9
View File
@@ -11,7 +11,7 @@
//! Raw media stays above `vllm-text`; this module lowers it into token IDs and
//! opaque tensor payloads before the request is handed to text generation.
use std::collections::HashSet;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::sync::{Arc, LazyLock};
@@ -24,6 +24,7 @@ use llm_multimodal::{
PromptReplacement, Tokenizer as TokenResolver, TrackedMedia, VideoClip, VisionPreProcessor,
VisionProcessorRegistry,
};
use serde::{Deserialize, Serialize};
use thiserror_ext::AsReport as _;
use tracing::warn;
use vllm_engine_core_client::protocol::dtype::ModelDtype;
@@ -52,6 +53,71 @@ pub struct MultimodalModelInfo {
video: Option<ModalitySupport>,
audio: Option<AudioModalitySupport>,
media_connector: Arc<MediaConnector>,
/// Maximum number of input items allowed per prompt for each modality.
limit_mm_per_prompt: MmLimitPerPrompt,
}
/// Per-modality item-count limits configured by `--limit-mm-per-prompt`.
///
/// Modalities absent from the map are unlimited.
pub type MmLimitPerPrompt = HashMap<MmLimitModality, MmLimitSpec>;
/// Modalities that `--limit-mm-per-prompt` can be keyed by.
///
/// Closed on purpose: these are exactly the keys Python accepts, per
/// `MultiModalDummyOptionsBuiltins` in `vllm/config/multimodal.py`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MmLimitModality {
Image,
Audio,
Video,
}
impl MmLimitModality {
/// The wire name, matching Python's modality strings.
pub fn as_str(self) -> &'static str {
match self {
Self::Image => "image",
Self::Audio => "audio",
Self::Video => "video",
}
}
}
/// One modality's limit, in either of the two shapes Python accepts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
untagged,
expecting = "an item count, or an object with an optional `count` field"
)]
pub enum MmLimitSpec {
/// Legacy form: `"image": 16`
Count(usize),
/// Configurable form:
/// `"video": {"count": 1, "num_frames": 32}`
Options {
/// Absent means unlimited, matching an absent modality.
#[serde(default, skip_serializing_if = "Option::is_none")]
count: Option<usize>,
/// Preserve Python-owned options for forwarding. Never interpreted
/// here: they size the engine's dummy-profiling encoder cache, which
/// has no Rust counterpart.
#[serde(flatten)]
extra: BTreeMap<String, serde_json::Value>,
},
}
impl MmLimitSpec {
/// The configured item count, or `None` when this modality is unlimited.
pub fn count(&self) -> Option<usize> {
match self {
Self::Count(count) => Some(*count),
Self::Options { count, .. } => *count,
}
}
}
/// Model metadata and tokenizer access shared by all multimodal specs.
@@ -287,6 +353,7 @@ impl MultimodalModelInfo {
model_type: Option<String>,
files: MultimodalConfigFiles<'_>,
tokenizer: DynTokenizer,
limit_mm_per_prompt: MmLimitPerPrompt,
) -> Result<Option<Self>> {
let config = match files.config {
Some(path) => {
@@ -319,7 +386,12 @@ impl MultimodalModelInfo {
tokenizer: TokenizerResolver(tokenizer),
};
Self::from_loaded(context, preprocessor_config, video_preprocessor_config)
Self::from_loaded(
context,
preprocessor_config,
video_preprocessor_config,
limit_mm_per_prompt,
)
}
/// Resolve multimodal support from an assembled context and parsed
@@ -328,6 +400,7 @@ impl MultimodalModelInfo {
context: MultimodalModelContext,
preprocessor_config: PreProcessorConfig,
video_preprocessor_config: PreProcessorConfig,
limit_mm_per_prompt: MmLimitPerPrompt,
) -> Result<Option<Self>> {
let (image, video) = Self::resolve_vision_lanes(
&context,
@@ -356,6 +429,7 @@ impl MultimodalModelInfo {
video,
audio,
media_connector,
limit_mm_per_prompt,
}))
}
@@ -567,7 +641,55 @@ fn input_audio_data_url(data: &str, format: Option<&str>) -> Result<String> {
Ok(format!("data:{mime_type};base64,{data}"))
}
/// The modality a content part counts against, or `None` for plain text.
///
/// Embedding inputs share their base modality's budget rather than getting one
/// of their own, matching Python's `modality.replace("_embeds", "")` in
/// `vllm/entrypoints/chat_utils.py`.
fn media_part_limit_modality(part: &MediaContentPart) -> Option<MmLimitModality> {
match part {
MediaContentPart::Text { .. } => None,
MediaContentPart::ImageUrl { .. }
| MediaContentPart::ImageData { .. }
| MediaContentPart::ImageEmbeds { .. } => Some(MmLimitModality::Image),
MediaContentPart::AudioUrl { .. } | MediaContentPart::AudioData { .. } => {
Some(MmLimitModality::Audio)
}
MediaContentPart::VideoUrl { .. } | MediaContentPart::VideoData { .. } => {
Some(MmLimitModality::Video)
}
}
}
impl MultimodalModelInfo {
/// Reject requests exceeding `--limit-mm-per-prompt`'s configured
/// per-modality item count, before any fetch/decode work is spent on them.
///
/// Modalities without a configured count are unlimited.
fn validate_mm_limits(&self, media_parts: &[MediaContentPart]) -> Result<()> {
let mut counts: HashMap<MmLimitModality, usize> = HashMap::new();
for part in media_parts {
if let Some(modality) = media_part_limit_modality(part) {
*counts.entry(modality).or_default() += 1;
}
}
for (modality, count) in counts {
let Some(limit) = self.limit_mm_per_prompt.get(&modality).and_then(MmLimitSpec::count)
else {
continue;
};
if count > limit {
return Err(Error::MmLimitExceeded {
modality: modality.as_str().to_string(),
limit,
});
}
}
Ok(())
}
/// Run media fetch, per-modality preprocessing, prompt expansion, and
/// feature build.
///
@@ -584,8 +706,7 @@ impl MultimodalModelInfo {
if media_parts_len == 0 {
return Ok(Vec::new());
}
// TODO: enforce per-modality item-count limits, aligned with the
// engine's `--limit-mm-per-prompt` semantics.
self.validate_mm_limits(&media_parts)?;
let fetched = self.fetch_media(media_parts).await?;
let mut prepared = Vec::new();
@@ -753,10 +874,11 @@ mod tests {
.with_regular_token("<|video_pad|>", QWEN3_VIDEO_PAD_ID)
}
fn test_info(
fn test_info_with_limits(
model_type: &str,
config: serde_json::Value,
tokenizer: TestTokenizer,
limit_mm_per_prompt: MmLimitPerPrompt,
) -> MultimodalModelInfo {
let context = MultimodalModelContext {
model_id: format!("{model_type}-test"),
@@ -769,11 +891,20 @@ mod tests {
context,
PreProcessorConfig::default(),
PreProcessorConfig::default(),
limit_mm_per_prompt,
)
.unwrap()
.unwrap_or_else(|| panic!("{model_type} multimodal support should resolve"))
}
fn test_info(
model_type: &str,
config: serde_json::Value,
tokenizer: TestTokenizer,
) -> MultimodalModelInfo {
test_info_with_limits(model_type, config, tokenizer, HashMap::new())
}
fn llama4_info() -> MultimodalModelInfo {
let config = serde_json::json!({
"model_type": "llama4",
@@ -783,16 +914,19 @@ mod tests {
test_info("llama4", config, llama4_tokenizer())
}
pub(super) fn qwen3_vl_info() -> MultimodalModelInfo {
let config = serde_json::json!({
fn qwen3_vl_config() -> serde_json::Value {
serde_json::json!({
"model_type": "qwen3_vl",
"image_token_id": QWEN3_IMAGE_PAD_ID,
"video_token_id": QWEN3_VIDEO_PAD_ID,
"vision_start_token_id": 151652,
"vision_end_token_id": 151653,
"vision_config": {"patch_size": 16}
});
test_info("qwen3_vl", config, qwen3_vl_tokenizer())
})
}
pub(super) fn qwen3_vl_info() -> MultimodalModelInfo {
test_info("qwen3_vl", qwen3_vl_config(), qwen3_vl_tokenizer())
}
#[test]
@@ -853,4 +987,123 @@ mod tests {
);
assert!(input_audio_data_url("AAAA", Some("flac")).is_err());
}
fn image_url_part() -> MediaContentPart {
MediaContentPart::ImageUrl {
url: "https://example.com/image.png".to_string(),
detail: None,
uuid: None,
}
}
fn qwen3_vl_info_with_limits(limit_mm_per_prompt: MmLimitPerPrompt) -> MultimodalModelInfo {
test_info_with_limits(
"qwen3_vl",
qwen3_vl_config(),
qwen3_vl_tokenizer(),
limit_mm_per_prompt,
)
}
#[test]
fn validate_mm_limits_ignores_text_parts() {
let info = qwen3_vl_info();
let parts = vec![
MediaContentPart::Text {
text: "hello".to_string(),
},
MediaContentPart::Text {
text: "world".to_string(),
},
];
assert!(info.validate_mm_limits(&parts).is_ok());
}
#[test]
fn validate_mm_limits_leaves_unconfigured_modalities_unlimited() {
let info = qwen3_vl_info();
let parts: Vec<_> = std::iter::repeat_with(image_url_part).take(1_000).collect();
assert!(info.validate_mm_limits(&parts).is_ok());
}
#[test]
fn validate_mm_limits_enforces_configured_limit_at_the_boundary() {
let info = qwen3_vl_info_with_limits(HashMap::from([(
MmLimitModality::Image,
MmLimitSpec::Count(1),
)]));
assert!(info.validate_mm_limits(&[image_url_part()]).is_ok());
let error = info.validate_mm_limits(&[image_url_part(), image_url_part()]).unwrap_err();
assert_eq!(
error.to_report_string(),
"At most 1 image(s) may be provided in one prompt."
);
// Confirms the HTTP-mapping bug found during implementation stays fixed:
// this must map to 400, not 500.
assert!(error.is_request_validation_error());
}
#[test]
fn validate_mm_limits_counts_image_embeds_against_the_image_limit() {
let info = qwen3_vl_info_with_limits(HashMap::from([(
MmLimitModality::Image,
MmLimitSpec::Count(1),
)]));
let image_embeds_part = MediaContentPart::ImageEmbeds {
payload: serde_json::Value::String("AAAA".to_string()),
uuid: None,
};
let error = info.validate_mm_limits(&[image_url_part(), image_embeds_part]).unwrap_err();
assert_eq!(
error.to_report_string(),
"At most 1 image(s) may be provided in one prompt."
);
}
/// An options object without a `count` carries only profiling keys, which
/// say nothing about how many items are allowed.
#[test]
fn validate_mm_limits_treats_a_count_less_options_object_as_unlimited() {
let info = qwen3_vl_info_with_limits(HashMap::from([(
MmLimitModality::Image,
MmLimitSpec::Options {
count: None,
extra: BTreeMap::from([("width".to_string(), serde_json::json!(512))]),
},
)]));
assert!(info.validate_mm_limits(&[image_url_part(), image_url_part()]).is_ok());
}
fn parse_limits(json: &str) -> MmLimitPerPrompt {
serde_json::from_str(json).expect("limit map should parse")
}
#[test]
fn limit_map_parses_both_shapes_python_accepts() {
let limits = parse_limits(r#"{"image": 16, "video": {"count": 1, "num_frames": 32}}"#);
assert_eq!(limits[&MmLimitModality::Image].count(), Some(16));
assert_eq!(limits[&MmLimitModality::Video].count(), Some(1));
assert_eq!(limits.get(&MmLimitModality::Audio), None);
}
#[test]
fn limit_map_rejects_keys_python_does_not_accept() {
assert!(serde_json::from_str::<MmLimitPerPrompt>(r#"{"image_embeds": 1}"#).is_err());
}
/// Managed mode forwards this map back to Python as JSON, where
/// `BaseDummyOptions.count` is a non-optional `int` under `extra="forbid"`.
/// Emitting `"count": null` would make the engine subprocess fail to start.
#[test]
fn limit_map_round_trips_without_emitting_a_null_count() {
let source = r#"{"video":{"num_frames":32}}"#;
let encoded = serde_json::to_string(&parse_limits(source)).expect("map should serialize");
assert_eq!(encoded, source);
}
}
+2
View File
@@ -107,6 +107,7 @@ mod tests {
context,
PreProcessorConfig::default(),
PreProcessorConfig::default(),
HashMap::new(),
)
.unwrap()
.expect("Inkling multimodal support")
@@ -126,6 +127,7 @@ mod tests {
context,
PreProcessorConfig::default(),
PreProcessorConfig::default(),
HashMap::new(),
)
.unwrap()
.expect("Qwen3-ASR multimodal support")
+1
View File
@@ -207,6 +207,7 @@ mod tests {
Some("qwen3_vl".to_string()),
files,
Arc::new(qwen3_vl_tokenizer()),
std::collections::HashMap::new(),
)
};
+27
View File
@@ -23,6 +23,7 @@ use serde_with::{DefaultOnNull, OneOrMany, serde_as};
use thiserror_ext::AsReport as _;
use uuid::Uuid;
use vllm_chat::ReasoningParserFactory;
use vllm_chat::multimodal::MmLimitPerPrompt;
use vllm_engine_core_client::TransportMode;
use vllm_managed_engine::ManagedEngineConfig;
use vllm_managed_engine::cli::{ManagedEngineArgs, repartition_managed_engine_args};
@@ -186,6 +187,17 @@ pub struct SharedRuntimeArgs {
#[serde(default)]
pub default_chat_template_kwargs: Option<HashMap<String, Value>>,
/// The maximum number of input items allowed per prompt for each
/// modality, as a JSON object (e.g. `{"image": 16, "video": 2}`).
///
/// Also accepts the engine's configurable form
/// (e.g. `{"video": {"count": 1, "num_frames": 32}}`); the extra
/// profiling options are forwarded to the engine untouched.
/// Unspecified modalities are unlimited.
#[arg(long, value_parser = parse_json::<MmLimitPerPrompt>, value_name = "JSON", default_value = "{}")]
#[serde(default)]
pub limit_mm_per_prompt: MmLimitPerPrompt,
/// The format to render message content within a chat template.
///
/// * "auto" detects the format from the template
@@ -348,6 +360,18 @@ impl SharedRuntimeArgs {
.expect("profiler config serialization should not fail")
}
/// Return the per-modality limits as JSON for managed Python engine
/// forwarding, or `None` when nothing is configured.
///
/// Round-tripping the parsed map rather than the raw argument keeps the
/// engine's own profiling options (`num_frames`, `width`, ...) intact.
pub fn limit_mm_per_prompt_json(&self) -> Option<String> {
(!self.limit_mm_per_prompt.is_empty()).then(|| {
serde_json::to_string(&self.limit_mm_per_prompt)
.expect("limit-mm-per-prompt serialization should not fail")
})
}
/// Apply fallback logic for API key configuration from env variables.
fn apply_env_api_key_fallback(&mut self) {
if self.api_key.is_empty()
@@ -399,6 +423,7 @@ impl SharedRuntimeArgs {
language_model_only: self.language_model_only,
chat_template: self.chat_template,
default_chat_template_kwargs: self.default_chat_template_kwargs,
limit_mm_per_prompt: self.limit_mm_per_prompt,
chat_template_content_format: self.chat_template_content_format,
max_logprobs: self.max_logprobs,
api_server_options,
@@ -451,6 +476,7 @@ impl SharedRuntimeArgs {
language_model_only: self.language_model_only,
chat_template: self.chat_template,
default_chat_template_kwargs: self.default_chat_template_kwargs,
limit_mm_per_prompt: self.limit_mm_per_prompt,
chat_template_content_format: self.chat_template_content_format,
max_logprobs: self.max_logprobs,
api_server_options,
@@ -666,6 +692,7 @@ impl ServeArgs {
self.runtime.disable_log_stats,
self.runtime.shutdown_timeout,
handshake_port,
self.runtime.limit_mm_per_prompt_json(),
)
}
}
+24
View File
@@ -64,6 +64,7 @@ fn serve_args_forward_python_flags_with_separator() {
http_timeout_keep_alive: None,
chat_template: None,
default_chat_template_kwargs: None,
limit_mm_per_prompt: {},
chat_template_content_format: Auto,
enable_log_requests: false,
enable_prompt_tokens_details: false,
@@ -762,6 +763,7 @@ fn frontend_args_accept_json() {
http_timeout_keep_alive: None,
chat_template: None,
default_chat_template_kwargs: None,
limit_mm_per_prompt: {},
chat_template_content_format: Auto,
enable_log_requests: false,
enable_prompt_tokens_details: false,
@@ -1117,6 +1119,24 @@ fn frontend_args_json_rejects_malformed_json() {
"#]].assert_eq(&error.to_string());
}
#[test]
fn serve_args_reject_unsupported_modality_in_limit_mm_per_prompt() {
let error = Cli::try_parse_from([
"vllm-rs",
"serve",
"Qwen/Qwen3-0.6B",
"--limit-mm-per-prompt",
r#"{"unsupported_modality": 1}"#,
])
.unwrap_err();
expect![[r#"
error: invalid value '{"unsupported_modality": 1}' for '--limit-mm-per-prompt <JSON>': invalid JSON object: unknown variant `unsupported_modality`, expected one of `image`, `audio`, `video` at line 1 column 23
For more information, try '--help'.
"#]].assert_eq(&error.to_string());
}
#[test]
fn serve_args_reject_flags_before_model() {
let error = Cli::try_parse_from(["vllm-rs", "serve", "--python", "python3", "Qwen/Qwen3-0.6B"])
@@ -1331,6 +1351,7 @@ fn serve_args_accept_handshake_aliases() {
http_timeout_keep_alive: None,
chat_template: None,
default_chat_template_kwargs: None,
limit_mm_per_prompt: {},
chat_template_content_format: Auto,
enable_log_requests: false,
enable_prompt_tokens_details: false,
@@ -1474,6 +1495,7 @@ fn serve_frontend_config_uses_dp_address_as_advertised_host() {
language_model_only: false,
chat_template: None,
default_chat_template_kwargs: None,
limit_mm_per_prompt: {},
chat_template_content_format: Auto,
max_logprobs: None,
api_server_options: ApiServerOptions {
@@ -1558,6 +1580,7 @@ fn serve_frontend_config_keeps_tcp_transport_for_non_local_only_topology() {
language_model_only: false,
chat_template: None,
default_chat_template_kwargs: None,
limit_mm_per_prompt: {},
chat_template_content_format: Auto,
max_logprobs: None,
api_server_options: ApiServerOptions {
@@ -1660,6 +1683,7 @@ fn frontend_config_uses_external_coordinator_when_coordinator_address_is_present
language_model_only: false,
chat_template: None,
default_chat_template_kwargs: None,
limit_mm_per_prompt: {},
chat_template_content_format: Auto,
max_logprobs: None,
api_server_options: ApiServerOptions {
-5
View File
@@ -299,11 +299,6 @@ pub struct EngineUnsupportedArgs {
)]
pub kv_sharing_fast_prefill: Option<Unsupported>,
/// The maximum number of input items and options allowed per
/// prompt for each modality.
#[arg(long)]
pub limit_mm_per_prompt: Option<Unsupported>,
/// Additional args passed to process media inputs, keyed by modalities.
#[arg(long)]
pub media_io_kwargs: Option<Unsupported>,
+5
View File
@@ -91,6 +91,7 @@ impl ManagedEngineArgs {
disable_log_stats: bool,
shutdown_timeout: u64,
handshake_port: u16,
limit_mm_per_prompt: Option<String>,
) -> ManagedEngineConfig {
let mut python_args = self.python_args;
// Manually forward some args to the Python engine.
@@ -126,6 +127,10 @@ impl ManagedEngineArgs {
python_args.push("--data-parallel-size-local".to_string());
python_args.push(data_parallel_size_local.to_string());
}
if let Some(limit_mm_per_prompt) = limit_mm_per_prompt {
python_args.push("--limit-mm-per-prompt".to_string());
python_args.push(limit_mm_per_prompt);
}
ManagedEngineConfig {
python: self.python,
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
use std::collections::HashMap;
use std::time::Duration;
use anyhow::{Context, Result, bail};
@@ -70,6 +71,7 @@ async fn main() -> Result<()> {
language_model_only: false,
chat_template: None,
default_chat_template_kwargs: None,
limit_mm_per_prompt: HashMap::new(),
chat_template_content_format: ChatTemplateContentFormatOption::Auto,
max_logprobs: None,
api_server_options: ApiServerOptions::default(),
+4
View File
@@ -10,6 +10,7 @@ use axum::http::{HeaderName, HeaderValue, Method};
use educe::Educe;
use serde::Serialize;
use serde_json::Value;
use vllm_chat::multimodal::MmLimitPerPrompt;
use vllm_chat::{ChatTemplateContentFormatOption, ParserSelection, RendererSelection};
use vllm_engine_core_client::{CoordinatorMode as EngineCoreCoordinatorMode, TransportMode};
@@ -184,6 +185,9 @@ pub struct Config {
pub chat_template: Option<String>,
/// Server-default keyword arguments merged into every chat-template render.
pub default_chat_template_kwargs: Option<HashMap<String, Value>>,
/// Maximum number of input items allowed per prompt for each modality.
/// Unspecified modalities are unlimited.
pub limit_mm_per_prompt: MmLimitPerPrompt,
/// How to serialize `message.content` for chat-template rendering.
pub chat_template_content_format: ChatTemplateContentFormatOption,
/// Optional maximum number of top log probabilities accepted by the
+1
View File
@@ -101,6 +101,7 @@ async fn build_state(config: &Config) -> Result<Arc<AppState>> {
.default_chat_template_kwargs
.clone()
.unwrap_or_default(),
limit_mm_per_prompt: config.limit_mm_per_prompt.clone(),
},
)
.await
+75
View File
@@ -577,6 +577,12 @@ fn render_fake_content(content: &ChatContent, placeholder: &str) -> vllm_chat::R
}
fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo {
qwen_multimodal_model_info_with_limits(std::collections::HashMap::new())
}
fn qwen_multimodal_model_info_with_limits(
limit_mm_per_prompt: vllm_chat::multimodal::MmLimitPerPrompt,
) -> vllm_chat::multimodal::MultimodalModelInfo {
let config_path = std::env::temp_dir().join(format!(
"vllm-server-qwen-config-{}.json",
uuid::Uuid::new_v4()
@@ -594,6 +600,7 @@ fn qwen_multimodal_model_info() -> vllm_chat::multimodal::MultimodalModelInfo {
..Default::default()
},
Arc::new(fake_chat_tokenizer()),
limit_mm_per_prompt,
)
.expect("load multimodal info")
.expect("qwen multimodal info is registered");
@@ -2307,6 +2314,74 @@ async fn non_stream_chat_image_url_reaches_engine_mm_features() {
assert_eq!(json["choices"][0]["message"]["content"], "hi");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn non_stream_chat_rejects_when_image_count_exceeds_limit_mm_per_prompt() {
// The request is rejected by `--limit-mm-per-prompt` validation before
// ever reaching the engine, so the mock engine task is never awaited.
let (chat, _engine_task) = test_models_with_engine_outputs_and_backend(
b"engine-openai-mm-limit",
default_stream_output_specs(),
Arc::new(FakeChatBackend::with_multimodal_model_info(
qwen_multimodal_model_info_with_limits(std::collections::HashMap::from([(
vllm_chat::multimodal::MmLimitModality::Image,
vllm_chat::multimodal::MmLimitSpec::Count(1),
)])),
)),
)
.await;
let app = build_router(Arc::new(AppState::new(
vec!["Qwen/Qwen1.5-0.5B-Chat".to_string()],
chat,
)));
let response = app
.clone()
.call(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(
json!({
"model": "Qwen/Qwen1.5-0.5B-Chat",
"stream": false,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "describe "},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
}
},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
}
}
]
}]
})
.to_string(),
))
.expect("build request"),
)
.await
.expect("call app");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
let json: serde_json::Value = serde_json::from_slice(&body).expect("decode json");
assert_eq!(json["error"]["type"], "invalid_request_error");
assert_eq!(
json["error"]["message"],
"At most 1 image(s) may be provided in one prompt."
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn non_stream_chat_includes_logprobs_and_prompt_logprobs() {
+1 -1
View File
@@ -1122,7 +1122,7 @@ if _is_cuda() or _is_hip():
# copying the relevant .py files from the source repository.
ext_modules.append(CMakeExtension(name="vllm.triton_kernels", optional=True))
if sys.version_info >= (3, 11):
if not _is_xpu() and sys.version_info >= (3, 11):
ext_modules.append(CMakeExtension(name="vllm.spinloop"))
ext_modules.append(CMakeExtension(name="vllm.fs_io_C"))
@@ -0,0 +1,115 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the EAGLE draft ``max_position_embeddings`` override (#48894).
EAGLE drafts share the target's positional space, but some draft
checkpoints (e.g. ``yuhuili/EAGLE3-LLaMA3.1-Instruct-8B``) ship a
``max_position_embeddings`` (2048) far smaller than the target's context.
That value sizes the draft's rotary ``cos_sin_cache`` while the proposer
feeds positions up to the target's ``max_model_len``, so the cache gather
goes out of bounds a device-side assert under torch.compile and silent
garbage reads in eager mode. ``SpeculativeConfig`` must raise the draft's
value to the target's ``max_model_len``, with a log, for the eagle/eagle3
methods only.
"""
import logging
import pytest
from transformers import PretrainedConfig
from vllm.config.model import ModelConfig
from vllm.config.parallel import ParallelConfig
from vllm.config.speculative import SpeculativeConfig
# All repos are public; only config/tokenizer-config files are fetched.
EAGLE3_DRAFT = "yuhuili/EAGLE3-LLaMA3.1-Instruct-8B" # max_position_embeddings=2048
LLAMA3_TARGET = "unsloth/Meta-Llama-3.1-8B-Instruct" # max_position_embeddings=131072
AR_MODEL = "JackFram/llama-68m" # max_position_embeddings=2048
_LOGGER = "vllm.config.speculative"
_OVERRIDE_MSG = "Overriding draft model max_position_embeddings"
@pytest.fixture
def vllm_caplog(caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch):
"""Make caplog see vLLM logger records (vLLM sets propagate=False)."""
monkeypatch.setattr(logging.getLogger("vllm"), "propagate", True)
with caplog.at_level(logging.INFO, logger=_LOGGER):
yield caplog
def _override_logged(caplog: pytest.LogCaptureFixture) -> bool:
return any(_OVERRIDE_MSG in record.getMessage() for record in caplog.records)
@pytest.mark.cpu_test
def test_override_raises_smaller_value(vllm_caplog: pytest.LogCaptureFixture):
hf_config = PretrainedConfig(max_position_embeddings=2048)
SpeculativeConfig._maybe_override_draft_max_position_embeddings(
hf_config, target_max_model_len=8192
)
assert hf_config.max_position_embeddings == 8192
assert _override_logged(vllm_caplog)
@pytest.mark.cpu_test
def test_override_keeps_sufficient_value(vllm_caplog: pytest.LogCaptureFixture):
hf_config = PretrainedConfig(max_position_embeddings=8192)
SpeculativeConfig._maybe_override_draft_max_position_embeddings(
hf_config, target_max_model_len=8192
)
assert hf_config.max_position_embeddings == 8192
assert not _override_logged(vllm_caplog)
@pytest.mark.cpu_test
def test_override_ignores_missing_attribute(vllm_caplog: pytest.LogCaptureFixture):
hf_config = PretrainedConfig()
hf_config.__dict__.pop("max_position_embeddings", None)
SpeculativeConfig._maybe_override_draft_max_position_embeddings(
hf_config, target_max_model_len=8192
)
assert not hasattr(hf_config, "max_position_embeddings")
assert not _override_logged(vllm_caplog)
@pytest.mark.cpu_test
@pytest.mark.parametrize("method", ["eagle", "eagle3"])
def test_eagle_draft_inherits_target_max_model_len(
method: str, vllm_caplog: pytest.LogCaptureFixture
):
target_model_config = ModelConfig(LLAMA3_TARGET)
assert target_model_config.max_model_len > 2048
speculative_config = SpeculativeConfig(
target_model_config=target_model_config,
target_parallel_config=ParallelConfig(),
model=EAGLE3_DRAFT,
method=method,
num_speculative_tokens=3,
)
draft_hf_config = speculative_config.draft_model_config.hf_config
assert draft_hf_config.max_position_embeddings == target_model_config.max_model_len
assert _override_logged(vllm_caplog)
@pytest.mark.cpu_test
def test_independent_draft_model_keeps_its_own_limit(
vllm_caplog: pytest.LogCaptureFixture,
):
"""An independent AR draft may genuinely have a smaller context than the
target; its max_position_embeddings must not be resized."""
target_model_config = ModelConfig(
AR_MODEL, hf_overrides={"max_position_embeddings": 8192}
)
assert target_model_config.max_model_len == 8192
speculative_config = SpeculativeConfig(
target_model_config=target_model_config,
target_parallel_config=ParallelConfig(),
model=AR_MODEL,
method="draft_model",
num_speculative_tokens=3,
)
draft_hf_config = speculative_config.draft_model_config.hf_config
assert draft_hf_config.max_position_embeddings == 2048
assert not _override_logged(vllm_caplog)
@@ -27,8 +27,8 @@ from vllm.model_executor.layers.fused_moe.experts.aiter_mxfp8_moe import ( # no
_AITER_SWIGLU_BETA,
AiterMxfp8Experts,
)
from vllm.model_executor.layers.fused_moe.experts.mxfp8_native_moe import ( # noqa: E402
Mxfp8NativeTritonExperts,
from vllm.model_executor.layers.fused_moe.experts.mxfp8_emulation_moe import ( # noqa: E402
Mxfp8EmulationTritonExperts,
)
from vllm.model_executor.layers.fused_moe.modular_kernel import ( # noqa: E402
FusedMoEActivationFormat,
@@ -150,16 +150,20 @@ def test_explicit_moe_backend_aiter():
def test_gfx950_picks_aiter():
"""Auto-select on real ROCm hardware with flydsl usable -> FlyDSL wins."""
with _flydsl_installed(True):
# NOTE: Fp8MoeBackend.AITER_MXFP8 does not require VLLM_ROCM_USE_AITER=1
with (
patch(f"{_AITER_MOD}.current_platform.supports_mx", return_value=True),
_flydsl_installed(True),
):
backend, experts_cls = select_mxfp8_moe_backend(_config())
assert backend is Fp8MoeBackend.AITER_MXFP8
assert experts_cls is AiterMxfp8Experts
def test_gfx942_picks_triton():
def test_gfx942_picks_emulation():
"""flydsl unusable (e.g. gfx942, no FlyDSL support) -> native Triton
dot_scaled backend wins instead."""
with _flydsl_installed(False):
with patch(f"{_AITER_MOD}.current_platform.supports_mx", return_value=False):
backend, experts_cls = select_mxfp8_moe_backend(_config())
assert backend is Fp8MoeBackend.TRITON_MXFP8
assert experts_cls is Mxfp8NativeTritonExperts
assert backend is Fp8MoeBackend.EMULATION
assert experts_cls is Mxfp8EmulationTritonExperts
+2
View File
@@ -497,6 +497,8 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
"Qwen2MoeForCausalLM": _HfExamplesInfo("Qwen/Qwen1.5-MoE-A2.7B-Chat"),
"Qwen3ForCausalLM": _HfExamplesInfo("Qwen/Qwen3-8B"),
"Qwen3MoeForCausalLM": _HfExamplesInfo("Qwen/Qwen3-30B-A3B"),
"Qwen3_5ForCausalLM": _HfExamplesInfo("codecho/Qwen3.5-0.8B-text-only"),
"Qwen3_5MoeForCausalLM": _HfExamplesInfo("codecho/Qwen3.5-35B-A3B-text-only"),
"MellumForCausalLM": _HfExamplesInfo("JetBrains/Mellum2-12B-A2.5B-Base"),
"Qwen3NextForCausalLM": _HfExamplesInfo(
"Qwen/Qwen3-Next-80B-A3B-Instruct",
+4
View File
@@ -187,3 +187,7 @@ def test_merge_multimodal_embeddings_no_sync():
_merge_multimodal_embeddings(
inputs_embeds, multimodal_embeddings, is_multimodal
)
def test_ci_retry_workflow():
pytest.fail("Intentional failure for /ci retry workflow validation")
+7
View File
@@ -14,6 +14,7 @@ from transformers import AutoVideoProcessor
from transformers.video_utils import VideoMetadata
from vllm.assets.base import get_vllm_public_assets
from vllm.models.minimax_m3.common.mm_preprocess import MiniMaxM3VideoBackend
from vllm.multimodal.video import (
PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
PYNVVIDEOCODEC_VIDEO_BACKEND,
@@ -388,6 +389,12 @@ def test_cosmos3_edge_uses_qwen3_vl_video_backend():
{"fps": 2},
id="qwen2_5_vl",
),
pytest.param(
"MiniMaxAI/MiniMax-M3",
MiniMaxM3VideoBackend,
None,
id="minimax_m3_vl",
),
],
)
def test_video_processor_from_model_repo(
@@ -0,0 +1,112 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for SpecDecodeBaseProposer.initialize_attn_backend.
Block tables are stored at kernel-block granularity, so the proposer's
``block_size`` (used for slot-mapping math) must be the kernel block size,
not the KV cache manager's block size — the two differ when manager blocks
are split for the attention kernel. The value must also be deterministic:
``_draft_attn_layer_names`` is a set, whose iteration order varies across
processes, so anything derived from iteration order must not leak into
``block_size``.
"""
from types import SimpleNamespace
import pytest
import vllm.v1.spec_decode.llm_base_proposer as llm_base_proposer
from vllm.v1.spec_decode.eagle import EagleProposer
SCHEDULER_BLOCK_SIZE = 256
KERNEL_BLOCK_SIZE = 64
class _FakeAttentionGroup:
def __init__(self, backend, layer_names, kv_cache_spec, kv_cache_group_id):
self.backend = backend
self.layer_names = list(layer_names)
self.kv_cache_spec = kv_cache_spec
self.kv_cache_group_id = kv_cache_group_id
self.kernel_block_size = None
def create_metadata_builders(self, vllm_config, device, kernel_block_size=None):
self.kernel_block_size = kernel_block_size
def get_metadata_builder(self):
return SimpleNamespace(kv_cache_spec=self.kv_cache_spec)
def _make_proposer(
monkeypatch: pytest.MonkeyPatch, layer_names: set[str]
) -> EagleProposer:
fake_layers = {}
for name in layer_names:
backend = SimpleNamespace(full_cls_name=lambda: "FakeBackend")
fake_layers[name] = SimpleNamespace(
get_attn_backend=lambda backend=backend: backend
)
monkeypatch.setattr(
llm_base_proposer, "get_layers_from_vllm_config", lambda *a, **k: fake_layers
)
monkeypatch.setattr(llm_base_proposer, "AttentionGroup", _FakeAttentionGroup)
proposer = EagleProposer.__new__(EagleProposer)
proposer.vllm_config = None
proposer.device = None
proposer._draft_attn_layer_names = set(layer_names)
proposer.kv_cache_gid = -1
proposer.draft_attn_groups = []
proposer.block_size = -1
return proposer
def _make_kv_cache_config(layer_names: set[str]) -> SimpleNamespace:
spec = SimpleNamespace(block_size=SCHEDULER_BLOCK_SIZE)
group = SimpleNamespace(layer_names=list(layer_names), kv_cache_spec=spec)
return SimpleNamespace(kv_cache_groups=[group])
def test_block_size_uses_kernel_block_size(monkeypatch: pytest.MonkeyPatch):
"""The proposer's slot-mapping math runs against the kernel-granularity
block table, so block_size must come from kernel_block_sizes."""
layer_names = {"draft.0.self_attn.attn"}
proposer = _make_proposer(monkeypatch, layer_names)
proposer.initialize_attn_backend(
_make_kv_cache_config(layer_names),
kernel_block_sizes=[KERNEL_BLOCK_SIZE],
)
assert proposer.block_size == KERNEL_BLOCK_SIZE
assert proposer.block_size != SCHEDULER_BLOCK_SIZE
# The metadata builder keeps receiving the kernel block size as well.
assert proposer.draft_attn_groups[0].kernel_block_size == KERNEL_BLOCK_SIZE
def test_block_size_falls_back_to_kv_cache_spec(monkeypatch: pytest.MonkeyPatch):
layer_names = {"draft.0.self_attn.attn"}
proposer = _make_proposer(monkeypatch, layer_names)
proposer.initialize_attn_backend(
_make_kv_cache_config(layer_names), kernel_block_sizes=None
)
assert proposer.block_size == SCHEDULER_BLOCK_SIZE
def test_draft_layer_iteration_is_deterministic(monkeypatch: pytest.MonkeyPatch):
"""_draft_attn_layer_names is a set; the attention groups built from it
must not depend on its (process-random) iteration order."""
layer_names = {"draft.c.attn", "draft.a.attn", "draft.b.attn"}
expected_order = sorted(layer_names)
for insertion_order in (expected_order, expected_order[::-1]):
proposer = _make_proposer(monkeypatch, set(insertion_order))
proposer.initialize_attn_backend(
_make_kv_cache_config(set(insertion_order)),
kernel_block_sizes=[KERNEL_BLOCK_SIZE],
)
assert len(proposer.draft_attn_groups) == 1
assert proposer.draft_attn_groups[0].layer_names == expected_order
assert proposer.block_size == KERNEL_BLOCK_SIZE
-26
View File
@@ -2391,17 +2391,6 @@ def topk_softmax(
e_score_correction_bias: torch.Tensor | None = None,
is_padding: torch.Tensor | None = None,
) -> None:
if current_platform.is_xpu():
# TODO: Remove after vllm-xpu-kernels supports is_padding.
torch.ops._moe_C.topk_softmax(
topk_weights,
topk_ids,
token_expert_indices,
gating_output,
renormalize,
e_score_correction_bias,
)
return
torch.ops._moe_C.topk_softmax(
topk_weights,
topk_ids,
@@ -2447,21 +2436,6 @@ def topk_hash_softplus_sqrt(
hash_indices_table: torch.Tensor | None = None,
is_padding: torch.Tensor | None = None,
) -> None:
if current_platform.is_xpu():
# TODO: Remove after vllm-xpu-kernels supports is_padding.
torch.ops._moe_C.topk_softplus_sqrt(
topk_weights,
topk_indices,
token_expert_indices,
gating_output,
renormalize,
routed_scaling_factor,
e_score_correction_bias,
input_tokens,
hash_indices_table,
)
return
torch.ops._moe_C.topk_softplus_sqrt(
topk_weights,
topk_indices,
+43
View File
@@ -910,6 +910,15 @@ class SpeculativeConfig:
f"Unsupported speculative method: '{self.method}'"
)
if self.method in ("eagle", "eagle3"):
# EAGLE drafts share the target's positional space; a
# draft checkpoint with a smaller max_position_embeddings
# than the target under-sizes its rotary cache (#48894).
SpeculativeConfig._maybe_override_draft_max_position_embeddings(
self.draft_model_config.hf_config,
self.target_model_config.max_model_len,
)
# Replace hf_config for EAGLE draft_model
if self.method in ("eagle", "eagle3", "dflash"):
from vllm.transformers_utils.configs.eagle import EAGLEConfig
@@ -1130,6 +1139,40 @@ class SpeculativeConfig:
)
return result
@staticmethod
def _maybe_override_draft_max_position_embeddings(
draft_hf_config: PretrainedConfig,
target_max_model_len: int,
) -> None:
"""Raise an EAGLE draft's max_position_embeddings up to the target's.
The proposer feeds the draft positions up to the target's
max_model_len, while max_position_embeddings sizes the draft's
rotary cos_sin_cache. A smaller checkpoint value (e.g. 2048 for
yuhuili/EAGLE3-LLaMA3.1-Instruct-8B) makes that cache gather go
out of bounds (#48894).
Args:
draft_hf_config: The draft model's HF config, mutated in place.
target_max_model_len: The target model's max_model_len.
"""
draft_max_position_embeddings = getattr(
draft_hf_config, "max_position_embeddings", None
)
if (
draft_max_position_embeddings is None
or draft_max_position_embeddings >= target_max_model_len
):
return
logger.info(
"Overriding draft model max_position_embeddings from %d to the "
"target model's max_model_len (%d); EAGLE drafts share the "
"target's positional space.",
draft_max_position_embeddings,
target_max_model_len,
)
draft_hf_config.max_position_embeddings = target_max_model_len
@staticmethod
def _verify_and_get_draft_tp(
target_parallel_config: ParallelConfig,
@@ -275,3 +275,15 @@ class ECConnectorBase(ABC):
get_finished().
"""
return False, None
def has_pending_push_work(self) -> bool:
"""Return True if the connector has push-mode work that requires
the engine main loop to keep stepping (e.g. for EPD,
Producer has push work when Xfer is in progress - Consumer
is reading it).
This mirrors exactly the KV Connector's has_pending_push_work().
Connectors that don't implement push-based EC transfer should
leave this as False.
"""
return False
+16
View File
@@ -768,6 +768,20 @@ class Qwen3_5ForConditionalGenerationConfig(VerifyAndUpdateConfig):
)
class Qwen3_5ForCausalLMConfig(Qwen3_5ForConditionalGenerationConfig):
@staticmethod
def verify_and_update_config(vllm_config: "VllmConfig") -> None:
Qwen3_5ForConditionalGenerationConfig.verify_and_update_config(vllm_config)
# Text-only Qwen3.5 models use one-dimensional positions. Remove the
# M-RoPE fields inherited from the multimodal configuration.
hf_text_config = vllm_config.model_config.hf_text_config
rope_parameters = getattr(hf_text_config, "rope_parameters", None)
if rope_parameters is not None:
rope_parameters.pop("mrope_section", None)
rope_parameters.pop("mrope_interleaved", None)
class ColQwen3_5Config(Qwen3_5ForConditionalGenerationConfig):
"""Apply the attention contract declared by a ColQwen3.5 checkpoint."""
@@ -884,7 +898,9 @@ MODELS_CONFIG_MAP: dict[str, type[VerifyAndUpdateConfig]] = {
"Qwen2ForRewardModel": Qwen2ForRewardModelConfig,
"Qwen3ForSequenceClassification": Qwen3ForSequenceClassificationConfig,
"Qwen3VLForSequenceClassification": Qwen3VLForSequenceClassificationConfig,
"Qwen3_5ForCausalLM": Qwen3_5ForCausalLMConfig,
"Qwen3_5ForConditionalGeneration": Qwen3_5ForConditionalGenerationConfig,
"Qwen3_5MoeForCausalLM": Qwen3_5ForCausalLMConfig,
"Qwen3_5MoeForConditionalGeneration": Qwen3_5ForConditionalGenerationConfig,
"UnlimitedOCRForCausalLM": UnlimitedOCRForCausalLMConfig,
"VoyageQwen3BidirectionalEmbedModel": VoyageQwen3BidirectionalEmbedModelConfig,
+40
View File
@@ -282,6 +282,7 @@ class Qwen3_5Model(Qwen3NextModel):
class Qwen3_5ForCausalLMBase(
nn.Module,
HasInnerState,
IsHybrid,
SupportsEagle3,
SupportsLoRA,
SupportsPP,
@@ -361,6 +362,45 @@ class Qwen3_5ForCausalLMBase(
return hidden_states
@classmethod
def get_mamba_state_dtype_from_config(
cls,
vllm_config: "VllmConfig",
) -> tuple[torch.dtype, torch.dtype]:
return MambaStateDtypeCalculator.gated_delta_net_state_dtype(
vllm_config.model_config.dtype,
vllm_config.cache_config.mamba_cache_dtype,
vllm_config.cache_config.mamba_ssm_cache_dtype,
)
@classmethod
def get_mamba_state_shape_from_config(
cls, vllm_config: "VllmConfig"
) -> tuple[tuple[int, int], tuple[int, int]]:
parallel_config = vllm_config.parallel_config
hf_config = vllm_config.model_config.hf_text_config
tp_size = parallel_config.tensor_parallel_size
num_spec = (
vllm_config.speculative_config.num_speculative_tokens
if vllm_config.speculative_config
else 0
)
return MambaStateShapeCalculator.gated_delta_net_state_shape(
tp_size,
hf_config.linear_num_key_heads,
hf_config.linear_num_value_heads,
hf_config.linear_key_head_dim,
hf_config.linear_value_head_dim,
hf_config.linear_conv_kernel_dim,
num_spec,
)
@classmethod
def get_mamba_state_copy_func(
cls,
) -> tuple[MambaStateCopyFunc, MambaStateCopyFunc]:
return MambaStateCopyFuncCalculator.gated_delta_net_state_copy_func()
def compute_logits(
self,
hidden_states: torch.Tensor,
+2
View File
@@ -195,6 +195,8 @@ _TEXT_GENERATION_MODELS = {
"Qwen2MoeForCausalLM": ("qwen2_moe", "Qwen2MoeForCausalLM"),
"Qwen3ForCausalLM": ("qwen3", "Qwen3ForCausalLM"),
"Qwen3MoeForCausalLM": ("qwen3_moe", "Qwen3MoeForCausalLM"),
"Qwen3_5ForCausalLM": ("qwen3_5", "Qwen3_5ForCausalLM"),
"Qwen3_5MoeForCausalLM": ("qwen3_5", "Qwen3_5MoeForCausalLM"),
"RWForCausalLM": ("falcon", "FalconForCausalLM"),
"SarvamMoEForCausalLM": ("sarvam", "SarvamMoEForCausalLM"),
"SarvamMLAForCausalLM": ("sarvam", "SarvamMLAForCausalLM"),
+37 -7
View File
@@ -3,8 +3,9 @@
import math
from collections.abc import Mapping, Sequence
from typing import cast
from typing import Any, Literal, cast
import numpy.typing as npt
import torch
from transformers import BatchFeature
from transformers.video_utils import VideoMetadata
@@ -469,10 +470,39 @@ class MiniMaxM3VLMultiModalProcessor(
]
# TODO(Isotr0py): Tie with MinimaxVideoProcessor
# after https://github.com/vllm-project/vllm/pull/44126
@VIDEO_LOADER_REGISTRY.register("minimax_m3_vl")
@VIDEO_LOADER_REGISTRY.register(
name="minimax_m3_vl",
video_processor="MiniMaxM3VLVideoProcessor",
)
class MiniMaxM3VideoBackend(VideoBackend):
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = 1,
max_duration: int = 300,
frame_recovery: bool = False,
*,
backend: Literal[
"opencv",
"pyav",
"torchcodec",
"pynvvideocodec",
"deepstream",
] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
data,
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
frame_recovery=frame_recovery,
backend=backend,
**kwargs,
)
@classmethod
def compute_frames_index_to_sample(
cls,
@@ -483,7 +513,6 @@ class MiniMaxM3VideoBackend(VideoBackend):
total_frames = source.total_frames_num
video_fps = source.original_fps
fps = target.fps
if total_frames <= 0 or video_fps <= 0 or fps <= 0:
return [0] if total_frames > 0 else []
@@ -503,8 +532,9 @@ class MiniMaxM3VideoBackend(VideoBackend):
break
indices.append(target_frame)
prev_kept_ts = target_frame / video_fps
last_frame_idx = total_frames - 1
# Because HF sample_frames includes the last frame,
# we will use HF as the standard.
last_frame_idx = total_frames
last_ts = last_frame_idx / video_fps
if indices and indices[-1] != last_frame_idx and last_ts - prev_kept_ts > eps:
indices.append(last_frame_idx)
+2
View File
@@ -124,7 +124,9 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
qwen3_asr="Qwen3ASRConfig",
qwen3_next="Qwen3NextConfig",
qwen3_5="Qwen3_5Config",
qwen3_5_text="Qwen3_5TextConfig",
qwen3_5_moe="Qwen3_5MoeConfig",
qwen3_5_moe_text="Qwen3_5MoeTextConfig",
laguna="LagunaConfig",
lfm2_moe="Lfm2MoeConfig",
**{"unlimited-ocr": "UnlimitedOCRConfig"},
+4
View File
@@ -2362,6 +2362,10 @@ class Scheduler(SchedulerInterface):
self.has_unfinished_requests()
or self.has_finished_requests()
or (self.connector is not None and self.connector.has_pending_push_work())
or (
self.ec_connector is not None
and self.ec_connector.has_pending_push_work()
)
)
def reset_prefix_cache(
+18 -4
View File
@@ -1740,7 +1740,10 @@ class SpecDecodeBaseProposer:
attention_groups: dict[tuple[str, str], AttentionGroup] = {}
if kv_cache_spec is not None:
for layer_name in self._draft_attn_layer_names:
# _draft_attn_layer_names is a set; iterate in sorted order so
# that attention_groups (and anything derived from its first
# element) is deterministic across processes.
for layer_name in sorted(self._draft_attn_layer_names):
attn_backend = all_attn_layers[layer_name].get_attn_backend()
backend_key = attn_backend.full_cls_name()
if backend_key not in attention_groups:
@@ -1772,9 +1775,20 @@ class SpecDecodeBaseProposer:
attention_groups[backend_key].layer_names.append(layer_name)
self.draft_attn_groups = list(attention_groups.values())
self.block_size = (
self.draft_attn_groups[0].get_metadata_builder().kv_cache_spec.block_size
)
if kernel_block_sizes is not None and 0 <= self.kv_cache_gid < len(
kernel_block_sizes
):
# Slot mappings are computed against the block table, which is
# stored at kernel-block granularity. Use the kernel block size
# rather than the KV cache manager's block size; the two differ
# when manager blocks are split for the attention kernel.
self.block_size = kernel_block_sizes[self.kv_cache_gid]
else:
self.block_size = (
self.draft_attn_groups[0]
.get_metadata_builder()
.kv_cache_spec.block_size
)
logger.debug("Using block size %d for drafting layers", self.block_size)
def _determine_batch_execution_and_padding(