From cac3e70cd4f522eaab9d28323285d5e31238f907 Mon Sep 17 00:00:00 2001
From: ap9272
Date: Thu, 9 Jul 2026 16:46:22 -0700
Subject: [PATCH 0001/1526] Correct model layer aliasing for Bert style models
(#43896)
---
vllm/model_executor/models/modernbert.py | 8 +++++++-
vllm/model_executor/warmup/deep_gemm_warmup.py | 5 ++---
2 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/vllm/model_executor/models/modernbert.py b/vllm/model_executor/models/modernbert.py
index 8195f61b05f2..d182fa071594 100644
--- a/vllm/model_executor/models/modernbert.py
+++ b/vllm/model_executor/models/modernbert.py
@@ -237,7 +237,11 @@ def forward(
@default_pooling_type(seq_pooling_type="CLS")
class ModernBertModel(nn.Module):
hf_to_vllm_mapper = WeightsMapper(
- orig_to_new_prefix={"layers.": "encoder_layer.layers."}
+ orig_to_new_prefix={
+ "model.layers.": "encoder_layer.layers.",
+ "layers.": "encoder_layer.layers.",
+ "model.": "",
+ }
)
def __init__(
@@ -266,6 +270,8 @@ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
for name, loaded_weight in weights:
if name.endswith(".bias") and name not in params_dict:
continue
+ if name not in params_dict:
+ continue
param = params_dict[name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, loaded_weight)
diff --git a/vllm/model_executor/warmup/deep_gemm_warmup.py b/vllm/model_executor/warmup/deep_gemm_warmup.py
index cfff491ab2bd..2c9182e86194 100644
--- a/vllm/model_executor/warmup/deep_gemm_warmup.py
+++ b/vllm/model_executor/warmup/deep_gemm_warmup.py
@@ -138,9 +138,6 @@ def _fp8_linear_may_use_deep_gemm(module: torch.nn.Module) -> bool:
Return True if the input module/layer could be processed with DeepGEMM.
"""
- # FIXME: this logic is brittle and incorrect - since we
- # could use DeepGEMM with for than just Fp8LinearMethod
- block_size = get_mk_alignment_for_contiguous_layout()[0]
if not (
isinstance(module, LinearBase)
and isinstance(module.quant_method, Fp8LinearMethod)
@@ -156,6 +153,8 @@ def _fp8_linear_may_use_deep_gemm(module: torch.nn.Module) -> bool:
):
return False
+ block_size = get_mk_alignment_for_contiguous_layout()[0]
+
w, _, block_sizes = _extract_data_from_linear_base_module(module)
return (
block_sizes == get_mk_alignment_for_contiguous_layout()
From f1a5adddb815610db46cae81f2a1d5a4609bf99d Mon Sep 17 00:00:00 2001
From: gnovack
Date: Thu, 9 Jul 2026 16:52:52 -0700
Subject: [PATCH 0002/1526] update marlin M size for EP (#48144)
Signed-off-by: gnovack
---
.../layers/fused_moe/experts/marlin_moe.py | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/vllm/model_executor/layers/fused_moe/experts/marlin_moe.py b/vllm/model_executor/layers/fused_moe/experts/marlin_moe.py
index 867f71b9bf64..17c166961397 100644
--- a/vllm/model_executor/layers/fused_moe/experts/marlin_moe.py
+++ b/vllm/model_executor/layers/fused_moe/experts/marlin_moe.py
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Fused MoE utilities for GPTQ."""
+import math
from collections.abc import Callable
import torch
@@ -312,6 +313,12 @@ def fused_marlin_moe(
assert num_bits in [4, 8]
assert topk_weights.dtype == torch.float32
+ if global_num_experts == -1:
+ global_num_experts = E
+ else:
+ # Set M to estimated valid tokens per rank
+ M = math.ceil(M * E / global_num_experts)
+
# M block size selection logic
# TODO: tune this further for specific models
for block_size_m in [8, 16, 32, 48, 64]:
@@ -321,8 +328,6 @@ def fused_marlin_moe(
if input_dtype is not None and input_dtype.itemsize == 1:
block_size_m = max(block_size_m, 16)
- if global_num_experts == -1:
- global_num_experts = E
sorted_token_ids, expert_ids, num_tokens_post_padded = moe_align_block_size(
topk_ids,
block_size_m,
From a0f6d767e42acf09f94e7af2bacca7f4c268c264 Mon Sep 17 00:00:00 2001
From: peizhang56
Date: Thu, 9 Jul 2026 17:20:15 -0700
Subject: [PATCH 0003/1526] [ROCm][CI] Move remaining engine/samplers AMD steps
to mi325_1 (#48169)
Signed-off-by: pei.zhang
Co-authored-by: Claude
---
.buildkite/test_areas/engine.yaml | 4 ++--
.buildkite/test_areas/samplers.yaml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.buildkite/test_areas/engine.yaml b/.buildkite/test_areas/engine.yaml
index 9edd9343dedb..7bb605465720 100644
--- a/.buildkite/test_areas/engine.yaml
+++ b/.buildkite/test_areas/engine.yaml
@@ -60,7 +60,7 @@ steps:
- pytest -v -s v1/e2e/general/test_async_scheduling.py
mirror:
amd:
- device: mi250_1
+ device: mi325_1
timeout_in_minutes: 60
depends_on:
- image-build-amd
@@ -76,7 +76,7 @@ steps:
- pytest -v -s v1/e2e/general --ignore v1/e2e/general/test_async_scheduling.py
mirror:
amd:
- device: mi250_1
+ device: mi325_1
timeout_in_minutes: 35
depends_on:
- image-build-amd
diff --git a/.buildkite/test_areas/samplers.yaml b/.buildkite/test_areas/samplers.yaml
index 6ec6f8efd351..5abc16889434 100644
--- a/.buildkite/test_areas/samplers.yaml
+++ b/.buildkite/test_areas/samplers.yaml
@@ -19,7 +19,7 @@ steps:
- VLLM_USE_FLASHINFER_SAMPLER=1 pytest -v -s samplers
mirror:
amd:
- device: mi250_1
+ device: mi325_1
depends_on:
- image-build-amd
commands:
From feb384ada2a1da1981ccb713ea6bd3f90c44f7b4 Mon Sep 17 00:00:00 2001
From: Augusto Yao
Date: Fri, 10 Jul 2026 10:03:00 +0800
Subject: [PATCH 0004/1526] [bugfix] bge-m3-sparse-plugin mismatch requests
(#48112)
Signed-off-by: augusto.yjh
---
.../sparse_embeddings_processor.py | 52 ++-----------------
1 file changed, 4 insertions(+), 48 deletions(-)
diff --git a/tests/plugins/bge_m3_sparse_plugin/bge_m3_sparse_processor/sparse_embeddings_processor.py b/tests/plugins/bge_m3_sparse_plugin/bge_m3_sparse_processor/sparse_embeddings_processor.py
index 77f8884fef23..a845dae77b48 100644
--- a/tests/plugins/bge_m3_sparse_plugin/bge_m3_sparse_processor/sparse_embeddings_processor.py
+++ b/tests/plugins/bge_m3_sparse_plugin/bge_m3_sparse_processor/sparse_embeddings_processor.py
@@ -3,9 +3,8 @@
from collections.abc import Sequence
-from vllm.config import ModelConfig, PoolerConfig, VllmConfig
+from vllm.config import PoolerConfig, VllmConfig
from vllm.entrypoints.openai.engine.protocol import UsageInfo
-from vllm.entrypoints.pooling.base.protocol import EmbedRequestMixin
from vllm.inputs import PromptType
from vllm.outputs import PoolingRequestOutput
from vllm.plugins.io_processors.interface import IOProcessor
@@ -14,7 +13,6 @@
from vllm.tokenizers.detokenizer_utils import convert_ids_list_to_tokens
from .types import (
- EMBED_TASKS,
SparseEmbeddingCompletionRequestMixin,
SparseEmbeddingResponse,
SparseEmbeddingResponseData,
@@ -38,7 +36,6 @@ def __init__(self, vllm_config: VllmConfig, renderer: BaseRenderer):
continue
self.default_pooling_params[param] = getattr(pooler_config, param)
self.embed_dimensions = vllm_config.model_config.embedding_size
- self.embed_request_queue: list[EmbedRequestMixin] = []
def __repr__(self) -> str:
return (
@@ -56,44 +53,9 @@ def merge_pooling_params(
# refer to PoolingCompletionRequest.to_pooling_params
# set and verify pooling params
params.skip_reading_prefix_cache = True
-
- raw_embed_request = self.embed_request_queue.pop(0)
- if raw_embed_request.embed_task not in EMBED_TASKS:
- raise ValueError(
- f"Unsupported task {raw_embed_request}, "
- f"Supported tasks are {EMBED_TASKS}"
- )
params.task = "embed&token_classify"
- params.use_activation = raw_embed_request.use_activation
- if params.use_activation is None:
- params.use_activation = True
-
- params.dimensions = raw_embed_request.dimensions
-
- model_config: ModelConfig = self.vllm_config.model_config
- for param in self.default_pooling_params:
- if getattr(params, param, None) is None:
- setattr(params, param, self.default_pooling_params[param])
-
- if params.dimensions is not None:
- if not model_config.is_matryoshka:
- raise ValueError(
- f'Model "{model_config.served_model_name}" does not '
- f"support matryoshka representation, "
- f"changing output dimensions will lead to poor results."
- )
-
- mds = model_config.matryoshka_dimensions
- if mds is not None:
- if params.dimensions not in mds:
- raise ValueError(
- f"Model {model_config.served_model_name!r} "
- f"only supports {str(mds)} matryoshka dimensions, "
- f"use other output dimensions will "
- f"lead to poor results."
- )
- elif params.dimensions < 1:
- raise ValueError("Dimensions must be greater than 0")
+ params.use_activation = True
+ params.dimensions = self.embed_dimensions
return params
def parse_request(
@@ -113,10 +75,8 @@ def pre_process(
if request_id is not None:
assert request_id not in self.online_requests, "request_id duplicated"
self.online_requests[request_id] = prompt
- self.embed_request_queue.extend(prompt.to_embed_requests_online())
else:
self.offline_requests.append(prompt)
- self.embed_request_queue.extend(prompt.to_embed_requests_offline())
return prompt.input
def _get_sparse_embedding_request(self, request_id: str | None = None):
@@ -157,11 +117,7 @@ def post_process(
raw_request = self._get_sparse_embedding_request(request_id)
has_dense_embed = raw_request.embed_task in ["dense", "dense&sparse"]
has_sparse_embed = raw_request.embed_task in ["sparse", "dense&sparse"]
- embed_dimensions = (
- self.embed_dimensions
- if raw_request.dimensions is None
- else raw_request.dimensions
- )
+ embed_dimensions = self.embed_dimensions
for idx in range(len(model_output)):
mo = model_output[idx]
sparse_embedding_dict: dict[int, float] = {}
From 88e5e2c57be8ce6e25510c1249352a23b8a85ec4 Mon Sep 17 00:00:00 2001
From: peizhang56
Date: Thu, 9 Jul 2026 19:14:38 -0700
Subject: [PATCH 0005/1526] [CI/Build][AMD] Fix ROCm OOM in
eagle_correctness_heavy by reserving CUDA graph memory (#47366)
Signed-off-by: pei.zhang
Co-authored-by: Claude
Co-authored-by: Andreas Karatzas
---
tests/v1/e2e/spec_decode/test_spec_decode.py | 7 +++++++
vllm/v1/worker/gpu_worker.py | 12 +++++++-----
2 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/tests/v1/e2e/spec_decode/test_spec_decode.py b/tests/v1/e2e/spec_decode/test_spec_decode.py
index 2eff02ea6ed6..96d6684594cc 100644
--- a/tests/v1/e2e/spec_decode/test_spec_decode.py
+++ b/tests/v1/e2e/spec_decode/test_spec_decode.py
@@ -17,6 +17,7 @@
multi_gpu_marks,
multi_gpu_only,
single_gpu_only,
+ wait_for_rocm_memory_to_settle,
)
from vllm import LLM, SamplingParams
from vllm.assets.base import VLLM_S3_BUCKET_URL
@@ -448,6 +449,9 @@ def _run_eagle_correctness(
del ref_llm
torch.accelerator.empty_cache()
cleanup_dist_env_and_memory()
+ # ROCm frees VRAM lazily; wait so the spec engine started right after
+ # does not OOM on its startup memory guard.
+ wait_for_rocm_memory_to_settle()
spec_llm = LLM(
model=model_name,
@@ -485,6 +489,9 @@ def _run_eagle_correctness(
del spec_llm
torch.accelerator.empty_cache()
cleanup_dist_env_and_memory()
+ # ROCm frees VRAM lazily; wait so the next parametrization's engine does
+ # not OOM on its startup memory guard.
+ wait_for_rocm_memory_to_settle()
@single_gpu_only
diff --git a/vllm/v1/worker/gpu_worker.py b/vllm/v1/worker/gpu_worker.py
index 182476e25336..1cd7e2598a03 100644
--- a/vllm/v1/worker/gpu_worker.py
+++ b/vllm/v1/worker/gpu_worker.py
@@ -478,11 +478,14 @@ def determine_available_memory(self) -> int:
)
# Profile CUDA graph memory if graphs will be captured.
- # Skip on ROCm/HIP/XPU as graph pool handles and get_memory_info
- # behave differently and can produce incorrect/negative estimates.
+ # ROCm is included: #44825 moved the profiler to
+ # torch.accelerator.get_memory_info (reliable on ROCm, as used by
+ # the AMD-CI mem tests), and graph_pool_handle resolves to the same
+ # torch.cuda handle the live capture path already uses on ROCm.
+ # XPU stays excluded (see #39977).
cudagraph_memory_estimate = 0
if (
- current_platform.is_cuda()
+ current_platform.is_cuda_alike()
and self.vllm_config.compilation_config.cudagraph_mode
!= CUDAGraphMode.NONE
):
@@ -498,8 +501,7 @@ def determine_available_memory(self) -> int:
+ profile_result.weights_memory
)
- # On ROCm, cudagraph_memory_estimate is always 0 so this is a no-op.
- # On CUDA, respect the opt-in flag as originally designed.
+ # Respect the opt-in flag as originally designed.
cudagraph_memory_estimate_applied = (
cudagraph_memory_estimate
if envs.VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS
From 2d814a00820daec7082599bea75ae1d0959a346c Mon Sep 17 00:00:00 2001
From: Chang Guo
Date: Thu, 9 Jul 2026 20:17:23 -0700
Subject: [PATCH 0006/1526] [kv_offload] Emit tier-owned BlockStored events
from FS/OBJ secondary tiers (#47923)
Signed-off-by: Change72
Co-authored-by: Claude Fable 5
Co-authored-by: Or Ozeri
---
docs/features/kv_offloading_usage.md | 28 +++
tests/v1/kv_offload/tiering/test_fs_tier.py | 196 +++++++++++++++++++
tests/v1/kv_offload/tiering/test_obj_tier.py | 108 +++++++++-
vllm/distributed/kv_events.py | 2 +
vllm/v1/kv_offload/tiering/fs/manager.py | 53 ++++-
vllm/v1/kv_offload/tiering/obj/manager.py | 55 +++++-
6 files changed, 433 insertions(+), 9 deletions(-)
diff --git a/docs/features/kv_offloading_usage.md b/docs/features/kv_offloading_usage.md
index cff65753d99a..8a48d11be73a 100644
--- a/docs/features/kv_offloading_usage.md
+++ b/docs/features/kv_offloading_usage.md
@@ -81,6 +81,8 @@ vllm serve \
Each entry in `secondary_tiers` is a dict with a required `type` field plus tier-specific fields.
+The filesystem and object-store tiers can publish hash-only `BlockStored` KV events for blocks they successfully store, tagged with a stable per-tier `medium` (`FS` for the filesystem tier, `OBJ` for the object-store tier). Set `enable_kv_events: true` in the tier's entry to opt in; events are published only when KV cache events are also enabled globally via `--kv-events-config`.
+
### Filesystem (FS)
The filesystem tier (`type: "fs"`) writes blocks to a directory on local storage.
@@ -91,6 +93,7 @@ The filesystem tier (`type: "fs"`) writes blocks to a directory on local storage
| `root_dir` | yes | — | Base directory; vLLM creates subdirectories beneath it (see [On-Disk Layout](#on-disk-layout)). |
| `n_read_threads` | no | `16` | Read-priority I/O threads (load path). |
| `n_write_threads` | no | `16` | Write-priority I/O threads (store path). |
+| `enable_kv_events` | no | `false` | Publish `BlockStored` KV events (medium `FS`) for successfully stored blocks. Requires KV cache events to be enabled globally. |
Each thread group prefers its own queue but pulls from the other when its primary queue is empty, so a write-heavy or read-heavy burst won't leave the off-priority queue waiting. Size the totals to your storage's effective concurrency.
@@ -120,6 +123,31 @@ To enable KV cache sharing between multiple vLLM instances using the same `root_
PYTHONHASHSEED=0 vllm serve ...
```
+### Object Store (OBJ)
+
+The object-store tier (`type: "obj"`) offloads blocks to an S3-compatible object store through the NIXL OBJ backend.
+
+| Key | Required | Default | Notes |
+| --- | --- | --- | --- |
+| `type` | yes | — | Must be `obj`. |
+| `store_config` | yes | — | Object store connection parameters (see below). |
+| `prefix` | no | `""` | Key prefix prepended to all object keys. |
+| `io_threads` | no | `4` | Number of NIXL OBJ backend I/O threads. |
+| `enable_kv_events` | no | `false` | Publish `BlockStored` KV events (medium `OBJ`) for successfully stored blocks. Requires KV cache events to be enabled globally. |
+
+`store_config` fields:
+
+| Key | Required | Default | Notes |
+| --- | --- | --- | --- |
+| `bucket` | yes | — | Bucket name. |
+| `endpoint_override` | yes | — | Object store endpoint host; the URL scheme is set separately via `scheme`. |
+| `scheme` | no | `http` | `http` or `https`. |
+| `access_key`, `secret_key`, `session_token` | no | `""` | Explicit credentials. When left empty, the NIXL OBJ plugin falls back to the AWS SDK default credential provider chain (IAM roles, environment variables, credential files), which enables workload-identity auth on Kubernetes. |
+| `region` | no | `""` | Bucket region, if the endpoint requires one. |
+| `ca_bundle` | no | `""` | CA bundle path for TLS verification. |
+
+Object keys follow the same run-configuration digest scheme as the filesystem tier (see [On-Disk Layout](#on-disk-layout)) and are stored under the optional `prefix`. The [Cross-Process Sharing](#cross-process-sharing) requirement (`PYTHONHASHSEED`) applies to shared buckets as well, so instances sharing a bucket produce identical keys for identical content. At startup the tier probes object store connectivity and fails fast with a configuration error if the bucket is unreachable.
+
### P2P (Including P/D)
The P2P tier (`type: "p2p"`) shares completed KV blocks between vLLM instances over RDMA via NIXL. Each instance binds a control socket on `host:port` and exchanges blocks directly with peers — no shared filesystem required.
diff --git a/tests/v1/kv_offload/tiering/test_fs_tier.py b/tests/v1/kv_offload/tiering/test_fs_tier.py
index 680a9584787a..c90a4dad5e79 100644
--- a/tests/v1/kv_offload/tiering/test_fs_tier.py
+++ b/tests/v1/kv_offload/tiering/test_fs_tier.py
@@ -18,8 +18,10 @@
import pytest
import torch
+from vllm.distributed.kv_events import MEDIUM_FS
from vllm.v1.kv_offload.base import (
LookupResult,
+ OffloadingEvent,
OffloadKey,
ReqContext,
ScheduleEndContext,
@@ -58,6 +60,16 @@
_MOCK_OFFLOADING_SPEC.block_size_factor = 1
+def _make_offloading_spec(enable_kv_cache_events: bool) -> MagicMock:
+ """Mock spec with an explicit global KV events flag."""
+ spec = MagicMock()
+ spec.vllm_config = _MOCK_VLLM_CONFIG
+ spec.kv_cache_config = _MOCK_KV_CACHE_CONFIG
+ spec.block_size_factor = 1
+ spec.kv_events_config.enable_kv_cache_events = enable_kv_cache_events
+ return spec
+
+
def key(n: int) -> OffloadKey:
return make_offload_key(n.to_bytes(8, "big"), 0)
@@ -164,6 +176,23 @@ def fs_tier(tmp_path):
tier.shutdown()
+@pytest.fixture
+def fs_tier_with_events(tmp_path):
+ tensor = _page_aligned_zero_tensor(4, _BLOCK_ELEMENTS)
+ mock_view = memoryview(tensor.numpy())
+ tier = FileSystemTierManager(
+ offloading_spec=_make_offloading_spec(enable_kv_cache_events=True),
+ primary_kv_view=mock_view,
+ tier_type="fs",
+ root_dir=str(tmp_path),
+ n_read_threads=4,
+ n_write_threads=4,
+ enable_kv_events=True,
+ )
+ yield tier
+ tier.shutdown()
+
+
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@@ -396,3 +425,170 @@ def test_batch_lookup_dispatch(fs_tier, monkeypatch, use_c_ext):
results = lookup_and_wait(tier, [key(1), key(2)])
assert results == [LookupResult.HIT, LookupResult.MISS]
+
+
+# ---------------------------------------------------------------------------
+# KV events
+# ---------------------------------------------------------------------------
+
+
+def test_successful_store_emits_stored_event(fs_tier_with_events):
+ """A completed store job emits one stored event with the job's keys."""
+ tier = fs_tier_with_events
+ keys = [key(1), key(2)]
+ tier.submit_store(make_job(1, keys, [0, 1]))
+ assert all(r.success for r in drain(tier))
+
+ events = list(tier.take_events())
+ assert len(events) == 1
+ assert events[0].keys == keys
+ # Literal medium pins the wire contract, not just the constant choice.
+ assert events[0].medium == "FS"
+ assert not events[0].removed
+ # take_events drains the buffer.
+ assert list(tier.take_events()) == []
+
+
+def test_load_job_emits_no_event(fs_tier_with_events):
+ tier = fs_tier_with_events
+ tier.submit_store(make_job(1, [key(1)], [0]))
+ results = drain(tier)
+ assert len(results) == 1
+ assert results[0].success
+ list(tier.take_events())
+
+ tier.submit_load(make_job(2, [key(1)], [1], is_promotion=True))
+ results = drain(tier)
+ assert len(results) == 1
+ assert results[0].success
+ assert list(tier.take_events()) == []
+
+
+def test_mixed_job_results_emit_event_only_for_successful_job(
+ fs_tier_with_events, monkeypatch
+):
+ """With a failed and a successful store job in flight, exactly one event
+ is emitted and its keys belong to the successful job."""
+ import vllm.v1.kv_offload.tiering.fs.manager as mgr_mod
+
+ tier = fs_tier_with_events
+ failing_path = tier.file_mapper.get_file_name(key(1))
+ original_store_block = mgr_mod.store_block
+
+ def flaky_store_block(dest_path, *args, **kwargs):
+ if dest_path == failing_path:
+ raise OSError("injected store failure")
+ return original_store_block(dest_path, *args, **kwargs)
+
+ monkeypatch.setattr(mgr_mod, "store_block", flaky_store_block)
+
+ tier.submit_store(make_job(1, [key(1)], [0]))
+ tier.submit_store(make_job(2, [key(2)], [1]))
+ results = drain(tier)
+ assert len(results) == 2
+ by_id = {r.job_id: r for r in results}
+ assert not by_id[1].success
+ assert by_id[2].success
+
+ events = list(tier.take_events())
+ assert len(events) == 1
+ assert events[0].keys == [key(2)]
+
+
+def test_partially_failed_store_emits_no_event(fs_tier_with_events, monkeypatch):
+ """A store job with any failed block emits no event for the whole job."""
+ import vllm.v1.kv_offload.tiering.fs.manager as mgr_mod
+
+ tier = fs_tier_with_events
+ failing_path = tier.file_mapper.get_file_name(key(2))
+ original_store_block = mgr_mod.store_block
+
+ def flaky_store_block(dest_path, *args, **kwargs):
+ if dest_path == failing_path:
+ raise OSError("injected store failure")
+ return original_store_block(dest_path, *args, **kwargs)
+
+ monkeypatch.setattr(mgr_mod, "store_block", flaky_store_block)
+
+ tier.submit_store(make_job(1, [key(1), key(2)], [0, 1]))
+ results = drain(tier)
+ assert len(results) == 1
+ assert not results[0].success
+ assert list(tier.take_events()) == []
+ assert tier._store_job_keys == {}
+
+
+def test_events_disabled_by_default(fs_tier):
+ tier, _ = fs_tier
+ tier.submit_store(make_job(1, [key(1)], [0]))
+ results = drain(tier)
+ assert len(results) == 1
+ assert results[0].success
+ assert tier.events is None
+ assert tier._store_job_keys == {}
+ assert list(tier.take_events()) == []
+
+
+def test_events_require_global_kv_events_flag(tmp_path):
+ """Tier-level opt-in alone is not enough; the global flag gates events."""
+ tensor = _page_aligned_zero_tensor(4, _BLOCK_ELEMENTS)
+ tier = FileSystemTierManager(
+ offloading_spec=_make_offloading_spec(enable_kv_cache_events=False),
+ primary_kv_view=memoryview(tensor.numpy()),
+ tier_type="fs",
+ root_dir=str(tmp_path),
+ enable_kv_events=True,
+ )
+ try:
+ assert tier.events is None
+ tier.submit_store(make_job(1, [key(1)], [0]))
+ results = drain(tier)
+ assert len(results) == 1
+ assert results[0].success
+ assert list(tier.take_events()) == []
+ assert tier._store_job_keys == {}
+ finally:
+ tier.shutdown()
+
+
+def test_cascade_store_emits_fs_event_through_tiering_manager(tmp_path):
+ """A GPU->CPU->fs cascade surfaces the tier-owned FS stored event via the
+ TieringOffloadingManager's aggregated take_events()."""
+ from vllm.v1.kv_offload.tiering.manager import (
+ CPUPrimaryTierOffloadingManager,
+ TieringOffloadingManager,
+ )
+
+ tensor = _page_aligned_zero_tensor(4, _BLOCK_ELEMENTS)
+ view = memoryview(tensor.numpy())
+ mock_region = MagicMock()
+ mock_region.create_kv_memoryview.return_value = view
+ primary = CPUPrimaryTierOffloadingManager(num_blocks=4, mmap_region=mock_region)
+ tier = FileSystemTierManager(
+ offloading_spec=_make_offloading_spec(enable_kv_cache_events=True),
+ primary_kv_view=primary.get_kv_memoryview(),
+ tier_type="fs",
+ root_dir=str(tmp_path),
+ enable_kv_events=True,
+ )
+ manager = TieringOffloadingManager(primary_tier=primary, secondary_tiers=[tier])
+ try:
+ keys = [key(1), key(2)]
+ manager.on_new_request(_CTX)
+ assert manager.prepare_store(keys, _CTX) is not None
+ manager.complete_store(keys, _CTX) # cascades to the fs tier
+
+ events: list[OffloadingEvent] = []
+ ctx = ScheduleEndContext(new_req_ids=[], preempted_req_ids=())
+ deadline = time.monotonic() + 5.0
+ while time.monotonic() < deadline and not events:
+ manager.on_schedule_end(ctx)
+ events.extend(manager.take_events())
+ time.sleep(0.01)
+
+ fs_events = [e for e in events if e.medium == MEDIUM_FS]
+ assert len(fs_events) == 1
+ assert set(fs_events[0].keys) == set(keys)
+ assert not fs_events[0].removed
+ finally:
+ tier.shutdown()
diff --git a/tests/v1/kv_offload/tiering/test_obj_tier.py b/tests/v1/kv_offload/tiering/test_obj_tier.py
index f429d6cf62d6..37687adce0cb 100644
--- a/tests/v1/kv_offload/tiering/test_obj_tier.py
+++ b/tests/v1/kv_offload/tiering/test_obj_tier.py
@@ -179,8 +179,19 @@ def _query_memory(self, queries, mem_type, agent_name):
# ---------------------------------------------------------------------------
+def _make_events_spec(enable_kv_cache_events: bool) -> SimpleNamespace:
+ """Offloading spec stub with an explicit global KV events flag."""
+ return SimpleNamespace(
+ vllm_config=_make_vllm_config(),
+ kv_cache_config=SimpleNamespace(kv_cache_groups=[]),
+ kv_events_config=SimpleNamespace(enable_kv_cache_events=enable_kv_cache_events),
+ )
+
+
def _make_tier(
num_blocks: int = 4,
+ offloading_spec: SimpleNamespace = _OFFLOADING_SPEC,
+ **tier_kwargs,
) -> tuple[ObjectStoreSecondaryTierManager, MockNixlAgent]:
"""Create a tier backed by a fresh MockNixlAgent."""
mock_agent = MockNixlAgent()
@@ -194,11 +205,12 @@ def _make_tier(
),
):
tier = ObjectStoreSecondaryTierManager(
- offloading_spec=_OFFLOADING_SPEC,
+ offloading_spec=offloading_spec,
primary_kv_view=view,
tier_type="obj",
store_config=_STORE_CONFIG,
prefix=_RUN_PREFIX,
+ **tier_kwargs,
)
return tier, mock_agent
@@ -421,6 +433,100 @@ def test_shutdown_idempotent(self):
tier.shutdown() # must not raise
+class TestObjTierKVEvents:
+ def setup_method(self):
+ self.tier, self.agent = _make_tier(
+ offloading_spec=_make_events_spec(enable_kv_cache_events=True),
+ enable_kv_events=True,
+ )
+
+ def test_successful_store_emits_stored_event(self):
+ """A completed store transfer emits one stored event with the job's keys."""
+ keys = [key(1), key(2)]
+ self.tier.submit_store(make_job(1, keys, [0, 1]))
+ assert all(r.success for r in drain(self.tier))
+
+ events = list(self.tier.take_events())
+ assert len(events) == 1
+ assert events[0].keys == keys
+ # Literal medium pins the wire contract, not just the constant choice.
+ assert events[0].medium == "OBJ"
+ assert not events[0].removed
+ # take_events drains the buffer.
+ assert list(self.tier.take_events()) == []
+
+ def test_mixed_job_results_emit_event_only_for_successful_job(self):
+ """With a failed and a successful store job resolving in the same
+ poll, exactly one event is emitted and its keys belong to the
+ successful job."""
+ original = self.agent.check_xfer_state
+ self.agent.check_xfer_state = lambda h: "ERR" if h._id == 0 else original(h)
+ self.tier.submit_store(make_job(1, [key(1)], [0])) # handle 0: fails
+ self.tier.submit_store(make_job(2, [key(2)], [1])) # handle 1: succeeds
+ results = drain(self.tier)
+ by_id = {r.job_id: r for r in results}
+ assert not by_id[1].success
+ assert by_id[2].success
+
+ events = list(self.tier.take_events())
+ assert len(events) == 1
+ assert events[0].keys == [key(2)]
+ assert self.tier._store_job_keys == {}
+
+ def test_load_job_emits_no_event(self):
+ self.tier.submit_store(make_job(1, [key(1)], [0]))
+ results = drain(self.tier)
+ assert len(results) == 1
+ assert results[0].success
+ list(self.tier.take_events())
+
+ self.tier.submit_load(make_job(2, [key(1)], [0]))
+ results = drain(self.tier)
+ assert len(results) == 1
+ assert results[0].success
+ assert list(self.tier.take_events()) == []
+
+ def test_failed_transfer_emits_no_event(self):
+ self.agent.check_xfer_state = lambda h: "ERR"
+ self.tier.submit_store(make_job(1, [key(1)], [0]))
+ results = drain(self.tier)
+ assert not results[0].success
+ assert list(self.tier.take_events()) == []
+ assert self.tier._store_job_keys == {}
+
+ def test_submission_failure_emits_no_event(self):
+ self.agent.make_prepped_xfer = lambda *a, **k: None
+ self.tier.submit_store(make_job(1, [key(1)], [0]))
+ results = list(self.tier.get_finished_jobs())
+ assert not results[0].success
+ assert list(self.tier.take_events()) == []
+ assert self.tier._store_job_keys == {}
+
+ def test_events_disabled_by_default(self):
+ tier, _ = _make_tier()
+ tier.submit_store(make_job(1, [key(1)], [0]))
+ results = drain(tier)
+ assert len(results) == 1
+ assert results[0].success
+ assert tier.events is None
+ assert tier._store_job_keys == {}
+ assert list(tier.take_events()) == []
+
+ def test_events_require_global_kv_events_flag(self):
+ """Tier-level opt-in alone is not enough; the global flag gates events."""
+ tier, _ = _make_tier(
+ offloading_spec=_make_events_spec(enable_kv_cache_events=False),
+ enable_kv_events=True,
+ )
+ tier.submit_store(make_job(1, [key(1)], [0]))
+ results = drain(tier)
+ assert len(results) == 1
+ assert results[0].success
+ assert tier.events is None
+ assert tier._store_job_keys == {}
+ assert list(tier.take_events()) == []
+
+
class TestObjStoreConfig:
def test_explicit_credentials_included(self):
cfg = ObjStoreConfig(
diff --git a/vllm/distributed/kv_events.py b/vllm/distributed/kv_events.py
index a7d83bb378f4..c2faf34095de 100644
--- a/vllm/distributed/kv_events.py
+++ b/vllm/distributed/kv_events.py
@@ -44,6 +44,8 @@ class KVCacheEvent(
MEDIUM_GPU = "GPU"
MEDIUM_CPU = "CPU"
+MEDIUM_FS = "FS"
+MEDIUM_OBJ = "OBJ"
class BlockStored(KVCacheEvent):
diff --git a/vllm/v1/kv_offload/tiering/fs/manager.py b/vllm/v1/kv_offload/tiering/fs/manager.py
index a38ac8eb4044..d8d17002856a 100644
--- a/vllm/v1/kv_offload/tiering/fs/manager.py
+++ b/vllm/v1/kv_offload/tiering/fs/manager.py
@@ -19,7 +19,7 @@
import json
import os
from collections.abc import Iterable
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, ClassVar
try:
from vllm.fs_io_C import batch_lookup as batch_lookup_C
@@ -30,11 +30,18 @@
from typing_extensions import override
+from vllm.distributed.kv_events import MEDIUM_FS
from vllm.logger import init_logger
-from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext
+from vllm.v1.kv_offload.base import (
+ LookupResult,
+ OffloadingEvent,
+ OffloadKey,
+ ReqContext,
+)
from vllm.v1.kv_offload.file_mapper import FileMapper
from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
from vllm.v1.kv_offload.tiering.base import (
+ JobId,
JobMetadata,
JobResult,
RequestOffloadingContext,
@@ -92,6 +99,8 @@ class FileSystemTierManager(SecondaryTierManager):
content.
"""
+ medium: ClassVar[str] = MEDIUM_FS
+
def __init__(
self,
offloading_spec: "OffloadingSpec",
@@ -100,6 +109,7 @@ def __init__(
root_dir: str,
n_read_threads: int = 16,
n_write_threads: int = 16,
+ enable_kv_events: bool = False,
):
"""
Args:
@@ -110,9 +120,26 @@ def __init__(
root_dir: Root directory for block files.
n_read_threads: Number of read-priority I/O threads.
n_write_threads: Number of write-priority I/O threads.
+ enable_kv_events: Emit BlockStored KV events for blocks
+ successfully stored to this tier. Effective only when KV
+ cache events are enabled globally (kv_events_config).
"""
super().__init__(offloading_spec, primary_kv_view, tier_type)
+ self.events: list[OffloadingEvent] | None = None
+ if enable_kv_events:
+ if offloading_spec.kv_events_config.enable_kv_cache_events:
+ self.events = []
+ else:
+ logger.warning(
+ "enable_kv_events is set on secondary tier '%s' but KV "
+ "cache events are disabled globally; the tier will not "
+ "emit events.",
+ tier_type,
+ )
+ # Keys of in-flight store jobs, tracked only when events are enabled.
+ self._store_job_keys: dict[JobId, list[OffloadKey]] = {}
+
# Extract block size from primary view
assert primary_kv_view.strides is not None, (
"primary_kv_view.strides cannot be None"
@@ -157,6 +184,8 @@ def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
@override
def submit_store(self, job_metadata: JobMetadata) -> None:
+ if self.events is not None:
+ self._store_job_keys[job_metadata.job_id] = list(job_metadata.keys)
tasks = (
functools.partial(
store_block,
@@ -188,10 +217,22 @@ def get_finished_jobs(self) -> Iterable[JobResult]:
"""
Collect completed jobs from the finished-jobs queue.
"""
- return (
- JobResult(job_id=job_id, success=success)
- for job_id, success in self._pool.get_finished()
- )
+ results = []
+ for job_id, success in self._pool.get_finished():
+ if self.events is not None:
+ keys = self._store_job_keys.pop(job_id, None)
+ if success and keys:
+ self.events.append(
+ OffloadingEvent(keys=keys, medium=self.medium, removed=False)
+ )
+ results.append(JobResult(job_id=job_id, success=success))
+ return results
+
+ @override
+ def take_events(self) -> Iterable[OffloadingEvent]:
+ if self.events is not None:
+ yield from self.events
+ self.events.clear()
@override
def drain_jobs(self) -> None:
diff --git a/vllm/v1/kv_offload/tiering/obj/manager.py b/vllm/v1/kv_offload/tiering/obj/manager.py
index 6060370ea067..954f86ed162f 100644
--- a/vllm/v1/kv_offload/tiering/obj/manager.py
+++ b/vllm/v1/kv_offload/tiering/obj/manager.py
@@ -5,15 +5,22 @@
import ctypes
import time
from collections.abc import Iterable
-from typing import TYPE_CHECKING, NamedTuple
+from typing import TYPE_CHECKING, ClassVar, NamedTuple
+from vllm.distributed.kv_events import MEDIUM_OBJ
from vllm.distributed.nixl_utils import NixlWrapper as nixl_agent
from vllm.distributed.nixl_utils import nixl_agent_config
from vllm.logger import init_logger
-from vllm.v1.kv_offload.base import LookupResult, OffloadKey, ReqContext
+from vllm.v1.kv_offload.base import (
+ LookupResult,
+ OffloadingEvent,
+ OffloadKey,
+ ReqContext,
+)
from vllm.v1.kv_offload.file_mapper import FileMapper
from vllm.v1.kv_offload.tiering.async_lookup import AsyncLookupManager
from vllm.v1.kv_offload.tiering.base import (
+ JobId,
JobMetadata,
JobResult,
RequestOffloadingContext,
@@ -90,6 +97,8 @@ class ObjectStoreSecondaryTierManager(SecondaryTierManager):
primary tier. Object keys are formed as ``{prefix}/{hash_shard}/{hash}.bin``.
"""
+ medium: ClassVar[str] = MEDIUM_OBJ
+
def __init__(
self,
offloading_spec: "OffloadingSpec",
@@ -98,8 +107,36 @@ def __init__(
store_config: dict,
prefix: str = "",
io_threads: int = 4,
+ enable_kv_events: bool = False,
):
+ """
+ Args:
+ offloading_spec: Offloading configuration.
+ primary_kv_view: Memoryview of the primary tier's CPU KV cache.
+ tier_type: Tier type identifier, set by SecondaryTierFactory.
+ store_config: Object store connection parameters (see ObjStoreConfig).
+ prefix: Key prefix prepended to all object keys.
+ io_threads: Number of NIXL I/O threads.
+ enable_kv_events: Emit BlockStored KV events for blocks
+ successfully stored to this tier. Effective only when KV
+ cache events are enabled globally (kv_events_config).
+ """
super().__init__(offloading_spec, primary_kv_view, tier_type)
+
+ self.events: list[OffloadingEvent] | None = None
+ if enable_kv_events:
+ if offloading_spec.kv_events_config.enable_kv_cache_events:
+ self.events = []
+ else:
+ logger.warning(
+ "enable_kv_events is set on secondary tier '%s' but KV "
+ "cache events are disabled globally; the tier will not "
+ "emit events.",
+ tier_type,
+ )
+ # Keys of in-flight store jobs, tracked only when events are enabled.
+ self._store_job_keys: dict[JobId, list[OffloadKey]] = {}
+
agent_config = nixl_agent_config(backends=[])
self._agent = nixl_agent("ObjAgent", agent_config)
obj_config = ObjStoreConfig(**store_config)
@@ -231,6 +268,8 @@ def lookup(self, key: OffloadKey, req_context: ReqContext) -> LookupResult:
return LookupResult.HIT if result else LookupResult.MISS
def submit_store(self, job_metadata: JobMetadata) -> None:
+ if self.events is not None:
+ self._store_job_keys[job_metadata.job_id] = list(job_metadata.keys)
obj_keys = (self._file_mapper.get_file_name(k) for k in job_metadata.keys)
self._submit_transfer(
job_metadata.job_id, job_metadata.block_ids, obj_keys, NIXL_WRITE
@@ -279,8 +318,20 @@ def get_finished_jobs(self) -> Iterable[JobResult]:
self._poll_active_transfers()
results = self._pending_results
self._pending_results = []
+ if self.events is not None:
+ for result in results:
+ keys = self._store_job_keys.pop(result.job_id, None)
+ if result.success and keys:
+ self.events.append(
+ OffloadingEvent(keys=keys, medium=self.medium, removed=False)
+ )
return results
+ def take_events(self) -> Iterable[OffloadingEvent]:
+ if self.events is not None:
+ yield from self.events
+ self.events.clear()
+
def drain_jobs(self) -> None:
"""Block until every submitted transfer has completed or failed.
From 95ed0feaa5cd7fb16d72c53ce04950aaf07c4698 Mon Sep 17 00:00:00 2001
From: Yan Xu
Date: Fri, 10 Jul 2026 12:34:45 +0800
Subject: [PATCH 0007/1526] DCP supports hybrid attention (#40996)
Signed-off-by: YanXu
Signed-off-by: Jingyi Yang
Co-authored-by: Jingyi Yang
---
tests/distributed/test_context_parallel.py | 8 +
tests/distributed/test_pynccl.py | 47 ++++
.../models/language/generation/test_hybrid.py | 14 +-
.../generation/test_vit_cudagraph.py | 1 +
tests/test_config.py | 8 +-
.../test_gpu_model_runner_streaming.py | 1 +
tests/v1/worker/test_cp_utils.py | 45 ++++
tests/v1/worker/test_gpu_input_batch.py | 6 +
tests/v1/worker/test_gpu_model_runner.py | 3 +
vllm/config/model.py | 7 +-
.../device_communicators/cuda_communicator.py | 23 +-
vllm/engine/arg_utils.py | 6 +-
.../layers/attention_layer_base.py | 1 +
vllm/model_executor/layers/mamba/abstract.py | 1 +
vllm/utils/cpu_triton_utils.py | 14 +-
vllm/v1/attention/backend.py | 2 +
vllm/v1/attention/backends/flash_attn.py | 221 ++++++++++++----
vllm/v1/core/kv_cache_coordinator.py | 49 +++-
vllm/v1/core/kv_cache_utils.py | 16 +-
vllm/v1/core/single_type_kv_cache_manager.py | 4 +
vllm/v1/kv_cache_interface.py | 34 +++
vllm/v1/worker/block_table.py | 77 ++++--
vllm/v1/worker/cp_utils.py | 237 ++++++++++++++++++
vllm/v1/worker/gpu_input_batch.py | 7 +-
vllm/v1/worker/gpu_model_runner.py | 77 ++++--
vllm/v1/worker/tpu_input_batch.py | 3 +-
26 files changed, 785 insertions(+), 127 deletions(-)
create mode 100644 tests/v1/worker/test_cp_utils.py
diff --git a/tests/distributed/test_context_parallel.py b/tests/distributed/test_context_parallel.py
index 484d29c5b536..007948aeba82 100644
--- a/tests/distributed/test_context_parallel.py
+++ b/tests/distributed/test_context_parallel.py
@@ -33,6 +33,7 @@
# [LANGUAGE GENERATION]
"deepseek-ai/DeepSeek-V2-Lite-Chat",
"Qwen/Qwen2.5-1.5B-Instruct",
+ "Qwen/Qwen3.5-0.8B", # hybrid attention model
]
# GSM8K eval configuration
@@ -46,6 +47,7 @@
"deepseek-ai/DeepSeek-V2-Lite-Chat": 0.64,
# .buildkite/lm-eval-harness/configs/Qwen2.5-1.5B-Instruct.yaml
"Qwen/Qwen2.5-1.5B-Instruct": 0.52,
+ "Qwen/Qwen3.5-0.8B": 0.33,
}
@@ -151,6 +153,12 @@ def iter_params(self, model_id: str):
cp_kv_cache_interleave_size=16, attn_backend="FLASHINFER"
),
],
+ "Qwen/Qwen3.5-0.8B": [
+ CPTestSettings.detailed(
+ cp_kv_cache_interleave_size=16,
+ attn_backend="FLASH_ATTN",
+ ),
+ ],
}
diff --git a/tests/distributed/test_pynccl.py b/tests/distributed/test_pynccl.py
index d7b04f68091d..62eba4843a0b 100644
--- a/tests/distributed/test_pynccl.py
+++ b/tests/distributed/test_pynccl.py
@@ -16,6 +16,7 @@
from vllm.distributed.device_communicators.pynccl_wrapper import NCCLLibrary
from vllm.distributed.parallel_state import (
ensure_model_parallel_initialized,
+ get_tp_group,
get_world_group,
graph_capture,
init_distributed_environment,
@@ -199,6 +200,52 @@ def test_pynccl_all_gather():
distributed_run(all_gather_worker_fn, 2)
+@worker_fn_wrapper
+def cuda_communicator_all_gather_dim_worker_fn():
+ with ensure_current_vllm_config():
+ ensure_model_parallel_initialized(2, 1)
+
+ tp_group = get_tp_group()
+ comm = tp_group.device_communicator
+ assert comm is not None
+
+ rank = tp_group.rank_in_group
+ world_size = tp_group.world_size
+ device = tp_group.device
+
+ shape = (2, 3, 4)
+ num_elems = 1
+ for size in shape:
+ num_elems *= size
+
+ for dim in (1, -1):
+ tensor = (
+ torch.arange(num_elems, dtype=torch.float32, device=device).reshape(shape)
+ + rank * num_elems
+ )
+ expected = torch.cat(
+ [
+ torch.arange(num_elems, dtype=torch.float32, device=device).reshape(
+ shape
+ )
+ + r * num_elems
+ for r in range(world_size)
+ ],
+ dim=dim,
+ )
+
+ result = comm.all_gather(tensor, dim=dim)
+ torch.accelerator.synchronize()
+ torch.testing.assert_close(result, expected, rtol=1e-5, atol=1e-8)
+
+
+@pytest.mark.skipif(
+ torch.accelerator.device_count() < 2, reason="Need at least 2 GPUs to run the test."
+)
+def test_cuda_communicator_all_gather_dim_not_zero():
+ distributed_run(cuda_communicator_all_gather_dim_worker_fn, 2)
+
+
@worker_fn_wrapper
def all_gatherv_worker_fn():
pynccl_comm = PyNcclCommunicator(
diff --git a/tests/models/language/generation/test_hybrid.py b/tests/models/language/generation/test_hybrid.py
index 0f19c1038ec7..f06998e07f62 100644
--- a/tests/models/language/generation/test_hybrid.py
+++ b/tests/models/language/generation/test_hybrid.py
@@ -43,6 +43,11 @@
"tiny-random/qwen3-next-moe",
]
+HYBRID_MODELS_REQUIRING_CHUNKED_PREFILL = {
+ "LiquidAI/LFM2-1.2B",
+ "tiny-random/qwen3-next-moe",
+}
+
FULL_CUDA_GRAPH_MODELS = [
"ai21labs/Jamba-tiny-dev",
"pfnet/plamo-2-1b",
@@ -92,8 +97,15 @@ def test_models(
example_prompts, max_tokens, num_logprobs
)
+ extra_kwargs = {}
+ if model in HYBRID_MODELS_REQUIRING_CHUNKED_PREFILL:
+ extra_kwargs["enable_chunked_prefill"] = True
+
with vllm_runner(
- model, max_num_seqs=MAX_NUM_SEQS, attention_backend=ATTN_BACKEND
+ model,
+ max_num_seqs=MAX_NUM_SEQS,
+ attention_backend=ATTN_BACKEND,
+ **extra_kwargs,
) as vllm_model:
vllm_outputs = vllm_model.generate_greedy_logprobs(
example_prompts, max_tokens, num_logprobs
diff --git a/tests/models/multimodal/generation/test_vit_cudagraph.py b/tests/models/multimodal/generation/test_vit_cudagraph.py
index 954bbdbb9b83..387046f3e742 100644
--- a/tests/models/multimodal/generation/test_vit_cudagraph.py
+++ b/tests/models/multimodal/generation/test_vit_cudagraph.py
@@ -171,6 +171,7 @@ def gemma3_chat_template(content: str) -> str:
"Describe this video in one sentence."
),
needs_video_metadata=True,
+ vllm_runner_kwargs={"enable_chunked_prefill": True},
marks=[pytest.mark.core_model],
),
"internvl": VitCudagraphTestConfig(
diff --git a/tests/test_config.py b/tests/test_config.py
index 1e93b610da56..6f2be35ae86f 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1121,14 +1121,14 @@ def test_is_chunked_prefill_supported(
(
"Qwen/Qwen3-Next-80B-A3B-Instruct",
"hybrid",
- False,
- "Hybrid models do not support prefix caching since the feature is still experimental.", # noqa: E501
+ True,
+ "Generative hybrid models support prefix caching.", # noqa: E501
),
(
"ibm-granite/granite-4.0-h-small",
"hybrid",
- False,
- "Hybrid models do not support prefix caching since the feature is still experimental.", # noqa: E501
+ True,
+ "Generative hybrid models support prefix caching.", # noqa: E501
),
(
"state-spaces/mamba-130m-hf",
diff --git a/tests/v1/streaming_input/test_gpu_model_runner_streaming.py b/tests/v1/streaming_input/test_gpu_model_runner_streaming.py
index 9b130e570f66..fd619610b767 100644
--- a/tests/v1/streaming_input/test_gpu_model_runner_streaming.py
+++ b/tests/v1/streaming_input/test_gpu_model_runner_streaming.py
@@ -38,6 +38,7 @@ def mock_model_runner_with_input_batch():
vocab_size=32000,
block_sizes=[16],
kernel_block_sizes=[16],
+ max_num_blocks_per_req=[64],
logitsprocs=None,
is_pooling_model=False,
)
diff --git a/tests/v1/worker/test_cp_utils.py b/tests/v1/worker/test_cp_utils.py
new file mode 100644
index 000000000000..b38ae4d0636c
--- /dev/null
+++ b/tests/v1/worker/test_cp_utils.py
@@ -0,0 +1,45 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+import pytest
+import torch
+
+from vllm.v1.attention.backends.utils import get_dcp_local_seq_lens
+from vllm.v1.worker.cp_utils import should_skip_dcp_context_attention
+
+
+def test_skip_gate_only_for_zero_context():
+ assert should_skip_dcp_context_attention(torch.zeros(3, dtype=torch.int32))
+ assert not should_skip_dcp_context_attention(
+ torch.tensor([0, 5, 0], dtype=torch.int32)
+ )
+
+
+@pytest.mark.parametrize(
+ "dcp_world_size,interleave_size,context_len",
+ [(2, 16, 10), (4, 16, 10), (8, 16, 10), (4, 1, 2)],
+)
+def test_skip_gate_rank_invariant_with_divergent_local_context(
+ dcp_world_size: int, interleave_size: int, context_len: int
+):
+ """Contexts shorter than a full interleave round land entirely on a
+ subset of DCP ranks, so the per-rank local context lengths diverge:
+ some ranks hold zero local context while others hold all of it. Ranks
+ with zero local context must still take the collective (non-skip) path,
+ otherwise the query all-gather in _forward_with_dcp deadlocks across
+ ranks. The skip gate must therefore depend only on the rank-invariant
+ global context lengths, never on get_dcp_local_seq_lens output.
+ """
+ context_kv_lens = torch.tensor([context_len], dtype=torch.int32)
+ local_maxes = [
+ int(
+ get_dcp_local_seq_lens(
+ context_kv_lens, dcp_world_size, rank, interleave_size
+ ).max()
+ )
+ for rank in range(dcp_world_size)
+ ]
+ # Precondition: the local view diverges across ranks.
+ assert 0 in local_maxes
+ assert max(local_maxes) > 0
+ # The batch still has context globally, so no rank may skip.
+ assert not should_skip_dcp_context_attention(context_kv_lens)
diff --git a/tests/v1/worker/test_gpu_input_batch.py b/tests/v1/worker/test_gpu_input_batch.py
index 4d0a1698a70b..e7aa2e03e0e4 100644
--- a/tests/v1/worker/test_gpu_input_batch.py
+++ b/tests/v1/worker/test_gpu_input_batch.py
@@ -238,6 +238,7 @@ def test_sampling_metadata_in_input_batch(device: str, batch_size: int):
vocab_size=1024,
block_sizes=[1],
kernel_block_sizes=[1],
+ max_num_blocks_per_req=[1024],
)
reqs: list[CachedRequestState] = []
req_id_reqs = {}
@@ -332,6 +333,7 @@ def test_swap_states_in_input_batch(device: str, batch_size: int, swap_list: lis
vocab_size=1024,
block_sizes=[1],
kernel_block_sizes=[1],
+ max_num_blocks_per_req=[1024],
)
ref_input_batch: InputBatch = InputBatch(
max_num_reqs=batch_size,
@@ -341,6 +343,7 @@ def test_swap_states_in_input_batch(device: str, batch_size: int, swap_list: lis
vocab_size=1024,
block_sizes=[1],
kernel_block_sizes=[1],
+ max_num_blocks_per_req=[1024],
)
reqs: list[CachedRequestState] = []
@@ -409,6 +412,7 @@ def test_pooling_prompt_lens_not_aliased(device: str):
vocab_size=VOCAB_SIZE,
block_sizes=[16],
kernel_block_sizes=[16],
+ max_num_blocks_per_req=[64],
is_pooling_model=True,
)
@@ -444,6 +448,7 @@ def test_placeholder_spec_token_ids_written_verbatim():
vocab_size=VOCAB_SIZE,
block_sizes=[16],
kernel_block_sizes=[16],
+ max_num_blocks_per_req=[1],
)
req = CachedRequestState(
req_id="req",
@@ -491,6 +496,7 @@ def test_pooling_metadata_token_id_buffers(
vocab_size=VOCAB_SIZE,
block_sizes=[16],
kernel_block_sizes=[16],
+ max_num_blocks_per_req=[64],
is_pooling_model=True,
)
req = _construct_pooling_request(0, PoolingParams(**pooling_params))
diff --git a/tests/v1/worker/test_gpu_model_runner.py b/tests/v1/worker/test_gpu_model_runner.py
index 7490cbb27be5..6e5b1aa5b882 100644
--- a/tests/v1/worker/test_gpu_model_runner.py
+++ b/tests/v1/worker/test_gpu_model_runner.py
@@ -89,6 +89,7 @@ def initialize_kv_cache(runner: GPUModelRunner):
kernel_block_sizes=[
kv_cache_config.kv_cache_groups[0].kv_cache_spec.block_size
],
+ max_num_blocks_per_req=[NUM_BLOCKS],
)
runner.initialize_attn_backend(kv_cache_config)
@@ -1397,6 +1398,7 @@ def test_input_batch_with_kernel_block_sizes():
vocab_size=vocab_size,
block_sizes=block_sizes,
kernel_block_sizes=kernel_block_sizes,
+ max_num_blocks_per_req=[16, 8],
)
# Verify that block tables were created with kernel block sizes
@@ -1457,6 +1459,7 @@ def test_hybrid_cache_integration(default_vllm_config, dist_init):
vocab_size=runner.model_config.get_vocab_size(),
block_sizes=[kv_cache_config.kv_cache_groups[0].kv_cache_spec.block_size],
kernel_block_sizes=[16],
+ max_num_blocks_per_req=[NUM_BLOCKS],
) # Use kernel block size
runner.initialize_attn_backend(kv_cache_config)
diff --git a/vllm/config/model.py b/vllm/config/model.py
index 6e3fef0dcca7..b5f4e69e031f 100644
--- a/vllm/config/model.py
+++ b/vllm/config/model.py
@@ -1881,11 +1881,8 @@ def is_prefix_caching_supported(self) -> bool:
else:
# for generative models
if attn_type == "hybrid":
- logger.debug(
- "Hybrid models do not support prefix caching since the feature "
- "is still experimental."
- )
- return False
+ logger.debug("Generative hybrid models support prefix caching.")
+ return True
elif attn_type == "attention_free":
logger.debug(
"Attention free models do not support prefix caching since the "
diff --git a/vllm/distributed/device_communicators/cuda_communicator.py b/vllm/distributed/device_communicators/cuda_communicator.py
index 555fd0ec9489..a196a54f5091 100644
--- a/vllm/distributed/device_communicators/cuda_communicator.py
+++ b/vllm/distributed/device_communicators/cuda_communicator.py
@@ -341,13 +341,30 @@ def all_reduce(self, input_):
def all_gather(self, input_: torch.Tensor, dim: int = -1) -> torch.Tensor:
# Route uniform dim-0 all-gathers through NVLS symmetric memory when
# enabled (mirrors reduce_scatter); otherwise fall back to the
- # base-class ring all-gather. Sequence parallelism's gather-before-GEMM
- # uses dim=0 with tp-aligned (uniform) shards.
+ # PyNccl/base-class all-gather. Sequence parallelism's
+ # gather-before-GEMM uses dim=0 with tp-aligned (uniform) shards.
if dim < 0:
dim += input_.dim()
if dim == 0 and should_nccl_symm_mem_ag_rs():
return self._all_gather_symm_mem(input_.contiguous())
- return super().all_gather(input_, dim)
+
+ pynccl_comm = self.pynccl_comm
+ if pynccl_comm is None or pynccl_comm.disabled:
+ return super().all_gather(input_, dim)
+
+ input_size = input_.size()
+ output_size = (input_size[0] * self.world_size,) + input_size[1:]
+ output_tensor = torch.empty(
+ output_size, dtype=input_.dtype, device=input_.device
+ )
+ pynccl_comm.all_gather(output_tensor, input_.contiguous())
+ output_tensor = output_tensor.reshape((self.world_size,) + input_size)
+ output_tensor = output_tensor.movedim(0, dim)
+ return output_tensor.reshape(
+ input_size[:dim]
+ + (self.world_size * input_size[dim],)
+ + input_size[dim + 1 :]
+ )
def reduce_scatter(self, input_: torch.Tensor, dim: int = -1):
world_size = self.world_size
diff --git a/vllm/engine/arg_utils.py b/vllm/engine/arg_utils.py
index d80ecb9c8555..c602eaf8f2ed 100644
--- a/vllm/engine/arg_utils.py
+++ b/vllm/engine/arg_utils.py
@@ -2493,7 +2493,11 @@ def _set_default_chunked_prefill_and_prefix_caching_args(
self, model_config: ModelConfig
) -> None:
default_chunked_prefill = model_config.is_chunked_prefill_supported
- default_prefix_caching = model_config.is_prefix_caching_supported
+ # Hybrid models support prefix caching but keep it opt-in for now
+ # while the feature matures.
+ default_prefix_caching = (
+ model_config.is_prefix_caching_supported and not model_config.is_hybrid
+ )
if self.enable_chunked_prefill is None:
self.enable_chunked_prefill = default_chunked_prefill
diff --git a/vllm/model_executor/layers/attention_layer_base.py b/vllm/model_executor/layers/attention_layer_base.py
index 97395b641497..53994114558e 100644
--- a/vllm/model_executor/layers/attention_layer_base.py
+++ b/vllm/model_executor/layers/attention_layer_base.py
@@ -19,6 +19,7 @@ class AttentionLayerBase(ABC):
"""
impl: "AttentionImpl"
+ supports_dcp: bool = True
@abstractmethod
def get_attn_backend(self) -> type[AttentionBackend]:
diff --git a/vllm/model_executor/layers/mamba/abstract.py b/vllm/model_executor/layers/mamba/abstract.py
index 8bbb21d7bc90..62fd64bd0dea 100644
--- a/vllm/model_executor/layers/mamba/abstract.py
+++ b/vllm/model_executor/layers/mamba/abstract.py
@@ -22,6 +22,7 @@ class MambaBase(AttentionLayerBase):
# Contains the KV cache (mamba state) for the layer
# in the shape specified by `self.get_state_shape`.
kv_cache: tuple[torch.Tensor, ...]
+ supports_dcp: bool = False
@abstractmethod
def get_state_shape(self) -> Iterable[tuple[int, ...]]:
diff --git a/vllm/utils/cpu_triton_utils.py b/vllm/utils/cpu_triton_utils.py
index 3b5012d01751..9ce33b835707 100644
--- a/vllm/utils/cpu_triton_utils.py
+++ b/vllm/utils/cpu_triton_utils.py
@@ -29,13 +29,17 @@ def _compute_slot_mapping_kernel_impl(
block_table_stride: int, # max_num_blocks_per_req
block_size: int,
slot_mapping: torch.Tensor, # [max_num_tokens], int64
- TOTAL_CP_WORLD_SIZE: int,
- TOTAL_CP_RANK: int,
- CP_KV_CACHE_INTERLEAVE_SIZE: int,
- PAD_ID: int,
- BLOCK_SIZE: int,
+ KV_CACHE_BLOCK_SIZE: int | None = None,
+ BLOCKS_PER_KV_BLOCK: int = 1,
+ TOTAL_CP_WORLD_SIZE: int = 1,
+ TOTAL_CP_RANK: int = 0,
+ CP_KV_CACHE_INTERLEAVE_SIZE: int = 1,
+ PAD_ID: int = -1,
+ BLOCK_SIZE: int = 1024,
) -> None:
assert TOTAL_CP_WORLD_SIZE == 1, "Context Parallelism is not supported on CPU."
+ if BLOCKS_PER_KV_BLOCK != 1:
+ assert block_size * BLOCKS_PER_KV_BLOCK == KV_CACHE_BLOCK_SIZE
torch.ops._C.compute_slot_mapping_kernel_impl(
query_start_loc,
positions,
diff --git a/vllm/v1/attention/backend.py b/vllm/v1/attention/backend.py
index bfecb3c952e1..ec07023f80ef 100644
--- a/vllm/v1/attention/backend.py
+++ b/vllm/v1/attention/backend.py
@@ -796,6 +796,8 @@ class AttentionImplBase(ABC, Generic[T]):
# Whether the attention impl supports Prefill Context Parallelism.
supports_pcp: bool = False
+ # Whether the attention impl supports Decode Context Parallelism.
+ supports_dcp: bool = True
# Whether the attention impl(or ops) supports MTP
# when cp_kv_cache_interleave_size > 1
supports_mtp_with_cp_non_trivial_interleave_size: bool = False
diff --git a/vllm/v1/attention/backends/flash_attn.py b/vllm/v1/attention/backends/flash_attn.py
index db5b0dda367e..d83d2f4d810a 100755
--- a/vllm/v1/attention/backends/flash_attn.py
+++ b/vllm/v1/attention/backends/flash_attn.py
@@ -56,10 +56,14 @@
AttentionMetadataBuilder,
CommonAttentionMetadata,
)
-from vllm.v1.attention.backends.utils import (
- get_kv_cache_layout,
-)
+from vllm.v1.attention.backends.utils import get_kv_cache_layout
from vllm.v1.kv_cache_interface import AttentionSpec
+from vllm.v1.worker.cp_utils import (
+ run_split_fa2_dcp_context_attention,
+ should_skip_dcp_context_attention,
+ should_split_fa2_dcp_context_attention,
+ split_dcp_context_queries,
+)
logger = init_logger(__name__)
@@ -245,6 +249,13 @@ class FlashAttentionMetadata:
max_dcp_context_kv_len: int | None = None
dcp_context_kv_lens: torch.Tensor | None = None
+ # Split counts for FA2 DCP context attention. num_prefill_* tracks
+ # context-bearing extend rows; pure prefills do not attend to DCP context.
+ num_decode_reqs: int = 0
+ num_prefill_reqs: int = 0
+ num_decode_tokens: int = 0
+ num_prefill_tokens: int = 0
+
# Optional aot scheduling
scheduler_metadata: torch.Tensor | None = None
prefix_scheduler_metadata: torch.Tensor | None = None
@@ -513,6 +524,10 @@ def schedule(
use_cascade = common_prefix_len > 0
max_dcp_context_kv_len = 0
dcp_context_kv_lens = None
+ num_decode_reqs = 0
+ num_prefill_reqs = 0
+ num_decode_tokens = 0
+ num_prefill_tokens = 0
cu_prefix_query_lens = None
prefix_kv_lens = None
@@ -532,23 +547,54 @@ def schedule(
self._dcp_context_kv_lens[num_reqs:] = 0
dcp_context_kv_lens = self._dcp_context_kv_lens[:num_reqs]
+ skip_dcp_context_attention = False
+ if common_attn_metadata.seq_lens_cpu_upper_bound is not None:
+ query_lens_cpu = (
+ common_attn_metadata.query_start_loc_cpu[1 : num_reqs + 1]
+ - common_attn_metadata.query_start_loc_cpu[:num_reqs]
+ )
+ context_kv_lens_cpu = (
+ common_attn_metadata.seq_lens_cpu_upper_bound[:num_reqs]
+ - query_lens_cpu
+ )
+ skip_dcp_context_attention = should_skip_dcp_context_attention(
+ context_kv_lens_cpu
+ )
+
+ if max_query_len > 1:
+ (
+ num_decode_reqs,
+ num_prefill_reqs,
+ num_decode_tokens,
+ num_prefill_tokens,
+ ) = split_dcp_context_queries(
+ common_attn_metadata.query_start_loc_cpu,
+ common_attn_metadata.seq_lens_cpu_upper_bound,
+ max_query_len,
+ num_actual_tokens,
+ )
+
# After DCP distribution, the maximum number of tokens for any rank is
# ceil(L / (N * I)) * I, where L is max_seq_len, N is dcp_world_size,
# and I is cp_kv_cache_interleave_size.
# This eliminates GPU->CPU sync while minimizing workspace over-allocation.
- num_partitions = self.dcp_world_size * self.cp_kv_cache_interleave_size
- max_dcp_context_kv_len = (
- (max_seq_len + num_partitions - 1) // num_partitions
- ) * self.cp_kv_cache_interleave_size
-
- scheduler_metadata = schedule(
- batch_size=num_reqs,
- cu_query_lens=query_start_loc,
- max_query_len=max_query_len,
- seqlens=dcp_context_kv_lens,
- max_seq_len=max_dcp_context_kv_len,
- causal=False,
- )
+ if skip_dcp_context_attention:
+ max_dcp_context_kv_len = 0
+ scheduler_metadata = None
+ else:
+ num_partitions = self.dcp_world_size * self.cp_kv_cache_interleave_size
+ max_dcp_context_kv_len = (
+ (max_seq_len + num_partitions - 1) // num_partitions
+ ) * self.cp_kv_cache_interleave_size
+
+ scheduler_metadata = schedule(
+ batch_size=num_reqs,
+ cu_query_lens=query_start_loc,
+ max_query_len=max_query_len,
+ seqlens=dcp_context_kv_lens,
+ max_seq_len=max_dcp_context_kv_len,
+ causal=False,
+ )
elif use_cascade:
cu_prefix_query_lens = torch.tensor(
[0, num_actual_tokens], dtype=torch.int32, device=self.device
@@ -614,6 +660,10 @@ def schedule(
slot_mapping=slot_mapping,
max_dcp_context_kv_len=max_dcp_context_kv_len,
dcp_context_kv_lens=dcp_context_kv_lens,
+ num_decode_reqs=num_decode_reqs,
+ num_prefill_reqs=num_prefill_reqs,
+ num_decode_tokens=num_decode_tokens,
+ num_prefill_tokens=num_prefill_tokens,
use_cascade=use_cascade,
common_prefix_len=common_prefix_len,
scheduler_metadata=scheduler_metadata,
@@ -743,8 +793,12 @@ def __init__(
self.dcp_combine = dcp_a2a_lse_reduce if dcp_a2a else cp_lse_ag_out_rs
self._dcp_dtype: torch.dtype | None = None
+ self._dcp_max_num_tokens: int = 0
if vllm_config is not None and self.dcp_world_size > 1:
self._dcp_dtype = vllm_config.model_config.dtype
+ self._dcp_max_num_tokens = (
+ vllm_config.scheduler_config.max_num_batched_tokens
+ )
def forward(
self,
@@ -1063,40 +1117,120 @@ def _forward_with_dcp(
block_table = attn_metadata.block_table
query = query.contiguous()
+ if attn_metadata.max_dcp_context_kv_len == 0:
+ flash_attn_varlen_func(
+ q=query,
+ k=key,
+ v=value,
+ out=output,
+ cu_seqlens_q=cu_seqlens_q,
+ max_seqlen_q=max_seqlen_q,
+ cu_seqlens_k=cu_seqlens_q,
+ max_seqlen_k=max_seqlen_q,
+ softmax_scale=self.scale,
+ causal=attn_metadata.causal,
+ alibi_slopes=self.alibi_slopes,
+ window_size=list(self.sliding_window)
+ if self.sliding_window is not None
+ else None,
+ softcap=self.logits_soft_cap,
+ return_softmax_lse=True,
+ fa_version=self.vllm_flash_attn_version,
+ q_descale=q_descale,
+ k_descale=k_descale,
+ v_descale=v_descale,
+ num_splits=attn_metadata.max_num_splits,
+ )
+ return output
+
query_across_dcp = get_dcp_group().all_gather(query, dim=1)
sliding_window_size = (
list(self.sliding_window) if self.sliding_window is not None else None
)
n = query_across_dcp.shape[0]
- (dcp_context_out,) = current_workspace_manager().get_simultaneous(
+ num_reqs = cu_seqlens_q.shape[0] - 1
+ num_decodes = attn_metadata.num_decode_reqs
+ num_context_prefills = attn_metadata.num_prefill_reqs
+ num_decode_tokens = attn_metadata.num_decode_tokens
+ num_context_prefill_tokens = attn_metadata.num_prefill_tokens
+ split_dcp_context = should_split_fa2_dcp_context_attention(
+ self.vllm_flash_attn_version,
+ max_seqlen_q,
+ num_reqs,
+ num_decodes,
+ num_context_prefills,
+ )
+ dcp_context_out_tokens = max(n, self._dcp_max_num_tokens)
+ dcp_context_out_spec = (
(
- (n, self.num_heads * self.dcp_world_size, self.head_size),
- self._dcp_dtype,
+ dcp_context_out_tokens,
+ self.num_heads * self.dcp_world_size,
+ self.head_size,
),
+ self._dcp_dtype,
)
- context_attn_out, context_lse = flash_attn_varlen_func(
- q=query_across_dcp,
- k=key_cache,
- v=value_cache,
- out=dcp_context_out,
- cu_seqlens_q=cu_seqlens_q,
- max_seqlen_q=max_seqlen_q,
- seqused_k=attn_metadata.dcp_context_kv_lens,
- max_seqlen_k=attn_metadata.max_dcp_context_kv_len,
- softmax_scale=self.scale,
- causal=False,
- alibi_slopes=self.alibi_slopes,
- window_size=sliding_window_size,
- block_table=block_table,
- softcap=self.logits_soft_cap,
- return_softmax_lse=True,
- scheduler_metadata=attn_metadata.scheduler_metadata,
- fa_version=self.vllm_flash_attn_version,
- q_descale=q_descale,
- k_descale=k_descale,
- v_descale=v_descale,
- num_splits=attn_metadata.max_num_splits,
+ (dcp_context_out_workspace,) = current_workspace_manager().get_simultaneous(
+ dcp_context_out_spec,
)
+ dcp_context_out = dcp_context_out_workspace[:n]
+
+ if split_dcp_context:
+ # TODO: Remove this DCP + FA2 mixed decode/prefill workaround once
+ # FA4 supports this Qwen3.5 shape.
+ assert attn_metadata.dcp_context_kv_lens is not None
+ assert attn_metadata.max_dcp_context_kv_len is not None
+ assert self.vllm_flash_attn_version is not None
+ context_attn_out, context_lse = run_split_fa2_dcp_context_attention(
+ flash_attn_varlen_func,
+ query_across_dcp,
+ key_cache,
+ value_cache,
+ dcp_context_out,
+ cu_seqlens_q,
+ max_seqlen_q,
+ attn_metadata.dcp_context_kv_lens,
+ attn_metadata.max_dcp_context_kv_len,
+ self.scale,
+ self.alibi_slopes,
+ sliding_window_size,
+ block_table,
+ self.logits_soft_cap,
+ self.vllm_flash_attn_version,
+ q_descale,
+ k_descale,
+ v_descale,
+ attn_metadata.max_num_splits,
+ self.num_heads,
+ self.dcp_world_size,
+ num_decodes,
+ num_context_prefills,
+ num_decode_tokens,
+ num_context_prefill_tokens,
+ )
+ else:
+ context_attn_out, context_lse = flash_attn_varlen_func(
+ q=query_across_dcp,
+ k=key_cache,
+ v=value_cache,
+ out=dcp_context_out,
+ cu_seqlens_q=cu_seqlens_q,
+ max_seqlen_q=max_seqlen_q,
+ seqused_k=attn_metadata.dcp_context_kv_lens,
+ max_seqlen_k=attn_metadata.max_dcp_context_kv_len,
+ softmax_scale=self.scale,
+ causal=False,
+ alibi_slopes=self.alibi_slopes,
+ window_size=sliding_window_size,
+ block_table=block_table,
+ softcap=self.logits_soft_cap,
+ return_softmax_lse=True,
+ scheduler_metadata=attn_metadata.scheduler_metadata,
+ fa_version=self.vllm_flash_attn_version,
+ q_descale=q_descale,
+ k_descale=k_descale,
+ v_descale=v_descale,
+ num_splits=attn_metadata.max_num_splits,
+ )
# FA returns LSE in shape [ H, B ] but DCP combine wants [ B, H ]
context_attn_out_cor, context_lse_cor = self.dcp_combine(
context_attn_out,
@@ -1106,14 +1240,11 @@ def _forward_with_dcp(
)
context_lse_cor = context_lse_cor.transpose(0, 1).contiguous()
- (dcp_query_out,) = current_workspace_manager().get_simultaneous(
- ((query.shape[0], self.num_heads, self.head_size), self._dcp_dtype),
- )
query_attn_out, query_lse = flash_attn_varlen_func(
q=query,
k=key,
v=value,
- out=dcp_query_out,
+ out=output,
cu_seqlens_q=cu_seqlens_q,
max_seqlen_q=max_seqlen_q,
cu_seqlens_k=cu_seqlens_q,
diff --git a/vllm/v1/core/kv_cache_coordinator.py b/vllm/v1/core/kv_cache_coordinator.py
index 4756136f03cf..2e978451b885 100644
--- a/vllm/v1/core/kv_cache_coordinator.py
+++ b/vllm/v1/core/kv_cache_coordinator.py
@@ -550,12 +550,28 @@ def __init__(
# different KV cache groups have different block sizes, the actual block size
# can be a multiple of hash_block_size.
self.hash_block_size = hash_block_size
+ self.dcp_world_size = dcp_world_size
+ group_block_sizes = [
+ manager.block_size for manager in self.single_type_managers
+ ]
assert all(
- g.kv_cache_spec.block_size % hash_block_size == 0
- for g in kv_cache_config.kv_cache_groups
- ), "block_size must be divisible by hash_block_size"
- assert dcp_world_size == 1, "DCP not support hybrid attn now."
+ block_size % hash_block_size == 0 for block_size in group_block_sizes
+ ), (
+ "Each KV cache group's real block_size must be divisible by "
+ f"hash_block_size. block_sizes={group_block_sizes}, "
+ f"hash_block_size={hash_block_size}"
+ )
assert pcp_world_size == 1, "PCP not support hybrid attn now."
+ if dcp_world_size > 1:
+ # DCP shards full-attention KV across ranks and replicates Mamba
+ # state; other spec types (e.g. sliding window) have no DCP-aware
+ # handling yet, so reject them explicitly.
+ for g in kv_cache_config.kv_cache_groups:
+ assert isinstance(g.kv_cache_spec, (FullAttentionSpec, MambaSpec)), (
+ "DCP with hybrid KV cache layouts only supports "
+ "full-attention and Mamba groups, got: "
+ f"{type(g.kv_cache_spec).__name__}."
+ )
self.verify_and_split_kv_cache_groups()
def verify_and_split_kv_cache_groups(self) -> None:
@@ -651,11 +667,11 @@ def find_longest_cache_hit(
- The number of tokens of the longest cache hit.
"""
- def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList:
- if kv_cache_spec.block_size == self.hash_block_size:
+ def _get_block_hashes(block_size: int) -> BlockHashList:
+ if block_size == self.hash_block_size:
return block_hashes
return BlockHashListWithBlockSize(
- block_hashes, self.hash_block_size, kv_cache_spec.block_size
+ block_hashes, self.hash_block_size, block_size
)
num_groups = len(self.kv_cache_config.kv_cache_groups)
@@ -680,13 +696,14 @@ def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList:
for idx, (spec, group_ids, manager_cls, use_eagle) in enumerate(
self.attention_groups
):
+ group_block_size = self.single_type_managers[group_ids[0]].block_size
cached_blocks = hit_blocks_by_group[group_ids[0]]
if isinstance(spec, FullAttentionSpec) and cached_blocks is not None:
# Full attention is downward-closed: we only need to look
# up cached blocks once; on subsequent iterations just trim
# to the (reduced) current hit length.
curr_hit_length = (
- curr_hit_length // spec.block_size * spec.block_size
+ curr_hit_length // group_block_size * group_block_size
)
continue
@@ -696,18 +713,23 @@ def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList:
if drop_eagle_block:
# Eagle needs to match one more block and then pop the last.
_max_length = min(
- curr_hit_length + spec.block_size, max_cache_hit_length
+ curr_hit_length + group_block_size, max_cache_hit_length
)
hit_blocks = manager_cls.find_longest_cache_hit(
- block_hashes=_get_block_hashes(spec),
+ block_hashes=_get_block_hashes(group_block_size),
max_length=_max_length,
kv_cache_group_ids=group_ids,
block_pool=self.block_pool,
kv_cache_spec=spec,
drop_eagle_block=drop_eagle_block,
alignment_tokens=self.scheduler_block_size,
+ dcp_world_size=(
+ self.dcp_world_size
+ if isinstance(spec, FullAttentionSpec)
+ else 1
+ ),
)
- _new_hit_length = len(hit_blocks[0]) * spec.block_size
+ _new_hit_length = len(hit_blocks[0]) * group_block_size
if drop_eagle_block:
eagle_verified.add(idx)
elif _new_hit_length < curr_hit_length:
@@ -728,7 +750,10 @@ def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList:
# Truncate full attention blocks to final hit_length (if present)
first_group = self.attention_groups[0]
if isinstance(first_group.spec, FullAttentionSpec):
- num_blocks = hit_length // first_group.spec.block_size
+ group_block_size = self.single_type_managers[
+ first_group.group_ids[0]
+ ].block_size
+ num_blocks = hit_length // group_block_size
for group_id in first_group.group_ids:
if (blks := hit_blocks_by_group[group_id]) is not None:
del blks[num_blocks:]
diff --git a/vllm/v1/core/kv_cache_utils.py b/vllm/v1/core/kv_cache_utils.py
index aa42f90bb90e..6e9530e381bf 100644
--- a/vllm/v1/core/kv_cache_utils.py
+++ b/vllm/v1/core/kv_cache_utils.py
@@ -627,7 +627,8 @@ def resolve_kv_cache_block_sizes(
- ``scheduler_block_size`` is the token-alignment invariant used by the
scheduler (e.g. for ``num_computed_tokens`` rounding). Single group:
``cache_config.block_size * dcp * pcp``. Multiple groups: LCM of every
- group's block size — context parallelism is not supported here.
+ group's effective block size. Attention groups are scaled by DCP/PCP;
+ Mamba groups keep their full per-rank state and are not scaled.
- ``hash_block_size`` is the granularity at which ``Request.block_hashes``
is computed. Single group: equals scheduler block size. Multiple groups:
``cache_config.hash_block_size`` override if set, else the GCD of group
@@ -645,13 +646,12 @@ def resolve_kv_cache_block_sizes(
bs = cache_config.block_size * dcp * pcp
return bs, bs
- if dcp != 1 or pcp != 1:
- raise ValueError(
- "Hybrid KV cache groups with multiple block sizes do not "
- "support context parallelism (dcp_world_size/pcp_world_size > 1)."
- )
-
- group_block_sizes = [g.kv_cache_spec.block_size for g in groups]
+ group_block_sizes = [
+ g.kv_cache_spec.block_size * dcp * pcp
+ if isinstance(g.kv_cache_spec, AttentionSpec)
+ else g.kv_cache_spec.block_size
+ for g in groups
+ ]
scheduler_block_size = math.lcm(*group_block_sizes)
# Block hashes are only consumed by prefix caching and KV connectors
diff --git a/vllm/v1/core/single_type_kv_cache_manager.py b/vllm/v1/core/single_type_kv_cache_manager.py
index 87c3f8feb725..324d0016cd0b 100644
--- a/vllm/v1/core/single_type_kv_cache_manager.py
+++ b/vllm/v1/core/single_type_kv_cache_manager.py
@@ -1031,6 +1031,10 @@ def __init__(
self, kv_cache_spec: MambaSpec, block_pool: BlockPool, **kwargs
) -> None:
super().__init__(kv_cache_spec, block_pool, **kwargs)
+ # Mamba layers use TP instead of DCP, so each rank holds the full
+ # recurrent state. Undo the DCP/PCP block_size scaling that the base
+ # class applies for attention groups whose KV cache is partitioned.
+ self.block_size = kv_cache_spec.block_size
self.cached_blocks_this_step: set[BlockHashWithGroupId] = set()
self.mamba_cache_mode = kv_cache_spec.mamba_cache_mode
self.num_speculative_blocks: int = kv_cache_spec.num_speculative_blocks
diff --git a/vllm/v1/kv_cache_interface.py b/vllm/v1/kv_cache_interface.py
index 4204c31be58d..b0409b16dee2 100644
--- a/vllm/v1/kv_cache_interface.py
+++ b/vllm/v1/kv_cache_interface.py
@@ -128,6 +128,18 @@ def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
"""
raise NotImplementedError
+ def max_num_blocks_per_req(self, vllm_config: VllmConfig, max_len: int) -> int:
+ """
+ The number of block table entries needed per request, i.e. the row
+ length of the worker-side block table for this cache group.
+
+ Args:
+ vllm_config: The vllm config.
+ max_len: The maximum sequence length to size for, including the
+ encoder length for encoder-decoder models.
+ """
+ return cdiv(max_len, self.block_size)
+
def copy_with_new_block_size(self, block_size: int) -> Self:
"""
Create a new KVCacheSpec from self but replacing the block size.
@@ -201,6 +213,16 @@ def real_page_size_bytes(self) -> int:
* get_dtype_size(self.dtype)
)
+ def max_num_blocks_per_req(self, vllm_config: VllmConfig, max_len: int) -> int:
+ # Attention KV is token-interleaved across DCP/PCP ranks, so each rank
+ # only stores max_len // (dcp * pcp) tokens per request.
+ parallel_config = vllm_config.parallel_config
+ total_cp_size = (
+ parallel_config.decode_context_parallel_size
+ * parallel_config.prefill_context_parallel_size
+ )
+ return cdiv(max_len, self.block_size * total_cp_size)
+
@dataclass(frozen=True, kw_only=True)
class FullAttentionSpec(AttentionSpec):
@@ -699,6 +721,18 @@ def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
else:
return self.page_size_bytes * (1 + self.num_speculative_blocks)
+ def max_num_blocks_per_req(self, vllm_config: VllmConfig, max_len: int) -> int:
+ # Mamba state is replicated across DCP/PCP ranks, never sharded, so
+ # no CP scaling applies.
+ if vllm_config.cache_config.mamba_cache_mode == "align":
+ # Block table rows are position-indexed over the full sequence
+ # even though only 2 + num_speculative_blocks state blocks are
+ # resident at a time (earlier states are nulled out by
+ # remove_skipped_blocks), so the row length must cover max_len
+ # rather than max_memory_usage_bytes.
+ return cdiv(max_len, self.block_size) + self.num_speculative_blocks
+ return cdiv(self.max_memory_usage_bytes(vllm_config), self.page_size_bytes)
+
def is_uniform_with_collection(
self, kv_cache_specs: dict[str, KVCacheSpec]
) -> bool:
diff --git a/vllm/v1/worker/block_table.py b/vllm/v1/worker/block_table.py
index d9c041ba0b89..d40887879fcb 100644
--- a/vllm/v1/worker/block_table.py
+++ b/vllm/v1/worker/block_table.py
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+from enum import Enum
+
import numpy as np
import torch
@@ -10,11 +12,15 @@
from vllm.utils.math_utils import cdiv
from vllm.v1.attention.backends.utils import PAD_SLOT_ID
from vllm.v1.utils import CpuGpuBuffer
-from vllm.v1.worker.cp_utils import get_total_cp_world_size
logger = init_logger(__name__)
+class SlotMappingMode(Enum):
+ TOKEN_TO_KV_SLOT = "token_to_kv_slot"
+ NONE = "none"
+
+
class BlockTable:
def __init__(
self,
@@ -26,6 +32,7 @@ def __init__(
device: torch.device,
kernel_block_size: int,
cp_kv_cache_interleave_size: int,
+ slot_mapping_mode: SlotMappingMode = SlotMappingMode.TOKEN_TO_KV_SLOT,
):
"""
Args:
@@ -38,11 +45,15 @@ def __init__(
kernel_block_size: The block_size of underlying attention kernel.
Will be the same as `block_size` if `block_size` is supported
by the attention kernel.
+ slot_mapping_mode: How this cache group maps scheduled tokens to
+ cache slots. Mamba-like state caches do not use token slot
+ mappings and should use SlotMappingMode.NONE.
"""
self.max_num_reqs = max_num_reqs
self.max_num_batched_tokens = max_num_batched_tokens
self.pin_memory = pin_memory
self.device = device
+ self.kv_cache_block_size = block_size
if kernel_block_size == block_size:
# Standard case: allocation and computation use same block size
@@ -98,6 +109,7 @@ def __init__(
self.dcp_world_size = 1
self.dcp_rank = 0
self.cp_kv_cache_interleave_size = cp_kv_cache_interleave_size
+ self.slot_mapping_mode = slot_mapping_mode
def append_row(
self,
@@ -145,6 +157,12 @@ def compute_slot_mapping(
positions: torch.Tensor,
) -> None:
num_tokens = positions.shape[0]
+ if self.slot_mapping_mode == SlotMappingMode.NONE:
+ # Mamba/GDN groups consume the block table as recurrent state
+ # indices and do not use per-token slot mappings.
+ return
+ assert self.slot_mapping_mode == SlotMappingMode.TOKEN_TO_KV_SLOT
+
total_cp_world_size = self.pcp_world_size * self.dcp_world_size
total_cp_rank = self.pcp_rank * self.dcp_world_size + self.dcp_rank
_compute_slot_mapping_kernel[(num_reqs + 1,)](
@@ -156,6 +174,8 @@ def compute_slot_mapping(
self.block_table.gpu.stride(0),
self.block_size,
self.slot_mapping.gpu,
+ KV_CACHE_BLOCK_SIZE=self.kv_cache_block_size,
+ BLOCKS_PER_KV_BLOCK=self.blocks_per_kv_block,
TOTAL_CP_WORLD_SIZE=total_cp_world_size,
TOTAL_CP_RANK=total_cp_rank,
CP_KV_CACHE_INTERLEAVE_SIZE=self.cp_kv_cache_interleave_size,
@@ -226,30 +246,27 @@ class MultiGroupBlockTable:
def __init__(
self,
max_num_reqs: int,
- max_model_len: int,
max_num_batched_tokens: int,
pin_memory: bool,
device: torch.device,
block_sizes: list[int],
kernel_block_sizes: list[int],
- max_num_blocks: list[int] | None = None,
+ max_num_blocks: list[int],
cp_kv_cache_interleave_size: int = 1,
+ slot_mapping_modes: list[SlotMappingMode] | None = None,
) -> None:
if len(kernel_block_sizes) != len(block_sizes):
raise ValueError(
f"kernel_block_sizes length ({len(kernel_block_sizes)}) "
f"must match block_sizes length ({len(block_sizes)})"
)
- if max_num_blocks is None:
- # Note(hc): each dcp rank only store
- # (max_model_len//dcp_world_size) tokens in kvcache,
- # so the block_size which used for calc max_num_blocks_per_req
- # must be multiplied by dcp_world_size.
- total_cp_world_size = get_total_cp_world_size()
- max_num_blocks = [
- cdiv(max_model_len, block_size * total_cp_world_size)
- for block_size in block_sizes
- ]
+ if slot_mapping_modes is None:
+ slot_mapping_modes = [SlotMappingMode.TOKEN_TO_KV_SLOT] * len(block_sizes)
+ if len(slot_mapping_modes) != len(block_sizes):
+ raise ValueError(
+ f"slot_mapping_modes length ({len(slot_mapping_modes)}) "
+ f"must match block_sizes length ({len(block_sizes)})"
+ )
if len(max_num_blocks) != len(block_sizes):
raise ValueError(
@@ -274,9 +291,15 @@ def __init__(
device,
kernel_block_size,
cp_kv_cache_interleave_size,
+ slot_mapping_mode=slot_mapping_mode,
)
- for block_size, kernel_block_size, max_num_blocks_per_req in zip(
- block_sizes, kernel_block_sizes, max_num_blocks
+ for (
+ block_size,
+ kernel_block_size,
+ max_num_blocks_per_req,
+ slot_mapping_mode,
+ ) in zip(
+ block_sizes, kernel_block_sizes, max_num_blocks, slot_mapping_modes
)
]
@@ -332,6 +355,8 @@ def _compute_slot_mapping_kernel(
block_table_stride, # max_num_blocks_per_req
block_size,
slot_mapping_ptr, # [max_num_tokens], int64
+ KV_CACHE_BLOCK_SIZE: tl.constexpr,
+ BLOCKS_PER_KV_BLOCK: tl.constexpr,
TOTAL_CP_WORLD_SIZE: tl.constexpr,
TOTAL_CP_RANK: tl.constexpr,
CP_KV_CACHE_INTERLEAVE_SIZE: tl.constexpr,
@@ -354,18 +379,14 @@ def _compute_slot_mapping_kernel(
start_idx = tl.load(query_start_loc_ptr + req_idx).to(tl.int64)
end_idx = tl.load(query_start_loc_ptr + req_idx + 1).to(tl.int64)
- virtual_block_size = block_size * TOTAL_CP_WORLD_SIZE
+ virtual_block_size = KV_CACHE_BLOCK_SIZE * TOTAL_CP_WORLD_SIZE
row_offset = req_idx * block_table_stride
for i in range(start_idx, end_idx, BLOCK_SIZE):
offsets = i + tl.arange(0, BLOCK_SIZE)
mask = offsets < end_idx
pos = tl.load(positions_ptr + offsets, mask=mask, other=0)
- block_indices = pos // virtual_block_size
- block_numbers = tl.load(block_table_ptr + row_offset + block_indices).to(
- tl.int64
- )
-
- virtual_block_offsets = pos - block_indices * virtual_block_size
+ virtual_block_indices = pos // virtual_block_size
+ virtual_block_offsets = pos - virtual_block_indices * virtual_block_size
is_local = (
virtual_block_offsets // CP_KV_CACHE_INTERLEAVE_SIZE
) % TOTAL_CP_WORLD_SIZE == TOTAL_CP_RANK
@@ -375,6 +396,16 @@ def _compute_slot_mapping_kernel(
virtual_block_offsets % CP_KV_CACHE_INTERLEAVE_SIZE
)
- slot_ids = block_numbers * block_size + local_block_offsets
+ block_indices = (
+ virtual_block_indices * BLOCKS_PER_KV_BLOCK
+ + local_block_offsets // block_size
+ )
+ block_numbers = tl.load(
+ block_table_ptr + row_offset + block_indices,
+ mask=mask & is_local,
+ other=0,
+ ).to(tl.int64)
+ slot_offsets = local_block_offsets % block_size
+ slot_ids = block_numbers * block_size + slot_offsets
slot_ids = tl.where(is_local, slot_ids, PAD_ID)
tl.store(slot_mapping_ptr + offsets, slot_ids, mask=mask)
diff --git a/vllm/v1/worker/cp_utils.py b/vllm/v1/worker/cp_utils.py
index 05cca52fc0db..11edd86a5db0 100644
--- a/vllm/v1/worker/cp_utils.py
+++ b/vllm/v1/worker/cp_utils.py
@@ -1,15 +1,23 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
+import torch
+
from vllm.config import VllmConfig, get_layers_from_vllm_config
from vllm.distributed import get_dcp_group, get_pcp_group
+from vllm.logger import init_logger
+from vllm.v1.attention.backend import CommonAttentionMetadata
+from vllm.v1.attention.backends.utils import split_decodes_prefills_and_extends
if TYPE_CHECKING:
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
else:
AttentionLayerBase = object
+logger = init_logger(__name__)
+
def check_attention_cp_compatibility(vllm_config: VllmConfig) -> None:
pcp_size = vllm_config.parallel_config.prefill_context_parallel_size
@@ -56,3 +64,232 @@ def get_total_cp_world_size():
# DCP might not be initialized in testing
dcp_world_size = 1
return dcp_world_size * pcp_world_size
+
+
+def get_dcp_dummy_context_len(
+ dcp_world_size: int,
+ cp_kv_cache_interleave_size: int,
+ has_kv_cache_config: bool,
+ create_mixed_batch: bool,
+ is_graph_capturing: bool,
+ uniform_decode: bool,
+) -> int:
+ if (
+ dcp_world_size <= 1
+ or not has_kv_cache_config
+ or not (create_mixed_batch or (is_graph_capturing and uniform_decode))
+ ):
+ return 0
+ return dcp_world_size * cp_kv_cache_interleave_size
+
+
+def prepare_dcp_dummy_context_metadata(
+ *,
+ input_batch: Any,
+ kv_cache_config: Any,
+ query_pos: Any,
+ positions: torch.Tensor,
+ query_start_loc: Any,
+ num_reqs: int,
+ num_tokens_unpadded: int,
+ dcp_dummy_context_len: int,
+) -> None:
+ """Populate valid fake KV metadata for DCP CUDA graph warmup/capture."""
+ if dcp_dummy_context_len == 0:
+ return
+
+ # DCP graph warmup may exercise context attention, so block-table entries
+ # must point at allocated KV blocks.
+ assert kv_cache_config is not None
+ max_valid_block_id = kv_cache_config.num_blocks - 1
+ assert max_valid_block_id > 0
+ for blk_table in input_batch.block_table.block_tables:
+ max_row_blocks = (
+ blk_table.max_num_blocks_per_req // blk_table.blocks_per_kv_block
+ )
+ block_ids = [
+ (block_idx % max_valid_block_id) + 1 for block_idx in range(max_row_blocks)
+ ]
+ for req_idx in range(num_reqs):
+ blk_table.add_row(block_ids, req_idx)
+ blk_table.commit_block_table(num_reqs)
+
+ query_pos.copy_to_gpu(num_tokens_unpadded)
+ positions[:num_tokens_unpadded] = (
+ query_pos.gpu[:num_tokens_unpadded] + dcp_dummy_context_len
+ )
+ input_batch.block_table.compute_slot_mapping(
+ num_reqs,
+ query_start_loc.gpu[: num_reqs + 1],
+ positions[:num_tokens_unpadded],
+ )
+
+
+def should_skip_dcp_context_attention(context_kv_lens_cpu: torch.Tensor) -> bool:
+ """Whether DCP context attention can be skipped for this batch.
+
+ Must be computed from rank-invariant inputs only (the global context
+ lengths, NOT this rank's local share from get_dcp_local_seq_lens): the
+ non-skip path in _forward_with_dcp issues DCP collectives (query
+ all-gather + LSE combine), so every DCP rank must take the same branch.
+ A rank can hold zero local context tokens while other ranks still hold
+ context for the same batch.
+ """
+ return int(context_kv_lens_cpu.max().item()) == 0
+
+
+def split_dcp_context_queries(
+ query_start_loc: torch.Tensor,
+ seq_lens_cpu_upper_bound: torch.Tensor | None,
+ max_query_len: int,
+ num_actual_tokens: int,
+) -> tuple[int, int, int, int]:
+ """Split reordered DCP context queries into decode and extend regions."""
+ num_reqs = query_start_loc.shape[0] - 1
+ if max_query_len <= 1:
+ return num_reqs, 0, num_actual_tokens, 0
+ if seq_lens_cpu_upper_bound is None:
+ return 0, num_reqs, 0, num_actual_tokens
+
+ common_attn_metadata = cast(
+ CommonAttentionMetadata,
+ SimpleNamespace(
+ max_query_len=max_query_len,
+ num_reqs=num_reqs,
+ num_actual_tokens=num_actual_tokens,
+ query_start_loc_cpu=query_start_loc,
+ seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
+ is_prefilling=None,
+ ),
+ )
+ (
+ num_decodes,
+ num_extends,
+ _num_prefills,
+ num_decode_tokens,
+ num_extend_tokens,
+ _num_prefill_tokens,
+ ) = split_decodes_prefills_and_extends(common_attn_metadata)
+ return num_decodes, num_extends, num_decode_tokens, num_extend_tokens
+
+
+def should_split_fa2_dcp_context_attention(
+ fa_version: int | None,
+ max_query_len: int,
+ num_reqs: int,
+ num_decode_reqs: int,
+ num_context_prefill_reqs: int,
+) -> bool:
+ num_prefills = num_reqs - num_decode_reqs
+ # TODO: Remove this FA2-only DCP compatibility path once FA4 supports
+ # the Qwen3.5 head_size=256 shape on Blackwell and can be used here.
+ # FA2 paged-varlen context attention can fail for DCP mixed batches when
+ # decode rows, context-bearing extend rows, and zero-context pure prefill
+ # rows are submitted together.
+ return (
+ fa_version == 2
+ and max_query_len > 1
+ and num_prefills > 0
+ and (num_decode_reqs > 0 or num_context_prefill_reqs < num_prefills)
+ )
+
+
+def run_split_fa2_dcp_context_attention(
+ flash_attn_varlen_func: Any,
+ query_across_dcp: torch.Tensor,
+ key_cache: torch.Tensor,
+ value_cache: torch.Tensor,
+ dcp_context_out: torch.Tensor,
+ cu_seqlens_q: torch.Tensor,
+ max_seqlen_q: int,
+ dcp_context_kv_lens: torch.Tensor,
+ max_dcp_context_kv_len: int,
+ softmax_scale: float,
+ alibi_slopes: torch.Tensor | None,
+ sliding_window_size: list[int] | None,
+ block_table: torch.Tensor,
+ softcap: float,
+ fa_version: int,
+ q_descale: torch.Tensor | None,
+ k_descale: torch.Tensor | None,
+ v_descale: torch.Tensor | None,
+ max_num_splits: int,
+ num_heads: int,
+ dcp_world_size: int,
+ num_decode_reqs: int,
+ num_context_prefill_reqs: int,
+ num_decode_tokens: int,
+ num_context_prefill_tokens: int,
+) -> tuple[torch.Tensor, torch.Tensor]:
+ dcp_context_out.zero_()
+ context_lse = torch.full(
+ (num_heads * dcp_world_size, query_across_dcp.shape[0]),
+ -torch.inf,
+ dtype=torch.float32,
+ device=query_across_dcp.device,
+ )
+
+ if num_decode_tokens > 0:
+ _, decode_context_lse = flash_attn_varlen_func(
+ q=query_across_dcp[:num_decode_tokens],
+ k=key_cache,
+ v=value_cache,
+ out=dcp_context_out[:num_decode_tokens],
+ cu_seqlens_q=cu_seqlens_q[: num_decode_reqs + 1],
+ max_seqlen_q=1,
+ seqused_k=dcp_context_kv_lens[:num_decode_reqs],
+ max_seqlen_k=max_dcp_context_kv_len,
+ softmax_scale=softmax_scale,
+ causal=False,
+ alibi_slopes=alibi_slopes,
+ window_size=sliding_window_size,
+ block_table=block_table[:num_decode_reqs],
+ softcap=softcap,
+ return_softmax_lse=True,
+ scheduler_metadata=None,
+ fa_version=fa_version,
+ q_descale=q_descale[:num_decode_reqs] if q_descale is not None else None,
+ k_descale=k_descale[:num_decode_reqs] if k_descale is not None else None,
+ v_descale=v_descale[:num_decode_reqs] if v_descale is not None else None,
+ num_splits=max_num_splits,
+ )
+ context_lse[:, :num_decode_tokens] = decode_context_lse
+
+ if num_context_prefill_tokens > 0:
+ prefill_start = num_decode_tokens
+ prefill_end = prefill_start + num_context_prefill_tokens
+ prefill_query_start_loc = (
+ cu_seqlens_q[
+ num_decode_reqs : num_decode_reqs + num_context_prefill_reqs + 1
+ ]
+ - num_decode_tokens
+ )
+ prefill_req_slice = slice(
+ num_decode_reqs, num_decode_reqs + num_context_prefill_reqs
+ )
+ _, prefill_context_lse = flash_attn_varlen_func(
+ q=query_across_dcp[prefill_start:prefill_end],
+ k=key_cache,
+ v=value_cache,
+ out=dcp_context_out[prefill_start:prefill_end],
+ cu_seqlens_q=prefill_query_start_loc,
+ max_seqlen_q=max_seqlen_q,
+ seqused_k=dcp_context_kv_lens[prefill_req_slice],
+ max_seqlen_k=max_dcp_context_kv_len,
+ softmax_scale=softmax_scale,
+ causal=False,
+ alibi_slopes=alibi_slopes,
+ window_size=sliding_window_size,
+ block_table=block_table[prefill_req_slice],
+ softcap=softcap,
+ return_softmax_lse=True,
+ scheduler_metadata=None,
+ fa_version=fa_version,
+ q_descale=q_descale[prefill_req_slice] if q_descale is not None else None,
+ k_descale=k_descale[prefill_req_slice] if k_descale is not None else None,
+ v_descale=v_descale[prefill_req_slice] if v_descale is not None else None,
+ num_splits=max_num_splits,
+ )
+ context_lse[:, prefill_start:prefill_end] = prefill_context_lse
+
+ return dcp_context_out, context_lse
diff --git a/vllm/v1/worker/gpu_input_batch.py b/vllm/v1/worker/gpu_input_batch.py
index 28d1a04b780c..bb4fb3fe453c 100644
--- a/vllm/v1/worker/gpu_input_batch.py
+++ b/vllm/v1/worker/gpu_input_batch.py
@@ -28,7 +28,7 @@
maybe_create_thinking_budget_state_holder,
)
from vllm.v1.utils import copy_slice
-from vllm.v1.worker.block_table import MultiGroupBlockTable
+from vllm.v1.worker.block_table import MultiGroupBlockTable, SlotMappingMode
@dataclass
@@ -99,13 +99,14 @@ def __init__(
vocab_size: int,
block_sizes: list[int], # The block_size of each kv cache group
kernel_block_sizes: list[int],
- max_num_blocks_per_req: list[int] | None = None,
+ max_num_blocks_per_req: list[int],
logitsprocs: LogitsProcessors | None = None,
logitsprocs_need_output_token_ids: bool = False,
num_spec_tokens: int = 0,
is_pooling_model: bool = False,
cp_kv_cache_interleave_size: int = 1,
reasoning_config: ReasoningConfig | None = None,
+ slot_mapping_modes: list[SlotMappingMode] | None = None,
):
self.thinking_budget_state_holder = maybe_create_thinking_budget_state_holder(
reasoning_config,
@@ -171,7 +172,6 @@ def __init__(
# Block table.
self.block_table = MultiGroupBlockTable(
max_num_reqs=max_num_reqs,
- max_model_len=max_model_len,
max_num_batched_tokens=max_num_batched_tokens,
pin_memory=PIN_MEMORY,
device=device,
@@ -179,6 +179,7 @@ def __init__(
kernel_block_sizes=kernel_block_sizes,
max_num_blocks=max_num_blocks_per_req,
cp_kv_cache_interleave_size=cp_kv_cache_interleave_size,
+ slot_mapping_modes=slot_mapping_modes,
)
# Sampling-related.
diff --git a/vllm/v1/worker/gpu_model_runner.py b/vllm/v1/worker/gpu_model_runner.py
index 38500ab0514d..efb302b90f47 100644
--- a/vllm/v1/worker/gpu_model_runner.py
+++ b/vllm/v1/worker/gpu_model_runner.py
@@ -153,10 +153,12 @@
KVCacheConfig,
KVCacheGroupSpec,
KVCacheSpec,
+ KVCacheSpecKind,
KVQuantMode,
MambaSpec,
SlidingWindowSpec,
UniformTypeKVCacheSpecs,
+ get_kv_cache_spec_kind,
)
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
from vllm.v1.outputs import (
@@ -201,9 +203,11 @@
from vllm.v1.structured_output.utils import apply_grammar_bitmask
from vllm.v1.utils import CpuGpuBuffer, record_function_or_nullcontext
from vllm.v1.worker import mamba_utils
+from vllm.v1.worker.block_table import SlotMappingMode
from vllm.v1.worker.cp_utils import (
check_attention_cp_compatibility,
- get_total_cp_world_size,
+ get_dcp_dummy_context_len,
+ prepare_dcp_dummy_context_metadata,
)
from vllm.v1.worker.dp_utils import coordinate_batch_across_dp
from vllm.v1.worker.ec_connector_model_runner_mixin import ECConnectorModelRunnerMixin
@@ -681,8 +685,13 @@ def __init__(
placeholder_block_size = (
self.cache_config.block_size or CacheConfig.DEFAULT_BLOCK_SIZE
)
+ placeholder_max_num_blocks = cdiv(
+ max(self.max_model_len, self.max_encoder_len), placeholder_block_size
+ )
self._init_block_sizes = [placeholder_block_size]
self._init_kernel_block_sizes = [placeholder_block_size]
+ self._init_max_num_blocks = [placeholder_max_num_blocks]
+ self._init_slot_mapping_modes = [SlotMappingMode.TOKEN_TO_KV_SLOT]
self.input_batch = InputBatch(
max_num_reqs=self.max_num_reqs,
# We need to use the encoder length for encoder-decoder
@@ -693,6 +702,7 @@ def __init__(
vocab_size=self.model_config.get_vocab_size(),
block_sizes=[placeholder_block_size],
kernel_block_sizes=[placeholder_block_size],
+ max_num_blocks_per_req=[placeholder_max_num_blocks],
num_spec_tokens=self.num_spec_tokens,
logitsprocs=build_logitsprocs(
self.vllm_config,
@@ -5858,6 +5868,14 @@ def _dummy_run(
num_reqs_padded = (
batch_desc.num_reqs if batch_desc.num_reqs is not None else num_reqs
)
+ dcp_dummy_context_len = get_dcp_dummy_context_len(
+ self.dcp_world_size,
+ self.parallel_config.cp_kv_cache_interleave_size,
+ hasattr(self, "kv_cache_config"),
+ create_mixed_batch,
+ is_graph_capturing,
+ uniform_decode,
+ )
ubatch_slices, ubatch_slices_padded = maybe_create_ubatch_slices(
should_ubatch,
num_scheduled_tokens,
@@ -5900,10 +5918,19 @@ def _dummy_run(
# In the mixed batch mode (used for FI warmup), we use
# shorter sequence lengths to run faster.
# TODO(luka) better system for describing dummy batches
- seq_lens = torch.tensor( # type: ignore[assignment]
- [1] * num_decode_tokens + [num_prefill_tokens + 1],
- dtype=torch.int,
- )
+ if dcp_dummy_context_len > 0:
+ seq_lens = torch.tensor( # type: ignore[assignment]
+ [1 + dcp_dummy_context_len] * num_decode_tokens
+ + [num_prefill_tokens + dcp_dummy_context_len],
+ dtype=torch.int,
+ )
+ else:
+ seq_lens = torch.tensor( # type: ignore[assignment]
+ [1] * num_decode_tokens + [num_prefill_tokens + 1],
+ dtype=torch.int,
+ )
+ elif dcp_dummy_context_len > 0:
+ seq_lens = max_query_len + dcp_dummy_context_len # type: ignore[assignment]
else:
seq_lens = max_query_len # type: ignore[assignment]
self.optimistic_seq_lens_cpu[:num_reqs] = seq_lens
@@ -5919,6 +5946,17 @@ def _dummy_run(
)
self.query_start_loc.copy_to_gpu()
+ prepare_dcp_dummy_context_metadata(
+ input_batch=self.input_batch,
+ kv_cache_config=getattr(self, "kv_cache_config", None),
+ query_pos=self.query_pos,
+ positions=self.positions,
+ query_start_loc=self.query_start_loc,
+ num_reqs=num_reqs,
+ num_tokens_unpadded=num_tokens_unpadded,
+ dcp_dummy_context_len=dcp_dummy_context_len,
+ )
+
# Sync block table CPU->GPU so cleared rows from
# remove_request() are visible to the attention metadata
# builder. Without this, stale block IDs from finished
@@ -7029,29 +7067,34 @@ def may_reinitialize_input_batch(
"""
block_sizes = []
max_num_blocks = []
+ slot_mapping_modes = []
max_model_len = max(self.max_model_len, self.max_encoder_len)
for kv_cache_group in kv_cache_config.kv_cache_groups:
- if isinstance(kv_cache_group.kv_cache_spec, EncoderOnlyAttentionSpec):
+ kv_cache_spec = kv_cache_group.kv_cache_spec
+ kv_cache_spec_kind = get_kv_cache_spec_kind(kv_cache_spec)
+ if kv_cache_spec_kind == KVCacheSpecKind.ENCODER_ONLY_ATTENTION:
continue
- block_size = kv_cache_group.kv_cache_spec.block_size
+ block_size = kv_cache_spec.block_size
block_sizes.append(block_size)
- max_num_blocks_per_req = cdiv(
- max_model_len, block_size * get_total_cp_world_size()
- )
- if isinstance(kv_cache_group.kv_cache_spec, MambaSpec):
- max_num_blocks_per_req = (
- max_num_blocks_per_req
- if self.cache_config.enable_prefix_caching
- else 1
- ) + kv_cache_group.kv_cache_spec.num_speculative_blocks
+ if kv_cache_spec_kind == KVCacheSpecKind.MAMBA:
+ slot_mapping_modes.append(SlotMappingMode.NONE)
+ else:
+ slot_mapping_modes.append(SlotMappingMode.TOKEN_TO_KV_SLOT)
+ max_num_blocks_per_req = kv_cache_spec.max_num_blocks_per_req(
+ self.vllm_config, max_model_len
+ )
max_num_blocks.append(max_num_blocks_per_req)
if (
block_sizes != self._init_block_sizes
or kernel_block_sizes != self._init_kernel_block_sizes
+ or max_num_blocks != self._init_max_num_blocks
+ or slot_mapping_modes != self._init_slot_mapping_modes
):
self._init_block_sizes = block_sizes
self._init_kernel_block_sizes = kernel_block_sizes
+ self._init_max_num_blocks = max_num_blocks
+ self._init_slot_mapping_modes = slot_mapping_modes
self.input_batch = InputBatch(
max_num_reqs=self.max_num_reqs,
max_model_len=max_model_len,
@@ -7065,7 +7108,9 @@ def may_reinitialize_input_batch(
logitsprocs=self.input_batch.logitsprocs,
logitsprocs_need_output_token_ids=self.input_batch.logitsprocs_need_output_token_ids,
is_pooling_model=self.is_pooling_model,
+ cp_kv_cache_interleave_size=self.parallel_config.cp_kv_cache_interleave_size,
reasoning_config=self.vllm_config.reasoning_config,
+ slot_mapping_modes=slot_mapping_modes,
)
assert self._init_block_sizes == block_sizes, (
diff --git a/vllm/v1/worker/tpu_input_batch.py b/vllm/v1/worker/tpu_input_batch.py
index 3758a73ee496..1396c8ad9d5a 100644
--- a/vllm/v1/worker/tpu_input_batch.py
+++ b/vllm/v1/worker/tpu_input_batch.py
@@ -29,6 +29,7 @@ def __init__(
vocab_size: int,
block_sizes: list[int], # The block_size of each kv cache group
kernel_block_sizes: list[int],
+ max_num_blocks_per_req: list[int],
):
self.max_num_reqs = max_num_reqs
self.max_model_len = max_model_len
@@ -64,12 +65,12 @@ def __init__(
# Block table.
self.block_table = MultiGroupBlockTable(
max_num_reqs=max_num_reqs,
- max_model_len=max_model_len,
max_num_batched_tokens=max_num_batched_tokens,
pin_memory=pin_memory,
device=device,
block_sizes=block_sizes,
kernel_block_sizes=kernel_block_sizes,
+ max_num_blocks=max_num_blocks_per_req,
)
# Sampling-related.
From e5588e49bc2642670116664a7fc4096e27adb179 Mon Sep 17 00:00:00 2001
From: GongLei-HW <1327185943@qq.com>
Date: Fri, 10 Jul 2026 13:46:54 +0800
Subject: [PATCH 0008/1526] [Core][KV events] Report prefix-cache-reused blocks
in full report mode (#45261)
Signed-off-by: Lei Gong
Co-authored-by: Lei Gong
Co-authored-by: Claude
---
tests/v1/core/test_prefix_caching.py | 119 +++++++++++++++++++++++-
vllm/v1/core/block_pool.py | 131 ++++++++++++++++++++++-----
vllm/v1/core/kv_cache_manager.py | 21 +++++
vllm/v1/core/kv_cache_utils.py | 19 ++++
vllm/v1/request.py | 5 +
5 files changed, 270 insertions(+), 25 deletions(-)
diff --git a/tests/v1/core/test_prefix_caching.py b/tests/v1/core/test_prefix_caching.py
index 59260a499ef4..03d61ec299f1 100644
--- a/tests/v1/core/test_prefix_caching.py
+++ b/tests/v1/core/test_prefix_caching.py
@@ -12,7 +12,12 @@
import vllm.v1.core.kv_cache_manager as kv_cache_manager
import vllm.v1.core.kv_cache_utils as kv_cache_utils
-from vllm.distributed.kv_events import AllBlocksCleared, BlockRemoved, BlockStored
+from vllm.distributed.kv_events import (
+ MEDIUM_GPU,
+ AllBlocksCleared,
+ BlockRemoved,
+ BlockStored,
+)
from vllm.lora.request import LoRARequest
from vllm.multimodal.inputs import (
MultiModalFeatureSpec,
@@ -2454,6 +2459,118 @@ def test_block_removed_event_group_idx(group_id: int):
assert event.group_idx == group_id
+def test_emit_cached_block_events():
+ """emit_cached_block_events emits one BlockStored for already-cached
+ (reused) prefix blocks, carrying the correct group_idx /
+ parent_block_hash / token_ids, and without mutating block state."""
+ block_size = 4
+ num_cached_blocks = 3
+ kv_cache_group_id = 1
+ num_tokens = block_size * 4 # 4 full blocks; reuse the first 3
+
+ pool = BlockPool(
+ num_gpu_blocks=8,
+ enable_caching=True,
+ hash_block_size=block_size,
+ enable_kv_cache_events=True,
+ )
+
+ req = make_request(
+ "req_emit_cached",
+ prompt_token_ids=list(range(num_tokens)),
+ block_size=block_size,
+ hash_fn=sha256,
+ )
+ assert len(req.block_hashes) >= num_cached_blocks
+
+ # Snapshot block state to prove emit_cached_block_events does not mutate it.
+ free_before = pool.get_num_free_blocks()
+ assert len(pool.cached_block_hash_to_block) == 0
+
+ pool.emit_cached_block_events(
+ request=req,
+ num_cached_blocks=num_cached_blocks,
+ block_size=block_size,
+ kv_cache_group_id=kv_cache_group_id,
+ )
+
+ # No block-state mutation: nothing allocated, nothing inserted into the
+ # prefix-cache map.
+ assert pool.get_num_free_blocks() == free_before
+ assert len(pool.cached_block_hash_to_block) == 0
+
+ events = pool.take_events()
+ assert len(events) == 1
+ event = events[0]
+ assert isinstance(event, BlockStored)
+
+ expected_hashes = [
+ kv_cache_utils.maybe_convert_block_hash(req.block_hashes[i])
+ for i in range(num_cached_blocks)
+ ]
+ assert event.block_hashes == expected_hashes
+ # Reused blocks start from block 0, so there is no parent block hash.
+ assert event.parent_block_hash is None
+ assert event.token_ids == list(req.all_token_ids[: num_cached_blocks * block_size])
+ assert event.group_idx == kv_cache_group_id
+ assert event.block_size == block_size
+ assert event.medium == MEDIUM_GPU
+ assert event.lora_id is None
+ assert event.lora_name is None
+
+
+def test_emit_cached_block_events_disabled():
+ """No events are emitted when enable_kv_cache_events is False."""
+ block_size = 4
+ pool = BlockPool(
+ num_gpu_blocks=8,
+ enable_caching=True,
+ hash_block_size=block_size,
+ enable_kv_cache_events=False,
+ )
+ req = make_request(
+ "req_emit_disabled",
+ prompt_token_ids=list(range(block_size * 4)),
+ block_size=block_size,
+ hash_fn=sha256,
+ )
+
+ pool.emit_cached_block_events(
+ request=req,
+ num_cached_blocks=3,
+ block_size=block_size,
+ kv_cache_group_id=0,
+ )
+
+ assert pool.take_events() == []
+
+
+def test_emit_cached_block_events_zero_cached():
+ """No events are emitted when num_cached_blocks == 0."""
+ block_size = 4
+ pool = BlockPool(
+ num_gpu_blocks=8,
+ enable_caching=True,
+ hash_block_size=block_size,
+ enable_kv_cache_events=True,
+ )
+ req = make_request(
+ "req_emit_zero",
+ prompt_token_ids=list(range(block_size * 4)),
+ block_size=block_size,
+ hash_fn=sha256,
+ )
+
+ pool.emit_cached_block_events(
+ request=req,
+ num_cached_blocks=0,
+ block_size=block_size,
+ kv_cache_group_id=0,
+ )
+
+ assert pool.take_events() == []
+
+
def test_eagle_enabled_removes_last_block():
"""Verify Eagle does NOT remove blocks when request
length is divisible by block size."""
diff --git a/vllm/v1/core/block_pool.py b/vllm/v1/core/block_pool.py
index 81ac05f36589..bc8f2d87adb9 100644
--- a/vllm/v1/core/block_pool.py
+++ b/vllm/v1/core/block_pool.py
@@ -14,8 +14,6 @@
from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
from vllm.v1.core.kv_cache_utils import (
BlockHash,
- BlockHashList,
- BlockHashListWithBlockSize,
BlockHashWithGroupId,
ExternalBlockHash,
FreeKVCacheBlockQueue,
@@ -25,6 +23,7 @@
get_group_id,
make_block_hash_with_group_id,
maybe_convert_block_hash,
+ resolve_block_hashes,
)
from vllm.v1.request import Request
@@ -261,17 +260,7 @@ def cache_full_blocks(
return
new_full_blocks = blocks[num_cached_blocks:num_full_blocks]
assert block_mask is None or len(block_mask) == len(new_full_blocks)
- if block_size == self.hash_block_size:
- # Common case.
- block_hashes: BlockHashList = request.block_hashes
- else:
- # block_size is a multiple of hash_block_size. This happens when
- # different KV cache groups have different block sizes.
- assert block_size % self.hash_block_size == 0
- block_hashes = BlockHashListWithBlockSize(
- request.block_hashes, self.hash_block_size, block_size
- )
- assert len(block_hashes) >= num_full_blocks
+ block_hashes = resolve_block_hashes(request, self.hash_block_size, block_size)
new_block_hashes = block_hashes[num_cached_blocks:]
new_hashes: list[ExternalBlockHash] | None = (
@@ -338,23 +327,117 @@ def cache_full_blocks(
extra_keys_list.append(extra_keys)
self.kv_event_queue.append(
- BlockStored(
+ self._build_block_stored_event(
+ request,
block_hashes=new_hashes,
parent_block_hash=parent_block_hash,
- token_ids=request.all_token_ids[start_token_idx:end_token_idx],
+ start_token_idx=start_token_idx,
+ end_token_idx=end_token_idx,
block_size=block_size,
- lora_id=request.lora_request.adapter_id
- if request.lora_request
- else None,
- medium=MEDIUM_GPU,
- lora_name=request.lora_request.name
- if request.lora_request
- else None,
- extra_keys=extra_keys_list if extra_keys_list else None,
- group_idx=kv_cache_group_id,
+ kv_cache_group_id=kv_cache_group_id,
+ extra_keys_list=extra_keys_list,
)
)
+ def _build_block_stored_event(
+ self,
+ request: Request,
+ block_hashes: list[ExternalBlockHash] | None,
+ parent_block_hash: ExternalBlockHash | None,
+ start_token_idx: int,
+ end_token_idx: int,
+ block_size: int,
+ kv_cache_group_id: int,
+ extra_keys_list: list[tuple[Any, ...] | None],
+ ) -> BlockStored:
+ """Build a ``BlockStored`` KV event for ``request``.
+
+ Shared by ``cache_full_blocks`` (newly cached blocks) and
+ ``emit_cached_block_events`` (prefix-cache-reused blocks) so both emit
+ identical event shapes for downstream consumers.
+ """
+ return BlockStored(
+ block_hashes=block_hashes,
+ parent_block_hash=parent_block_hash,
+ token_ids=request.all_token_ids[start_token_idx:end_token_idx],
+ block_size=block_size,
+ lora_id=request.lora_request.adapter_id if request.lora_request else None,
+ medium=MEDIUM_GPU,
+ lora_name=request.lora_request.name if request.lora_request else None,
+ extra_keys=extra_keys_list if extra_keys_list else None,
+ group_idx=kv_cache_group_id,
+ )
+
+ def emit_cached_block_events(
+ self,
+ request: Request,
+ num_cached_blocks: int,
+ block_size: int,
+ kv_cache_group_id: int,
+ ) -> None:
+ """Generate BlockStored events for blocks reused from prefix cache.
+
+ Unlike cache_full_blocks(), this does NOT modify block state —
+ the blocks are already cached. It only generates events so that
+ external consumers (e.g. gateway) can learn about reused blocks.
+
+ Args:
+ request: The request whose prefix cache blocks were reused.
+ num_cached_blocks: Number of blocks that were cache hits.
+ block_size: Number of tokens per block.
+ kv_cache_group_id: The KV cache group ID.
+ """
+ if not self.enable_kv_cache_events or num_cached_blocks == 0:
+ return
+
+ block_hashes = resolve_block_hashes(request, self.hash_block_size, block_size)
+
+ # Collect external hashes and extra_keys for cached blocks.
+ cached_hashes: list[ExternalBlockHash] = []
+ extra_keys_list: list[tuple[Any, ...] | None] = []
+ curr_mm_idx = 0
+ for i in range(num_cached_blocks):
+ block_start = i * block_size
+ block_end = block_start + block_size
+ cached_hashes.append(maybe_convert_block_hash(block_hashes[i]))
+ extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
+ request, block_start, block_end, curr_mm_idx
+ )
+ extra_keys_list.append(extra_keys)
+
+ if not cached_hashes:
+ return
+
+ # Prefix-cache hits always form a contiguous prefix starting at block 0,
+ # so the first (and thus the whole group's) parent block hash is None.
+ parent_block_hash: ExternalBlockHash | None = None
+ start_token_idx = 0
+ end_token_idx = num_cached_blocks * block_size
+
+ logger.debug(
+ "EmitCachedBlock event: block_size=%d, "
+ "num_cached_blocks=%d, parent_block_hash=%s, "
+ "token_ids_len=%d, group_idx=%s",
+ block_size,
+ num_cached_blocks,
+ parent_block_hash,
+ len(request.all_token_ids[start_token_idx:end_token_idx]),
+ kv_cache_group_id,
+ )
+
+ self.kv_event_queue.append(
+ self._build_block_stored_event(
+ request,
+ block_hashes=cached_hashes,
+ parent_block_hash=parent_block_hash,
+ start_token_idx=start_token_idx,
+ end_token_idx=end_token_idx,
+ block_size=block_size,
+ kv_cache_group_id=kv_cache_group_id,
+ extra_keys_list=extra_keys_list,
+ )
+ )
+
def cache_partial_block(
self,
request: Request,
diff --git a/vllm/v1/core/kv_cache_manager.py b/vllm/v1/core/kv_cache_manager.py
index 4b62915edeff..4d5d613866ad 100644
--- a/vllm/v1/core/kv_cache_manager.py
+++ b/vllm/v1/core/kv_cache_manager.py
@@ -136,6 +136,7 @@ def __init__(
max_in_flight_tokens = max_model_len
self.enable_caching = enable_caching
+ self.enable_kv_cache_events = enable_kv_cache_events
self.use_eagle = use_eagle
self.log_stats = log_stats
self.metrics_collector = metrics_collector
@@ -235,6 +236,26 @@ def get_computed_blocks(self, request: Request) -> tuple[KVCacheBlocks, int]:
)
)
+ # When kv_cache_report_mode is "full", emit BlockStored events
+ # for the reused prefix cache blocks so that external consumers
+ # (e.g. gateway) can learn about them.
+ if (
+ num_new_computed_tokens > 0
+ and self.enable_kv_cache_events
+ and getattr(request, "kv_cache_report_mode", "incremental") == "full"
+ ):
+ for group_idx, group_blocks in enumerate(computed_blocks):
+ num_blocks = len(group_blocks)
+ if num_blocks > 0:
+ group = self.kv_cache_config.kv_cache_groups[group_idx]
+ block_size = group.kv_cache_spec.block_size
+ self.block_pool.emit_cached_block_events(
+ request,
+ num_blocks,
+ block_size,
+ group_idx,
+ )
+
if self.log_stats:
assert self.prefix_cache_stats is not None
self.prefix_cache_stats.record(
diff --git a/vllm/v1/core/kv_cache_utils.py b/vllm/v1/core/kv_cache_utils.py
index 6e9530e381bf..27af83c9a516 100644
--- a/vllm/v1/core/kv_cache_utils.py
+++ b/vllm/v1/core/kv_cache_utils.py
@@ -2249,3 +2249,22 @@ def _get_value_at(self, idx: int) -> BlockHash:
BlockHashList = list[BlockHash] | BlockHashListWithBlockSize
+
+
+def resolve_block_hashes(
+ request: Request,
+ hash_block_size: int,
+ block_size: int,
+) -> BlockHashList:
+ """Resolve the block-hash view for ``request`` at ``block_size``.
+
+ When ``block_size`` equals ``hash_block_size``, reuse the request's
+ precomputed ``block_hashes`` directly; otherwise recalculate at
+ ``block_size`` granularity (``block_size`` must be a multiple of
+ ``hash_block_size``, which happens when KV cache groups differ in
+ block size).
+ """
+ if block_size == hash_block_size:
+ return request.block_hashes
+ assert block_size % hash_block_size == 0
+ return BlockHashListWithBlockSize(request.block_hashes, hash_block_size, block_size)
diff --git a/vllm/v1/request.py b/vllm/v1/request.py
index e9946a7f76b8..058d498d621d 100644
--- a/vllm/v1/request.py
+++ b/vllm/v1/request.py
@@ -115,6 +115,11 @@ def __init__(
self.kv_transfer_params = sampling_params.extra_args.get(
"kv_transfer_params"
)
+ self.kv_cache_report_mode = sampling_params.extra_args.get(
+ "kv_cache_report_mode", "incremental"
+ )
+ else:
+ self.kv_cache_report_mode = "incremental"
else:
raise ValueError("sampling_params and pooling_params can't both be unset")
From 5715fde12c1e28eccd08a2394a114339b12a96c1 Mon Sep 17 00:00:00 2001
From: alberto
Date: Fri, 10 Jul 2026 08:34:02 +0100
Subject: [PATCH 0009/1526] [Feature][Parser] Support include_reasoning param
for non-Harmony models (#44301)
Signed-off-by: Alberto Perdomo
Co-authored-by: Chauncey
---
docs/features/reasoning_outputs.md | 63 +++
.../chat_completion/test_include_reasoning.py | 158 ++++++
tests/parser/engine/conftest.py | 1 +
tests/parser/engine/test_nemotron_v3.py | 1 +
tests/parser/engine/test_parser_engine.py | 1 +
tests/parser/test_include_reasoning.py | 456 ++++++++++++++++++
.../openai/chat_completion/serving.py | 50 +-
vllm/entrypoints/openai/responses/context.py | 6 +-
vllm/entrypoints/openai/responses/protocol.py | 9 +
vllm/entrypoints/openai/responses/serving.py | 7 +
vllm/parser/abstract_parser.py | 19 +-
vllm/parser/engine/parser_engine.py | 10 +-
vllm/parser/harmony.py | 10 +
13 files changed, 765 insertions(+), 26 deletions(-)
create mode 100644 tests/entrypoints/openai/chat_completion/test_include_reasoning.py
create mode 100644 tests/parser/test_include_reasoning.py
diff --git a/docs/features/reasoning_outputs.md b/docs/features/reasoning_outputs.md
index 50a58b8b3e37..9495c4672ce0 100644
--- a/docs/features/reasoning_outputs.md
+++ b/docs/features/reasoning_outputs.md
@@ -351,6 +351,69 @@ print(response.choices[0].message.reasoning)
print(response.choices[0].message.content)
```
+## Suppressing Reasoning Output
+
+You can suppress reasoning content from API responses using the `include_reasoning` parameter. When set to `false`, reasoning tokens are still generated (so model quality is unaffected) but excluded from the response. This reduces network traffic without changing inference behavior.
+
+The parameter is supported in both the Chat Completions API and the Responses API, for streaming and non-streaming requests.
+
+When `include_reasoning=false`, vLLM also suppresses per-token metadata (logprobs and token IDs) to prevent leaking reasoning content through decoded token text in logprob entries or raw token IDs.
+
+### Chat Completions API
+
+```python
+from openai import OpenAI
+
+client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
+model = client.models.list().data[0].id
+
+# Reasoning is included by default (include_reasoning=True)
+response = client.chat.completions.create(
+ model=model,
+ messages=[{"role": "user", "content": "What is 15 * 37?"}],
+ extra_body={"include_reasoning": False},
+)
+
+msg = response.choices[0].message
+assert msg.content # Content is still present
+assert not getattr(msg, "reasoning", None) # Reasoning is suppressed
+```
+
+Streaming works the same way, reasoning deltas are omitted from chunks:
+
+```python
+stream = client.chat.completions.create(
+ model=model,
+ messages=[{"role": "user", "content": "What is 15 * 37?"}],
+ stream=True,
+ extra_body={"include_reasoning": False},
+)
+
+for chunk in stream:
+ delta = chunk.choices[0].delta
+ # delta.reasoning will always be None
+ if delta.content:
+ print(delta.content, end="", flush=True)
+```
+
+### Responses API
+
+```python
+from openai import OpenAI
+
+client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
+
+response = client.responses.create(
+ model=client.models.list().data[0].id,
+ input="What is 15 * 37?",
+ include_reasoning=False,
+)
+
+# No "reasoning" items in output
+types = [item.type for item in response.output]
+assert "reasoning" not in types
+```
+
## Limitations
- The reasoning content is only available for online serving's chat completion endpoint (`/v1/chat/completions`), Anthropic Messages API (`/v1/messages`) and the Responses API (`/v1/responses`).
diff --git a/tests/entrypoints/openai/chat_completion/test_include_reasoning.py b/tests/entrypoints/openai/chat_completion/test_include_reasoning.py
new file mode 100644
index 000000000000..50261cfff804
--- /dev/null
+++ b/tests/entrypoints/openai/chat_completion/test_include_reasoning.py
@@ -0,0 +1,158 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+"""E2E tests for ``include_reasoning`` with non-Harmony reasoning models.
+
+Verifies that reasoning content is included by default and suppressed
+when ``include_reasoning=False``, for both streaming and non-streaming
+Chat Completions.
+"""
+
+import openai
+import pytest
+import pytest_asyncio
+
+from tests.utils import RemoteOpenAIServer
+
+MODEL_NAME = "Qwen/Qwen3-0.6B"
+MESSAGES = [{"role": "user", "content": "What is 1+1? Be concise."}]
+
+
+@pytest.fixture(scope="module")
+def server():
+ args = [
+ "--reasoning-parser",
+ "qwen3",
+ "--max-model-len",
+ "2048",
+ "--enforce-eager",
+ "--gpu-memory-utilization",
+ "0.4",
+ ]
+ with RemoteOpenAIServer(MODEL_NAME, args) as remote_server:
+ yield remote_server
+
+
+@pytest_asyncio.fixture
+async def client(server):
+ async with server.get_async_client() as async_client:
+ yield async_client
+
+
+@pytest.mark.asyncio
+async def test_include_reasoning_true_non_streaming(client: openai.AsyncOpenAI):
+ """Default: reasoning content appears in non-streaming response."""
+ response = await client.chat.completions.create(
+ model=MODEL_NAME,
+ messages=MESSAGES,
+ max_tokens=200,
+ extra_body={"include_reasoning": True},
+ )
+
+ msg = response.choices[0].message
+ reasoning = getattr(msg, "reasoning", None) or getattr(
+ msg, "reasoning_content", None
+ )
+ assert reasoning, "Expected reasoning content when include_reasoning=True"
+ assert msg.content, "Expected content in response"
+
+
+@pytest.mark.asyncio
+async def test_include_reasoning_false_non_streaming(client: openai.AsyncOpenAI):
+ """Reasoning content is suppressed when include_reasoning=False."""
+ response = await client.chat.completions.create(
+ model=MODEL_NAME,
+ messages=MESSAGES,
+ max_tokens=200,
+ extra_body={"include_reasoning": False},
+ )
+
+ msg = response.choices[0].message
+ reasoning = getattr(msg, "reasoning", None) or getattr(
+ msg, "reasoning_content", None
+ )
+ assert not reasoning, (
+ f"Expected no reasoning when include_reasoning=False, got: {reasoning}"
+ )
+ assert msg.content, "Expected content in response even without reasoning"
+
+
+@pytest.mark.asyncio
+async def test_include_reasoning_true_streaming(client: openai.AsyncOpenAI):
+ """Default: reasoning deltas appear in streaming response."""
+ stream = await client.chat.completions.create(
+ model=MODEL_NAME,
+ messages=MESSAGES,
+ max_tokens=200,
+ stream=True,
+ extra_body={"include_reasoning": True},
+ )
+
+ reasoning_parts = []
+ content_parts = []
+ async for chunk in stream:
+ delta = chunk.choices[0].delta if chunk.choices else None
+ if delta:
+ r = getattr(delta, "reasoning", None) or getattr(
+ delta, "reasoning_content", None
+ )
+ if r:
+ reasoning_parts.append(r)
+ if delta.content:
+ content_parts.append(delta.content)
+
+ reasoning_text = "".join(reasoning_parts)
+ content_text = "".join(content_parts)
+
+ assert reasoning_text, "Expected reasoning deltas when include_reasoning=True"
+ assert content_text, "Expected content deltas in streaming response"
+
+
+@pytest.mark.asyncio
+async def test_include_reasoning_false_streaming(client: openai.AsyncOpenAI):
+ """Reasoning deltas are suppressed in streaming when include_reasoning=False."""
+ stream = await client.chat.completions.create(
+ model=MODEL_NAME,
+ messages=MESSAGES,
+ max_tokens=200,
+ stream=True,
+ extra_body={"include_reasoning": False},
+ )
+
+ reasoning_parts = []
+ content_parts = []
+ async for chunk in stream:
+ delta = chunk.choices[0].delta if chunk.choices else None
+ if delta:
+ r = getattr(delta, "reasoning", None) or getattr(
+ delta, "reasoning_content", None
+ )
+ if r:
+ reasoning_parts.append(r)
+ if delta.content:
+ content_parts.append(delta.content)
+
+ reasoning_text = "".join(reasoning_parts)
+ content_text = "".join(content_parts)
+
+ assert not reasoning_text, (
+ f"Expected no reasoning deltas when include_reasoning=False, "
+ f"got: {reasoning_text[:100]}"
+ )
+ assert content_text, "Expected content deltas even without reasoning"
+
+
+@pytest.mark.asyncio
+async def test_default_includes_reasoning(client: openai.AsyncOpenAI):
+ """Without specifying include_reasoning, reasoning appears (default=True)."""
+ response = await client.chat.completions.create(
+ model=MODEL_NAME,
+ messages=MESSAGES,
+ max_tokens=200,
+ )
+
+ msg = response.choices[0].message
+ reasoning = getattr(msg, "reasoning", None) or getattr(
+ msg, "reasoning_content", None
+ )
+ assert reasoning, "Expected reasoning content by default"
diff --git a/tests/parser/engine/conftest.py b/tests/parser/engine/conftest.py
index 157234c852aa..2522d146c895 100644
--- a/tests/parser/engine/conftest.py
+++ b/tests/parser/engine/conftest.py
@@ -49,4 +49,5 @@ def mock_request():
req = MagicMock(spec=ChatCompletionRequest)
req.tools = []
req.tool_choice = "auto"
+ req.include_reasoning = True
return req
diff --git a/tests/parser/engine/test_nemotron_v3.py b/tests/parser/engine/test_nemotron_v3.py
index 6aedcd1513b2..a7f166f9f3a9 100644
--- a/tests/parser/engine/test_nemotron_v3.py
+++ b/tests/parser/engine/test_nemotron_v3.py
@@ -43,6 +43,7 @@ def _make_request(**chat_template_kwargs):
request = MagicMock(spec=ChatCompletionRequest)
request.tools = []
request.tool_choice = "auto"
+ request.include_reasoning = True
request.chat_template_kwargs = chat_template_kwargs or None
return request
diff --git a/tests/parser/engine/test_parser_engine.py b/tests/parser/engine/test_parser_engine.py
index 635f51196a50..36258668215b 100644
--- a/tests/parser/engine/test_parser_engine.py
+++ b/tests/parser/engine/test_parser_engine.py
@@ -896,6 +896,7 @@ def _make_delegating_request():
req = MagicMock(spec=ChatCompletionRequest)
req.tools = []
req.tool_choice = "auto"
+ req.include_reasoning = True
return req
diff --git a/tests/parser/test_include_reasoning.py b/tests/parser/test_include_reasoning.py
new file mode 100644
index 000000000000..3d1893577ef0
--- /dev/null
+++ b/tests/parser/test_include_reasoning.py
@@ -0,0 +1,456 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Tests for include_reasoning suppression in the unified Parser interface.
+
+Covers non-streaming (parser.parse() + build_response_output_items),
+streaming (parse_delta), and ParsableContext.append_output() paths.
+"""
+
+import json
+import os
+
+import pytest
+
+_STRICT_TOOL_CALLING_ENV = "VLLM_ENFORCE_STRICT_TOOL_CALLING"
+_STRICT_TOOL_CALLING_ENV_VALUE = os.environ.get(_STRICT_TOOL_CALLING_ENV)
+os.environ[_STRICT_TOOL_CALLING_ENV] = "0"
+
+from vllm.entrypoints.openai.chat_completion.protocol import ( # noqa: E402
+ ChatCompletionRequest,
+)
+from vllm.entrypoints.openai.engine.protocol import DeltaMessage # noqa: E402
+from vllm.entrypoints.openai.responses.protocol import ResponsesRequest # noqa: E402
+from vllm.parser.abstract_parser import DelegatingParser # noqa: E402
+from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser # noqa: E402
+from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser # noqa: E402
+
+
+@pytest.fixture(scope="module", autouse=True)
+def restore_strict_tool_calling_env():
+ yield
+ if _STRICT_TOOL_CALLING_ENV_VALUE is None:
+ os.environ.pop(_STRICT_TOOL_CALLING_ENV, None)
+ else:
+ os.environ[_STRICT_TOOL_CALLING_ENV] = _STRICT_TOOL_CALLING_ENV_VALUE
+
+
+class ThinkReasoningParser(BaseThinkingReasoningParser):
+ @property
+ def start_token(self) -> str:
+ return ""
+
+ @property
+ def end_token(self) -> str:
+ return ""
+
+
+MODEL_OUTPUT_REASONING_AND_CONTENT = (
+ "let me think about thisThe answer is 42."
+)
+
+MODEL_OUTPUT_REASONING_AND_TOOL = (
+ "I need to call a tool"
+ '\n{"name": "get_weather", '
+ '"arguments": {"city": "Dallas"}}\n'
+)
+
+MODEL_OUTPUT_CONTENT_ONLY = "The answer is 42."
+
+
+@pytest.fixture(scope="module")
+def tokenizer():
+ from vllm.tokenizers import get_tokenizer
+
+ return get_tokenizer("Qwen/Qwen3-32B")
+
+
+def make_responses_request(**kwargs) -> ResponsesRequest:
+ defaults = dict(model="test-model", input="test input")
+ defaults.update(kwargs)
+ return ResponsesRequest(**defaults)
+
+
+def make_chat_request(**kwargs) -> ChatCompletionRequest:
+ defaults = dict(
+ model="test-model",
+ messages=[{"role": "user", "content": "hi"}],
+ )
+ defaults.update(kwargs)
+ return ChatCompletionRequest(**defaults)
+
+
+def make_parser(tokenizer, reasoning=False, tool=False):
+ class TestParser(DelegatingParser):
+ reasoning_parser_cls = ThinkReasoningParser if reasoning else None
+ tool_parser_cls = Hermes2ProToolParser if tool else None
+
+ return TestParser(tokenizer)
+
+
+# ── Non-streaming: parser.parse() + build_response_output_items ──────
+
+
+def parse_and_build(parser, request, model_output, enable_auto_tools=False):
+ """Mirrors the non-streaming path in _make_response_output_items /
+ ParsableContext.append_output(): parse → suppress reasoning → build items.
+ """
+ from vllm.entrypoints.openai.responses.utils import (
+ build_response_output_items,
+ )
+
+ reasoning, content, tool_calls = parser.parse(
+ model_output, request, enable_auto_tools=enable_auto_tools
+ )
+ if not request.include_reasoning:
+ reasoning = None
+ return build_response_output_items(
+ reasoning=reasoning,
+ content=content,
+ tool_calls=tool_calls,
+ )
+
+
+class TestNonStreamingIncludeReasoning:
+ def test_include_reasoning_true_has_reasoning_item(self, tokenizer):
+ """Default: reasoning items appear in output."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_responses_request(include_reasoning=True)
+
+ outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
+
+ types = [o.type for o in outputs]
+ assert "reasoning" in types
+ assert "message" in types
+
+ def test_include_reasoning_false_no_reasoning_item(self, tokenizer):
+ """Reasoning item is suppressed when include_reasoning=False."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_responses_request(include_reasoning=False)
+
+ outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
+
+ types = [o.type for o in outputs]
+ assert "reasoning" not in types
+ assert "message" in types
+ assert outputs[0].content[0].text == "The answer is 42."
+
+ def test_include_reasoning_false_content_preserved(self, tokenizer):
+ """Content is extracted correctly even when reasoning is suppressed."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_responses_request(include_reasoning=False)
+
+ outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
+
+ message = next(o for o in outputs if o.type == "message")
+ assert message.content[0].text == "The answer is 42."
+
+ def test_include_reasoning_false_tool_calls_preserved(self, tokenizer):
+ """Tool calls still work when reasoning is suppressed."""
+ parser = make_parser(tokenizer, reasoning=True, tool=True)
+ request = make_responses_request(
+ include_reasoning=False,
+ tools=[
+ {
+ "type": "function",
+ "name": "get_weather",
+ "parameters": {
+ "type": "object",
+ "properties": {"city": {"type": "string"}},
+ },
+ }
+ ],
+ )
+
+ outputs = parse_and_build(
+ parser,
+ request,
+ MODEL_OUTPUT_REASONING_AND_TOOL,
+ enable_auto_tools=True,
+ )
+
+ types = [o.type for o in outputs]
+ assert "reasoning" not in types
+ assert "function_call" in types
+ fc = next(o for o in outputs if o.type == "function_call")
+ assert fc.name == "get_weather"
+ assert json.loads(fc.arguments) == {"city": "Dallas"}
+
+ def test_no_reasoning_parser_include_false_is_noop(self, tokenizer):
+ """include_reasoning=False is harmless when no reasoning parser."""
+ parser = make_parser(tokenizer, reasoning=False)
+ request = make_responses_request(include_reasoning=False)
+
+ outputs = parse_and_build(parser, request, MODEL_OUTPUT_CONTENT_ONLY)
+
+ assert len(outputs) == 1
+ assert outputs[0].type == "message"
+ assert outputs[0].content[0].text == MODEL_OUTPUT_CONTENT_ONLY
+
+ def test_default_include_reasoning_is_true(self, tokenizer):
+ """ResponsesRequest defaults to include_reasoning=True."""
+ request = make_responses_request()
+ assert request.include_reasoning is True
+
+ def test_include_reasoning_false_suppresses_all_reasoning(self, tokenizer):
+ """Reasoning is suppressed regardless of request type."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_responses_request(include_reasoning=False)
+
+ outputs = parse_and_build(parser, request, MODEL_OUTPUT_REASONING_AND_CONTENT)
+
+ assert all(o.type != "reasoning" for o in outputs)
+
+
+# ── Streaming: parse_delta ───────────────────────────────────────────
+
+
+def stream_text(parser, tokenizer, text, request, prompt_token_ids=None):
+ token_ids = tokenizer.encode(text, add_special_tokens=False)
+ results: list[DeltaMessage | None] = []
+ for i, tid in enumerate(token_ids):
+ delta_text = tokenizer.decode([tid])
+ is_last = i == len(token_ids) - 1
+ result = parser.parse_delta(
+ delta_text,
+ [tid],
+ request,
+ prompt_token_ids=prompt_token_ids,
+ finished=is_last,
+ )
+ prompt_token_ids = None
+ results.append(result)
+ return results
+
+
+def collect_fields(results):
+ all_reasoning = "".join(r.reasoning for r in results if r and r.reasoning)
+ all_content = "".join(r.content for r in results if r and r.content)
+ all_tool_calls = [tc for r in results if r and r.tool_calls for tc in r.tool_calls]
+ return all_reasoning, all_content, all_tool_calls
+
+
+class TestParseDeltaIncludeReasoning:
+ def test_streaming_include_true_emits_reasoning(self, tokenizer):
+ """With include_reasoning=True, reasoning deltas are emitted."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_responses_request(include_reasoning=True)
+
+ results = stream_text(
+ parser,
+ tokenizer,
+ MODEL_OUTPUT_REASONING_AND_CONTENT,
+ request,
+ prompt_token_ids=[],
+ )
+ reasoning, content, _ = collect_fields(results)
+
+ assert "let me think about this" in reasoning
+ assert "42" in content
+
+ def test_streaming_include_false_suppresses_reasoning(self, tokenizer):
+ """With include_reasoning=False, no reasoning deltas are emitted."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_responses_request(include_reasoning=False)
+
+ results = stream_text(
+ parser,
+ tokenizer,
+ MODEL_OUTPUT_REASONING_AND_CONTENT,
+ request,
+ prompt_token_ids=[],
+ )
+ reasoning, content, _ = collect_fields(results)
+
+ assert reasoning == ""
+ assert "42" in content
+
+ def test_streaming_include_false_content_still_works(self, tokenizer):
+ """Content is correctly extracted in streaming even with suppression."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_responses_request(include_reasoning=False)
+
+ results = stream_text(
+ parser,
+ tokenizer,
+ MODEL_OUTPUT_REASONING_AND_CONTENT,
+ request,
+ prompt_token_ids=[],
+ )
+ _, content, _ = collect_fields(results)
+
+ assert "The answer is 42" in content
+
+ def test_streaming_include_false_tool_calls_preserved(self, tokenizer):
+ """Tool calls stream correctly when reasoning is suppressed."""
+ parser = make_parser(tokenizer, reasoning=True, tool=True)
+ request = make_responses_request(
+ include_reasoning=False,
+ tools=[
+ {
+ "type": "function",
+ "name": "get_weather",
+ "parameters": {
+ "type": "object",
+ "properties": {"city": {"type": "string"}},
+ },
+ }
+ ],
+ )
+
+ results = stream_text(
+ parser,
+ tokenizer,
+ MODEL_OUTPUT_REASONING_AND_TOOL,
+ request,
+ prompt_token_ids=[],
+ )
+ reasoning, content, tool_calls = collect_fields(results)
+
+ assert reasoning == ""
+ assert len(tool_calls) > 0
+ assert tool_calls[0].function.name == "get_weather"
+ tool_args = "".join(
+ tc.function.arguments for tc in tool_calls if tc.function.arguments
+ )
+ assert json.loads(tool_args) == {"city": "Dallas"}
+
+ def test_streaming_no_reasoning_parser_include_false(self, tokenizer):
+ """No crash when reasoning parser absent and include_reasoning=False."""
+ parser = make_parser(tokenizer, reasoning=False)
+ request = make_responses_request(include_reasoning=False)
+
+ results = stream_text(
+ parser,
+ tokenizer,
+ MODEL_OUTPUT_CONTENT_ONLY,
+ request,
+ prompt_token_ids=[],
+ )
+ reasoning, content, _ = collect_fields(results)
+
+ assert reasoning == ""
+ assert "42" in content
+
+ def test_streaming_chat_completion_include_false(self, tokenizer):
+ """parse_delta also respects ChatCompletionRequest.include_reasoning."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_chat_request(include_reasoning=False)
+
+ results = stream_text(
+ parser,
+ tokenizer,
+ MODEL_OUTPUT_REASONING_AND_CONTENT,
+ request,
+ prompt_token_ids=[],
+ )
+ reasoning, content, _ = collect_fields(results)
+
+ assert reasoning == ""
+ assert "42" in content
+
+ def test_streaming_reasoning_only_deltas_become_none(self, tokenizer):
+ """Deltas that carry only reasoning become None (not empty)."""
+ parser = make_parser(tokenizer, reasoning=True)
+ request = make_responses_request(include_reasoning=False)
+
+ results = stream_text(
+ parser,
+ tokenizer,
+ MODEL_OUTPUT_REASONING_AND_CONTENT,
+ request,
+ prompt_token_ids=[],
+ )
+
+ for r in results:
+ if r is not None:
+ assert r.reasoning is None
+
+
+# ── ParsableContext.append_output() ───────────────────────────────────
+
+
+class TestParsableContextIncludeReasoning:
+ def _make_context(self, tokenizer, request):
+ from vllm.entrypoints.openai.responses.context import ParsableContext
+
+ class TestParser(DelegatingParser):
+ reasoning_parser_cls = ThinkReasoningParser
+ tool_parser_cls = None
+
+ return ParsableContext(
+ tokenizer=tokenizer,
+ parser_cls=TestParser,
+ response_messages=[],
+ request=request,
+ available_tools=None,
+ chat_template=None,
+ chat_template_content_format="auto",
+ )
+
+ def test_process_include_false_suppresses_reasoning(self, tokenizer):
+ """ParsableContext.process() suppresses reasoning items."""
+ from vllm.outputs import CompletionOutput, RequestOutput
+
+ request = make_responses_request(include_reasoning=False)
+ ctx = self._make_context(tokenizer, request)
+
+ output = RequestOutput(
+ request_id="test",
+ prompt=None,
+ prompt_token_ids=[],
+ prompt_logprobs=None,
+ outputs=[
+ CompletionOutput(
+ index=0,
+ text=MODEL_OUTPUT_REASONING_AND_CONTENT,
+ token_ids=tokenizer.encode(
+ MODEL_OUTPUT_REASONING_AND_CONTENT,
+ add_special_tokens=False,
+ ),
+ cumulative_logprob=None,
+ logprobs=None,
+ finish_reason="stop",
+ )
+ ],
+ finished=True,
+ )
+
+ ctx.append_output(output)
+
+ types = [getattr(m, "type", None) for m in ctx.response_messages]
+ assert "reasoning" not in types
+ assert "message" in types
+
+ def test_process_include_true_has_reasoning(self, tokenizer):
+ """ParsableContext.process() includes reasoning by default."""
+ from vllm.outputs import CompletionOutput, RequestOutput
+
+ request = make_responses_request(include_reasoning=True)
+ ctx = self._make_context(tokenizer, request)
+
+ output = RequestOutput(
+ request_id="test",
+ prompt=None,
+ prompt_token_ids=[],
+ prompt_logprobs=None,
+ outputs=[
+ CompletionOutput(
+ index=0,
+ text=MODEL_OUTPUT_REASONING_AND_CONTENT,
+ token_ids=tokenizer.encode(
+ MODEL_OUTPUT_REASONING_AND_CONTENT,
+ add_special_tokens=False,
+ ),
+ cumulative_logprob=None,
+ logprobs=None,
+ finish_reason="stop",
+ )
+ ],
+ finished=True,
+ )
+
+ ctx.append_output(output)
+
+ types = [getattr(m, "type", None) for m in ctx.response_messages]
+ assert "reasoning" in types
+ assert "message" in types
diff --git a/vllm/entrypoints/openai/chat_completion/serving.py b/vllm/entrypoints/openai/chat_completion/serving.py
index caa3f724da45..eddcf014afee 100644
--- a/vllm/entrypoints/openai/chat_completion/serving.py
+++ b/vllm/entrypoints/openai/chat_completion/serving.py
@@ -600,19 +600,8 @@ async def chat_completion_stream_generator(
prompt_token_ids=res.prompt_token_ids,
finished=output.finish_reason is not None,
)
- if delta_message is not None:
- if delta_message.tool_calls:
- tools_streamed[i] = True
-
- if (
- delta_message.reasoning
- and not request.include_reasoning
- ):
- delta_message.reasoning = None
- if not (
- delta_message.content or delta_message.tool_calls
- ):
- delta_message = None
+ if delta_message is not None and delta_message.tool_calls:
+ tools_streamed[i] = True
# handle streaming just a content delta (no parsers)
else:
@@ -627,13 +616,22 @@ async def chat_completion_stream_generator(
# "control token" for tool calls or the parser otherwise
# wasn't ready to send a token, then
# get the next token without streaming a chunk
+ # When reasoning is hidden, suppress per-token
+ # metadata (logprobs, token_ids) on every chunk to
+ # prevent leaking reasoning tokens through decoded
+ # token text in logprob entries or raw token IDs.
+ hide_stream_metadata = (
+ not request.include_reasoning and parser is not None
+ )
+ if hide_stream_metadata:
+ logprobs = None
+
if delta_message is None:
# NOTE: If return_token_ids is enabled, we still need to
# send a chunk with token_ids even if delta_message is None
# to ensure all tokens are included in the response
- if (
- output.finish_reason is None
- and not request.return_token_ids
+ if output.finish_reason is None and (
+ not request.return_token_ids or hide_stream_metadata
):
continue
delta_message = DeltaMessage()
@@ -666,6 +664,10 @@ async def chat_completion_stream_generator(
delta=True,
)
+ include_token_ids = (
+ request.return_token_ids and not hide_stream_metadata
+ )
+
if output.finish_reason is None:
# Send token-by-token response for each request.n
choice_data = ChatCompletionResponseStreamChoice(
@@ -674,9 +676,7 @@ async def chat_completion_stream_generator(
logprobs=logprobs,
finish_reason=None,
token_ids=(
- as_list(output.token_ids)
- if request.return_token_ids
- else None
+ as_list(output.token_ids) if include_token_ids else None
),
)
@@ -704,9 +704,7 @@ async def chat_completion_stream_generator(
finish_reason=finish_reason_,
stop_reason=output.stop_reason,
token_ids=(
- as_list(output.token_ids)
- if request.return_token_ids
- else None
+ as_list(output.token_ids) if include_token_ids else None
),
)
@@ -883,12 +881,16 @@ async def chat_completion_full_generator(
enable_auto_tools=self.enable_auto_tools,
model_output_token_ids=token_ids,
)
+ suppress_metadata = not request.include_reasoning and parser is not None
if not request.include_reasoning:
reasoning = None
+ if suppress_metadata:
+ logprobs = None
else:
reasoning = None
content = output.text
tool_calls = []
+ suppress_metadata = False
auto_tools_called = False
is_named_tool_choice = (
@@ -982,7 +984,9 @@ async def chat_completion_full_generator(
else "stop",
stop_reason=output.stop_reason,
token_ids=(
- as_list(output.token_ids) if request.return_token_ids else None
+ as_list(output.token_ids)
+ if request.return_token_ids and not suppress_metadata
+ else None
),
routed_experts=routed_experts_b64,
)
diff --git a/vllm/entrypoints/openai/responses/context.py b/vllm/entrypoints/openai/responses/context.py
index ed989e2ba9f4..e1d3f4cf7ef4 100644
--- a/vllm/entrypoints/openai/responses/context.py
+++ b/vllm/entrypoints/openai/responses/context.py
@@ -310,7 +310,9 @@ def __init__(
self.finish_reason: str | None = None
self.enable_auto_tools = enable_auto_tools
- self.response_parser = response_parser
+ self.response_parser = response_parser or (
+ parser_cls(tokenizer, request.tools) if parser_cls is not None else None
+ )
self.parser_cls = parser_cls
self.request = request
@@ -344,6 +346,8 @@ def append_output(self, output: RequestOutput) -> None:
enable_auto_tools=self.enable_auto_tools,
model_output_token_ids=completion.token_ids,
)
+ if not self.request.include_reasoning:
+ reasoning = None
self.response_messages.extend(
build_response_output_items(
reasoning=reasoning,
diff --git a/vllm/entrypoints/openai/responses/protocol.py b/vllm/entrypoints/openai/responses/protocol.py
index ba8bc5a40f1b..423068c71ca8 100644
--- a/vllm/entrypoints/openai/responses/protocol.py
+++ b/vllm/entrypoints/openai/responses/protocol.py
@@ -161,6 +161,15 @@ class ResponsesRequest(OpenAIBaseModel):
previous_response_id: str | None = None
prompt: ResponsePrompt | None = None
reasoning: Reasoning | None = None
+ include_reasoning: bool = Field(
+ default=True,
+ description=(
+ "Whether to include reasoning content in the response. "
+ "When false, reasoning tokens are still generated but "
+ "excluded from the output. This reduces network traffic "
+ "without affecting model inference."
+ ),
+ )
service_tier: Literal["auto", "default", "flex", "scale", "priority"] = "auto"
store: bool | None = True
stream: bool | None = False
diff --git a/vllm/entrypoints/openai/responses/serving.py b/vllm/entrypoints/openai/responses/serving.py
index 40a520127920..3926284d701f 100644
--- a/vllm/entrypoints/openai/responses/serving.py
+++ b/vllm/entrypoints/openai/responses/serving.py
@@ -1068,6 +1068,9 @@ def _make_response_output_items(
enable_auto_tools=self.enable_auto_tools,
model_output_token_ids=final_output.token_ids,
)
+ if not request.include_reasoning:
+ reasoning = None
+ logprobs = None
return build_response_output_items(
reasoning=reasoning,
content=content,
@@ -1342,11 +1345,15 @@ async def _process_simple_streaming_events(
) -> AsyncGenerator[StreamingResponsesResponse, None]:
processor = SimpleStreamingEventProcessor(tools=request.tools)
+ hide_stream_metadata = not request.include_reasoning and self.parser is not None
+
def _get_logprobs(
output: CompletionOutput,
) -> list[response_text_delta_event.Logprob]:
if not request.is_include_output_logprobs():
return []
+ if hide_stream_metadata:
+ return []
return self._create_stream_response_logprobs(
token_ids=output.token_ids,
logprobs=output.logprobs,
diff --git a/vllm/parser/abstract_parser.py b/vllm/parser/abstract_parser.py
index 639aec0fa992..b643cbfc1c66 100644
--- a/vllm/parser/abstract_parser.py
+++ b/vllm/parser/abstract_parser.py
@@ -447,7 +447,7 @@ def _extract_tool_calls(
tool_calls = list[FunctionCall]()
if is_named_tool_choice and supports_required_and_named:
- if content is None:
+ if content is None or (isinstance(content, str) and not content.strip()):
return [], None
function_name = self._get_function_name(request)
tool_calls.append(
@@ -496,6 +496,14 @@ def _extract_tool_calls(
content = None
else:
# No tool calls.
+ # For required/named tool choice (when falling back to auto
+ # parsing), if content is empty or whitespace-only, return
+ # empty list with None content.
+ if (is_required_tool_choice or is_named_tool_choice) and (
+ content is None
+ or (isinstance(content, str) and not content.strip())
+ ):
+ return [], None
return None, content
return tool_calls, content
@@ -917,6 +925,15 @@ def parse_delta(
delta_message = self.finalize_generation(delta_message, request, state)
delta_message = self._flush_engine_parsers(delta_message)
+ # Suppress reasoning deltas if not requested
+ if delta_message and not request.include_reasoning:
+ delta_message.reasoning = None
+
+ # If only reasoning was in the message (no content, no tool_calls)
+ # skip emitting entirely
+ if not delta_message.content and not delta_message.tool_calls:
+ delta_message = None
+
return delta_message
def _flush_engine_parsers(
diff --git a/vllm/parser/engine/parser_engine.py b/vllm/parser/engine/parser_engine.py
index 6f21cbca768a..fc2d653779ed 100644
--- a/vllm/parser/engine/parser_engine.py
+++ b/vllm/parser/engine/parser_engine.py
@@ -443,7 +443,15 @@ def parse_delta(
if finished:
events.extend(self._engine.finish())
result = self._events_to_delta(events, finished=finished)
- return self._strip_trailing_reasoning(result)
+ result = self._strip_trailing_reasoning(result)
+
+ # Suppress reasoning deltas if not requested
+ if result and not request.include_reasoning:
+ result.reasoning = None
+ if not result.content and not result.tool_calls:
+ result = None
+
+ return result
def _strip_trailing_reasoning(
self,
diff --git a/vllm/parser/harmony.py b/vllm/parser/harmony.py
index 5043ca191f3e..1442246f139c 100644
--- a/vllm/parser/harmony.py
+++ b/vllm/parser/harmony.py
@@ -293,6 +293,16 @@ def parse_delta(
delta_message.reasoning = combined_reasoning
if tool_messages:
delta_message.tool_calls = tool_messages
+
+ # Suppress reasoning deltas if not requested
+ if delta_message and not request.include_reasoning:
+ delta_message.reasoning = None
+
+ # If only reasoning was in the message (no content, no tool_calls)
+ # skip emitting entirely
+ if not delta_message.content and not delta_message.tool_calls:
+ return None
+
return delta_message
def process_chunk(self, token_ids: Sequence[int]) -> ChunkResult:
From 300e33797fac8a949a0ed89fab2633de96e65fa8 Mon Sep 17 00:00:00 2001
From: Jiangyun Zhu
Date: Fri, 10 Jul 2026 15:37:51 +0800
Subject: [PATCH 0010/1526] [Perf] fuse more rmsnorm and all-reduce in qwen3.5
(#46998)
Signed-off-by: zjy0516
---
.../layers/mamba/gdn/qwen_gdn_linear_attn.py | 36 +++++++++----------
vllm/model_executor/models/qwen3_next.py | 16 +++------
2 files changed, 21 insertions(+), 31 deletions(-)
diff --git a/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py b/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
index 06bfe5c5de27..6e10b9a9932b 100644
--- a/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
+++ b/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
@@ -844,17 +844,14 @@ def rearrange_mixed_qkv(self, mixed_qkv):
def forward(
self,
hidden_states: torch.Tensor,
- output: torch.Tensor,
- ):
- self._forward_method(hidden_states, output)
+ ) -> torch.Tensor:
+ return self._forward_method(hidden_states)
def _output_projection(
self,
core_attn_out: torch.Tensor,
z: torch.Tensor,
- output: torch.Tensor,
- num_tokens: int,
- ):
+ ) -> torch.Tensor:
"""Part 3: RMSNormGated + output linear projection.
The RMSNormGated + quant sequence is eligible for fusion
@@ -866,13 +863,13 @@ def _output_projection(
core_attn_out = self.norm(core_attn_out, z)
core_attn_out = core_attn_out.reshape(z_shape_og)
core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
- output[:num_tokens], _ = self.out_proj(core_attn_out)
+ output, _ = self.out_proj(core_attn_out)
+ return output
def forward_hip(
self,
hidden_states: torch.Tensor,
- output: torch.Tensor,
- ):
+ ) -> torch.Tensor:
"""ROCm forward using AITER Triton fused projection+attention when
available, otherwise falling back to the generic CUDA path."""
if GDN_AITER_TRITON_AVAILABLE:
@@ -901,15 +898,14 @@ def forward_hip(
use_aiter=True,
)
- self._output_projection(core_attn_out, z, output, num_tokens)
+ return self._output_projection(core_attn_out, z)
else:
- self.forward_cuda(hidden_states, output)
+ return self.forward_cuda(hidden_states)
def forward_cuda(
self,
hidden_states: torch.Tensor,
- output: torch.Tensor,
- ):
+ ) -> torch.Tensor:
"""
Forward pass with three parts:
1. Input projection
@@ -964,13 +960,12 @@ def forward_cuda(
# ============================================================
# Part 3: Output Projection
# ============================================================
- self._output_projection(core_attn_out, z, output, num_tokens)
+ return self._output_projection(core_attn_out, z)
def forward_xpu(
self,
hidden_states: torch.Tensor,
- output: torch.Tensor,
- ):
+ ) -> torch.Tensor:
"""
Forward pass with three parts:
1. Input projection
@@ -1013,13 +1008,13 @@ def forward_xpu(
core_attn_out = self.norm(core_attn_out, z)
core_attn_out = core_attn_out.reshape(z_shape_og)
core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
- output[:num_tokens], _ = self.out_proj(core_attn_out)
+ out, _ = self.out_proj(core_attn_out)
+ return out
def forward_cpu(
self,
hidden_states: torch.Tensor,
- output: torch.Tensor,
- ):
+ ) -> torch.Tensor:
assert not hasattr(self, "in_proj_qkv"), "lora isn't supported on CPU."
mixed_qkvz, _ = self.in_proj_qkvz(hidden_states)
@@ -1063,7 +1058,8 @@ def forward_cpu(
core_attn_out = self.norm(core_attn_out, z)
core_attn_out = core_attn_out.reshape(z_shape_og)
core_attn_out = core_attn_out.flatten(-2) # ... h d -> ... (h d)
- output[:num_tokens], _ = self.out_proj(core_attn_out)
+ out, _ = self.out_proj(core_attn_out)
+ return out
def _warmup_prefill_kernels(self, qkv_or_qkvz: torch.Tensor, v_dim: int) -> None:
"""Warm up GDN prefill kernels during V1 profiling.
diff --git a/vllm/model_executor/models/qwen3_next.py b/vllm/model_executor/models/qwen3_next.py
index ef320dd526b7..9a18cd4aad7b 100644
--- a/vllm/model_executor/models/qwen3_next.py
+++ b/vllm/model_executor/models/qwen3_next.py
@@ -381,15 +381,15 @@ def _project_qkv_gate(
def forward(
self,
positions: torch.Tensor,
- output: torch.Tensor,
hidden_states: torch.Tensor,
- ):
+ ) -> torch.Tensor:
qkv, _ = self.qkv_proj(hidden_states)
q, k, v, gate = self._project_qkv_gate(qkv, positions)
attn_output = self.attn(q, k, v)
if gate is not None:
attn_output = attn_output * torch.sigmoid(gate)
- output[:], _ = self.o_proj(attn_output)
+ output, _ = self.o_proj(attn_output)
+ return output
class Qwen3NextDecoderLayer(nn.Module):
@@ -484,21 +484,15 @@ def forward(
else:
hidden_states, residual = self.input_layernorm(hidden_states, residual)
- self_attention_output = torch.empty_like(hidden_states)
if self.layer_type == "linear_attention":
- self.linear_attn(
- hidden_states=hidden_states,
- output=self_attention_output,
- )
+ hidden_states = self.linear_attn(hidden_states=hidden_states)
elif self.layer_type == "full_attention":
- self.self_attn(
+ hidden_states = self.self_attn(
hidden_states=hidden_states,
- output=self_attention_output,
positions=positions,
)
else:
raise ValueError("Invalid layer_type")
- hidden_states = self_attention_output
if self.layer_scale:
if len(hidden_states.shape) == 2:
From 28eaf05d5690a0c0d20e0999e3aa03a81102dfab Mon Sep 17 00:00:00 2001
From: Chaojun Zhang
Date: Fri, 10 Jul 2026 15:40:51 +0800
Subject: [PATCH 0011/1526] [XPU] Enable v1/sample tests on XPU CI (#44472)
Signed-off-by: Chaojun Zhang
---
.buildkite/intel_jobs/misc_intel.yaml | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/.buildkite/intel_jobs/misc_intel.yaml b/.buildkite/intel_jobs/misc_intel.yaml
index 656df8791b9e..55c9b9d9b424 100644
--- a/.buildkite/intel_jobs/misc_intel.yaml
+++ b/.buildkite/intel_jobs/misc_intel.yaml
@@ -72,9 +72,7 @@ steps:
pytest -v -s v1/test_oracle.py &&
pytest -v -s v1/test_request.py &&
pytest -v -s v1/test_outputs.py &&
- pytest -v -s v1/sample/test_topk_topp_sampler.py &&
- pytest -v -s v1/sample/test_logprobs.py &&
- pytest -v -s v1/sample/test_logprobs_e2e.py'
+ pytest -v -s v1/sample'
- label: Basic Models Tests (Initialization)
timeout_in_minutes: 60
From 433f291195ded3ca8d278bc78da9280c5d4e5329 Mon Sep 17 00:00:00 2001
From: "Kevin H. Luu"
Date: Fri, 10 Jul 2026 00:53:16 -0700
Subject: [PATCH 0012/1526] [CI] Right-size test-area timeouts from nightly
durations (#48186)
Signed-off-by: khluu
Co-authored-by: Claude Opus 4.8
---
.buildkite/test_areas/attention.yaml | 6 +--
.buildkite/test_areas/basic_correctness.yaml | 4 +-
.buildkite/test_areas/benchmarks.yaml | 4 +-
.buildkite/test_areas/compile.yaml | 22 +++++------
.buildkite/test_areas/cuda.yaml | 4 +-
.buildkite/test_areas/disaggregated.yaml | 24 ++++++------
.buildkite/test_areas/distributed.yaml | 20 +++++-----
.buildkite/test_areas/docker.yaml | 2 +-
.buildkite/test_areas/e2e_integration.yaml | 10 ++---
.buildkite/test_areas/engine.yaml | 22 +++++------
.buildkite/test_areas/entrypoints.yaml | 20 +++++-----
.buildkite/test_areas/expert_parallelism.yaml | 6 +--
.buildkite/test_areas/kernels.yaml | 38 +++++++++----------
.buildkite/test_areas/lm_eval.yaml | 38 +++++++++----------
.buildkite/test_areas/lora.yaml | 6 +--
.buildkite/test_areas/misc.yaml | 30 +++++++--------
.buildkite/test_areas/model_executor.yaml | 2 +-
.buildkite/test_areas/model_runner_v2.yaml | 8 ++--
.buildkite/test_areas/models_basic.yaml | 8 ++--
.buildkite/test_areas/models_distributed.yaml | 2 +-
.buildkite/test_areas/models_language.yaml | 16 ++++----
.buildkite/test_areas/models_multimodal.yaml | 14 +++----
.buildkite/test_areas/plugins.yaml | 2 +-
.buildkite/test_areas/pytorch.yaml | 10 ++---
.buildkite/test_areas/quantization.yaml | 8 ++--
.buildkite/test_areas/rust_frontend.yaml | 10 ++---
.../test_areas/rust_frontend_cargo.yaml | 4 +-
.buildkite/test_areas/samplers.yaml | 2 +-
.buildkite/test_areas/spec_decode.yaml | 22 +++++------
.buildkite/test_areas/weight_loading.yaml | 2 +-
30 files changed, 183 insertions(+), 183 deletions(-)
diff --git a/.buildkite/test_areas/attention.yaml b/.buildkite/test_areas/attention.yaml
index 01e43b501497..9c662fec1afc 100644
--- a/.buildkite/test_areas/attention.yaml
+++ b/.buildkite/test_areas/attention.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: V1 attention (H100-MI300)
key: v1-attention-h100-mi300
- timeout_in_minutes: 30
+ timeout_in_minutes: 85
device: h100
source_file_dependencies:
- vllm/config/attention.py
@@ -16,7 +16,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 70
+ timeout_in_minutes: 95
depends_on:
- image-build-amd
source_file_dependencies:
@@ -30,7 +30,7 @@ steps:
- label: V1 attention (B200)
key: v1-attention-b200
- timeout_in_minutes: 30
+ timeout_in_minutes: 80
device: b200-k8s
source_file_dependencies:
- vllm/config/attention.py
diff --git a/.buildkite/test_areas/basic_correctness.yaml b/.buildkite/test_areas/basic_correctness.yaml
index d7173b6438d8..1b92babb3ce7 100644
--- a/.buildkite/test_areas/basic_correctness.yaml
+++ b/.buildkite/test_areas/basic_correctness.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Basic Correctness
key: basic-correctness
- timeout_in_minutes: 30
+ timeout_in_minutes: 45
device: h200_18gb
source_file_dependencies:
- vllm/
@@ -19,6 +19,6 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 50
+ timeout_in_minutes: 70
depends_on:
- image-build-amd
diff --git a/.buildkite/test_areas/benchmarks.yaml b/.buildkite/test_areas/benchmarks.yaml
index 622ebd44f409..7c26a8d6e982 100644
--- a/.buildkite/test_areas/benchmarks.yaml
+++ b/.buildkite/test_areas/benchmarks.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Benchmarks CLI Test
key: benchmarks-cli-test
- timeout_in_minutes: 20
+ timeout_in_minutes: 30
device: h200_18gb
source_file_dependencies:
- vllm/
@@ -23,7 +23,7 @@ steps:
num_gpus: 2
optional: true
working_dir: "/vllm-workspace/"
- timeout_in_minutes: 10
+ timeout_in_minutes: 20
source_file_dependencies:
- benchmarks/attention_benchmarks/
- vllm/v1/attention/
diff --git a/.buildkite/test_areas/compile.yaml b/.buildkite/test_areas/compile.yaml
index 01248738d519..eba37f657d90 100644
--- a/.buildkite/test_areas/compile.yaml
+++ b/.buildkite/test_areas/compile.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Sequence Parallel Correctness Tests (2 GPUs)
key: sequence-parallel-correctness-tests-2-gpus
- timeout_in_minutes: 50
+ timeout_in_minutes: 80
working_dir: "/vllm-workspace/"
num_devices: 2
source_file_dependencies:
@@ -19,7 +19,7 @@ steps:
- label: Sequence Parallel Correctness Tests (2xH100)
key: sequence-parallel-correctness-tests-2xh100
- timeout_in_minutes: 50
+ timeout_in_minutes: 75
working_dir: "/vllm-workspace/"
device: h100
optional: true
@@ -30,7 +30,7 @@ steps:
- label: AsyncTP Correctness Tests (2xH100)
key: asynctp-correctness-tests-2xh100
- timeout_in_minutes: 50
+ timeout_in_minutes: 30
working_dir: "/vllm-workspace/"
device: h100
optional: true
@@ -41,7 +41,7 @@ steps:
- label: AsyncTP Correctness Tests (B200)
key: asynctp-correctness-tests-b200
- timeout_in_minutes: 50
+ timeout_in_minutes: 30
working_dir: "/vllm-workspace/"
device: b200-k8s
optional: true
@@ -52,7 +52,7 @@ steps:
- label: Distributed Compile Unit Tests (2xH100)
key: distributed-compile-unit-tests-2xh100
- timeout_in_minutes: 20
+ timeout_in_minutes: 45
working_dir: "/vllm-workspace/"
device: h100
num_devices: 2
@@ -66,7 +66,7 @@ steps:
- label: Fusion and Compile Unit Tests (2xB200)
key: fusion-and-compile-unit-tests-2xb200
- timeout_in_minutes: 20
+ timeout_in_minutes: 30
working_dir: "/vllm-workspace/"
device: b200-k8s
source_file_dependencies:
@@ -96,7 +96,7 @@ steps:
- label: Fusion E2E Quick (H100)
key: fusion-e2e-quick-h100
- timeout_in_minutes: 15
+ timeout_in_minutes: 25
working_dir: "/vllm-workspace/"
device: h100
num_devices: 1
@@ -115,7 +115,7 @@ steps:
- label: Fusion E2E Config Sweep (H100)
key: fusion-e2e-config-sweep-h100
- timeout_in_minutes: 30
+ timeout_in_minutes: 25
working_dir: "/vllm-workspace/"
device: h100
num_devices: 1
@@ -149,7 +149,7 @@ steps:
- label: Fusion E2E TP2 Quick (H100)
key: fusion-e2e-tp2-quick-h100
- timeout_in_minutes: 20
+ timeout_in_minutes: 35
working_dir: "/vllm-workspace/"
device: h100
num_devices: 2
@@ -167,7 +167,7 @@ steps:
- label: Fusion E2E TP2 AR-RMS Config Sweep (H100)
key: fusion-e2e-tp2-ar-rms-config-sweep-h100
- timeout_in_minutes: 40
+ timeout_in_minutes: 30
working_dir: "/vllm-workspace/"
device: h100
num_devices: 2
@@ -207,7 +207,7 @@ steps:
- label: Fusion E2E TP2 (B200)
key: fusion-e2e-tp2-b200
- timeout_in_minutes: 20
+ timeout_in_minutes: 45
working_dir: "/vllm-workspace/"
device: b200-k8s
num_devices: 2
diff --git a/.buildkite/test_areas/cuda.yaml b/.buildkite/test_areas/cuda.yaml
index 956c76cf05f5..99e1949fef78 100644
--- a/.buildkite/test_areas/cuda.yaml
+++ b/.buildkite/test_areas/cuda.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Platform Tests
key: platform-tests
- timeout_in_minutes: 15
+ timeout_in_minutes: 20
device: h200_18gb
source_file_dependencies:
- vllm/envs.py
@@ -19,7 +19,7 @@ steps:
- label: Cudagraph
key: cudagraph
- timeout_in_minutes: 20
+ timeout_in_minutes: 30
source_file_dependencies:
- tests/v1/cudagraph
- vllm/v1/cudagraph_dispatcher.py
diff --git a/.buildkite/test_areas/disaggregated.yaml b/.buildkite/test_areas/disaggregated.yaml
index 4cab698f322b..38020d91ad00 100644
--- a/.buildkite/test_areas/disaggregated.yaml
+++ b/.buildkite/test_areas/disaggregated.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Distributed NixlConnector PD accuracy (4 GPUs)
key: distributed-nixlconnector-pd-accuracy-4-gpus
- timeout_in_minutes: 30
+ timeout_in_minutes: 55
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
@@ -16,7 +16,7 @@ steps:
mirror:
amd:
device: mi300_4
- timeout_in_minutes: 110
+ timeout_in_minutes: 85
depends_on:
- image-build-amd
source_file_dependencies:
@@ -29,7 +29,7 @@ steps:
- label: Distributed FlashInfer NixlConnector PD accuracy (4 GPUs)
key: distributed-flashinfer-nixlconnector-pd-accuracy-4-gpus
- timeout_in_minutes: 30
+ timeout_in_minutes: 55
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
@@ -66,7 +66,7 @@ steps:
mirror:
amd:
device: mi300_4
- timeout_in_minutes: 50
+ timeout_in_minutes: 60
depends_on:
- image-build-amd
source_file_dependencies:
@@ -79,7 +79,7 @@ steps:
- label: CrossLayer KV layout Distributed NixlConnector PD accuracy tests (4 GPUs)
key: crosslayer-kv-layout-distributed-nixlconnector-pd-accuracy-tests-4-gpus
- timeout_in_minutes: 30
+ timeout_in_minutes: 55
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
@@ -91,7 +91,7 @@ steps:
mirror:
amd:
device: mi300_4
- timeout_in_minutes: 110
+ timeout_in_minutes: 85
depends_on:
- image-build-amd
source_file_dependencies:
@@ -104,7 +104,7 @@ steps:
- label: Hybrid SSM NixlConnector PD accuracy tests (4 GPUs)
key: hybrid-ssm-nixlconnector-pd-accuracy-tests-4-gpus
- timeout_in_minutes: 25
+ timeout_in_minutes: 60
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
@@ -116,7 +116,7 @@ steps:
mirror:
amd:
device: mi300_4
- timeout_in_minutes: 60
+ timeout_in_minutes: 80
depends_on:
- image-build-amd
source_file_dependencies:
@@ -143,7 +143,7 @@ steps:
- label: MultiConnector (Nixl+Offloading) PD accuracy (2 GPUs)
key: multiconnector-nixl-offloading-pd-accuracy-2-gpus
- timeout_in_minutes: 30
+ timeout_in_minutes: 40
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
@@ -158,7 +158,7 @@ steps:
- label: NixlConnector PD + Spec Decode acceptance (2 GPUs)
key: nixlconnector-pd-spec-decode-acceptance-2-gpus
- timeout_in_minutes: 30
+ timeout_in_minutes: 45
device: a100
working_dir: "/vllm-workspace/tests"
num_devices: 2
@@ -172,7 +172,7 @@ steps:
mirror:
amd:
device: mi300_2
- timeout_in_minutes: 60
+ timeout_in_minutes: 70
depends_on:
- image-build-amd
source_file_dependencies:
@@ -186,7 +186,7 @@ steps:
- label: MultiConnector (Nixl+Offloading) PD edge cases (2 GPUs)
key: multiconnector-nixl-offloading-pd-edge-cases-2-gpus
- timeout_in_minutes: 30
+ timeout_in_minutes: 25
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
diff --git a/.buildkite/test_areas/distributed.yaml b/.buildkite/test_areas/distributed.yaml
index 0b81cdb9d116..b519132c6f1f 100644
--- a/.buildkite/test_areas/distributed.yaml
+++ b/.buildkite/test_areas/distributed.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Distributed Comm Ops
key: distributed-comm-ops
- timeout_in_minutes: 20
+ timeout_in_minutes: 25
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
@@ -18,7 +18,7 @@ steps:
- label: Distributed DP Tests (2 GPUs)
key: distributed-dp-tests-2-gpus
- timeout_in_minutes: 20
+ timeout_in_minutes: 35
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
@@ -55,7 +55,7 @@ steps:
- label: Distributed Compile + RPC Tests (2 GPUs)
key: distributed-compile-rpc-tests-2-gpus
- timeout_in_minutes: 20
+ timeout_in_minutes: 65
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
@@ -78,7 +78,7 @@ steps:
- label: Distributed Torchrun + Shutdown Tests (2 GPUs)
key: distributed-torchrun-shutdown-tests-2-gpus
- timeout_in_minutes: 20
+ timeout_in_minutes: 30
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
@@ -133,7 +133,7 @@ steps:
- label: Distributed DP Tests (4 GPUs)
key: distributed-dp-tests-4-gpus
- timeout_in_minutes: 30
+ timeout_in_minutes: 45
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
@@ -154,7 +154,7 @@ steps:
- label: Distributed Compile + Comm (4 GPUs)
key: distributed-compile-comm-4-gpus
- timeout_in_minutes: 30
+ timeout_in_minutes: 70
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
@@ -176,7 +176,7 @@ steps:
- label: Distributed Tests (8xH100)
key: distributed-tests-8xh100
- timeout_in_minutes: 10
+ timeout_in_minutes: 20
device: h100
num_devices: 8
working_dir: "/vllm-workspace/tests"
@@ -212,7 +212,7 @@ steps:
- label: Distributed Tests (2xH100-2xMI300)
key: distributed-tests-2xh100-2xmi300
- timeout_in_minutes: 15
+ timeout_in_minutes: 30
device: h100
optional: true
working_dir: "/vllm-workspace/"
@@ -259,7 +259,7 @@ steps:
- label: Pipeline + Context Parallelism (4 GPUs)
key: pipeline-context-parallelism-4-gpus
- timeout_in_minutes: 60
+ timeout_in_minutes: 55
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
@@ -274,7 +274,7 @@ steps:
- label: RayExecutorV2 (4 GPUs)
key: rayexecutorv2-4-gpus
- timeout_in_minutes: 60
+ timeout_in_minutes: 45
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
diff --git a/.buildkite/test_areas/docker.yaml b/.buildkite/test_areas/docker.yaml
index 9bf96221abe0..9f0562ca3bcf 100644
--- a/.buildkite/test_areas/docker.yaml
+++ b/.buildkite/test_areas/docker.yaml
@@ -3,7 +3,7 @@ depends_on:
- image-build-cpu
steps:
- label: Docker Build Metadata
- timeout_in_minutes: 10
+ timeout_in_minutes: 20
device: cpu-small
source_file_dependencies:
- .buildkite/release-pipeline.yaml
diff --git a/.buildkite/test_areas/e2e_integration.yaml b/.buildkite/test_areas/e2e_integration.yaml
index 3f87e3958d0b..6655ae781e8d 100644
--- a/.buildkite/test_areas/e2e_integration.yaml
+++ b/.buildkite/test_areas/e2e_integration.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: DeepSeek V2-Lite Sync EPLB Accuracy (4xH100)
key: deepseek-v2-lite-sync-eplb-accuracy-4xh100
- timeout_in_minutes: 60
+ timeout_in_minutes: 25
device: h100
optional: true
num_devices: 4
@@ -14,7 +14,7 @@ steps:
- label: Qwen3-30B-A3B-FP8-block Sync EPLB Accuracy (4xH100)
key: qwen3-30b-a3b-fp8-block-sync-eplb-accuracy-4xh100
- timeout_in_minutes: 60
+ timeout_in_minutes: 25
device: h100
optional: true
num_devices: 4
@@ -24,7 +24,7 @@ steps:
- label: Qwen3-30B-A3B-FP8-block Sync EPLB Accuracy (2xB200)
key: qwen3-30b-a3b-fp8-block-sync-eplb-accuracy-2xb200
- timeout_in_minutes: 60
+ timeout_in_minutes: 20
device: b200-k8s
optional: true
num_devices: 2
@@ -34,7 +34,7 @@ steps:
- label: Qwen3-30B-A3B-FP8 DP4 Async EPLB Accuracy
key: qwen3-30b-a3b-fp8-dp4-async-eplb-accuracy
- timeout_in_minutes: 60
+ timeout_in_minutes: 25
device: h100
optional: true
num_devices: 4
@@ -44,7 +44,7 @@ steps:
- label: DeepSeek V2-Lite Prefetch Offload Accuracy (H100)
key: deepseek-v2-lite-prefetch-offload-accuracy-h100
- timeout_in_minutes: 60
+ timeout_in_minutes: 20
device: h100
optional: true
num_devices: 1
diff --git a/.buildkite/test_areas/engine.yaml b/.buildkite/test_areas/engine.yaml
index 7bb605465720..1c50ab7a1132 100644
--- a/.buildkite/test_areas/engine.yaml
+++ b/.buildkite/test_areas/engine.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Engine
key: engine
- timeout_in_minutes: 15
+ timeout_in_minutes: 30
device: h200_18gb
source_file_dependencies:
- vllm/compilation/
@@ -29,13 +29,13 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 60
+ timeout_in_minutes: 50
depends_on:
- image-build-amd
- label: Engine (1 GPU)
key: engine-1-gpu
- timeout_in_minutes: 30
+ timeout_in_minutes: 45
source_file_dependencies:
- vllm/v1/engine/
- tests/v1/engine/
@@ -45,13 +45,13 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 40
+ timeout_in_minutes: 55
depends_on:
- image-build-amd
- label: e2e Scheduling (1 GPU)
key: e2e-scheduling-1-gpu
- timeout_in_minutes: 30
+ timeout_in_minutes: 35
device: h200_18gb
source_file_dependencies:
- vllm/v1/
@@ -61,14 +61,14 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 60
+ timeout_in_minutes: 70
depends_on:
- image-build-amd
- label: e2e Core (1 GPU)
device: h200_35gb
key: e2e-core-1-gpu
- timeout_in_minutes: 30
+ timeout_in_minutes: 40
source_file_dependencies:
- vllm/v1/
- tests/v1/e2e/general/
@@ -77,7 +77,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 35
+ timeout_in_minutes: 60
depends_on:
- image-build-amd
source_file_dependencies:
@@ -87,7 +87,7 @@ steps:
- label: V1 e2e (2 GPUs)
key: v1-e2e-2-gpus
- timeout_in_minutes: 60 # TODO: Fix timeout after we have more confidence in the test stability
+ timeout_in_minutes: 25 # TODO: Fix timeout after we have more confidence in the test stability
optional: true
num_devices: 2
source_file_dependencies:
@@ -120,7 +120,7 @@ steps:
- label: V1 e2e (4 GPUs)
key: v1-e2e-4-gpus
- timeout_in_minutes: 60 # TODO: Fix timeout after we have more confidence in the test stability
+ timeout_in_minutes: 20 # TODO: Fix timeout after we have more confidence in the test stability
optional: true
num_devices: 4
source_file_dependencies:
@@ -148,7 +148,7 @@ steps:
- label: V1 e2e (4xH100)
key: v1-e2e-4xh100
- timeout_in_minutes: 60
+ timeout_in_minutes: 35
device: h100
num_devices: 4
optional: true
diff --git a/.buildkite/test_areas/entrypoints.yaml b/.buildkite/test_areas/entrypoints.yaml
index 5ef88d4b97b5..2db4c5ad5a21 100644
--- a/.buildkite/test_areas/entrypoints.yaml
+++ b/.buildkite/test_areas/entrypoints.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Entrypoints Unit Tests
key: entrypoints-unit-tests
- timeout_in_minutes: 10
+ timeout_in_minutes: 25
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/entrypoints
@@ -16,7 +16,7 @@ steps:
- label: Entrypoints Integration (LLM)
key: entrypoints-integration-llm
- timeout_in_minutes: 40
+ timeout_in_minutes: 60
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/
@@ -37,7 +37,7 @@ steps:
- label: Entrypoints Integration (API Server)
key: entrypoints-integration-api-server
device: h200_35gb
- timeout_in_minutes: 130
+ timeout_in_minutes: 50
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/
@@ -56,7 +56,7 @@ steps:
- label: Entrypoints Integration (API Server OpenAI - Part 1)
key: entrypoints-integration-api-server-openai-part-1
- timeout_in_minutes: 50
+ timeout_in_minutes: 45
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/
@@ -68,13 +68,13 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 80
+ timeout_in_minutes: 65
depends_on:
- image-build-amd
- label: Entrypoints Integration (API Server OpenAI - Part 2)
key: entrypoints-integration-api-server-openai-part-2
- timeout_in_minutes: 50
+ timeout_in_minutes: 45
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/
@@ -109,7 +109,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 60
+ timeout_in_minutes: 65
depends_on:
- image-build-amd
@@ -126,7 +126,7 @@ steps:
- label: Entrypoints Integration (Speech to Text)
device: h200_35gb
key: entrypoints-integration-speech_to_text
- timeout_in_minutes: 50
+ timeout_in_minutes: 45
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/
@@ -138,7 +138,7 @@ steps:
- label: Entrypoints Integration (Multimodal)
device: h200_35gb
key: entrypoints-integration-multimodal
- timeout_in_minutes: 50
+ timeout_in_minutes: 45
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/
@@ -160,7 +160,7 @@ steps:
- label: OpenAI API Correctness
key: openai-api-correctness
- timeout_in_minutes: 30
+ timeout_in_minutes: 20
device: h200_18gb
source_file_dependencies:
- csrc/
diff --git a/.buildkite/test_areas/expert_parallelism.yaml b/.buildkite/test_areas/expert_parallelism.yaml
index ccb3054f2e9c..d02ffeb77485 100644
--- a/.buildkite/test_areas/expert_parallelism.yaml
+++ b/.buildkite/test_areas/expert_parallelism.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: EPLB Algorithm
key: eplb-algorithm
- timeout_in_minutes: 15
+ timeout_in_minutes: 20
device: h200_18gb
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
@@ -27,7 +27,7 @@ steps:
- label: EPLB Execution # 17min
key: eplb-execution
- timeout_in_minutes: 27
+ timeout_in_minutes: 25
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
@@ -39,7 +39,7 @@ steps:
- label: Elastic EP Scaling Test
key: elastic-ep-scaling-test
- timeout_in_minutes: 20
+ timeout_in_minutes: 30
device: h100
working_dir: "/vllm-workspace/tests"
num_devices: 4
diff --git a/.buildkite/test_areas/kernels.yaml b/.buildkite/test_areas/kernels.yaml
index 10c132da095a..d9e350f5093a 100644
--- a/.buildkite/test_areas/kernels.yaml
+++ b/.buildkite/test_areas/kernels.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: vLLM IR Tests
key: vllm-ir-tests
- timeout_in_minutes: 10
+ timeout_in_minutes: 35
device: h200_18gb
working_dir: "/vllm-workspace/"
source_file_dependencies:
@@ -16,7 +16,7 @@ steps:
- label: Kernels Core Operation Test
key: kernels-core-operation-test
- timeout_in_minutes: 75
+ timeout_in_minutes: 120
source_file_dependencies:
- csrc/
- tests/kernels/core
@@ -27,7 +27,7 @@ steps:
- label: Kernels MiniMax Reduce RMS Test (2 GPUs)
key: kernels-minimax-reduce-rms-test-2-gpus
- timeout_in_minutes: 15
+ timeout_in_minutes: 20
num_devices: 2
device: h100
source_file_dependencies:
@@ -41,7 +41,7 @@ steps:
- label: Deepseek V4 Kernel Test (H100)
key: deepseek-v4-kernel-test-h100
- timeout_in_minutes: 15
+ timeout_in_minutes: 30
device: h100
source_file_dependencies:
- csrc/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu
@@ -54,7 +54,7 @@ steps:
- label: Deepseek V4 Kernel Test (B200)
key: deepseek-v4-kernel-test-b200
- timeout_in_minutes: 15
+ timeout_in_minutes: 20
device: b200-k8s
source_file_dependencies:
- csrc/fused_deepseek_v4_qnorm_rope_kv_insert_kernel.cu
@@ -65,7 +65,7 @@ steps:
- label: Kernels Attention Test %N
key: kernels-attention-test
- timeout_in_minutes: 35
+ timeout_in_minutes: 65
source_file_dependencies:
- csrc/attention/
- vllm/v1/attention
@@ -79,7 +79,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 55
+ timeout_in_minutes: 90
depends_on:
- image-build-amd
source_file_dependencies:
@@ -106,7 +106,7 @@ steps:
- label: Kernels Quantization Test %N
key: kernels-quantization-test
- timeout_in_minutes: 90
+ timeout_in_minutes: 60
source_file_dependencies:
- csrc/quantization/
- vllm/model_executor/layers/quantization
@@ -131,7 +131,7 @@ steps:
- label: Kernels MoE Test %N
key: kernels-moe-test
- timeout_in_minutes: 25
+ timeout_in_minutes: 50
source_file_dependencies:
- csrc/quantization/cutlass_w8a8/moe/
- csrc/moe/
@@ -147,7 +147,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 50
+ timeout_in_minutes: 65
source_file_dependencies:
- csrc/quantization/cutlass_w8a8/moe/
- csrc/moe/
@@ -163,7 +163,7 @@ steps:
- label: Kernels Mamba Test
key: kernels-mamba-test
- timeout_in_minutes: 45
+ timeout_in_minutes: 40
source_file_dependencies:
- csrc/mamba/
- tests/kernels/mamba
@@ -172,7 +172,7 @@ steps:
- pytest -v -s kernels/mamba
- label: Kernels KDA Test
- timeout_in_minutes: 20
+ timeout_in_minutes: 25
device: h200_18gb
source_file_dependencies:
- vllm/model_executor/layers/fla/ops/kda.py
@@ -184,7 +184,7 @@ steps:
- label: Kernels DeepGEMM Test (H100)
key: kernels-deepgemm-test-h100
- timeout_in_minutes: 45
+ timeout_in_minutes: 35
device: h100
num_devices: 1
source_file_dependencies:
@@ -211,7 +211,7 @@ steps:
- label: Kernels (B200)
key: kernels-b200
- timeout_in_minutes: 30
+ timeout_in_minutes: 80
working_dir: "/vllm-workspace/"
device: b200-k8s
# optional: true
@@ -264,7 +264,7 @@ steps:
- label: Kernels Helion Test
key: kernels-helion-test
- timeout_in_minutes: 30
+ timeout_in_minutes: 115
device: h100
source_file_dependencies:
- vllm/utils/import_utils.py
@@ -276,7 +276,7 @@ steps:
- label: Kernels FP8 MoE Test (1xH100)
key: kernels-fp8-moe-test-1xh100
- timeout_in_minutes: 90
+ timeout_in_minutes: 40
device: h100
num_devices: 1
optional: true
@@ -293,7 +293,7 @@ steps:
- label: Kernels FP8 MoE Test (2xH100)
key: kernels-fp8-moe-test-2xh100
- timeout_in_minutes: 90
+ timeout_in_minutes: 45
device: h100
num_devices: 2
optional: true
@@ -303,7 +303,7 @@ steps:
- label: Kernels Fp4 MoE Test (B200)
key: kernels-fp4-moe-test-b200
- timeout_in_minutes: 60
+ timeout_in_minutes: 25
device: b200-k8s
num_devices: 1
optional: true
@@ -316,7 +316,7 @@ steps:
- label: Kernels FusedMoE Layer Test (2 H100s)
key: kernels-fusedmoe-layer-test-2-h100s
- timeout_in_minutes: 90
+ timeout_in_minutes: 30
device: h100
num_devices: 2
source_file_dependencies:
diff --git a/.buildkite/test_areas/lm_eval.yaml b/.buildkite/test_areas/lm_eval.yaml
index feba8f26eb32..1f7d27875367 100644
--- a/.buildkite/test_areas/lm_eval.yaml
+++ b/.buildkite/test_areas/lm_eval.yaml
@@ -5,7 +5,7 @@ steps:
- label: LM Eval Small Models
device: h200_35gb
key: lm-eval-small-models
- timeout_in_minutes: 75
+ timeout_in_minutes: 45
source_file_dependencies:
- csrc/
- vllm/model_executor/layers/quantization
@@ -56,7 +56,7 @@ steps:
- label: LM Eval Small Models (1xB200)
key: lm-eval-small-models-1xb200
- timeout_in_minutes: 120
+ timeout_in_minutes: 50
device: b200-k8s
optional: true
source_file_dependencies:
@@ -80,7 +80,7 @@ steps:
- label: LM Eval Large Models EP (2xB200)
key: lm-eval-large-models-ep-2xb200
- timeout_in_minutes: 120
+ timeout_in_minutes: 60
device: b200-k8s
optional: true
num_devices: 2
@@ -92,7 +92,7 @@ steps:
- label: LM Eval Qwen3.5 Models (2xB200)
key: lm-eval-qwen3-5-models-2xb200
- timeout_in_minutes: 120
+ timeout_in_minutes: 45
device: b200-k8s
optional: true
num_devices: 2
@@ -109,7 +109,7 @@ steps:
- label: LM Eval Large Models (8xH200)
key: lm-eval-large-models-8xh200
- timeout_in_minutes: 60
+ timeout_in_minutes: 50
device: h200
optional: true
num_devices: 8
@@ -118,7 +118,7 @@ steps:
mirror:
amd:
device: mi300_8
- timeout_in_minutes: 180
+ timeout_in_minutes: 60
depends_on:
- image-build-amd
commands:
@@ -152,7 +152,7 @@ steps:
- label: LM Eval Humming f16 (A100 - TEMPORARY)
key: lm-eval-humming-f16-a100
- timeout_in_minutes: 120
+ timeout_in_minutes: 75
device: a100
optional: true
num_devices: 1
@@ -167,7 +167,7 @@ steps:
- label: LM Eval Humming Act int8 (A100 - TEMPORARY)
key: lm-eval-humming-act-a100
- timeout_in_minutes: 120
+ timeout_in_minutes: 45
device: a100
optional: true
num_devices: 1
@@ -182,7 +182,7 @@ steps:
- label: LM Eval Humming f16 (H100 - TEMPORARY)
key: lm-eval-humming-f16-h100
- timeout_in_minutes: 120
+ timeout_in_minutes: 70
device: h100
optional: true
num_devices: 1
@@ -197,7 +197,7 @@ steps:
- label: LM Eval Humming Act fp8/int8 (H100 - TEMPORARY)
key: lm-eval-humming-act-h100
- timeout_in_minutes: 120
+ timeout_in_minutes: 70
device: h100
optional: true
num_devices: 1
@@ -213,7 +213,7 @@ steps:
- label: LM Eval Humming f16 (B200 - TEMPORARY)
key: lm-eval-humming-f16-b200
- timeout_in_minutes: 120
+ timeout_in_minutes: 50
device: b200-k8s
optional: true
num_devices: 1
@@ -228,7 +228,7 @@ steps:
- label: LM Eval Humming Act fp8/int8 (B200 - TEMPORARY)
key: lm-eval-humming-act-b200
- timeout_in_minutes: 120
+ timeout_in_minutes: 50
device: b200-k8s
optional: true
num_devices: 1
@@ -244,7 +244,7 @@ steps:
- label: LM Eval TurboQuant KV Cache
key: lm-eval-turboquant-kv-cache
- timeout_in_minutes: 75
+ timeout_in_minutes: 55
device: h200_18gb
source_file_dependencies:
- vllm/model_executor/layers/quantization/turboquant/
@@ -256,7 +256,7 @@ steps:
- label: GPQA Eval (GPT-OSS) (2xH100)
key: gpqa-eval-gpt-oss-2xh100
- timeout_in_minutes: 120
+ timeout_in_minutes: 35
device: h100
optional: true
num_devices: 2
@@ -270,7 +270,7 @@ steps:
- label: GPQA Eval (GPT-OSS) (2xB200)
key: gpqa-eval-gpt-oss-2xb200
- timeout_in_minutes: 120
+ timeout_in_minutes: 30
device: b200-k8s
optional: true
num_devices: 2
@@ -284,7 +284,7 @@ steps:
- label: GPQA Eval (GPT-OSS) (DGX Spark)
key: gpqa-eval-gpt-oss-spark
- timeout_in_minutes: 120
+ timeout_in_minutes: 35
device: dgx-spark
optional: true
num_devices: 1
@@ -313,7 +313,7 @@ steps:
- label: LM Eval KV-Offload (2xH100)
key: kv-offload-medium
- timeout_in_minutes: 60
+ timeout_in_minutes: 30
device: h100
num_devices: 2
source_file_dependencies:
@@ -327,7 +327,7 @@ steps:
- label: LM Eval KV-Offload (4xH100)
key: kv-offload-large
- timeout_in_minutes: 60
+ timeout_in_minutes: 40
device: h100
num_devices: 4
source_file_dependencies:
@@ -341,7 +341,7 @@ steps:
- label: MRCR Eval Small Models
device: h200_35gb
- timeout_in_minutes: 30
+ timeout_in_minutes: 25
source_file_dependencies:
- tests/evals/mrcr/
commands:
diff --git a/.buildkite/test_areas/lora.yaml b/.buildkite/test_areas/lora.yaml
index bd437c52265f..46a3710ea808 100644
--- a/.buildkite/test_areas/lora.yaml
+++ b/.buildkite/test_areas/lora.yaml
@@ -5,7 +5,7 @@ steps:
- label: LoRA %N
device: h200_35gb
key: lora
- timeout_in_minutes: 30
+ timeout_in_minutes: 40
source_file_dependencies:
- vllm/lora
- tests/lora
@@ -16,7 +16,7 @@ steps:
amd:
device: mi325_1
working_dir: "/vllm-workspace/tests"
- timeout_in_minutes: 60
+ timeout_in_minutes: 65
source_file_dependencies:
- vllm/lora
- tests/lora
@@ -27,7 +27,7 @@ steps:
- label: LoRA TP (Distributed)
key: lora-tp-distributed
- timeout_in_minutes: 30
+ timeout_in_minutes: 60
num_devices: 4
source_file_dependencies:
- vllm/lora
diff --git a/.buildkite/test_areas/misc.yaml b/.buildkite/test_areas/misc.yaml
index fd6ef2e61bae..13840c8db2fc 100644
--- a/.buildkite/test_areas/misc.yaml
+++ b/.buildkite/test_areas/misc.yaml
@@ -5,7 +5,7 @@ steps:
- label: V1 Spec Decode
device: h200_35gb
key: v1-spec-decode
- timeout_in_minutes: 30
+ timeout_in_minutes: 40
source_file_dependencies:
- vllm/config/
- vllm/distributed/
@@ -24,13 +24,13 @@ steps:
mirror:
amd:
device: mi300_1
- timeout_in_minutes: 65
+ timeout_in_minutes: 75
depends_on:
- image-build-amd
- label: V1 Sample + Logits
key: v1-sample-logits
- timeout_in_minutes: 30
+ timeout_in_minutes: 45
device: h200_18gb
source_file_dependencies:
- vllm/config/
@@ -64,7 +64,7 @@ steps:
- label: V1 Core + KV + Metrics
key: v1-core-kv-metrics
- timeout_in_minutes: 30
+ timeout_in_minutes: 60
source_file_dependencies:
- vllm/config/
- vllm/distributed/
@@ -108,7 +108,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 60
+ timeout_in_minutes: 75
depends_on:
- image-build-amd
@@ -172,7 +172,7 @@ steps:
- label: Regression
key: regression
- timeout_in_minutes: 20
+ timeout_in_minutes: 30
device: h200_18gb
source_file_dependencies:
- vllm/config/
@@ -195,7 +195,7 @@ steps:
- label: Examples
device: h200_35gb
key: examples
- timeout_in_minutes: 45
+ timeout_in_minutes: 40
working_dir: "/vllm-workspace/examples"
source_file_dependencies:
- vllm/entrypoints
@@ -237,7 +237,7 @@ steps:
- label: Metrics, Tracing (2 GPUs)
key: metrics-tracing-2-gpus
- timeout_in_minutes: 20
+ timeout_in_minutes: 25
num_devices: 2
source_file_dependencies:
- vllm/config/
@@ -281,7 +281,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 20
+ timeout_in_minutes: 45
depends_on:
- image-build-amd
source_file_dependencies:
@@ -292,7 +292,7 @@ steps:
- label: Async Engine, Inputs, Utils, Worker
device: h200_35gb
key: async-engine-inputs-utils-worker
- timeout_in_minutes: 50
+ timeout_in_minutes: 25
source_file_dependencies:
- vllm/assets/
- vllm/config/
@@ -319,7 +319,7 @@ steps:
key: async-engine-inputs-utils-worker-config-cpu
depends_on:
- image-build-cpu
- timeout_in_minutes: 30
+ timeout_in_minutes: 65
source_file_dependencies:
- vllm/assets/
- vllm/config/
@@ -381,7 +381,7 @@ steps:
- label: Batch Invariance (A100)
key: batch-invariance-a100
- timeout_in_minutes: 30
+ timeout_in_minutes: 40
device: a100
source_file_dependencies:
- vllm/v1/attention
@@ -395,7 +395,7 @@ steps:
- label: Batch Invariance (H100)
key: batch-invariance-h100
- timeout_in_minutes: 30
+ timeout_in_minutes: 40
device: h100
source_file_dependencies:
- vllm/v1/attention
@@ -411,7 +411,7 @@ steps:
- label: Batch Invariance (B200)
key: batch-invariance-b200
- timeout_in_minutes: 30
+ timeout_in_minutes: 35
device: b200-k8s
source_file_dependencies:
- vllm/v1/attention
@@ -430,7 +430,7 @@ steps:
- label: Acceptance Length Test (Large Models) # optional
device: h200_35gb
key: acceptance-length-test-large-models
- timeout_in_minutes: 25
+ timeout_in_minutes: 20
gpu: h100
optional: true
num_gpus: 1
diff --git a/.buildkite/test_areas/model_executor.yaml b/.buildkite/test_areas/model_executor.yaml
index aaf85b4f2753..4280e600df4b 100644
--- a/.buildkite/test_areas/model_executor.yaml
+++ b/.buildkite/test_areas/model_executor.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Model Executor
key: model-executor
- timeout_in_minutes: 35
+ timeout_in_minutes: 45
source_file_dependencies:
- vllm/engine/arg_utils.py
- vllm/config/model.py
diff --git a/.buildkite/test_areas/model_runner_v2.yaml b/.buildkite/test_areas/model_runner_v2.yaml
index dbb35df80be9..3601aeee1172 100644
--- a/.buildkite/test_areas/model_runner_v2.yaml
+++ b/.buildkite/test_areas/model_runner_v2.yaml
@@ -5,7 +5,7 @@ steps:
- label: Model Runner V2 Core Tests
device: h200_35gb
key: model-runner-v2-core-tests
- timeout_in_minutes: 45
+ timeout_in_minutes: 35
source_file_dependencies:
- vllm/v1/worker/gpu/
- vllm/v1/worker/gpu_worker.py
@@ -27,7 +27,7 @@ steps:
- label: Model Runner V2 Examples
device: h200_35gb
key: model-runner-v2-examples
- timeout_in_minutes: 45
+ timeout_in_minutes: 35
working_dir: "/vllm-workspace/examples"
source_file_dependencies:
- vllm/v1/worker/gpu/
@@ -63,7 +63,7 @@ steps:
- label: Model Runner V2 Distributed (2 GPUs)
key: model-runner-v2-distributed-2-gpus
- timeout_in_minutes: 45
+ timeout_in_minutes: 30
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
@@ -84,7 +84,7 @@ steps:
- label: Model Runner V2 Pipeline Parallelism (4 GPUs)
key: model-runner-v2-pipeline-parallelism-4-gpus
- timeout_in_minutes: 60
+ timeout_in_minutes: 50
working_dir: "/vllm-workspace/tests"
num_devices: 4
source_file_dependencies:
diff --git a/.buildkite/test_areas/models_basic.yaml b/.buildkite/test_areas/models_basic.yaml
index 3a113f1982a1..0fcd4f410487 100644
--- a/.buildkite/test_areas/models_basic.yaml
+++ b/.buildkite/test_areas/models_basic.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Basic Models Tests (Initialization)
key: basic-models-tests-initialization
- timeout_in_minutes: 45
+ timeout_in_minutes: 25
device: h200_18gb
source_file_dependencies:
- vllm/
@@ -17,7 +17,7 @@ steps:
- label: Basic Models Tests (Extra Initialization) %N
device: h200_35gb
key: basic-models-tests-extra-initialization
- timeout_in_minutes: 45
+ timeout_in_minutes: 100
source_file_dependencies:
- vllm/model_executor/models/
- tests/models/test_initialization.py
@@ -32,7 +32,7 @@ steps:
- label: Basic Models Tests (Other)
device: h200_35gb
key: basic-models-tests-other
- timeout_in_minutes: 45
+ timeout_in_minutes: 35
source_file_dependencies:
- vllm/
- tests/models/test_terratorch.py
@@ -50,7 +50,7 @@ steps:
key: basic-models-test-other-cpu
depends_on:
- image-build-cpu
- timeout_in_minutes: 10
+ timeout_in_minutes: 20
source_file_dependencies:
- vllm/
- tests/models/test_utils.py
diff --git a/.buildkite/test_areas/models_distributed.yaml b/.buildkite/test_areas/models_distributed.yaml
index a3ee7666ed09..c1ec5eb00ae5 100644
--- a/.buildkite/test_areas/models_distributed.yaml
+++ b/.buildkite/test_areas/models_distributed.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Distributed Model Tests (2 GPUs)
key: distributed-model-tests-2-gpus
- timeout_in_minutes: 50
+ timeout_in_minutes: 60
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
diff --git a/.buildkite/test_areas/models_language.yaml b/.buildkite/test_areas/models_language.yaml
index 2c163bc80491..d89d3fabaecf 100644
--- a/.buildkite/test_areas/models_language.yaml
+++ b/.buildkite/test_areas/models_language.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Language Models Tests (Standard)
key: language-models-tests-standard
- timeout_in_minutes: 25
+ timeout_in_minutes: 30
device: h200_18gb
source_file_dependencies:
- vllm/
@@ -21,7 +21,7 @@ steps:
- label: Language Models Tests (Extra Standard) %N
key: language-models-tests-extra-standard
- timeout_in_minutes: 45
+ timeout_in_minutes: 40
source_file_dependencies:
- vllm/model_executor/models/
- tests/models/language/pooling/test_embedding.py
@@ -52,7 +52,7 @@ steps:
- label: Language Models Tests (Hybrid) %N
key: language-models-tests-hybrid
- timeout_in_minutes: 75
+ timeout_in_minutes: 65
source_file_dependencies:
- vllm/
- tests/models/language/generation
@@ -67,7 +67,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 90
+ timeout_in_minutes: 70
depends_on:
- image-build-amd
commands:
@@ -78,7 +78,7 @@ steps:
- label: Language Models Test (Extended Generation) # 80min
device: h200_35gb
key: language-models-test-extended-generation
- timeout_in_minutes: 110
+ timeout_in_minutes: 65
optional: true
source_file_dependencies:
- vllm/
@@ -92,7 +92,7 @@ steps:
- label: Language Models Test (PPL)
key: language-models-test-ppl
- timeout_in_minutes: 110
+ timeout_in_minutes: 30
device: h200_18gb
optional: true
source_file_dependencies:
@@ -104,7 +104,7 @@ steps:
- label: Language Models Test (Extended Pooling) # 36min
device: h200_35gb
key: language-models-test-extended-pooling
- timeout_in_minutes: 50
+ timeout_in_minutes: 70
optional: true
source_file_dependencies:
- vllm/
@@ -120,7 +120,7 @@ steps:
- label: Language Models Test (MTEB)
key: language-models-test-mteb
- timeout_in_minutes: 110
+ timeout_in_minutes: 45
device: h200_18gb
optional: true
source_file_dependencies:
diff --git a/.buildkite/test_areas/models_multimodal.yaml b/.buildkite/test_areas/models_multimodal.yaml
index a721efa6067e..6720483c25f2 100644
--- a/.buildkite/test_areas/models_multimodal.yaml
+++ b/.buildkite/test_areas/models_multimodal.yaml
@@ -20,7 +20,7 @@ steps:
- label: "Multi-Modal Models (Standard) 2: qwen3 + gemma"
key: multi-modal-models-standard-2-qwen3-gemma
- timeout_in_minutes: 45
+ timeout_in_minutes: 50
device: h200_18gb
source_file_dependencies:
- vllm/
@@ -38,7 +38,7 @@ steps:
- label: "Multi-Modal Models (Standard) 3: llava + qwen2_vl"
device: h200_35gb
key: multi-modal-models-standard-3-llava-qwen2-vl
- timeout_in_minutes: 45
+ timeout_in_minutes: 40
source_file_dependencies:
- vllm/
- tests/models/multimodal
@@ -54,7 +54,7 @@ steps:
- label: "Multi-Modal Models (Standard) 4: other + whisper"
device: h200_35gb
key: multi-modal-models-standard-4-other-whisper
- timeout_in_minutes: 45
+ timeout_in_minutes: 50
source_file_dependencies:
- vllm/
- tests/models/multimodal
@@ -73,7 +73,7 @@ steps:
key: multi-modal-processor-cpu
depends_on:
- image-build-cpu
- timeout_in_minutes: 60
+ timeout_in_minutes: 125
source_file_dependencies:
- vllm/
- tests/models/multimodal
@@ -84,7 +84,7 @@ steps:
- label: Multi-Modal Processor # 44min
key: multi-modal-processor
- timeout_in_minutes: 60
+ timeout_in_minutes: 65
device: h200_18gb
source_file_dependencies:
- vllm/
@@ -96,7 +96,7 @@ steps:
- label: Multi-Modal Accuracy Eval (Small Models) # 50min
device: h200_35gb
key: multi-modal-accuracy-eval-small-models
- timeout_in_minutes: 70
+ timeout_in_minutes: 30
working_dir: "/vllm-workspace/.buildkite/lm-eval-harness"
source_file_dependencies:
- vllm/multimodal/
@@ -164,7 +164,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 60
+ timeout_in_minutes: 75
depends_on:
- image-build-amd
source_file_dependencies:
diff --git a/.buildkite/test_areas/plugins.yaml b/.buildkite/test_areas/plugins.yaml
index 5effe17513d4..e0bb67b1bb47 100644
--- a/.buildkite/test_areas/plugins.yaml
+++ b/.buildkite/test_areas/plugins.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Plugin Tests (2 GPUs)
key: plugin-tests-2-gpus
- timeout_in_minutes: 60
+ timeout_in_minutes: 35
working_dir: "/vllm-workspace/tests"
num_devices: 2
source_file_dependencies:
diff --git a/.buildkite/test_areas/pytorch.yaml b/.buildkite/test_areas/pytorch.yaml
index 5c3060582aa8..72cb00696644 100644
--- a/.buildkite/test_areas/pytorch.yaml
+++ b/.buildkite/test_areas/pytorch.yaml
@@ -5,7 +5,7 @@ steps:
- label: PyTorch Compilation Unit Tests
device: h200_35gb
key: pytorch-compilation-unit-tests
- timeout_in_minutes: 10
+ timeout_in_minutes: 90
source_file_dependencies:
- vllm/__init__.py
- vllm/_aiter_ops.py
@@ -78,7 +78,7 @@ steps:
- label: PyTorch Compilation Passes Unit Tests
key: pytorch-compilation-passes-unit-tests
- timeout_in_minutes: 20
+ timeout_in_minutes: 45
source_file_dependencies:
- vllm/__init__.py
- vllm/_aiter_ops.py
@@ -110,13 +110,13 @@ steps:
mirror:
amd:
device: mi300_1
- timeout_in_minutes: 180
+ timeout_in_minutes: 65
depends_on:
- image-build-amd
- label: PyTorch Fullgraph Smoke Test
key: pytorch-fullgraph-smoke-test
- timeout_in_minutes: 35
+ timeout_in_minutes: 60
source_file_dependencies:
- vllm/__init__.py
- vllm/_aiter_ops.py
@@ -152,7 +152,7 @@ steps:
- label: PyTorch Fullgraph
key: pytorch-fullgraph
- timeout_in_minutes: 30
+ timeout_in_minutes: 40
device: h200_18gb
source_file_dependencies:
- vllm/__init__.py
diff --git a/.buildkite/test_areas/quantization.yaml b/.buildkite/test_areas/quantization.yaml
index a92ee24f4aac..ce3e58e501b9 100644
--- a/.buildkite/test_areas/quantization.yaml
+++ b/.buildkite/test_areas/quantization.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Quantization
key: quantization
- timeout_in_minutes: 90
+ timeout_in_minutes: 60
source_file_dependencies:
- csrc/
- vllm/model_executor/layers/quantization
@@ -23,7 +23,7 @@ steps:
- label: Quantized Fusions
key: quantized-fusions
- timeout_in_minutes: 30
+ timeout_in_minutes: 20
source_file_dependencies:
- tests/fusion
- vllm/model_executor/layers/fusion
@@ -35,7 +35,7 @@ steps:
- label: Quantized MoE Test (B200)
key: quantized-moe-test-b200
- timeout_in_minutes: 60
+ timeout_in_minutes: 120
working_dir: "/vllm-workspace/"
device: b200-k8s
source_file_dependencies:
@@ -53,7 +53,7 @@ steps:
- label: Quantized Models Test
key: quantized-models-test
- timeout_in_minutes: 60
+ timeout_in_minutes: 50
source_file_dependencies:
- vllm/model_executor/layers/quantization
- tests/models/quantization
diff --git a/.buildkite/test_areas/rust_frontend.yaml b/.buildkite/test_areas/rust_frontend.yaml
index 1dfe912aa89e..9e5e09c3ec30 100644
--- a/.buildkite/test_areas/rust_frontend.yaml
+++ b/.buildkite/test_areas/rust_frontend.yaml
@@ -3,7 +3,7 @@ depends_on:
- image-build
steps:
- label: Rust Frontend OpenAI Coverage
- timeout_in_minutes: 90
+ timeout_in_minutes: 30
device: h200_18gb
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
@@ -38,7 +38,7 @@ steps:
- pytest -v -s v1/sample/test_logprobs_e2e.py -k "test_prompt_logprobs_e2e_server"
- label: Rust Frontend Serve/Admin Coverage
- timeout_in_minutes: 60
+ timeout_in_minutes: 25
device: h200_18gb
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
@@ -67,7 +67,7 @@ steps:
- pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info"
- label: Rust Frontend Core Correctness
- timeout_in_minutes: 30
+ timeout_in_minutes: 20
device: h200_18gb
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
@@ -81,7 +81,7 @@ steps:
- pytest -s entrypoints/openai/correctness/test_lmeval.py::test_lm_eval_accuracy_v1_engine
- label: Rust Frontend Tool Use
- timeout_in_minutes: 60
+ timeout_in_minutes: 25
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- rust/
@@ -95,7 +95,7 @@ steps:
- pytest -v -s tool_use --ignore=tool_use/mistral --models llama3.2 -k "not test_response_format_with_tool_choice_required and not test_parallel_tool_calls_false and not test_tool_call_and_choice"
- label: Rust Frontend Distributed
- timeout_in_minutes: 30
+ timeout_in_minutes: 25
num_devices: 4
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
diff --git a/.buildkite/test_areas/rust_frontend_cargo.yaml b/.buildkite/test_areas/rust_frontend_cargo.yaml
index 06f9eb9c245d..21d4c2ac2192 100644
--- a/.buildkite/test_areas/rust_frontend_cargo.yaml
+++ b/.buildkite/test_areas/rust_frontend_cargo.yaml
@@ -4,7 +4,7 @@ steps:
- label: Rust Frontend Cargo Style + Clippy
key: rust-frontend-cargo-style-clippy
depends_on: []
- timeout_in_minutes: 30
+ timeout_in_minutes: 20
device: cpu-medium
no_plugin: true
source_file_dependencies:
@@ -18,7 +18,7 @@ steps:
- label: Rust Frontend Cargo Tests
key: rust-frontend-cargo-tests
depends_on: []
- timeout_in_minutes: 30
+ timeout_in_minutes: 20
device: cpu-medium
no_plugin: true
source_file_dependencies:
diff --git a/.buildkite/test_areas/samplers.yaml b/.buildkite/test_areas/samplers.yaml
index 5abc16889434..2e7cd4a623e6 100644
--- a/.buildkite/test_areas/samplers.yaml
+++ b/.buildkite/test_areas/samplers.yaml
@@ -5,7 +5,7 @@ steps:
- label: Samplers Test
device: h200_35gb
key: samplers-test
- timeout_in_minutes: 75
+ timeout_in_minutes: 40
source_file_dependencies:
- vllm/model_executor/layers
- vllm/sampling_metadata.py
diff --git a/.buildkite/test_areas/spec_decode.yaml b/.buildkite/test_areas/spec_decode.yaml
index 671638f6f642..096c324bb8e2 100644
--- a/.buildkite/test_areas/spec_decode.yaml
+++ b/.buildkite/test_areas/spec_decode.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Spec Decode Eagle
key: spec-decode-eagle
- timeout_in_minutes: 30
+ timeout_in_minutes: 25
device: h200_18gb
source_file_dependencies:
- vllm/v1/spec_decode/
@@ -15,7 +15,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 45
+ timeout_in_minutes: 60
depends_on:
- image-build-amd
source_file_dependencies:
@@ -29,7 +29,7 @@ steps:
- label: Spec Decode Eagle Nightly B200
key: spec-decode-eagle-nightly-b200
- timeout_in_minutes: 30
+ timeout_in_minutes: 25
device: b200-k8s
optional: true
source_file_dependencies:
@@ -41,7 +41,7 @@ steps:
- label: Spec Decode Speculators + MTP
key: spec-decode-speculators-mtp
- timeout_in_minutes: 30
+ timeout_in_minutes: 20
device: h200_18gb
source_file_dependencies:
- vllm/v1/spec_decode/
@@ -82,7 +82,7 @@ steps:
- label: Spec Decode Ngram + Suffix
key: spec-decode-ngram-suffix
- timeout_in_minutes: 30
+ timeout_in_minutes: 20
device: h200_18gb
source_file_dependencies:
- vllm/v1/spec_decode/
@@ -93,7 +93,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 65
+ timeout_in_minutes: 55
# TODO(akaratza): Test after Torch >= 2.12 bump
soft_fail: true
depends_on:
@@ -109,7 +109,7 @@ steps:
- label: Spec Decode Draft Model
key: spec-decode-draft-model
- timeout_in_minutes: 30
+ timeout_in_minutes: 45
device: h200_18gb
source_file_dependencies:
- vllm/v1/spec_decode/
@@ -120,7 +120,7 @@ steps:
mirror:
amd:
device: mi325_1
- timeout_in_minutes: 50
+ timeout_in_minutes: 55
depends_on:
- image-build-amd
source_file_dependencies:
@@ -134,7 +134,7 @@ steps:
- label: Spec Decode Draft Model Nightly B200
key: spec-decode-draft-model-nightly-b200
- timeout_in_minutes: 30
+ timeout_in_minutes: 40
device: b200-k8s
optional: true
source_file_dependencies:
@@ -146,7 +146,7 @@ steps:
- label: Speculators Correctness
key: speculators-correctness
- timeout_in_minutes: 60
+ timeout_in_minutes: 30
device: h100
optional: true
num_devices: 1
@@ -159,7 +159,7 @@ steps:
- pytest -v -s v1/spec_decode/test_speculators_correctness.py -m slow_test
- label: Spec Decode MTP hybrid (B200)
- timeout_in_minutes: 30
+ timeout_in_minutes: 20
device: b200-k8s
optional: true
source_file_dependencies:
diff --git a/.buildkite/test_areas/weight_loading.yaml b/.buildkite/test_areas/weight_loading.yaml
index 9d7bd0bce91c..eeb24a49d8d3 100644
--- a/.buildkite/test_areas/weight_loading.yaml
+++ b/.buildkite/test_areas/weight_loading.yaml
@@ -4,7 +4,7 @@ depends_on:
steps:
- label: Weight Loading Multiple GPU # 33min
key: weight-loading-multiple-gpu
- timeout_in_minutes: 45
+ timeout_in_minutes: 50
working_dir: "/vllm-workspace/tests"
num_devices: 2
optional: true
From 216ee58780a85470971ac9be02d0fd2eabc942b7 Mon Sep 17 00:00:00 2001
From: wenjun liu
Date: Fri, 10 Jul 2026 15:56:00 +0800
Subject: [PATCH 0013/1526] Add XPU nightly and release image publishing to
DockerHub (#48126)
Signed-off-by: wenjun.liu
Signed-off-by: jun,du
Co-authored-by: jun,du
Co-authored-by: Kunshang Ji
---
.buildkite/release-pipeline.yaml | 18 ++++++++++++++++
.buildkite/scripts/publish-release-images.sh | 16 ++++++++++++++
.../scripts/xpu/push-nightly-builds-xpu.sh | 21 +++++++++++++++++++
3 files changed, 55 insertions(+)
create mode 100644 .buildkite/scripts/xpu/push-nightly-builds-xpu.sh
diff --git a/.buildkite/release-pipeline.yaml b/.buildkite/release-pipeline.yaml
index b1fe875bc2d8..1d3a1d4a5348 100644
--- a/.buildkite/release-pipeline.yaml
+++ b/.buildkite/release-pipeline.yaml
@@ -848,6 +848,23 @@ steps:
DOCKER_BUILDKIT: "1"
S3_BUCKET: "vllm-wheels"
+ - label: "Publish nightly XPU image to DockerHub"
+ depends_on:
+ - create-manifest-xpu
+ if: build.env("NIGHTLY") == "1"
+ agents:
+ queue: small_cpu_queue_release
+ commands:
+ - "bash .buildkite/scripts/xpu/push-nightly-builds-xpu.sh"
+ - "bash .buildkite/scripts/cleanup-nightly-builds.sh nightly- vllm/vllm-openai-xpu"
+ plugins:
+ - docker-login#v3.0.0:
+ username: vllmbot
+ password-env: DOCKERHUB_TOKEN
+ env:
+ DOCKER_BUILDKIT: "1"
+ DOCKERHUB_USERNAME: "vllmbot"
+
- label: "Publish nightly ROCm image to DockerHub"
depends_on:
- build-rocm-release-image
@@ -878,6 +895,7 @@ steps:
- create-multi-arch-manifest-cuda-12-9
- create-multi-arch-manifest-ubuntu2404
- create-multi-arch-manifest-cuda-12-9-ubuntu2404
+ - create-manifest-xpu
- build-rocm-release-image
- input-release-version
# Wait for CPU builds if their block steps were unblocked, so publish
diff --git a/.buildkite/scripts/publish-release-images.sh b/.buildkite/scripts/publish-release-images.sh
index ec319aa76006..91b5c3ace1be 100755
--- a/.buildkite/scripts/publish-release-images.sh
+++ b/.buildkite/scripts/publish-release-images.sh
@@ -130,6 +130,22 @@ docker tag public.ecr.aws/q9t5s3a7/vllm-release-repo:${ROCM_BASE_CACHE_KEY}-rocm
docker push vllm/vllm-openai-rocm:latest-base
docker push vllm/vllm-openai-rocm:v${RELEASE_VERSION}-base
+# ---- XPU ----
+
+docker pull public.ecr.aws/q9t5s3a7/vllm-release-repo:${COMMIT}-x86_64-xpu
+
+docker tag public.ecr.aws/q9t5s3a7/vllm-release-repo:${COMMIT}-x86_64-xpu vllm/vllm-openai-xpu:latest-x86_64
+docker tag public.ecr.aws/q9t5s3a7/vllm-release-repo:${COMMIT}-x86_64-xpu vllm/vllm-openai-xpu:v${RELEASE_VERSION}-x86_64
+docker push vllm/vllm-openai-xpu:latest-x86_64
+docker push vllm/vllm-openai-xpu:v${RELEASE_VERSION}-x86_64
+
+docker manifest rm vllm/vllm-openai-xpu:latest || true
+docker manifest rm vllm/vllm-openai-xpu:v${RELEASE_VERSION} || true
+docker manifest create vllm/vllm-openai-xpu:latest vllm/vllm-openai-xpu:latest-x86_64 --amend
+docker manifest create vllm/vllm-openai-xpu:v${RELEASE_VERSION} vllm/vllm-openai-xpu:v${RELEASE_VERSION}-x86_64 --amend
+docker manifest push vllm/vllm-openai-xpu:latest
+docker manifest push vllm/vllm-openai-xpu:v${RELEASE_VERSION}
+
# ---- CPU ----
# CPU images are behind separate block steps and may not have been built.
# All-or-nothing: inspect both arches first, then either publish everything
diff --git a/.buildkite/scripts/xpu/push-nightly-builds-xpu.sh b/.buildkite/scripts/xpu/push-nightly-builds-xpu.sh
new file mode 100644
index 000000000000..ca50f2543bba
--- /dev/null
+++ b/.buildkite/scripts/xpu/push-nightly-builds-xpu.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -ex
+
+ORIG_TAG_NAME="$BUILDKITE_COMMIT"
+REPO="vllm/vllm-openai-xpu"
+
+echo "Pushing original XPU tag ${ORIG_TAG_NAME}-xpu to nightly tags in ${REPO}"
+
+aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/q9t5s3a7
+docker pull public.ecr.aws/q9t5s3a7/vllm-release-repo:"$ORIG_TAG_NAME"-x86_64-xpu
+
+docker tag public.ecr.aws/q9t5s3a7/vllm-release-repo:"$ORIG_TAG_NAME"-x86_64-xpu ${REPO}:nightly-x86_64
+docker push ${REPO}:nightly-x86_64
+
+docker manifest rm ${REPO}:nightly || true
+docker manifest rm ${REPO}:nightly-"$BUILDKITE_COMMIT" || true
+docker manifest create ${REPO}:nightly ${REPO}:nightly-x86_64 --amend
+docker manifest create ${REPO}:nightly-"$BUILDKITE_COMMIT" ${REPO}:nightly-x86_64 --amend
+docker manifest push ${REPO}:nightly
+docker manifest push ${REPO}:nightly-"$BUILDKITE_COMMIT"
\ No newline at end of file
From 074bdd0d9961fb7f81c9311e00c00cfd5aff77a3 Mon Sep 17 00:00:00 2001
From: Bugen Zhao
Date: Fri, 10 Jul 2026 16:15:33 +0800
Subject: [PATCH 0014/1526] [Rust Frontend] Integrate MM video support (#47959)
Signed-off-by: Bugen Zhao
---
rust/Cargo.lock | 4 +-
rust/Cargo.toml | 4 +-
rust/src/chat/Cargo.toml | 1 +
rust/src/chat/src/backend/hf.rs | 17 +-
rust/src/chat/src/error.rs | 32 +-
rust/src/chat/src/multimodal.rs | 894 ++++++++----------
rust/src/chat/src/multimodal/expand.rs | 446 +++++++++
rust/src/chat/src/multimodal/image.rs | 141 +++
rust/src/chat/src/multimodal/tensor.rs | 46 +-
rust/src/chat/src/multimodal/video.rs | 316 +++++++
rust/src/chat/src/renderer/hf/mod.rs | 91 +-
rust/src/chat/src/request.rs | 37 +-
.../routes/openai/chat_completions/convert.rs | 21 +-
.../server/src/routes/openai/utils/types.rs | 6 +-
rust/src/server/src/routes/tests.rs | 12 +-
rust/src/text/src/backend/hf/model_files.rs | 19 +
16 files changed, 1571 insertions(+), 516 deletions(-)
create mode 100644 rust/src/chat/src/multimodal/expand.rs
create mode 100644 rust/src/chat/src/multimodal/image.rs
create mode 100644 rust/src/chat/src/multimodal/video.rs
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index cc055505dc4e..d155e07edadd 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -2167,7 +2167,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "llm-multimodal"
version = "1.7.1"
-source = "git+https://github.com/smg-project/llm-multimodal?rev=7d74582aeaf0e4086a44964382655d22f1af0686#7d74582aeaf0e4086a44964382655d22f1af0686"
+source = "git+https://github.com/smg-project/llm-multimodal?rev=c8a29dcc755139fdc26185f400ea48c6d6d48273#c8a29dcc755139fdc26185f400ea48c6d6d48273"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -2473,6 +2473,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"rawpointer",
+ "serde",
]
[[package]]
@@ -5093,6 +5094,7 @@ dependencies = [
"llm-multimodal",
"minijinja",
"minijinja-contrib",
+ "ndarray 0.17.2",
"oss-harmony",
"paste",
"reqwest",
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 435350c07117..db9764672f9e 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -53,12 +53,12 @@ hyper-util = { version = "0.1.20", features = [
indexmap = "2.13.0"
itertools = "0.14.0"
libc = "0.2.177"
-llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "7d74582aeaf0e4086a44964382655d22f1af0686" }
+llm-multimodal = { git = "https://github.com/smg-project/llm-multimodal", rev = "c8a29dcc755139fdc26185f400ea48c6d6d48273" }
mimalloc = "0.1.52"
minijinja = { version = "2.0", features = ["unstable_machinery", "json", "builtins", "loader", "loop_controls", "preserve_order"] }
minijinja-contrib = { version = "2.0", features = ["pycompat"] }
native-tls-vendored = { package = "native-tls", version = "0.2.18", features = ["vendored"] }
-ndarray = { version = "0.16.1", features = ["serde"] }
+ndarray = { version = "0.17", features = ["serde"] }
openai-harmony = { package = "oss-harmony", git = "https://github.com/oss-harmony/harmony", tag = "v0.0.11", default-features = false }
openai-protocol = "1.6.0"
openssl = "0.10"
diff --git a/rust/src/chat/Cargo.toml b/rust/src/chat/Cargo.toml
index 95ce5ff2e42c..00a6e223b775 100644
--- a/rust/src/chat/Cargo.toml
+++ b/rust/src/chat/Cargo.toml
@@ -42,6 +42,7 @@ anyhow.workspace = true
bytes.workspace = true
clap.workspace = true
expect-test.workspace = true
+ndarray.workspace = true
paste.workspace = true
rmp-serde.workspace = true
serial_test.workspace = true
diff --git a/rust/src/chat/src/backend/hf.rs b/rust/src/chat/src/backend/hf.rs
index fdfe8620b20a..47c0bfb78c82 100644
--- a/rust/src/chat/src/backend/hf.rs
+++ b/rust/src/chat/src/backend/hf.rs
@@ -10,7 +10,7 @@ use crate::backend::{
NewChatOutputProcessorOptions,
};
use crate::error::Result;
-use crate::multimodal::MultimodalModelInfo;
+use crate::multimodal::{MultimodalConfigFiles, MultimodalModelInfo};
use crate::output::{
DefaultChatOutputProcessor, HarmonyChatOutputProcessor, validate_harmony_parser_overrides,
};
@@ -46,8 +46,12 @@ impl HfChatBackend {
MultimodalModelInfo::from_paths(
model_id.clone(),
(!model_type.is_empty()).then_some(model_type.to_string()),
- files.config_path.as_deref(),
- files.preprocessor_config_path.as_deref(),
+ MultimodalConfigFiles {
+ config: files.config_path.as_deref(),
+ preprocessor_config: files.preprocessor_config_path.as_deref(),
+ video_preprocessor_config: files.video_preprocessor_config_path.as_deref(),
+ processor_config: files.processor_config_path.as_deref(),
+ },
tokenizer.clone(),
)?
};
@@ -139,8 +143,11 @@ pub(super) async fn load_model_backends(
fn resolve_multimodal_render_info(
info: Option<&MultimodalModelInfo>,
) -> Option {
+ use llm_multimodal::Modality;
+
info.map(|info| MultimodalRenderInfo {
- placeholder_token: info.placeholder_token().to_string(),
+ image_token: info.placeholder_token(Modality::Image).map(str::to_string),
+ video_token: info.placeholder_token(Modality::Video).map(str::to_string),
})
}
@@ -192,6 +199,8 @@ mod tests {
tokenizer_config_path: Some(tokenizer_config_path),
generation_config_path: None,
preprocessor_config_path: None,
+ video_preprocessor_config_path: None,
+ processor_config_path: None,
chat_template_path: None,
config_path: Some(config_path),
}
diff --git a/rust/src/chat/src/error.rs b/rust/src/chat/src/error.rs
index da2396c2198d..8e5d4ef84f89 100644
--- a/rust/src/chat/src/error.rs
+++ b/rust/src/chat/src/error.rs
@@ -1,5 +1,5 @@
use thiserror::Error;
-use thiserror_ext::Macro;
+use thiserror_ext::{AsReport as _, Macro};
type BoxedError = Box;
@@ -18,6 +18,8 @@ pub enum Error {
UnsupportedMultimodalRenderer,
#[error("unsupported multimodal content: {0}")]
UnsupportedMultimodalContent(&'static str),
+ #[error("`{modality}` input is not supported by this model")]
+ UnsupportedModality { modality: String },
#[error("multimodal preprocessing error: {0}")]
Multimodal(#[message] String),
#[error("{kind} parsing is not available for model `{model_id}`")]
@@ -80,11 +82,39 @@ impl Error {
match self {
Self::PromptTooLong { .. } => true,
Self::Text(error) => error.is_request_validation_error(),
+ Self::UnsupportedMultimodalRenderer
+ | Self::UnsupportedMultimodalContent(_)
+ | Self::UnsupportedModality { .. } => true,
+
_ => false,
}
}
}
+impl From for Error {
+ fn from(error: llm_multimodal::MediaConnectorError) -> Self {
+ Self::Multimodal(error.to_report_string())
+ }
+}
+
+impl From for Error {
+ fn from(error: llm_multimodal::MultiModalError) -> Self {
+ Self::Multimodal(error.to_report_string())
+ }
+}
+
+impl From for Error {
+ fn from(error: llm_multimodal::TransformError) -> Self {
+ Self::Multimodal(error.to_report_string())
+ }
+}
+
+impl From for Error {
+ fn from(error: llm_multimodal::registry::ModelRegistryError) -> Self {
+ Self::Multimodal(error.to_report_string())
+ }
+}
+
/// Format the available-parser suffix used in user-facing error messages.
fn available_parser_hint(available_names: &[String]) -> String {
if available_names.is_empty() {
diff --git a/rust/src/chat/src/multimodal.rs b/rust/src/chat/src/multimodal.rs
index 7d950d581da5..422696ae7107 100644
--- a/rust/src/chat/src/multimodal.rs
+++ b/rust/src/chat/src/multimodal.rs
@@ -1,8 +1,8 @@
-//! Chat-layer multimodal image preparation.
+//! Chat-layer multimodal media preparation.
//!
-//! This module owns the narrow image-only multimodal path for chat requests:
-//! it extracts image parts from structured chat messages, fetches and
-//! preprocesses them through `llm-multimodal`, expands rendered prompt
+//! This module owns the multimodal path for chat requests: it extracts media
+//! parts from structured chat messages, fetches and preprocesses them through
+//! `llm-multimodal` one modality at a time, expands rendered prompt
//! placeholders after tokenization, and builds the engine-facing
//! `MmFeatures` payload.
//!
@@ -16,19 +16,15 @@ use std::sync::{Arc, LazyLock};
use itertools::izip;
use llm_multimodal::{
- AsyncMultiModalTracker, FieldLayout, MediaConnector, MediaConnectorConfig, MediaContentPart,
- Modality, ModelMetadata, ModelProcessorSpec, ModelRegistry, PreProcessorConfig,
- PreprocessedEncoderInputs as PreprocessedImages, PromptReplacement, Tokenizer as TokenResolver,
- TrackedMedia, VisionPreProcessor as ImagePreProcessor,
- VisionProcessorRegistry as ImageProcessorRegistry,
+ AsyncMultiModalTracker, FieldLayout, ImageFrame, MediaConnector, MediaConnectorConfig,
+ MediaContentPart, Modality, ModelMetadata, ModelProcessorSpec, ModelRegistry,
+ PreProcessorConfig, PreprocessedEncoderInputs, PromptReplacement, Tokenizer as TokenResolver,
+ TrackedMedia, VideoClip, VisionPreProcessor, VisionProcessorRegistry,
};
+use thiserror_ext::AsReport as _;
use tracing::warn;
use vllm_engine_core_client::protocol::dtype::ModelDtype;
-use vllm_engine_core_client::protocol::multimodal::{
- MmBatchedField, MmFeatureSpec, MmFeatures, MmField, MmFieldElem, MmFlatField, MmKwargsItem,
- MmSharedField, MmSlice, PlaceholderRange, SliceSpec,
-};
-use vllm_engine_core_client::protocol::tensor::WireTensor;
+use vllm_engine_core_client::protocol::multimodal::{MmFeatureSpec, MmFeatures, MmKwargsItem};
use vllm_text::Prompt;
use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
@@ -36,14 +32,20 @@ use crate::error::{Error, Result, bail_multimodal, multimodal};
use crate::renderer::RenderedPrompt;
use crate::request::{ChatContent, ChatContentPart, ChatMessage, ChatRequest};
+mod expand;
+mod image;
mod tensor;
+mod video;
+
+use self::expand::expand_prompt_token_ids;
/// Resolved multimodal support for one loaded model.
#[derive(Clone)]
pub struct MultimodalModelInfo {
context: MultimodalModelContext,
spec: ResolvedMultimodalSpec,
- image_processor: ResolvedImageProcessor,
+ image: Option,
+ video: Option,
media_connector: Arc,
}
@@ -75,91 +77,171 @@ impl MultimodalModelContext {
REGISTRY.lookup(&self.metadata())
}
- /// Resolve a static image preprocessor for one loaded model.
- fn resolve_image_processor(&self) -> Option<&'static dyn ImagePreProcessor> {
- static REGISTRY: LazyLock =
- LazyLock::new(ImageProcessorRegistry::with_defaults);
+ /// Resolve a static vision preprocessor for one loaded model.
+ ///
+ /// The vision preprocessor serves both the image and video modalities.
+ fn resolve_vision_processor(&self) -> Option<&'static dyn VisionPreProcessor> {
+ static REGISTRY: LazyLock =
+ LazyLock::new(VisionProcessorRegistry::with_defaults);
REGISTRY.find(&self.model_id, self.model_type.as_deref())
}
}
-/// Static model-specific prompt and tensor-layout behavior.
+/// Static model-specific tensor-layout behavior shared across modalities.
#[derive(Clone)]
struct ResolvedMultimodalSpec {
raw: &'static dyn ModelProcessorSpec,
- placeholder_token: String,
- placeholder_marker_token_id: u32,
- placeholder_embed_token_id: u32,
field_layouts: HashMap,
keep_on_cpu_keys: HashSet,
}
impl ResolvedMultimodalSpec {
- fn new(raw: &'static dyn ModelProcessorSpec, context: &MultimodalModelContext) -> Result {
- let metadata = context.metadata();
- let placeholder_token =
- raw.placeholder_token(&metadata).map_err(|error| multimodal!("{error}"))?;
- // This is the rendered prompt marker, so resolve it from the token
- // string itself. Do not use `ModelProcessorSpec::placeholder_token_id()`:
- // for some specs that ID is the replacement vision/patch token,
- // not necessarily the token ID of `placeholder_token`.
- let placeholder_marker_token_id =
- context.tokenizer().token_to_id(&placeholder_token).ok_or_else(|| {
- multimodal!(
- "placeholder token `{placeholder_token}` is not in the tokenizer vocabulary"
- )
- })?;
- let placeholder_embed_token_id =
- raw.placeholder_token_id(&metadata).map_err(|error| multimodal!("{error}"))? as u32;
-
- Ok(Self {
+ fn new(raw: &'static dyn ModelProcessorSpec) -> Self {
+ Self {
raw,
- placeholder_token,
- placeholder_marker_token_id,
- placeholder_embed_token_id,
field_layouts: raw.field_layouts(),
keep_on_cpu_keys: raw.keep_on_cpu_keys().into_iter().collect(),
- })
+ }
}
- fn prompt_replacements(
+ fn prompt_replacements_for(
&self,
context: &MultimodalModelContext,
- preprocessed: &PreprocessedImages,
+ preprocessed: &PreprocessedEncoderInputs,
+ modality: Modality,
) -> Result> {
- self.raw
- .prompt_replacements(&context.metadata(), preprocessed)
- .map_err(|error| multimodal!("{error}"))
+ Ok(self.raw.prompt_replacements_for(&context.metadata(), preprocessed, modality)?)
}
}
-/// Static image preprocessor plus its loaded config.
+/// Resolved placeholder tokens for one modality.
#[derive(Clone)]
-struct ResolvedImageProcessor {
- raw: &'static dyn ImagePreProcessor,
+struct ResolvedPlaceholder {
+ token: String,
+ /// The token ID emitted for `token` in the rendered prompt.
+ marker_token_id: u32,
+ /// The model-declared embed token ID marked in `is_embed` masks.
+ embed_token_id: u32,
+}
+
+impl ResolvedPlaceholder {
+ fn resolve(
+ raw: &'static dyn ModelProcessorSpec,
+ context: &MultimodalModelContext,
+ modality: Modality,
+ ) -> Result {
+ let metadata = context.metadata();
+ let token = raw.placeholder_token_for(&metadata, modality)?;
+ // This is the rendered prompt marker, so resolve it from the token
+ // string itself. Do not use `ModelProcessorSpec::placeholder_token_id_for()`:
+ // for some specs that ID is the replacement vision/patch token,
+ // not necessarily the token ID of the placeholder token.
+ let marker_token_id = context.tokenizer().token_to_id(&token).ok_or_else(|| {
+ multimodal!("placeholder token `{token}` is not in the tokenizer vocabulary")
+ })?;
+ let embed_token_id = raw.placeholder_token_id_for(&metadata, modality)? as u32;
+
+ Ok(Self {
+ token,
+ marker_token_id,
+ embed_token_id,
+ })
+ }
+}
+
+/// Static per-modality vision preprocessor plus its loaded config and
+/// resolved placeholder tokens.
+#[derive(Clone)]
+struct ModalitySupport {
+ placeholder: ResolvedPlaceholder,
+ processor: &'static dyn VisionPreProcessor,
config: PreProcessorConfig,
}
-/// Request-scoped fetched media, kept together with tracker UUID metadata.
-struct FetchedImageMedia {
- frames: Vec>,
- uuids: Vec
+You can also trigger this build from the command line with
+[`.buildkite/scripts/trigger-ci-build.sh`](../../../.buildkite/scripts/trigger-ci-build.sh)
+(dry-run by default; pass `--execute` to actually trigger it).
+
+## Test against PyTorch nightly
+
+The steps above test against a specific PyTorch RC/stable wheel pinned in the
+requirements files. To instead build and run the CI suite against the latest
+PyTorch **nightly** wheels, set the `TORCH_NIGHTLY=1` environment variable on
+the build (or apply the `ready-torch-nightly` label to the PR).
+
+When `TORCH_NIGHTLY=1`, the base CI image is built against PyTorch nightly
+(`image_build_torch_nightly.sh`, `PYTORCH_NIGHTLY=1`, CUDA 13.0) and tagged at
+the normal image tag, so the entire existing pipeline runs on nightly torch --
+there is no separate pipeline section to trigger. Combine it with `RUN_ALL=1`
+to run the full suite (the `ready-torch-nightly` label and
+`trigger-ci-build.sh --torch-nightly` both set this for you). This is the
+configuration to use for a scheduled "vLLM vs PyTorch nightly" run.
+
+Use `.buildkite/scripts/trigger-ci-build.sh --torch-nightly` to trigger it from
+the command line.
+
## Update all the different vLLM platforms
Rather than attempting to update all vLLM platforms in a single pull request, it's more manageable
From e23b19309b8705b21c3b3ff4129c9974ba15a419 Mon Sep 17 00:00:00 2001
From: ViranjanPagar
Date: Fri, 10 Jul 2026 14:53:30 +0530
Subject: [PATCH 0017/1526] Deepstream video backend (#42424)
Signed-off-by: Viranjan Pagar
Signed-off-by: Isotr0py
Signed-off-by: Isotr0py
Co-authored-by: Claude Opus 4.8 (1M context)
Co-authored-by: Isotr0py
Co-authored-by: Isotr0py
Co-authored-by: Roger Wang
---
docs/features/multimodal_inputs.md | 49 +++++++++
setup.py | 3 +
vllm/multimodal/video.py | 171 +++++++++++++++++++++++++++--
3 files changed, 212 insertions(+), 11 deletions(-)
diff --git a/docs/features/multimodal_inputs.md b/docs/features/multimodal_inputs.md
index e44596626f80..df33cea05426 100644
--- a/docs/features/multimodal_inputs.md
+++ b/docs/features/multimodal_inputs.md
@@ -879,6 +879,55 @@ vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
Works with common video formats like MP4 when using OpenCV backends.
+#### GPU Video Decoding with DeepStream (NVDEC)
+
+By default vLLM decodes video on the CPU. On NVIDIA GPUs you can instead decode
+directly on the hardware video engine (NVDEC) with the DeepStream backend, which
+keeps decoding off the CPU and can significantly increase video throughput.
+
+Install the backend (Linux x86-64 only):
+
+```bash
+pip install vllm[deepstream]
+```
+
+The pip wheel bundles the DeepStream libraries but still relies on a few system
+packages that pip cannot install. On Ubuntu:
+
+```bash
+apt-get install -y \
+ gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
+ gstreamer1.0-plugins-bad gstreamer1.0-libav \
+ python3-gi python3-gst-1.0 libv4l-0 cuda-libraries-13-0
+```
+
+Select the backend either with an environment variable:
+
+```bash
+export VLLM_VIDEO_LOADER_BACKEND=deepstream
+vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct
+```
+
+or per request via `--media-io-kwargs`:
+
+```bash
+vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
+ --media-io-kwargs '{"video": {"backend": "deepstream"}}'
+```
+
+**Parameters:**
+
+- `pool_size`: Number of GPU decode workers in the process-wide decode pool
+ (clamped to `[1, 16]`). When unset it defaults to
+ `VLLM_MEDIA_LOADING_THREAD_COUNT` (default `8`). The pool is a singleton, so
+ the first request's value wins.
+
+```bash
+# Example: 12 decode workers
+vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
+ --media-io-kwargs '{"video": {"backend": "deepstream", "pool_size": 12}}'
+```
+
#### Pre-extracted Frame Sequences with `media_io_kwargs`
When you extract video frames on the client side and send them as `video/jpeg` (base64-concatenated JPEG frames), you can preserve the original video metadata by using `media_io_kwargs` in your request. This enables more accurate video understanding by preserving temporal information that would otherwise be lost during client-side frame extraction.
diff --git a/setup.py b/setup.py
index b305fb1b00f2..e8f529701845 100644
--- a/setup.py
+++ b/setup.py
@@ -1257,6 +1257,9 @@ def add_vllm_package_data(filename: str) -> None:
"mistral_common[audio]",
], # Required for audio processing
"video": [], # Kept for backwards compatibility
+ # NVIDIA DeepStream (NVDEC) GPU video-decode backend. Linux x86-64
+ # only; also needs system GStreamer + libv4l (see docs).
+ "deepstream": ["nvidia-deepstream-videodecode-cu13>=9.0.2"],
"flashinfer": [], # Kept for backwards compatibility
# Optional deps for Helion kernel development
# NOTE: When updating helion version, also update CI files:
diff --git a/vllm/multimodal/video.py b/vllm/multimodal/video.py
index aab8bfd3b3c5..ca111f0b3f86 100644
--- a/vllm/multimodal/video.py
+++ b/vllm/multimodal/video.py
@@ -826,6 +826,114 @@ def decode_frames_pynvvideocodec(
return frames, source, frame_idx, valid_frame_indices
+class DeepStreamVideoBackendMixin:
+ """NVIDIA DeepStream (NVDEC) GPU-decode codec utilities.
+
+ Decoding runs on a shared pool of daemon threads inside one CUDA
+ context (see the ``nvidia-deepstream-videodecode-cu13`` package). The
+ container bytes are pushed into an ``appsrc`` GStreamer pipeline, so no
+ local file path is required — HTTP and base64 sources decode identically
+ to local files.
+
+ Like the OpenCV/PyAV mixins, this provides only the codec layer.
+ Frame *selection* lives in the loader's
+ ``compute_frames_index_to_sample`` and arrives here as an explicit
+ list of frame indices.
+ """
+
+ # Process-wide lazy decode pool, shared across all DeepStream backends.
+ _pool: ClassVar[Any] = None
+ _pool_lock: ClassVar[Any] = None
+
+ @classmethod
+ def _get_pool(cls, pool_size: int | None = None):
+ """Lazy-initialize the shared decode pool on first use.
+
+ ``pool_size`` (number of decode worker threads) comes from
+ ``--media-io-kwargs`` (``{"video": {"pool_size": N}}``); when unset it
+ defaults to the existing ``VLLM_MEDIA_LOADING_THREAD_COUNT`` so no
+ DeepStream-specific env var is needed. The pool is a process-wide
+ singleton, so the first decode's value wins.
+ """
+ if cls._pool is not None:
+ return cls._pool
+ if cls._pool_lock is None:
+ cls._pool_lock = threading.Lock()
+ with cls._pool_lock:
+ if cls._pool is not None:
+ return cls._pool
+ import os
+
+ from nvidia.deepstream_videodecode import DecodePool
+
+ if pool_size is None:
+ pool_size = int(os.environ.get("VLLM_MEDIA_LOADING_THREAD_COUNT", 8))
+ pool_size = max(1, min(int(pool_size), 16))
+ logger.info(
+ "[DeepStream] initializing decode pool with %d workers",
+ pool_size,
+ )
+ cls._pool = DecodePool(num_workers=pool_size)
+ return cls._pool
+
+ @classmethod
+ def decode_indices(
+ cls,
+ data: bytes,
+ frame_indices: list[int],
+ source: VideoSourceMetadata,
+ codec: str = "",
+ pool_size: int | None = None,
+ timeout_sec: float = 120.0,
+ ) -> tuple[npt.NDArray, list[int]]:
+ """Decode the requested frame indices from raw container bytes.
+
+ The whole stream is decoded; the pool keeps exactly the frames whose
+ decode-order index is in ``frame_indices`` (1:1, frame-exact) and
+ sends EOS once the last one is matched.
+
+ ``codec`` (e.g. ``"h264"``/``"hevc"``) lets the pool keep its NVDEC
+ session warm across same-codec streams and rebuild only on a codec
+ change. Frames are returned as a CPU NHWC uint8 array so the
+ upstream multimodal parser sees the same shape as the other
+ backends.
+ """
+ if not frame_indices:
+ raise ValueError("DeepStream backend received no frame indices")
+
+ result = cls._get_pool(pool_size).decode(
+ data,
+ target_indices=frame_indices,
+ codec=codec,
+ max_frames=len(frame_indices),
+ timeout_sec=timeout_sec,
+ )
+ if result.error:
+ raise ValueError(f"DeepStream decode failed: {result.error}")
+ if result.frames is None or result.n_kept == 0:
+ raise ValueError("DeepStream decode produced no frames")
+
+ valid = frame_indices[: result.n_kept]
+ # GPU -> CPU NHWC uint8 at the codec boundary (one PCIe copy); keeps
+ # the array shape identical to the OpenCV/PyAV backends. Copy into
+ # PINNED host memory (reused across calls by PyTorch's pinned caching
+ # allocator) so the D2H runs at full PCIe bandwidth (~13 GB/s) rather
+ # than the ~1 GB/s pageable path that plain ``.cpu()`` takes — ~12x
+ # faster for a 1080p x8 frame batch (~46ms -> ~4ms). ``numpy()`` keeps
+ # the pinned tensor alive via the array's base.
+ import torch
+
+ gpu = result.frames
+ if gpu.is_cuda:
+ host = torch.empty(gpu.shape, dtype=gpu.dtype, pin_memory=True)
+ host.copy_(gpu, non_blocking=True)
+ torch.cuda.current_stream().synchronize()
+ arr = host.numpy()
+ else:
+ arr = gpu.numpy()
+ return arr, valid
+
+
@VIDEO_LOADER_REGISTRY.register("opencv")
class VideoBackend(
VideoLoader,
@@ -833,14 +941,15 @@ class VideoBackend(
PyAVVideoBackendMixin,
TorchCodecVideoBackendMixin,
PyNvVideoCodecVideoBackendMixin,
+ DeepStreamVideoBackendMixin,
):
"""Uniform-sampling video backend.
Samples ``num_frames`` uniformly across the video (or one frame every
``1/fps`` seconds, whichever produces fewer frames). The decoding codec
is selected via the ``backend`` kwarg (``"opencv"``, ``"pyav"``,
- ``"torchcodec"`` or ``"pynvvideocodec"``), which can be passed through
- ``--media-io-kwargs``. Defaults to ``"opencv"``.
+ ``"torchcodec"``, ``"pynvvideocodec"``, or ``"deepstream"``),
+ which can be passed through ``--media-io-kwargs``. Defaults to ``"opencv"``.
"""
_sampling_suffix: ClassVar[str] = ""
@@ -885,7 +994,9 @@ def load_bytes(
max_duration: int = 300,
frame_recovery: bool = False,
*,
- backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
+ backend: Literal[
+ "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
+ ] = "opencv",
num_ffmpeg_threads: int = 0,
seek_mode: Literal["exact", "approximate"] = "exact",
**kwargs,
@@ -901,7 +1012,7 @@ def load_bytes(
frame_recovery: Enable forward-scan recovery for failed frames.
Only honored by the OpenCV codec.
backend: Decoding codec — ``"opencv"``, ``"pyav"``,
- ``"torchcodec"`` or ``"pynvvideocodec"``.
+ ``"torchcodec"``, ``"pynvvideocodec"`` or ``"deepstream"``.
num_ffmpeg_threads: Number of FFmpeg decoding threads, only used by
TorchCodec: ``0`` (default) relies on the FFmpeg default value
which is ``min(cpu_count + 1, 16)``.
@@ -982,11 +1093,37 @@ def load_bytes(
target,
**kwargs,
)
+ elif backend == "deepstream":
+ assert not frame_recovery, (
+ "frame_recovery is only available for `opencv` backend"
+ )
+ # Decode-pool size comes from media-io-kwargs (no env var); the
+ # pool is a process-wide singleton so the first decode's value
+ # wins. Pop it so it isn't forwarded to the frame sampler.
+ pool_size = kwargs.pop("pool_size", None)
+ # Probe container metadata from the bytes via GStreamer (in
+ # the deepstream video-decode wheel) — no PyAV/pymediainfo, no path.
+ from nvidia.deepstream_videodecode import probe_metadata
+
+ total_frames, original_fps, duration, _w, _h, codec = probe_metadata(data)
+ source = cls._prepare_source(
+ VideoSourceMetadata(
+ total_frames_num=total_frames,
+ original_fps=original_fps,
+ duration=duration,
+ )
+ )
+ frame_idx = cls.compute_frames_index_to_sample(
+ source=source, target=target, **kwargs
+ )
+ frames, valid = cls.decode_indices(
+ data, frame_idx, source, codec=codec, pool_size=pool_size
+ )
else:
raise ValueError(
f"Unknown video codec backend {backend!r}; "
"valid options: 'opencv', 'pyav', 'torchcodec', "
- "'pynvvideocodec'."
+ "'pynvvideocodec' and 'deepstream'."
)
if len(valid) < len(frame_idx):
@@ -1073,7 +1210,9 @@ def load_bytes(
max_duration: int = 300,
frame_recovery: bool = False,
*,
- backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
+ backend: Literal[
+ "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
+ ] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
@@ -1152,7 +1291,9 @@ def load_bytes(
max_duration: int = 300,
frame_recovery: bool = False,
*,
- backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
+ backend: Literal[
+ "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
+ ] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
@@ -1244,7 +1385,9 @@ def load_bytes(
max_duration: int = 300,
frame_recovery: bool = False,
*,
- backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
+ backend: Literal[
+ "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
+ ] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
@@ -1369,7 +1512,9 @@ def load_bytes(
max_duration: int = 300,
frame_recovery: bool = False,
*,
- backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
+ backend: Literal[
+ "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
+ ] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
@@ -1467,7 +1612,9 @@ def load_bytes(
max_duration: int = 300,
frame_recovery: bool = False,
*,
- backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
+ backend: Literal[
+ "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
+ ] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
frames, metadata = super().load_bytes(
@@ -1790,7 +1937,9 @@ def load_bytes(
max_duration: int = 300,
frame_recovery: bool = False,
*,
- backend: Literal["opencv", "pyav", "torchcodec", "pynvvideocodec"] = "opencv",
+ backend: Literal[
+ "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
+ ] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
frames, metadata = super().load_bytes(
From c241c7a2b015f8168d1c75e80cee15e45c18ba94 Mon Sep 17 00:00:00 2001
From: Reid <61492567+reidliu41@users.noreply.github.com>
Date: Fri, 10 Jul 2026 18:03:58 +0800
Subject: [PATCH 0018/1526] [Rust Frontend] Add roundtrip fixtures for more
chat parsers (#47883)
Co-authored-by: Bugen Zhao
Signed-off-by: reidliu41
Signed-off-by: Bugen Zhao
---
rust/src/chat/tests/roundtrip.rs | 42 ++++++++++++++++++++++++++++++++
1 file changed, 42 insertions(+)
diff --git a/rust/src/chat/tests/roundtrip.rs b/rust/src/chat/tests/roundtrip.rs
index b1670814ef62..83cf9002be28 100644
--- a/rust/src/chat/tests/roundtrip.rs
+++ b/rust/src/chat/tests/roundtrip.rs
@@ -117,6 +117,19 @@ impl RoundtripCase {
}
}
+ /// MiniMax M3 invoke format with `` reasoning tags.
+ fn minimax_m3() -> Self {
+ Self {
+ model_id: "MiniMaxAI/MiniMax-M3",
+ assistant_stop_suffix: "[e~[\n",
+ tool_call_parser: ParserSelection::Auto,
+ reasoning_parser: ParserSelection::Auto,
+ thinking_behavior: ThinkingBehavior::Always { value: true },
+ json_fmt: compact_json_fmt(),
+ sort_json_keys: false,
+ }
+ }
+
/// DeepSeek V4 DSML tool-call format.
fn deepseek_v4() -> Self {
Self {
@@ -143,6 +156,19 @@ impl RoundtripCase {
}
}
+ /// GLM-4.5 XML-like argument format with `` reasoning tags.
+ fn glm45() -> Self {
+ Self {
+ model_id: "zai-org/GLM-4.5",
+ assistant_stop_suffix: "",
+ tool_call_parser: ParserSelection::Auto,
+ reasoning_parser: ParserSelection::Auto,
+ thinking_behavior: ThinkingBehavior::Toggleable { default: true },
+ json_fmt: compact_json_fmt(),
+ sort_json_keys: false,
+ }
+ }
+
/// GLM-4.7 XML-like argument format with `` reasoning tags.
fn glm47() -> Self {
Self {
@@ -209,6 +235,19 @@ impl RoundtripCase {
}
}
+ /// Nemotron V3 with `` / `` reasoning tags.
+ fn nemotron_v3() -> Self {
+ Self {
+ model_id: "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16",
+ assistant_stop_suffix: "<|im_end|>\n",
+ tool_call_parser: ParserSelection::Auto,
+ reasoning_parser: ParserSelection::Auto,
+ thinking_behavior: ThinkingBehavior::Always { value: true },
+ json_fmt: compact_json_fmt(),
+ sort_json_keys: false,
+ }
+ }
+
/// GPT-OSS Harmony token-id renderer and native Harmony output processor.
fn gpt_oss() -> Self {
Self {
@@ -247,11 +286,14 @@ roundtrip_tests! {
qwen3 => [reasoning_and_content, tool_call_mix],
qwen35 => [reasoning_and_content, tool_call_mix],
minimax_m25 => [reasoning_and_content, tool_call_mix],
+ minimax_m3 => [reasoning_and_content, tool_call_mix],
deepseek_v4 => [reasoning_and_content, tool_call_mix],
deepseek_v32 => [tool_call_mix],
+ glm45 => [reasoning_and_content, tool_call_mix],
glm47 => [reasoning_and_content, tool_call_mix],
seed_oss => [reasoning_and_content],
step3p5 => [reasoning_and_content],
+ nemotron_v3 => [reasoning_and_content],
gemma4 => [tool_call_mix], // Gemma4 strips reasoning in history if there's no tool call
kimi_k25 => [tool_call_mix], // Kimi K2.5 strips reasoning in history
gpt_oss => [tool_call_mix], // Harmony strips reasoning in history if there's no tool call
From 68ea76e78005bb53d9ef6b855b6c23411f5bb1e1 Mon Sep 17 00:00:00 2001
From: Isotr0py
Date: Fri, 10 Jul 2026 19:17:42 +0800
Subject: [PATCH 0019/1526] [Misc] Remove dead code in ViT functionality test
(#48220)
Signed-off-by: Isotr0py
---
.../test_vit_backend_functionality.py | 438 ------------------
1 file changed, 438 deletions(-)
delete mode 100644 tests/models/multimodal/generation/test_vit_backend_functionality.py
diff --git a/tests/models/multimodal/generation/test_vit_backend_functionality.py b/tests/models/multimodal/generation/test_vit_backend_functionality.py
deleted file mode 100644
index ad912067a1f0..000000000000
--- a/tests/models/multimodal/generation/test_vit_backend_functionality.py
+++ /dev/null
@@ -1,438 +0,0 @@
-# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
-"""
-Consolidated test for ViT attention backend functionality across multiple models.
-
-This test validates that each multimodal model can successfully generate outputs
-using different ViT attention backends. Tests are parametrized by model and backend.
-"""
-
-from typing import Any
-
-import pytest
-from transformers import AutoProcessor
-
-from vllm import LLM, SamplingParams
-from vllm.multimodal.utils import encode_image_url
-from vllm.multimodal.video import sample_frames_from_video
-from vllm.platforms import current_platform
-from vllm.v1.attention.backends.registry import AttentionBackendEnum
-
-from ....utils import create_new_process_for_each_test
-from ...utils import dummy_hf_overrides
-
-# Dots.OCR prompt from official repository
-# https://github.com/rednote-hilab/dots.ocr/blob/d72d1d8c5bdd0362eb264f714cdbd1e5daa7cdff/dots_ocr/utils/prompts.py#L3
-# ruff: noqa: E501
-DOTS_OCR_PROMPT = """Please output the layout information from the PDF image, including each layout element's bbox, its category, and the corresponding text content within the bbox.
-
-1. Bbox format: [x1, y1, x2, y2]
-
-2. Layout Categories: The possible categories are ['Caption', 'Footnote', 'Formula', 'List-item', 'Page-footer', 'Page-header', 'Picture', 'Section-header', 'Table', 'Text', 'Title'].
-
-3. Text Extraction & Formatting Rules:
- - Picture: For the 'Picture' category, the text field should be omitted.
- - Formula: Format its text as LaTeX.
- - Table: Format its text as HTML.
- - All Others (Text, Title, etc.): Format their text as Markdown.
-
-4. Constraints:
- - The output text must be the original text from the image, with no translation.
- - All layout elements must be sorted according to human reading order.
-
-5. Final Output: The entire output must be a single JSON object.
-"""
-
-VIDEO_PLACEHOLDER = "<|vision_start|><|video_pad|><|vision_end|>"
-
-
-# Model configurations
-MODEL_CONFIGS: dict[str, dict[str, Any]] = {
- "dots_ocr": {
- "model_name": "rednote-hilab/dots.ocr",
- "interface": "llm_chat",
- "max_model_len": 32768,
- "max_num_seqs": 1,
- "limit_mm_per_prompt": {"image": 1},
- "sampling_params": {
- "temperature": 0.1,
- "max_tokens": 16384,
- "top_p": 0.9,
- "stop_token_ids": None,
- },
- "use_specific_image": "stop_sign",
- "prompt_builder": "build_dots_ocr_prompt",
- "output_validator": lambda x: len(x) > 10 and "stop" in x.lower(),
- },
- "ernie45_vl": {
- "model_name": "baidu/ERNIE-4.5-VL-28B-A3B-PT",
- "interface": "llm_generate",
- "max_model_len": 16384,
- "max_num_seqs": 2,
- "sampling_params": {
- "temperature": 0.0,
- "max_tokens": 256,
- "stop_token_ids": None,
- },
- "use_processor": True,
- "question": "What is the content of each image?",
- },
- "glm4_1v": {
- "model_name": "zai-org/GLM-4.1V-9B-Thinking",
- "interface": "llm_generate",
- "max_model_len": 32768,
- "max_num_seqs": 2,
- "sampling_params": {
- "temperature": 0.0,
- "max_tokens": 256,
- "stop_token_ids": None,
- },
- "use_processor": True,
- "question": "What is the content of each image?",
- },
- "glm_ocr": {
- "model_name": "zai-org/GLM-OCR",
- "interface": "llm_generate",
- "max_model_len": 131072,
- "max_num_seqs": 2,
- "sampling_params": {
- "temperature": 0.0,
- "max_tokens": 256,
- "stop_token_ids": None,
- },
- "use_processor": True,
- "question": "Text Recognition:",
- },
- "keye_vl": {
- "model_name": "Kwai-Keye/Keye-VL-8B-Preview",
- "interface": "llm_generate",
- "max_model_len": 8192,
- "max_num_seqs": 5,
- "sampling_params": {
- "temperature": 0.0,
- "max_tokens": 256,
- "stop_token_ids": None,
- },
- "supported_backends": {
- AttentionBackendEnum.FLASH_ATTN,
- AttentionBackendEnum.ROCM_AITER_FA,
- },
- "use_processor": True,
- "question": "What is the content of each image?",
- },
- "ovis2_5": {
- "model_name": "AIDC-AI/Ovis2.5-2B",
- "interface": "llm_generate",
- "max_model_len": 8192,
- "max_num_seqs": 2,
- "sampling_params": {
- "temperature": 0.0,
- "max_tokens": 256,
- "stop_token_ids": None,
- },
- "prompt_builder": "build_ovis_prompt",
- "question": "What is the content of each image?",
- },
- "qwen2_5_vl": {
- "model_name": "Qwen/Qwen2.5-VL-3B-Instruct",
- "interface": "vllm_runner",
- "media_type": "video",
- "max_model_len": 4000,
- "max_num_seqs": 1,
- "limit_mm_per_prompt": {"video": 1},
- "sampling_params": {
- "max_tokens": 128,
- },
- "runner_kwargs": {
- "runner": "generate",
- "dtype": "bfloat16",
- },
- "video_params": {
- "num_frames": 16,
- "pruning_rates": [0.0, 0.75],
- },
- },
- "qwen2_5_omni": {
- "model_name": "Qwen/Qwen2.5-Omni-3B",
- "interface": "llm_generate",
- "max_model_len": 32768,
- "max_num_seqs": 2,
- "limit_mm_per_prompt": {"image": 3, "video": 3, "audio": 3},
- "sampling_params": {
- "temperature": 0.6,
- "top_p": 0.95,
- "top_k": 20,
- "max_tokens": 16384,
- },
- "use_processor": True,
- "question": "What is the content of each image?",
- },
- "qwen3_omni": {
- "model_name": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
- "interface": "llm_generate",
- "max_model_len": 32768,
- "max_num_seqs": 2,
- "limit_mm_per_prompt": {"image": 3, "video": 3, "audio": 3},
- "sampling_params": {
- "temperature": 0.6,
- "top_p": 0.95,
- "top_k": 20,
- "max_tokens": 16384,
- },
- "use_processor": True,
- "question": "What is the content of each image?",
- },
-}
-
-
-# Prompt builder functions
-def build_dots_ocr_prompt(images, config):
- """Build Dots.OCR specific prompt with OCR instructions."""
- # Use only stop_sign image for Dots.OCR
- image = images[0] # Already filtered to stop_sign
- image_url = encode_image_url(image)
-
- placeholders = [{"type": "image_url", "image_url": {"url": image_url}}]
- messages = [
- {
- "role": "user",
- "content": [
- *placeholders,
- {
- "type": "text",
- "text": f"<|img|><|imgpad|><|endofimg|>{DOTS_OCR_PROMPT}",
- },
- ],
- },
- ]
-
- return messages
-
-
-def build_processor_prompt(images, config):
- """Build prompt using AutoProcessor.apply_chat_template()."""
- processor = AutoProcessor.from_pretrained(
- config["model_name"], trust_remote_code=True
- )
-
- image_urls = [encode_image_url(img) for img in images]
- placeholders = [{"type": "image", "image": url} for url in image_urls]
- messages = [
- {
- "role": "user",
- "content": [
- *placeholders,
- {"type": "text", "text": config["question"]},
- ],
- },
- ]
-
- return processor.apply_chat_template(
- messages, tokenize=False, add_generation_prompt=True
- )
-
-
-def build_ovis_prompt(images, config):
- """Build Ovis2.5 specific prompt with custom format."""
- image_urls = [encode_image_url(img) for img in images]
-
- placeholders = "\n".join(
- f"Image-{i}: \n" for i, _ in enumerate(image_urls, start=1)
- )
-
- return (
- f"<|im_start|>user\n\n{placeholders}\n{config['question']}<|im_end|>\n"
- "<|im_start|>assistant\n"
- )
-
-
-def build_qwen2_5_video_prompt():
- """Build Qwen2.5-VL video prompt with EVS placeholder."""
- return (
- f"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
- f"<|im_start|>user\n{VIDEO_PLACEHOLDER}"
- "Describe this video with a short sentence (no more than 20 words)"
- "<|im_end|><|im_start|>assistant\n"
- )
-
-
-# Handler functions
-def run_llm_generate_test(config, mm_encoder_attn_backend, image_assets):
- """Standard LLM.generate() interface handler."""
- images = [asset.pil_image for asset in image_assets]
-
- # Build prompt
- if config.get("use_processor"):
- prompt = build_processor_prompt(images, config)
- else:
- prompt_builder_name = config.get("prompt_builder", "build_ovis_prompt")
- prompt_builder = globals()[prompt_builder_name]
- prompt = prompt_builder(images, config)
-
- # Determine limit_mm_per_prompt
- limit_mm_per_prompt = config.get("limit_mm_per_prompt", {"image": len(images)})
-
- # Create engine
- llm = LLM(
- model=config["model_name"],
- trust_remote_code=True,
- max_model_len=config["max_model_len"],
- max_num_seqs=config["max_num_seqs"],
- limit_mm_per_prompt=limit_mm_per_prompt,
- mm_encoder_attn_backend=mm_encoder_attn_backend,
- hf_overrides=dummy_hf_overrides,
- load_format="dummy",
- seed=42,
- )
-
- # Generate
- sampling_params = SamplingParams(**config["sampling_params"])
- outputs = llm.generate(
- {
- "prompt": prompt,
- "multi_modal_data": {"image": images},
- },
- sampling_params=sampling_params,
- )
-
- # Validate
- for o in outputs:
- generated_text = o.outputs[0].text
- validator = config.get("output_validator", lambda x: len(x) > 10)
- assert validator(generated_text), (
- f"Validation failed for {config['model_name']}: {generated_text}"
- )
-
-
-def run_llm_chat_test(config, mm_encoder_attn_backend, image_assets):
- """LLM.chat() interface handler for Dots.OCR."""
- # Filter to stop_sign image only
- stop_sign_image = [
- asset.pil_image for asset in image_assets if asset.name == "stop_sign"
- ][0]
-
- # Build messages
- messages = build_dots_ocr_prompt([stop_sign_image], config)
-
- # Create engine
- llm = LLM(
- model=config["model_name"],
- trust_remote_code=True,
- max_model_len=config["max_model_len"],
- max_num_seqs=config["max_num_seqs"],
- limit_mm_per_prompt=config["limit_mm_per_prompt"],
- mm_encoder_attn_backend=mm_encoder_attn_backend,
- hf_overrides=dummy_hf_overrides,
- load_format="dummy",
- seed=42,
- )
-
- # Generate using chat
- sampling_params = SamplingParams(**config["sampling_params"])
- outputs = llm.chat(messages=messages, sampling_params=sampling_params)
-
- # Validate
- for o in outputs:
- generated_text = o.outputs[0].text
- validator = config.get("output_validator", lambda x: len(x) > 10)
- assert validator(generated_text), (
- f"Validation failed for {config['model_name']}: {generated_text}"
- )
-
-
-def run_video_test(config, mm_encoder_attn_backend, video_assets, vllm_runner):
- """Video test with EVS (Efficient Video Sampling) handler."""
- for pruning_rate in config["video_params"]["pruning_rates"]:
- num_frames = config["video_params"]["num_frames"]
-
- # Sample frames from video
- sampled_vids = [
- sample_frames_from_video(asset.np_ndarrays, num_frames)
- for asset in video_assets
- ]
-
- # Build prompt and prepare video
- prompt = build_qwen2_5_video_prompt()
- prompts = [prompt]
- videos = [sampled_vids[0]]
-
- # Run with vllm_runner context manager
- with vllm_runner(
- config["model_name"],
- max_model_len=config["max_model_len"],
- max_num_seqs=config["max_num_seqs"],
- limit_mm_per_prompt=config["limit_mm_per_prompt"],
- tensor_parallel_size=1,
- video_pruning_rate=pruning_rate,
- mm_encoder_attn_backend=mm_encoder_attn_backend,
- hf_overrides=dummy_hf_overrides,
- load_format="dummy",
- **config["runner_kwargs"],
- ) as vllm_model:
- outputs = vllm_model.generate_greedy(
- prompts,
- config["sampling_params"]["max_tokens"],
- videos=videos,
- )
-
- # Validate output
- assert len(outputs) == 1, f"Expected 1 output, got {len(outputs)}"
- output_ids, output_text = outputs[0]
- assert len(output_ids) > 0, "Generated no output IDs"
- assert len(output_text) > 0, "Generated empty text"
- assert isinstance(output_text, str), (
- f"Output is not string: {type(output_text)}"
- )
-
-
-# Main test function
-@pytest.mark.parametrize("model_key", list(MODEL_CONFIGS.keys()))
-@pytest.mark.parametrize(
- "mm_encoder_attn_backend",
- [None] + current_platform.get_supported_vit_attn_backends(),
-)
-@pytest.mark.skip(reason="Broken test due to memory segmentation fault")
-@create_new_process_for_each_test()
-def test_vit_backend_functionality(
- model_key: str,
- mm_encoder_attn_backend: AttentionBackendEnum | None,
- image_assets,
- video_assets,
- vllm_runner,
- request,
-):
- """Test ViT attention backend functionality for multimodal models.
-
- This test validates that each model can successfully generate outputs
- using different ViT attention backends. The test:
- 1. Filters unsupported backends per model
- 2. Applies appropriate GPU marks
- 3. Routes to the correct test handler based on interface
- 4. Validates output meets minimum requirements
- """
- config = MODEL_CONFIGS[model_key]
-
- # Step 1: Backend filtering
- if (
- "supported_backends" in config
- and mm_encoder_attn_backend is not None
- and mm_encoder_attn_backend not in config["supported_backends"]
- ):
- pytest.skip(
- f"{model_key} does not support {mm_encoder_attn_backend} backend now."
- )
-
- # Step 2: Apply GPU marks dynamically
- if "gpu_marks" in config:
- for mark in config["gpu_marks"]:
- request.applymarker(mark)
-
- # Step 3: Route to appropriate handler
- if config.get("media_type") == "video":
- run_video_test(config, mm_encoder_attn_backend, video_assets, vllm_runner)
- elif config["interface"] == "llm_chat":
- run_llm_chat_test(config, mm_encoder_attn_backend, image_assets)
- elif config["interface"] == "llm_generate":
- run_llm_generate_test(config, mm_encoder_attn_backend, image_assets)
- else:
- raise ValueError(f"Unknown interface: {config['interface']}")
From 7614b88ebdd9739cd126daf6f33e867e27ce52a7 Mon Sep 17 00:00:00 2001
From: Nick Hill
Date: Fri, 10 Jul 2026 12:42:53 +0100
Subject: [PATCH 0020/1526] [Bugfix][Spec Decode] Fix DFlash draft/target
layer-count mismatch (#48113)
Signed-off-by: Nick Hill
Co-authored-by: Claude Opus 4.8 (1M context)
---
tests/models/registry.py | 2 +-
vllm/model_executor/models/qwen3_dflash.py | 8 ++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/tests/models/registry.py b/tests/models/registry.py
index 0b632580e274..fccfb549f505 100644
--- a/tests/models/registry.py
+++ b/tests/models/registry.py
@@ -1403,7 +1403,7 @@ def check_available_online(
# ),
# [DFlash]
"DFlashDraftModel": _HfExamplesInfo(
- "Qwen/Qwen3.5-4B",
+ "Qwen/Qwen3-4B",
speculative_model="z-lab/Qwen3-4B-DFlash-b16",
use_original_num_layers=True, # Need all layers since DFlash has >1 layer,
max_model_len=8192, # Reduce max len to ensure test runs in low-VRAM CI env
diff --git a/vllm/model_executor/models/qwen3_dflash.py b/vllm/model_executor/models/qwen3_dflash.py
index 12bc1430726c..a03fec0234d2 100644
--- a/vllm/model_executor/models/qwen3_dflash.py
+++ b/vllm/model_executor/models/qwen3_dflash.py
@@ -754,6 +754,14 @@ def combine_hidden_states(
needs_squeeze = hidden_states.dim() == 1
if needs_squeeze:
hidden_states = hidden_states.unsqueeze(0)
+ expected = self.model.fc.input_size
+ if hidden_states.shape[-1] != expected:
+ raise ValueError(
+ f"DFlash drafter expects {expected} concatenated aux hidden "
+ f"features but received {hidden_states.shape[-1]}. This usually "
+ "means the draft model's target_layer_ids reference layers that "
+ "do not exist in the target model (incompatible draft/target pair)."
+ )
result = self.model.fc(hidden_states)
if needs_squeeze:
result = result.squeeze(0)
From fabec87f63cdf0725d48e3eb6fdef9799837bcaf Mon Sep 17 00:00:00 2001
From: FAN YUCHEN <2994114386@qq.com>
Date: Fri, 10 Jul 2026 20:27:58 +0800
Subject: [PATCH 0021/1526] [Model] Migrate MistralLarge3ForCausalLM to
AutoWeightsLoader (#48153)
Signed-off-by: Yuchen Fan
---
vllm/model_executor/models/mistral_large_3.py | 131 +++++++++++-------
.../models/mistral_large_3_eagle.py | 15 +-
2 files changed, 89 insertions(+), 57 deletions(-)
diff --git a/vllm/model_executor/models/mistral_large_3.py b/vllm/model_executor/models/mistral_large_3.py
index ff7e9b60c1d3..603ce5c0f010 100644
--- a/vllm/model_executor/models/mistral_large_3.py
+++ b/vllm/model_executor/models/mistral_large_3.py
@@ -2,62 +2,91 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Iterable
-import regex as re
+import regex
import torch
from vllm.model_executor.models.deepseek_v2 import DeepseekV3ForCausalLM
+from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper
class MistralLarge3ForCausalLM(DeepseekV3ForCausalLM):
- # fmt: off
- remapping = {
- r"layers\.(\d+)\.attention_norm\.weight": r"model.layers.\1.input_layernorm.weight", # noqa: E501
- r"layers\.(\d+)\.attention\.wq_a\.(\w+)": r"model.layers.\1.self_attn.q_a_proj.\2", # noqa: E501
- r"layers\.(\d+)\.attention\.q_a_norm\.weight": r"model.layers.\1.self_attn.q_a_layernorm.weight", # noqa: E501
- r"layers\.(\d+)\.attention\.wq_b\.(\w+)": r"model.layers.\1.self_attn.q_b_proj.\2", # noqa: E501
- r"layers\.(\d+)\.attention\.wkv_a_with_mqa\.(\w+)": r"model.layers.\1.self_attn.kv_a_proj_with_mqa.\2", # noqa: E501
- r"layers\.(\d+)\.attention\.kv_a_norm\.weight": r"model.layers.\1.self_attn.kv_a_layernorm.weight", # noqa: E501
- r"layers\.(\d+)\.attention\.wkv_b\.(\w+)": r"model.layers.\1.self_attn.kv_b_proj.\2", # noqa: E501
- r"layers\.(\d+)\.attention\.wo\.(\w+)": r"model.layers.\1.self_attn.o_proj.\2", # noqa: E501
- r"layers\.(\d+)\.ffn_norm\.weight": r"model.layers.\1.post_attention_layernorm.weight", # noqa: E501
- r"layers\.(\d+)\.feed_forward\.w1\.(\w+)": r"model.layers.\1.mlp.gate_proj.\2", # noqa: E501
- r"layers\.(\d+)\.feed_forward\.w2\.(\w+)": r"model.layers.\1.mlp.down_proj.\2", # noqa: E501
- r"layers\.(\d+)\.feed_forward\.w3\.(\w+)": r"model.layers.\1.mlp.up_proj.\2", # noqa: E501
- r"layers\.(\d+)\.gate\.weight": r"model.layers.\1.mlp.gate.weight", # noqa: E501
- r"layers\.(\d+)\.shared_experts\.w1\.(\w+)": r"model.layers.\1.mlp.shared_experts.gate_proj.\2", # noqa: E501
- r"layers\.(\d+)\.shared_experts\.w2\.(\w+)": r"model.layers.\1.mlp.shared_experts.down_proj.\2", # noqa: E501
- r"layers\.(\d+)\.shared_experts\.w3\.(\w+)": r"model.layers.\1.mlp.shared_experts.up_proj.\2", # noqa: E501
- r"layers\.(\d+)\.experts\.(\d+)\.w1\.(\w+)": r"model.layers.\1.mlp.experts.\2.gate_proj.\3", # noqa: E501
- r"layers\.(\d+)\.experts\.(\d+)\.w2\.(\w+)": r"model.layers.\1.mlp.experts.\2.down_proj.\3", # noqa: E501
- r"layers\.(\d+)\.experts\.(\d+)\.w3\.(\w+)": r"model.layers.\1.mlp.experts.\2.up_proj.\3", # noqa: E501
- r"norm\.weight": "model.norm.weight", # noqa: E501
- r"tok_embeddings\.weight": "model.embed_tokens.weight", # noqa: E501
- r"output\.weight": "lm_head.weight", # noqa: E501
- }
- # fmt: on
+ # WeightsMapper applies all matching patterns sequentially (no break on first
+ # match). This is safe here because every pattern is anchored at both ends
+ # (\A...\Z) and after substitution the resulting key always starts with
+ # "model." or "lm_head.", so no later pattern can accidentally match again.
+ hf_to_vllm_mapper = WeightsMapper(
+ orig_to_new_regex={ # noqa: B950
+ regex.compile(
+ r"\Alayers\.(\d+)\.attention_norm\.weight\Z"
+ ): r"model.layers.\1.input_layernorm.weight",
+ regex.compile(
+ r"\Alayers\.(\d+)\.attention\.wq_a\.(\w+)\Z"
+ ): r"model.layers.\1.self_attn.q_a_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.attention\.q_a_norm\.weight\Z"
+ ): r"model.layers.\1.self_attn.q_a_layernorm.weight",
+ regex.compile(
+ r"\Alayers\.(\d+)\.attention\.wq_b\.(\w+)\Z"
+ ): r"model.layers.\1.self_attn.q_b_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.attention\.wkv_a_with_mqa\.(\w+)\Z"
+ ): r"model.layers.\1.self_attn.kv_a_proj_with_mqa.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.attention\.kv_a_norm\.weight\Z"
+ ): r"model.layers.\1.self_attn.kv_a_layernorm.weight",
+ regex.compile(
+ r"\Alayers\.(\d+)\.attention\.wkv_b\.(\w+)\Z"
+ ): r"model.layers.\1.self_attn.kv_b_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.attention\.wo\.(\w+)\Z"
+ ): r"model.layers.\1.self_attn.o_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.ffn_norm\.weight\Z"
+ ): r"model.layers.\1.post_attention_layernorm.weight",
+ regex.compile(
+ r"\Alayers\.(\d+)\.feed_forward\.w1\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.gate_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.feed_forward\.w2\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.down_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.feed_forward\.w3\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.up_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.gate\.weight\Z"
+ ): r"model.layers.\1.mlp.gate.weight",
+ regex.compile(
+ r"\Alayers\.(\d+)\.shared_experts\.w1\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.shared_experts.gate_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.shared_experts\.w2\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.shared_experts.down_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.shared_experts\.w3\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.shared_experts.up_proj.\2",
+ regex.compile(
+ r"\Alayers\.(\d+)\.experts\.(\d+)\.w1\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.experts.\2.gate_proj.\3",
+ regex.compile(
+ r"\Alayers\.(\d+)\.experts\.(\d+)\.w2\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.experts.\2.down_proj.\3",
+ regex.compile(
+ r"\Alayers\.(\d+)\.experts\.(\d+)\.w3\.(\w+)\Z"
+ ): r"model.layers.\1.mlp.experts.\2.up_proj.\3",
+ regex.compile(r"\Anorm\.weight\Z"): "model.norm.weight",
+ regex.compile(r"\Atok_embeddings\.weight\Z"): "model.embed_tokens.weight",
+ regex.compile(r"\Aoutput\.weight\Z"): "lm_head.weight",
+ },
+ orig_to_new_suffix={
+ ".qscale_act": ".input_scale",
+ ".qscale_weight": ".weight_scale",
+ },
+ )
+ # Bypass super().load_weights() and construct AutoWeightsLoader(self)
+ # directly (same pattern as Qwen2ForCausalLM). Any logic in the parent
+ # class's load_weights is a thin wrapper around AutoWeightsLoader, and
+ # we must apply hf_to_vllm_mapper before the loader walks the tree.
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
- return super().load_weights(map(self._remap_mistral_to_ds, weights))
-
- def _remap_mistral_to_ds(
- self, weight: tuple[str, torch.Tensor]
- ) -> tuple[str, torch.Tensor]:
- """Remap Mistral parameters to DeepseekV2 parameters."""
- name, loaded_weight = weight
-
- for k, v in self.remapping.items():
- match = re.fullmatch(k, name)
- if match:
- name = re.sub(k, v, name)
- break
- else:
- raise ValueError(f"Cannot remap {name}")
-
- # Remapping scale names. We could do this in the regex above but it
- # would triple the number of lines for most layers.
- if name.endswith(".qscale_act"):
- name = re.sub(r"\.qscale_act$", ".input_scale", name)
- elif name.endswith(".qscale_weight"):
- name = re.sub(r"\.qscale_weight$", ".weight_scale", name)
-
- return name, loaded_weight
+ loader = AutoWeightsLoader(self)
+ return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
diff --git a/vllm/model_executor/models/mistral_large_3_eagle.py b/vllm/model_executor/models/mistral_large_3_eagle.py
index bde5bc9451f9..8ace01205d03 100644
--- a/vllm/model_executor/models/mistral_large_3_eagle.py
+++ b/vllm/model_executor/models/mistral_large_3_eagle.py
@@ -5,6 +5,7 @@
from collections.abc import Iterable
from functools import partial
+import regex
import torch
import torch.nn as nn
@@ -22,7 +23,7 @@
from vllm.model_executor.models.mistral_large_3 import MistralLarge3ForCausalLM
from .interfaces import SupportsMultiModal
-from .utils import make_empty_intermediate_tensors_factory, maybe_prefix
+from .utils import WeightsMapper, make_empty_intermediate_tensors_factory, maybe_prefix
logger = init_logger(__name__)
@@ -107,11 +108,13 @@ def forward(
class EagleMistralLarge3ForCausalLM(MistralLarge3ForCausalLM):
- remapping = MistralLarge3ForCausalLM.remapping | {
- r"eagle_linear\.weight": r"model.fc.weight",
- r"eagle_linear\.qscale_act": r"model.fc.input_scale",
- r"eagle_linear\.qscale_weight": r"model.fc.weight_scale",
- }
+ hf_to_vllm_mapper = MistralLarge3ForCausalLM.hf_to_vllm_mapper | WeightsMapper(
+ orig_to_new_regex={
+ regex.compile(r"\Aeagle_linear\.weight\Z"): r"model.fc.weight",
+ regex.compile(r"\Aeagle_linear\.qscale_act\Z"): r"model.fc.input_scale",
+ regex.compile(r"\Aeagle_linear\.qscale_weight\Z"): r"model.fc.weight_scale",
+ },
+ )
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
target_layer_num = vllm_config.model_config.get_num_layers(
From e257faf87d8e003451e8693262c2f90df4acea45 Mon Sep 17 00:00:00 2001
From: Wentao Ye <44945378+yewentao256@users.noreply.github.com>
Date: Fri, 10 Jul 2026 09:33:07 -0400
Subject: [PATCH 0022/1526] [Refactor] Remove unused rocm kernel
`combine_topk_swa_indices_ragged` (#48158)
Signed-off-by: yewentao256
---
.../attention/test_rocm_triton_attn_dsv4.py | 89 -----------
vllm/models/deepseek_v4/amd/rocm.py | 147 ------------------
2 files changed, 236 deletions(-)
diff --git a/tests/kernels/attention/test_rocm_triton_attn_dsv4.py b/tests/kernels/attention/test_rocm_triton_attn_dsv4.py
index e00726f64d80..41863c916318 100644
--- a/tests/kernels/attention/test_rocm_triton_attn_dsv4.py
+++ b/tests/kernels/attention/test_rocm_triton_attn_dsv4.py
@@ -197,46 +197,6 @@ def _ragged_from_rows(
)
-def _ref_combine_topk_swa_ragged(
- device: torch.device,
-) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
- expected_ragged = torch.tensor(
- [
- 100,
- 101,
- 7,
- 8,
- 9,
- 110,
- 111,
- 8,
- 9,
- 10,
- 120,
- 121,
- 122,
- 9,
- 10,
- 11,
- 150,
- 27,
- 28,
- 29,
- 160,
- 161,
- 28,
- 29,
- 30,
- ],
- dtype=torch.int32,
- device=device,
- )
- expected_lens = torch.tensor([5, 5, 6, 4, 5], dtype=torch.int32, device=device)
- expected_indptr = torch.zeros(6, dtype=torch.int32, device=device)
- torch.cumsum(expected_lens, dim=0, out=expected_indptr[1:])
- return expected_ragged, expected_indptr, expected_lens
-
-
@torch.inference_mode()
def test_compute_global_topk_ragged_indices_and_indptr() -> None:
from vllm.models.deepseek_v4.amd.rocm import (
@@ -369,55 +329,6 @@ def test_sparse_attn_decode_ragged_kernel() -> None:
torch.testing.assert_close(actual, expected, atol=2e-2, rtol=2e-2)
-@torch.inference_mode()
-def test_combine_topk_swa_indices_ragged() -> None:
- from vllm.models.deepseek_v4.amd.rocm import (
- combine_topk_swa_indices_ragged,
- )
-
- device = torch.device("cuda")
- topk_indices = torch.tensor(
- [
- [100, 101, 102, 103],
- [110, 111, 112, 113],
- [120, 121, 122, 123],
- [130, 131, 132, 133],
- [140, 141, 142, 143],
- ],
- dtype=torch.int32,
- device=device,
- )
- query_start_loc = torch.tensor([0, 3, 5], dtype=torch.int32, device=device)
- seq_lens = torch.tensor([6, 4], dtype=torch.int32, device=device)
- gather_lens = torch.tensor([4, 3], dtype=torch.int32, device=device)
- window_size = 3
- compress_ratio = 2
- topk = 4
- M = 20
- N = 8
-
- actual_ragged, actual_indptr, actual_lens = combine_topk_swa_indices_ragged(
- topk_indices,
- query_start_loc,
- seq_lens,
- gather_lens,
- window_size,
- compress_ratio,
- topk,
- M,
- N,
- )
- expected_ragged, expected_indptr, expected_lens = _ref_combine_topk_swa_ragged(
- device
- )
-
- torch.testing.assert_close(
- actual_ragged[: expected_ragged.numel()], expected_ragged
- )
- torch.testing.assert_close(actual_indptr, expected_indptr)
- torch.testing.assert_close(actual_lens, expected_lens)
-
-
@requires_gfx950
@torch.inference_mode()
def test_decode_num_splits_heuristic(monkeypatch) -> None:
diff --git a/vllm/models/deepseek_v4/amd/rocm.py b/vllm/models/deepseek_v4/amd/rocm.py
index 641b3da68bdf..b3456ba785c7 100644
--- a/vllm/models/deepseek_v4/amd/rocm.py
+++ b/vllm/models/deepseek_v4/amd/rocm.py
@@ -272,153 +272,6 @@ def compute_global_topk_ragged_indices_and_indptr(
return global_topk_ragged, topk_indptr, topk_lens
-@triton.jit
-def _compute_combined_lens_kernel(
- combined_lens_ptr,
- query_start_loc_ptr,
- seq_lens_ptr,
- TOP_K: tl.constexpr,
- COMPRESS_RATIO: tl.constexpr,
- WINDOW_SIZE: tl.constexpr,
-):
- batch_idx = tl.program_id(0)
- worker_id = tl.program_id(1)
- num_workers = tl.num_programs(1)
-
- base = tl.load(query_start_loc_ptr)
- query_start = tl.load(query_start_loc_ptr + batch_idx) - base
- query_end = tl.load(query_start_loc_ptr + batch_idx + 1) - base
- query_len = query_end - query_start
- seq_len = tl.load(seq_lens_ptr + batch_idx)
- start_pos = seq_len - query_len
-
- for token_idx in range(query_start + worker_id, query_end, num_workers):
- token_idx_in_query = token_idx - query_start
- pos = start_pos + token_idx_in_query
- topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
- swa_len = tl.minimum(pos + 1, WINDOW_SIZE)
- tl.store(combined_lens_ptr + token_idx, topk_len + swa_len)
-
-
-@triton.jit
-def _combine_topk_swa_indices_ragged_kernel(
- combined_ragged_ptr,
- combined_indptr_ptr,
- topk_indices_ptr,
- topk_indices_stride,
- query_start_loc_ptr,
- seq_lens_ptr,
- gather_lens_ptr,
- M,
- N,
- topk_width,
- TOP_K: tl.constexpr,
- COMPRESS_RATIO: tl.constexpr,
- WINDOW_SIZE: tl.constexpr,
- BLOCK_SIZE: tl.constexpr,
-):
- batch_idx = tl.program_id(0)
- worker_id = tl.program_id(1)
- block_idx = tl.program_id(2)
- num_workers = tl.num_programs(1)
-
- base = tl.load(query_start_loc_ptr)
- query_start = tl.load(query_start_loc_ptr + batch_idx) - base
- query_end = tl.load(query_start_loc_ptr + batch_idx + 1) - base
- query_len = query_end - query_start
- seq_len = tl.load(seq_lens_ptr + batch_idx)
- gather_len = tl.load(gather_lens_ptr + batch_idx)
- start_pos = seq_len - query_len
- gather_start = seq_len - gather_len
-
- for token_idx in range(query_start + worker_id, query_end, num_workers):
- token_idx_in_query = token_idx - query_start
- pos = start_pos + token_idx_in_query
- topk_len = tl.minimum((pos + 1) // COMPRESS_RATIO, TOP_K)
- swa_len = tl.minimum(pos + 1, WINDOW_SIZE)
- combined_len = topk_len + swa_len
-
- offset = block_idx * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
- if block_idx * BLOCK_SIZE < combined_len:
- out_start = tl.load(combined_indptr_ptr + token_idx)
- topk_mask = (offset < topk_len) & (offset < topk_width)
- topk_vals = tl.load(
- topk_indices_ptr + token_idx * topk_indices_stride + offset,
- mask=topk_mask,
- other=-1,
- )
- tl.store(
- combined_ragged_ptr + out_start + offset,
- topk_vals + M * batch_idx,
- mask=topk_mask,
- )
-
- swa_offset = offset - topk_len
- swa_mask = (offset >= topk_len) & (swa_offset < swa_len)
- tl.store(
- combined_ragged_ptr + out_start + offset,
- M * batch_idx + N + swa_offset + pos - swa_len + 1 - gather_start,
- mask=swa_mask,
- )
-
-
-def combine_topk_swa_indices_ragged(
- topk_indices: torch.Tensor,
- query_start_loc: torch.Tensor,
- seq_lens: torch.Tensor,
- gather_lens: torch.Tensor,
- window_size: int,
- compress_ratio: int,
- topk: int,
- M: int,
- N: int,
-) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
- topk_indices = topk_indices.reshape(topk_indices.shape[0], -1).contiguous()
- num_tokens = topk_indices.shape[0]
- num_reqs = seq_lens.shape[0]
- combined_lens = torch.empty(
- num_tokens, dtype=torch.int32, device=topk_indices.device
- )
-
- num_workers = 128
- _compute_combined_lens_kernel[(num_reqs, num_workers)](
- combined_lens,
- query_start_loc,
- seq_lens,
- TOP_K=topk,
- COMPRESS_RATIO=compress_ratio,
- WINDOW_SIZE=window_size,
- )
-
- combined_indptr = _build_indptr_from_lengths(combined_lens)
- combined_ragged = torch.empty(
- num_tokens * (topk + window_size),
- dtype=torch.int32,
- device=topk_indices.device,
- )
- if combined_ragged.numel() > 0:
- block = 128
- _combine_topk_swa_indices_ragged_kernel[
- (num_reqs, num_workers, triton.cdiv(topk + window_size, block))
- ](
- combined_ragged,
- combined_indptr,
- topk_indices,
- topk_indices.stride(0),
- query_start_loc,
- seq_lens,
- gather_lens,
- M,
- N,
- topk_indices.shape[-1],
- TOP_K=topk,
- COMPRESS_RATIO=compress_ratio,
- WINDOW_SIZE=window_size,
- BLOCK_SIZE=block,
- )
- return combined_ragged, combined_indptr, combined_lens
-
-
def _copy_ragged_to_graph_buffers(
ragged_indices: torch.Tensor,
ragged_indptr: torch.Tensor,
From b12cca6a2352ea6926864c925246eeb581be8caa Mon Sep 17 00:00:00 2001
From: XuZhou <17717803682@163.com>
Date: Fri, 10 Jul 2026 21:55:28 +0800
Subject: [PATCH 0023/1526] [Bugfix] Fix turboquant FP8 cast failure for BF16
models on Ampere GPUs (#39988)
Signed-off-by: Xu Zhou
Co-authored-by: Xu Zhou
Co-authored-by: Hoseung Kim
---
vllm/v1/attention/ops/triton_turboquant_store.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/vllm/v1/attention/ops/triton_turboquant_store.py b/vllm/v1/attention/ops/triton_turboquant_store.py
index 3ad2d41488e7..d5437d7314cd 100644
--- a/vllm/v1/attention/ops/triton_turboquant_store.py
+++ b/vllm/v1/attention/ops/triton_turboquant_store.py
@@ -188,7 +188,7 @@ def _tq_fused_store_fp8(
# ── FP8 KEY: cast to FP8 in-kernel and store ─────────────────
d_offs = tl.arange(0, BLOCK_D)
d_mask = d_offs < D
- k_vals = tl.load(Key_ptr + base + d_offs, mask=d_mask, other=0.0)
+ k_vals = tl.load(Key_ptr + base + d_offs, mask=d_mask, other=0.0).to(tl.float32)
k_fp8 = k_vals.to(tl.float8e4b15) if FP8_E4B15 else k_vals.to(tl.float8e4nv)
k_bytes = k_fp8.to(tl.uint8, bitcast=True)
tl.store(KV_cache_ptr + slot_base + d_offs, k_bytes, mask=d_mask)
From 85c09e9885e346ea1612da30ebff5a75f67d2350 Mon Sep 17 00:00:00 2001
From: HuYiPeng <144002351+MynameFelix@users.noreply.github.com>
Date: Fri, 10 Jul 2026 22:08:20 +0800
Subject: [PATCH 0024/1526] =?UTF-8?q?fix:=20correct=20load=5Fweights=20tra?=
=?UTF-8?q?ck=20logic=20and=20enable=20weight=20integrity=20for=E2=80=A6?=
=?UTF-8?q?=20(#41811)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Yipeng Hu
Signed-off-by: HuYiPeng <144002351+MynameFelix@users.noreply.github.com>
Signed-off-by: Isotr0py
Co-authored-by: Yipeng Hu
Co-authored-by: Isotr0py
---
.../test_filter_duplicate_safetensors.py | 79 +++++++++++++++++++
.../model_loader/weight_utils.py | 8 ++
2 files changed, 87 insertions(+)
create mode 100644 tests/model_executor/model_loader/test_filter_duplicate_safetensors.py
diff --git a/tests/model_executor/model_loader/test_filter_duplicate_safetensors.py b/tests/model_executor/model_loader/test_filter_duplicate_safetensors.py
new file mode 100644
index 000000000000..babc6c061a9c
--- /dev/null
+++ b/tests/model_executor/model_loader/test_filter_duplicate_safetensors.py
@@ -0,0 +1,79 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+import json
+import os
+import tempfile
+
+import pytest
+
+from vllm.model_executor.model_loader.weight_utils import (
+ filter_duplicate_safetensors_files,
+)
+
+
+def test_filter_duplicate_safetensors_files_missing_weight():
+ with tempfile.TemporaryDirectory() as tmpdir:
+ existing_file = os.path.join(tmpdir, "model-00001-of-00002.safetensors")
+ with open(existing_file, "wb") as f:
+ f.write(b"")
+
+ existing_file2 = os.path.join(tmpdir, "model-00002-of-00002.safetensors")
+ with open(existing_file2, "wb") as f:
+ f.write(b"")
+
+ index_file = os.path.join(tmpdir, "model.safetensors.index.json")
+ index_content = {
+ "weight_map": {
+ "layer.0.weight": "model-00001-of-00002.safetensors",
+ "layer.1.weight": "model-00002-of-00002.safetensors",
+ "layer.2.weight": "model-00003-of-00002.safetensors",
+ }
+ }
+ with open(index_file, "w") as f:
+ json.dump(index_content, f)
+
+ hf_weights_files = [
+ os.path.join(tmpdir, "model-00001-of-00002.safetensors"),
+ os.path.join(tmpdir, "model-00002-of-00002.safetensors"),
+ ]
+
+ with pytest.raises(FileNotFoundError) as exc_info:
+ filter_duplicate_safetensors_files(
+ hf_weights_files=hf_weights_files,
+ hf_folder=tmpdir,
+ index_file="model.safetensors.index.json",
+ )
+
+ assert "model-00003-of-00002.safetensors" in str(exc_info.value)
+
+
+def test_filter_duplicate_safetensors_files_all_exist():
+ with tempfile.TemporaryDirectory() as tmpdir:
+ existing_files = []
+ for i in range(1, 3):
+ file_path = os.path.join(tmpdir, f"model-0000{i}-of-00002.safetensors")
+ with open(file_path, "wb") as f:
+ f.write(b"")
+ existing_files.append(file_path)
+
+ index_file = os.path.join(tmpdir, "model.safetensors.index.json")
+ index_content = {
+ "weight_map": {
+ "layer.0.weight": "model-00001-of-00002.safetensors",
+ "layer.1.weight": "model-00002-of-00002.safetensors",
+ }
+ }
+ with open(index_file, "w") as f:
+ json.dump(index_content, f)
+
+ filter_duplicate_safetensors_files(
+ hf_weights_files=existing_files,
+ hf_folder=tmpdir,
+ index_file="model.safetensors.index.json",
+ )
+
+
+if __name__ == "__main__":
+ test_filter_duplicate_safetensors_files_missing_weight()
+ test_filter_duplicate_safetensors_files_all_exist()
diff --git a/vllm/model_executor/model_loader/weight_utils.py b/vllm/model_executor/model_loader/weight_utils.py
index 47c6c02be6ab..ef25a156d9ea 100644
--- a/vllm/model_executor/model_loader/weight_utils.py
+++ b/vllm/model_executor/model_loader/weight_utils.py
@@ -595,6 +595,14 @@ def filter_duplicate_safetensors_files(
weight_files_in_index = set()
for weight_name in weight_map:
weight_files_in_index.add(os.path.join(hf_folder, weight_map[weight_name]))
+ # Check if files referenced in model.safetensors.index.json actually exist.
+ # Raise error if any file is missing.
+ hf_weights_files_set = set(hf_weights_files)
+ missing_files = weight_files_in_index - hf_weights_files_set
+ if missing_files:
+ raise FileNotFoundError(
+ f"Weight files referenced in index but missing: {missing_files}"
+ )
# Filter out any fields that are not found in the index file.
hf_weights_files = [f for f in hf_weights_files if f in weight_files_in_index]
return hf_weights_files
From 978a6dfa3f79470e4459a454816202e78c1d2108 Mon Sep 17 00:00:00 2001
From: Tyler Michael Smith
Date: Fri, 10 Jul 2026 10:12:50 -0400
Subject: [PATCH 0025/1526] [Build/CI] Build arm64 PR and postmerge image
builds for Blackwell SM10x and SM110 (#48041)
Signed-off-by: Tyler Michael Smith
---
.buildkite/image_build/image_build_arm64.sh | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/.buildkite/image_build/image_build_arm64.sh b/.buildkite/image_build/image_build_arm64.sh
index 3f73987846c9..10cb417ec4de 100755
--- a/.buildkite/image_build/image_build_arm64.sh
+++ b/.buildkite/image_build/image_build_arm64.sh
@@ -19,13 +19,14 @@ if docker manifest inspect "$IMAGE" >/dev/null 2>&1; then
echo "Image found"
else
echo "Image not found, proceeding with build..."
- # build for arm64 GPU targets: Grace/GH200 (sm_90) and DGX Spark/GB10
+ # build for arm64 GPU targets: Grace/GH200 (sm_90),
+ # Blackwell/Thor (sm_100/sm_103/sm_110), and DGX Spark/GB10
# (sm_121, family-covered by 12.0 under CUDA 13)
docker build --file docker/Dockerfile \
--platform linux/arm64 \
--build-arg max_jobs=16 \
--build-arg nvcc_threads=4 \
- --build-arg torch_cuda_arch_list="9.0 12.0" \
+ --build-arg torch_cuda_arch_list="9.0 10.0 11.0 12.0" \
--build-arg USE_SCCACHE=1 \
--build-arg buildkite_commit="$BUILDKITE_COMMIT" \
--tag "$IMAGE" \
From 08dfd68610d2e05a0d8ddc99c23488da6163df3f Mon Sep 17 00:00:00 2001
From: Michael Goin
Date: Fri, 10 Jul 2026 10:17:50 -0400
Subject: [PATCH 0026/1526] [Model] Add LongCat-Flash-Lite (n-gram embedding)
(#47857)
Signed-off-by: mgoin
---
CMakeLists.txt | 1 +
.../ngram_embedding_kernels.cu | 96 +++++
csrc/libtorch_stable/ops.h | 9 +
csrc/libtorch_stable/torch_bindings.cpp | 13 +
tests/models/registry.py | 7 +
tests/models/utils.py | 9 +-
vllm/_custom_ops.py | 31 ++
vllm/config/speculative.py | 2 +-
vllm/config/vllm.py | 1 +
vllm/model_executor/models/config.py | 20 +-
vllm/model_executor/models/longcat_flash.py | 15 +-
.../models/longcat_flash_mtp.py | 18 +-
.../models/longcat_flash_ngram.py | 405 ++++++++++++++++++
vllm/model_executor/models/registry.py | 4 +
.../model_arch_config_convertor.py | 1 +
vllm/v1/worker/gpu/attn_utils.py | 10 +-
16 files changed, 630 insertions(+), 12 deletions(-)
create mode 100644 csrc/libtorch_stable/ngram_embedding_kernels.cu
create mode 100644 vllm/model_executor/models/longcat_flash_ngram.py
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7fc49a2d15b1..3ddce1c3c3cf 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -390,6 +390,7 @@ if(VLLM_GPU_LANG STREQUAL "CUDA" OR VLLM_GPU_LANG STREQUAL "HIP")
"csrc/libtorch_stable/cuda_view.cu"
"csrc/libtorch_stable/cuda_utils_kernels.cu"
"csrc/libtorch_stable/activation_kernels.cu"
+ "csrc/libtorch_stable/ngram_embedding_kernels.cu"
"csrc/libtorch_stable/quantization/activation_kernels.cu"
"csrc/libtorch_stable/quantization/w8a8/int8/scaled_quant.cu"
"csrc/libtorch_stable/quantization/w8a8/fp8/common.cu"
diff --git a/csrc/libtorch_stable/ngram_embedding_kernels.cu b/csrc/libtorch_stable/ngram_embedding_kernels.cu
new file mode 100644
index 000000000000..a4da62f9acd5
--- /dev/null
+++ b/csrc/libtorch_stable/ngram_embedding_kernels.cu
@@ -0,0 +1,96 @@
+// N-gram embedding index kernel for LongCat-Flash (n-gram embedding variant).
+//
+// Adapted from SGLang:
+// https://github.com/sgl-project/sglang/blob/main/python/sglang/jit_kernel/csrc/ngram_embedding.cuh
+//
+// For each position, computes the hashed n-gram embedding ids that index the
+// concatenated embedder table. Integer tensors are int32 except ``row_indices``
+// (int64); the token table is ``[max_running_reqs, max_context_len]`` int32,
+// where a negative entry marks an ignored token (e.g. an EOS boundary).
+
+#include "torch_utils.h"
+
+#include "ops.h"
+
+#include
+
+namespace vllm::ngram_embedding {
+
+constexpr int kBlockThreads = 256;
+
+__global__ void ComputeNGramIdsKernel(
+ int batch_size, int ne_n, int ne_k,
+ int* ne_weights, // [ne_n-1, ne_k, ne_n]
+ int* ne_mods, // [ne_n-1, ne_k]
+ int* exclusive_ne_embedder_size_sums, // [(ne_n-1)*ne_k + 1]
+ int* exclusive_req_len_sums, // [batch_size + 1]
+ int* ne_token_table, // [max_running_reqs, max_context_len]
+ int max_context_len,
+ const int64_t* __restrict__ row_indices, // [batch_size]
+ int* column_starts, // [batch_size]
+ int* n_gram_ids // [token_num, (ne_n-1)*ne_k]
+) {
+ const int req_id = blockIdx.x % batch_size;
+ const int config_id = (blockIdx.x - req_id) / batch_size;
+ // n and k are offset from their physical meaning: n = real_n - 2, k = real_k
+ // - 1 (they index into ne_weights / ne_mods).
+ const int k = config_id % ne_k;
+ const int n = (config_id - config_id % ne_k) / ne_k;
+ const int ne_weight_base_idx = n * ne_k * ne_n + k * ne_n;
+ const int ne_mod = ne_mods[n * ne_k + k];
+ for (int i = exclusive_req_len_sums[req_id] + threadIdx.x;
+ i < exclusive_req_len_sums[req_id + 1]; i += blockDim.x) {
+ uint64_t n_gram_id = 0;
+ const int64_t current_token_offset = i - exclusive_req_len_sums[req_id];
+ const int64_t req_token_table_index =
+ row_indices[req_id] * static_cast(max_context_len);
+ const int64_t current_token_table_index =
+ req_token_table_index + column_starts[req_id] + current_token_offset;
+ for (int j = 0; j < n + 2; j++) {
+ if (current_token_table_index - j < req_token_table_index) {
+ break; // outside this request's range
+ }
+ if (ne_token_table[current_token_table_index - j] < 0) {
+ break; // ignored token
+ }
+ const uint64_t term =
+ (uint64_t)ne_token_table[current_token_table_index - j] *
+ (uint64_t)ne_weights[ne_weight_base_idx + j];
+ n_gram_id += term % ne_mod;
+ }
+ n_gram_id %= ne_mod;
+ n_gram_id += exclusive_ne_embedder_size_sums[n * ne_k + k];
+ n_gram_ids[i * (ne_n - 1) * ne_k + n * ne_k + k] = (int)(n_gram_id);
+ }
+}
+
+} // namespace vllm::ngram_embedding
+
+void ngram_compute_n_gram_ids(
+ int64_t ne_n, int64_t ne_k, torch::stable::Tensor& ne_weights,
+ torch::stable::Tensor& ne_mods,
+ torch::stable::Tensor& exclusive_ne_embedder_size_sums,
+ torch::stable::Tensor& exclusive_req_len_sums,
+ torch::stable::Tensor& ne_token_table, torch::stable::Tensor& row_indices,
+ torch::stable::Tensor& column_starts, torch::stable::Tensor& n_gram_ids) {
+ const int batch_size = static_cast(exclusive_req_len_sums.size(0) - 1);
+ const int max_context_len = static_cast(ne_token_table.size(1));
+ const int num_configs = (static_cast(ne_n) - 1) * static_cast(ne_k);
+ const int grid_size = num_configs * batch_size;
+ if (grid_size <= 0) return;
+
+ const torch::stable::accelerator::DeviceGuard device_guard(
+ ne_weights.get_device_index());
+ const cudaStream_t stream = get_current_cuda_stream();
+ vllm::ngram_embedding::ComputeNGramIdsKernel<<<
+ grid_size, vllm::ngram_embedding::kBlockThreads, 0, stream>>>(
+ batch_size, static_cast(ne_n), static_cast(ne_k),
+ ne_weights.mutable_data_ptr(),
+ ne_mods.mutable_data_ptr(),
+ exclusive_ne_embedder_size_sums.mutable_data_ptr(),
+ exclusive_req_len_sums.mutable_data_ptr(),
+ ne_token_table.mutable_data_ptr(), max_context_len,
+ row_indices.const_data_ptr(),
+ column_starts.mutable_data_ptr(),
+ n_gram_ids.mutable_data_ptr());
+}
diff --git a/csrc/libtorch_stable/ops.h b/csrc/libtorch_stable/ops.h
index 0daa024a7672..4520f4a99ec9 100644
--- a/csrc/libtorch_stable/ops.h
+++ b/csrc/libtorch_stable/ops.h
@@ -554,3 +554,12 @@ void cp_gather_indexer_k_quant_cache(
// quant_block_size * 4]
const torch::stable::Tensor& block_table, // [batch_size, num_blocks]
const torch::stable::Tensor& cu_seq_lens); // [batch_size + 1]
+
+// LongCat n-gram embedding index kernel (see ngram_embedding_kernels.cu).
+void ngram_compute_n_gram_ids(
+ int64_t ne_n, int64_t ne_k, torch::stable::Tensor& ne_weights,
+ torch::stable::Tensor& ne_mods,
+ torch::stable::Tensor& exclusive_ne_embedder_size_sums,
+ torch::stable::Tensor& exclusive_req_len_sums,
+ torch::stable::Tensor& ne_token_table, torch::stable::Tensor& row_indices,
+ torch::stable::Tensor& column_starts, torch::stable::Tensor& n_gram_ids);
diff --git a/csrc/libtorch_stable/torch_bindings.cpp b/csrc/libtorch_stable/torch_bindings.cpp
index 66a93a5ba13b..7581769f4f45 100644
--- a/csrc/libtorch_stable/torch_bindings.cpp
+++ b/csrc/libtorch_stable/torch_bindings.cpp
@@ -598,9 +598,22 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
"Tensor? initial_state_idx,"
"Tensor? cu_chunk_seqlen,"
"Tensor? last_chunk_indices) -> ()");
+
+ // LongCat n-gram embedding index kernel. All tensor args are marked mutable
+ // to match the (non-const) stable-Tensor& C++ signature; only ne_token_table
+ // and n_gram_ids are actually written in place.
+ ops.def(
+ "ngram_compute_n_gram_ids(int ne_n, int ne_k, Tensor(a!) ne_weights, "
+ "Tensor(b!) ne_mods, Tensor(c!) exclusive_ne_embedder_size_sums, "
+ "Tensor(d!) exclusive_req_len_sums, Tensor(e!) ne_token_table, "
+ "Tensor(f!) row_indices, Tensor(g!) column_starts, "
+ "Tensor(h!) n_gram_ids) -> ()");
}
STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) {
+ // LongCat n-gram embedding index kernel.
+ ops.impl("ngram_compute_n_gram_ids", TORCH_BOX(&ngram_compute_n_gram_ids));
+
// Per-token group quantization
ops.impl("per_token_group_fp8_quant", TORCH_BOX(&per_token_group_quant_fp8));
ops.impl("per_token_group_fp8_quant_packed",
diff --git a/tests/models/registry.py b/tests/models/registry.py
index fccfb549f505..ec9ef35436fe 100644
--- a/tests/models/registry.py
+++ b/tests/models/registry.py
@@ -382,6 +382,13 @@ def check_available_online(
"LongcatFlashForCausalLM": _HfExamplesInfo(
"meituan-longcat/LongCat-Flash-Chat", trust_remote_code=True
),
+ "LongcatFlashNgramForCausalLM": _HfExamplesInfo(
+ "meituan-longcat/LongCat-Flash-Lite",
+ trust_remote_code=True,
+ # Shrink the ~62GB n-gram tables (ngram_vocab_size_ratio * vocab_size)
+ # so the dummy-weight init test fits in CI memory.
+ hf_overrides={"ngram_vocab_size_ratio": 1},
+ ),
"MambaForCausalLM": _HfExamplesInfo("state-spaces/mamba-130m-hf"),
"Mamba2ForCausalLM": _HfExamplesInfo(
"mistralai/Mamba-Codestral-7B-v0.1",
diff --git a/tests/models/utils.py b/tests/models/utils.py
index 91d76d5f243c..f1018efe8a08 100644
--- a/tests/models/utils.py
+++ b/tests/models/utils.py
@@ -529,8 +529,13 @@ class DummyConfig:
}
)
- # Update num_hidden_layers for non-Longcat architectures
- if model_arch != "LongcatFlashForCausalLM" and model_arch != "LongCatFlashMTPModel":
+ # Update num_hidden_layers for non-Longcat architectures (Longcat derives it
+ # from num_layers for its dual-attention layers).
+ if model_arch not in (
+ "LongcatFlashForCausalLM",
+ "LongCatFlashMTPModel",
+ "LongcatFlashNgramForCausalLM",
+ ):
update_dict["num_hidden_layers"] = num_hidden_layers
text_config.update(update_dict)
diff --git a/vllm/_custom_ops.py b/vllm/_custom_ops.py
index 4de43e96a884..2a4579ae03a3 100644
--- a/vllm/_custom_ops.py
+++ b/vllm/_custom_ops.py
@@ -235,6 +235,37 @@ def rms_norm(
torch.ops._C.rms_norm(out, input, weight, epsilon)
+# LongCat n-gram embedding index kernel (see csrc/.../ngram_embedding_kernels.cu).
+def ngram_compute_n_gram_ids(
+ ne_n: int,
+ ne_k: int,
+ ne_weights: torch.Tensor,
+ ne_mods: torch.Tensor,
+ exclusive_ne_embedder_size_sums: torch.Tensor,
+ exclusive_req_len_sums: torch.Tensor,
+ ne_token_table: torch.Tensor,
+ row_indices: torch.Tensor,
+ column_starts: torch.Tensor,
+ n_gram_ids: torch.Tensor,
+) -> None:
+ """Compute concatenated (offset) n-gram ids for a ragged prefill batch.
+
+ Writes ``n_gram_ids`` of shape ``[token_num, (ne_n-1)*ne_k]``.
+ """
+ torch.ops._C.ngram_compute_n_gram_ids(
+ ne_n,
+ ne_k,
+ ne_weights,
+ ne_mods,
+ exclusive_ne_embedder_size_sums,
+ exclusive_req_len_sums,
+ ne_token_table,
+ row_indices,
+ column_starts,
+ n_gram_ids,
+ )
+
+
def fused_add_rms_norm(
input: torch.Tensor,
residual: torch.Tensor,
diff --git a/vllm/config/speculative.py b/vllm/config/speculative.py
index 3dc8d33cf9fb..ea749091da03 100644
--- a/vllm/config/speculative.py
+++ b/vllm/config/speculative.py
@@ -518,7 +518,7 @@ def hf_config_override(hf_config: PretrainedConfig) -> PretrainedConfig:
"architectures": ["Qwen3_5MoeMTP" if is_moe else "Qwen3_5MTP"],
}
)
- if hf_config.model_type == "longcat_flash":
+ if hf_config.model_type in ("longcat_flash", "longcat_flash_ngram"):
hf_config.model_type = "longcat_flash_mtp"
n_predict = getattr(hf_config, "num_nextn_predict_layers", 1)
hf_config.update(
diff --git a/vllm/config/vllm.py b/vllm/config/vllm.py
index 062877b16942..dc0180034141 100644
--- a/vllm/config/vllm.py
+++ b/vllm/config/vllm.py
@@ -70,6 +70,7 @@
"DeepseekV2ForCausalLM",
"Qwen2MoeForCausalLM",
"GraniteMoeForCausalLM",
+ "LongcatFlashNgramForCausalLM",
}
)
diff --git a/vllm/model_executor/models/config.py b/vllm/model_executor/models/config.py
index cb01a12efdf7..96232237a06e 100644
--- a/vllm/model_executor/models/config.py
+++ b/vllm/model_executor/models/config.py
@@ -499,7 +499,7 @@ def verify_and_update_model_config(model_config: "ModelConfig") -> None:
"last": "LAST",
}
- pooling_type = pooling_type_map.get(hf_config.pooling, None)
+ pooling_type = pooling_type_map.get(hf_config.pooling)
if pooling_type is None:
raise ValueError(f"pool_type {hf_config.pooling!r} not supported")
@@ -809,6 +809,23 @@ def verify_and_update_model_config(model_config: "ModelConfig") -> None:
model_config.hf_config.embedding_size = model_config.hf_config.num_labels
+class LongcatFlashNgramForCausalLMConfig(VerifyAndUpdateConfig):
+ @staticmethod
+ def verify_and_update_config(vllm_config: "VllmConfig") -> None:
+ # LongCat-Flash-Lite's zero-expert MoE trips a data-dependent assert
+ # under torch.compile, and its n-gram inputs_embeds are only wired for
+ # FULL cudagraph capture (PIECEWISE prefill drops them). Default to
+ # no-compile + FULL cudagraph (prefill runs eager) unless the user
+ # configured compilation explicitly.
+ from vllm.config.compilation import CompilationMode, CUDAGraphMode
+
+ compilation_config = vllm_config.compilation_config
+ if compilation_config.mode is None:
+ compilation_config.mode = CompilationMode.NONE
+ if compilation_config.cudagraph_mode is None:
+ compilation_config.cudagraph_mode = CUDAGraphMode.FULL
+
+
MODELS_CONFIG_MAP: dict[str, type[VerifyAndUpdateConfig]] = {
"ColBERTJinaRobertaModel": JinaRobertaModelConfig,
"ColQwen3_5": ColQwen3_5Config,
@@ -822,6 +839,7 @@ def verify_and_update_model_config(model_config: "ModelConfig") -> None:
"Gemma4ForConditionalGeneration": Gemma4Config,
"Gemma4UnifiedForConditionalGeneration": Gemma4Config,
"GptOssForCausalLM": GptOssForCausalLMConfig,
+ "LongcatFlashNgramForCausalLM": LongcatFlashNgramForCausalLMConfig,
"GteModel": SnowflakeGteNewModelConfig,
"GteNewForSequenceClassification": GteNewModelConfig,
"GteNewModel": GteNewModelConfig,
diff --git a/vllm/model_executor/models/longcat_flash.py b/vllm/model_executor/models/longcat_flash.py
index 3dd1118aa8a4..18628a64cef7 100644
--- a/vllm/model_executor/models/longcat_flash.py
+++ b/vllm/model_executor/models/longcat_flash.py
@@ -318,7 +318,7 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
hidden_states = hidden_states.view(-1, hidden_dim)
# Align to FusedMoE padded hidden size to avoid dim mismatch
- padded_hidden = self.experts.hidden_size
+ padded_hidden = self.experts.moe_config.hidden_dim
if hidden_dim < padded_hidden:
hidden_states_padded = torch.nn.functional.pad(
hidden_states,
@@ -687,14 +687,23 @@ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
).split([self_attn.qk_nope_head_dim, self_attn.v_head_dim], dim=1)
self_attn.w_kc = w_kc.transpose(1, 2).contiguous().transpose(1, 2)
self_attn.w_vc = w_vc.contiguous().transpose(1, 2)
- if self.config.mla_scale_q_lora:
+ # Guard against compounding on incremental load_weights calls:
+ # the in-place ``*=`` would otherwise re-apply the MLA LoRA
+ # scaling to the layernorm weights on each pass.
+ if self.config.mla_scale_q_lora and not getattr(
+ self_attn, "_mla_q_lora_scaled", False
+ ):
self_attn.q_a_layernorm.weight.data *= (
self.config.hidden_size / self.config.q_lora_rank
) ** 0.5
- if self.config.mla_scale_kv_lora:
+ self_attn._mla_q_lora_scaled = True
+ if self.config.mla_scale_kv_lora and not getattr(
+ self_attn, "_mla_kv_lora_scaled", False
+ ):
self_attn.kv_a_layernorm.weight.data *= (
self.config.hidden_size / self.config.kv_lora_rank
) ** 0.5
+ self_attn._mla_kv_lora_scaled = True
return loaded_params
diff --git a/vllm/model_executor/models/longcat_flash_mtp.py b/vllm/model_executor/models/longcat_flash_mtp.py
index 13921d73512c..26fff05da63f 100644
--- a/vllm/model_executor/models/longcat_flash_mtp.py
+++ b/vllm/model_executor/models/longcat_flash_mtp.py
@@ -126,8 +126,10 @@ def forward(
class LongCatFlashMTP(nn.Module):
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
- # LongCat MTP without MoE layers
- vllm_config.model_config.hf_config.n_routed_experts = None
+ # LongCat MTP has no MoE layers: clear n_routed_experts so the predictor
+ # builds a dense MLP. object.__setattr__ bypasses the ngram remote
+ # config's strict validation (it rejects setting the int field to None).
+ object.__setattr__(vllm_config.model_config.hf_config, "n_routed_experts", None)
self.config = FlashConfig(**vllm_config.model_config.hf_config.__dict__)
self.quant_config = (
None
@@ -287,14 +289,22 @@ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
).split([self_attn.qk_nope_head_dim, self_attn.v_head_dim], dim=1)
self_attn.w_kc = w_kc.transpose(1, 2).contiguous().transpose(1, 2)
self_attn.w_vc = w_vc.contiguous().transpose(1, 2)
- if self.config.mla_scale_q_lora:
+ # Guard against compounding on incremental load_weights calls (the
+ # in-place *= would otherwise double-apply the LoRA scaling).
+ if self.config.mla_scale_q_lora and not getattr(
+ self_attn, "_mla_q_lora_scaled", False
+ ):
self_attn.q_a_layernorm.weight.data *= (
self.config.hidden_size / self.config.q_lora_rank
) ** 0.5
- if self.config.mla_scale_kv_lora:
+ self_attn._mla_q_lora_scaled = True
+ if self.config.mla_scale_kv_lora and not getattr(
+ self_attn, "_mla_kv_lora_scaled", False
+ ):
self_attn.kv_a_layernorm.weight.data *= (
self.config.hidden_size / self.config.kv_lora_rank
) ** 0.5
+ self_attn._mla_kv_lora_scaled = True
return loaded_params
def _rewrite_spec_layer_name(
diff --git a/vllm/model_executor/models/longcat_flash_ngram.py b/vllm/model_executor/models/longcat_flash_ngram.py
new file mode 100644
index 000000000000..5aaa1aad9bb8
--- /dev/null
+++ b/vllm/model_executor/models/longcat_flash_ngram.py
@@ -0,0 +1,405 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Inference-only LongCat-Flash-Lite (n-gram embedding) model.
+
+``LongcatFlashNgramForCausalLM`` is LongCat-Flash (MLA dual-attention +
+zero-expert MoE + YaRN) plus an n-gram embedding input layer: each position's
+embedding fuses the token embedding with hashed embeddings of the preceding
+``n`` tokens. That per-request token history is isolated in a Model-Runner-V2
+:class:`LongcatNgramModelState` (mirroring ``DiffusionGemmaModelState``), so
+``get_model_state_cls`` makes the model MRV2-only.
+"""
+
+from collections.abc import Iterable
+from typing import Any
+
+import torch
+from torch import nn
+
+from vllm import _custom_ops as ops
+from vllm.config import VllmConfig
+from vllm.distributed import get_pp_group
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
+from vllm.model_executor.layers.vocab_parallel_embedding import (
+ ParallelLMHead,
+ VocabParallelEmbedding,
+)
+from vllm.v1.core.sched.output import NewRequestData
+from vllm.v1.worker.gpu.input_batch import InputBatch
+from vllm.v1.worker.gpu.model_states.default import DefaultModelState
+from vllm.v1.worker.gpu.states import RequestState
+
+from .interfaces import SupportsLoRA, SupportsPP
+from .longcat_flash import FlashConfig, FlashModel
+from .utils import AutoWeightsLoader, PPMissingLayer, maybe_prefix
+
+
+def uses_ngram_embedding(config: FlashConfig) -> bool:
+ return getattr(config, "ngram_vocab_size_ratio", None) is not None
+
+
+def _config_dtype(config: FlashConfig) -> torch.dtype:
+ dt = getattr(config, "torch_dtype", None) or getattr(config, "dtype", None)
+ if isinstance(dt, torch.dtype):
+ return dt
+ return getattr(torch, str(dt), None) or torch.bfloat16
+
+
+class NgramEmbedding(nn.Module):
+ """Token embedding fused with hashed n-gram embeddings.
+
+ TP-sharded: the ``k*(n-1)`` per-embedder tables are concatenated into one
+ :class:`VocabParallelEmbedding` (``oe_embedder``) with per-embedder offsets,
+ and the projections are stacked into one ``oe_projection`` applied with a
+ single ``bmm``. Hashing math is ported from the HF reference.
+ """
+
+ def __init__(self, config: FlashConfig, base_embeddings: nn.Module) -> None:
+ super().__init__()
+ self.config = config
+ self.word_embeddings = base_embeddings
+
+ self.m = config.ngram_vocab_size_ratio * config.vocab_size
+ self.k = config.emb_split_num
+ self.n = config.emb_neighbor_num
+ self.pad_id = config.pad_token_id
+ self.eos_token_id = config.eos_token_id
+ self._dtype = _config_dtype(config)
+
+ self._init_ngram_embeddings()
+
+ def _init_ngram_embeddings(self) -> None:
+ self.num_embedders = self.k * (self.n - 1)
+ oe_dim = self.config.hidden_size // self.num_embedders
+ self.oe_dim = oe_dim
+
+ # Exclusive prefix sums of per-embedder table sizes; each embedder's
+ # local id is offset into the single concatenated table.
+ sizes = [int(self.m + i * 2 + 1) for i in range(self.num_embedders)]
+ offsets = [0]
+ for s in sizes:
+ offsets.append(offsets[-1] + s)
+ self._offsets = offsets # len num_embedders + 1
+ self._sizes = sizes
+
+ self.oe_embedder = VocabParallelEmbedding(
+ offsets[-1], oe_dim, params_dtype=self._dtype
+ )
+ # Stacked projections: oe_projection[i] = post_projs[i].weight.T
+ self.oe_projection = nn.Parameter(
+ torch.empty(
+ self.num_embedders, oe_dim, self.config.hidden_size, dtype=self._dtype
+ ),
+ requires_grad=False,
+ )
+
+ # Precomputed tables for the CUDA n-gram id kernel (ngram_embedding
+ # _kernels.cu): ne_weights[i][j][delta] = vocab^delta mod ne_mods[i][j],
+ # ne_mods[i][j] = m + 2*(i*k+j) + 1. Registered as non-persistent buffers
+ # so they follow the module to the device (not part of the checkpoint).
+ vocab = self.config.vocab_size
+ ne_weights = torch.zeros(self.n - 1, self.k, self.n, dtype=torch.int32)
+ ne_mods = torch.zeros(self.n - 1, self.k, dtype=torch.int32)
+ for i in range(self.n - 1):
+ for j in range(self.k):
+ mod = int(self.m + 2 * (i * self.k + j) + 1)
+ ne_mods[i, j] = mod
+ for delta in range(self.n):
+ ne_weights[i, j, delta] = pow(vocab, delta, mod)
+ self.register_buffer("ne_weights", ne_weights, persistent=False)
+ self.register_buffer("ne_mods", ne_mods, persistent=False)
+ self.register_buffer(
+ "exclusive_sizes",
+ torch.tensor(offsets, dtype=torch.int32),
+ persistent=False,
+ )
+
+ def load_weight(self, weight_name: str, loaded_weight: torch.Tensor) -> str:
+ """Split a per-embedder checkpoint weight into the sharded layout.
+
+ Returns the destination parameter's qualified name (relative to the
+ enclosing model) so the caller can mark it loaded for completeness
+ checks.
+ """
+ if "ngram_embeddings.embedders." in weight_name:
+ index = int(
+ weight_name.split("ngram_embeddings.embedders.")[1].split(".")[0]
+ )
+ lo, hi = self._offsets[index], self._offsets[index + 1]
+ assert hi - lo == loaded_weight.shape[0], (
+ f"{hi - lo=} {loaded_weight.shape[0]=}"
+ )
+ shard = self.oe_embedder.shard_indices
+ tp_start, tp_end = shard.org_vocab_start_index, shard.org_vocab_end_index
+ load_start, load_end = max(lo, tp_start), min(hi, tp_end)
+ if load_start < load_end:
+ self.oe_embedder.weight.data[
+ load_start - tp_start : load_end - tp_start
+ ] = loaded_weight[load_start - lo : load_end - lo]
+ return "ngram_embeddings.oe_embedder.weight"
+ elif "ngram_embeddings.post_projs." in weight_name:
+ index = int(
+ weight_name.split("ngram_embeddings.post_projs.")[1].split(".")[0]
+ )
+ self.oe_projection.data[index].copy_(loaded_weight.t())
+ return "ngram_embeddings.oe_projection"
+ else:
+ raise AssertionError(f"Unexpected ngram weight: {weight_name}")
+
+ def embed_batched(
+ self, input_ids: torch.Tensor, oe_ids: torch.Tensor
+ ) -> torch.Tensor:
+ """Fused n-gram embedding for a flat batch given precomputed ids.
+
+ Args:
+ input_ids: ``[num_tokens]`` current token per position.
+ oe_ids: ``[num_tokens, num_embedders]`` global (offset) n-gram ids,
+ as produced by the ``ngram_compute_n_gram_ids`` kernel.
+ Returns: ``[num_tokens, hidden]``.
+ """
+ word = self.word_embeddings(input_ids) # [N, H]
+ flat = oe_ids.permute(1, 0).contiguous() # [num_embedders, N]
+ oe = self.oe_embedder(flat) # [num_embedders, N, oe_dim]
+ proj = torch.bmm(oe, self.oe_projection) # [num_embedders, N, H]
+ all_h = torch.cat([word.unsqueeze(0), proj], dim=0) # [ne+1, N, H]
+ return all_h.mean(dim=0) # [N, H]
+
+
+class FlashNgramModel(FlashModel):
+ """FlashModel whose input embedding is an :class:`NgramEmbedding`."""
+
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
+ # Each FlashDecoderLayer is a *dual* layer (2 attentions), so the number
+ # of decoder layers is ``num_layers``. The ngram HF config sets
+ # ``num_hidden_layers`` to a multiple of that (attention-module count),
+ # which FlashModel would otherwise build as too many (dead) layers.
+ hf = vllm_config.model_config.hf_config
+ num_layers = getattr(hf, "num_layers", None)
+ if num_layers is not None and hf.num_hidden_layers != num_layers:
+ hf.num_hidden_layers = num_layers
+ super().__init__(vllm_config=vllm_config, prefix=prefix)
+ if get_pp_group().is_first_rank and uses_ngram_embedding(self.config):
+ self.ngram_embeddings = NgramEmbedding(self.config, self.embed_tokens)
+ else:
+ self.ngram_embeddings = None
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ # Names arrive with the ``model.`` prefix already stripped (routed here
+ # by AutoWeightsLoader). Split the concatenated/sharded ngram tables and
+ # stacked projections; delegate everything else to FlashModel.
+ loaded: set[str] = set()
+ rest: list[tuple[str, torch.Tensor]] = []
+ for name, w in weights:
+ if self.ngram_embeddings is not None and (
+ "ngram_embeddings.embedders." in name
+ or "ngram_embeddings.post_projs." in name
+ ):
+ loaded.add(self.ngram_embeddings.load_weight(name, w))
+ else:
+ rest.append((name, w))
+ loaded |= super().load_weights(rest)
+ return loaded
+
+
+class LongcatFlashNgramForCausalLM(nn.Module, SupportsLoRA, SupportsPP):
+ """LongCat-Flash-Lite for causal LM (MRV2-only, n-gram embedding)."""
+
+ packed_modules_mapping = {
+ "qkv_proj": ["q_proj", "k_proj", "v_proj"],
+ "gate_up_proj": ["gate_proj", "up_proj"],
+ }
+
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
+ super().__init__()
+ if not vllm_config.use_v2_model_runner:
+ raise NotImplementedError(
+ "LongcatFlashNgramForCausalLM (LongCat-Flash-Lite) requires the "
+ "V2 model runner for its n-gram embedding state; it is selected "
+ "automatically unless VLLM_USE_V2_MODEL_RUNNER=0 is set."
+ )
+ config = FlashConfig(**vllm_config.model_config.hf_config.__dict__)
+ config.intermediate_size = getattr(
+ config, "ffn_hidden_size", config.intermediate_size
+ )
+ self.config = config
+ self.quant_config = vllm_config.quant_config
+
+ self.model = FlashNgramModel(
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
+ )
+ if get_pp_group().is_last_rank:
+ self.lm_head = ParallelLMHead(
+ config.vocab_size,
+ config.hidden_size,
+ quant_config=self.quant_config,
+ prefix=maybe_prefix(prefix, "lm_head"),
+ )
+ else:
+ self.lm_head = PPMissingLayer()
+ self.logits_processor = LogitsProcessor(config.vocab_size)
+ self.make_empty_intermediate_tensors = (
+ self.model.make_empty_intermediate_tensors
+ )
+
+ @staticmethod
+ def get_model_state_cls() -> type["LongcatNgramModelState"]:
+ return LongcatNgramModelState
+
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
+ return self.model.embed_input_ids(input_ids)
+
+ def forward(
+ self,
+ input_ids: torch.Tensor | None,
+ positions: torch.Tensor,
+ intermediate_tensors=None,
+ inputs_embeds: torch.Tensor | None = None,
+ ):
+ # inputs_embeds is produced by LongcatNgramModelState.prepare_inputs.
+ return self.model(input_ids, positions, intermediate_tensors, inputs_embeds)
+
+ def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
+ return self.logits_processor(self.lm_head, hidden_states)
+
+ def get_expert_mapping(self):
+ return self.model.get_expert_mapping()
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ # AutoWeightsLoader routes ``model.*`` to FlashNgramModel.load_weights
+ # (which handles the ngram split) and ``lm_head.*`` to the head. MTP
+ # weights are not part of this model.
+ loader = AutoWeightsLoader(self, skip_prefixes=["model.mtp."])
+ return loader.load_weights(weights)
+
+
+class LongcatNgramModelState(DefaultModelState):
+ """Per-request n-gram token history for LongCat-Flash-Lite.
+
+ Maintains a small CPU-side per-slot context (last ``n-1`` processed tokens)
+ and a persistent ``inputs_embeds`` buffer. ``prepare_inputs`` computes the
+ fused n-gram embedding per request into the buffer, handed to the model
+ forward as ``inputs_embeds``.
+ """
+
+ def __init__(self, vllm_config, model, encoder_cache, device) -> None:
+ super().__init__(vllm_config, model, encoder_cache, device)
+ config = model.config
+ self.ngram = model.model.ngram_embeddings
+ self.n = int(config.emb_neighbor_num)
+ self.ctx_len = self.n - 1
+ self.eos_id = int(config.eos_token_id)
+
+ # Per-slot left-context: last ``n-1`` processed tokens, EOS negated. A
+ # negative entry (incl. the -1 fill) marks a context boundary that stops
+ # the n-gram walk (matches the kernel's EOS break / fresh-request start).
+ self.token_context = torch.full(
+ (self.max_num_reqs, self.ctx_len), -1, dtype=torch.int32, device=device
+ )
+
+ self._inputs_embeds_buf = torch.zeros(
+ self.max_num_tokens,
+ config.hidden_size,
+ dtype=self.dtype,
+ device=device,
+ )
+
+ def _neg_eos(self, toks: list[int]) -> list[int]:
+ return [-t if t == self.eos_id else t for t in toks]
+
+ def add_request(self, req_index: int, new_req_data: NewRequestData) -> None:
+ super().add_request(req_index, new_req_data) # rope positions
+ # Fresh request -> no left-context (-1 fill). On resume, seed from the
+ # already-processed token tail. Use prefill_token_ids (full processed
+ # sequence incl. generated tokens on v2 resume), like DefaultModelState;
+ # the prompt alone would be too short when resuming after decode.
+ ctx = [-1] * self.ctx_len
+ ncomp = new_req_data.num_computed_tokens
+ toks_src = new_req_data.prefill_token_ids or new_req_data.prompt_token_ids
+ if ncomp > 0 and toks_src is not None:
+ lo = max(0, ncomp - self.ctx_len)
+ toks = self._neg_eos(list(toks_src[lo:ncomp]))
+ ctx[self.ctx_len - len(toks) :] = toks
+ self.token_context[req_index] = torch.tensor(
+ ctx, dtype=torch.int32, device=self.token_context.device
+ )
+
+ def prepare_inputs(
+ self, input_batch: InputBatch, req_states: RequestState
+ ) -> dict[str, Any]:
+ model_inputs = super().prepare_inputs(input_batch, req_states) # positions
+ num_tokens = input_batch.num_tokens
+ num_padded = input_batch.num_tokens_after_padding
+ input_ids = input_batch.input_ids[:num_tokens]
+ embeds = self._inputs_embeds_buf[:num_padded]
+
+ oe_ids = self._compute_oe_ids(input_batch)
+ embeds[:num_tokens].copy_(self.ngram.embed_batched(input_ids, oe_ids))
+ model_inputs["inputs_embeds"] = embeds
+ return model_inputs
+
+ def prepare_dummy_inputs(self, num_reqs: int, num_tokens: int) -> dict[str, Any]:
+ # FULL cudagraph replay reads only the captured buffers, so capture must
+ # reference the same persistent ``inputs_embeds`` buffer prepare_inputs
+ # re-fills (the base class wires this for multimodal models only).
+ model_inputs = super().prepare_dummy_inputs(num_reqs, num_tokens) # positions
+ model_inputs["inputs_embeds"] = self._inputs_embeds_buf[:num_tokens]
+ return model_inputs
+
+ def _compute_oe_ids(self, input_batch: InputBatch) -> torch.Tensor:
+ """Batched global n-gram ids ``[num_tokens, num_embedders]``.
+
+ Assembles an ephemeral per-request token table (``[n-1] context ++
+ current tokens``, EOS-negated) and runs the ``ngram_compute_n_gram_ids``
+ CUDA kernel for the whole batch, then rolls each slot's context forward.
+ """
+ device = self.token_context.device
+ num_tokens = input_batch.num_tokens
+ num_reqs = input_batch.num_reqs
+ ctx_len = self.ctx_len
+ idx_mapping = input_batch.idx_mapping[:num_reqs].long()
+ qsl = input_batch.query_start_loc[: num_reqs + 1].to(torch.int32)
+ cur = input_batch.input_ids[:num_tokens].to(torch.int32)
+
+ cur_neg = torch.where(cur == self.eos_id, -cur, cur)
+ req_lens = qsl[1:] - qsl[:-1]
+ max_len = int(req_lens.max().item())
+ width = ctx_len + max_len
+
+ # table[r] = [context(n-1) | current tokens | pad(-1)]
+ table = torch.full((num_reqs, width), -1, dtype=torch.int32, device=device)
+ table[:, :ctx_len] = self.token_context[idx_mapping]
+ tok_req = torch.repeat_interleave(
+ torch.arange(num_reqs, device=device), req_lens.long()
+ )
+ col = ctx_len + (
+ torch.arange(num_tokens, device=device) - qsl[:-1].long()[tok_req]
+ )
+ table[tok_req, col] = cur_neg
+
+ column_starts = torch.full(
+ (num_reqs,), ctx_len, dtype=torch.int32, device=device
+ )
+ row_indices = torch.arange(num_reqs, dtype=torch.int64, device=device)
+ n_gram_ids = torch.empty(
+ num_tokens, self.ngram.num_embedders, dtype=torch.int32, device=device
+ )
+ ops.ngram_compute_n_gram_ids(
+ self.n,
+ self.ngram.k,
+ self.ngram.ne_weights,
+ self.ngram.ne_mods,
+ self.ngram.exclusive_sizes,
+ qsl,
+ table,
+ row_indices,
+ column_starts,
+ n_gram_ids,
+ )
+
+ # Roll context: new context = last n-1 of [context | current] per slot.
+ gather = req_lens.long().unsqueeze(1) + torch.arange(
+ ctx_len, device=device
+ ).unsqueeze(0)
+ rows = torch.arange(num_reqs, device=device).unsqueeze(1)
+ self.token_context[idx_mapping] = table[rows, gather]
+ return n_gram_ids.long()
diff --git a/vllm/model_executor/models/registry.py b/vllm/model_executor/models/registry.py
index 6d70afc4f687..4999662dd6e3 100644
--- a/vllm/model_executor/models/registry.py
+++ b/vllm/model_executor/models/registry.py
@@ -145,6 +145,10 @@
# For decapoda-research/llama-*
"LLaMAForCausalLM": ("llama", "LlamaForCausalLM"),
"LongcatFlashForCausalLM": ("longcat_flash", "LongcatFlashForCausalLM"),
+ "LongcatFlashNgramForCausalLM": (
+ "longcat_flash_ngram",
+ "LongcatFlashNgramForCausalLM",
+ ),
"MambaForCausalLM": ("mamba", "MambaForCausalLM"),
"Mamba2ForCausalLM": ("mamba2", "Mamba2ForCausalLM"),
"MellumForCausalLM": ("mellum", "MellumForCausalLM"),
diff --git a/vllm/transformers_utils/model_arch_config_convertor.py b/vllm/transformers_utils/model_arch_config_convertor.py
index 7ced083afb54..b6f73f39f3dd 100644
--- a/vllm/transformers_utils/model_arch_config_convertor.py
+++ b/vllm/transformers_utils/model_arch_config_convertor.py
@@ -268,6 +268,7 @@ def is_deepseek_mla(self) -> bool:
"kimi_k2",
"kimi_linear",
"longcat_flash",
+ "longcat_flash_ngram",
"pangu_ultra_moe",
"pangu_ultra_moe_mtp",
"bailing_hybrid",
diff --git a/vllm/v1/worker/gpu/attn_utils.py b/vllm/v1/worker/gpu/attn_utils.py
index becfd610bde4..227c51bb473a 100644
--- a/vllm/v1/worker/gpu/attn_utils.py
+++ b/vllm/v1/worker/gpu/attn_utils.py
@@ -540,7 +540,15 @@ def init_kv_cache(
shared_kv_cache_layers=shared_kv_cache_layers,
kv_cache_config=kv_cache_config,
)
- bind_kv_cache(kv_caches, forward_context, runner_kv_caches)
+ # Dual-attention models (e.g. LongCat-Flash) put two Attention modules per
+ # decoder layer, so a layer name carries two integers (layer + module index).
+ num_attn_module = (
+ 2
+ if vllm_config.model_config.hf_config.model_type
+ in ("longcat_flash", "longcat_flash_ngram")
+ else 1
+ )
+ bind_kv_cache(kv_caches, forward_context, runner_kv_caches, num_attn_module)
return kv_caches
From c227aaa3f8edd02dae4583e27246430eebabfb25 Mon Sep 17 00:00:00 2001
From: larryli2-amd
Date: Fri, 10 Jul 2026 23:22:19 +0800
Subject: [PATCH 0027/1526] [ROCm] Enable DeepSeek-V4 DSpark speculative
decoding on AMD (MI350X / MI355X, gfx950) (#47419)
Signed-off-by: larryli2-amd
Signed-off-by: larryli2-amd
Co-authored-by: Andreas Karatzas
---
tests/models/test_registry.py | 8 +-
vllm/config/speculative.py | 25 ++
vllm/models/deepseek_v4/__init__.py | 8 +-
vllm/models/deepseek_v4/amd/dspark.py | 499 ++++++++++++++++++++++++++
vllm/models/deepseek_v4/amd/model.py | 50 ++-
vllm/models/deepseek_v4/amd/rocm.py | 10 +-
6 files changed, 586 insertions(+), 14 deletions(-)
create mode 100644 vllm/models/deepseek_v4/amd/dspark.py
diff --git a/tests/models/test_registry.py b/tests/models/test_registry.py
index 46c887838a1b..1418704036c1 100644
--- a/tests/models/test_registry.py
+++ b/tests/models/test_registry.py
@@ -48,9 +48,11 @@ def test_registry_imports(model_arch):
"(see #41376)"
)
- # DSpark draft model is NVIDIA-only; class is stubbed to None on ROCm/XPU.
- if model_arch == "DSparkDraftModel" and not current_platform.is_cuda():
- pytest.skip("DSparkDraftModel is only supported on CUDA")
+ # DSpark draft model is supported on CUDA and ROCm; stubbed to None on XPU.
+ if model_arch == "DSparkDraftModel" and not (
+ current_platform.is_cuda() or current_platform.is_rocm()
+ ):
+ pytest.skip("DSparkDraftModel is only supported on CUDA and ROCm")
# Ensure all model classes can be imported successfully
model_cls = ModelRegistry._try_load_model_cls(model_arch)
diff --git a/vllm/config/speculative.py b/vllm/config/speculative.py
index ea749091da03..b9c612621982 100644
--- a/vllm/config/speculative.py
+++ b/vllm/config/speculative.py
@@ -942,6 +942,31 @@ def __post_init__(self):
"`num_speculative_tokens` was not provided"
)
+ if self.method == "dspark":
+ # DSpark is a semi-autoregressive *block* drafter. A
+ # speculative length smaller than the checkpoint's block
+ # feeds the block / Markov-head machinery an unsupported
+ # layout and yields incorrect (garbled) output rather than
+ # merely lower acceptance. Require num_speculative_tokens to
+ # be at least the block size (e.g. 5 or 7 for DeepSeek-V4).
+ dspark_block_size = getattr(
+ self.draft_model_config.hf_config,
+ "dspark_block_size",
+ None,
+ )
+ if (
+ dspark_block_size is not None
+ and self.num_speculative_tokens < dspark_block_size
+ ):
+ raise ValueError(
+ "DSpark requires num_speculative_tokens >= "
+ f"dspark_block_size ({dspark_block_size}); got "
+ f"{self.num_speculative_tokens}. Smaller values "
+ "produce incorrect output. Use "
+ f"num_speculative_tokens={dspark_block_size} or "
+ "larger (e.g. 7)."
+ )
+
self.draft_tensor_parallel_size = (
SpeculativeConfig._verify_and_get_draft_tp(
self.target_parallel_config,
diff --git a/vllm/models/deepseek_v4/__init__.py b/vllm/models/deepseek_v4/__init__.py
index d03eba3d414e..05662bbd321a 100644
--- a/vllm/models/deepseek_v4/__init__.py
+++ b/vllm/models/deepseek_v4/__init__.py
@@ -15,16 +15,16 @@
# default that mypy sees; the ROCm/XPU branches override at runtime and are
# kept type-compatible via ``# type: ignore[assignment]``.
if current_platform.is_rocm():
+ from .amd.dspark import ( # type: ignore[assignment]
+ DSparkDeepseekV4ForCausalLM,
+ )
from .amd.model import DeepseekV4ForCausalLM
from .amd.mtp import DeepSeekV4MTP
-
- # DSpark is NVIDIA-only for now.
- DSparkDeepseekV4ForCausalLM = None # type: ignore[assignment]
elif current_platform.is_xpu():
from .xpu.model import DeepseekV4ForCausalLM # type: ignore[assignment]
from .xpu.mtp import DeepSeekV4MTP # type: ignore[assignment]
- DSparkDeepseekV4ForCausalLM = None # type: ignore[assignment]
+ DSparkDeepseekV4ForCausalLM = None # type: ignore[assignment, misc]
else:
from .nvidia.dspark import ( # type: ignore[assignment]
DSparkDeepseekV4ForCausalLM,
diff --git a/vllm/models/deepseek_v4/amd/dspark.py b/vllm/models/deepseek_v4/amd/dspark.py
new file mode 100644
index 000000000000..4769483ff279
--- /dev/null
+++ b/vllm/models/deepseek_v4/amd/dspark.py
@@ -0,0 +1,499 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""DSpark draft model for DeepSeek-V4 on ROCm/AMD (gfx950).
+
+ROCm port of ``nvidia/dspark.py``. Follows the same nvidia->amd recipe used for
+``amd/mtp.py``:
+
+ * import ``DeepseekV4DecoderLayer`` from the AMD ``.model`` (aiter/triton
+ attention + MHC CustomOp path) instead of the nvidia one;
+ * route the MHC head through the ``HCHeadOp`` CustomOp dispatcher (aiter /
+ tilelang / triton / torch) instead of calling the tilelang kernels directly,
+ and gate the trailing ``mhc_post`` on ``use_fused_mhc`` (False on the aiter
+ path, where the decoder layer already applies hc_post in-layer);
+ * drop the mega-MoE weight path (``make_deepseek_v4_expert_params_mapping`` /
+ ``use_mega_moe`` / ``finalize_mega_moe_weights`` do not exist in amd/model.py).
+
+Everything else — the semi-autoregressive drafting hooks, the Markov head, the
+sliding-window context-KV insert, and the checkpoint ``mtp.*`` weight remap — is
+pure torch / Triton and shared with the nvidia implementation unchanged.
+"""
+
+from collections.abc import Iterable
+
+import regex as re
+import torch
+import torch.nn as nn
+
+from vllm.config import VllmConfig, get_current_vllm_config
+from vllm.distributed import (
+ get_tensor_model_parallel_rank,
+ get_tensor_model_parallel_world_size,
+)
+from vllm.logger import init_logger
+from vllm.model_executor.layers.fused_moe import (
+ fused_moe_make_expert_params_mapping,
+)
+from vllm.model_executor.layers.layernorm import RMSNorm
+from vllm.model_executor.layers.linear import ReplicatedLinear
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
+from vllm.model_executor.layers.mhc import HCHeadOp
+from vllm.model_executor.layers.vocab_parallel_embedding import (
+ ParallelLMHead,
+ VocabParallelEmbedding,
+)
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
+from vllm.model_executor.models.qwen3_dspark import (
+ DSparkMarkovHead,
+)
+from vllm.model_executor.models.utils import maybe_prefix
+
+from .model import (
+ DeepseekV4DecoderLayer,
+)
+
+logger = init_logger(__name__)
+
+# MoE expert scale suffix differs by expert dtype (mirrors deepseek_v4 loaders):
+# fp4 experts register ``.weight_scale``; block-fp8 experts ``.weight_scale_inv``.
+_EXPERT_SCALE_RE = re.compile(r"\.experts\.\d+\.w[123]\.scale$")
+
+
+class DSparkDeepseekV4Model(nn.Module):
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
+ super().__init__()
+ assert vllm_config.speculative_config is not None
+ config = vllm_config.speculative_config.draft_model_config.hf_config
+ self.config = config
+ self.hidden_size = config.hidden_size
+ self.hc_mult = config.hc_mult
+ self.hc_eps = config.hc_eps
+ self.rms_norm_eps = config.rms_norm_eps
+ self.num_hidden_layers = config.num_hidden_layers
+ self.target_layer_ids = tuple(config.dspark_target_layer_ids)
+
+ self.num_dspark_layers = getattr(config, "n_mtp_layers", None) or 3
+
+ # Shared with the target (aliased by the speculator's loading utility).
+ self.embed_tokens = VocabParallelEmbedding(
+ config.vocab_size,
+ config.hidden_size,
+ prefix=maybe_prefix(prefix, "embed_tokens"),
+ )
+
+ self.main_proj = ReplicatedLinear(
+ config.hidden_size * len(self.target_layer_ids),
+ config.hidden_size,
+ bias=False,
+ return_bias=False,
+ quant_config=vllm_config.quant_config,
+ prefix=maybe_prefix(prefix, "main_proj"),
+ )
+ self.main_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
+
+ current_vllm_config = get_current_vllm_config()
+ self.layers = nn.ModuleList(
+ [
+ DeepseekV4DecoderLayer(
+ current_vllm_config,
+ prefix=maybe_prefix(prefix, f"layers.{self.num_hidden_layers + i}"),
+ )
+ for i in range(self.num_dspark_layers)
+ ]
+ )
+
+ # Heads: final norm + hc_head, and the Markov head
+ # Loaded from the "final" MTP layer weights (mtp.*) in the target checkpoint
+ self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
+ hc_dim = self.hc_mult * config.hidden_size
+ self.hc_head_fn = nn.Parameter(
+ torch.empty(self.hc_mult, hc_dim, dtype=torch.float32),
+ requires_grad=False,
+ )
+ self.hc_head_base = nn.Parameter(
+ torch.empty(self.hc_mult, dtype=torch.float32), requires_grad=False
+ )
+ self.hc_head_scale = nn.Parameter(
+ torch.empty(1, dtype=torch.float32), requires_grad=False
+ )
+ draft_vocab_size = (
+ getattr(config, "draft_vocab_size", None) or config.vocab_size
+ )
+ self.markov_head = DSparkMarkovHead(
+ config.vocab_size,
+ draft_vocab_size,
+ config.dspark_markov_rank,
+ prefix=maybe_prefix(prefix, "markov_head"),
+ )
+
+ # MHC head CustomOp dispatcher (aiter / tilelang / triton / torch),
+ # replacing the direct nvidia tilelang kernel call.
+ self.hc_head_op = HCHeadOp()
+
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
+ return self.embed_tokens(input_ids)
+
+ def combine_hidden_states(self, aux_hidden_states: torch.Tensor) -> torch.Tensor:
+ """main_x = main_norm(main_proj(concat of target aux hidden states)).
+
+ ``aux_hidden_states`` is [T, hidden_size * len(target_layer_ids)].
+ """
+ return self.main_norm(self.main_proj(aux_hidden_states))
+
+ @torch.inference_mode()
+ def precompute_and_store_context_kv(
+ self,
+ main_x: torch.Tensor,
+ context_positions: torch.Tensor,
+ context_slot_mappings: list[torch.Tensor | None] | None = None,
+ ) -> None:
+ """Insert the sliding-window context KV for every draft layer.
+
+ Mirrors the reference DSparkAttention: each layer derives its context KV
+ from the SAME projected target hidden ``main_x``, via that layer's own
+ ``wkv`` + ``kv_norm`` + RoPE + quant, then writes it at the
+ layer's context slots.
+
+ ``context_slot_mappings`` is a per-layer list (each entry is the context
+ slot mapping for that layer's kv-cache group, since the hybrid manager may
+ place draft layers in different groups). ``None`` (or a ``None`` entry)
+ runs the projection to reserve workspace but writes nothing (profiling).
+ """
+ for i, layer in enumerate(self.layers):
+ slot_mapping = (
+ None if context_slot_mappings is None else context_slot_mappings[i]
+ )
+ attn = layer.attn
+ # Optimized DSV4 MLA path: wkv part of the fused wq_a|wkv projection
+ # (q_lora part discarded), then RoPE/quant/insert via the fused op.
+ qr_kv, _ = attn.fused_wqa_wkv(main_x)
+ kv = qr_kv[..., attn.q_lora_rank :]
+ kv = attn.kv_norm(kv)
+ if slot_mapping is None:
+ continue
+ _insert_context_kv(attn, kv, context_positions, slot_mapping)
+
+ def forward(
+ self,
+ input_ids: torch.Tensor,
+ positions: torch.Tensor,
+ inputs_embeds: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ if inputs_embeds is None:
+ inputs_embeds = self.embed_input_ids(input_ids)
+ # Expand to hc_mult copies for hyper-connections ([T, H] -> [T, hc, H]).
+ hidden_states = inputs_embeds.unsqueeze(-2).repeat(1, self.hc_mult, 1)
+
+ residual = post_mix = res_mix = None
+ for layer in self.layers:
+ hidden_states, residual, post_mix, res_mix = layer(
+ hidden_states,
+ positions,
+ input_ids,
+ post_mix,
+ res_mix,
+ residual,
+ )
+ # On the fused-MHC path the trailing hc_post must be applied here; on the
+ # aiter unfused path (ROCm default) the decoder layer already applied
+ # hc_post in-layer and returned None mixes, so this is skipped. Mirrors
+ # amd/mtp.py.
+ last_layer = self.layers[-1]
+ if last_layer.use_fused_mhc:
+ hidden_states = last_layer.hc_post(
+ hidden_states, residual, post_mix, res_mix
+ )
+ # hc_head reduces the hc copies; return the PRE-norm head hidden.
+ hidden_states = self.hc_head_op(
+ hidden_states,
+ self.hc_head_fn,
+ self.hc_head_scale,
+ self.hc_head_base,
+ self.rms_norm_eps,
+ self.hc_eps,
+ )
+ return hidden_states
+
+
+def _insert_context_kv(
+ attn: nn.Module,
+ kv: torch.Tensor,
+ positions: torch.Tensor,
+ slot_mapping: torch.Tensor,
+) -> None:
+ """RoPE + quant + paged-cache insert of (already kv_norm'd) context KV.
+
+ Reuses the DSV4 fused insert ops (which also process a query; we pass a dummy
+ query and discard it, since context tokens have no query). Mirrors
+ ``DeepseekV4Attention._fused_qnorm_rope_kv_insert``.
+ """
+ swa_cache = attn.swa_cache_layer.kv_cache
+ block_size = attn.swa_cache_layer.block_size
+ cos_sin_cache = attn.rotary_emb.cos_sin_cache
+ cache_dtype = swa_cache.dtype
+ n_ctx = kv.shape[0]
+ dummy_q = torch.zeros(
+ (n_ctx, attn.n_local_heads, attn.head_dim),
+ dtype=kv.dtype,
+ device=kv.device,
+ )
+ if cache_dtype == torch.uint8:
+ # fp8_ds_mla UE8M0 paged layout (the gfx950 aiter SWA cache path).
+ swa_2d = swa_cache.view(swa_cache.shape[0], -1)
+ torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert(
+ dummy_q,
+ kv,
+ swa_2d,
+ slot_mapping,
+ positions,
+ cos_sin_cache,
+ attn.padded_heads,
+ attn.eps,
+ block_size,
+ )
+ elif cache_dtype == torch.bfloat16:
+ swa_3d = swa_cache.view(-1, block_size, attn.head_dim)
+ torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(
+ dummy_q,
+ kv,
+ swa_3d,
+ slot_mapping,
+ positions,
+ cos_sin_cache,
+ attn.eps,
+ block_size,
+ )
+ else: # per-tensor fp8 (torch.float8_e4m3fn)
+ # NOTE(rocm): unreachable on ROCm/aiter, where the SWA cache dtype is
+ # uint8 (fp8_ds_mla) or bfloat16. This branch relies on FlashInfer-only
+ # attributes (``_flashinfer_fp8_*``) that the aiter attention layer does
+ # not define; kept for parity with the nvidia path.
+ swa_3d = swa_cache.view(-1, block_size, attn.head_dim)
+ dummy_q_fp8 = torch.zeros_like(dummy_q, dtype=torch.float8_e4m3fn)
+ torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_fp8_insert(
+ dummy_q,
+ kv,
+ dummy_q_fp8,
+ swa_3d,
+ slot_mapping,
+ positions,
+ cos_sin_cache,
+ attn._flashinfer_fp8_kv_scale,
+ attn._flashinfer_fp8_q_scale_inv,
+ attn.eps,
+ block_size,
+ )
+
+
+class DSparkDeepseekV4ForCausalLM(nn.Module):
+ # Draft weights ship in the target checkpoint (mtp.*) without embed/head, so
+ # load_dspark_model always aliases the target's.
+ has_own_embed_tokens = False
+ has_own_lm_head = False
+
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = "") -> None:
+ super().__init__()
+ assert vllm_config.speculative_config is not None
+ self.draft_model_config = vllm_config.speculative_config.draft_model_config
+ self.config = self.draft_model_config.hf_config
+ self.model = DSparkDeepseekV4Model(
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
+ )
+ # Shared with the target (aliased by the speculator's load utility).
+ self.lm_head = ParallelLMHead(
+ self.config.vocab_size,
+ self.config.hidden_size,
+ prefix=maybe_prefix(prefix, "lm_head"),
+ )
+ self.logits_processor = LogitsProcessor(self.config.vocab_size)
+
+ # --- Hooks used by the speculator -------------------------------------
+
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
+ return self.model.embed_input_ids(input_ids)
+
+ def combine_hidden_states(self, aux_hidden_states: torch.Tensor) -> torch.Tensor:
+ return self.model.combine_hidden_states(aux_hidden_states)
+
+ def get_draft_kv_cache_layer_names(self) -> list[str]:
+ # DSV4 MLA path: each draft layer's sliding-window cache is a separate
+ # layer, named by its prefix.
+ return [layer.attn.swa_cache_layer.prefix for layer in self.model.layers]
+
+ def precompute_and_store_context_kv(
+ self,
+ context_states: torch.Tensor,
+ context_positions: torch.Tensor,
+ context_slot_mappings: list[torch.Tensor | None] | None = None,
+ ) -> None:
+ self.model.precompute_and_store_context_kv(
+ context_states, context_positions, context_slot_mappings
+ )
+
+ def forward(
+ self,
+ input_ids: torch.Tensor,
+ positions: torch.Tensor,
+ inputs_embeds: torch.Tensor | None = None,
+ ) -> torch.Tensor:
+ # Returns the pre-norm hc_head hidden ([T, hidden_size]).
+ return self.model(input_ids, positions, inputs_embeds)
+
+ def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ """Base logits U_k = lm_head(norm(head_hidden))."""
+ return self.logits_processor(self.lm_head, self.model.norm(hidden_states))
+
+ def compute_draft_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ # Full-vocab draft: base logits, no d2t scatter.
+ return self.compute_logits(hidden_states)
+
+ def map_draft_to_target(self, draft_ids: torch.Tensor) -> torch.Tensor:
+ return draft_ids # full-vocab: draft ids are target ids
+
+ def markov_embed(self, token_ids: torch.Tensor) -> torch.Tensor:
+ return self.model.markov_head.embed(token_ids)
+
+ def markov_bias(self, markov_embed: torch.Tensor) -> torch.Tensor:
+ return self.model.markov_head.bias(markov_embed, self.logits_processor)
+
+ # --- Weight loading ----------------------------------------------------
+
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
+ """Load the ``mtp.{0,1,2}.*`` draft weights from the target checkpoint.
+
+ Non-mtp weights (embed/head/main layers) belong to the target model and
+ are skipped here. ``embed_tokens``/``lm_head`` are aliased from the target.
+ """
+ # AMD DeepseekV4MoE has no mega-MoE path; always use the standard
+ # per-expert fused-MoE mapping (mirrors amd/mtp.py).
+ expert_mapping = fused_moe_make_expert_params_mapping(
+ self,
+ ckpt_gate_proj_name="w1",
+ ckpt_down_proj_name="w2",
+ ckpt_up_proj_name="w3",
+ num_experts=self.config.n_routed_experts,
+ )
+ expert_scale_suffix = (
+ ".weight_scale"
+ if getattr(self.config, "expert_dtype", "fp4") == "fp4"
+ else ".weight_scale_inv"
+ )
+
+ # (param_name, ckpt_shard_name, shard_id) for non-expert stacked params.
+ stacked_params_mapping = [
+ ("gate_up_proj", "w1", 0),
+ ("gate_up_proj", "w3", 1),
+ ("attn.fused_wqa_wkv", "attn.wq_a", 0),
+ ("attn.fused_wqa_wkv", "attn.wkv", 1),
+ ]
+
+ params_dict = dict(self.named_parameters())
+ loaded_params: set[str] = set()
+
+ tp_size = get_tensor_model_parallel_world_size()
+ tp_rank = get_tensor_model_parallel_rank()
+ n_local_head = self.config.num_attention_heads // tp_size
+ head_start = n_local_head * tp_rank
+ head_end = n_local_head * (tp_rank + 1)
+
+ for name, loaded_weight in weights:
+ mapped = self._remap_dspark_name(name)
+ if mapped is None:
+ continue
+ name = mapped
+
+ # ``.scale`` -> per-method scale suffix.
+ if name.endswith(".scale"):
+ suffix = (
+ expert_scale_suffix
+ if _EXPERT_SCALE_RE.search(name)
+ else ".weight_scale_inv"
+ )
+ name = name.removesuffix(".scale") + suffix
+
+ # E8M0 expert scales: keep raw exponent bytes.
+ if ".experts." in name:
+ if (
+ "weight_scale" in name
+ and loaded_weight.dtype == torch.float8_e8m0fnu
+ ):
+ loaded_weight = loaded_weight.view(torch.uint8)
+ for param_name, weight_name, expert_id, shard_id in expert_mapping:
+ if weight_name not in name:
+ continue
+ name_mapped = name.replace(weight_name, param_name)
+ param = params_dict[name_mapped]
+ success = param.weight_loader(
+ param,
+ loaded_weight,
+ name_mapped,
+ shard_id=shard_id,
+ expert_id=expert_id,
+ return_success=True,
+ )
+ if success:
+ loaded_params.add(name_mapped)
+ break
+ continue
+
+ # Stacked rules only apply to decoder-layer weights. Head-stack params
+ # (main_proj/norm/hc_head/markov_head) load directly — otherwise e.g.
+ # "markov_w1" would collide with the "w1" shard rule.
+ is_layer_param = name.startswith("model.layers.")
+ for param_name, weight_name, stacked_shard_id in stacked_params_mapping:
+ if not is_layer_param or weight_name not in name:
+ continue
+ name = name.replace(weight_name, param_name)
+ param = params_dict[name]
+ param.weight_loader(param, loaded_weight, stacked_shard_id)
+ loaded_params.add(name)
+ break
+ else:
+ if "attn_sink" in name:
+ narrow = loaded_weight[head_start:head_end]
+ params_dict[name][: narrow.shape[0]].copy_(narrow)
+ loaded_params.add(name)
+ continue
+ if ".shared_experts.w2" in name:
+ name = name.replace(
+ ".shared_experts.w2", ".shared_experts.down_proj"
+ )
+ if name.endswith(".ffn.gate.bias"):
+ name = name.replace(
+ ".ffn.gate.bias", ".ffn.gate.e_score_correction_bias"
+ )
+ param = params_dict[name]
+ weight_loader = getattr(param, "weight_loader", default_weight_loader)
+ weight_loader(param, loaded_weight)
+ loaded_params.add(name)
+
+ logger.info_once("DSpark draft model loaded: %d params", len(loaded_params))
+ return loaded_params
+
+ def _remap_dspark_name(self, name: str) -> str | None:
+ """Map a checkpoint ``mtp.{i}.*`` name to this model's parameter path.
+
+ Returns None for non-mtp weights (owned by the target model).
+ """
+ m = re.match(r"mtp\.(\d+)\.(.*)", name)
+ if m is None:
+ return None
+ stage = int(m.group(1))
+ rest = m.group(2)
+ # The confidence head is not wired into inference yet; drop its weights.
+ if rest.startswith("confidence_head."):
+ return None
+ # Head-stack params live at model level (mtp.last), context combiner at
+ # model level (mtp.0); everything else is a per-layer decoder block.
+ head_prefixes = (
+ "norm.",
+ "hc_head_fn",
+ "hc_head_base",
+ "hc_head_scale",
+ "markov_head.",
+ )
+ if rest.startswith(("main_proj.", "main_norm.")) or rest.startswith(
+ head_prefixes
+ ):
+ return f"model.{rest}"
+ return f"model.layers.{stage}.{rest}"
diff --git a/vllm/models/deepseek_v4/amd/model.py b/vllm/models/deepseek_v4/amd/model.py
index 01646ad99438..9093bd412899 100644
--- a/vllm/models/deepseek_v4/amd/model.py
+++ b/vllm/models/deepseek_v4/amd/model.py
@@ -40,7 +40,11 @@
VocabParallelEmbedding,
)
from vllm.model_executor.model_loader.weight_utils import default_weight_loader
-from vllm.model_executor.models.interfaces import SupportsPP
+from vllm.model_executor.models.interfaces import (
+ EagleModelMixin,
+ SupportsEagle3,
+ SupportsPP,
+)
from vllm.model_executor.models.utils import (
AutoWeightsLoader,
PPMissingLayer,
@@ -437,7 +441,7 @@ def forward(
)
-class DeepseekV4Model(nn.Module):
+class DeepseekV4Model(nn.Module, EagleModelMixin):
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
@@ -573,7 +577,19 @@ def forward(
hidden_states = intermediate_tensors["hidden_states"]
residual, post_mix, res_mix = None, None, None
- for layer in islice(self.layers, self.start_layer, self.end_layer):
+ # EAGLE3 / DSpark / DFlash aux hidden states: reconstructed (post-mhc)
+ # hidden state at the configured target layers, averaged over the
+ # hc_mult streams to [T, hidden_size]. Empty unless a draft model set
+ # aux_hidden_state_layers.
+ aux_hidden_states: list[torch.Tensor] = []
+ # On the fused path the final layer's hc_post output is reused below
+ # (avoids computing hc_post twice when the last layer is also an aux
+ # layer).
+ final_aux_recon: torch.Tensor | None = None
+ for idx, layer in enumerate(
+ islice(self.layers, self.start_layer, self.end_layer),
+ start=self.start_layer,
+ ):
hidden_states, residual, post_mix, res_mix = layer(
hidden_states,
positions,
@@ -582,8 +598,30 @@ def forward(
res_mix,
residual,
)
+ if (idx + 1) in self.aux_hidden_state_layers:
+ # On the unfused (aiter) path the layer already applied hc_post,
+ # so hidden_states is the reconstructed stream; on the fused
+ # path reconstruct it via hc_post before averaging.
+ if layer.use_fused_mhc:
+ aux_recon = layer.hc_post(
+ hidden_states, residual, post_mix, res_mix
+ )
+ final_aux_recon = aux_recon
+ else:
+ aux_recon = hidden_states
+ aux_hidden_states.append(aux_recon.mean(dim=1))
if layer is not None and layer.use_fused_mhc:
- hidden_states = layer.hc_post(hidden_states, residual, post_mix, res_mix)
+ # Reuse the last layer's hc_post output if it was already computed
+ # for the aux hidden state above; otherwise compute it now.
+ if (
+ final_aux_recon is not None
+ and self.end_layer in self.aux_hidden_state_layers
+ ):
+ hidden_states = final_aux_recon
+ else:
+ hidden_states = layer.hc_post(
+ hidden_states, residual, post_mix, res_mix
+ )
if not get_pp_group().is_last_rank:
return IntermediateTensors({"hidden_states": hidden_states})
@@ -601,6 +639,8 @@ def forward(
self.hc_eps,
)
hidden_states = self.norm(hidden_states)
+ if len(aux_hidden_states) > 0:
+ return hidden_states, aux_hidden_states
return hidden_states
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
@@ -751,7 +791,7 @@ def _make_deepseek_v4_weights_mapper(expert_dtype: str) -> WeightsMapper:
)
-class DeepseekV4ForCausalLM(nn.Module, SupportsPP):
+class DeepseekV4ForCausalLM(nn.Module, SupportsPP, SupportsEagle3):
model_cls = DeepseekV4Model
# Default mapper assumes the original FP4-expert checkpoint layout.
diff --git a/vllm/models/deepseek_v4/amd/rocm.py b/vllm/models/deepseek_v4/amd/rocm.py
index b3456ba785c7..d4aef6ec9ea6 100644
--- a/vllm/models/deepseek_v4/amd/rocm.py
+++ b/vllm/models/deepseek_v4/amd/rocm.py
@@ -371,8 +371,12 @@ class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuild
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
max_tokens = self.vllm_config.scheduler_config.max_num_batched_tokens
+ # The non-causal (DSpark draft) path widens each token's SWA index list
+ # to ``noncausal_index_width`` (>= window_size), so size the persistent
+ # ragged buffer to the wider bound to cover both causal and non-causal.
+ swa_index_width = max(self.window_size, self.noncausal_index_width)
self.decode_swa_ragged_indices_buffer = torch.empty(
- max_tokens * self.window_size,
+ max_tokens * swa_index_width,
dtype=torch.int32,
device=self.device,
)
@@ -411,7 +415,9 @@ def build(
self.decode_swa_ragged_indices_buffer,
self.decode_swa_ragged_indptr_buffer,
base.num_decode_tokens,
- self.window_size,
+ # Actual dense width for this build: window_size (causal) or
+ # noncausal_index_width (DSpark non-causal draft).
+ base.decode_swa_indices.shape[-1],
)
return DeepseekV4ROCMAiterSparseSWAMetadata(
From 735def4fcf39945b6e6c24769878760e3e113b15 Mon Sep 17 00:00:00 2001
From: Matthew Bonanni
Date: Fri, 10 Jul 2026 15:24:52 -0400
Subject: [PATCH 0028/1526] [Bugfix] Fix FlashMLA dense fp8 metadata crash
(num_sm_parts clamp) (#48045)
Signed-off-by: Matthew Bonanni
Co-authored-by: Claude
---
cmake/external_projects/flashmla.cmake | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cmake/external_projects/flashmla.cmake b/cmake/external_projects/flashmla.cmake
index 65986df55012..50ddc667a844 100644
--- a/cmake/external_projects/flashmla.cmake
+++ b/cmake/external_projects/flashmla.cmake
@@ -19,7 +19,7 @@ else()
FetchContent_Declare(
flashmla
GIT_REPOSITORY https://github.com/vllm-project/FlashMLA
- GIT_TAG a6ec2ba7bd0a7dff98b3f4d3e6b52b159c48d78b
+ GIT_TAG b70aff3d110a2b1a037e62eac295166b5143643a
GIT_PROGRESS TRUE
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
From f378f79b7c34d7ca94d53db34837929da2db03ed Mon Sep 17 00:00:00 2001
From: gnovack
Date: Fri, 10 Jul 2026 13:33:28 -0700
Subject: [PATCH 0029/1526] handle topk_ids padding in align sum kernel
(#47785)
Signed-off-by: gnovack
---
.../moe/moe_align_sum_kernels.cu | 95 +++++++++----------
.../kernels/moe/test_moe_align_block_size.py | 22 +++--
2 files changed, 63 insertions(+), 54 deletions(-)
diff --git a/csrc/libtorch_stable/moe/moe_align_sum_kernels.cu b/csrc/libtorch_stable/moe/moe_align_sum_kernels.cu
index 1fa2c0d18e77..152bc116edb6 100644
--- a/csrc/libtorch_stable/moe/moe_align_sum_kernels.cu
+++ b/csrc/libtorch_stable/moe/moe_align_sum_kernels.cu
@@ -82,6 +82,21 @@ __global__ void batched_moe_align_block_size_kernel(
}
} // namespace batched_moe_align_block_size
+template
+__device__ __forceinline__ int get_local_expert_id(
+ size_t idx, const scalar_t* __restrict__ topk_ids,
+ int32_t* __restrict__ expert_map, int32_t num_experts,
+ bool has_expert_map) {
+ int expert_id = topk_ids[idx];
+ if (expert_id >= num_experts || expert_id < 0) {
+ return -1;
+ }
+ if (has_expert_map) {
+ expert_id = expert_map[expert_id];
+ }
+ return expert_id;
+}
+
template
__device__ void _moe_align_block_size(
const scalar_t* __restrict__ topk_ids,
@@ -126,20 +141,15 @@ __device__ void _moe_align_block_size(
const size_t stride = blockDim.x;
for (size_t i = tid; i < numel; i += stride) {
- int expert_id = topk_ids[i];
- if (expert_id >= num_experts) {
- continue;
+ if (int expert_id = get_local_expert_id(i, topk_ids, expert_map,
+ num_experts, has_expert_map);
+ expert_id != -1) {
+ int warp_idx = expert_id / experts_per_warp;
+ int expert_offset = expert_id % experts_per_warp;
+ int mask = token_mask == nullptr ? 1 : token_mask[i / topk_num];
+ atomicAdd(&shared_counts[warp_idx * experts_per_warp + expert_offset],
+ mask);
}
- if (has_expert_map) {
- expert_id = expert_map[expert_id];
- // filter invalid experts
- if (expert_id == -1) continue;
- }
- int warp_idx = expert_id / experts_per_warp;
- int expert_offset = expert_id % experts_per_warp;
- int mask = token_mask == nullptr ? 1 : token_mask[i / topk_num];
- atomicAdd(&shared_counts[warp_idx * experts_per_warp + expert_offset],
- mask);
}
__syncthreads();
@@ -227,14 +237,12 @@ __device__ void _moe_align_block_size_small_batch_expert(
}
for (size_t i = tid; i < numel; i += stride) {
- int32_t expert_id = topk_ids[i];
- if (has_expert_map) {
- expert_id = expert_map[expert_id];
- // filter invalid expert
- if (expert_id == -1) continue;
+ if (int expert_id = get_local_expert_id(i, topk_ids, expert_map,
+ num_experts, has_expert_map);
+ expert_id != -1) {
+ int mask = token_mask == nullptr ? 1 : token_mask[i / topk_num];
+ tokens_cnts[(tid + 1) * num_experts + expert_id] += mask;
}
- int mask = token_mask == nullptr ? 1 : token_mask[i / topk_num];
- tokens_cnts[(tid + 1) * num_experts + expert_id] += mask;
}
__syncthreads();
@@ -276,18 +284,16 @@ __device__ void _moe_align_block_size_small_batch_expert(
}
for (size_t i = tid; i < numel; i += stride) {
- int32_t expert_id = topk_ids[i];
- if (has_expert_map) {
- expert_id = expert_map[expert_id];
- // filter invalid expert
- if (expert_id == -1) continue;
- }
- int32_t rank_post_pad =
- tokens_cnts[tid * num_experts + expert_id] + cumsum[expert_id];
-
- if (token_mask == nullptr || token_mask[i / topk_num]) {
- sorted_token_ids[sorted_token_ids_offset + rank_post_pad] = i;
- ++tokens_cnts[tid * num_experts + expert_id];
+ if (int expert_id = get_local_expert_id(i, topk_ids, expert_map,
+ num_experts, has_expert_map);
+ expert_id != -1) {
+ int32_t rank_post_pad =
+ tokens_cnts[tid * num_experts + expert_id] + cumsum[expert_id];
+
+ if (token_mask == nullptr || token_mask[i / topk_num]) {
+ sorted_token_ids[sorted_token_ids_offset + rank_post_pad] = i;
+ ++tokens_cnts[tid * num_experts + expert_id];
+ }
}
}
}
@@ -303,22 +309,15 @@ __device__ void _count_and_sort_expert_tokens(
const size_t stride = blockDim.x * gridDim.y;
for (size_t i = tid; i < numel; i += stride) {
- int32_t expert_id = topk_ids[i];
- if (expert_id >= num_experts) {
- continue;
- }
-
- if (has_expert_map) {
- expert_id = expert_map[expert_id];
- // filter invalid experts
- if (expert_id == -1) continue;
- }
-
- if (token_mask == nullptr || token_mask[i / topk_num]) {
- int32_t rank_post_pad = atomicAdd(
- &cumsum_buffer[(model_offset * (num_experts + 1)) + expert_id], 1);
- sorted_token_ids[max_num_tokens_padded * model_offset + rank_post_pad] =
- i;
+ if (int expert_id = get_local_expert_id(i, topk_ids, expert_map,
+ num_experts, has_expert_map);
+ expert_id != -1) {
+ if (token_mask == nullptr || token_mask[i / topk_num]) {
+ int32_t rank_post_pad = atomicAdd(
+ &cumsum_buffer[(model_offset * (num_experts + 1)) + expert_id], 1);
+ sorted_token_ids[max_num_tokens_padded * model_offset + rank_post_pad] =
+ i;
+ }
}
}
}
diff --git a/tests/kernels/moe/test_moe_align_block_size.py b/tests/kernels/moe/test_moe_align_block_size.py
index 9096d0ab8569..a017fa07bf90 100644
--- a/tests/kernels/moe/test_moe_align_block_size.py
+++ b/tests/kernels/moe/test_moe_align_block_size.py
@@ -246,20 +246,30 @@ def test_moe_align_block_size(
@pytest.mark.parametrize("topk", [2, 4])
@pytest.mark.parametrize("num_experts", [8, 64])
@pytest.mark.parametrize("block_size", [64])
+@pytest.mark.parametrize("mask_inactive_experts", [False, True])
def test_moe_align_block_size_with_expert_map(
- m: int, topk: int, num_experts: int, block_size: int
+ m: int,
+ topk: int,
+ num_experts: int,
+ block_size: int,
+ mask_inactive_experts: bool,
):
"""Test moe_align_block_size with expert mapping (EP scenario)"""
- topk_ids = torch.zeros((m, topk), device="cuda", dtype=torch.int32)
- for i in range(m):
- experts = torch.randperm(num_experts, device="cuda")[:topk]
- topk_ids[i] = experts
-
expert_map = torch.full((num_experts,), -1, device="cuda", dtype=torch.int32)
local_experts = list(range(0, num_experts, 2))
for i, expert_id in enumerate(local_experts):
expert_map[expert_id] = i
+ topk_ids = torch.empty((m, topk), device="cuda", dtype=torch.int32)
+ for i in range(m):
+ experts = torch.randperm(num_experts, device="cuda")[:topk]
+ for k in range(topk):
+ topk_ids[i, k] = (
+ experts[k]
+ if (experts[k] in local_experts) or not mask_inactive_experts
+ else -1
+ )
+
actual_sorted_ids, actual_expert_ids, actual_num_tokens = moe_align_block_size(
topk_ids=topk_ids,
block_size=block_size,
From 26ff616bbf43c5c5ecb847589705cebdbff46706 Mon Sep 17 00:00:00 2001
From: Nick Hill
Date: Fri, 10 Jul 2026 22:01:23 +0100
Subject: [PATCH 0030/1526] [Bugfix][Test] Register Qwen/Qwen3.5-4B example
model (#48276)
Signed-off-by: Nick Hill
Co-authored-by: Claude Opus 4.8 (1M context)
---
tests/models/registry.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/models/registry.py b/tests/models/registry.py
index ec9ef35436fe..41850ad8fbbb 100644
--- a/tests/models/registry.py
+++ b/tests/models/registry.py
@@ -1308,6 +1308,7 @@ def check_available_online(
),
"Qwen3_5ForConditionalGeneration": _HfExamplesInfo(
"Qwen/Qwen3.5-0.8B",
+ extras={"4b": "Qwen/Qwen3.5-4B"},
max_model_len=4096,
),
"Qwen3_5MoeForConditionalGeneration": _HfExamplesInfo(
From ed908cf0a08af20839b82dc0638dae91b1ad630b Mon Sep 17 00:00:00 2001
From: Ashwin Giridharan
Date: Fri, 10 Jul 2026 15:47:51 -0700
Subject: [PATCH 0031/1526] [Bugfix] Fix thinking_token_budget not enforced
after natural re-entry (#45984)
Signed-off-by: Ashwin Giridharan
---
.../v1/logits_processors/test_correctness.py | 410 +++++++++++++++++-
vllm/v1/sample/thinking_budget_state.py | 89 ++--
vllm/v1/worker/gpu_input_batch.py | 1 +
3 files changed, 453 insertions(+), 47 deletions(-)
diff --git a/tests/v1/logits_processors/test_correctness.py b/tests/v1/logits_processors/test_correctness.py
index 17dc624fe42c..a51157557e24 100644
--- a/tests/v1/logits_processors/test_correctness.py
+++ b/tests/v1/logits_processors/test_correctness.py
@@ -145,6 +145,7 @@ def _generate_fake_sampling_metadata(
vllm_config.scheduler_config.max_num_seqs,
num_spec,
device,
+ is_pin_memory=False,
)
fake_sampling_metadata = SamplingMetadata(
temperature=torch.full((batch_size,), 0.0),
@@ -879,6 +880,7 @@ def test_maybe_create_thinking_budget_holder_without_reasoning():
cfg.scheduler_config.max_num_seqs,
0,
torch.device("cpu"),
+ is_pin_memory=False,
)
is None
)
@@ -1251,12 +1253,20 @@ def test_thinking_budget_long_thinking_section_end_marker_found_at_correct_index
out.append(tok)
h.update_state([out], None, None)
assert h._state[0]["end_thinking"] == -1 # not present yet
- expected_end_idx = len(out) # marker appended next
out.extend(end)
h.update_state([out], None, None)
- assert h._state[0]["start_thinking"] == 0
- assert h._state[0]["end_thinking"] == expected_end_idx
+ # After a natural exit, start_thinking and end_thinking are reset to -1
+ # (state machine prepared for next block). Verify the exit happened at the
+ # correct position by checking scan_offset (set to len(output) after exit).
+ assert not h._state[0]["in_think"], "Should have exited think mode after "
+ assert h._state[0]["start_thinking"] == -1, (
+ "start_thinking should be reset after natural exit"
+ )
+ assert h._state[0]["scan_offset"] == len(out), (
+ f"scan_offset should point past the end marker; "
+ f"expected {len(out)}, got {h._state[0]['scan_offset']}"
+ )
# --- Thinking budget re-entry tests (issue #43708) ---
@@ -1381,3 +1391,397 @@ def test_single_block_not_broken(self):
assert not holder._state[0]["in_end"]
assert not holder._state[0]["in_think"]
+
+
+# --- Thinking budget natural-end re-entry tests (issue #45974) ---
+# After a model naturally emits before exhausting its budget,
+# subsequent blocks must still be tracked and budget-enforced.
+
+
+class TestThinkingBudgetNaturalEndReentry:
+ """Tests for thinking budget enforcement across multiple think blocks.
+
+ Covers both natural-end re-entry (issue #45974) and forced-end re-entry
+ (issue #43708).
+ """
+
+ THINK_START = 100
+ THINK_END_SINGLE = [200]
+ THINK_END_MULTI = [200, 201, 202]
+ BUDGET = 10
+ CONTENT_TOKEN = 50
+ THINK_TOKEN = 60
+
+ @staticmethod
+ def _make_holder(end_token_ids):
+ from dataclasses import dataclass
+
+ from vllm.v1.sample.thinking_budget_state import (
+ ThinkingBudgetStateHolder,
+ )
+
+ @dataclass
+ class FakeReasoningConfig:
+ reasoning_start_token_ids: list[int]
+ reasoning_end_token_ids: list[int]
+ enabled: bool = True
+
+ cfg = FakeReasoningConfig(
+ reasoning_start_token_ids=[TestThinkingBudgetNaturalEndReentry.THINK_START],
+ reasoning_end_token_ids=end_token_ids,
+ )
+ return ThinkingBudgetStateHolder(
+ reasoning_config=cfg,
+ max_num_seqs=8,
+ num_spec_tokens=0,
+ device=torch.device("cpu"),
+ is_pin_memory=False,
+ )
+
+ @staticmethod
+ def _sync_batch(holder, budget):
+ from unittest.mock import MagicMock
+
+ params = MagicMock()
+ params.thinking_token_budget = budget
+ batch_update = MagicMock(
+ removed=[],
+ added=[(0, params, None, [])],
+ moved=[],
+ )
+ holder.sync_batch(batch_update)
+
+ @staticmethod
+ def _step(holder, output_tok_ids):
+ holder.update_state(
+ output_token_ids=[output_tok_ids],
+ spec_token_ids=None,
+ repeat_indices=None,
+ )
+
+ # --- Natural-end re-entry tests ---
+
+ def test_natural_end_reentry_single_token(self):
+ """After natural , a new block must be enforced."""
+ holder = self._make_holder(self.THINK_END_SINGLE)
+ self._sync_batch(holder, self.BUDGET)
+
+ output = []
+
+ # Block 1: 6 tokens (under budget) + natural
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(6):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+ output.append(self.THINK_END_SINGLE[0])
+ self._step(holder, list(output))
+
+ assert not holder._state[0]["in_end"]
+ assert not holder._state[0]["in_think"]
+
+ # Some content
+ for _ in range(3):
+ output.append(self.CONTENT_TOKEN)
+ self._step(holder, list(output))
+
+ # Block 2: re-entry — should be budget-tracked
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(self.BUDGET + 1):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+
+ assert holder._state[0]["in_end"], (
+ "Second thinking block after natural end must be budget-enforced"
+ )
+
+ def test_natural_end_reentry_multi_token(self):
+ """Multi-token natural end still allows Block 2 enforcement."""
+ holder = self._make_holder(self.THINK_END_MULTI)
+ self._sync_batch(holder, self.BUDGET)
+
+ output = []
+
+ # Block 1: 4 tokens + natural multi-token
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(4):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+ for tok in self.THINK_END_MULTI:
+ output.append(tok)
+ self._step(holder, list(output))
+
+ assert not holder._state[0]["in_think"]
+
+ # Content
+ for _ in range(5):
+ output.append(self.CONTENT_TOKEN)
+ self._step(holder, list(output))
+
+ # Block 2: re-entry
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(self.BUDGET + 1):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+
+ assert holder._state[0]["in_end"], (
+ "Second block after multi-token natural end must be enforced"
+ )
+
+ def test_natural_end_immediate_reentry(self):
+ """Natural followed immediately by with no content."""
+ holder = self._make_holder(self.THINK_END_SINGLE)
+ self._sync_batch(holder, self.BUDGET)
+
+ output = []
+
+ # Block 1: 5 tokens + natural end
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(5):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+ output.append(self.THINK_END_SINGLE[0])
+ self._step(holder, list(output))
+
+ # Immediate re-entry — NO content between blocks
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(self.BUDGET + 1):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+
+ assert holder._state[0]["in_end"], (
+ "Immediate re-entry after natural end must be budget-enforced"
+ )
+
+ def test_multiple_natural_reentries(self):
+ """Block 1 natural -> Block 2 natural -> Block 3 must be enforced."""
+ holder = self._make_holder(self.THINK_END_SINGLE)
+ self._sync_batch(holder, self.BUDGET)
+
+ output = []
+
+ # Block 1: 4 tokens + natural end
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(4):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+ output.append(self.THINK_END_SINGLE[0])
+ self._step(holder, list(output))
+
+ # Content
+ for _ in range(2):
+ output.append(self.CONTENT_TOKEN)
+ self._step(holder, list(output))
+
+ # Block 2: 3 tokens + natural end
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(3):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+ output.append(self.THINK_END_SINGLE[0])
+ self._step(holder, list(output))
+
+ # Content
+ for _ in range(2):
+ output.append(self.CONTENT_TOKEN)
+ self._step(holder, list(output))
+
+ # Block 3: exceed budget
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(self.BUDGET + 1):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+
+ assert holder._state[0]["in_end"], (
+ "Third block after two natural ends must be budget-enforced"
+ )
+
+ def test_natural_then_forced_in_block_2(self):
+ """Block 1 ends naturally, Block 2 exceeds budget -> forced end."""
+ budget = 5
+ holder = self._make_holder(self.THINK_END_SINGLE)
+ self._sync_batch(holder, budget)
+
+ output = []
+
+ # Block 1: 3 tokens + natural end (under budget)
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(3):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+ output.append(self.THINK_END_SINGLE[0])
+ self._step(holder, list(output))
+
+ assert not holder._state[0]["in_end"]
+
+ # Content
+ for _ in range(3):
+ output.append(self.CONTENT_TOKEN)
+ self._step(holder, list(output))
+
+ # Block 2: exceed budget
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(budget + 1):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+
+ assert holder._state[0]["in_end"], (
+ "Block 2 must trigger forced end after natural end in Block 1"
+ )
+
+ def test_budget_one_natural_end_reentry(self):
+ """Budget = 1: natural end with 0 think tokens, re-entry enforced."""
+ budget = 1
+ holder = self._make_holder(self.THINK_END_SINGLE)
+ self._sync_batch(holder, budget)
+
+ output = []
+
+ # Block 1: immediate natural end (0 think tokens)
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ output.append(self.THINK_END_SINGLE[0])
+ self._step(holder, list(output))
+
+ assert not holder._state[0]["in_end"]
+
+ # Content
+ for _ in range(3):
+ output.append(self.CONTENT_TOKEN)
+ self._step(holder, list(output))
+
+ # Block 2: 2 think tokens (should be enforced at budget=1)
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+
+ assert holder._state[0]["in_end"], (
+ "Budget=1 must enforce after 1 token in re-entry block"
+ )
+
+ def test_partial_end_sequence_in_content(self):
+ """Partial end sequence in content shouldn't trigger false exit."""
+ holder = self._make_holder(self.THINK_END_MULTI)
+ self._sync_batch(holder, self.BUDGET)
+
+ output = []
+
+ # Block 1: natural end with full sequence [200, 201, 202]
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(3):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+ for tok in self.THINK_END_MULTI:
+ output.append(tok)
+ self._step(holder, list(output))
+
+ # Content with partial end sequence [200, 201] but not [200, 201, 202]
+ output.append(self.CONTENT_TOKEN)
+ self._step(holder, list(output))
+ output.append(200)
+ self._step(holder, list(output))
+ output.append(201)
+ self._step(holder, list(output))
+ output.append(self.CONTENT_TOKEN)
+ self._step(holder, list(output))
+
+ # Block 2: re-entry must still be enforced
+ output.append(self.THINK_START)
+ self._step(holder, list(output))
+ for _ in range(self.BUDGET + 1):
+ output.append(self.THINK_TOKEN)
+ self._step(holder, list(output))
+
+ assert holder._state[0]["in_end"], (
+ "Partial end tokens in content must not prevent Block 2 enforcement"
+ )
+
+ def test_continue_thinking_natural_end_reentry(self):
+ """Prompt-prefill inside : after natural end, Block 2 must be enforced.
+
+ When a request arrives with the prompt already inside a block,
+ continue_thinking is set to True. This test verifies that after a
+ natural exit, continue_thinking is cleared and Block 2
+ enforcement works correctly.
+ """
+ from dataclasses import dataclass
+ from unittest.mock import MagicMock
+
+ from vllm.v1.sample.thinking_budget_state import ThinkingBudgetStateHolder
+
+ @dataclass
+ class FakeReasoningConfig:
+ reasoning_start_token_ids: list[int]
+ reasoning_end_token_ids: list[int]
+ enabled: bool = True
+
+ cfg = FakeReasoningConfig(
+ reasoning_start_token_ids=[self.THINK_START],
+ reasoning_end_token_ids=self.THINK_END_SINGLE,
+ )
+ holder = ThinkingBudgetStateHolder(
+ reasoning_config=cfg,
+ max_num_seqs=8,
+ num_spec_tokens=0,
+ device=torch.device("cpu"),
+ is_pin_memory=False,
+ )
+
+ # Simulate: prompt already inside (sets continue_thinking=True)
+ prompt_tok_ids = [self.THINK_START]
+ params = MagicMock()
+ params.thinking_token_budget = self.BUDGET
+ batch_update = MagicMock(
+ removed=[],
+ added=[(0, params, prompt_tok_ids, [])],
+ moved=[],
+ )
+ holder.sync_batch(batch_update)
+ assert holder._state[0]["continue_thinking"], (
+ "Prompt inside must set continue_thinking=True"
+ )
+
+ # Block 1: emit some think tokens then natural
+ output = list(prompt_tok_ids)
+ for _ in range(4):
+ output.append(self.THINK_TOKEN)
+ holder.update_state([output], None, None)
+ output.append(self.THINK_END_SINGLE[0])
+ holder.update_state([output], None, None)
+
+ assert not holder._state[0]["in_think"], "Should have exited think mode"
+ assert not holder._state[0]["continue_thinking"], (
+ "continue_thinking must be cleared after natural end"
+ )
+
+ # Content tokens
+ for _ in range(3):
+ output.append(self.CONTENT_TOKEN)
+ holder.update_state([output], None, None)
+
+ # Block 2: re-entry must be detected and budget enforced
+ output.append(self.THINK_START)
+ holder.update_state([output], None, None)
+ for _ in range(self.BUDGET + 1):
+ output.append(self.THINK_TOKEN)
+ holder.update_state([output], None, None)
+
+ assert holder._state[0]["in_end"], (
+ "Block 2 after natural end of prompt-prefill session must be enforced"
+ )
+
+ # --- Forced-end re-entry tests ---
diff --git a/vllm/v1/sample/thinking_budget_state.py b/vllm/v1/sample/thinking_budget_state.py
index 95c3406b02c4..f19f612f4898 100644
--- a/vllm/v1/sample/thinking_budget_state.py
+++ b/vllm/v1/sample/thinking_budget_state.py
@@ -7,7 +7,7 @@
import torch
from vllm.platforms import current_platform
-from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d
+from vllm.utils.torch_utils import async_tensor_h2d
from vllm.v1.sample.logits_processor.interface import (
BatchUpdate,
MoveDirectionality,
@@ -22,11 +22,12 @@ def maybe_create_thinking_budget_state_holder(
max_num_seqs: int,
num_spec_tokens: int,
device: torch.device,
+ is_pin_memory: bool,
) -> "ThinkingBudgetStateHolder | None":
if reasoning_config is None:
return None
return ThinkingBudgetStateHolder(
- reasoning_config, max_num_seqs, num_spec_tokens, device, PIN_MEMORY
+ reasoning_config, max_num_seqs, num_spec_tokens, device, is_pin_memory
)
@@ -173,19 +174,6 @@ def _find_last_sequence_index(target_list: list[int], token_ids: list[int]) -> i
return i
return -1
- @staticmethod
- def _find_last_sequence_index_from(
- target_list: list[int], token_ids: list[int], search_start: int
- ) -> int:
- """Last occurrence of ``token_ids`` at or after ``search_start``."""
- if not token_ids:
- return -1
- lo = max(0, search_start)
- for i in range(len(target_list) - len(token_ids), lo - 1, -1):
- if target_list[i : i + len(token_ids)] == token_ids:
- return i
- return -1
-
def _init_state_entry(
self, prompt_tok_ids: list[int] | None, thinking_token_budget: int
) -> dict[str, Any]:
@@ -239,8 +227,6 @@ def _init_state_entry(
"force_index": [],
"start_thinking": start_thinking,
"end_thinking": -1,
- "start_search_pos": 0,
- "end_search_pos": 0,
"in_spec_mode": False,
"bonus_token_forced": False,
"continue_thinking": continue_thinking,
@@ -256,41 +242,40 @@ def _update_think_state(self, state: dict[str, Any]) -> None:
state["force_index"] = []
return
- output_tok_ids = state.get("output_tok_ids", [])
if state["start_thinking"] == -1:
- seq_len = len(self.think_start_token_ids)
scan_offset = state.get("scan_offset", 0)
- start_thinking = self._find_last_sequence_index_from(
- output_tok_ids,
- self.think_start_token_ids,
- max(scan_offset, state["start_search_pos"] - (seq_len - 1)),
+ output_slice = state.get("output_tok_ids", [])[scan_offset:]
+ start_thinking = self._find_last_sequence_index(
+ output_slice, self.think_start_token_ids
)
- if start_thinking >= 0 and scan_offset > 0:
- # Re-entry after a forced end: budget was already exhausted
- # in a prior block, so immediately force-close this one.
- # scan_offset > 0 is only set after forced-end completion
- # (never after natural end), so this won't block legitimate
- # re-entries where budget remains.
- state["start_thinking"] = start_thinking
- state["in_think"] = False
- state["in_end"] = True
- state["end_count"] = 0
- state["force_index"] = [0]
- return
+ if start_thinking >= 0:
+ start_thinking += scan_offset
state["start_thinking"] = start_thinking
- if start_thinking == -1:
- state["start_search_pos"] = len(output_tok_ids)
if state["end_thinking"] == -1:
- seq_len = len(self.think_end_token_ids)
scan_offset = state.get("scan_offset", 0)
- end_thinking = self._find_last_sequence_index_from(
- output_tok_ids,
- self.think_end_token_ids,
- max(scan_offset, state["end_search_pos"] - (seq_len - 1)),
+ output_slice = state.get("output_tok_ids", [])[scan_offset:]
+ end_thinking = self._find_last_sequence_index(
+ output_slice, self.think_end_token_ids
)
+ if end_thinking >= 0:
+ end_thinking += scan_offset
state["end_thinking"] = end_thinking
- if end_thinking == -1:
- state["end_search_pos"] = len(output_tok_ids)
+
+ if (
+ not state.get("in_end", False)
+ and state["start_thinking"] >= 0
+ and state["end_thinking"] >= 0
+ and state["end_thinking"] > state["start_thinking"]
+ and not state.get("continue_thinking", False)
+ ):
+ state["in_think"] = False
+ state["think_count"] = 0
+ state["continue_thinking"] = False
+ state["start_thinking"] = -1
+ state["end_thinking"] = -1
+ state["scan_offset"] = len(state.get("output_tok_ids", []))
+ state["check_count_down"] = state["thinking_token_budget"]
+ return
if state["start_thinking"] == -1:
return
@@ -314,10 +299,18 @@ def _update_think_state(self, state: dict[str, Any]) -> None:
predicted_countdown = current_step_countdown - len(state["spec_token_ids"]) - 1
# We only proceed further if we have counted down the thinking budget
# to 0 or less and when we are in the "in think" mode.
+ # Exception: when continue_thinking=True and a natural is
+ # detected (end_thinking != -1), fall through to handle the exit —
+ # even if the budget hasn't expired yet. For continue_thinking=False,
+ # the early natural-end detection block above already handles it.
+ natural_end_with_continue = (
+ state.get("continue_thinking", False) and state["end_thinking"] != -1
+ )
if (
not state.get("in_end", False)
and predicted_countdown >= 0
and state["start_thinking"] > -1
+ and not natural_end_with_continue
):
state["check_count_down"] = current_step_countdown
state["prev_output_length"] = len(state.get("output_tok_ids", []))
@@ -389,6 +382,10 @@ def _update_think_state(self, state: dict[str, Any]) -> None:
# Case: ......... - exiting think mode
state["in_think"] = False
state["think_count"] = 0
+ state["continue_thinking"] = False
+ state["start_thinking"] = -1
+ state["end_thinking"] = -1
+ state["scan_offset"] = len(state.get("output_tok_ids", []))
elif absolute_start_pos >= 0 and not state["continue_thinking"]:
# Found think start - entering think mode
@@ -400,6 +397,10 @@ def _update_think_state(self, state: dict[str, Any]) -> None:
# Found think end - exiting think mode
state["in_think"] = False
state["think_count"] = 0
+ state["continue_thinking"] = False
+ state["start_thinking"] = -1
+ state["end_thinking"] = -1
+ state["scan_offset"] = len(state.get("output_tok_ids", []))
elif state["in_think"]:
# Continue thinking mode, increment count by new tokens
diff --git a/vllm/v1/worker/gpu_input_batch.py b/vllm/v1/worker/gpu_input_batch.py
index bb4fb3fe453c..baa502ee8117 100644
--- a/vllm/v1/worker/gpu_input_batch.py
+++ b/vllm/v1/worker/gpu_input_batch.py
@@ -113,6 +113,7 @@ def __init__(
max_num_reqs,
num_spec_tokens,
device,
+ PIN_MEMORY,
)
self.thinking_token_budget_reqs: set[str] = set()
self.is_pooling_model = is_pooling_model
From 29fd6888922139e33980b81ec0c50477233eea00 Mon Sep 17 00:00:00 2001
From: Michael Goin
Date: Fri, 10 Jul 2026 21:13:08 -0400
Subject: [PATCH 0032/1526] Add VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS and skip
CuTeDSL fp4_gemm autotuning by default (#48268)
Signed-off-by: mgoin
Signed-off-by: Michael Goin
Co-authored-by: Claude Fable 5
---
vllm/envs.py | 14 +++++++++
vllm/model_executor/warmup/kernel_warmup.py | 34 +++++++++++++++++++--
2 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/vllm/envs.py b/vllm/envs.py
index 9305bb9cbe7d..33b9f2f14fd8 100755
--- a/vllm/envs.py
+++ b/vllm/envs.py
@@ -197,6 +197,7 @@
VLLM_BLOCKSCALE_FP8_GEMM_FLASHINFER: bool = True
VLLM_USE_FLASHINFER_MOE_INT4: bool = False
VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR: str | None = None
+ VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS: list[str] | None = None
VLLM_FLASHINFER_ALLREDUCE_BACKEND: Literal["auto", "trtllm", "mnnvl"] = "auto"
VLLM_FLASHINFER_WORKSPACE_BUFFER_SIZE: int = 394 * 1024 * 1024
VLLM_XGRAMMAR_CACHE_MB: int = 0
@@ -1594,6 +1595,18 @@ def _resolve_rust_frontend_path() -> str | None:
"VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR": lambda: os.getenv(
"VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR", None
),
+ # Comma-separated FlashInfer op names to exclude from autotuning, using
+ # the heuristic fallback tactic instead. Unset: skip "fp4_gemm" when the
+ # CuTe-DSL NVFP4 linear kernel is selected. Empty: skip nothing.
+ "VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS": lambda: (
+ None
+ if "VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS" not in os.environ
+ else [
+ v.strip()
+ for v in os.environ["VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS"].split(",")
+ if v.strip()
+ ]
+ ),
# Flashinfer fused allreduce backend.
"VLLM_FLASHINFER_ALLREDUCE_BACKEND": env_with_choices(
"VLLM_FLASHINFER_ALLREDUCE_BACKEND",
@@ -2110,6 +2123,7 @@ def compile_factors() -> dict[str, object]:
"VLLM_DEBUG_LOG_API_SERVER_RESPONSE",
"VLLM_TUNED_CONFIG_FOLDER",
"VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR",
+ "VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS",
"VLLM_ENGINE_ITERATION_TIMEOUT_S",
"VLLM_HTTP_TIMEOUT_KEEP_ALIVE",
"VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS",
diff --git a/vllm/model_executor/warmup/kernel_warmup.py b/vllm/model_executor/warmup/kernel_warmup.py
index b7f3c265704d..d96a2e594bbb 100644
--- a/vllm/model_executor/warmup/kernel_warmup.py
+++ b/vllm/model_executor/warmup/kernel_warmup.py
@@ -130,6 +130,25 @@ def _is_flashinfer_backend(backend):
cutedsl_warmup()
+def _flashinfer_autotune_skip_ops(runner: "GPUModelRunner") -> set[str] | None:
+ if envs.VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS is not None:
+ return set(envs.VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS) or None
+
+ from vllm.model_executor.kernels.linear import (
+ FlashInferCuteDslNvFp4LinearKernel,
+ )
+
+ for module in runner.get_model().modules():
+ for holder_name in ("quant_method", "scheme"):
+ kernel = getattr(getattr(module, holder_name, None), "kernel", None)
+ # CuTe-DSL mm_fp4 tuning JIT-compiles every tactic and its
+ # fallback is already the heuristic; all mm_fp4 backends share
+ # the "fp4_gemm" op name, so skip only when cute-dsl is selected.
+ if isinstance(kernel, FlashInferCuteDslNvFp4LinearKernel):
+ return {"fp4_gemm"}
+ return None
+
+
def flashinfer_autotune(runner: "GPUModelRunner") -> None:
"""
Autotune FlashInfer operations.
@@ -146,6 +165,15 @@ def flashinfer_autotune(runner: "GPUModelRunner") -> None:
import vllm.utils.flashinfer as fi_utils
from vllm.distributed.parallel_state import get_world_group
+ autotune_kwargs: dict = {}
+ skip_ops = _flashinfer_autotune_skip_ops(runner)
+ if skip_ops:
+ logger.info(
+ "Skipping FlashInfer autotuning for ops %s",
+ sorted(skip_ops),
+ )
+ autotune_kwargs["skip_ops"] = skip_ops
+
use_persistent_cache = True
# When distributed, tune on every rank so the collectives stay synchronized.
@@ -153,7 +181,7 @@ def flashinfer_autotune(runner: "GPUModelRunner") -> None:
use_persistent_cache = False
if not use_persistent_cache:
- with torch.inference_mode(), fi_utils.autotune():
+ with torch.inference_mode(), fi_utils.autotune(**autotune_kwargs):
runner._dummy_run(
num_tokens=runner.scheduler_config.max_num_batched_tokens,
skip_eplb=True,
@@ -181,7 +209,9 @@ def flashinfer_autotune(runner: "GPUModelRunner") -> None:
with torch.inference_mode():
if is_leader:
- with fi_utils.autotune(tune_mode=True, cache=str(cache_path)):
+ with fi_utils.autotune(
+ tune_mode=True, cache=str(cache_path), **autotune_kwargs
+ ):
runner._dummy_run(**dummy_run_kwargs)
else:
runner._dummy_run(**dummy_run_kwargs)
From 1bf3997eaeb41b1ef5cf25d87faa97b7f0be4788 Mon Sep 17 00:00:00 2001
From: Joe Rowell
Date: Sat, 11 Jul 2026 03:46:13 +0200
Subject: [PATCH 0033/1526] [Quantization] Bound peak memory when repacking FP4
MoE weights for Marlin (#47851)
Signed-off-by: Joe Rowell
Signed-off-by: mgoin
Co-authored-by: mgoin
---
.../quantization/utils/marlin_utils_fp4.py | 77 ++++++++-----------
1 file changed, 34 insertions(+), 43 deletions(-)
diff --git a/vllm/model_executor/layers/quantization/utils/marlin_utils_fp4.py b/vllm/model_executor/layers/quantization/utils/marlin_utils_fp4.py
index 35a335ac80bc..f7174ab8f1ff 100644
--- a/vllm/model_executor/layers/quantization/utils/marlin_utils_fp4.py
+++ b/vllm/model_executor/layers/quantization/utils/marlin_utils_fp4.py
@@ -306,6 +306,37 @@ def prepare_fp4_layer_for_marlin(
return
+def _repack_marlin_experts(
+ weight: torch.Tensor,
+ size_n: int,
+ size_k: int,
+ perm: torch.Tensor,
+ is_a_8bit: bool,
+) -> torch.Tensor:
+ """Repack each expert to marlin format into a preallocated output."""
+ num_experts = weight.shape[0]
+ out: torch.Tensor | None = None
+ for i in range(num_experts):
+ qweight = weight[i].view(torch.int32).T.contiguous()
+ marlin_qweight = ops.gptq_marlin_repack(
+ b_q_weight=qweight,
+ perm=perm,
+ size_k=size_k,
+ size_n=size_n,
+ num_bits=4,
+ is_a_8bit=is_a_8bit,
+ )
+ if out is None:
+ out = torch.empty(
+ (num_experts, *marlin_qweight.shape),
+ dtype=marlin_qweight.dtype,
+ device=marlin_qweight.device,
+ )
+ out[i] = marlin_qweight
+ assert out is not None
+ return out
+
+
def prepare_nvfp4_moe_layer_for_marlin(
layer: RoutedExperts,
w13: torch.Tensor,
@@ -371,7 +402,6 @@ def pad_w2(x: torch.Tensor, packing: int) -> torch.Tensor:
# WEIGHT
# Repack weights to marlin format
def repack_weight(weight: torch.Tensor, name: str) -> torch.Tensor:
- tensor_list = []
if "w13" in name:
size_n, size_k = N * num_shards, K
assert weight.shape == (E, size_n, size_k // 2)
@@ -383,20 +413,7 @@ def repack_weight(weight: torch.Tensor, name: str) -> torch.Tensor:
weight = pad_w2(weight, packing=2)
size_k = padded_N
- for i in range(E):
- qweight = weight[i].view(torch.int32).T.contiguous()
-
- marlin_qweight = ops.gptq_marlin_repack(
- b_q_weight=qweight,
- perm=perm,
- size_k=size_k,
- size_n=size_n,
- num_bits=4,
- is_a_8bit=is_a_8bit,
- )
- tensor_list.append(marlin_qweight)
-
- return torch.cat([x.unsqueeze(0) for x in tensor_list], 0)
+ return _repack_marlin_experts(weight, size_n, size_k, perm, is_a_8bit)
w13 = repack_weight(w13, "w13")
w2 = repack_weight(w2, "w2")
@@ -472,7 +489,6 @@ def prepare_moe_fp4_layer_for_marlin(
# Repack weights to marlin format
for name in ["w13_weight", "w2_weight"]:
weight = getattr(layer, name)
- tensor_list = []
if "w13" in name:
size_n, size_k = n * 2, k
else:
@@ -480,20 +496,7 @@ def prepare_moe_fp4_layer_for_marlin(
assert weight.shape == (e, size_n, size_k // 2)
- for i in range(e):
- qweight = weight[i].view(torch.int32).T.contiguous()
-
- marlin_qweight = ops.gptq_marlin_repack(
- b_q_weight=qweight,
- perm=perm,
- size_k=size_k,
- size_n=size_n,
- num_bits=4,
- is_a_8bit=is_a_8bit,
- )
- tensor_list.append(marlin_qweight)
-
- weight = torch.cat([x.unsqueeze(0) for x in tensor_list], 0)
+ weight = _repack_marlin_experts(weight, size_n, size_k, perm, is_a_8bit)
weight = torch.nn.Parameter(weight, requires_grad=False)
setattr(layer, name, weight)
@@ -615,7 +618,6 @@ def prepare_moe_mxfp4_layer_for_marlin(
# WEIGHT: Repack weights to marlin format
def repack_weight(weight: torch.Tensor, name: str) -> torch.Tensor:
- tensor_list = []
if "w13" in name:
size_n, size_k = n * 2, k
else:
@@ -623,18 +625,7 @@ def repack_weight(weight: torch.Tensor, name: str) -> torch.Tensor:
assert weight.shape == (e, size_n, size_k // 2)
- for i in range(e):
- qweight = weight[i].view(torch.int32).T.contiguous()
- marlin_qweight = ops.gptq_marlin_repack(
- b_q_weight=qweight,
- perm=perm,
- size_k=size_k,
- size_n=size_n,
- num_bits=4,
- is_a_8bit=is_a_8bit,
- )
- tensor_list.append(marlin_qweight)
- return torch.cat([x.unsqueeze(0) for x in tensor_list], 0)
+ return _repack_marlin_experts(weight, size_n, size_k, perm, is_a_8bit)
w13 = repack_weight(w13, "w13")
w2 = repack_weight(w2, "w2")
From 092387963c09ff3dde648e4ff620174d5d32c5d3 Mon Sep 17 00:00:00 2001
From: Jimmy Lee <58957694+thisisjimmyfb@users.noreply.github.com>
Date: Fri, 10 Jul 2026 19:05:35 -0700
Subject: [PATCH 0034/1526] [BugFix] weights processing peak memory reduction
for nvfp4 MoE layers (#46276)
Signed-off-by: Jimmy Lee
---
tests/kernels/moe/test_flashinfer_b12x_moe.py | 33 +++++++------------
.../quantization/utils/flashinfer_fp4_moe.py | 32 +++++++++++++-----
2 files changed, 36 insertions(+), 29 deletions(-)
diff --git a/tests/kernels/moe/test_flashinfer_b12x_moe.py b/tests/kernels/moe/test_flashinfer_b12x_moe.py
index b15cbcdd8129..d1859e04f684 100644
--- a/tests/kernels/moe/test_flashinfer_b12x_moe.py
+++ b/tests/kernels/moe/test_flashinfer_b12x_moe.py
@@ -41,6 +41,9 @@
from vllm.model_executor.layers.fused_moe.experts.flashinfer_b12x_moe import (
FlashInferB12xExperts,
)
+from vllm.model_executor.layers.quantization.utils.flashinfer_fp4_moe import (
+ reorder_w1w3_to_w3w1,
+)
from vllm.utils.torch_utils import set_random_seed
# Dimensions chosen to satisfy FP4 alignment requirements (k multiple of 256,
@@ -53,23 +56,6 @@
]
-def _reorder_gate_up_to_up_gate(
- w: torch.Tensor,
- w_s: torch.Tensor,
-) -> tuple[torch.Tensor, torch.Tensor]:
- """Swap gate and up-projection halves along dim=1 to [up, gate] order.
-
- The B12x kernel expects weights in [up (w3), gate (w1)] order while the
- BF16 reference uses [gate (w1), up (w3)]. This replicates the reordering
- done at model-load time by ``prepare_nvfp4_moe_layer_for_fi_or_cutlass``.
- """
- n = w.shape[1] // 2
- return (
- torch.cat([w[:, n:, :], w[:, :n, :]], dim=1),
- torch.cat([w_s[:, n:, :], w_s[:, :n, :]], dim=1),
- )
-
-
def _process_b12x_weights(
experts: FlashInferB12xExperts,
w1_scale: torch.Tensor,
@@ -142,9 +128,14 @@ def test_flashinfer_b12x_moe(
sf_vec_size = 16
# W1: reorder BF16 from [gate, up] → [up, gate], then quantise.
- w1_reordered = torch.cat(
- [w1_bf16[:, n:, :], w1_bf16[:, :n, :]], dim=1
- ) # shape (e, 2n, k), [up, gate]
+ # Note: in reorder_w1w3_to_w3w1, "w1" refers to the gate projection
+ # and "w3" refers to the up projection.
+ # A dummy scale is passed and discarded; real scales come from
+ # fp4_quantize after reordering.
+ w1_reordered, _ = reorder_w1w3_to_w3w1(
+ w1_bf16.clone(),
+ torch.ones((e, 2 * n, 1), device="cuda", dtype=torch.float32),
+ )
w1_flat = w1_reordered.reshape(e * 2 * n, k)
w1_q_flat, w1_sf_flat = fp4_quantize(
w1_flat,
@@ -190,6 +181,7 @@ def test_flashinfer_b12x_moe(
moe_config=moe_config,
quant_config=quant_config,
)
+
_process_b12x_weights(
experts,
w1_blockscale,
@@ -324,7 +316,6 @@ def test_flashinfer_b12x_moe_relu2(
use_monolithic=False,
),
experts,
- inplace=False,
)
score = torch.randn((m, e), device="cuda", dtype=dtype)
diff --git a/vllm/model_executor/layers/quantization/utils/flashinfer_fp4_moe.py b/vllm/model_executor/layers/quantization/utils/flashinfer_fp4_moe.py
index 23a7131a582a..2fd21c2dff8d 100644
--- a/vllm/model_executor/layers/quantization/utils/flashinfer_fp4_moe.py
+++ b/vllm/model_executor/layers/quantization/utils/flashinfer_fp4_moe.py
@@ -23,7 +23,6 @@
logger = init_logger(__name__)
-
__all__ = [
"reorder_w1w3_to_w3w1",
]
@@ -32,18 +31,35 @@
def reorder_w1w3_to_w3w1(
weight: torch.Tensor, scale: torch.Tensor, dim: int = -2
) -> tuple[torch.Tensor, torch.Tensor]:
- """Re-order the concatenated `[w1, w3]` tensors to `[w3, w1]`"""
+ """Re-order concatenated `[w1, w3]` tensors to `[w3, w1]` in-place.
+
+ `weight` and `scale` must be contiguous; they remain contiguous on return.
+ """
+ assert weight.is_contiguous(), "weight must be contiguous"
+ assert scale.is_contiguous(), "scale must be contiguous"
size = weight.size(dim)
assert size % 2 == 0, f"Expected even size in dim {dim}, got {size}"
half = size // 2
+ d = dim % weight.dim()
- w1, w3 = weight.split(half, dim=dim)
- s1, s3 = scale.split(half, dim=dim)
-
- return (
- torch.cat([w3, w1], dim=dim).contiguous(),
- torch.cat([s3, s1], dim=dim).contiguous(),
+ # 64 MB transient cap
+ bytes_per_row = max(
+ weight.numel() // size * weight.element_size(),
+ scale.numel() // size * scale.element_size(),
)
+ chunk = max(1, min(half, (64 << 20) // max(bytes_per_row, 1)))
+
+ fa, fb = [slice(None)] * weight.dim(), [slice(None)] * weight.dim()
+ for off in range(0, half, chunk):
+ end = min(off + chunk, half)
+ fa[d], fb[d] = slice(off, end), slice(half + off, half + end)
+ a, b = tuple(fa), tuple(fb)
+ for t in (weight, scale):
+ tmp = t[b].clone()
+ t[b] = t[a]
+ t[a] = tmp
+
+ return weight, scale
def interleave_linear_and_gate(
From 9c18e90f6c94b90ecdaa99b2230389ba40e0fc69 Mon Sep 17 00:00:00 2001
From: Lucas Wilkinson
Date: Fri, 10 Jul 2026 23:22:39 -0400
Subject: [PATCH 0035/1526] [BugFix] Fix packed HND KV cache reshape for
FlashAttention (#47314)
Signed-off-by: Lucas Wilkinson
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
vllm/v1/worker/gpu/attn_utils.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/vllm/v1/worker/gpu/attn_utils.py b/vllm/v1/worker/gpu/attn_utils.py
index 227c51bb473a..4f9e5e99a948 100644
--- a/vllm/v1/worker/gpu/attn_utils.py
+++ b/vllm/v1/worker/gpu/attn_utils.py
@@ -218,7 +218,7 @@ def _reshape_attention_kv_cache(
kv_cache = (
kv_raw_tensor.view(-1, block_stride)[:, offset : offset + page_bytes]
.view(dtype)
- .view(kv_cache_shape)
+ .view(permuted_kv_cache_shape)
)
elif kv_cache_spec.page_size_padded is not None:
# Use a strided view to skip the padding between physical pages.
From 04d553f390fd37e09ab111936ef1592881299957 Mon Sep 17 00:00:00 2001
From: Lucas Wilkinson
Date: Fri, 10 Jul 2026 23:24:59 -0400
Subject: [PATCH 0036/1526] [Misc] Use meta tensor for KV cache stride
calculation (#47316)
Signed-off-by: Lucas Wilkinson
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
vllm/v1/worker/gpu/attn_utils.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/vllm/v1/worker/gpu/attn_utils.py b/vllm/v1/worker/gpu/attn_utils.py
index 4f9e5e99a948..5c07860b3ba4 100644
--- a/vllm/v1/worker/gpu/attn_utils.py
+++ b/vllm/v1/worker/gpu/attn_utils.py
@@ -238,7 +238,7 @@ def _reshape_attention_kv_cache(
page_stride = kv_cache_spec.page_size_bytes // dtype_size
num_blocks_dim = inv_order[0]
- strides = list(torch.empty(permuted_kv_cache_shape).stride())
+ strides = list(torch.empty(permuted_kv_cache_shape, device="meta").stride())
strides[num_blocks_dim] = page_stride
kv_cache = torch.as_strided(
From 3d99b0499aff7ce3b91942b0385558dab4eecf76 Mon Sep 17 00:00:00 2001
From: Robert Shaw <114415538+robertgshaw2-redhat@users.noreply.github.com>
Date: Sat, 11 Jul 2026 00:07:00 -0400
Subject: [PATCH 0037/1526] [Logs] DP Supervisor Log Improvement (#48278)
Signed-off-by: Robert Shaw
Co-authored-by: Robert Shaw
---
.../entrypoints/openai/test_dp_supervisor.py | 80 ++++++++++++-------
vllm/entrypoints/openai/dp_supervisor.py | 58 +++++++++++---
2 files changed, 96 insertions(+), 42 deletions(-)
diff --git a/tests/entrypoints/openai/test_dp_supervisor.py b/tests/entrypoints/openai/test_dp_supervisor.py
index abe0cac890fd..576e7ef16df4 100644
--- a/tests/entrypoints/openai/test_dp_supervisor.py
+++ b/tests/entrypoints/openai/test_dp_supervisor.py
@@ -301,9 +301,20 @@ async def serve(self):
async def fake_shutdown_children(self):
return None
+ def fake_start_children(self):
+ return None
+
+ async def fake_monitor_children(self):
+ # Mark ready so the supervisor server is started, then block until
+ # shutdown (triggered when the failing server task exits).
+ self._is_ready = True
+ await self._shutdown_event.wait()
+
monkeypatch.setattr(dp_sup.asyncio, "get_running_loop", lambda: FakeLoop())
monkeypatch.setattr(dp_sup.uvicorn, "Server", FakeServer)
monkeypatch.setattr(DPSupervisor, "_shutdown_children", fake_shutdown_children)
+ monkeypatch.setattr(DPSupervisor, "_start_children", fake_start_children)
+ monkeypatch.setattr(DPSupervisor, "_monitor_children", fake_monitor_children)
supervisor = DPSupervisor(_make_unit_args())
@@ -437,8 +448,11 @@ def launch_mock_vllm_with_drain(
async def _poll_supervisor_health(expected_status: int, use_ssl: bool = False) -> bool:
"""
- Poll GET /health on the supervisor until expected_status is seen.
- A connection error is treated as 503-equivalent when expected_status != 200.
+ GET /health on the supervisor once and check for expected_status.
+
+ Pass expected_status=-1 to assert the supervisor is not listening yet
+ (a connection error is expected). The supervisor only starts its HTTP
+ server once every child is ready, so /health is refused until then.
"""
scheme = "https" if use_ssl else "http"
url = f"{scheme}://127.0.0.1:{_SUPERVISOR_PORT}/health"
@@ -456,6 +470,17 @@ async def _poll_supervisor_health(expected_status: int, use_ssl: bool = False) -
return True
+async def _await_supervisor_health(
+ expected_status: int, use_ssl: bool = False, retries: int = 20
+) -> bool:
+ """Retry _poll_supervisor_health, tolerating supervisor server startup."""
+ for _ in range(retries):
+ if await _poll_supervisor_health(expected_status, use_ssl=use_ssl):
+ return True
+ await asyncio.sleep(0.5)
+ return False
+
+
async def _poll_until_api_server_running(
port: int, retries: int = 10, use_ssl: bool = False
) -> None:
@@ -534,7 +559,7 @@ async def _run_supervisor(
@pytest.mark.asyncio
async def test_basic_lifecycle(monkeypatch):
"""
- A) Supervisor /health returns 503 while children are unhealthy.
+ A) Supervisor is not listening while children are unhealthy.
B) /health returns 200 once every child reports healthy.
C) SIGTERM and shutdown
"""
@@ -543,24 +568,23 @@ async def test_basic_lifecycle(monkeypatch):
vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
for port in vllm_server_ports:
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
await _poll_until_api_server_running(port)
await _set_healthy(vllm_server_ports[0])
await asyncio.sleep(1.0)
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
- print("/health is 503 --- expected!")
+ print("supervisor not listening --- expected!")
for port in vllm_server_ports:
await _set_healthy(port)
- await asyncio.sleep(1.0)
- assert await _poll_supervisor_health(200)
+ assert await _await_supervisor_health(200)
assert supervisor.is_ready
print("/health is 200 --- expected!")
@@ -589,19 +613,18 @@ async def test_basic_lifecycle_with_ssl(monkeypatch):
vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
- assert await _poll_supervisor_health(503, use_ssl=True)
+ assert await _poll_supervisor_health(-1, use_ssl=True)
assert not supervisor.is_ready
for port in vllm_server_ports:
- assert await _poll_supervisor_health(503, use_ssl=True)
+ assert await _poll_supervisor_health(-1, use_ssl=True)
assert not supervisor.is_ready
await _poll_until_api_server_running(port, use_ssl=True)
for port in vllm_server_ports:
await _set_healthy(port, use_ssl=True)
- await asyncio.sleep(1.0)
- assert await _poll_supervisor_health(200, use_ssl=True)
+ assert await _await_supervisor_health(200, use_ssl=True)
assert supervisor.is_ready
@@ -616,7 +639,7 @@ async def test_failed_startup(monkeypatch):
vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
for port in vllm_server_ports:
@@ -632,7 +655,7 @@ async def test_failed_startup(monkeypatch):
@pytest.mark.asyncio
async def test_becomes_unhealthy(monkeypatch):
"""
- A) Supervisor /health returns 503 while children are unhealthy.
+ A) Supervisor is not listening while children are unhealthy.
B) /health returns 200 once every child reports healthy.
C) Child process becomes unhealthy.
D) Detected and shutdown.
@@ -642,24 +665,23 @@ async def test_becomes_unhealthy(monkeypatch):
vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
for port in vllm_server_ports:
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
await _poll_until_api_server_running(port)
await _set_healthy(vllm_server_ports[0])
await asyncio.sleep(1.0)
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
- print("/health is 503 --- expected!")
+ print("supervisor not listening --- expected!")
for port in vllm_server_ports:
await _set_healthy(port)
- await asyncio.sleep(1.0)
- assert await _poll_supervisor_health(200)
+ assert await _await_supervisor_health(200)
assert supervisor.is_ready
print("/health is 200 --- expected!")
@@ -674,7 +696,7 @@ async def test_becomes_unhealthy(monkeypatch):
@pytest.mark.asyncio
async def test_dp_server_fails(monkeypatch):
"""
- A) Supervisor /health returns 503 while children are unhealthy.
+ A) Supervisor is not listening while children are unhealthy.
B) /health returns 200 once every child reports healthy.
C) Child process fails.
D) Detected and shutdown.
@@ -684,24 +706,23 @@ async def test_dp_server_fails(monkeypatch):
vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
for port in vllm_server_ports:
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
await _poll_until_api_server_running(port)
await _set_healthy(vllm_server_ports[0])
await asyncio.sleep(1.0)
- assert await _poll_supervisor_health(503)
+ assert await _poll_supervisor_health(-1)
assert not supervisor.is_ready
- print("/health is 503 --- expected!")
+ print("supervisor not listening --- expected!")
for port in vllm_server_ports:
await _set_healthy(port)
- await asyncio.sleep(1.0)
- assert await _poll_supervisor_health(200)
+ assert await _await_supervisor_health(200)
assert supervisor.is_ready
print("/health is 200 --- expected!")
@@ -739,8 +760,7 @@ async def test_shutdown_timeout(monkeypatch: pytest.MonkeyPatch):
for port in vllm_server_ports:
await _set_healthy(port)
- await asyncio.sleep(1.0)
- assert await _poll_supervisor_health(200)
+ assert await _await_supervisor_health(200)
assert supervisor.is_ready
start_t = time.perf_counter()
diff --git a/vllm/entrypoints/openai/dp_supervisor.py b/vllm/entrypoints/openai/dp_supervisor.py
index f55f11f81df2..d669ec4d1d58 100644
--- a/vllm/entrypoints/openai/dp_supervisor.py
+++ b/vllm/entrypoints/openai/dp_supervisor.py
@@ -283,14 +283,46 @@ def is_ready(self) -> bool:
async def run(self) -> None:
loop = asyncio.get_running_loop()
+ decorate_logs("DPSupervisor")
# K8s sends SIGTERM for shutdown - begin graceful termination.
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, partial(self._handle_signal, sig))
- # Launch DPSupervisor Server.
+ supervisor_server: uvicorn.Server | None = None
+ supervisor_server_task: asyncio.Task[None] | None = None
+ try:
+ # Launch vLLM DP Servers and begin monitoring them.
+ self._start_children()
+ monitor_task = asyncio.create_task(
+ self._monitor_children(), name="dp-monitor"
+ )
+
+ # Only start the DPSupervisor server once the vLLM DP Servers are
+ # ready. This avoids the supervisor /health endpoint returning 503
+ # to external load balancer probes while the engines initialize.
+ await self._wait_until_ready(monitor_task)
+ if self.is_ready and not monitor_task.done():
+ supervisor_server, supervisor_server_task = await self._start_server()
+
+ await monitor_task
+ finally:
+ self._is_ready = False
+ await self._shutdown_children()
+
+ # Shutdown the DP Supervisor server.
+ if supervisor_server is not None and supervisor_server_task is not None:
+ supervisor_server.should_exit = True
+ await supervisor_server_task
+
+ async def _start_server(self) -> tuple[uvicorn.Server, asyncio.Task[None]]:
+ """
+ Launch the DPSupervisor HTTP server.
+
+ Called only after the vLLM DP Servers are ready so that /health does
+ not return 503 to external probes while the engines are initializing.
+ """
app = _build_dp_supervisor_app(self)
- decorate_logs("DPSupervisor")
host = self.args.host or "0.0.0.0"
config = uvicorn.Config(
app,
@@ -319,18 +351,20 @@ async def run(self) -> None:
raise RuntimeError("DPSupervisor exited before startup.")
await asyncio.sleep(0.05)
logger.info("Started DPSupervisor on %s:%d", host, self.supervisor_port)
+ return supervisor_server, supervisor_server_task
- # Launch and Monitor vLLM Server Processes.
- try:
- self._start_children()
- await self._monitor_children()
- finally:
- self._is_ready = False
- await self._shutdown_children()
+ async def _wait_until_ready(self, monitor_task: asyncio.Task[None]) -> None:
+ """
+ Block until the vLLM DP Servers are ready or shutdown is triggered.
- # Shutdown the DP Supervisor server.
- supervisor_server.should_exit = True
- await supervisor_server_task
+ Returns early if monitoring stops (e.g. a DP Server dies during
+ startup), in which case the supervisor server is never started.
+ """
+ logger.info("Waiting for vLLM DP Servers to become ready.")
+ while not self._is_ready and not self._shutdown_event.is_set():
+ if monitor_task.done():
+ return
+ await asyncio.sleep(0.05)
def _handle_signal(self, signum: int) -> None:
"""
From bec0a4ede621bc9f1ec7be39b79ed2e286da4ca1 Mon Sep 17 00:00:00 2001
From: Lucas Wilkinson
Date: Sat, 11 Jul 2026 01:20:25 -0400
Subject: [PATCH 0038/1526] [Revert] [Build] Update vllm ...builds FA3 with
torch stable API (#48269)
Signed-off-by: Lucas Wilkinson
---
cmake/external_projects/vllm_flash_attn.cmake | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cmake/external_projects/vllm_flash_attn.cmake b/cmake/external_projects/vllm_flash_attn.cmake
index c8b1d6891876..97a8cfe87b7b 100644
--- a/cmake/external_projects/vllm_flash_attn.cmake
+++ b/cmake/external_projects/vllm_flash_attn.cmake
@@ -39,7 +39,7 @@ else()
FetchContent_Declare(
vllm-flash-attn
GIT_REPOSITORY https://github.com/vllm-project/flash-attention.git
- GIT_TAG b3964b1d8b95d8e8447435668ab169a2700bab65
+ GIT_TAG bb9a72e7dde0dc614ffc663e052cd6a19ce73a42
GIT_PROGRESS TRUE
# Don't share the vllm-flash-attn build between build types
BINARY_DIR ${CMAKE_BINARY_DIR}/vllm-flash-attn
From 4a6440acefbd4d977620bdb6dfb7fb325cd9bda7 Mon Sep 17 00:00:00 2001
From: Harry Mellor <19981378+hmellor@users.noreply.github.com>
Date: Sat, 11 Jul 2026 08:56:14 +0100
Subject: [PATCH 0039/1526] Bump Transformers version to 5.13.0 (#47867)
Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>
---
requirements/test/cpu.txt | 4 ++--
requirements/test/cuda.in | 2 +-
requirements/test/cuda.txt | 4 ++--
requirements/test/nightly-torch.txt | 2 +-
requirements/test/rocm.in | 2 +-
requirements/test/rocm.txt | 4 ++--
requirements/test/xpu.in | 2 +-
requirements/test/xpu.txt | 4 ++--
vllm/model_executor/models/granitemoehybrid.py | 4 ++++
vllm/model_executor/models/hunyuan_vision.py | 14 +++++++++++---
vllm/model_executor/models/olmo3.py | 8 +++-----
vllm/transformers_utils/processors/__init__.py | 4 ----
12 files changed, 30 insertions(+), 24 deletions(-)
diff --git a/requirements/test/cpu.txt b/requirements/test/cpu.txt
index da2c7b772104..5035523747fd 100644
--- a/requirements/test/cpu.txt
+++ b/requirements/test/cpu.txt
@@ -962,7 +962,7 @@ s3transfer==0.10.3
# via boto3
sacrebleu==2.4.3
# via lm-eval
-safetensors==0.7.0
+safetensors==0.8.0
# via
# -r requirements/test/../common.txt
# accelerate
@@ -1159,7 +1159,7 @@ tqdm==4.67.3
# segmentation-models-pytorch
# sentence-transformers
# transformers
-transformers==5.10.4
+transformers @ git+https://github.com/huggingface/transformers.git@bef013c4a2180aeef83355bd200a7383ce1ac7e3
# via
# -r requirements/test/../common.txt
# -r requirements/test/cuda.in
diff --git a/requirements/test/cuda.in b/requirements/test/cuda.in
index a0061fbf78cb..988b396bf5a3 100644
--- a/requirements/test/cuda.in
+++ b/requirements/test/cuda.in
@@ -39,7 +39,7 @@ open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_
datamodel_code_generator # required for minicpm3 test
lm-eval[api]>=0.4.12 # required for model evaluation test
mteb[bm25s]>=2, <3 # required for mteb test
-transformers==5.10.4
+transformers @ git+https://github.com/huggingface/transformers.git@v5.13-release
tokenizers==0.22.2
schemathesis>=4.0.0 # Required for openai schema test.
# quantization
diff --git a/requirements/test/cuda.txt b/requirements/test/cuda.txt
index 2f7b942e9118..da02d16e8022 100644
--- a/requirements/test/cuda.txt
+++ b/requirements/test/cuda.txt
@@ -1053,7 +1053,7 @@ s3transfer==0.10.3
# via boto3
sacrebleu==2.4.3
# via lm-eval
-safetensors==0.7.0
+safetensors==0.8.0
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
@@ -1261,7 +1261,7 @@ tqdm==4.67.3
# segmentation-models-pytorch
# sentence-transformers
# transformers
-transformers==5.10.4
+transformers @ git+https://github.com/huggingface/transformers.git@bef013c4a2180aeef83355bd200a7383ce1ac7e3
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
diff --git a/requirements/test/nightly-torch.txt b/requirements/test/nightly-torch.txt
index 826473db1b23..8cb7863d12be 100644
--- a/requirements/test/nightly-torch.txt
+++ b/requirements/test/nightly-torch.txt
@@ -29,7 +29,7 @@ opencv-python-headless >= 4.13.0 # required for video test
datamodel_code_generator # required for minicpm3 test
lm-eval[api]>=0.4.12 # required for model evaluation test
mteb[bm25s]>=2, <3 # required for mteb test
-transformers==5.10.4
+transformers @ git+https://github.com/huggingface/transformers.git@v5.13-release
tokenizers==0.22.2
schemathesis>=4.0.0 # Required for openai schema test.
# quantization
diff --git a/requirements/test/rocm.in b/requirements/test/rocm.in
index b1c9a473e2fc..f8553637fa96 100644
--- a/requirements/test/rocm.in
+++ b/requirements/test/rocm.in
@@ -35,7 +35,7 @@ open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_
datamodel_code_generator # required for minicpm3 test
lm-eval[api]>=0.4.12 # required for model evaluation test
mteb[bm25s]>=2, <3 # required for mteb test
-transformers==5.10.4
+transformers @ git+https://github.com/huggingface/transformers.git@v5.13-release
tokenizers==0.22.2
schemathesis>=4.0.0 # Required for openai schema test
# quantization
diff --git a/requirements/test/rocm.txt b/requirements/test/rocm.txt
index 52a0ad88c9c1..7e30075184bb 100644
--- a/requirements/test/rocm.txt
+++ b/requirements/test/rocm.txt
@@ -1035,7 +1035,7 @@ s3transfer==0.16.0
# via boto3
sacrebleu==2.6.0
# via lm-eval
-safetensors==0.7.0
+safetensors==0.8.0
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
@@ -1218,7 +1218,7 @@ tqdm==4.67.3
# sentence-transformers
# tilelang
# transformers
-transformers==5.10.4
+transformers @ git+https://github.com/huggingface/transformers.git@bef013c4a2180aeef83355bd200a7383ce1ac7e3
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
diff --git a/requirements/test/xpu.in b/requirements/test/xpu.in
index 1172553c4acb..6d5d6e4ad850 100644
--- a/requirements/test/xpu.in
+++ b/requirements/test/xpu.in
@@ -17,7 +17,7 @@ accelerate
arctic-inference
lm_eval[api]>=0.4.12
modelscope<1.38
-transformers==5.10.4
+transformers @ git+https://github.com/huggingface/transformers.git@v5.13-release
# --- Audio Processing ---
librosa
diff --git a/requirements/test/xpu.txt b/requirements/test/xpu.txt
index 6335fc90cff3..60bf02ab28b5 100644
--- a/requirements/test/xpu.txt
+++ b/requirements/test/xpu.txt
@@ -779,7 +779,7 @@ rpds-py==0.30.0
# referencing
sacrebleu==2.6.0
# via lm-eval
-safetensors==0.7.0
+safetensors==0.8.0
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
@@ -940,7 +940,7 @@ tqdm==4.67.3
# pqdm
# sentence-transformers
# transformers
-transformers==5.10.4
+transformers @ git+https://github.com/huggingface/transformers.git@bef013c4a2180aeef83355bd200a7383ce1ac7e3
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
diff --git a/vllm/model_executor/models/granitemoehybrid.py b/vllm/model_executor/models/granitemoehybrid.py
index b50d11e39421..a50a95a302e8 100644
--- a/vllm/model_executor/models/granitemoehybrid.py
+++ b/vllm/model_executor/models/granitemoehybrid.py
@@ -316,8 +316,12 @@ def forward(
ALL_DECODER_LAYER_TYPES = {
+ # Transformers < 5.13.0
"attention": GraniteMoeHybridAttentionDecoderLayer,
"mamba": GraniteMoeHybridMambaDecoderLayer,
+ # Transformers >= 5.13.0
+ "full_attention": GraniteMoeHybridAttentionDecoderLayer,
+ "linear_attention": GraniteMoeHybridMambaDecoderLayer,
}
diff --git a/vllm/model_executor/models/hunyuan_vision.py b/vllm/model_executor/models/hunyuan_vision.py
index 87520fe29eee..b980e8ae46a7 100644
--- a/vllm/model_executor/models/hunyuan_vision.py
+++ b/vllm/model_executor/models/hunyuan_vision.py
@@ -73,6 +73,7 @@
BaseProcessingInfo,
PromptReplacement,
PromptUpdate,
+ PromptUpdateDetails,
)
from vllm.sequence import IntermediateTensors
from vllm.transformers_utils.configs.hunyuan_vl import (
@@ -751,8 +752,10 @@ def _get_prompt_updates(
hf_processor = self.info.get_hf_processor(**hf_processor_mm_kwargs)
image_processor = self.info.get_image_processor(**hf_processor_mm_kwargs)
- placeholder = {
+ token_ids = {
"image": hf_processor.image_token_id,
+ "image_start": hf_processor.image_start_token_id,
+ "image_end": hf_processor.image_end_token_id,
}
merge_size = image_processor.merge_size
@@ -766,12 +769,17 @@ def get_replacement_hunyuan_vl(item_idx: int, modality: str):
num_tokens = (int(grid_h) // merge_size) * (
int(grid_w) // merge_size + 1
) + 2
- return [placeholder[modality]] * num_tokens
+ tokens = (
+ [token_ids[f"{modality}_start"]]
+ + [token_ids[modality]] * num_tokens
+ + [token_ids[f"{modality}_end"]]
+ )
+ return PromptUpdateDetails.select_token_id(tokens, token_ids[modality])
return [
PromptReplacement(
modality=modality,
- target=[placeholder[modality]],
+ target=[token_ids[modality]],
replacement=partial(get_replacement_hunyuan_vl, modality=modality),
)
for modality in ("image",)
diff --git a/vllm/model_executor/models/olmo3.py b/vllm/model_executor/models/olmo3.py
index 3c36c0597884..922834a8ee68 100644
--- a/vllm/model_executor/models/olmo3.py
+++ b/vllm/model_executor/models/olmo3.py
@@ -137,11 +137,9 @@ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
)
# Rotary embeddings. Rope scaling is only applied on full attention layers.
- if sliding_window is None:
- rope_parameters = self.config.rope_parameters
- else:
- rope_theta = self.config.rope_parameters["rope_theta"]
- rope_parameters = {"rope_type": "default", "rope_theta": rope_theta}
+ rope_parameters = self.config.rope_parameters
+ attn_type = "full_attention" if sliding_window is None else "sliding_attention"
+ rope_parameters = rope_parameters.get(attn_type, rope_parameters)
self.rotary_emb = get_rope(
self.head_dim,
max_position=self.max_position_embeddings,
diff --git a/vllm/transformers_utils/processors/__init__.py b/vllm/transformers_utils/processors/__init__.py
index e4ece0a41972..60eb2922fda9 100644
--- a/vllm/transformers_utils/processors/__init__.py
+++ b/vllm/transformers_utils/processors/__init__.py
@@ -21,8 +21,6 @@
"GLM4VProcessor",
"Granite4VisionProcessor",
"H2OVLProcessor",
- "HunYuanVLProcessor",
- "HunYuanVLImageProcessor",
"Moondream3Processor",
"InternVLProcessor",
"IsaacProcessor",
@@ -58,8 +56,6 @@
"GLM4VProcessor": "vllm.transformers_utils.processors.glm4v",
"Granite4VisionProcessor": "vllm.transformers_utils.processors.granite4_vision",
"H2OVLProcessor": "vllm.transformers_utils.processors.h2ovl",
- "HunYuanVLProcessor": "vllm.transformers_utils.processors.hunyuan_vl",
- "HunYuanVLImageProcessor": "vllm.transformers_utils.processors.hunyuan_vl_image",
"InternVLProcessor": "vllm.transformers_utils.processors.internvl",
"IsaacProcessor": "vllm.transformers_utils.processors.isaac",
"KimiAudioProcessor": "vllm.transformers_utils.processors.kimi_audio",
From 0b6636cbcbd3d40cb8c36459a3ff0f1f3c9b48a7 Mon Sep 17 00:00:00 2001
From: Qiming Zhang
Date: Sat, 11 Jul 2026 02:27:13 -0700
Subject: [PATCH 0040/1526] [XPU]remove is_xxx from moe class and bump up
kernels (#48079)
Signed-off-by: mayuyuace
Co-authored-by: Kunshang Ji
---
requirements/xpu.txt | 2 +-
vllm/_custom_ops.py | 12 --------
.../layers/fused_moe/experts/xpu_moe.py | 30 ++++---------------
3 files changed, 7 insertions(+), 37 deletions(-)
diff --git a/requirements/xpu.txt b/requirements/xpu.txt
index 68b8eb130104..2da3c6654b13 100644
--- a/requirements/xpu.txt
+++ b/requirements/xpu.txt
@@ -18,4 +18,4 @@ torchvision
torchcodec >= 0.14 # Required for the torchcodec video decoding backend
auto_round_lib>=0.14.0
-vllm_xpu_kernels @ https://github.com/vllm-project/vllm-xpu-kernels/releases/download/v0.1.10.1/vllm_xpu_kernels-0.1.10.1-cp38-abi3-manylinux_2_28_x86_64.whl
+vllm_xpu_kernels @ https://github.com/vllm-project/vllm-xpu-kernels/releases/download/v0.1.11/vllm_xpu_kernels-0.1.11-cp38-abi3-manylinux_2_28_x86_64.whl
diff --git a/vllm/_custom_ops.py b/vllm/_custom_ops.py
index 2a4579ae03a3..627058cb2e33 100644
--- a/vllm/_custom_ops.py
+++ b/vllm/_custom_ops.py
@@ -2277,18 +2277,6 @@ def topk_sigmoid(
e_score_correction_bias: torch.Tensor | None = None,
routed_scaling_factor: float = 1.0,
) -> None:
- if current_platform.is_xpu():
- # xpu doesn't support routed_scaling_factor currently, will revert
- # in next vllm-xpu-kernels bumpup
- torch.ops._moe_C.topk_sigmoid(
- topk_weights,
- topk_ids,
- token_expert_indices,
- gating_output,
- renormalize,
- e_score_correction_bias,
- )
- return
torch.ops._moe_C.topk_sigmoid(
topk_weights,
topk_ids,
diff --git a/vllm/model_executor/layers/fused_moe/experts/xpu_moe.py b/vllm/model_executor/layers/fused_moe/experts/xpu_moe.py
index e8b29bcf2ce0..cde167e5d364 100644
--- a/vllm/model_executor/layers/fused_moe/experts/xpu_moe.py
+++ b/vllm/model_executor/layers/fused_moe/experts/xpu_moe.py
@@ -62,11 +62,6 @@ def __init__(
max_num_tokens,
num_dispatchers,
)
- self.is_fp8 = False
- self.is_int4 = False
- self.is_mxfp4 = False
- self.is_block_fp8 = False
- self.is_mxfp8 = False
self.gemm1_clamp_limit = quant_config.gemm1_clamp_limit
self.fused_moe_impl: XpuFusedMoe | None = None
@@ -149,17 +144,14 @@ def apply(
expert_tokens_meta: mk.ExpertTokensMetadata | None,
apply_router_weight_on_input: bool,
):
- # The kernel takes is_fp8/is_int4/is_mxfp4 as independent booleans.
- # In this hierarchy each subclass flips exactly one to True; assert
- # the invariant so a future subclass that sets two doesn't silently
- # miscompute (kernel-side priority is undocumented).
- assert sum([self.is_fp8, self.is_int4, self.is_mxfp4]) <= 1, (
- "XPUExperts: at most one of is_fp8, is_int4, is_mxfp4 may be True; "
- f"got is_fp8={self.is_fp8}, is_int4={self.is_int4}, "
- f"is_mxfp4={self.is_mxfp4}."
- )
if self.fused_moe_impl is None:
topk = topk_ids.size(-1)
+ if (
+ self.quant_config is not None
+ and self.quant_config.weight_quant_dtype == "mxfp4"
+ ):
+ w1 = w1.view(torch.float4_e2m1fn_x2)
+ w2 = w2.view(torch.float4_e2m1fn_x2)
self.fused_moe_impl = XpuFusedMoe(
w13=w1,
w13_scales=self.w1_scale,
@@ -172,11 +164,6 @@ def apply(
num_experts=self.moe_config.num_local_experts,
ep_rank=self.moe_config.ep_rank,
ep_size=self.moe_config.ep_size,
- is_fp8=self.is_fp8,
- is_int4=self.is_int4,
- is_mxfp4=self.is_mxfp4,
- is_mxfp8=self.is_mxfp8,
- is_block_fp8=self.is_block_fp8,
gemm1_clamp_limit=self.gemm1_clamp_limit,
)
assert self.fused_moe_impl is not None
@@ -202,7 +189,6 @@ def __init__(
max_num_tokens,
num_dispatchers,
)
- self.is_fp8 = True
@staticmethod
def _supports_quant_scheme(
@@ -231,7 +217,6 @@ def __init__(
num_dispatchers,
)
assert quant_config.quant_dtype == "mxfp8"
- self.is_mxfp8 = True
@staticmethod
def _supports_quant_scheme(
@@ -259,7 +244,6 @@ def __init__(
max_num_tokens,
num_dispatchers,
)
- self.is_block_fp8 = True
@staticmethod
def _supports_quant_scheme(
@@ -298,7 +282,6 @@ def __init__(
max_num_tokens,
num_dispatchers,
)
- self.is_int4 = True
@staticmethod
def _supports_quant_scheme(
@@ -325,7 +308,6 @@ def __init__(
max_num_tokens,
num_dispatchers,
)
- self.is_mxfp4 = True
@staticmethod
def _supports_quant_scheme(
From 1bd8f80a643efb5f977469692e458fd9a4fd3b4f Mon Sep 17 00:00:00 2001
From: Harry Mellor <19981378+hmellor@users.noreply.github.com>
Date: Sat, 11 Jul 2026 10:31:14 +0100
Subject: [PATCH 0041/1526] [CI] Point CI at Transformers release rather than
release branch (#48328)
Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>
---
requirements/test/cpu.txt | 2 +-
requirements/test/cuda.in | 2 +-
requirements/test/cuda.txt | 2 +-
requirements/test/nightly-torch.txt | 2 +-
requirements/test/rocm.in | 2 +-
requirements/test/rocm.txt | 2 +-
requirements/test/xpu.in | 2 +-
requirements/test/xpu.txt | 2 +-
8 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/requirements/test/cpu.txt b/requirements/test/cpu.txt
index 5035523747fd..b6918202ac72 100644
--- a/requirements/test/cpu.txt
+++ b/requirements/test/cpu.txt
@@ -1159,7 +1159,7 @@ tqdm==4.67.3
# segmentation-models-pytorch
# sentence-transformers
# transformers
-transformers @ git+https://github.com/huggingface/transformers.git@bef013c4a2180aeef83355bd200a7383ce1ac7e3
+transformers==5.13.1
# via
# -r requirements/test/../common.txt
# -r requirements/test/cuda.in
diff --git a/requirements/test/cuda.in b/requirements/test/cuda.in
index 988b396bf5a3..bd6f179c105a 100644
--- a/requirements/test/cuda.in
+++ b/requirements/test/cuda.in
@@ -39,7 +39,7 @@ open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_
datamodel_code_generator # required for minicpm3 test
lm-eval[api]>=0.4.12 # required for model evaluation test
mteb[bm25s]>=2, <3 # required for mteb test
-transformers @ git+https://github.com/huggingface/transformers.git@v5.13-release
+transformers==5.13.1
tokenizers==0.22.2
schemathesis>=4.0.0 # Required for openai schema test.
# quantization
diff --git a/requirements/test/cuda.txt b/requirements/test/cuda.txt
index da02d16e8022..94986ec2479e 100644
--- a/requirements/test/cuda.txt
+++ b/requirements/test/cuda.txt
@@ -1261,7 +1261,7 @@ tqdm==4.67.3
# segmentation-models-pytorch
# sentence-transformers
# transformers
-transformers @ git+https://github.com/huggingface/transformers.git@bef013c4a2180aeef83355bd200a7383ce1ac7e3
+transformers==5.13.1
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
diff --git a/requirements/test/nightly-torch.txt b/requirements/test/nightly-torch.txt
index 8cb7863d12be..dfefa8239c5f 100644
--- a/requirements/test/nightly-torch.txt
+++ b/requirements/test/nightly-torch.txt
@@ -29,7 +29,7 @@ opencv-python-headless >= 4.13.0 # required for video test
datamodel_code_generator # required for minicpm3 test
lm-eval[api]>=0.4.12 # required for model evaluation test
mteb[bm25s]>=2, <3 # required for mteb test
-transformers @ git+https://github.com/huggingface/transformers.git@v5.13-release
+transformers==5.13.1
tokenizers==0.22.2
schemathesis>=4.0.0 # Required for openai schema test.
# quantization
diff --git a/requirements/test/rocm.in b/requirements/test/rocm.in
index f8553637fa96..c5f8f85f2f31 100644
--- a/requirements/test/rocm.in
+++ b/requirements/test/rocm.in
@@ -35,7 +35,7 @@ open_clip_torch==2.32.0 # Required for nemotron_vl test, Nemotron Parse in test_
datamodel_code_generator # required for minicpm3 test
lm-eval[api]>=0.4.12 # required for model evaluation test
mteb[bm25s]>=2, <3 # required for mteb test
-transformers @ git+https://github.com/huggingface/transformers.git@v5.13-release
+transformers==5.13.1
tokenizers==0.22.2
schemathesis>=4.0.0 # Required for openai schema test
# quantization
diff --git a/requirements/test/rocm.txt b/requirements/test/rocm.txt
index 7e30075184bb..1b4484c3a2d2 100644
--- a/requirements/test/rocm.txt
+++ b/requirements/test/rocm.txt
@@ -1218,7 +1218,7 @@ tqdm==4.67.3
# sentence-transformers
# tilelang
# transformers
-transformers @ git+https://github.com/huggingface/transformers.git@bef013c4a2180aeef83355bd200a7383ce1ac7e3
+transformers==5.13.1
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
diff --git a/requirements/test/xpu.in b/requirements/test/xpu.in
index 6d5d6e4ad850..d2380a751319 100644
--- a/requirements/test/xpu.in
+++ b/requirements/test/xpu.in
@@ -17,7 +17,7 @@ accelerate
arctic-inference
lm_eval[api]>=0.4.12
modelscope<1.38
-transformers @ git+https://github.com/huggingface/transformers.git@v5.13-release
+transformers==5.13.1
# --- Audio Processing ---
librosa
diff --git a/requirements/test/xpu.txt b/requirements/test/xpu.txt
index 60bf02ab28b5..cd745684119b 100644
--- a/requirements/test/xpu.txt
+++ b/requirements/test/xpu.txt
@@ -940,7 +940,7 @@ tqdm==4.67.3
# pqdm
# sentence-transformers
# transformers
-transformers @ git+https://github.com/huggingface/transformers.git@bef013c4a2180aeef83355bd200a7383ce1ac7e3
+transformers==5.13.1
# via
# -c requirements/common.txt
# -r requirements/test/../common.txt
From 19069bcbd5be607a9c502e007951d8325d6a340e Mon Sep 17 00:00:00 2001
From: Jee Jee Li
Date: Sat, 11 Jul 2026 21:07:48 +0800
Subject: [PATCH 0042/1526] FP32 router GEMV optimization (#48335)
Signed-off-by: peiyuanz
Signed-off-by: Jee Jee Li
Co-authored-by: peiyuanz
Co-authored-by: Claude
Co-authored-by: zhouzhou
---
csrc/libtorch_stable/fp32_router_gemm.cu | 235 +++++++++++++-----
.../libtorch_stable/fp32_router_gemm_entry.cu | 11 +-
tests/kernels/test_fp32_router_gemm.py | 99 +++++++-
.../layers/fused_moe/router/gate_linear.py | 6 +-
4 files changed, 284 insertions(+), 67 deletions(-)
diff --git a/csrc/libtorch_stable/fp32_router_gemm.cu b/csrc/libtorch_stable/fp32_router_gemm.cu
index 64393fad6198..3207082d579a 100644
--- a/csrc/libtorch_stable/fp32_router_gemm.cu
+++ b/csrc/libtorch_stable/fp32_router_gemm.cu
@@ -1,13 +1,18 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright contributors to the vLLM project
//
-// Router GEMM: activation(T) x weight(fp32) -> fp32, H=3072, E=256, M<=32.
+// Router GEMM: activation(T) x weight(fp32) -> fp32, M<=32, for the
+// supported (E, H) pairs listed at the bottom of this file.
// Supports bf16 or fp32 activation; weight is always fp32.
// Adapted from dsv3_router_gemm_float_out.cu.
+// (E=256, H=6144) bf16 uses a B300-tuned wide-block geometry; see
+// invokeFp32RouterGemm.
#include
#include
+#include
+
// ---------------------------------------------------------------------------
// Load helpers
// ---------------------------------------------------------------------------
@@ -73,94 +78,113 @@ __device__ __forceinline__ void load_activation<__nv_bfloat16, 8>(
// InputT : type of activation (float or __nv_bfloat16)
// Weight is always fp32; output is always fp32.
// VPT = 16 / sizeof(InputT): 4 for fp32, 8 for bf16
-template
-__global__ __launch_bounds__(128, 1) void fp32_router_gemm_kernel(
- float* out, InputT const* mat_a, float const* mat_b) {
+// Each block computes kEPB expert columns; wider blocks / kEPB > 1 are
+// selected per (shape, M) in invokeFp32RouterGemm (B300-tuned, see below).
+// kTGroups > 1 splits the tokens across groups of kBlockSize threads within
+// the block: all groups scan the same weight K-slices (group 0 misses to
+// DRAM, later groups hit L1) so weight traffic stays 1x, while per-thread
+// accumulator registers drop by kTGroups (at M=16 the 32 fp32 accumulators
+// push the kernel to 128 regs/thread and 1 block/SM).
+template
+__global__ __launch_bounds__(
+ kBlockSize* kTGroups, 1) void fp32_router_gemm_kernel(float* out,
+ InputT const* mat_a,
+ float const* mat_b) {
constexpr int VPT = 16 / sizeof(InputT);
constexpr int k_elems_per_k_iteration = VPT * kBlockSize;
constexpr int k_iterations = kHiddenDim / k_elems_per_k_iteration;
+ static_assert(kHiddenDim % k_elems_per_k_iteration == 0);
+ static_assert(kNumTokens % kTGroups == 0);
constexpr int kWarpSize = 32;
- constexpr int kNumWarps = kBlockSize / kWarpSize;
+ constexpr int kNumWarps = kBlockSize / kWarpSize; // per token group
+ constexpr int kMG = kNumTokens / kTGroups; // tokens per group
- int const n_idx = blockIdx.x;
- int const tid = threadIdx.x;
+ int const e_base = blockIdx.x * kEPB;
+ int const tid = threadIdx.x % kBlockSize;
+ int const m0 = (threadIdx.x / kBlockSize) * kMG;
int const warpId = tid / kWarpSize;
int const laneId = tid % kWarpSize;
- float acc[kNumTokens] = {};
- __shared__ float sm_reduction[kNumTokens][kNumWarps];
-
- float const* b_col = mat_b + n_idx * kHiddenDim;
-
- int k_bases[k_iterations];
-#pragma unroll
- for (int ki = 0; ki < k_iterations; ki++) {
- k_bases[ki] = ki * k_elems_per_k_iteration + tid * VPT;
- }
+ float acc[kMG][kEPB] = {};
+ __shared__ float sm_reduction[kNumTokens][kEPB][kNumWarps];
#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
cudaGridDependencySynchronize();
+ // Fire the PDL trigger right after our own wait instead of at kernel end:
+ // a gridsync-ing consumer is unaffected (its wait always targets full grid
+ // completion), while a consumer that reads none of our outputs (e.g. the
+ // NVFP4 activation quant, which reads the same hidden_states) can launch
+ // now and fully overlap this kernel's body.
+ cudaTriggerProgrammaticLaunchCompletion();
#endif
+#pragma unroll
for (int ki = 0; ki < k_iterations; ki++) {
- int const k_base = k_bases[ki];
+ int const k_base = ki * k_elems_per_k_iteration + tid * VPT;
- float b_float[VPT];
- load_weight(b_col + k_base, b_float);
+ float b_float[kEPB][VPT];
+#pragma unroll
+ for (int e = 0; e < kEPB; e++) {
+ load_weight(mat_b + (e_base + e) * kHiddenDim + k_base, b_float[e]);
+ }
#pragma unroll
- for (int m_idx = 0; m_idx < kNumTokens; m_idx++) {
+ for (int m_idx = 0; m_idx < kMG; m_idx++) {
float a_float[VPT];
- load_activation(mat_a + m_idx * kHiddenDim + k_base,
- a_float);
+ load_activation(
+ mat_a + (size_t)(m0 + m_idx) * kHiddenDim + k_base, a_float);
+#pragma unroll
+ for (int e = 0; e < kEPB; e++) {
#pragma unroll
- for (int k = 0; k < VPT; k++) {
- acc[m_idx] += a_float[k] * b_float[k];
+ for (int k = 0; k < VPT; k++) {
+ acc[m_idx][e] += a_float[k] * b_float[e][k];
+ }
}
}
}
// Warp-level butterfly reduction
#pragma unroll
- for (int m = 0; m < kNumTokens; m++) {
- float sum = acc[m];
- sum += __shfl_xor_sync(0xffffffff, sum, 16);
- sum += __shfl_xor_sync(0xffffffff, sum, 8);
- sum += __shfl_xor_sync(0xffffffff, sum, 4);
- sum += __shfl_xor_sync(0xffffffff, sum, 2);
- sum += __shfl_xor_sync(0xffffffff, sum, 1);
- if (laneId == 0) sm_reduction[m][warpId] = sum;
+ for (int m = 0; m < kMG; m++) {
+#pragma unroll
+ for (int e = 0; e < kEPB; e++) {
+ float sum = acc[m][e];
+ sum += __shfl_xor_sync(0xffffffff, sum, 16);
+ sum += __shfl_xor_sync(0xffffffff, sum, 8);
+ sum += __shfl_xor_sync(0xffffffff, sum, 4);
+ sum += __shfl_xor_sync(0xffffffff, sum, 2);
+ sum += __shfl_xor_sync(0xffffffff, sum, 1);
+ if (laneId == 0) sm_reduction[m0 + m][e][warpId] = sum;
+ }
}
__syncthreads();
- if (tid == 0) {
-#pragma unroll
- for (int m = 0; m < kNumTokens; m++) {
- float final_sum = 0.0f;
+ // Parallel finalize: one thread per (m, e) output.
+ for (int idx = threadIdx.x; idx < kNumTokens * kEPB;
+ idx += kBlockSize * kTGroups) {
+ int const m = idx / kEPB;
+ int const e = idx % kEPB;
+ float final_sum = 0.0f;
#pragma unroll
- for (int w = 0; w < kNumWarps; w++) final_sum += sm_reduction[m][w];
- out[m * kNumExperts + n_idx] = final_sum;
- }
+ for (int w = 0; w < kNumWarps; w++) final_sum += sm_reduction[m][e][w];
+ out[m * kNumExperts + e_base + e] = final_sum;
}
-
-#if defined(__CUDA_ARCH__) && (__CUDA_ARCH__ >= 900)
- cudaTriggerProgrammaticLaunchCompletion();
-#endif
}
// ---------------------------------------------------------------------------
// Launcher
// ---------------------------------------------------------------------------
-template
-void invokeFp32RouterGemm(float* output, InputT const* mat_a,
- float const* mat_b, cudaStream_t stream) {
- constexpr int kBlockSize = 128;
+template
+static void launchFp32RouterGemm(float* output, InputT const* mat_a,
+ float const* mat_b, cudaStream_t stream) {
+ static_assert(kNumExperts % kEPB == 0);
cudaLaunchConfig_t config;
- config.gridDim = kNumExperts;
- config.blockDim = kBlockSize;
+ config.gridDim = kNumExperts / kEPB;
+ config.blockDim = kBlockSize * kTGroups;
config.dynamicSmemBytes = 0;
config.stream = stream;
cudaLaunchAttribute attrs[1];
@@ -168,15 +192,112 @@ void invokeFp32RouterGemm(float* output, InputT const* mat_a,
attrs[0].val.programmaticStreamSerializationAllowed = 1;
config.numAttrs = 1;
config.attrs = attrs;
- cudaLaunchKernelEx(&config,
- fp32_router_gemm_kernel,
- output, mat_a, mat_b);
+ cudaLaunchKernelEx(
+ &config,
+ fp32_router_gemm_kernel,
+ output, mat_a, mat_b);
+}
+
+static bool isBlackwellFamily() {
+ static int sm = []() {
+ int dev = 0, major = 0, minor = 0;
+ cudaGetDevice(&dev);
+ cudaDeviceGetAttribute(&major, cudaDevAttrComputeCapabilityMajor, dev);
+ cudaDeviceGetAttribute(&minor, cudaDevAttrComputeCapabilityMinor, dev);
+ return major * 10 + minor;
+ }();
+ return sm >= 100;
+}
+
+template
+void invokeFp32RouterGemm(float* output, InputT const* mat_a,
+ float const* mat_b, cudaStream_t stream) {
+ // Geometry tuned on B300 per supported shape, bf16 activation, under a
+ // production-fidelity harness (CUDA-graph replay, per-layer cold weights).
+ // GLM-5.2 (E=256, H=6144):
+ // M <= 4 : BS=768, EPB=1 (2.7us vs cast+cuBLAS 8.1us at M=1)
+ // M in [5, 15]
+ // or odd : BS=384, EPB=2 (crossover vs BS=768 measured in (4, 8))
+ // M >= 16, even : BS=192, EPB=2, 2 token groups (M=16 4.79us vs 5.04,
+ // M=24 5.71 vs 6.38, M=32 6.79 vs 7.72; M=12 loses at
+ // 0.97x, so the boundary is 16).
+ // Only enabled on the Blackwell family where it was validated; Hopper and
+ // other shapes / fp32 activation keep the legacy geometry.
+ if constexpr (std::is_same_v && kNumExperts == 256 &&
+ kHiddenDim == 6144) {
+ if (!isBlackwellFamily()) {
+ launchFp32RouterGemm(
+ output, mat_a, mat_b, stream);
+ return;
+ }
+ if constexpr (kNumTokens <= 4) {
+ launchFp32RouterGemm(
+ output, mat_a, mat_b, stream);
+ } else if constexpr (kNumTokens >= 16 && kNumTokens % 2 == 0) {
+ launchFp32RouterGemm(output, mat_a, mat_b, stream);
+ } else {
+ launchFp32RouterGemm(
+ output, mat_a, mat_b, stream);
+ }
+ } else if constexpr (std::is_same_v &&
+ kNumExperts == 128 && kHiddenDim == 6144) {
+ // MiniMax-M3. Legacy 128/1 only fills 128 blocks and pays the same
+ // accumulator register cliffs; B300 sweep:
+ // even M in [6, 10] : BS=384, EPB=1, 2 token groups (1.26-1.43x)
+ // even M >= 12 : BS=192, EPB=1, 2 token groups (1.59-1.66x at
+ // M >= 18; re-measured on B300+B200: 192 also wins
+ // M=12/14 by 5-11%% on both, ties 384 at 16)
+ // M <= 5 / odd : BS=384, EPB=1 (1.03-1.19x)
+ if (!isBlackwellFamily()) {
+ launchFp32RouterGemm(
+ output, mat_a, mat_b, stream);
+ return;
+ }
+ if constexpr (kNumTokens >= 12 && kNumTokens % 2 == 0) {
+ launchFp32RouterGemm(output, mat_a, mat_b, stream);
+ } else if constexpr (kNumTokens >= 6 && kNumTokens % 2 == 0) {
+ launchFp32RouterGemm(output, mat_a, mat_b, stream);
+ } else {
+ launchFp32RouterGemm(
+ output, mat_a, mat_b, stream);
+ }
+ } else if constexpr (std::is_same_v &&
+ kNumExperts == 256 && kHiddenDim == 3072) {
+ // MiniMax-M2/M2.5. The 3.1MB weight is latency-floor bound at small M
+ // (legacy already optimal); token groups win only at even M >= 8
+ // (1.05-1.17x). EPB crossover measured between 12 and 16.
+ if (!isBlackwellFamily()) {
+ launchFp32RouterGemm(
+ output, mat_a, mat_b, stream);
+ return;
+ }
+ if constexpr (kNumTokens >= 14 && kNumTokens % 2 == 0) {
+ // M=14 originally measured 0.91x and stayed on legacy; two fresh
+ // sweeps (B300 dev1 + B200) both put 192/2/tg2 ahead by 3.5-4%%.
+ launchFp32RouterGemm(output, mat_a, mat_b, stream);
+ } else if constexpr (kNumTokens >= 8 && kNumTokens <= 12 &&
+ kNumTokens % 2 == 0) {
+ launchFp32RouterGemm(output, mat_a, mat_b, stream);
+ } else {
+ launchFp32RouterGemm(
+ output, mat_a, mat_b, stream);
+ }
+ } else {
+ launchFp32RouterGemm(
+ output, mat_a, mat_b, stream);
+ }
}
// ---------------------------------------------------------------------------
// Explicit instantiations: M=1..32, for both input types, for the supported
-// (E, H) pairs: (256, 3072) [MiniMax-M2/M2.5] and (128, 6144) [MiniMax-M3].
+// (E, H) pairs: (256, 3072) [MiniMax-M2/M2.5], (128, 6144) [MiniMax-M3]
+// and (256, 6144) [GLM-5.2].
// ---------------------------------------------------------------------------
#define INSTANTIATE(T, M, E, H) \
@@ -221,6 +342,8 @@ INSTANTIATE_ALL(float, 256, 3072)
INSTANTIATE_ALL(__nv_bfloat16, 256, 3072)
INSTANTIATE_ALL(float, 128, 6144)
INSTANTIATE_ALL(__nv_bfloat16, 128, 6144)
+INSTANTIATE_ALL(float, 256, 6144)
+INSTANTIATE_ALL(__nv_bfloat16, 256, 6144)
#undef INSTANTIATE_ALL
#undef INSTANTIATE
diff --git a/csrc/libtorch_stable/fp32_router_gemm_entry.cu b/csrc/libtorch_stable/fp32_router_gemm_entry.cu
index fc09193c8643..8643e33cbe7d 100644
--- a/csrc/libtorch_stable/fp32_router_gemm_entry.cu
+++ b/csrc/libtorch_stable/fp32_router_gemm_entry.cu
@@ -25,10 +25,12 @@ inline int getSMVersion() {
static constexpr int FP32_MAX_TOKENS = 32;
// Supported (hidden_dim, num_experts) pairs (must match the instantiations in
-// fp32_router_gemm.cu): (3072, 256) for MiniMax-M2/M2.5, (6144, 128) for M3.
+// fp32_router_gemm.cu): (3072, 256) for MiniMax-M2/M2.5, (6144, 128) for M3,
+// (6144, 256) for GLM-5.2.
static inline bool fp32_router_gemm_supported(int hidden_dim, int num_experts) {
return (hidden_dim == 3072 && num_experts == 256) ||
- (hidden_dim == 6144 && num_experts == 128);
+ (hidden_dim == 6144 && num_experts == 128) ||
+ (hidden_dim == 6144 && num_experts == 256);
}
// Forward declarations — 4 template params must match fp32_router_gemm.cu
@@ -77,6 +79,9 @@ void dispatchFp32RouterGemm(int num_experts, int hidden_dim, int num_tokens,
} else if (num_experts == 128 && hidden_dim == 6144) {
Fp32LoopUnroller::unroll(
num_tokens, output, mat_a, mat_b, stream);
+ } else if (num_experts == 256 && hidden_dim == 6144) {
+ Fp32LoopUnroller::unroll(
+ num_tokens, output, mat_a, mat_b, stream);
} else {
throw std::invalid_argument(
"fp32_router_gemm: unsupported (hidden_dim, num_experts) pair");
@@ -111,7 +116,7 @@ void fp32_router_gemm(
STD_TORCH_CHECK(
fp32_router_gemm_supported(hidden_dim, num_experts),
"fp32_router_gemm: supported (hidden_dim, num_experts) pairs are "
- "(3072, 256) and (6144, 128)");
+ "(3072, 256), (6144, 128) and (6144, 256)");
STD_TORCH_CHECK(num_tokens <= FP32_MAX_TOKENS,
"fp32_router_gemm: num_tokens must be in [0, 32]");
STD_TORCH_CHECK(
diff --git a/tests/kernels/test_fp32_router_gemm.py b/tests/kernels/test_fp32_router_gemm.py
index 0673a438c546..571dfa077d32 100644
--- a/tests/kernels/test_fp32_router_gemm.py
+++ b/tests/kernels/test_fp32_router_gemm.py
@@ -3,9 +3,13 @@
"""Tests for fp32_router_gemm kernel: activation×weight→fp32.
Supported (hidden_size, num_experts) pairs:
- (3072, 256) -> MiniMax-M2/M2.5, (6144, 128) -> MiniMax-M3
+ (3072, 256) -> MiniMax-M2/M2.5, (6144, 128) -> MiniMax-M3,
+ (6144, 256) -> GLM-5.2
-Correctness baseline: torch.matmul in float64.
+Correctness baseline: F.linear in float32. Every M in [1, 32] is covered so
+all tuned geometries (wide-block, experts-per-block, token-group; boundaries
+at M=4/5, odd/even, M=15/16) are exercised on Blackwell, and the legacy
+128/1 geometry everywhere else.
"""
import pytest
@@ -14,7 +18,8 @@
from vllm._custom_ops import fp32_router_gemm
# (hidden_size, num_experts)
-SHAPES = [(3072, 256), (6144, 128)]
+SHAPES = [(3072, 256), (6144, 128), (6144, 256)]
+ALL_M = list(range(1, 33))
# Absolute tolerance for fp32 kernel vs float64 reference
ATOL_FP32 = 2e-4
ATOL_BF16 = 2e-2 # bf16 activation has lower precision
@@ -34,7 +39,7 @@ def _ref(mat_a: torch.Tensor, mat_b: torch.Tensor) -> torch.Tensor:
@pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
-@pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 32])
+@pytest.mark.parametrize("num_tokens", ALL_M)
def test_fp32_activation(num_tokens: int, hidden_dim: int, num_experts: int):
"""fp32 activation → fp32 output should match reference closely."""
_requires_sm90()
@@ -52,7 +57,7 @@ def test_fp32_activation(num_tokens: int, hidden_dim: int, num_experts: int):
@pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
-@pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 32])
+@pytest.mark.parametrize("num_tokens", ALL_M)
def test_bf16_activation(num_tokens: int, hidden_dim: int, num_experts: int):
"""bf16 activation → fp32 output should match reference within bf16 error."""
_requires_sm90()
@@ -82,3 +87,87 @@ def test_output_shape_and_dtype(hidden_dim: int, num_experts: int):
assert out.shape == (4, num_experts)
assert out.dtype == torch.float32
assert out.device.type == "cuda"
+
+
+@pytest.mark.parametrize("hidden_dim,num_experts", SHAPES)
+@pytest.mark.parametrize("num_tokens", [1, 2, 4, 8, 16, 24, 32])
+def test_topk_routing_consistency(num_tokens: int, hidden_dim: int, num_experts: int):
+ """The gate feeds top-k expert selection: the kernel's top-8 must match
+ an fp64 reference's top-8 per token (ties tolerated). This is the
+ business-level correctness of the router — numeric error only matters
+ if it flips the argsort."""
+ _requires_sm90()
+ top_k = 8
+ device = torch.device("cuda")
+ for seed in range(5):
+ torch.manual_seed(1000 + seed)
+ mat_a = torch.randn(num_tokens, hidden_dim, dtype=torch.bfloat16, device=device)
+ mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
+ out = fp32_router_gemm(mat_a, mat_b)
+ ref = mat_a.double() @ mat_b.double().t()
+ kernel_idx = out.topk(top_k, dim=-1).indices
+ ref_vals, ref_idx = ref.topk(top_k, dim=-1)
+ for t in range(num_tokens):
+ got = set(kernel_idx[t].tolist())
+ want = set(ref_idx[t].tolist())
+ if got == want:
+ continue
+ # Tolerate genuine near-ties around the k-th value only.
+ kth = ref_vals[t, -1].item()
+ for e in got.symmetric_difference(want):
+ gap = abs(ref[t, e].item() - kth)
+ assert gap < 1e-3, (
+ f"top-{top_k} mismatch beyond tie tolerance: token {t}, "
+ f"expert {e}, gap {gap:.3e}"
+ )
+
+
+def test_zero_tokens_returns_empty():
+ """M=0 is a graceful no-op returning an empty [0, E] tensor."""
+ _requires_sm90()
+ device = torch.device("cuda")
+ hidden_dim, num_experts = SHAPES[0]
+ mat_a = torch.empty(0, hidden_dim, dtype=torch.float32, device=device)
+ mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
+ out = fp32_router_gemm(mat_a, mat_b)
+ assert out.shape == (0, num_experts)
+ assert out.dtype == torch.float32
+
+
+def test_rejects_invalid_inputs():
+ """The entry must fail loudly, never compute silently wrong results."""
+ _requires_sm90()
+ device = torch.device("cuda")
+ hidden_dim, num_experts = SHAPES[0]
+ mat_b = torch.randn(num_experts, hidden_dim, dtype=torch.float32, device=device)
+
+ # num_tokens > 32 (beyond the instantiated range)
+ with pytest.raises(Exception, match="num_tokens"):
+ fp32_router_gemm(
+ torch.randn(33, hidden_dim, dtype=torch.float32, device=device), mat_b
+ )
+
+ # unsupported (hidden_dim, num_experts) pair
+ with pytest.raises(Exception, match="supported"):
+ fp32_router_gemm(
+ torch.randn(4, 1024, dtype=torch.float32, device=device),
+ torch.randn(64, 1024, dtype=torch.float32, device=device),
+ )
+
+ # non-contiguous activation (a column-slice view)
+ wide = torch.randn(4, hidden_dim * 2, dtype=torch.float32, device=device)
+ with pytest.raises(Exception, match="contiguous"):
+ fp32_router_gemm(wide[:, :hidden_dim], mat_b)
+
+ # wrong weight dtype (bf16 weight is not a supported layout)
+ with pytest.raises(Exception, match="float32"):
+ fp32_router_gemm(
+ torch.randn(4, hidden_dim, dtype=torch.float32, device=device),
+ mat_b.to(torch.bfloat16),
+ )
+
+ # fp16 activation (only fp32 / bf16 are accepted)
+ with pytest.raises(Exception, match="float32 or bfloat16"):
+ fp32_router_gemm(
+ torch.randn(4, hidden_dim, dtype=torch.float16, device=device), mat_b
+ )
diff --git a/vllm/model_executor/layers/fused_moe/router/gate_linear.py b/vllm/model_executor/layers/fused_moe/router/gate_linear.py
index b11464b19d66..37e7018a867c 100644
--- a/vllm/model_executor/layers/fused_moe/router/gate_linear.py
+++ b/vllm/model_executor/layers/fused_moe/router/gate_linear.py
@@ -15,8 +15,8 @@ class GateLinear(ReplicatedLinear):
"""MoE gate linear layer with multi-tier GEMM dispatch:
1. DSV3 specialized kernel (SM90+, M<=16, H=7168 E=256/384, H=6144 E=256)
- 2. fp32 specialized kernel (SM90+, bf16/fp32 in, fp32 out,
- M<=32, H=3072, E=256)
+ 2. fp32 specialized kernel (SM90+, bf16/fp32 in, fp32 out, M<=32,
+ (H, E) in {(3072, 256), (6144, 128), (6144, 256)})
3. cuBLAS bf16×bf16→fp32 (SM90+ + bf16 weight + fp32 out_dtype)
4. F.linear via ReplicatedLinear (ultimate fallback)
@@ -36,7 +36,7 @@ class GateLinear(ReplicatedLinear):
# (hidden_size, num_experts) pairs with an instantiated fp32 kernel:
# (3072, 256) -> MiniMax-M2/M2.5, (6144, 128) -> MiniMax-M3
- FP32_SUPPORTED_SHAPES = {(3072, 256), (6144, 128)}
+ FP32_SUPPORTED_SHAPES = {(3072, 256), (6144, 128), (6144, 256)}
FP32_MAX_TOKENS = 32
def __init__(
From 76fedaa2a5382e4b76d236253da55b0646b1459c Mon Sep 17 00:00:00 2001
From: Yejing Lai
Date: Sat, 11 Jul 2026 21:56:55 +0800
Subject: [PATCH 0043/1526] [XPU][UT]Fix InternS1ProForConditionalGeneration
AssertionError (#48232)
Signed-off-by: Lai, Yejing
---
tests/models/utils.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/tests/models/utils.py b/tests/models/utils.py
index f1018efe8a08..86fd80cd62a0 100644
--- a/tests/models/utils.py
+++ b/tests/models/utils.py
@@ -468,6 +468,9 @@ def dummy_hf_overrides(
# Kimi uses `num_expert_group` instead of `n_group`.
if n_group is None:
n_group = getattr(text_config, "num_expert_group", None)
+ # InternS1Pro uses `router_n_groups` instead of `n_group`.
+ if n_group is None:
+ n_group = getattr(text_config, "router_n_groups", None)
num_experts = n_group * 2 if n_group is not None else 2
# we use three layers for Gemma-3n to check
@@ -515,6 +518,9 @@ class DummyConfig:
"EagleLlama4ForCausalLM",
):
num_experts_per_tok = 1
+ elif model_arch == "InternS1ProForConditionalGeneration":
+ assert n_group is not None
+ num_experts_per_tok = n_group
update_dict.update(
{
"num_experts": num_experts,
From 51878e5b6e4cf27352c427b8508b4f338ba39c9c Mon Sep 17 00:00:00 2001
From: Lucas Wilkinson
Date: Sat, 11 Jul 2026 11:11:16 -0400
Subject: [PATCH 0044/1526] [2/N][KV-Cache Layout Refactor] Pack K/V into the
content dim across attention backends (#44455)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: Lucas Wilkinson
Signed-off-by: Matthew Bonanni
Signed-off-by: Lucas Wilkinson
Co-authored-by: Claude
Co-authored-by: OpenAI Codex
Co-authored-by: Matthew Bonanni
Co-authored-by: Nicolò Lucchesi
---
...minimax_m3_qknorm_rope_kv_insert_kernel.cu | 57 ++++---
tests/compile/passes/test_fusion_attn.py | 9 +-
tests/kernels/attention/test_cache.py | 8 +-
.../test_flashinfer_trtllm_attention.py | 30 ++--
tests/kernels/attention/test_minimax_m3.py | 77 +++++-----
tests/v1/attention/test_attention_backends.py | 22 ++-
.../test_trtllm_attention_integration.py | 82 +++++-----
.../unit/test_mooncake_connector.py | 104 +++++--------
.../kv_connector/unit/test_nixl_connector.py | 79 ++++++++--
.../unit/test_transfer_topology_sharded.py | 4 +-
tests/v1/worker/test_gpu_model_runner.py | 12 +-
.../kv_transfer/kv_connector/utils.py | 69 ++++-----
.../kv_connector/v1/moriio/moriio_layout.py | 30 ++++
.../kv_connector/v1/nixl/base_worker.py | 124 +++++-----------
.../minimax_m3/common/ops/sparse_attn.py | 35 ++---
.../minimax_m3/common/sparse_attention.py | 13 +-
.../minimax_m3/nvidia/sparse_attention_msa.py | 3 +-
vllm/platforms/rocm.py | 18 ++-
vllm/utils/torch_utils.py | 56 ++-----
vllm/v1/attention/backend.py | 12 +-
vllm/v1/attention/backends/flash_attn.py | 31 ++--
.../attention/backends/flash_attn_diffkv.py | 42 +++---
vllm/v1/attention/backends/flashinfer.py | 140 ++++++++++++------
vllm/v1/attention/backends/flex_attention.py | 18 ++-
vllm/v1/attention/backends/rocm_aiter_fa.py | 12 +-
.../backends/rocm_aiter_unified_attn.py | 9 +-
vllm/v1/attention/backends/triton_attn.py | 102 +++++++------
.../attention/backends/triton_attn_diffkv.py | 45 +++---
vllm/v1/attention/backends/turboquant_attn.py | 12 +-
.../ops/triton_reshape_and_cache_flash.py | 9 +-
.../attention/ops/triton_turboquant_store.py | 4 +-
31 files changed, 664 insertions(+), 604 deletions(-)
diff --git a/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu b/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
index 5429ab12d82c..a2162a03b835 100644
--- a/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
+++ b/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
@@ -288,16 +288,15 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
int64_t const* __restrict__ positions, // [N] i64
int64_t const* __restrict__ slot_mapping, // main K/V slots or nullptr
int64_t const* __restrict__ index_slot_mapping, // index K slots/nullptr
- cache_t* __restrict__ kv_cache, // [nb,2,bs,nkv,128] or nullptr
+ cache_t* __restrict__ kv_cache, // [nb,nkv,bs,2*128] or nullptr
out_idx_t* __restrict__ index_cache, // [nb*bs, 128]; scalar_t or e4m3 byte
float const eps, int const rotary_dim, int const num_tokens, int const nq,
int const nkv, int const niq, int const block_size,
- // kv_cache strides (in elements) for logical shape [nb, 2, bs, nkv, 128].
- // The head_dim (last) dim is always innermost-contiguous (stride 1), so the
- // NHD/HND layout choice is fully captured by these four strides: NHD keeps
- // s_token < s_head, HND swaps them. dim_base addresses head_dim directly.
- int64_t const kv_s_block, int64_t const kv_s_kv, int64_t const kv_s_token,
- int64_t const kv_s_head) {
+ // kv_cache strides (in elements) for logical shape [nb, nkv, bs, 2*128].
+ // The content (last) dim is always innermost-contiguous (stride 1), so the
+ // NHD/HND layout choice is captured by the head/token strides.
+ int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
+ int64_t const kv_s_dim) {
#if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM)
// _typeConvert is unavailable on pre-Ampere; the M3 kernel only
// runs with bf16/fp16 inputs in practice. Discard the bf16 body there.
@@ -438,16 +437,16 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
storeElems(index_cache + sm * kHeadDim + dim_base, elems);
}
} else if (isK || isV) {
- // kv_cache logical shape [num_blocks, 2, block_size, nkv, head_dim].
+ // kv_cache logical shape [num_blocks, nkv, block_size, 2*head_dim].
// Paging is logical (block = sm/block_size, token = sm%block_size);
// the physical NHD/HND layout is honoured via the passed strides.
int64_t const b = sm / block_size;
int64_t const t = sm % block_size;
int const kv = isK ? 0 : 1;
- int64_t const off =
- b * kv_s_block + kv * kv_s_kv + t * kv_s_token + head * kv_s_head;
- storeCacheElems(kv_cache + off + dim_base,
- elems);
+ int64_t const off = b * kv_s_block + head * kv_s_head +
+ t * kv_s_token +
+ (kv * kHeadDim + dim_base) * kv_s_dim;
+ storeCacheElems(kv_cache + off, elems);
}
}
}
@@ -474,8 +473,8 @@ void launchFusedMiniMaxM3(
int64_t const* index_slot_mapping, cache_t* kv_cache, void* index_cache,
float const eps, int const rotary_dim, int const num_tokens, int const nq,
int const nkv, int const niq, int const block_size,
- int64_t const kv_s_block, int64_t const kv_s_kv, int64_t const kv_s_token,
- int64_t const kv_s_head, bool const has_index, bool const insert_kv,
+ int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
+ int64_t const kv_s_dim, bool const has_index, bool const insert_kv,
bool const fp8_idx, cudaStream_t stream) {
// Index outputs are scalar_t (bf16) or e4m3 bytes (uint8_t); reinterpret the
// void* pointers per instantiation in the LAUNCH macro.
@@ -517,20 +516,20 @@ void launchFusedMiniMaxM3(
iq_norm_w, ik_norm_w, cos_sin_cache, positions, slot_mapping, \
index_slot_mapping, kv_cache, reinterpret_cast(index_cache), \
eps, rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
- kv_s_kv, kv_s_token, kv_s_head)
+ kv_s_head, kv_s_token, kv_s_dim)
#else
// ROCm: standard kernel launch syntax (no PDL/stream serialization).
// clang-format off
#define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \
- fusedMiniMaxM3QNormRopeKVInsertKernel \
<<>>( \
qkv, q_out, reinterpret_cast(index_q_out), q_norm_w, \
k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
slot_mapping, index_slot_mapping, kv_cache, \
reinterpret_cast(index_cache), eps, rotary_dim, \
- num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_kv, \
- kv_s_token, kv_s_head)
+ num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_head, \
+ kv_s_token, kv_s_dim)
// clang-format on
#endif
@@ -584,8 +583,8 @@ void launchFusedMiniMaxM3(
? reinterpret_cast(index_cache->data_ptr()) \
: nullptr, \
static_cast(eps), static_cast(rotary_dim), num_tokens, nq, \
- nkv, niq, static_cast(block_size), kv_s_block, kv_s_kv, kv_s_token, \
- kv_s_head, has_index, insert_kv, fp8_idx, stream)
+ nkv, niq, static_cast(block_size), kv_s_block, kv_s_head, \
+ kv_s_token, kv_s_dim, has_index, insert_kv, fp8_idx, stream)
// ────────────────────────────────────────────────────────────────────────────
// Torch op wrapper
@@ -602,7 +601,7 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
int64_t num_index_heads, // niq; 0 => dense
std::optional slot_mapping, // [N] i64
std::optional index_slot_mapping, // [N] i64
- std::optional kv_cache, // [nb,2,bs,nkv,128]
+ std::optional kv_cache, // [nb,nkv,bs,2*128]
std::optional index_cache, // [nb,bs,128]
int64_t block_size,
std::optional q_out, // [N, nq*128] contiguous
@@ -673,11 +672,11 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
index_k_norm_weight->numel() == kHeadDim,
"index norm weights must have 128 elements");
}
- // kv_cache strides (logical shape [nb, 2, bs, nkv, head_dim]). Read straight
+ // kv_cache strides (logical shape [nb, nkv, bs, 2*head_dim]). Read straight
// off the tensor so the kernel honours whatever physical layout the attention
- // backend allocated (NHD: stride order (0,1,2,3,4); HND: (0,1,3,2,4)). No new
+ // backend allocated (NHD: stride order (0,2,1,3); HND: (0,1,2,3)). No new
// op argument is needed -- the strides ride along with the tensor itself.
- int64_t kv_s_block = 0, kv_s_kv = 0, kv_s_token = 0, kv_s_head = 0;
+ int64_t kv_s_block = 0, kv_s_head = 0, kv_s_token = 0, kv_s_dim = 0;
torch::stable::Tensor const* effective_index_slot_mapping = nullptr;
if (insert_kv) {
STD_TORCH_CHECK(
@@ -707,13 +706,13 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
index_cache->scalar_type() ==
torch::headeronly::ScalarType::Float8_e4m3fn),
"insert mode requires index_cache matching qkv dtype or fp8 e4m3");
- STD_TORCH_CHECK(kv_cache->dim() == 5 && kv_cache->stride(4) == 1,
- "kv_cache must be [nb,2,bs,nkv,head_dim] with contiguous "
- "head_dim (stride(4)==1)");
+ STD_TORCH_CHECK(kv_cache->dim() == 4 && kv_cache->stride(3) == 1,
+ "kv_cache must be [nb,nkv,bs,2*head_dim] with contiguous "
+ "content dim (stride(3)==1)");
kv_s_block = kv_cache->stride(0);
- kv_s_kv = kv_cache->stride(1);
+ kv_s_head = kv_cache->stride(1);
kv_s_token = kv_cache->stride(2);
- kv_s_head = kv_cache->stride(3);
+ kv_s_dim = kv_cache->stride(3);
effective_index_slot_mapping = index_slot_mapping.has_value()
? &index_slot_mapping.value()
: &slot_mapping.value();
diff --git a/tests/compile/passes/test_fusion_attn.py b/tests/compile/passes/test_fusion_attn.py
index b776f6af98a1..76536f3387c5 100644
--- a/tests/compile/passes/test_fusion_attn.py
+++ b/tests/compile/passes/test_fusion_attn.py
@@ -111,7 +111,11 @@ def build_attn_metadata(self, batch_size: int) -> AttentionMetadata:
# Fetch the attention backend and kv cache shape and stride order
attn_backend = self.attn.attn_backend
kv_cache_shape = attn_backend.get_kv_cache_shape(
- num_blocks, self.block_size, self.num_kv_heads, self.head_size
+ num_blocks,
+ self.block_size,
+ self.num_kv_heads,
+ self.head_size,
+ cache_dtype_str=self.attn.kv_cache_dtype,
)
try:
kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
@@ -125,11 +129,10 @@ def build_attn_metadata(self, batch_size: int) -> AttentionMetadata:
# Create dummy KV cache
raw_tensor = torch.zeros(
- 2 * num_blocks * self.block_size * self.num_kv_heads * self.head_size,
+ kv_cache_shape,
dtype=self.attn.kv_cache_torch_dtype,
device=self.device,
)
- raw_tensor = raw_tensor.view(kv_cache_shape)
kv_cache = raw_tensor.permute(*inv_order)
self.attn.kv_cache = kv_cache
diff --git a/tests/kernels/attention/test_cache.py b/tests/kernels/attention/test_cache.py
index 7558da1c6008..6fe355f945d0 100644
--- a/tests/kernels/attention/test_cache.py
+++ b/tests/kernels/attention/test_cache.py
@@ -10,7 +10,7 @@
from vllm import _custom_ops as ops
from vllm.model_executor.layers.quantization.utils.quant_utils import scaled_dequantize
from vllm.platforms import current_platform
-from vllm.utils.torch_utils import nvfp4_kv_cache_split_views, set_random_seed
+from vllm.utils.torch_utils import nvfp4_split_data_scale, set_random_seed
COPYING_DIRECTION = [("cuda", "cpu"), ("cuda", "cuda"), ("cpu", "cuda")]
DTYPES = [torch.bfloat16, torch.float]
@@ -255,10 +255,8 @@ def test_reshape_and_cache_flash(
nvfp4_key_data = None
nvfp4_value_data = None
if kv_cache_dtype == "nvfp4":
- (nvfp4_key_data,), (key_scale_cache,) = nvfp4_kv_cache_split_views(key_cache)
- (nvfp4_value_data,), (value_scale_cache,) = nvfp4_kv_cache_split_views(
- value_cache
- )
+ nvfp4_key_data, key_scale_cache = nvfp4_split_data_scale(key_cache)
+ nvfp4_value_data, value_scale_cache = nvfp4_split_data_scale(value_cache)
if kv_cache_dtype == "nvfp4":
# Global scale = amax / 448 (per-tensor)
diff --git a/tests/kernels/attention/test_flashinfer_trtllm_attention.py b/tests/kernels/attention/test_flashinfer_trtllm_attention.py
index 87a12c2ff395..20c34e589f08 100644
--- a/tests/kernels/attention/test_flashinfer_trtllm_attention.py
+++ b/tests/kernels/attention/test_flashinfer_trtllm_attention.py
@@ -13,7 +13,7 @@
from vllm.utils.math_utils import round_up
from vllm.utils.torch_utils import (
nvfp4_kv_cache_full_dim,
- nvfp4_kv_cache_split_views,
+ nvfp4_split_data_scale,
set_random_seed,
)
@@ -74,17 +74,18 @@ def make_nvfp4_kv_cache(
kv_scale_val, dtype=torch.float32, device=kv_bf16_hnd.device
)
- # Allocate in HND physical order, permute to NHD logical order.
- # hnd_order swaps dims 2↔3; it is its own inverse.
+ # layout: (B, 2*H, N, full_dim)
+ # where K heads occupy the first H heads and V heads occupy the second H heads.
full_dim = nvfp4_kv_cache_full_dim(head_size)
- hnd_order = (0, 1, 3, 2, 4)
- kv_cache = torch.zeros(
- (num_blocks, 2, num_kv_heads, block_size, full_dim),
+ kv_cache_hnd = torch.zeros(
+ (num_blocks, 2 * num_kv_heads, block_size, full_dim),
dtype=torch.uint8,
device=kv_bf16_hnd.device,
- ).permute(*hnd_order)
+ )
+ kv_cache_nhd = kv_cache_hnd.permute(0, 2, 1, 3)
+ k_view_nhd, v_view_nhd = kv_cache_nhd.split(num_kv_heads, dim=-2)
- # Flatten NHD [N, T, H, D] → token tensors [N*T, H, D] for the kernel.
+ # Flatten input KV → token tensors [B*N, H, head_size] for the kernel.
num_tokens = num_blocks * block_size
k_tokens = (
kv_bf16_hnd[:, 0]
@@ -98,22 +99,21 @@ def make_nvfp4_kv_cache(
)
slot_mapping = torch.arange(num_tokens, dtype=torch.long, device=kv_bf16_hnd.device)
- # reshape_and_cache_flash: kernel receives kv_cache[:, 0] and [:, 1]
- # (full K/V buffers containing both data and scale).
torch.ops._C_cache_ops.reshape_and_cache_flash(
k_tokens,
v_tokens,
- kv_cache[:, 0],
- kv_cache[:, 1],
+ k_view_nhd,
+ v_view_nhd,
slot_mapping,
"nvfp4",
kv_scale_tensor,
kv_scale_tensor,
)
- # Split in HND order for trtllm kernel (expects HND numTokensPerPage).
- kv_cache_hnd = kv_cache.permute(*hnd_order)
- (k_data, v_data), (k_scales, v_scales) = nvfp4_kv_cache_split_views(kv_cache_hnd)
+ # Split into data/scale views in HNC order for trtllm kernel.
+ k_cache_hnc, v_cache_hnc = kv_cache_hnd.split(num_kv_heads, dim=1)
+ k_data, k_scales = nvfp4_split_data_scale(k_cache_hnc)
+ v_data, v_scales = nvfp4_split_data_scale(v_cache_hnc)
# Dequantize for the FA2 reference baseline.
ref_k = dequant_nvfp4_kv_cache(
diff --git a/tests/kernels/attention/test_minimax_m3.py b/tests/kernels/attention/test_minimax_m3.py
index 01405a0e5d12..6251f423f1e1 100644
--- a/tests/kernels/attention/test_minimax_m3.py
+++ b/tests/kernels/attention/test_minimax_m3.py
@@ -662,8 +662,9 @@ def _reference_sparse_attn(
positions = torch.arange(seq_len, device="cuda")
pages = block_table[req_id, positions // BLOCK_SIZE]
rows = positions % BLOCK_SIZE
- k_req = kv_cache[pages, 0, rows]
- v_req = kv_cache[pages, 1, rows].float()
+ kv_req = kv_cache[pages, :, rows]
+ k_req = kv_req[..., :HEAD_DIM]
+ v_req = kv_req[..., HEAD_DIM:].float()
q_pos = prefix_len + torch.arange(q_len, device="cuda")
key_blocks = positions // BLOCK_SIZE
@@ -791,15 +792,15 @@ def test_main_backend_layout_contract():
flash_attn-style stride order for each layout."""
nb, bs, h, d = 7, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
- assert logical == (nb, 2, bs, h, d)
- # The old HND-ordered shape is no longer the logical shape.
- assert logical != (nb, 2, h, bs, d)
+ assert logical == (nb, h, bs, 2 * d)
+ # The old separate K/V-axis shape is no longer the logical shape.
+ assert logical != (nb, 2, bs, h, d)
try:
set_kv_cache_layout("HND")
- assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 3, 2, 4)
+ assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 2, 3)
set_kv_cache_layout("NHD")
- assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 2, 3, 4)
+ assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 2, 1, 3)
finally:
set_kv_cache_layout(None)
@@ -829,7 +830,7 @@ def test_main_backend_unknown_layout_raises(monkeypatch):
def test_indexer_backend_stride_order_is_identity():
- """The 3-dim indexer cache must not inherit the parent's 5-element stride
+ """The 3-dim indexer cache must not inherit the parent's 4-element stride
order; it overrides to the 3-element identity so the allocator keeps the
contiguous layout."""
assert MiniMaxM3IndexerBackend.get_kv_cache_stride_order() == (0, 1, 2)
@@ -848,9 +849,9 @@ def test_indexer_backend_stride_order_is_identity():
assert _stride_order_for(MiniMaxM3IndexerBackend, len(indexer_shape)) == (0, 1, 2)
-def test_hnd_allocation_is_byte_identical_to_transpose():
- """Under HND the backend-visible logical view is byte-identical to the
- pre-change allocate-HND-then-transpose(2, 3) workaround."""
+def test_hnd_allocation_is_packed_head_major():
+ """Under HND the backend-visible logical view is the packed head-major
+ physical allocation."""
nb, bs, h, d = 4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
try:
@@ -860,21 +861,22 @@ def test_hnd_allocation_is_byte_identical_to_transpose():
set_kv_cache_layout(None)
physical_shape = tuple(logical[i] for i in stride_order)
- # The physical (permuted) shape equals the old hardcoded HND shape.
- assert physical_shape == (nb, 2, h, bs, d)
+ assert physical_shape == (nb, h, bs, 2 * d)
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
raw = torch.empty(physical_shape, device="cuda", dtype=DTYPE)
view = raw.permute(*inv_order)
- expected = raw.view((nb, 2, h, bs, d)).transpose(2, 3)
+ expected = raw.view((nb, h, bs, 2 * d))
assert view.shape == expected.shape
assert view.stride() == expected.stride()
assert view.storage_offset() == expected.storage_offset()
- # Negative: the identity (wrong) stride order under HND does not reproduce
- # the transpose view.
- wrong_view = raw.view(logical)
+ # Negative: the NHD stride order under HND does not reproduce the
+ # head-major view.
+ wrong_order = (0, 2, 1, 3)
+ wrong_inv = [wrong_order.index(i) for i in range(len(wrong_order))]
+ wrong_view = raw.view(tuple(logical[i] for i in wrong_order)).permute(*wrong_inv)
assert wrong_view.stride() != expected.stride()
@@ -1037,14 +1039,18 @@ def test_decode_wrong_layout_breaks_parity():
seq_lens_list = (130, 257)
q, block_table, seq_lens, topk_idx, num_pages = _build_decode_inputs(seq_lens_list)
- # Physical HND storage [blocks, 2, heads, block, dim].
+ # Physical HND storage [blocks, heads, block, packed_kv_dim].
phys = torch.randn(
- (num_pages, 2, NUM_KV_HEADS, BLOCK_SIZE, HEAD_DIM), device="cuda", dtype=DTYPE
+ (num_pages, NUM_KV_HEADS, BLOCK_SIZE, 2 * HEAD_DIM),
+ device="cuda",
+ dtype=DTYPE,
+ )
+ # Correct logical packed-HND view vs. the same bytes mislabeled as NHD
+ # physical storage and then exposed as a logical cache.
+ correct = phys
+ wrong = phys.reshape(num_pages, BLOCK_SIZE, NUM_KV_HEADS, 2 * HEAD_DIM).permute(
+ 0, 2, 1, 3
)
- # Correct logical-NHD view (strided) vs. the same bytes mislabeled as a
- # contiguous-NHD cache — same shape, different content mapping.
- correct = phys.permute(0, 1, 3, 2, 4)
- wrong = phys.reshape(num_pages, 2, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM)
q_lens_t = torch.ones(len(seq_lens_list), device="cuda", dtype=torch.int32)
prefix_lens = seq_lens - q_lens_t
@@ -1072,9 +1078,9 @@ def _make_attn_group(backend, spec):
def test_main_cache_byte_identical_through_production_allocator():
"""AC-2: drive the real allocator (`_reshape_kv_cache`) for the M3 main
`FullAttentionSpec` under HND and assert the backend-visible view has the
- same shape, stride, and storage offset as the pre-change
- allocate-HND-then-transpose path; the indexer `MLAAttentionSpec` allocates
- through the same path to its 3-dim shape."""
+ same shape, stride, and storage offset as the packed-HND allocation; the
+ indexer `MLAAttentionSpec` allocates through the same path to its 3-dim
+ shape."""
nb = 4
spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
@@ -1092,8 +1098,7 @@ def test_main_cache_byte_identical_through_production_allocator():
set_kv_cache_layout(None)
view = kv_caches["main"]
- oracle = raw.view(DTYPE).view((nb, 2, NUM_KV_HEADS, BLOCK_SIZE, HEAD_DIM))
- oracle = oracle.transpose(2, 3)
+ oracle = raw.view(DTYPE).view((nb, NUM_KV_HEADS, BLOCK_SIZE, 2 * HEAD_DIM))
assert tuple(view.shape) == tuple(oracle.shape)
assert view.stride() == oracle.stride()
assert view.storage_offset() == oracle.storage_offset()
@@ -1119,13 +1124,13 @@ def test_main_cache_byte_identical_through_production_allocator():
def test_indexer_inherited_stride_order_trips_allocator_assert():
- """AC-4 negative: without the indexer override, the inherited 5-element
+ """AC-4 negative: without the indexer override, the inherited 4-element
stride order trips the allocator's `len(stride_order) == len(shape)` assert
for the 3-dim indexer shape; the `AssertionError` is NOT swallowed by the
allocator's `(AttributeError, NotImplementedError)` fallback."""
class _BrokenIndexerBackend(MiniMaxM3IndexerBackend):
- # Simulate inheriting the parent's 5-element stride order.
+ # Simulate inheriting the parent's 4-element stride order.
get_kv_cache_stride_order = staticmethod(
MiniMaxM3SparseBackend.get_kv_cache_stride_order
)
@@ -1192,10 +1197,8 @@ def _require_unpadded_block_first(spec, stride_order):
@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
def test_reshape_and_cache_flash_write_persists(kv_layout: str):
"""AC-5 write path: the `reshape_and_cache_flash` write site now consumes
- `self.kv_cache.unbind(1)` directly. Writing through those views must persist
- into the bound storage (read back through an independent logical view) under
- both layouts — a `.contiguous()` copy of the unbind slice would leave the
- bound storage unchanged."""
+ packed-content K/V split views. Writing through those views must persist
+ into the bound storage under both layouts."""
torch.manual_seed(0)
num_pages = 4
kv_cache = _allocate_main_kv_via_contract(num_pages)
@@ -1203,7 +1206,7 @@ def test_reshape_and_cache_flash_write_persists(kv_layout: str):
kv_cache.zero_()
# Exactly the production write-site code under test.
- key_cache, value_cache = kv_cache.unbind(1)
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(HEAD_DIM, dim=-1)
num_tokens = 12
slot_mapping = torch.randperm(num_pages * BLOCK_SIZE, device="cuda")[
@@ -1222,5 +1225,5 @@ def test_reshape_and_cache_flash_write_persists(kv_layout: str):
for t in range(num_tokens):
slot = int(slot_mapping[t].item())
blk, intra = divmod(slot, BLOCK_SIZE)
- torch.testing.assert_close(kv_cache[blk, 0, intra], key[t])
- torch.testing.assert_close(kv_cache[blk, 1, intra], value[t])
+ torch.testing.assert_close(kv_cache[blk, :, intra, :HEAD_DIM], key[t])
+ torch.testing.assert_close(kv_cache[blk, :, intra, HEAD_DIM:], value[t])
diff --git a/tests/v1/attention/test_attention_backends.py b/tests/v1/attention/test_attention_backends.py
index d630037e9bbd..abbdeac1ef8c 100644
--- a/tests/v1/attention/test_attention_backends.py
+++ b/tests/v1/attention/test_attention_backends.py
@@ -140,12 +140,10 @@ def create_and_prepopulate_kv_cache(
block_table = common_attn_metadata.block_table_tensor
slot_mapping = common_attn_metadata.slot_mapping
- # Create KV cache and populate in (2, num_blocks, ...) layout for easy
- # flat indexing, then transpose to (num_blocks, 2, ...) layout.
kv_cache = torch.zeros(
- 2, num_blocks, block_size, num_kv_heads, head_size, dtype=dtype, device=device
+ num_blocks, block_size, num_kv_heads, 2 * head_size, dtype=dtype, device=device
)
- kv_cache_flat = kv_cache.view(2, -1, num_kv_heads, head_size)
+ kv_cache_flat = kv_cache.view(-1, num_kv_heads, 2 * head_size)
# Populate the cache with the context tokens
# Start from block_id=1 since block_id=0 is considered the null block
@@ -154,15 +152,12 @@ def create_and_prepopulate_kv_cache(
k_context, v_context = k_contexts[i], v_contexts[i]
start = start_block_idx * block_size
end = start + k_context.shape[0]
- kv_cache_flat[0, start:end, ...] = k_context
- kv_cache_flat[1, start:end, ...] = v_context
+ kv_cache_flat[start:end, :, :head_size] = k_context
+ kv_cache_flat[start:end, :, head_size:] = v_context
# Stay block aligned and allocate enough blocks for the new tokens
start_block_idx += cdiv(int(seq_lens[i]), block_size)
- # Transpose to (num_blocks, 2, ...) layout
- kv_cache = kv_cache.transpose(0, 1).contiguous()
-
blocks_end = start_block_idx
# Permute the context blocks (excluding block 0 which is null)
@@ -199,7 +194,8 @@ def create_and_prepopulate_kv_cache(
i, block_indices
] * block_size + token_inter_block_offsets.to(device)
- return kv_cache
+ # Transpose to logical (num_blocks, num_kv_heads, block_size, 2*hs)
+ return kv_cache.transpose(1, 2).contiguous()
class MockAttentionLayer:
@@ -496,9 +492,6 @@ def _test_backend_correctness(
set_kv_cache_layout("HND")
reset_kv_cache_layout = True
- # Apply stride order like runtime does in
- # _reshape_kv_cache (attn_utils.py:182-210): permute to physical
- # layout, make contiguous, then permute to logical layout.
kv_cache_for_backend = kv_cache
if backend_cls is not None:
try:
@@ -506,6 +499,9 @@ def _test_backend_correctness(
except (AttributeError, NotImplementedError):
stride_order = tuple(range(kv_cache.ndim))
if stride_order != tuple(range(kv_cache.ndim)):
+ # Apply stride order like runtime does in
+ # _reshape_kv_cache (attn_utils.py:182-210): permute to physical
+ # layout, make contiguous, then permute to logical layout.
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
kv_cache_for_backend = (
kv_cache.permute(*stride_order).contiguous().permute(*inv_order)
diff --git a/tests/v1/attention/test_trtllm_attention_integration.py b/tests/v1/attention/test_trtllm_attention_integration.py
index 0fe9a7ecf62e..c8a080e6d1d5 100644
--- a/tests/v1/attention/test_trtllm_attention_integration.py
+++ b/tests/v1/attention/test_trtllm_attention_integration.py
@@ -97,13 +97,13 @@ def _create_hnd_kv_cache(
device,
num_blocks,
common_attn_metadata,
+ kv_in_head_dim=False,
):
- """Create and populate a KV cache with HND-compatible strides.
+ """Create and populate a packed KV cache with HND-compatible strides.
- The returned tensor has logical shape
- (num_blocks, 2, block_size, num_kv_heads, head_size) but is physically
- laid out as (num_blocks, 2, num_kv_heads, block_size, head_size) so that
- ``kv_cache.permute(0, 1, 3, 2, 4)`` yields a contiguous HND view.
+ When kv_in_head_dim=False (default), returns (B, H, N, 2*hs) with K/V
+ packed in the content dim. When kv_in_head_dim=True, returns
+ (B, 2*H, N, hs) with K/V as separate head groups.
"""
seq_lens = common_attn_metadata.seq_lens.cpu()
query_lens = (
@@ -114,26 +114,34 @@ def _create_hnd_kv_cache(
slot_mapping = common_attn_metadata.slot_mapping
batch_size = len(k_contexts)
- # Build cache in (2, num_blocks, block_size, num_kv_heads, head_size)
- # then convert to HND format (same approach as test_attention_backends.py).
- kv_cache_raw = torch.zeros(
- 2,
+ # kv_in_head_dim: (B, N, 2*H, hs) — K/V as separate head groups
+ # else: (B, N, H, 2*hs) — K/V packed in content dim
+ n_heads, content = (
+ (2 * num_kv_heads, head_size)
+ if kv_in_head_dim
+ else (num_kv_heads, 2 * head_size)
+ )
+ kv_cache = torch.zeros(
num_blocks,
block_size,
- num_kv_heads,
- head_size,
+ n_heads,
+ content,
dtype=dtype,
device=device,
)
- kv_cache_flat = kv_cache_raw.view(2, -1, num_kv_heads, head_size)
+ kv_cache_flat = kv_cache.view(-1, n_heads, content)
start_block_idx = 1
for i in range(batch_size):
k_ctx, v_ctx = k_contexts[i], v_contexts[i]
start = start_block_idx * block_size
end = start + k_ctx.shape[0]
- kv_cache_flat[0, start:end] = k_ctx
- kv_cache_flat[1, start:end] = v_ctx
+ if kv_in_head_dim:
+ kv_cache_flat[start:end, :num_kv_heads] = k_ctx
+ kv_cache_flat[start:end, num_kv_heads:] = v_ctx
+ else:
+ kv_cache_flat[start:end, :, :head_size] = k_ctx
+ kv_cache_flat[start:end, :, head_size:] = v_ctx
start_block_idx += cdiv(int(seq_lens[i]), block_size)
blocks_end = start_block_idx
@@ -142,7 +150,7 @@ def _create_hnd_kv_cache(
perm = torch.randperm(blocks_end - 1) + 1
inv_perm = torch.zeros(blocks_end, dtype=torch.long, device=device)
inv_perm[1:] = torch.argsort(perm) + 1
- kv_cache_raw[:, 1:blocks_end] = kv_cache_raw[:, perm]
+ kv_cache[1:blocks_end] = kv_cache[perm]
# Build block table.
start_block_idx = 1
@@ -165,10 +173,8 @@ def _create_hnd_kv_cache(
i, block_indices
] * block_size + intra_block_offsets.to(device)
- # Transpose to FlashInfer logical shape then make HND-strided.
- kv_cache = kv_cache_raw.transpose(0, 1)
- kv_cache = kv_cache.transpose(2, 3).contiguous().transpose(2, 3)
- return kv_cache
+ # Transpose to canonical: (B, H, N, 2*hs) or (B, 2*H, N, hs)
+ return kv_cache.transpose(1, 2).contiguous()
def _create_nvfp4_hnd_kv_cache(
@@ -187,20 +193,15 @@ def _create_nvfp4_hnd_kv_cache(
reshape_and_cache_flash, using the same block-table layout as
_create_hnd_kv_cache.
- The returned tensor is dtype ``uint8`` with shape
- ``(num_blocks, 2, block_size, num_kv_heads, full_dim)`` in logical
- (NHD) order, but physically permuted to HND layout via stride order
- ``(0, 1, 3, 2, 4)`` (i.e. ``num_kv_heads`` before ``block_size``).
-
- The last dimension ``full_dim = head_size // 2 + head_size // 16``
- packs two regions contiguously:
+ The returned tensor is dtype ``uint8`` with head-group layout
+ ``(num_blocks, 2 * num_kv_heads, block_size, full_dim)``
+ where K heads occupy the first ``num_kv_heads`` heads and V heads the second.
+ Each ``full_dim = head_size // 2 + head_size // 16`` block packs two regions:
- **FP4 data** (``head_size // 2`` bytes): pairs of E2M1 values,
two per byte.
- **FP8 block scales** (``head_size // 16`` bytes): one E4M3
scale per 16-element block.
- Dimension 1 indexes K (``[:, 0]``) and V (``[:, 1]``).
-
Args:
k_contexts: List of key context tensors, one per sequence.
v_contexts: List of value context tensors, one per sequence.
@@ -219,6 +220,7 @@ def _create_nvfp4_hnd_kv_cache(
``torch.Tensor``: The nvfp4 kv_cache tensor (uint8, HND-strided).
"""
# First create a bf16 HND cache so block tables are populated.
+ # Use kv_in_head_dim=True so K/V are separate head groups (B, 2*H, N, hs).
bf16_cache = _create_hnd_kv_cache(
k_contexts,
v_contexts,
@@ -229,20 +231,20 @@ def _create_nvfp4_hnd_kv_cache(
device,
num_blocks,
common_attn_metadata,
+ kv_in_head_dim=True,
)
- # Allocate nvfp4 cache: same shape but with full_dim (data + scale).
+ # (num_blocks, 2 * num_kv_heads, block_size, full_dim) — K heads first, then V heads
full_dim = nvfp4_kv_cache_full_dim(head_size)
- hnd_order = (0, 1, 3, 2, 4)
nvfp4_cache = torch.zeros(
- (num_blocks, 2, num_kv_heads, block_size, full_dim),
+ (num_blocks, 2 * num_kv_heads, block_size, full_dim),
dtype=torch.uint8,
device=device,
- ).permute(*hnd_order)
+ )
+ k_cache, v_cache = nvfp4_cache.split(num_kv_heads, dim=1)
# Flatten bf16 context into tokens and quantize via reshape_and_cache_flash.
- # bf16_cache is (num_blocks, 2, block_size, num_kv_heads, head_size) logical
- # with HND physical strides.
+ # bf16_cache is (B, 2*H, N, hs); split K/V on head dim.
block_table = common_attn_metadata.block_table_tensor
seq_lens = common_attn_metadata.seq_lens.cpu()
query_lens = (
@@ -258,19 +260,21 @@ def _create_nvfp4_hnd_kv_cache(
# Gather context tokens from the bf16 cache using block table.
n_ctx_blocks = (ctx_len + block_size - 1) // block_size
blocks = block_table[i, :n_ctx_blocks]
- # bf16_cache[:, kv_idx] is (num_blocks, block_size, num_kv_heads, head_size)
- k_ctx = bf16_cache[blocks, 0].reshape(-1, num_kv_heads, head_size)[:ctx_len]
- v_ctx = bf16_cache[blocks, 1].reshape(-1, num_kv_heads, head_size)[:ctx_len]
+ # bf16_cache is (B, 2*H, N, hs); split K and V head groups.
+ k_bf16, v_bf16 = bf16_cache[blocks].split(num_kv_heads, dim=1)
+ k_ctx = k_bf16.transpose(1, 2).reshape(-1, num_kv_heads, head_size)[:ctx_len]
+ v_ctx = v_bf16.transpose(1, 2).reshape(-1, num_kv_heads, head_size)[:ctx_len]
# Build slot mapping for these context tokens.
token_offsets = torch.arange(ctx_len, device=device)
block_indices = token_offsets // block_size
intra_offsets = token_offsets % block_size
slots = block_table[i, block_indices] * block_size + intra_offsets
+ # reshape_and_cache_flash expects (B, N, H, D) cache views.
torch.ops._C_cache_ops.reshape_and_cache_flash(
k_ctx,
v_ctx,
- nvfp4_cache[:, 0],
- nvfp4_cache[:, 1],
+ k_cache.transpose(1, 2),
+ v_cache.transpose(1, 2),
slots,
"nvfp4",
kv_scale_t,
diff --git a/tests/v1/kv_connector/unit/test_mooncake_connector.py b/tests/v1/kv_connector/unit/test_mooncake_connector.py
index a227af909e4a..4847b956b196 100644
--- a/tests/v1/kv_connector/unit/test_mooncake_connector.py
+++ b/tests/v1/kv_connector/unit/test_mooncake_connector.py
@@ -289,10 +289,9 @@ async def test_send_kv_to_decode_aligns_consumer_regions_by_layer_metadata(
prefill_worker = prefill_connector.connector_worker
block_len = 4096
- kv_half = block_len // 2
prefill_worker.kv_caches_base_addr = [0x1000]
prefill_worker.block_len_per_layer = [block_len]
- prefill_worker.kv_block_len_per_layer = [kv_half]
+ prefill_worker.kv_block_len_per_layer = [block_len]
prefill_worker.registered_layer_names = ["model.layers.1.self_attn"]
prefill_worker.registered_layer_indices = [1]
@@ -321,7 +320,7 @@ async def run_in_executor(self, executor, func, *args):
req_blocks={"d-req-layer-align": (transfer_id, [[20]])},
kv_caches_base_addr=[0xA000, 0xB000],
block_lens=[block_len, block_len],
- kv_block_lens=[kv_half, kv_half],
+ kv_block_lens=[block_len, block_len],
registered_layer_names=[
"model.layers.0.self_attn",
"model.layers.1.self_attn",
@@ -338,15 +337,9 @@ async def run_in_executor(self, executor, func, *args):
await prefill_worker.send_kv_to_decode(identity, mock_socket, xfer_meta)
src_ptrs, dst_ptrs, lengths = mock_send_blocks.call_args[0][1:]
- assert src_ptrs == [
- 0x1000 + 10 * block_len,
- 0x1000 + 10 * block_len + kv_half,
- ]
- assert dst_ptrs == [
- 0xB000 + 20 * block_len,
- 0xB000 + 20 * block_len + kv_half,
- ]
- assert lengths == [kv_half, kv_half]
+ assert src_ptrs == [0x1000 + 10 * block_len]
+ assert dst_ptrs == [0xB000 + 20 * block_len]
+ assert lengths == [block_len]
sent_identity, sent_payload = mock_socket.send_multipart.call_args[0][0]
assert sent_identity == identity
@@ -832,9 +825,8 @@ async def test_kv_producer(monkeypatch):
prefill_worker = prefill_connector.connector_worker
prefill_worker.kv_caches_base_addr = [0x1000]
block_len = 4096
- kv_half = block_len // 2
prefill_worker.block_len_per_layer = [block_len]
- prefill_worker.kv_block_len_per_layer = [kv_half]
+ prefill_worker.kv_block_len_per_layer = [block_len]
prefill_worker.registered_layer_names = ["model.layers.0.self_attn"]
prefill_worker.registered_layer_indices = [0]
@@ -862,7 +854,7 @@ async def test_kv_producer(monkeypatch):
req_blocks={"d-req-1": (transfer_id, [[20, 21]])},
kv_caches_base_addr=[0x2000],
block_lens=[block_len],
- kv_block_lens=[kv_half],
+ kv_block_lens=[block_len],
registered_layer_names=["model.layers.0.self_attn"],
registered_layer_indices=[0],
)
@@ -874,24 +866,18 @@ async def test_kv_producer(monkeypatch):
with patch.object(
prefill_worker, "_send_blocks", return_value=0
) as mock_send_blocks:
- # With blocks-first layout, each block is virtually split
- # into K and V halves, producing non-coalesced transfers.
- def expected_split_transfers(src_base, dst_base, src_blocks, dst_blocks):
- """Build expected (src_ptrs, dst_ptrs, lengths) for
- virtual-split K/V transfers."""
- src_ptrs, dst_ptrs, lengths = [], [], []
- for kv_offset in (0, kv_half):
- for sb, db in zip(src_blocks, dst_blocks):
- src_ptrs.append(src_base + sb * block_len + kv_offset)
- dst_ptrs.append(dst_base + db * block_len + kv_offset)
- lengths.append(kv_half)
- return src_ptrs, dst_ptrs, lengths
+
+ def expected_transfers(src_base, dst_base, src_blocks, dst_blocks):
+ n = len(src_blocks)
+ return (
+ [src_base + src_blocks[0] * block_len],
+ [dst_base + dst_blocks[0] * block_len],
+ [n * block_len],
+ )
# Normal case: 2 blocks to 2 blocks
await prefill_worker.send_kv_to_decode(identity, mock_socket, xfer_meta)
- src, dst, lens = expected_split_transfers(
- 0x1000, 0x2000, [10, 11], [20, 21]
- )
+ src, dst, lens = expected_transfers(0x1000, 0x2000, [10, 11], [20, 21])
mock_send_blocks.assert_called_once_with(
"consumer-host:54321",
src,
@@ -923,7 +909,7 @@ def expected_split_transfers(src_base, dst_base, src_blocks, dst_blocks):
# Worker processes the consumer's request
await prefill_worker.send_kv_to_decode(identity, mock_socket, xfer_meta)
# Verify transfer parameters are correct: 11 to 20
- src, dst, lens = expected_split_transfers(0x1000, 0x2000, [11], [20])
+ src, dst, lens = expected_transfers(0x1000, 0x2000, [11], [20])
mock_send_blocks.assert_called_once_with(
"consumer-host:54321",
src,
@@ -1266,7 +1252,7 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
prefill_worker.kv_caches_base_addr = [0x1000]
prefill_worker.block_len_per_layer = [local_block_len]
- prefill_worker.kv_block_len_per_layer = [local_block_len // 2]
+ prefill_worker.kv_block_len_per_layer = [local_block_len]
prefill_worker.registered_layer_names = ["model.layers.0.self_attn"]
prefill_worker.registered_layer_indices = [0]
@@ -1314,7 +1300,7 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
},
kv_caches_base_addr=[0x2000],
block_lens=[remote_block_len],
- kv_block_lens=[remote_block_len // 2],
+ kv_block_lens=[remote_block_len],
registered_layer_names=["model.layers.0.self_attn"],
registered_layer_indices=[0],
)
@@ -1336,47 +1322,31 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
flat_remote = [b for g in remote_block_ids for b in g]
num_blocks = len(flat_local)
- # With blocks-first layout, virtual split halves block
- # lengths and doubles transfer regions (K + V).
- local_kv_block_len = local_block_len // 2
- remote_kv_block_len = remote_block_len // 2
-
- assert len(src_ptrs) == 2 * num_blocks
- assert len(dst_ptrs) == 2 * num_blocks
- assert len(lengths) == 2 * num_blocks
+ assert len(src_ptrs) == num_blocks
+ assert len(dst_ptrs) == num_blocks
+ assert len(lengths) == num_blocks
- # Compute expected offsets using kv_block_len
if d_tp_size <= P_TP_SIZE:
tp_ratio = P_TP_SIZE // d_tp_size
expected_src_off = 0
- expected_dst_off = (P_TP_RANK % tp_ratio) * local_kv_block_len
- expected_xfer_len = local_kv_block_len
+ expected_dst_off = (P_TP_RANK % tp_ratio) * local_block_len
+ expected_xfer_len = local_block_len
else:
ratio_abs = d_tp_size // P_TP_SIZE
- expected_src_off = (d_rank % ratio_abs) * remote_kv_block_len
+ expected_src_off = (d_rank % ratio_abs) * remote_block_len
expected_dst_off = 0
- expected_xfer_len = remote_kv_block_len
-
- # First num_blocks entries are K region,
- # next num_blocks are V region.
- for region_idx in range(2):
- local_region_base = 0x1000 + region_idx * local_kv_block_len
- remote_region_base = 0x2000 + region_idx * remote_kv_block_len
- for blk_idx, (lblk, rblk) in enumerate(
- zip(flat_local, flat_remote)
- ):
- idx = region_idx * num_blocks + blk_idx
- assert src_ptrs[idx] == (
- local_region_base
- + lblk * local_block_len
- + expected_src_off
- )
- assert dst_ptrs[idx] == (
- remote_region_base
- + rblk * remote_block_len
- + expected_dst_off
- )
- assert lengths[idx] == expected_xfer_len
+ expected_xfer_len = remote_block_len
+
+ local_region_base = 0x1000
+ remote_region_base = 0x2000
+ for blk_idx, (lblk, rblk) in enumerate(zip(flat_local, flat_remote)):
+ assert src_ptrs[blk_idx] == (
+ local_region_base + lblk * local_block_len + expected_src_off
+ )
+ assert dst_ptrs[blk_idx] == (
+ remote_region_base + rblk * remote_block_len + expected_dst_off
+ )
+ assert lengths[blk_idx] == expected_xfer_len
# Verify successful response sent back to consumer
mock_socket.send_multipart.assert_called_once()
diff --git a/tests/v1/kv_connector/unit/test_nixl_connector.py b/tests/v1/kv_connector/unit/test_nixl_connector.py
index 49d75e193f8d..0724e7769373 100644
--- a/tests/v1/kv_connector/unit/test_nixl_connector.py
+++ b/tests/v1/kv_connector/unit/test_nixl_connector.py
@@ -1068,6 +1068,65 @@ def test_handshake_succeed_on_kv_cache_layout_mismatch_with_experimental(
# whole block is moved.
worker.add_remote_agent(meta, remote_tp_size=1)
+ @patch(
+ "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
+ FakeNixlWrapper,
+ )
+ def test_hybrid_mamba_attention_remote_descs_use_packed_head_slices(
+ self, default_vllm_config, dist_init
+ ):
+ worker = FakeNixlConnectorWorker(
+ create_vllm_config(), "engine", hand_shake_latency=0
+ )
+
+ remote_block_len = 2048
+ local_block_len = remote_block_len // 2
+ worker.block_len_per_layer = [local_block_len]
+ worker._region_is_mla = [False]
+ worker.num_blocks = 1
+ worker.num_regions = 1
+ worker._has_mamba = True
+ worker._mamba_ssm_size = (128, 256)
+ worker.transfer_topo = TransferTopology(
+ tp_rank=1,
+ tp_size=2,
+ block_size=worker.block_size,
+ engine_id=worker.engine_id,
+ is_mla=False,
+ is_mamba=True,
+ total_num_kv_heads=2,
+ attn_backends=worker.attn_backends,
+ tensor_shape=None,
+ )
+ assert worker.transfer_topo.virtually_split_kv_in_blocks
+
+ plan = MagicMock(
+ source_ranks_per_group=((0,), (0,)),
+ rank_offset_factor=1,
+ )
+ meta = MagicMock(
+ kv_caches_base_addr=[0x1000],
+ device_id=0,
+ num_blocks=1,
+ block_lens=[remote_block_len],
+ )
+
+ assert worker.get_backend_aware_kv_block_len(0, mamba_view=False) == (
+ local_block_len
+ )
+ assert (
+ worker.get_backend_aware_kv_block_len(0, first_split=True, mamba_view=True)
+ == worker._mamba_ssm_size[0]
+ )
+ assert (
+ worker.get_backend_aware_kv_block_len(0, first_split=False, mamba_view=True)
+ == worker._mamba_ssm_size[1]
+ )
+
+ assert worker._build_fa_remote(plan, meta, block_size_ratio=1) == [
+ (0x1000 + local_block_len, local_block_len, 0)
+ ]
+
@patch(
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
FakeNixlWrapper,
@@ -1691,13 +1750,6 @@ def req_id(outputs: list[RequestOutput]) -> str:
reason="Attention backend FLASH_ATTN is not supported on ROCm",
),
),
- pytest.param(
- "ROCM_ATTN",
- marks=pytest.mark.skipif(
- not current_platform.is_rocm(),
- reason="Attention backend ROCM_ATTN is only supported on ROCm",
- ),
- ),
"TRITON_ATTN",
],
)
@@ -1824,8 +1876,7 @@ def test_register_kv_caches(
test_shape = backend_cls.get_kv_cache_shape(
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
)
- is_blocks_first = len(test_shape) == 5 and test_shape[0] == 1
- virtually_split = is_blocks_first and not connector.prefer_cross_layer_blocks
+ is_blocks_first = len(test_shape) == 4 and test_shape[0] == 1
if connector.prefer_cross_layer_blocks:
with set_current_vllm_config(vllm_config):
@@ -1856,7 +1907,7 @@ def test_register_kv_caches(
]
expected_num_entries = 1
- expected_blocks_count = num_blocks * (2 if virtually_split else 1)
+ expected_blocks_count = num_blocks
kv_caches = {"all-layers": cross_layers_kv_cache}
else:
@@ -1885,6 +1936,7 @@ def test_register_kv_caches(
unique_tensor.data_ptr(),
]
expected_num_entries = 2
+ expected_blocks_count = kv_cache_config.num_blocks * 2
else:
expected_tensor_size = (
shared_tensor[0].element_size() * shared_tensor[0].numel()
@@ -1896,7 +1948,7 @@ def test_register_kv_caches(
unique_tensor[1].data_ptr(),
]
expected_num_entries = 4
- expected_blocks_count = kv_cache_config.num_blocks * 4
+ expected_blocks_count = kv_cache_config.num_blocks * 4
# Execute register_kv_caches
connector.register_kv_caches(kv_caches)
@@ -1930,10 +1982,7 @@ def test_register_kv_caches(
else:
num_blocks = kv_cache_config.num_blocks
- if virtually_split:
- expected_block_len = expected_tensor_size // num_blocks // 2
- else:
- expected_block_len = expected_tensor_size // num_blocks
+ expected_block_len = expected_tensor_size // num_blocks
for i, block_entry in enumerate(blocks_data):
block_start_addr, block_len, tp_rank = block_entry
diff --git a/tests/v1/kv_connector/unit/test_transfer_topology_sharded.py b/tests/v1/kv_connector/unit/test_transfer_topology_sharded.py
index ac00bb481284..1fed8ff3b7e2 100644
--- a/tests/v1/kv_connector/unit/test_transfer_topology_sharded.py
+++ b/tests/v1/kv_connector/unit/test_transfer_topology_sharded.py
@@ -18,8 +18,8 @@ def get_kv_cache_shape(
block_size: int,
num_kv_heads: int,
head_size: int,
- ) -> tuple[int, int, int, int, int]:
- return (2, num_blocks, num_kv_heads, block_size, head_size)
+ ) -> tuple[int, int, int, int]:
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
def _make_topology(
diff --git a/tests/v1/worker/test_gpu_model_runner.py b/tests/v1/worker/test_gpu_model_runner.py
index 6e5b1aa5b882..57931669c548 100644
--- a/tests/v1/worker/test_gpu_model_runner.py
+++ b/tests/v1/worker/test_gpu_model_runner.py
@@ -795,9 +795,10 @@ def test_kv_cache_stride_order(monkeypatch, model_runner):
)
# TODO mla test
- default_stride = tuple(range(5))
+ default_stride = tuple(range(len(expected_kv_cache_shape)))
+ non_default_stride = (*default_stride[1:], default_stride[0])
# Permutation that gets you back to expected kv shape
- for test_stride in ((1, 4, 0, 2, 3), (0, 1, 2, 3, 4)):
+ for test_stride in (non_default_stride, default_stride):
def rnd_stride_order(
include_num_layers_dimension: bool = False, test_stride=test_stride
@@ -1300,9 +1301,10 @@ def test_hybrid_attention_mamba_tensor_shapes():
actual_kv = vllm_ctx[layer].kv_cache[kernel_block, :]
expected = attn_blocks_constant[i]
- # Check K and V separately
- assert torch.equal(actual_kv[0], expected)
- assert torch.equal(actual_kv[1], expected)
+ # Packed layout: (num_kv_heads, block_size, 2*head_size). Every
+ # head in the block was filled with the same constant.
+ for head_idx in range(actual_kv.shape[0]):
+ assert torch.equal(actual_kv[head_idx], expected)
for layer in [layer_2, layer_3, layer_4, layer_5]:
for i, kv_block in enumerate(kv_blocks_for_mamba):
diff --git a/vllm/distributed/kv_transfer/kv_connector/utils.py b/vllm/distributed/kv_transfer/kv_connector/utils.py
index 71c9db075cb9..77f043ef5270 100644
--- a/vllm/distributed/kv_transfer/kv_connector/utils.py
+++ b/vllm/distributed/kv_transfer/kv_connector/utils.py
@@ -10,7 +10,12 @@
import torch
-from vllm.config import VllmConfig, get_current_vllm_config, get_layers_from_vllm_config
+from vllm.config import (
+ VllmConfig,
+ get_current_vllm_config,
+ get_layers_from_vllm_config,
+ set_current_vllm_config,
+)
from vllm.distributed.kv_transfer.kv_connector.factory import KVConnectorFactory
from vllm.logger import init_logger
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
@@ -344,14 +349,15 @@ def get_current_attn_backends(
)
from vllm.v1.attention.selector import get_attn_backend
- return [
- get_attn_backend(
- head_size=vllm_config.model_config.get_head_size(),
- dtype=vllm_config.model_config.dtype,
- kv_cache_dtype=vllm_config.cache_config.cache_dtype,
- use_mla=vllm_config.model_config.use_mla,
- )
- ]
+ with set_current_vllm_config(vllm_config):
+ return [
+ get_attn_backend(
+ head_size=vllm_config.model_config.get_head_size(),
+ dtype=vllm_config.model_config.dtype,
+ kv_cache_dtype=vllm_config.cache_config.cache_dtype,
+ use_mla=vllm_config.model_config.use_mla,
+ )
+ ]
def get_current_attn_backend(
@@ -426,12 +432,16 @@ def __post_init__(self):
head_size=1,
)
logger.debug("Test kv_cache_shape: %s", kv_cache_shape)
- # Non-MLA backends caches have 5 dims [num_blocks, 2, H,N,D],
- # we just mock num_blocks to 1 for the dimension check below.
- # Hybrid SSM models assume a single blocks_first layout
- self._is_kv_layout_blocks_first = self.is_mamba or (
- len(kv_cache_shape) == 5 and kv_cache_shape[0] == 1
- )
+ assert kv_cache_shape[0] == 1, (
+ "KV cache layout must be blocks-first; expected mocked "
+ f"num_blocks=1 in leading dim, got shape {kv_cache_shape}."
+ )
+ if not self.is_mla:
+ assert len(kv_cache_shape) == 4, (
+ "Attention KV cache layout must be standardized as "
+ "[num_blocks, num_kv_heads, block_size, content_size], "
+ f"got shape {kv_cache_shape}."
+ )
self._cross_layers_blocks = False
if self.tensor_shape is not None:
@@ -490,29 +500,19 @@ def unregister_remote_engine(self, remote_engine_id: EngineId) -> None:
# Layout properties
# ============================================================
- @property
- def is_kv_layout_blocks_first(self) -> bool:
- return self._is_kv_layout_blocks_first
-
@property
def cross_layers_blocks(self) -> bool:
return self._cross_layers_blocks
@property
def virtually_split_kv_in_blocks(self) -> bool:
- # Whether to logically split each block into K and V halves.
- # Applies when K/V are interleaved within each block (blocks-first),
- # but NOT when cross-layer blocks are used — cross-layer blocks have
- # per-layer K/V interleaving (L0_K, L0_V, L1_K, L1_V, ...) so a
- # simple half-split does not separate K from V.
- return self._is_kv_layout_blocks_first and not self._cross_layers_blocks
-
- @property
- def split_k_and_v(self) -> bool:
- # Whether to register regions for K and V separately (when present).
- return not (
- self._cross_layers_blocks or self.is_mla or self.is_kv_layout_blocks_first
- )
+ # Whether to logically split each block into two separately-indexable
+ # sub-regions. With K and V packed into the content dim, an attention
+ # block transfers as a single unit — no K/V sub-split is needed. Only
+ # Mamba still needs this, to index its two state regions (conv/ssm)
+ # separately. Not applicable to cross-layer blocks (per-layer
+ # interleaving means a simple half-split does not separate the parts).
+ return self.is_mamba and not self._cross_layers_blocks
# ============================================================
# Common methods
@@ -616,8 +616,9 @@ def get_transfer_cache_regions(
# Swap [2<>num_blocks] dims for hybrid SSM layout.
cache = cache.transpose(0, 1)
- # Regular case: backends like FA register K/V in separate regions
- return cache if self.split_k_and_v else [cache]
+ # K and V are packed into one tensor (content dim), so each layer
+ # registers as a single region.
+ return [cache]
def describe(self, remote_engine_id: EngineId, remote_pp_rank: int = 0) -> str:
"""One-line summary of transfer config for logging."""
diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py b/vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py
index 39dbec3886cd..d80541ade637 100644
--- a/vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py
+++ b/vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py
@@ -7,6 +7,7 @@
import torch
from vllm.v1.kv_cache_interface import (
+ AttentionSpec,
KVCacheConfig,
KVCacheSpec,
MLAAttentionSpec,
@@ -56,6 +57,11 @@ def is_mla_cache_layer(
return isinstance(spec, (MLAAttentionSpec, SlidingWindowMLASpec))
+def _content_packed_dim(spec: AttentionSpec) -> int:
+ head_size_v = getattr(spec, "head_size_v", spec.head_size)
+ return spec.head_size + head_size_v
+
+
def _spec_dim_matches(value: int, expected: int | None) -> bool:
return expected is None or value == expected
@@ -223,6 +229,30 @@ def get_layer_transfer_geometry(
split_kv_regions=False,
)
+ if (
+ not is_mla_cache
+ and isinstance(spec, AttentionSpec)
+ and len(shape) == 4
+ and shape[1] == spec.num_kv_heads
+ and shape[2] == spec.block_size
+ and shape[3] == _content_packed_dim(spec)
+ ):
+ num_blocks, num_kv_heads, block_size, packed_dim = shape
+ slot_size_bytes = num_kv_heads * packed_dim * element_size
+ block_len = block_size * slot_size_bytes
+ return LayerTransferGeometry(
+ num_blocks=num_blocks,
+ block_size=block_size,
+ block_len=block_len,
+ slot_size_bytes=slot_size_bytes,
+ block_stride=stride[0],
+ local_kv_stride=None,
+ remote_kv_stride=None,
+ transfers_per_block=1,
+ regions_per_block=1,
+ split_kv_regions=False,
+ )
+
cache_kind = "MLA" if is_mla_cache else "K/V"
raise ValueError(
f"Unsupported MoRIIO {cache_kind} cache shape for layer "
diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
index eeee594cc3a8..10554e7ede73 100644
--- a/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+++ b/vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
@@ -216,12 +216,10 @@ def _fa_desc_replicated(self, num_fa_descs: int) -> list[bool]:
if n_regions == 0 or self.num_regions == 0:
return [False] * num_fa_descs
nblk = num_fa_descs // self.num_regions
- virtually_split = self.transfer_topo.virtually_split_kv_in_blocks
flags: list[bool] = []
for i in range(n_regions):
replicated = self._is_region_replicated(i)
- num_streams = 1 if replicated or not virtually_split else 2
- flags.extend([replicated] * (num_streams * nblk))
+ flags.extend([replicated] * nblk)
assert len(flags) == num_fa_descs, (
f"FA desc flags {len(flags)} != num_fa_descs {num_fa_descs}"
)
@@ -712,31 +710,31 @@ def initialize_host_xfer_buffer(self, kv_caches: dict[str, torch.Tensor]) -> Non
NOT directly supported by NIXL (e.g., tpu)
"""
xfer_buffers: dict[str, torch.Tensor] = {}
- inv_order = [0, 1, 3, 2, 4]
try:
for layer_name, kv_cache in kv_caches.items():
kv_shape = kv_cache.shape
kv_dtype = kv_cache.dtype
permute_shape = False
- if (
- self.kv_cache_layout == "NHD"
- and self.vllm_config.kv_transfer_config is not None
- and self.vllm_config.kv_transfer_config.enable_permute_local_kv
- ):
- logger.info_once(
- "'enable_permute_local_kv' flag is enabled while "
- "device KV Layout is NHD. Init host buffer with"
- " HND to better support Decode/Prefill TP_ratio > 1."
- )
- # Since NHD will not support Decode/Prefill TP_ratio > 1,
- # we can leverage host_buffer for permute
- self.host_buffer_kv_cache_layout = "HND"
- kv_shape = (
- tuple(kv_shape[i] for i in inv_order)
- if not self.use_mla
- else kv_shape
- )
- permute_shape = not self.use_mla
+ inv_order = (0, 2, 1, 3)
+ if not self.use_mla:
+ assert kv_cache.ndim == 4
+
+ if self.kv_cache_layout == "NHD":
+ if self.kv_transfer_config.enable_permute_local_kv:
+ logger.info_once(
+ "'enable_permute_local_kv' flag is enabled while "
+ "device KV Layout is NHD. Init host buffer with"
+ " HND to better support Decode/Prefill TP_ratio > 1."
+ )
+ # Since NHD will not support Decode/Prefill TP_ratio > 1,
+ # we can leverage host_buffer for permute.
+ self.host_buffer_kv_cache_layout = "HND"
+ else:
+ # Packed KV layout is logical (B, H, N, 2*D). Allocate
+ # (B, N, H, 2*D) and view it as logical (B, H, N, 2*D)
+ # so raw NIXL transfers see NHD physical strides.
+ kv_shape = tuple(kv_shape[i] for i in inv_order)
+ permute_shape = True
xfer_buffers[layer_name] = torch.empty(
kv_shape, dtype=kv_dtype, device="cpu"
@@ -1151,9 +1149,7 @@ def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
f"backend={self.backend_name}, "
"all_backends="
f"{[backend.get_name() for backend in self.attn_backends]}, "
- f"kv_cache_layout={self.kv_cache_layout}, "
- "blocks_first="
- f"{self.transfer_topo.is_kv_layout_blocks_first}"
+ f"kv_cache_layout={self.kv_cache_layout}"
)
# Need to make sure the device ID is non-negative for NIXL,
@@ -1184,22 +1180,6 @@ def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
regions_per_layer = self.num_regions // num_local_layers
self._remote_region_offset = regions_per_layer * start_layer
- if self.transfer_topo.virtually_split_kv_in_blocks:
- # NOTE (NickLucche) When FlashInfer is used, memory is registered
- # with joint KV for each block. This minimizes the overhead in
- # registerMem allowing faster descs queries. In order to be able to
- # split on kv_heads dim as required by heterogeneous TP, one must
- # be able to index K/V separately. Hence we double the number
- # of 'virtual' regions here and halve `block_len` below.
- # Similarly for Mamba layers, we register SSM+Conv as a single region and
- # then duplicate it logically to be able to index SSM/Conv separately.
- # Exception: key-only REPLICATE regions (MLA) have no V half, so
- # they contribute a single desc stream and are not doubled.
- self.num_regions = sum(
- 1 if self._is_region_replicated(i) else 2
- for i in range(len(self._region_is_mla))
- )
-
# Total local FA descriptors (boundary between FA and mamba descs).
self.num_descs = self.num_regions * self.num_blocks
@@ -1363,22 +1343,6 @@ def _build_fa_local(
block_offset = block_id * page_stride
addr = base_addr + block_offset
result.append((addr, kv_block_len, self.device_id))
-
- if (
- self.transfer_topo.virtually_split_kv_in_blocks
- and not self._is_region_replicated(i)
- ):
- # Separate and interleave K/V regions to maintain the same
- # descs ordering. This is needed for selecting contiguous heads
- # when split across TP ranks. (Skipped for key-only REPLICATE.)
- second_split = self.get_backend_aware_kv_block_len(
- layer_idx=i, first_split=False, mamba_view=False
- )
- for block_id in range(num_blocks):
- block_offset = block_id * page_stride
- addr = base_addr + block_offset
- v_addr = addr + kv_block_len
- result.append((v_addr, second_split, self.device_id))
return result
def _build_fa_remote(
@@ -1423,20 +1387,6 @@ def _build_fa_remote(
# tp rank of size local_block_len.
addr = base_addr + block_offset + rank_offset
result.append((addr, local_block_len, nixl_agent_meta.device_id))
-
- emits_v = self.transfer_topo.virtually_split_kv_in_blocks and not replicated
- if emits_v:
- # With FlashInfer index V separately to allow head splitting.
- second_split = self.get_backend_aware_kv_block_len(
- layer_idx=i, first_split=False, mamba_view=False
- )
- second_split = second_split // num_reads
- for block_id in range(num_blocks):
- block_offset = block_id * page_size
- addr = base_addr + block_offset + rank_offset
- # Hop over the first split of remote page, K, to read V.
- v_addr = addr + nixl_agent_meta.block_lens[i] // 2
- result.append((v_addr, second_split, nixl_agent_meta.device_id))
return result
def register_local_xfer_handler(
@@ -1927,24 +1877,18 @@ def post_process_device_kv_on_receive(
block_size_ratio,
)
- split_k_and_v = self.transfer_topo.split_k_and_v
-
for block_ids in block_ids_list:
indices = torch.tensor(block_ids, device=self.device_type, dtype=torch.long)
- for _, cache_or_caches in self.device_kv_caches.items():
- cache_list = cache_or_caches if split_k_and_v else [cache_or_caches]
- for cache in cache_list:
- if self.enable_permute_local_kv and block_size_ratio > 1:
- kv_postprocess_blksize_and_layout_on_receive(
- cache, indices, block_size_ratio
- )
- elif self.enable_permute_local_kv:
- kv_postprocess_layout_on_receive(cache, indices)
- else:
- kv_postprocess_blksize_on_receive(
- cache, indices, block_size_ratio
- )
+ for cache in self.device_kv_caches.values():
+ if self.enable_permute_local_kv and block_size_ratio > 1:
+ kv_postprocess_blksize_and_layout_on_receive(
+ cache, indices, block_size_ratio
+ )
+ elif self.enable_permute_local_kv:
+ kv_postprocess_layout_on_receive(cache, indices)
+ else:
+ kv_postprocess_blksize_on_receive(cache, indices, block_size_ratio)
def post_process_device_kv_on_receive_heterogeneous_attn(
self, block_ids: list[int]
@@ -2398,12 +2342,10 @@ def get_backend_aware_kv_block_len(
|1st_split-2nd_split| |1st_split-2nd_split |
"""
assert self.transfer_topo is not None
- virtually_split = self.transfer_topo.virtually_split_kv_in_blocks
- if virtually_split and mamba_view:
+ if self.transfer_topo.virtually_split_kv_in_blocks and mamba_view:
block_len = self._mamba_ssm_size[not first_split]
else:
- half_block = virtually_split and not self._is_region_replicated(layer_idx)
- block_len = self.block_len_per_layer[layer_idx] // (2 if half_block else 1)
+ block_len = self.block_len_per_layer[layer_idx]
return block_len
def get_kv_connector_stats(self) -> KVConnectorStats | None:
diff --git a/vllm/models/minimax_m3/common/ops/sparse_attn.py b/vllm/models/minimax_m3/common/ops/sparse_attn.py
index 08d375dc6109..e32818c47622 100644
--- a/vllm/models/minimax_m3/common/ops/sparse_attn.py
+++ b/vllm/models/minimax_m3/common/ops/sparse_attn.py
@@ -8,7 +8,8 @@
page.
Main K/V cache layout (vLLM):
- ``(num_blocks, 2, 128, num_kv_heads, head_dim)`` K=[:,0] V=[:,1]
+ ``(num_blocks, num_kv_heads, 128, 2 * head_dim)``
+ K=[..., :head_dim] V=[..., head_dim:]
Only the paths MiniMax M3 uses are implemented: no attention sink, base-2
(exp2/log2) softmax. The decode kernels use split-K (flash-decoding) over the
@@ -50,7 +51,7 @@
@triton.jit(do_not_specialize_on_alignment=["seq_lens", "prefix_lens"])
def _gqa_sparse_fwd_kernel(
q_ptr, # [total_q, num_heads, head_dim]
- kv_cache_ptr, # main cache: [num_blocks, 2, 128, num_kv_heads, head_dim]
+ kv_cache_ptr, # main cache: [num_blocks, num_kv_heads, 128, 2*head_dim]
t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
o_ptr, # [total_q, num_heads, head_dim]
block_table_ptr, # [num_reqs, max_blocks]
@@ -68,9 +69,8 @@ def _gqa_sparse_fwd_kernel(
stride_qh,
stride_qd,
stride_kv_blk,
- stride_kv_kv,
- stride_kv_pos,
stride_kv_h,
+ stride_kv_pos,
stride_kv_d,
stride_th,
stride_tn,
@@ -140,9 +140,8 @@ def _gqa_sparse_fwd_kernel(
k = tl.load(
kv_cache_ptr
+ page * stride_kv_blk
- + 0 * stride_kv_kv
- + off_n[None, :] * stride_kv_pos
+ pid_kh * stride_kv_h
+ + off_n[None, :] * stride_kv_pos
+ off_d[:, None] * stride_kv_d,
mask=d_mask[:, None] & pos_mask[None, :],
other=0.0,
@@ -162,10 +161,9 @@ def _gqa_sparse_fwd_kernel(
v = tl.load(
kv_cache_ptr
+ page * stride_kv_blk
- + 1 * stride_kv_kv
- + off_n[:, None] * stride_kv_pos
+ pid_kh * stride_kv_h
- + off_d[None, :] * stride_kv_d,
+ + off_n[:, None] * stride_kv_pos
+ + (head_dim + off_d[None, :]) * stride_kv_d,
mask=pos_mask[:, None] & d_mask[None, :],
other=0.0,
)
@@ -206,7 +204,7 @@ def _gqa_sparse_fwd_kernel(
@triton.jit(do_not_specialize=["decode_query_len"])
def _gqa_sparse_decode_kernel(
q_ptr, # [total_q, num_heads, head_dim]
- kv_cache_ptr, # main cache: [num_blocks, 2, 128, num_kv_heads, head_dim]
+ kv_cache_ptr, # main cache: [num_blocks, num_kv_heads, 128, 2*head_dim]
t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
o_ptr, # partial out: [NUM_TOPK_CHUNKS, total_q, num_heads, head_dim]
lse_ptr, # partial lse (log2): [NUM_TOPK_CHUNKS, total_q, num_heads]
@@ -222,9 +220,8 @@ def _gqa_sparse_decode_kernel(
stride_qh,
stride_qd,
stride_kv_blk,
- stride_kv_kv,
- stride_kv_pos,
stride_kv_h,
+ stride_kv_pos,
stride_kv_d,
stride_th,
stride_tn,
@@ -300,9 +297,8 @@ def _gqa_sparse_decode_kernel(
k = tl.load(
kv_cache_ptr
+ page * stride_kv_blk
- + 0 * stride_kv_kv
- + off_n[None, :] * stride_kv_pos
+ pid_kh * stride_kv_h
+ + off_n[None, :] * stride_kv_pos
+ off_d[:, None] * stride_kv_d,
mask=d_mask[:, None] & pos_mask[None, :],
other=0.0,
@@ -319,10 +315,9 @@ def _gqa_sparse_decode_kernel(
v = tl.load(
kv_cache_ptr
+ page * stride_kv_blk
- + 1 * stride_kv_kv
- + off_n[:, None] * stride_kv_pos
+ pid_kh * stride_kv_h
- + off_d[None, :] * stride_kv_d,
+ + off_n[:, None] * stride_kv_pos
+ + (head_dim + off_d[None, :]) * stride_kv_d,
mask=pos_mask[:, None] & d_mask[None, :],
other=0.0,
)
@@ -418,7 +413,7 @@ def _merge_topk_attn_out_kernel(
@torch.no_grad()
def minimax_m3_sparse_attn(
q: torch.Tensor, # [total_q, num_heads, head_dim]
- kv_cache: torch.Tensor, # [num_blocks, 2, 128, num_kv_heads, head_dim]
+ kv_cache: torch.Tensor, # [num_blocks, num_kv_heads, 128, 2*head_dim]
topk_idx: torch.Tensor, # [num_kv_heads, total_q, topk]
block_table: torch.Tensor, # [batch, max_blocks]
cu_seqlens_q: torch.Tensor, # [batch+1] int32
@@ -459,7 +454,6 @@ def minimax_m3_sparse_attn(
kv_cache.stride(1),
kv_cache.stride(2),
kv_cache.stride(3),
- kv_cache.stride(4),
topk_idx.stride(0),
topk_idx.stride(1),
topk_idx.stride(2),
@@ -476,7 +470,7 @@ def minimax_m3_sparse_attn(
@torch.no_grad()
def minimax_m3_sparse_attn_decode(
q: torch.Tensor, # [total_q, num_heads, head_dim]
- kv_cache: torch.Tensor, # [num_blocks, 2, 128, num_kv_heads, head_dim]
+ kv_cache: torch.Tensor, # [num_blocks, num_kv_heads, 128, 2*head_dim]
topk_idx: torch.Tensor, # [num_kv_heads, total_q, topk]
block_table: torch.Tensor, # [num_reqs, max_blocks]
seq_lens: torch.Tensor, # [num_reqs] int32
@@ -529,7 +523,6 @@ def minimax_m3_sparse_attn_decode(
kv_cache.stride(1),
kv_cache.stride(2),
kv_cache.stride(3),
- kv_cache.stride(4),
topk_idx.stride(0),
topk_idx.stride(1),
topk_idx.stride(2),
diff --git a/vllm/models/minimax_m3/common/sparse_attention.py b/vllm/models/minimax_m3/common/sparse_attention.py
index 88109772eb1b..c87fe74c1700 100644
--- a/vllm/models/minimax_m3/common/sparse_attention.py
+++ b/vllm/models/minimax_m3/common/sparse_attention.py
@@ -104,20 +104,25 @@ def get_kv_cache_shape(
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
- # Permutation from get_kv_cache_shape to the actual memory layout.
+ # `stride_order` indicates the permutation that gets us from
+ # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
+ # layout we want.
if include_num_layers_dimension:
raise NotImplementedError # no cross-layer KV blocks in M3
cache_layout = get_kv_cache_layout()
if cache_layout == "NHD":
- stride_order = (0, 1, 2, 3, 4)
+ # (num_blocks, block_size, num_kv_heads, 2*head_size)
+ stride_order = (0, 2, 1, 3)
elif cache_layout == "HND":
- stride_order = (0, 1, 3, 2, 4)
+ # (num_blocks, num_kv_heads, block_size, 2*head_size)
+ stride_order = (0, 1, 2, 3)
else:
raise ValueError(f"Unknown cache layout format {cache_layout}.")
return stride_order
diff --git a/vllm/models/minimax_m3/nvidia/sparse_attention_msa.py b/vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
index 545667c8cfe4..cf56fcf5e5d4 100644
--- a/vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
+++ b/vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
@@ -79,8 +79,7 @@ def forward(
# strided view directly (topK stays innermost-contiguous).
prefill_topk = topk[nd:num_tokens].transpose(0, 1)
qp = q[nd:]
- k_cache = kv_cache[:, 0].transpose(1, 2)
- v_cache = kv_cache[:, 1].transpose(1, 2)
+ k_cache, v_cache = kv_cache.split(self.head_size, dim=-1)
k2q_row_ptr, k2q_q_indices, schedule = build_k2q_csr(
prefill_topk,
p.cu_seqlens_q,
diff --git a/vllm/platforms/rocm.py b/vllm/platforms/rocm.py
index 6c3a0fe96ecc..4b182aeb8852 100644
--- a/vllm/platforms/rocm.py
+++ b/vllm/platforms/rocm.py
@@ -427,8 +427,8 @@ def _get_backend_priorities(
]
backends = []
- # ROCM_ATTN uses (2, num_blocks, ...) KV cache layout which is
- # incompatible with KV connectors that require blocks-first layout.
+ # Keep ROCM_ATTN disabled for KV connectors until connector transfer
+ # semantics are validated for its asymmetric native K/V cache views.
if not use_kv_connector:
backends.append(AttentionBackendEnum.ROCM_ATTN)
if rocm_aiter_ops.is_mha_enabled():
@@ -511,6 +511,20 @@ def get_valid_backends(
attn_selector_config.use_sparse,
attn_selector_config.use_kv_connector,
)
+ from vllm.config import get_current_vllm_config_or_none
+
+ vllm_config = get_current_vllm_config_or_none()
+ is_encoder_decoder = (
+ getattr(getattr(vllm_config, "model_config", None), "attn_type", None)
+ == "encoder_decoder"
+ )
+ # ROCM_ATTN still uses a legacy attention layout (KV is the outer
+ # dimension) that is incompatible with the encoder backend layouts. The
+ # encoder and decoder need the layouts to match. This is currently
+ # enforced implicitly.
+ # TODO: Make this explicit in the selector in a future PR.
+ if is_encoder_decoder and AttentionBackendEnum.ROCM_ATTN in backend_priorities:
+ backend_priorities.remove(AttentionBackendEnum.ROCM_ATTN)
for priority, backend in enumerate(backend_priorities):
try:
backend_class = backend.get_class()
diff --git a/vllm/utils/torch_utils.py b/vllm/utils/torch_utils.py
index cf821a54baac..1472024c2b11 100644
--- a/vllm/utils/torch_utils.py
+++ b/vllm/utils/torch_utils.py
@@ -416,26 +416,24 @@ def nvfp4_kv_cache_full_dim(head_size: int) -> int:
return head_size // 2 + head_size // 16
-def _nvfp4_split_data_scale(
+def nvfp4_split_data_scale(
kv_side: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
- """Split a single NVFP4 KV-side buffer into data and scale views.
+ """Split one side (K or V) of an NVFP4 KV cache into data and scale.
- The input is a 4D tensor for one KV side (K or V) whose last
- dimension is ``full_dim = data_dim + scale_dim``. The physical
- layout within each side is [data | scale], both packed contiguously.
+ The input is a 4D uint8 tensor whose last dimension is
+ ``full_dim = data_dim + scale_dim``. The physical layout within each
+ side is ``[data | scale]``, both packed contiguously.
+
+ The caller is responsible for slicing K and V from the combined cache
+ first (e.g. ``kv_cache.split(num_kv_heads, dim=1)``).
Args:
- kv_side: 4D uint8 tensor with shape
- ``(num_pages, dim_1, dim_2, full_dim)``.
- May be in any permutation order (NHD or HND).
+ kv_side: 4D uint8 tensor ``(B, H, N, full_dim)``.
Returns:
- ``(data, scale)`` where
- ``data`` is a uint8 view with shape
- ``(num_pages, dim_1, dim_2, data_dim)``.
- ``scale`` is a float8_e4m3fn view with shape
- ``(num_pages, dim_1, dim_2, scale_dim)``.
+ ``(data, scale)`` where *data* is uint8 and *scale* is
+ float8_e4m3fn, both views of the same storage.
"""
num_pages = kv_side.shape[0]
dim_1, dim_2 = kv_side.shape[1], kv_side.shape[2]
@@ -468,38 +466,6 @@ def _nvfp4_split_data_scale(
return data, scale
-def nvfp4_kv_cache_split_views(kv_cache: torch.Tensor) -> tuple[tuple, tuple]:
- """Split an NVFP4 KV cache tensor into data and scale views.
-
- Accepts either a 5D tensor ``(num_pages, 2, dim_2, dim_3, full_dim)``
- or a 4D single-side tensor ``(num_pages, dim_2, dim_3, full_dim)``.
-
- Per-page layout: [K_data | K_scale | V_data | V_scale].
- Each KV side is self-contained (data followed by its scale), so the
- 5D case simply splits each side independently.
-
- The returned views are in the same dim order as the input (NHD or
- HND), so callers get views matching whichever order they passed in.
-
- Args:
- kv_cache: 5D or 4D uint8 tensor where the last dimension is
- ``full_dim = data_dim + scale_dim = 9 * head_size / 16``.
-
- Returns:
- For 5D input:
- ``(k_data, v_data), (k_scale, v_scale)``
- For 4D input (single KV side):
- ``(data,), (scale,)``
- """
- if kv_cache.dim() == 4:
- data, scale = _nvfp4_split_data_scale(kv_cache)
- return (data,), (scale,)
-
- k_data, k_scale = _nvfp4_split_data_scale(kv_cache[:, 0])
- v_data, v_scale = _nvfp4_split_data_scale(kv_cache[:, 1])
- return (k_data, v_data), (k_scale, v_scale)
-
-
def create_kv_caches_with_random_flash(
num_blocks: int,
block_size: int,
diff --git a/vllm/v1/attention/backend.py b/vllm/v1/attention/backend.py
index ec07023f80ef..4ecf0b9ad067 100644
--- a/vllm/v1/attention/backend.py
+++ b/vllm/v1/attention/backend.py
@@ -122,11 +122,11 @@ def get_kv_cache_stride_order(
) -> tuple[int, ...]:
"""
Get the physical (memory layout) ordering of the kv cache dimensions.
- e.g. if the KV cache shape is
- [2, num_blocks, block_size, num_heads, head_size],
- and get_kv_cache_stride_order returns (1, 3, 0, 2, 4) then the physical
+ Standard attention backends pack K and V into the content dim, giving
+ the logical shape [num_blocks, num_heads, block_size, 2 * head_size].
+ e.g. if get_kv_cache_stride_order returns (0, 2, 1, 3) then the physical
ordering of dimensions is
- [num_blocks, num_heads, 2, block_size, head_size].
+ [num_blocks, block_size, num_heads, 2 * head_size].
If this function is unimplemented / raises NotImplementedError,
the physical layout of the KV cache will match the logical shape.
@@ -135,9 +135,9 @@ def get_kv_cache_stride_order(
include_num_layers_dimension: if True, includes an additional
num_layers dimension, which is assumed to be prepended
to the logical KV cache shape.
- With the above example, a return value (2, 4, 0, 1, 3, 5)
+ With the above example, a return value (1, 0, 3, 2, 4)
corresponds to
- [num_blocks, num_heads, num_layers, 2, block_size, head_size].
+ [num_blocks, num_layers, block_size, num_heads, 2 * head_size].
If an additional dimension is NOT included in the returned
tuple, the physical layout will not include a layers dimension.
diff --git a/vllm/v1/attention/backends/flash_attn.py b/vllm/v1/attention/backends/flash_attn.py
index d83d2f4d810a..844385326fee 100755
--- a/vllm/v1/attention/backends/flash_attn.py
+++ b/vllm/v1/attention/backends/flash_attn.py
@@ -133,25 +133,29 @@ def get_kv_cache_shape(
) -> tuple[int, ...]:
if block_size % 16 != 0:
raise ValueError("Block size must be a multiple of 16.")
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
+ # K and V are packed into the content dim: logical (B, H, N, 2*D).
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
- # `stride_order` indicates the permutation that gets
- # us from `get_kv_cache_shape` to the actual memory layout we want.
+ # `stride_order` indicates the permutation that gets us from
+ # `get_kv_cache_shape` (logical (B, H, N, 2*D)) to the actual memory
+ # layout we want.
cache_layout = get_kv_cache_layout()
if cache_layout == "NHD" and include_num_layers_dimension:
- # (num_blocks, num_layers, 2, block_size, num_kv_heads, head_size)
- return (1, 0, 2, 3, 4, 5)
+ # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
+ return (1, 0, 3, 2, 4)
elif cache_layout == "NHD":
- stride_order = (0, 1, 2, 3, 4)
+ # (num_blocks, block_size, num_kv_heads, 2*head_size)
+ stride_order = (0, 2, 1, 3)
elif cache_layout == "HND" and include_num_layers_dimension:
- # (num_blocks, num_kv_heads, num_layers, 2, block_size, head_size)
- return (1, 4, 0, 2, 3, 5)
+ # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
+ return (1, 2, 0, 3, 4)
elif cache_layout == "HND":
- stride_order = (0, 1, 3, 2, 4)
+ # (num_blocks, num_kv_heads, block_size, 2*head_size)
+ stride_order = (0, 1, 2, 3)
else:
raise ValueError(f"Unknown cache layout format {cache_layout}.")
return stride_order
@@ -819,7 +823,7 @@ def forward(
key: shape = [num_tokens, num_kv_heads, head_size]
value: shape = [num_tokens, num_kv_heads, head_size]
kv_cache: shape =
- [num_blocks, 2, block_size, num_kv_heads, head_size]
+ [num_blocks, num_kv_heads, block_size, 2 * head_size]
attn_metadata: Metadata for attention.
Returns:
shape = [num_tokens, num_heads * head_size]
@@ -866,8 +870,8 @@ def forward(
layer,
)
- # For decoder and cross-attention, use KV cache as before
- key_cache, value_cache = kv_cache.unbind(1)
+ # (B, H, N, 2*D) -> ((B, N, H, D), (B, N, H, D))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
# Fix degenerate strides on size-1 dims (e.g. num_kv_heads=1 with TP).
# FA3/4 on H100+ uses TMA, which requires ≥16-byte stride alignment.
# See vllm.utils.torch_utils.canonicalize_singleton_dim_strides.
@@ -1075,7 +1079,8 @@ def do_kv_cache_update(
# Scatter write into the KV cache using slot_mapping indices.
# No TMA kernel is invoked here, so stride canonicalization is not needed.
- key_cache, value_cache = kv_cache.unbind(1)
+ # (B, H, N, 2*D) -> ((B, N, H, D), (B, N, H, D))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
# Reshape the input keys and values and store them in the cache.
# Skip this if sharing KV cache with an earlier attention layer.
diff --git a/vllm/v1/attention/backends/flash_attn_diffkv.py b/vllm/v1/attention/backends/flash_attn_diffkv.py
index ff8fbfc022b7..02c88b4b3656 100644
--- a/vllm/v1/attention/backends/flash_attn_diffkv.py
+++ b/vllm/v1/attention/backends/flash_attn_diffkv.py
@@ -83,10 +83,12 @@ def get_kv_cache_shape(
) -> tuple[int, ...]:
if block_size % 16 != 0:
raise ValueError("Block size must be a multiple of 16.")
+ # Logical (blocks-first, head-major) layout: K and V (with their
+ # different head sizes) packed in the content dim.
return (
num_blocks,
- block_size,
num_kv_heads,
+ block_size,
head_size + FlashAttentionDiffKVBackend.head_size_v,
)
@@ -94,21 +96,22 @@ def get_kv_cache_shape(
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
- # `stride_order` indicates the permutation that gets
- # us from `get_kv_cache_shape` to the actual memory layout we want.
+ # `stride_order` indicates the permutation that gets us from
+ # `get_kv_cache_shape` (logical (B, H, N, C_k+C_v)) to the actual
+ # memory layout we want.
cache_layout = get_kv_cache_layout()
if cache_layout == "NHD" and include_num_layers_dimension:
- # (num_blocks, num_layers, block_size,
- # num_kv_heads, head_size + head_size_v)
- return (1, 0, 2, 3, 4)
+ # (num_blocks, num_layers, block_size, num_kv_heads, C_k+C_v)
+ return (1, 0, 3, 2, 4)
elif cache_layout == "NHD":
- stride_order = (0, 1, 2, 3)
+ # (num_blocks, block_size, num_kv_heads, C_k+C_v)
+ stride_order = (0, 2, 1, 3)
elif cache_layout == "HND" and include_num_layers_dimension:
- # (num_blocks, num_kv_heads, num_layers,
- # block_size, head_size + head_size_v)
- return (1, 3, 0, 2, 4)
+ # (num_blocks, num_kv_heads, num_layers, block_size, C_k+C_v)
+ return (1, 2, 0, 3, 4)
elif cache_layout == "HND":
- stride_order = (0, 2, 1, 3)
+ # (num_blocks, num_kv_heads, block_size, C_k+C_v)
+ stride_order = (0, 1, 2, 3)
else:
raise ValueError(f"Unknown cache layout format {cache_layout}.")
return stride_order
@@ -142,21 +145,14 @@ def do_kv_cache_update(
# we use direct Q, K, V tensors without caching
return
- # Unlike standard FlashAttn which splits kv_cache via unbind(0),
# DiffKV packs K and V into a single tensor along the last dim:
# kv_cache shape: [num_blocks, block_size, num_kv_heads,
# head_size_k + head_size_v]
- # The triton kernel handles this combined layout directly.
- #
- # NOTE(woosuk): key and value are padded while slot_mapping is
- # not padded. However, we don't need to do key[:num_actual_tokens]
- # and value[:num_actual_tokens] because the reshape_and_cache_flash
- # op uses the slot_mapping's shape to determine the number of
- # actual tokens.
+ # (B, H, N, C) -> (B, N, H, C) for kernel compatibility.
triton_reshape_and_cache_flash_diffkv(
key,
value,
- kv_cache,
+ kv_cache.transpose(1, 2),
slot_mapping,
self.kv_cache_dtype,
layer._k_scale,
@@ -229,10 +225,8 @@ def forward(
layer,
)
- # For decoder and cross-attention, use KV cache as before
- # Different head_size for K and V
- key_cache = kv_cache[..., : self.head_size]
- value_cache = kv_cache[..., self.head_size :]
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
# Fix degenerate strides on size-1 dims (e.g. num_kv_heads=1 with TP).
# FA3/4 on H100+ uses TMA, which requires ≥16-byte stride alignment.
# See vllm.utils.torch_utils.canonicalize_singleton_dim_strides.
diff --git a/vllm/v1/attention/backends/flashinfer.py b/vllm/v1/attention/backends/flashinfer.py
index 12eab21e3e13..699073939ea1 100755
--- a/vllm/v1/attention/backends/flashinfer.py
+++ b/vllm/v1/attention/backends/flashinfer.py
@@ -51,7 +51,7 @@
is_quantized_kv_cache,
is_strictly_contiguous,
nvfp4_kv_cache_full_dim,
- nvfp4_kv_cache_split_views,
+ nvfp4_split_data_scale,
)
from vllm.v1.attention.backend import (
AttentionBackend,
@@ -111,9 +111,12 @@ def _trtllm_prefill_attn_kvfp8_dequant(
src_stride_page,
src_stride_kv,
src_stride_head,
+ src_stride_block,
+ src_stride_head_size,
DST_K_CACHE_STRIDE: tl.constexpr,
DST_KV_CACHE_STRIDE: tl.constexpr,
HEAD_STRIDE: tl.constexpr,
+ HEAD_SIZE: tl.constexpr,
NUM_KV_HEADS: tl.constexpr,
):
batch_idx = tl.program_id(0).to(tl.int64)
@@ -129,18 +132,25 @@ def _trtllm_prefill_attn_kvfp8_dequant(
v_scale_val = tl.load(v_scale_ptr)
mock_page_idx = batch_idx * block_table_stride + mock_block_table_idx + 1
- head_offsets = tl.arange(0, HEAD_STRIDE)
+ logical_offsets = tl.arange(0, HEAD_STRIDE)
+ block_offsets = logical_offsets // HEAD_SIZE
+ head_size_offsets = logical_offsets % HEAD_SIZE
for h in range(NUM_KV_HEADS):
h_off = tl.cast(h, tl.int64)
# Read K from source (supports non-contiguous page/kv/head strides)
- src_k = orig_page_num * src_stride_page + h_off * src_stride_head + head_offsets
+ src_k = (
+ orig_page_num * src_stride_page
+ + h_off * src_stride_head
+ + block_offsets * src_stride_block
+ + head_size_offsets * src_stride_head_size
+ )
fp8_k = tl.load(kv_cache_ptr + src_k)
dequant_k = (fp8_k.to(tl.float32) * k_scale_val).to(dequant_dtype)
# Write K to contiguous mock cache
- dst_k = mock_page_idx * DST_KV_CACHE_STRIDE + h * HEAD_STRIDE + head_offsets
+ dst_k = mock_page_idx * DST_KV_CACHE_STRIDE + h * HEAD_STRIDE + logical_offsets
tl.store(mock_kv_cache_ptr + dst_k, dequant_k)
# Read V from source (offset by src_stride_kv for the V half)
@@ -148,7 +158,8 @@ def _trtllm_prefill_attn_kvfp8_dequant(
orig_page_num * src_stride_page
+ src_stride_kv
+ h_off * src_stride_head
- + head_offsets
+ + block_offsets * src_stride_block
+ + head_size_offsets * src_stride_head_size
)
fp8_v = tl.load(kv_cache_ptr + src_v)
dequant_v = (fp8_v.to(tl.float32) * v_scale_val).to(dequant_dtype)
@@ -158,7 +169,7 @@ def _trtllm_prefill_attn_kvfp8_dequant(
mock_page_idx * DST_KV_CACHE_STRIDE
+ DST_K_CACHE_STRIDE
+ h * HEAD_STRIDE
- + head_offsets
+ + logical_offsets
)
tl.store(mock_kv_cache_ptr + dst_v, dequant_v)
@@ -175,17 +186,14 @@ def trtllm_prefill_attn_kvfp8_dequant(
assert s[1] == 2
assert dequant_dtype in (torch.bfloat16, torch.float16)
+ # Logical source layout is (B, 2, H, N, D). The tensor may be a
+ # non-contiguous view, so the Triton kernel indexes it with actual strides.
+ strides = kv_cache.stride()
num_kv_heads, block_size, head_size = s[2], s[3], s[4]
head_stride = block_size * head_size
k_cache_stride = num_kv_heads * head_stride
kv_cache_stride = k_cache_stride * s[1]
- strides = kv_cache.stride()
- assert strides[3] == head_size and strides[4] == 1, (
- "For kv cache layouts, (block_size, head_size) "
- f"dimensions must be contiguous, got strides {strides}"
- )
-
new_s = (batch_size * num_of_page_per_token + 1, s[1], s[2], s[3], s[4])
# mock kv cache contains just the pages needed by this prefill
mock_kv_cache = torch.empty(new_s, dtype=dequant_dtype, device=kv_cache.device)
@@ -207,9 +215,12 @@ def trtllm_prefill_attn_kvfp8_dequant(
strides[0],
strides[1],
strides[2],
+ strides[3],
+ strides[4],
k_cache_stride,
kv_cache_stride,
head_stride,
+ head_size,
num_kv_heads,
)
return mock_kv_cache, mock_block_table
@@ -286,7 +297,7 @@ def run(
self,
layer: torch.nn.Module,
prefill_query: torch.Tensor,
- kv_cache_permute: torch.Tensor,
+ kv_cache_tuple: tuple[torch.Tensor, torch.Tensor],
key: torch.Tensor,
value: torch.Tensor,
out: torch.Tensor,
@@ -296,7 +307,7 @@ def run(
)
output_context_tmp, lse_context_tmp = self._context.run(
prefill_query_across_dcp,
- kv_cache_permute,
+ kv_cache_tuple,
k_scale=layer._k_scale_float,
v_scale=layer._v_scale_float,
return_lse=True,
@@ -386,28 +397,31 @@ def get_kv_cache_shape(
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
if cache_dtype_str == "nvfp4":
- # Packed layout: fp4 data + fp8 block scales in last dim
- last_dim = nvfp4_kv_cache_full_dim(head_size)
- return (num_blocks, 2, block_size, num_kv_heads, last_dim)
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
+ full_dim = nvfp4_kv_cache_full_dim(head_size)
+ return (num_blocks, 2 * num_kv_heads, block_size, full_dim)
+ # Pack K and V in the content dim (B, H, N, 2*hs).
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
# `stride_order` indicates the permutation that gets us from
- # `get_kv_cache_shape` to the actual memory layout we want.
+ # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
+ # layout we want.
cache_layout = get_kv_cache_layout()
if cache_layout == "NHD" and include_num_layers_dimension:
- # (num_blocks, num_layers, 2, block_size, num_kv_heads, head_size)
- return (1, 0, 2, 3, 4, 5)
+ # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
+ return (1, 0, 3, 2, 4)
elif cache_layout == "NHD":
- stride_order = (0, 1, 2, 3, 4)
+ # (num_blocks, block_size, num_kv_heads, 2*head_size)
+ stride_order = (0, 2, 1, 3)
elif cache_layout == "HND" and include_num_layers_dimension:
- # (num_blocks, 2, num_kv_heads, num_layers, block_size, head_size)
- return (1, 2, 4, 0, 3, 5)
+ # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
+ return (1, 2, 0, 3, 4)
elif cache_layout == "HND":
- stride_order = (0, 1, 3, 2, 4)
+ # (num_blocks, num_kv_heads, block_size, 2*head_size)
+ stride_order = (0, 1, 2, 3)
else:
raise ValueError(f"Unknown cache layout format {cache_layout}.")
return stride_order
@@ -1714,7 +1728,19 @@ def forward(
if attn_metadata.use_cascade:
# Cascade attention (rare case).
assert attn_metadata.cascade_wrapper is not None
- output.copy_(attn_metadata.cascade_wrapper.run(query, kv_cache))
+ stride_order = FlashInferBackend.get_kv_cache_stride_order()
+ if self.is_kvcache_nvfp4:
+ kv_cache_views = tuple(
+ cache.permute(*stride_order)
+ for cache in kv_cache.split(self.num_kv_heads, dim=1)
+ )
+ else:
+ kv_perm = kv_cache.permute(*stride_order)
+ kv_cache_views = kv_perm.split(self.head_size, dim=-1)
+ kv_tuple = tuple(
+ canonicalize_singleton_dim_strides(cache) for cache in kv_cache_views
+ )
+ output.copy_(attn_metadata.cascade_wrapper.run(query, kv_tuple))
return output
# When using spec decoding, num_decodes can be < num_decode_tokens
@@ -1740,14 +1766,23 @@ def forward(
)
kv_cache_permute = fixed
- # For NVFP4, the kv_cache last dim is full_dim (data + scale packed).
- # Split into correctly-strided data and scale views.
+ # Split K/V — zero-copy views. NVFP4 stores K/V as separate head
+ # groups; other dtypes pack K/V in the content dim.
+ hs = self.head_size
nvfp4_kv_data = None
nvfp4_kv_block_scales = None
if self.is_kvcache_nvfp4:
- nvfp4_kv_data, nvfp4_kv_block_scales = nvfp4_kv_cache_split_views(
- kv_cache_permute
+ k_cache, v_cache = kv_cache.split(self.num_kv_heads, dim=1)
+ kv_cache_tuple = (
+ canonicalize_singleton_dim_strides(k_cache.permute(*stride_order)),
+ canonicalize_singleton_dim_strides(v_cache.permute(*stride_order)),
)
+ k_data, k_sf = nvfp4_split_data_scale(kv_cache_tuple[0])
+ v_data, v_sf = nvfp4_split_data_scale(kv_cache_tuple[1])
+ nvfp4_kv_data = (k_data, v_data)
+ nvfp4_kv_block_scales = (k_sf, v_sf)
+ else:
+ kv_cache_tuple = kv_cache_permute.split(hs, dim=-1)
use_dcp = self.dcp_world_size > 1
@@ -1786,7 +1821,7 @@ def forward(
prefill_wrapper.run(
layer,
prefill_query,
- kv_cache_permute,
+ kv_cache_tuple,
key[num_decode_tokens:],
value[num_decode_tokens:],
out=output[num_decode_tokens:],
@@ -1803,7 +1838,9 @@ def forward(
assert prefill_wrapper._causal == attn_metadata.causal
if self.is_kvcache_nvfp4:
- kv_cache_permute = nvfp4_kv_data
+ kv_cache_for_fi = nvfp4_kv_data
+ else:
+ kv_cache_for_fi = kv_cache_tuple
kv_cache_sf = (
nvfp4_kv_block_scales if self.is_kvcache_nvfp4 else None
)
@@ -1821,7 +1858,7 @@ def forward(
prefill_wrapper.run(
prefill_query,
- kv_cache_permute,
+ kv_cache_for_fi,
q_scale=layer._q_scale_float,
k_scale=layer._k_scale_float,
v_scale=layer._v_scale_float,
@@ -1888,8 +1925,7 @@ def forward(
# TRTLLM prefill attention does not support BF16 Q
# and fp8 kv cache. So to enable prefill attention
# with fp8 kv cache, we can construct a mock block
- # and mock kv cache with BF16 KV involved in the prefill
- #
+ # and mock kv cache with BF16 KV involved in the prefill.
kv_cache_permute = canonicalize_singleton_dim_strides(
kv_cache_permute
)
@@ -1901,15 +1937,21 @@ def forward(
"KV cache inner dims (block_size, head_size) must be "
f"contiguous, got strides {kv_strides}"
)
+ # fp8 uses (B, H, N, 2*hs); reshape to (B, 2, H, N, hs)
+ # for the dequant kernel — zero-copy view. The dequant
+ # kernel handles the interleaved K/V block stride.
+ B_kv, H_kv, N_kv = kv_cache_permute.shape[:3]
+ kv_cache_5d = kv_cache_permute.view(B_kv, H_kv, N_kv, 2, hs)
+ kv_cache_5d = kv_cache_5d.permute(0, 3, 1, 2, 4)
mock_kv_cache, mock_block_table = trtllm_prefill_attn_kvfp8_dequant(
- kv_cache_permute,
+ kv_cache_5d,
block_tables_prefill,
layer._k_scale,
layer._v_scale,
attn_metadata.q_data_type_prefill,
)
else:
- mock_kv_cache = kv_cache_permute
+ mock_kv_cache = kv_cache_tuple
mock_block_table = block_tables_prefill
trtllm_batch_context_with_kv_cache(
@@ -1957,7 +1999,9 @@ def forward(
assert decode_wrapper._sm_scale == self.scale
if self.is_kvcache_nvfp4:
- kv_cache_permute = nvfp4_kv_data
+ kv_cache_for_fi = nvfp4_kv_data
+ else:
+ kv_cache_for_fi = kv_cache_tuple
kv_cache_sf = nvfp4_kv_block_scales if self.is_kvcache_nvfp4 else None
# NVFP4 kernel only supports FP8 output.
@@ -1980,7 +2024,7 @@ def forward(
)
decode_wrapper.run(
decode_query,
- kv_cache_permute,
+ kv_cache_for_fi,
q_scale=layer._q_scale_float,
k_scale=layer._k_scale_float,
v_scale=layer._v_scale_float,
@@ -1997,7 +2041,7 @@ def forward(
else:
decode_wrapper.run(
decode_query,
- kv_cache_permute,
+ kv_cache_for_fi,
q_scale=layer._q_scale_float,
k_scale=layer._k_scale_float,
v_scale=layer._v_scale_float,
@@ -2079,7 +2123,7 @@ def forward(
trtllm_batch_decode_with_kv_cache(
query=decode_query,
kv_cache=(
- nvfp4_kv_data if self.is_kvcache_nvfp4 else kv_cache_permute
+ nvfp4_kv_data if self.is_kvcache_nvfp4 else kv_cache_tuple
),
workspace_buffer=workspace_buffer,
block_tables=block_tables_decode,
@@ -2119,8 +2163,18 @@ def do_kv_cache_update(
# and value[:num_actual_tokens] because the reshape_and_cache_flash
# op uses the slot_mapping's shape to determine the number of
# actual tokens.
- k_cache = kv_cache[:, 0]
- v_cache = kv_cache[:, 1]
+ if self.is_kvcache_nvfp4:
+ # (B, 2*H, N, full_dim) -> ((B, N, H, full_dim),
+ # (B, N, H, full_dim));
+ # K heads first, then V heads.
+ k_cache, v_cache = kv_cache.transpose(1, 2).split(
+ self.num_kv_heads, dim=-2
+ )
+ else:
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ k_cache, v_cache = kv_cache.transpose(1, 2).split(
+ self.head_size, dim=-1
+ )
torch.ops._C_cache_ops.reshape_and_cache_flash(
key,
value,
diff --git a/vllm/v1/attention/backends/flex_attention.py b/vllm/v1/attention/backends/flex_attention.py
index c45294bfc796..b918f2adeff0 100644
--- a/vllm/v1/attention/backends/flex_attention.py
+++ b/vllm/v1/attention/backends/flex_attention.py
@@ -130,15 +130,16 @@ def get_kv_cache_shape(
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
if include_num_layers_dimension:
- return (1, 0, 3, 2, 4, 5)
- return (0, 2, 1, 3, 4)
+ return (1, 0, 3, 2, 4)
+ return (0, 2, 1, 3)
@staticmethod
def get_builder_cls() -> type["FlexAttentionMetadataBuilder"]:
@@ -1242,7 +1243,8 @@ def do_kv_cache_update(
if self.attn_type == AttentionType.ENCODER_ONLY:
return
- key_cache, value_cache = kv_cache.unbind(1)
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
torch.ops._C_cache_ops.reshape_and_cache_flash(
key,
value,
@@ -1273,7 +1275,7 @@ def forward(
key: shape = [num_tokens, num_kv_heads, head_size]
value: shape = [num_tokens, num_kv_heads, head_size]
kv_cache: shape =
- [num_blocks, 2, block_size, num_kv_heads, head_size]
+ [num_blocks, num_kv_heads, block_size, 2 * head_size]
attn_metadata: Metadata for attention.
Returns:
shape = [num_tokens, num_heads * head_size]
@@ -1347,9 +1349,11 @@ def forward(
else:
assert self.attn_type == AttentionType.DECODER
- key_cache, value_cache = kv_cache.unbind(1)
+ kv_cache = kv_cache.transpose(1, 2)
+ hs = self.head_size
+ key_cache, value_cache = kv_cache.split(hs, dim=-1)
- # Flatten (num_blocks, block_size) into a single token dim
+ # Flatten (num_blocks, block_size) into a single token dim.
key_cache = key_cache.view(-1, self.num_kv_heads, self.head_size)
value_cache = value_cache.view(-1, self.num_kv_heads, self.head_size)
query, key_tensor, value_tensor = map(
diff --git a/vllm/v1/attention/backends/rocm_aiter_fa.py b/vllm/v1/attention/backends/rocm_aiter_fa.py
index 0f289649b1c9..4290c22ced7c 100644
--- a/vllm/v1/attention/backends/rocm_aiter_fa.py
+++ b/vllm/v1/attention/backends/rocm_aiter_fa.py
@@ -768,7 +768,8 @@ def get_kv_cache_shape(
) -> tuple[int, ...]:
if block_size % 16 != 0:
raise ValueError("Block size must be a multiple of 16.")
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
@classmethod
def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
@@ -1061,7 +1062,8 @@ def forward(
# Whenever making a change in this method, please benchmark the
# performance to make sure it does not introduce any overhead.
num_actual_tokens = attn_metadata.num_actual_tokens
- key_cache, value_cache = kv_cache.unbind(1)
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
if is_quantized_kv_cache(self.kv_cache_dtype):
key_cache = key_cache.view(current_platform.fp8_dtype())
@@ -1388,7 +1390,8 @@ def do_kv_cache_update(
kv_cache: torch.Tensor,
slot_mapping: torch.Tensor,
):
- key_cache, value_cache = kv_cache.unbind(1)
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
# key and value may be None in the case of cross attention. They are
# calculated once based on the output from the encoder and then cached
@@ -1454,7 +1457,8 @@ def do_rope_and_kv_cache_update(
kv_cache: torch.Tensor,
layer_slot_mapping: torch.Tensor,
):
- key_cache, value_cache = kv_cache.unbind(1)
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
flash_layout = True
is_fp8_kv_cache = is_quantized_kv_cache(self.kv_cache_dtype)
diff --git a/vllm/v1/attention/backends/rocm_aiter_unified_attn.py b/vllm/v1/attention/backends/rocm_aiter_unified_attn.py
index 57b64cc93df7..cc45fade5ab4 100644
--- a/vllm/v1/attention/backends/rocm_aiter_unified_attn.py
+++ b/vllm/v1/attention/backends/rocm_aiter_unified_attn.py
@@ -87,7 +87,8 @@ def get_kv_cache_shape(
) -> tuple[int, ...]:
if block_size % 16 != 0:
raise ValueError("Block size must be a multiple of 16.")
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
@staticmethod
def use_cascade_attention(*args, **kwargs) -> bool:
@@ -150,10 +151,8 @@ def __init__(
def _split_kv_cache(
self, kv_cache: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor]:
- # Blocks-first ``(num_blocks, 2, ...)``. The model runner normalizes any
- # shared decoder/cross-attention allocation to this layout, so no
- # per-backend restriding is needed here.
- return kv_cache.unbind(1)
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ return kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
def forward(
self,
diff --git a/vllm/v1/attention/backends/triton_attn.py b/vllm/v1/attention/backends/triton_attn.py
index 7b4a652939ae..fa2fe8f1e6ba 100644
--- a/vllm/v1/attention/backends/triton_attn.py
+++ b/vllm/v1/attention/backends/triton_attn.py
@@ -324,6 +324,7 @@ def get_kv_cache_shape(
) -> tuple[int, ...]:
if block_size % 16 != 0:
raise ValueError("Block size must be a multiple of 16.")
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
if kv_cache_uses_per_token_head_scales(cache_dtype_str):
# Pad the head dim by sizeof(float32)/sizeof(cache_dtype) so the
# per-(token, head) scale fits inline after the quantized data;
@@ -341,26 +342,30 @@ def get_kv_cache_shape(
data_head_size = head_size // 2
else:
data_head_size = head_size
- return (num_blocks, 2, block_size, num_kv_heads, data_head_size + scale_pad)
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
+ padded_hs = data_head_size + scale_pad
+ return (num_blocks, num_kv_heads, block_size, 2 * padded_hs)
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
- # `stride_order` indicates the permutation that gets
- # us from `get_kv_cache_shape` to the actual memory layout we want.
+ # `stride_order` indicates the permutation that gets us from
+ # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
+ # layout we want.
cache_layout = get_kv_cache_layout()
if cache_layout == "NHD" and include_num_layers_dimension:
- # (num_blocks, num_layers, 2, block_size, num_kv_heads, head_size)
- return (1, 0, 2, 3, 4, 5)
+ # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
+ return (1, 0, 3, 2, 4)
elif cache_layout == "NHD":
- stride_order = (0, 1, 2, 3, 4)
+ # (num_blocks, block_size, num_kv_heads, 2*head_size)
+ stride_order = (0, 2, 1, 3)
elif cache_layout == "HND" and include_num_layers_dimension:
- # (num_blocks, num_kv_heads, num_layers, 2, block_size, head_size)
- return (1, 4, 0, 2, 3, 5)
+ # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
+ return (1, 2, 0, 3, 4)
elif cache_layout == "HND":
- stride_order = (0, 1, 3, 2, 4)
+ # (num_blocks, num_kv_heads, block_size, 2*head_size)
+ stride_order = (0, 1, 2, 3)
else:
raise ValueError(f"Unknown cache layout: {cache_layout}")
return stride_order
@@ -410,12 +415,16 @@ class TritonAttentionImpl(AttentionImpl):
_v_scale_cache: torch.Tensor | None = None
def _ensure_scale_caches(self, kv_cache: torch.Tensor) -> None:
- """Extract per-head scale views from the padded head dimension.
+ """Extract per-head scale views from the padded content dimension.
- The KV cache shape is ``(num_blocks, 2, block_size, nkv, hs+pad)``
- where ``pad = sizeof(float32) / sizeof(cache_dtype)``. The last
- ``pad`` elements of each head hold one float32 scale. We create
- strided float32 views over those bytes.
+ The KV cache is packed as logical shape
+ ``(num_blocks, nkv, block_size, 2 * (hs + pad))`` where
+ ``pad = sizeof(float32) / sizeof(cache_dtype)``. The content dim holds
+ ``[K(hs) | K_scale(pad) | V(hs) | V_scale(pad)]`` per (head, slot); the
+ last ``pad`` elements of each half hold one float32 scale. We create
+ strided float32 views over those bytes. ``kv_cache`` must be the
+ packed logical tensor (call before any transpose), but may have HND or
+ NHD physical strides.
Scale shape: ``(num_blocks, block_size, num_kv_heads)``
"""
@@ -423,9 +432,10 @@ def _ensure_scale_caches(self, kv_cache: torch.Tensor) -> None:
return
from vllm.utils.torch_utils import get_dtype_size
- num_blocks, _, block_size, nkv, padded_hs = kv_cache.shape
+ num_blocks, nkv, block_size, content = kv_cache.shape
dtype_sz = kv_cache.element_size()
scale_pad = get_dtype_size(torch.float32) // dtype_sz # e.g. 4
+ padded_hs = content // 2
hs = padded_hs - scale_pad
raw = kv_cache.untyped_storage()
@@ -433,31 +443,37 @@ def _ensure_scale_caches(self, kv_cache: torch.Tensor) -> None:
raw
)
- # In the raw bytes, each (block, kv_half, slot, head) occupies
- # padded_hs * dtype_sz bytes. The scale float32 sits at byte
- # offset hs * dtype_sz within that region.
- kv_half_bytes = block_size * nkv * padded_hs * dtype_sz
- full_block_f32 = 2 * kv_half_bytes // 4 # stride between blocks
- slot_f32 = nkv * padded_hs * dtype_sz // 4 # stride between slots
- head_f32 = padded_hs * dtype_sz // 4 # stride between heads
- scale_off_f32 = hs * dtype_sz // 4 # offset to scale within head
-
- # K scales: kv_half=0
+ def to_f32_units(elements: int) -> int:
+ nbytes = elements * dtype_sz
+ assert nbytes % 4 == 0
+ return nbytes // 4
+
+ # Actual strides (in float32 units) from the tensor. The logical cache
+ # may be physically NHD, so do not assume C-contiguous HND layout.
+ strides = kv_cache.stride()
+ block_f32 = to_f32_units(strides[0])
+ head_f32 = to_f32_units(strides[1])
+ slot_f32 = to_f32_units(strides[2])
+ # Scale sits at byte offset hs within each (K, then V) content half.
+ base_off_f32 = to_f32_units(kv_cache.storage_offset())
+ k_scale_off_f32 = base_off_f32 + to_f32_units(hs)
+ v_scale_off_f32 = base_off_f32 + to_f32_units(padded_hs + hs)
+
+ # K scales (first content half)
self._k_scale_cache = torch.as_strided(
base_f32,
size=(num_blocks, block_size, nkv),
- stride=(full_block_f32, slot_f32, head_f32),
- storage_offset=scale_off_f32,
+ stride=(block_f32, slot_f32, head_f32),
+ storage_offset=k_scale_off_f32,
)
self._k_scale_cache.fill_(1.0)
- # V scales: kv_half=1, offset by kv_half_bytes
- v_base_f32 = kv_half_bytes // 4
+ # V scales (second content half)
self._v_scale_cache = torch.as_strided(
base_f32,
size=(num_blocks, block_size, nkv),
- stride=(full_block_f32, slot_f32, head_f32),
- storage_offset=v_base_f32 + scale_off_f32,
+ stride=(block_f32, slot_f32, head_f32),
+ storage_offset=v_scale_off_f32,
)
self._v_scale_cache.fill_(1.0)
@@ -576,7 +592,7 @@ def forward(
key: shape = [num_tokens, num_kv_heads, head_size]
value: shape = [num_tokens, num_kv_heads, head_size]
kv_cache: shape =
- [num_blocks, 2, block_size, num_kv_heads, head_size]
+ [num_blocks, num_kv_heads, block_size, 2 * head_size]
attn_metadata: Metadata for attention.
Returns:
shape = [num_tokens, num_heads * head_size]
@@ -617,6 +633,7 @@ def forward(
layer,
)
+ # KV cache arrives in logical (B, H, N, 2*hs) order.
# Per-token-head quantized KV cache: handled by the core unified
# kernel, which dequantizes per-(token, head) inline via constexpr
# branches (INT8 / FP8) and dispatches to the packed INT4 kernel.
@@ -627,7 +644,9 @@ def forward(
q_descale = k_descale = v_descale = None
# FP8 per-tensor / auto path (original flow).
else:
- key_cache, value_cache = kv_cache.unbind(1)
+ kv_cache = kv_cache.transpose(1, 2)
+ hs = self.head_size
+ key_cache, value_cache = kv_cache.split(hs, dim=-1)
if (
is_quantized_kv_cache(self.kv_cache_dtype)
and key_cache.dtype != self.fp8_dtype
@@ -711,7 +730,8 @@ def _pth_key_value_caches(
) -> tuple[torch.Tensor, torch.Tensor]:
"""Per-token-head K/V cache views (ensures scale caches; FP8 retyped)."""
self._ensure_scale_caches(kv_cache)
- key_cache, value_cache = kv_cache.unbind(1)
+ padded_hs = kv_cache.shape[-1] // 2
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(padded_hs, dim=-1)
if self._kv_quant_mode == KVQuantMode.FP8_PER_TOKEN_HEAD:
key_cache = key_cache.view(self.fp8_dtype)
value_cache = value_cache.view(self.fp8_dtype)
@@ -778,13 +798,9 @@ def do_kv_cache_update(
return
# Reshape the input keys and values and store them in the cache.
if self._is_per_token_head_quant:
- self._ensure_scale_caches(kv_cache)
- key_cache, value_cache = kv_cache.unbind(1)
+ key_cache, value_cache = self._pth_key_value_caches(kv_cache)
k_scale_cache = self._k_scale_cache
v_scale_cache = self._v_scale_cache
- if self._kv_quant_mode == KVQuantMode.FP8_PER_TOKEN_HEAD:
- key_cache = key_cache.view(self.fp8_dtype)
- value_cache = value_cache.view(self.fp8_dtype)
triton_reshape_and_cache_flash_per_token_head_quant(
key,
value,
@@ -797,7 +813,8 @@ def do_kv_cache_update(
)
return
# For decoder and cross-attention, use KV cache as before.
- key_cache, value_cache = kv_cache.unbind(1)
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
if is_quantized_kv_cache(self.kv_cache_dtype):
key_cache = key_cache.view(self.fp8_dtype)
value_cache = value_cache.view(self.fp8_dtype)
@@ -829,7 +846,8 @@ def do_rope_and_kv_cache_update(
kv_cache: torch.Tensor,
layer_slot_mapping: torch.Tensor,
):
- key_cache, value_cache = kv_cache.unbind(1)
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
flash_layout = True
is_fp8_kv_cache = is_quantized_kv_cache(self.kv_cache_dtype)
diff --git a/vllm/v1/attention/backends/triton_attn_diffkv.py b/vllm/v1/attention/backends/triton_attn_diffkv.py
index 3420a0eba477..02ea8401cbe6 100644
--- a/vllm/v1/attention/backends/triton_attn_diffkv.py
+++ b/vllm/v1/attention/backends/triton_attn_diffkv.py
@@ -2,12 +2,9 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Triton attention backend with different K/V head dimensions (DiffKV).
-The KV cache layout is identical to ``FlashAttentionDiffKVBackend`` — K
-and V are packed along the last dim:
-
- [num_blocks, block_size, num_kv_heads, head_size_qk + head_size_v]
-
-so existing helpers (``triton_reshape_and_cache_flash_diffkv``) are reused.
+The KV cache layout is identical to ``FlashAttentionDiffKVBackend``: K and V
+are packed along the last dim in the logical shape
+``[num_blocks, num_kv_heads, block_size, head_size_qk + head_size_v]``.
"""
from typing import ClassVar
@@ -106,10 +103,12 @@ def get_kv_cache_shape(
) -> tuple[int, ...]:
if block_size % 16 != 0:
raise ValueError("Block size must be a multiple of 16.")
+ # Logical (blocks-first, head-major) layout: K and V (with their
+ # different head sizes) packed in the content dim.
return (
num_blocks,
- block_size,
num_kv_heads,
+ block_size,
head_size + TritonAttentionDiffKVBackend.head_size_v,
)
@@ -117,19 +116,22 @@ def get_kv_cache_shape(
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
+ # `stride_order` indicates the permutation that gets us from
+ # `get_kv_cache_shape` (logical (B, H, N, C_k+C_v)) to the actual
+ # memory layout we want.
cache_layout = get_kv_cache_layout()
if cache_layout == "NHD" and include_num_layers_dimension:
- # (num_blocks, num_layers, block_size,
- # num_kv_heads, head_size + head_size_v)
- return (1, 0, 2, 3, 4)
+ # (num_blocks, num_layers, block_size, num_kv_heads, C_k+C_v)
+ return (1, 0, 3, 2, 4)
elif cache_layout == "NHD":
- return (0, 1, 2, 3)
+ # (num_blocks, block_size, num_kv_heads, C_k+C_v)
+ return (0, 2, 1, 3)
elif cache_layout == "HND" and include_num_layers_dimension:
- # (num_blocks, num_kv_heads, num_layers,
- # block_size, head_size + head_size_v)
- return (1, 3, 0, 2, 4)
+ # (num_blocks, num_kv_heads, num_layers, block_size, C_k+C_v)
+ return (1, 2, 0, 3, 4)
elif cache_layout == "HND":
- return (0, 2, 1, 3)
+ # (num_blocks, num_kv_heads, block_size, C_k+C_v)
+ return (0, 1, 2, 3)
else:
raise ValueError(f"Unknown cache layout format {cache_layout}.")
@@ -175,13 +177,12 @@ def do_kv_cache_update(
kv_cache: torch.Tensor,
slot_mapping: torch.Tensor,
) -> None:
- # Cache is packed [..., head_size_qk + head_size_v]; the diffkv
- # reshape kernel writes K to [..., :head_size_qk] and V to
- # [..., head_size_qk:hqk+hv].
+ # Cache is logical (B, H, N, C); the diffkv reshape kernel expects
+ # (B, N, H, C).
triton_reshape_and_cache_flash_diffkv(
key,
value,
- kv_cache,
+ kv_cache.transpose(1, 2),
slot_mapping,
self.kv_cache_dtype,
layer._k_scale,
@@ -210,7 +211,7 @@ def forward(
query: [num_tokens, num_heads, head_size_qk]
key: [num_tokens, num_kv_heads, head_size_qk]
value: [num_tokens, num_kv_heads, head_size_v]
- kv_cache: [num_blocks, block_size, num_kv_heads,
+ kv_cache: [num_blocks, num_kv_heads, block_size,
head_size_qk + head_size_v]
output: [num_tokens, num_heads, head_size_v]
"""
@@ -231,8 +232,8 @@ def forward(
head_size_qk = self.head_size
head_size_v = TritonAttentionDiffKVBackend.head_size_v
- # Slice the packed cache into K / V views. Strides on dims 0/1/2
- # match the original cache; dim 3 stays contiguous (stride 1).
+ # Triton DiffKV kernels consume (B, N, H, D) cache views.
+ kv_cache = kv_cache.transpose(1, 2)
key_cache = kv_cache[..., :head_size_qk]
value_cache = kv_cache[..., head_size_qk : head_size_qk + head_size_v]
diff --git a/vllm/v1/attention/backends/turboquant_attn.py b/vllm/v1/attention/backends/turboquant_attn.py
index af4ab007a8be..a274339afceb 100644
--- a/vllm/v1/attention/backends/turboquant_attn.py
+++ b/vllm/v1/attention/backends/turboquant_attn.py
@@ -141,9 +141,10 @@ def get_kv_cache_shape(
Standard attention backends use (2, num_blocks, block_size, num_kv_heads,
head_dim) with a leading 2 to separate K and V. TurboQuant packs K+V
- into a single interleaved slot per head per position, so the cache is:
+ into a single interleaved slot per head per position. The logical
+ (blocks-first, head-major) shape is:
- (num_blocks, block_size, num_kv_heads, slot_size_aligned)
+ (num_blocks, num_kv_heads, block_size, slot_size_aligned)
Each slot = [key_packed | value_packed | padding].
This is safe because TQ has its own get_kv_cache_shape override and
@@ -159,7 +160,7 @@ def get_kv_cache_shape(
)
tq_config = TurboQuantConfig.from_cache_dtype(cache_dtype_str, head_size)
- return (num_blocks, block_size, num_kv_heads, tq_config.slot_size_aligned)
+ return (num_blocks, num_kv_heads, block_size, tq_config.slot_size_aligned)
@classmethod
def supports_kv_cache_dtype(cls, kv_cache_dtype: CacheDType | None) -> bool:
@@ -422,6 +423,8 @@ def do_kv_cache_update(
k = key[:N].view(N, self.num_kv_heads, self.head_size)
v = value[:N].view(N, self.num_kv_heads, self.head_size)
+ # (B, H, N, C) -> (B, N, H, C) for TQ kernels
+ kv_cache = kv_cache.transpose(1, 2)
self._store_kv(k, v, kv_cache, slot_mapping, layer)
def forward(
@@ -449,6 +452,9 @@ def forward(
if attn_metadata is None:
return output.fill_(0)
+ # (B, H, N, C) -> (B, N, H, C) for TQ kernels
+ kv_cache = kv_cache.transpose(1, 2)
+
# Slice to actual tokens
N = attn_metadata.num_actual_tokens
if N <= 0:
diff --git a/vllm/v1/attention/ops/triton_reshape_and_cache_flash.py b/vllm/v1/attention/ops/triton_reshape_and_cache_flash.py
index 0f0022c2cb46..882b6787aee4 100644
--- a/vllm/v1/attention/ops/triton_reshape_and_cache_flash.py
+++ b/vllm/v1/attention/ops/triton_reshape_and_cache_flash.py
@@ -473,6 +473,7 @@ def reshape_and_cache_kernel_flash_diffkv(
value_stride: tl.int64,
block_stride: tl.int64,
page_stride: tl.int64,
+ head_stride: tl.int64,
num_heads: tl.constexpr,
head_size_k: tl.constexpr,
head_size_v: tl.constexpr,
@@ -498,9 +499,7 @@ def reshape_and_cache_kernel_flash_diffkv(
src_value_idx = token_idx * value_stride + tile_i * head_size_v
tgt_idx = (
- block_idx * block_stride
- + block_offset * page_stride
- + tile_i * (head_size_k + head_size_v)
+ block_idx * block_stride + block_offset * page_stride + tile_i * head_stride
)
# [TILE_SIZE]
@@ -542,7 +541,7 @@ def reshape_and_cache_kernel_flash_diffkv(
def triton_reshape_and_cache_flash_diffkv(
key: torch.Tensor, # [num_tokens, num_heads, head_size]
value: torch.Tensor, # [num_tokens, num_heads, head_size_v]
- # [num_blocks, block_size, num_heads, head_size + head_size_v]
+ # Strided [num_blocks, block_size, num_heads, head_size + head_size_v].
kv_cache: torch.Tensor,
slot_mapping: torch.Tensor, # [num_tokens]
kv_cache_dtype: str, # "auto", "fp8"
@@ -558,6 +557,7 @@ def triton_reshape_and_cache_flash_diffkv(
v_stride = value.stride()[0]
block_stride = kv_cache.stride()[0]
page_stride = kv_cache.stride()[1]
+ head_stride = kv_cache.stride()[2]
kv_cache_torch_dtype = (
current_platform.fp8_dtype()
@@ -599,6 +599,7 @@ def triton_reshape_and_cache_flash_diffkv(
value_stride=v_stride,
block_stride=block_stride,
page_stride=page_stride,
+ head_stride=head_stride,
num_heads=num_heads,
head_size_k=head_size_k,
head_size_v=head_size_v,
diff --git a/vllm/v1/attention/ops/triton_turboquant_store.py b/vllm/v1/attention/ops/triton_turboquant_store.py
index d5437d7314cd..e680f6883a7d 100644
--- a/vllm/v1/attention/ops/triton_turboquant_store.py
+++ b/vllm/v1/attention/ops/triton_turboquant_store.py
@@ -389,7 +389,7 @@ def triton_turboquant_store(
_tq_fused_store_fp8[grid](
k_flat,
v_flat,
- kv_cache.view(-1),
+ kv_cache,
slot_mapping,
stride_cache_block=stride_block,
stride_cache_pos=stride_pos,
@@ -425,7 +425,7 @@ def triton_turboquant_store(
norms.squeeze(1),
v_flat,
midpoints,
- kv_cache.view(-1),
+ kv_cache,
slot_mapping,
stride_cache_block=stride_block,
stride_cache_pos=stride_pos,
From 006731153693b01320feb93ab165de24fc364b0c Mon Sep 17 00:00:00 2001
From: ErenAta16
Date: Sat, 11 Jul 2026 18:42:08 +0300
Subject: [PATCH 0045/1526] fix(entrypoints): stop resolve_items leaking
in-flight media fetch tasks on partial failure (#48333)
Signed-off-by: ErenAta16
---
.../entrypoints/unit_tests/test_chat_utils.py | 39 +++++++++++++++++++
vllm/entrypoints/chat_utils.py | 17 ++++++--
2 files changed, 52 insertions(+), 4 deletions(-)
diff --git a/tests/entrypoints/unit_tests/test_chat_utils.py b/tests/entrypoints/unit_tests/test_chat_utils.py
index 7738f4c3b041..82b262321d6c 100644
--- a/tests/entrypoints/unit_tests/test_chat_utils.py
+++ b/tests/entrypoints/unit_tests/test_chat_utils.py
@@ -1,9 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+import asyncio
import warnings
from collections.abc import Mapping
from typing import Literal
+from unittest.mock import MagicMock
import pytest
import torch
@@ -13,6 +15,7 @@
from vllm.assets.video import VideoAsset
from vllm.config import ModelConfig
from vllm.entrypoints.chat_utils import (
+ AsyncMultiModalItemTracker,
ConversationMessage,
_postprocess_messages,
parse_chat_messages,
@@ -2742,3 +2745,39 @@ def test_postprocess_messages_null_arguments_string():
tool_calls = messages[0]["tool_calls"]
assert tool_calls is not None
assert tool_calls[0]["function"]["arguments"] == {}
+
+
+@pytest.mark.asyncio
+async def test_resolve_items_does_not_leak_tasks_on_partial_failure():
+ """Regression test: one failing media fetch must not abandon the other
+ still-in-flight fetches in the same modality batch.
+
+ Before the fix, `resolve_items` gathered per-modality fetches with plain
+ `asyncio.gather`, so the first exception propagated immediately while
+ sibling fetches (real network/thread-pool work in production) kept
+ running detached, with nothing left to await or cancel them.
+ """
+
+ async def _fetch(should_fail: bool, delay: float):
+ if should_fail:
+ await asyncio.sleep(0.01)
+ raise ValueError("simulated fetch failure")
+ await asyncio.sleep(delay)
+ return ("decoded", None)
+
+ tracker = AsyncMultiModalItemTracker(MagicMock())
+ tracker._items_by_modality["image"] = [
+ lambda: _fetch(True, 0),
+ lambda: _fetch(False, 0.2),
+ lambda: _fetch(False, 0.2),
+ ]
+
+ tasks_before = asyncio.all_tasks()
+ with pytest.raises(ValueError, match="simulated fetch failure"):
+ await tracker.resolve_items()
+
+ leaked_tasks = asyncio.all_tasks() - tasks_before
+ assert not leaked_tasks, (
+ f"resolve_items left {len(leaked_tasks)} task(s) running after "
+ f"raising: {leaked_tasks}"
+ )
diff --git a/vllm/entrypoints/chat_utils.py b/vllm/entrypoints/chat_utils.py
index f0b56da3432d..b7d96c894aed 100644
--- a/vllm/entrypoints/chat_utils.py
+++ b/vllm/entrypoints/chat_utils.py
@@ -821,10 +821,19 @@ async def resolve_items(
if not self._items_by_modality:
return None, None
- resolved_items_by_modality = {
- modality: await asyncio.gather(*(item() for item in items))
- for modality, items in self._items_by_modality.items()
- }
+ resolved_items_by_modality: dict[str, list[Any]] = {}
+ for modality, items in self._items_by_modality.items():
+ results = await asyncio.gather(
+ *(item() for item in items), return_exceptions=True
+ )
+ for result in results:
+ if isinstance(result, BaseException):
+ # Gathering with return_exceptions=True lets every task in
+ # this modality finish (or itself fail) before we raise,
+ # instead of abandoning still-in-flight fetches (real
+ # network/thread-pool work) the moment the first one fails.
+ raise result
+ resolved_items_by_modality[modality] = results
mm_processor = (
self.mm_processor if self._model_config.is_multimodal_model else None
From 54503ecec0f3ac31e5ecfc5f28652e4cc42307b5 Mon Sep 17 00:00:00 2001
From: Ievgen Bondarenko
Date: Sat, 11 Jul 2026 08:52:52 -0700
Subject: [PATCH 0046/1526] fix(processor): route MiMo-V2-Omni media fetch
through MediaConnector (#43117)
Signed-off-by: Ievgen Bondarenko
Signed-off-by: Ievgen (Jack) Bondarenko
Signed-off-by: Isotr0py
Co-authored-by: Claude Opus 4.7 (1M context)
Co-authored-by: Isotr0py
---
.../processors/mimo_v2_omni.py | 98 +++++--------------
1 file changed, 22 insertions(+), 76 deletions(-)
diff --git a/vllm/transformers_utils/processors/mimo_v2_omni.py b/vllm/transformers_utils/processors/mimo_v2_omni.py
index 97df3184113e..22349f751364 100644
--- a/vllm/transformers_utils/processors/mimo_v2_omni.py
+++ b/vllm/transformers_utils/processors/mimo_v2_omni.py
@@ -7,33 +7,21 @@
"""
import contextlib
-import copy
-import io
import logging
import math
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
-from io import BytesIO
from typing import Any, Literal
import numpy as np
import regex as re
-import requests
import torch
import torch.nn.functional as F
from PIL import Image
from transformers import BatchFeature, TensorType
from transformers.processing_utils import ProcessorMixin
-try:
- from torchcodec.decoders import AudioDecoder
-
- _HAS_TORCHCODEC = True
-except ImportError:
- AudioDecoder = None
- _HAS_TORCHCODEC = False
-
try:
import torchaudio
from torchaudio.transforms import MelSpectrogram as _MelSpectrogram
@@ -62,7 +50,7 @@
@dataclass
class ImageInput:
- # PIL.Image | str (path/url/base64) | bytes | torch.Tensor (C,H,W)
+ # PIL.Image | torch.Tensor (C,H,W)
image: Any
max_pixels: int | None = None
min_pixels: int | None = None
@@ -87,7 +75,7 @@ class VideoInput:
@dataclass
class AudioInput:
- # str (path/url/base64) | bytes | tuple[waveform_1D, sr]
+ # tuple[waveform_1D, sr]
# | np.ndarray | torch.Tensor (T,n_vq)
audio: Any
@@ -168,14 +156,6 @@ def _smart_resize(
return int(h_bar), int(w_bar)
-def _to_rgb(img: Image.Image) -> Image.Image:
- if img.mode == "RGBA":
- bg = Image.new("RGB", img.size, (255, 255, 255))
- bg.paste(img, mask=img.split()[3])
- return bg
- return img.convert("RGB")
-
-
def _standardize(images: torch.Tensor) -> torch.Tensor:
key = str(images.device)
if key not in _mean_std_cache:
@@ -228,27 +208,6 @@ def _transform_single(
return _standardize(out).squeeze(0), w_bar, h_bar
-def _fetch_image(src: Any) -> Image.Image:
- if isinstance(src, Image.Image):
- return _to_rgb(src)
- if isinstance(src, bytes):
- return _to_rgb(copy.deepcopy(Image.open(BytesIO(src))))
- if isinstance(src, str):
- if src.startswith(("http://", "https://")):
- r = requests.get(src, timeout=30)
- r.raise_for_status()
- return _to_rgb(copy.deepcopy(Image.open(BytesIO(r.content))))
- if src.startswith("file://"):
- return _to_rgb(Image.open(src[7:]))
- if src.startswith("data:image"):
- import pybase64 as _b64
-
- _, b64 = src.split("base64,", 1)
- return _to_rgb(copy.deepcopy(Image.open(BytesIO(_b64.b64decode(b64)))))
- return _to_rgb(Image.open(src))
- raise ValueError(f"Unrecognized image source: {type(src)}")
-
-
# ---------------------------------------------------------------------------
# Core processor
# ---------------------------------------------------------------------------
@@ -259,6 +218,10 @@ class MiMoVLProcessor:
Handles image/video/audio preprocessing and token sequence construction.
Ported from SGLang's MiMoVLProcessor.
+
+ Media strings (URL / path / data:) are resolved upstream in vLLM's media
+ pipeline; this processor accepts only already-decoded inputs (``PIL.Image``
+ for images, ``(waveform, sr)`` tuples for audio).
"""
def __init__(
@@ -451,33 +414,14 @@ def _resolve_vid_kw(self, vid: VideoInput) -> dict:
return kw
def preprocess_audio(self, audio: Any) -> tuple[torch.Tensor, int]:
- """Decode audio bytes/path/tuple → (mel_spec (T, n_mels), token_len)."""
- if isinstance(audio, tuple):
- waveform, original_sr = audio
- else:
- if AudioDecoder is None:
- raise RuntimeError(
- "torchcodec is required for audio. "
- "Install with: pip install torchcodec"
- )
- if isinstance(audio, bytes):
- file_obj: Any = io.BytesIO(audio)
- elif isinstance(audio, str):
- if audio.startswith("data:"):
- import pybase64 as _b64
-
- file_obj = io.BytesIO(_b64.b64decode(audio.split(",")[1]))
- elif audio.startswith(("http://", "https://")):
- r = requests.get(audio, timeout=30)
- r.raise_for_status()
- file_obj = io.BytesIO(r.content)
- else:
- file_obj = audio
- else:
- raise ValueError(f"Unsupported audio source type: {type(audio)}")
- samples = AudioDecoder(file_obj).get_all_samples()
- waveform = samples.data
- original_sr = samples.sample_rate
+ """Convert a pre-loaded ``(waveform, sr)`` tuple into (mel_spec, token_len)."""
+ if not isinstance(audio, tuple):
+ raise ValueError(
+ f"Unsupported audio source type: {type(audio)}. Audio must be a "
+ "pre-decoded (waveform, sample_rate) tuple; URL/path/bytes "
+ "resolution is handled upstream in vLLM's media pipeline."
+ )
+ waveform, original_sr = audio
if original_sr != self.audio_sampling_rate:
if original_sr not in self._resamplers:
@@ -504,8 +448,6 @@ def preprocess_audio(self, audio: Any) -> tuple[torch.Tensor, int]:
def process_image(self, image: ImageInput) -> torch.Tensor:
kw = self._resolve_img_kw(image)
src = image.image
- if isinstance(src, (str, bytes)):
- src = _fetch_image(src)
tensor, _, _ = _transform_single(
src,
factor=self.patch_size * self.merge_size,
@@ -584,7 +526,7 @@ def process_audio(self, audio: AudioInput) -> Any:
src = audio.audio
if isinstance(src, np.ndarray):
src = (torch.from_numpy(src).float(), self.audio_sampling_rate)
- if isinstance(src, (str, bytes, tuple)):
+ if isinstance(src, tuple):
return self.preprocess_audio(src)
# Pre-tokenized tensor (T, n_vq)
assert isinstance(src, torch.Tensor) and src.ndim == 2
@@ -899,7 +841,7 @@ class MiMoOmniProcessor(ProcessorMixin):
"""HuggingFace-compatible ProcessorMixin wrapper for MiMo-Omni.
Accepts PIL images, pre-decoded video tuples (frames_TCHW, timestamps_T),
- and audio (file path / bytes / (waveform, sr) tuple / numpy array).
+ and audio (file path / (waveform, sr) tuple / numpy array).
"""
attributes = ["tokenizer"]
@@ -1011,8 +953,12 @@ def __init__(
)
@classmethod
- def from_hf_config(cls, tokenizer: Any, hf_config: Any) -> "MiMoOmniProcessor":
- """Convenience factory: instantiate directly from an HF model config object."""
+ def from_hf_config(
+ cls,
+ tokenizer: Any,
+ hf_config: Any,
+ ) -> "MiMoOmniProcessor":
+ """Instantiate directly from an HF model config object."""
vc = hf_config.vision_config
if isinstance(vc, dict):
patch_size = vc.get("patch_size", 14)
From 1ef1c7ebba7f96d9835ade1fbd3658397e0b9be6 Mon Sep 17 00:00:00 2001
From: Jiangyun Zhu
Date: Sun, 12 Jul 2026 04:00:14 +0800
Subject: [PATCH 0047/1526] [CI] split tests to reduce CI time (#48219)
Signed-off-by: zjy0516
---
.buildkite/test_areas/models_basic.yaml | 2 +-
.buildkite/test_areas/models_multimodal.yaml | 5 +++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/.buildkite/test_areas/models_basic.yaml b/.buildkite/test_areas/models_basic.yaml
index 0fcd4f410487..95827a894588 100644
--- a/.buildkite/test_areas/models_basic.yaml
+++ b/.buildkite/test_areas/models_basic.yaml
@@ -27,7 +27,7 @@ steps:
# subset of supported models (the complement of the small subset in the above
# test.) Also run if model initialization test file is modified
- pytest -v -s models/test_initialization.py -k 'not test_can_initialize_small_subset' --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB
- parallelism: 2
+ parallelism: 4
- label: Basic Models Tests (Other)
device: h200_35gb
diff --git a/.buildkite/test_areas/models_multimodal.yaml b/.buildkite/test_areas/models_multimodal.yaml
index 6720483c25f2..473192f03f9a 100644
--- a/.buildkite/test_areas/models_multimodal.yaml
+++ b/.buildkite/test_areas/models_multimodal.yaml
@@ -69,7 +69,7 @@ steps:
depends_on:
- image-build-amd
-- label: Multi-Modal Processor (CPU)
+- label: Multi-Modal Processor (CPU) %N
key: multi-modal-processor-cpu
depends_on:
- image-build-cpu
@@ -80,7 +80,8 @@ steps:
- tests/models/registry.py
device: cpu-medium
commands:
- - pytest -v -s models/multimodal/processing --ignore models/multimodal/processing/test_tensor_schema.py
+ - pytest -v -s models/multimodal/processing --ignore models/multimodal/processing/test_tensor_schema.py --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB
+ parallelism: 4
- label: Multi-Modal Processor # 44min
key: multi-modal-processor
From 9a48eef89a777fc7039d9765fb2f6fc618216948 Mon Sep 17 00:00:00 2001
From: Alejandro Paredes La Torre
<99832715+AlejandroParedesLT@users.noreply.github.com>
Date: Sat, 11 Jul 2026 17:13:50 -0700
Subject: [PATCH 0048/1526] [Bugfix][LoRA] Support ark_linear base layer in
_get_lora_device (#47690)
Signed-off-by: AlejandroParedesLT
---
tests/lora/test_layers_utils.py | 39 +++++++++++++++++++++++++++++++++
vllm/lora/layers/utils.py | 3 +++
2 files changed, 42 insertions(+)
create mode 100644 tests/lora/test_layers_utils.py
diff --git a/tests/lora/test_layers_utils.py b/tests/lora/test_layers_utils.py
new file mode 100644
index 000000000000..0087519a33b0
--- /dev/null
+++ b/tests/lora/test_layers_utils.py
@@ -0,0 +1,39 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+import pytest
+import torch
+from torch import nn
+
+from vllm.lora.layers.utils import _get_lora_device
+
+pytestmark = pytest.mark.skip_global_cleanup
+
+
+def _param() -> nn.Parameter:
+ return nn.Parameter(torch.empty(1), requires_grad=False)
+
+
+def test_get_lora_device_unquantized():
+ base_layer = nn.Module()
+ base_layer.weight = _param()
+ assert _get_lora_device(base_layer) == base_layer.weight.device
+
+
+def test_get_lora_device_gptq_awq():
+ base_layer = nn.Module()
+ base_layer.qweight = _param()
+ assert _get_lora_device(base_layer) == base_layer.qweight.device
+
+
+def test_get_lora_device_ark_linear():
+ base_layer = nn.Module()
+ base_layer.ark_linear = nn.Module()
+ base_layer.ark_linear.qweight = _param()
+ assert _get_lora_device(base_layer) == base_layer.ark_linear.qweight.device
+
+
+def test_get_lora_device_unsupported_raises():
+ base_layer = nn.Module()
+ with pytest.raises(ValueError, match="Unsupported base layer"):
+ _get_lora_device(base_layer)
diff --git a/vllm/lora/layers/utils.py b/vllm/lora/layers/utils.py
index 17c21d36f5b9..b4cde2564b39 100644
--- a/vllm/lora/layers/utils.py
+++ b/vllm/lora/layers/utils.py
@@ -57,6 +57,9 @@ def _get_lora_device(base_layer: nn.Module) -> torch.device:
# GPTQ/AWQ
elif hasattr(base_layer, "qweight"):
return base_layer.qweight.device
+ # INC WNA16 (AutoRound)
+ elif hasattr(base_layer, "ark_linear"):
+ return base_layer.ark_linear.qweight.device
# MoE layer
elif hasattr(base_layer, "w2_weight"):
return base_layer.w2_weight.device
From 8e981630c9336233ca9de91452f68918bddbc4e2 Mon Sep 17 00:00:00 2001
From: "zhao, zhenhui"
Date: Sun, 12 Jul 2026 12:30:34 +0800
Subject: [PATCH 0049/1526] [CI][CPU] Add Qwen2-VL multimodal tests for CPU
backend and fix incompatibilities (#48072)
Signed-off-by: Zhenhui Zhao
---
.buildkite/hardware_tests/cpu.yaml | 15 ++++++++++-
.../multimodal/generation/test_qwen2_5_vl.py | 25 ++++++++++++++-----
2 files changed, 33 insertions(+), 7 deletions(-)
diff --git a/.buildkite/hardware_tests/cpu.yaml b/.buildkite/hardware_tests/cpu.yaml
index b34585f2532d..ebfd1c7524ad 100644
--- a/.buildkite/hardware_tests/cpu.yaml
+++ b/.buildkite/hardware_tests/cpu.yaml
@@ -141,9 +141,22 @@ steps:
commands:
- |
bash .buildkite/scripts/hardware_ci/run-cpu-test.sh 45m "
- pytest -x -v -s tests/models/multimodal/generation --ignore=tests/models/multimodal/generation/test_pixtral.py -m cpu_model --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB"
+ pytest -x -v -s tests/models/multimodal/generation --ignore=tests/models/multimodal/generation/test_pixtral.py --ignore=tests/models/multimodal/generation/test_qwen2_5_vl.py -m cpu_model --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --shard-id=$$BUILDKITE_PARALLEL_JOB"
parallelism: 4
+- label: CPU-Qwen2.5-VL Multimodal Tests
+ depends_on: []
+ device: intel_cpu
+ no_plugin: true
+ source_file_dependencies:
+ # - vllm/
+ - vllm/model_executor/layers/rotary_embedding
+ - tests/models/multimodal/generation/
+ commands:
+ - |
+ bash .buildkite/scripts/hardware_ci/run-cpu-test.sh 40m "
+ VLLM_CI_ENV=0 pytest -x -v -s tests/models/multimodal/generation/test_qwen2_5_vl.py"
+
- label: "Arm CPU Test"
depends_on: []
soft_fail: false
diff --git a/tests/models/multimodal/generation/test_qwen2_5_vl.py b/tests/models/multimodal/generation/test_qwen2_5_vl.py
index 15a14da24d1d..05a9b0299839 100644
--- a/tests/models/multimodal/generation/test_qwen2_5_vl.py
+++ b/tests/models/multimodal/generation/test_qwen2_5_vl.py
@@ -5,6 +5,7 @@
from vllm.assets.image import ImageAsset
from vllm.multimodal.video import sample_frames_from_video
+from vllm.platforms import current_platform
from ....conftest import VIDEO_ASSETS
@@ -52,11 +53,15 @@ def _encoder_cudagraph_config(*, max_vision_items: int) -> dict:
@pytest.mark.core_model
@pytest.mark.parametrize("model", models)
-@pytest.mark.parametrize("video_pruning_rate", [0.0, 0.75])
+@pytest.mark.parametrize(
+ "video_pruning_rate", [0.0] if current_platform.is_cpu() else [0.0, 0.75]
+)
@pytest.mark.parametrize("num_frames", [16])
@pytest.mark.parametrize("dtype", [target_dtype])
@pytest.mark.parametrize("max_tokens", [128])
-@pytest.mark.parametrize("use_bytecode_hook", [True, False])
+@pytest.mark.parametrize(
+ "use_bytecode_hook", [True] if current_platform.is_cpu() else [True, False]
+)
def test_qwen2_5_vl_evs_functionality(
vllm_runner,
video_assets,
@@ -109,11 +114,15 @@ def test_qwen2_5_vl_evs_functionality(
@pytest.mark.core_model
@pytest.mark.parametrize("model", models)
-@pytest.mark.parametrize("video_pruning_rate", [0.0, 0.75])
+@pytest.mark.parametrize(
+ "video_pruning_rate", [0.0] if current_platform.is_cpu() else [0.0, 0.75]
+)
@pytest.mark.parametrize("num_frames", [16])
@pytest.mark.parametrize("dtype", [target_dtype])
@pytest.mark.parametrize("max_tokens", [128])
-@pytest.mark.parametrize("use_bytecode_hook", [True, False])
+@pytest.mark.parametrize(
+ "use_bytecode_hook", [True] if current_platform.is_cpu() else [True, False]
+)
def test_qwen2_5_vl_evs_batched_videos(
vllm_runner,
video_assets,
@@ -174,7 +183,9 @@ def test_qwen2_5_vl_evs_batched_videos(
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("dtype", [target_dtype])
@pytest.mark.parametrize("max_tokens", [128])
-@pytest.mark.parametrize("use_bytecode_hook", [True, False])
+@pytest.mark.parametrize(
+ "use_bytecode_hook", [True] if current_platform.is_cpu() else [True, False]
+)
def test_qwen2_5_vl_window_attention_image(
vllm_runner,
model,
@@ -210,7 +221,9 @@ def test_qwen2_5_vl_window_attention_image(
@pytest.mark.parametrize("model", models)
@pytest.mark.parametrize("dtype", [target_dtype])
@pytest.mark.parametrize("max_tokens", [128])
-@pytest.mark.parametrize("use_bytecode_hook", [True, False])
+@pytest.mark.parametrize(
+ "use_bytecode_hook", [True] if current_platform.is_cpu() else [True, False]
+)
def test_qwen2_5_vl_window_attention_image_batch(
vllm_runner,
model,
From 481e481be786c1ca3229e26aa34c15ffd22375af Mon Sep 17 00:00:00 2001
From: Jiangyun Zhu
Date: Sun, 12 Jul 2026 13:37:51 +0800
Subject: [PATCH 0050/1526] [2/N][Core] support partial prefix cache hit for
hybrid model (#46384)
Signed-off-by: zjy0516
Signed-off-by: Yifan Qiao
Co-authored-by: Yifan Qiao
---
.../test_partial_prefix_cache_hits.py | 816 ++++++++++++++++++
tests/v1/core/test_deferred_block_free.py | 62 ++
tests/v1/core/test_kv_cache_utils.py | 60 ++
tests/v1/core/test_prefix_caching.py | 5 +-
.../core/test_single_type_kv_cache_manager.py | 14 +-
.../unit/test_mooncake_store_coordinator.py | 30 +-
.../unit/test_mooncake_store_hma_e2e.py | 1 +
vllm/config/cache.py | 20 +-
.../v1/mooncake/store/coordinator.py | 26 +-
.../kv_connector/v1/mooncake/store/worker.py | 4 +-
vllm/engine/arg_utils.py | 5 +
vllm/v1/core/block_pool.py | 26 +-
vllm/v1/core/kv_cache_coordinator.py | 114 ++-
vllm/v1/core/kv_cache_manager.py | 23 +-
vllm/v1/core/kv_cache_utils.py | 57 +-
vllm/v1/core/sched/output.py | 5 +
vllm/v1/core/sched/scheduler.py | 76 +-
vllm/v1/core/single_type_kv_cache_manager.py | 420 +++++++--
vllm/v1/worker/gpu/model_runner.py | 11 +-
vllm/v1/worker/gpu_model_runner.py | 7 +
vllm/v1/worker/utils.py | 44 +-
21 files changed, 1651 insertions(+), 175 deletions(-)
create mode 100644 tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
diff --git a/tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py b/tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
new file mode 100644
index 000000000000..51aefe63aaa4
--- /dev/null
+++ b/tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
@@ -0,0 +1,816 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Fine-grained partial prefix-cache hits for hybrid (full attention + mamba
+"align") models: scheduler chunk splitting, partial tail registration, CoW
+on partial hits, and same-step deferral."""
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from tests.v1.core.test_prefix_caching import make_kv_cache_manager, make_request
+from vllm.utils.hashing import sha256
+from vllm.v1.core.kv_cache_utils import (
+ KVCacheBlockCopy,
+ get_block_hash,
+ get_group_id,
+ init_none_hash,
+)
+from vllm.v1.core.sched.scheduler import Scheduler
+from vllm.v1.kv_cache_interface import (
+ FullAttentionSpec,
+ KVCacheConfig,
+ KVCacheGroupSpec,
+ MambaSpec,
+)
+
+
+@pytest.fixture(autouse=True)
+def _auto_init_hash_fn():
+ init_none_hash(sha256)
+
+
+def test_mamba_align_split_partial_tail_schedule():
+ """Chunk ends with partial hits on: block-aligned chunks, one extra stop
+ at the prompt's last hash boundary (registering the partial tail), then
+ the remaining tokens. block=512, hash=32, prompt=10000, budget=8192:
+ 0 -> 8192 -> 9728 -> 9984 -> 10000."""
+ block_size = 512
+ hash_block_size = 32
+ mock = SimpleNamespace(
+ cache_config=SimpleNamespace(block_size=block_size),
+ use_eagle=False,
+ hash_block_size=hash_block_size,
+ mamba_partial_cache_hit=True,
+ )
+ split = Scheduler._mamba_block_aligned_split
+
+ req = make_request("0", [0] * 10000, hash_block_size, sha256)
+ req.num_computed_tokens = 0
+ assert split(self=mock, request=req, num_new_tokens=8192) == 8192
+ req.num_computed_tokens = 8192
+ # Stop at the last block boundary (9728).
+ assert split(self=mock, request=req, num_new_tokens=1808) == 1536
+ req.num_computed_tokens = 9728
+ # Extra stop at the prompt's last hash boundary (9984).
+ assert split(self=mock, request=req, num_new_tokens=272) == 256
+ req.num_computed_tokens = 9984
+ # Final 16 tokens run unchanged (no mid-block-resume stop: the next
+ # block boundary is past the last block boundary).
+ assert split(self=mock, request=req, num_new_tokens=16) == 16
+
+ # Partial hits off: no extra stop, the tail runs in one chunk.
+ mock.mamba_partial_cache_hit = False
+ req.num_computed_tokens = 9728
+ assert split(self=mock, request=req, num_new_tokens=272) == 272
+ mock.mamba_partial_cache_hit = True
+
+ # A request resumed mid-block (partial hash hit at 9984): the first chunk
+ # stops at the next block boundary (10240), later chunk ends re-align.
+ req2 = make_request("1", [0] * 12000, hash_block_size, sha256)
+ req2.num_computed_tokens = 9984
+ assert split(self=mock, request=req2, num_new_tokens=2016) == 256
+ req2.num_computed_tokens = 10240
+ assert split(self=mock, request=req2, num_new_tokens=1000) == 512
+
+
+def test_hybrid_mamba_align_partial_hash_hit():
+ hash_block_size = 2
+ mamba_block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=20,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=hash_block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=mamba_block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ )
+
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
+ assert num_computed == 0
+ blocks = manager.allocate_slots(req0, 6, num_computed, computed_blocks)
+ assert blocks is not None
+ manager.free(req0)
+ manager.new_step_starts()
+
+ partial_mamba_hash = req0.block_hashes[6 // hash_block_size - 1]
+ partial_mamba_block = manager.block_pool.get_cached_block(
+ partial_mamba_hash, kv_cache_group_ids=[1]
+ )
+ assert partial_mamba_block is not None
+ assert partial_mamba_block[0].block_hash_num_tokens == 6
+
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
+ assert num_computed == 6
+ assert [len(group) for group in computed_blocks.blocks] == [3, 2]
+
+ new_blocks = manager.allocate_slots(req1, 2, num_computed, computed_blocks)
+ assert new_blocks is not None
+ mamba_new_block_ids = new_blocks.get_block_ids()[1]
+ assert len(mamba_new_block_ids) == 1
+ assert mamba_new_block_ids[0] != partial_mamba_block[0].block_id
+ assert manager.get_blocks("1").get_block_ids()[1][1] == mamba_new_block_ids[0]
+ assert partial_mamba_block[0].block_hash is not None
+ assert get_block_hash(partial_mamba_block[0].block_hash) == partial_mamba_hash
+ assert get_group_id(partial_mamba_block[0].block_hash) == 1
+ assert partial_mamba_block[0].block_hash_num_tokens == 6
+ copies, _ = manager.take_kv_cache_block_copies()
+ assert (
+ KVCacheBlockCopy(
+ src_block_id=partial_mamba_block[0].block_id,
+ dst_block_id=mamba_new_block_ids[0],
+ )
+ in copies
+ )
+ assert manager.get_blocks("1").blocks[1][1].block_hash_num_tokens == 8
+
+
+def test_hybrid_mamba_partial_tail_owner_uses_cow_on_continue():
+ hash_block_size = 2
+ block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=24,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=hash_block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ )
+
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
+ assert num_computed == 0
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
+
+ partial_mamba_hash = req0.block_hashes[6 // hash_block_size - 1]
+ partial_mamba_block = manager.block_pool.get_cached_block(
+ partial_mamba_hash, kv_cache_group_ids=[1]
+ )
+ assert partial_mamba_block is not None
+ partial_mamba_block_id = partial_mamba_block[0].block_id
+ assert manager.get_blocks("0").get_block_ids()[1][1] == partial_mamba_block_id
+
+ req0.num_computed_tokens = 6
+ req0.append_output_token_ids([3])
+ new_blocks = manager.allocate_slots(req0, 1)
+ assert new_blocks is not None
+
+ # Reversed CoW for the owning request: it keeps its own block (the
+ # worker's block table is append-only), and no new mamba block is handed
+ # to the worker. The prefix-cache entry is moved to a private copy that
+ # the queued block copy fills before the next forward.
+ assert new_blocks.get_block_ids()[1] == []
+ assert manager.get_blocks("0").get_block_ids()[1][1] == partial_mamba_block_id
+ copies, _ = manager.take_kv_cache_block_copies()
+ cow_copy = next(c for c in copies if c.src_block_id == partial_mamba_block_id)
+ assert cow_copy.dst_block_id != partial_mamba_block_id
+ # The source block gave up the hash; the copy target now owns the entry.
+ assert partial_mamba_block[0].block_hash is None
+ moved = manager.block_pool.get_cached_block(
+ partial_mamba_hash, kv_cache_group_ids=[1]
+ )
+ assert moved is not None
+ assert moved[0].block_id == cow_copy.dst_block_id
+ assert get_block_hash(moved[0].block_hash) == partial_mamba_hash
+ assert get_group_id(moved[0].block_hash) == 1
+ assert moved[0].block_hash_num_tokens == 6
+
+
+def test_hybrid_mamba_partial_tail_owner_continue_preserves_later_hit():
+ hash_block_size = 2
+ block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=32,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=hash_block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ )
+
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
+ assert num_computed == 0
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
+
+ partial_mamba_hash = req0.block_hashes[6 // hash_block_size - 1]
+ partial_mamba_block = manager.block_pool.get_cached_block(
+ partial_mamba_hash, kv_cache_group_ids=[1]
+ )
+ assert partial_mamba_block is not None
+ partial_mamba_block_id = partial_mamba_block[0].block_id
+
+ req0.num_computed_tokens = 6
+ req0.append_output_token_ids([3])
+ assert manager.allocate_slots(req0, 1) is not None
+ # The owner moved the prefix-cache entry to a private copy; capture its id.
+ owner_copies, _ = manager.take_kv_cache_block_copies()
+ cow_copy = next(c for c in owner_copies if c.src_block_id == partial_mamba_block_id)
+ moved_block_id = cow_copy.dst_block_id
+ manager.new_step_starts()
+
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 4, 4], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
+ assert num_computed == 6
+ # The later request hits the moved (private-copy) entry, not the source.
+ assert computed_blocks.get_block_ids()[1][1] == moved_block_id
+
+ new_blocks = manager.allocate_slots(req1, 2, num_computed, computed_blocks)
+ assert new_blocks is not None
+ mamba_new_block_ids = new_blocks.get_block_ids()[1]
+ assert len(mamba_new_block_ids) == 1
+ assert mamba_new_block_ids[0] != moved_block_id
+ # The hitting request CoWs from the moved entry into its own private block.
+ copies, _ = manager.take_kv_cache_block_copies()
+ assert (
+ KVCacheBlockCopy(
+ src_block_id=moved_block_id,
+ dst_block_id=mamba_new_block_ids[0],
+ )
+ in copies
+ )
+
+
+def test_hybrid_mamba_moved_partial_entry_defers_same_step_hit():
+ """The owner's move re-arms the same-step guard: the moved entry is
+ filled by this step's copy, and chained same-step copies read stale
+ sources, so a request hitting it in the move step must be deferred."""
+ hash_block_size = 2
+ block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=32,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=hash_block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ )
+
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
+ assert num_computed == 0
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
+ manager.new_step_starts()
+
+ # The owning request continues decoding: the partial entry moves to a
+ # private copy in this step.
+ req0.num_computed_tokens = 6
+ req0.append_output_token_ids([3])
+ assert manager.allocate_slots(req0, 1) is not None
+
+ # A request hitting the moved entry in the SAME step must be deferred.
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 4, 4], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
+ assert num_computed == 6
+ assert manager.allocate_slots(req1, 2, num_computed, computed_blocks) is None
+
+ # Next step the moved entry is consumable.
+ manager.new_step_starts()
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
+ assert num_computed == 6
+ assert manager.allocate_slots(req1, 2, num_computed, computed_blocks) is not None
+
+
+def test_hybrid_full_attention_partial_hash_hit_uses_cow():
+ hash_block_size = 2
+ block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=24,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ )
+
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
+ assert num_computed == 0
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
+ manager.free(req0)
+ manager.new_step_starts()
+
+ partial_full_hash = req0.block_hashes[6 // hash_block_size - 1]
+ partial_full_block = manager.block_pool.get_cached_block(
+ partial_full_hash, kv_cache_group_ids=[0]
+ )
+ assert partial_full_block is not None
+
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
+ assert num_computed == 6
+ assert [len(group) for group in computed_blocks.blocks] == [2, 2]
+
+ new_blocks = manager.allocate_slots(req1, 2, num_computed, computed_blocks)
+ assert new_blocks is not None
+ full_new_block_ids = new_blocks.get_block_ids()[0]
+ assert len(full_new_block_ids) == 1
+ assert full_new_block_ids[0] != partial_full_block[0].block_id
+ assert partial_full_block[0].block_hash is not None
+ assert get_block_hash(partial_full_block[0].block_hash) == partial_full_hash
+ assert get_group_id(partial_full_block[0].block_hash) == 0
+ assert partial_full_block[0].block_hash_num_tokens == 6
+ copies, retained = manager.take_kv_cache_block_copies()
+ assert (
+ KVCacheBlockCopy(
+ src_block_id=partial_full_block[0].block_id,
+ dst_block_id=full_new_block_ids[0],
+ )
+ in copies
+ )
+ assert partial_full_block[0].ref_cnt == 1
+ manager.block_pool.free_blocks(retained)
+ assert partial_full_block[0].ref_cnt == 0
+
+
+def test_hybrid_partial_hit_cow_target_starts_uncached():
+ hash_block_size = 2
+ block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=32,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ )
+
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
+ assert num_computed == 0
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
+ manager.free(req0)
+ manager.new_step_starts()
+
+ partial_hash = req0.block_hashes[6 // hash_block_size - 1]
+ partial_full_block = manager.block_pool.get_cached_block(
+ partial_hash, kv_cache_group_ids=[0]
+ )
+ partial_mamba_block = manager.block_pool.get_cached_block(
+ partial_hash, kv_cache_group_ids=[1]
+ )
+ assert partial_full_block is not None
+ assert partial_mamba_block is not None
+
+ req1 = make_request("1", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
+ assert num_computed == 6
+
+ new_blocks = manager.allocate_slots(
+ req1,
+ 2,
+ num_computed,
+ computed_blocks,
+ delay_cache_blocks=True,
+ )
+ assert new_blocks is not None
+
+ full_cow_block = manager.get_blocks("1").blocks[0][1]
+ mamba_cow_block = manager.get_blocks("1").blocks[1][1]
+ assert full_cow_block.block_id != partial_full_block[0].block_id
+ assert mamba_cow_block.block_id != partial_mamba_block[0].block_id
+ assert full_cow_block.block_hash is None
+ assert full_cow_block.block_hash_num_tokens is None
+ assert mamba_cow_block.block_hash is None
+ assert mamba_cow_block.block_hash_num_tokens is None
+
+ assert partial_full_block[0].block_hash is not None
+ assert get_block_hash(partial_full_block[0].block_hash) == partial_hash
+ assert get_group_id(partial_full_block[0].block_hash) == 0
+ assert partial_full_block[0].block_hash_num_tokens == 6
+ assert partial_mamba_block[0].block_hash is not None
+ assert get_block_hash(partial_mamba_block[0].block_hash) == partial_hash
+ assert get_group_id(partial_mamba_block[0].block_hash) == 1
+ assert partial_mamba_block[0].block_hash_num_tokens == 6
+
+
+def test_hybrid_partial_hash_truncates_full_attention_hit_length():
+ hash_block_size = 2
+ block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=24,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ )
+ pool = manager.block_pool
+ req = make_request(
+ "0",
+ [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5],
+ hash_block_size,
+ sha256,
+ )
+
+ full_blocks = pool.get_new_blocks(3)
+ pool.cache_full_blocks(
+ request=req,
+ blocks=full_blocks,
+ num_cached_blocks=0,
+ num_full_blocks=2,
+ block_size=block_size,
+ kv_cache_group_id=0,
+ )
+ pool.cache_partial_block(
+ request=req,
+ block=full_blocks[2],
+ num_tokens=10,
+ kv_cache_group_id=0,
+ block_size=block_size,
+ )
+
+ mamba_block = pool.get_new_blocks(1)[0]
+ pool.cache_partial_block(
+ request=req,
+ block=mamba_block,
+ num_tokens=6,
+ kv_cache_group_id=1,
+ block_size=block_size,
+ )
+
+ computed_blocks, num_computed = manager.get_computed_blocks(req)
+ assert num_computed == 6
+ assert [len(group) for group in computed_blocks.blocks] == [2, 2]
+
+
+def test_cow_retained_blocks_returned_for_release():
+ """new_step_starts returns the CoW copy retentions instead of freeing
+ them; the scheduler owns releasing them once the copy has run."""
+ hash_block_size = 2
+ block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=24,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=hash_block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ )
+ req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
+ assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
+
+ # The owner's move queues a copy and retains both endpoints.
+ req0.num_computed_tokens = 6
+ req0.append_output_token_ids([3])
+ assert manager.allocate_slots(req0, 1) is not None
+ (cow_copy,), retained = manager.take_kv_cache_block_copies()
+ assert {b.block_id for b in retained} == {
+ cow_copy.src_block_id,
+ cow_copy.dst_block_id,
+ }
+ # Not freed yet: the retention refs are still held.
+ assert all(b.ref_cnt > 0 for b in retained)
+ manager.block_pool.free_blocks(retained)
+
+
+def test_free_cow_retained_blocks_defers_until_copy_step_processed():
+ """Scheduler releases CoW retentions immediately when the copy's step has
+ been processed (or deferral is off), and defers them otherwise."""
+ from collections import deque
+
+ freed: list = []
+ blocks = [SimpleNamespace(block_id=7), SimpleNamespace(block_id=9)]
+ mock = SimpleNamespace(
+ kv_cache_manager=SimpleNamespace(
+ block_pool=SimpleNamespace(free_blocks=freed.extend)
+ ),
+ deferred_frees=deque(),
+ defer_block_free=True,
+ processed_step_seq=2,
+ )
+ free = Scheduler._free_cow_retained_blocks
+
+ # Copy step still in flight: deferred with its fence.
+ free(mock, list(blocks), fence_seq=3)
+ assert not freed
+ assert mock.deferred_frees == deque([(3, blocks[::-1])])
+
+ # Copy step processed: freed immediately.
+ mock.processed_step_seq = 3
+ free(mock, list(blocks), fence_seq=3)
+ assert freed == blocks
+
+ # Deferral disabled: freed immediately regardless of the fence.
+ freed.clear()
+ mock.deferred_frees.clear()
+ mock.defer_block_free = False
+ mock.processed_step_seq = 0
+ free(mock, list(blocks), fence_seq=3)
+ assert freed == blocks
+
+
+def test_full_attention_eagle_drops_one_hash_unit():
+ """With fine-grained partial hits, eagle rewinds the hit by one hash unit
+ instead of a whole cache block: the tail block's KV is append-only, so it
+ still covers the reduced length and stays in the hit as a partial block."""
+ from vllm.v1.core.block_pool import BlockPool
+ from vllm.v1.core.single_type_kv_cache_manager import FullAttentionManager
+
+ hash_block_size = 2
+ block_size = 4
+ pool = BlockPool(
+ num_gpu_blocks=10, enable_caching=True, hash_block_size=hash_block_size
+ )
+ spec = FullAttentionSpec(
+ block_size=block_size, num_kv_heads=1, head_size=1, dtype=torch.float32
+ )
+ req = make_request("0", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
+
+ def find(drop_eagle_block):
+ return FullAttentionManager.find_longest_cache_hit(
+ block_hashes=req.block_hashes,
+ max_length=8,
+ kv_cache_group_ids=[0],
+ block_pool=pool,
+ kv_cache_spec=spec,
+ drop_eagle_block=drop_eagle_block,
+ alignment_tokens=hash_block_size,
+ )
+
+ # Two full cached blocks (hit 8): eagle rewinds to 6, keeping the last
+ # block as a partial hit instead of dropping it to 4.
+ blocks = pool.get_new_blocks(2)
+ pool.cache_full_blocks(
+ request=req,
+ blocks=blocks,
+ num_cached_blocks=0,
+ num_full_blocks=2,
+ block_size=block_size,
+ kv_cache_group_id=0,
+ )
+ hit_blocks, hit_length = find(drop_eagle_block=False)
+ assert (hit_length, len(hit_blocks[0])) == (8, 2)
+ hit_blocks, hit_length = find(drop_eagle_block=True)
+ assert (hit_length, len(hit_blocks[0])) == (6, 2)
+
+ # A partial tail at 6 (block 1 not fully cached): eagle rewinds to the
+ # block boundary and trims the tail block.
+ pool2 = BlockPool(
+ num_gpu_blocks=10, enable_caching=True, hash_block_size=hash_block_size
+ )
+ pool = pool2
+ blocks = pool.get_new_blocks(2)
+ pool.cache_full_blocks(
+ request=req,
+ blocks=blocks[:1],
+ num_cached_blocks=0,
+ num_full_blocks=1,
+ block_size=block_size,
+ kv_cache_group_id=0,
+ )
+ assert (
+ pool.cache_partial_block(
+ request=req,
+ block=blocks[1],
+ num_tokens=6,
+ kv_cache_group_id=0,
+ block_size=block_size,
+ )
+ is not None
+ )
+ hit_blocks, hit_length = find(drop_eagle_block=False)
+ assert (hit_length, len(hit_blocks[0])) == (6, 2)
+ hit_blocks, hit_length = find(drop_eagle_block=True)
+ assert (hit_length, len(hit_blocks[0])) == (4, 1)
+
+
+def test_hybrid_partial_hit_with_eagle_stays_within_group_blocks():
+ """Regression: with eagle, the mamba group must not receive the eagle
+ lookup margin — its finder never applies the drop, so it could return a
+ hit past the blocks the (dropped) full-attention group covers, crashing
+ the consumer's CoW with block_idx >= len(req_blocks)."""
+ hash_block_size = 2
+ block_size = 2 * hash_block_size
+ kv_cache_config = KVCacheConfig(
+ num_blocks=32,
+ kv_cache_tensors=[],
+ kv_cache_groups=[
+ KVCacheGroupSpec(
+ ["full"],
+ FullAttentionSpec(
+ block_size=block_size,
+ num_kv_heads=1,
+ head_size=1,
+ dtype=torch.float32,
+ ),
+ ),
+ KVCacheGroupSpec(
+ ["mamba"],
+ MambaSpec(
+ block_size=block_size,
+ shapes=(1, 1),
+ dtypes=(torch.float32,),
+ mamba_cache_mode="align",
+ ),
+ ),
+ ],
+ )
+ manager = make_kv_cache_manager(
+ kv_cache_config=kv_cache_config,
+ max_model_len=8192,
+ enable_caching=True,
+ hash_block_size=hash_block_size,
+ use_eagle=True,
+ )
+
+ # The owner prefills in scheduler-split style: stop at the block boundary
+ # (4), then at the prompt's last hash boundary (6, partial entries).
+ req0 = make_request("0", [7] * 6, hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req0)
+ assert manager.allocate_slots(req0, 4, num_computed, computed_blocks) is not None
+ req0.num_computed_tokens = 4
+ manager.new_step_starts()
+ assert manager.allocate_slots(req0, 2) is not None
+ req0.num_computed_tokens = 6
+ manager.new_step_starts()
+
+ # A longer request with eagle: full attention drops the partial tail, so
+ # the joint hit must fall back to the block boundary the FA blocks cover.
+ req1 = make_request("1", [7] * 6 + [9] * 2, hash_block_size, sha256)
+ computed_blocks, num_computed = manager.get_computed_blocks(req1)
+ assert num_computed == 4
+ assert all(
+ len(group) * block_size >= num_computed for group in computed_blocks.blocks
+ )
+ assert manager.allocate_slots(req1, 4, num_computed, computed_blocks) is not None
diff --git a/tests/v1/core/test_deferred_block_free.py b/tests/v1/core/test_deferred_block_free.py
index 8cab620f0e34..13789a396015 100644
--- a/tests/v1/core/test_deferred_block_free.py
+++ b/tests/v1/core/test_deferred_block_free.py
@@ -18,6 +18,7 @@
import pytest
from vllm.config import VllmConfig
+from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.outputs import ModelRunnerOutput
from vllm.v1.request import RequestStatus
@@ -412,3 +413,64 @@ def test_non_async_abort_defers_via_last_sched_seq():
scheduler.update_from_output(out0, _make_model_runner_output(out0))
assert not scheduler.deferred_frees
assert pool.get_num_free_blocks() == num_free_initially
+
+
+def test_cow_retentions_deferred_until_copy_step_processed():
+ """The endpoints of a queued KV block copy must stay out of the free
+ pool until the step that runs the copy has been processed. Freed
+ earlier, an endpoint can be reallocated (e.g. as a PD KV-load
+ destination) and overwritten by a transfer that is not ordered against
+ the copy still pending in the in-flight step.
+ """
+ scheduler = _create_deferring_scheduler()
+ pool = scheduler.kv_cache_manager.block_pool
+ manager = scheduler.kv_cache_manager.coordinator.single_type_managers[0]
+
+ request = create_requests(
+ num_requests=1,
+ num_tokens=NUM_PROMPT_TOKENS,
+ max_tokens=5,
+ stop_token_ids=[STOP_TOKEN_ID],
+ )[0]
+ scheduler.add_request(request)
+
+ # Simulate a partial-hit CoW performed while scheduling step 1, whose
+ # hitting request was freed within the same step: the copy rides out0
+ # and each endpoint stays alive only through its copy retention.
+ src_block, dst_block = pool.get_new_blocks(2)
+ block_copy = KVCacheBlockCopy(
+ src_block_id=src_block.block_id, dst_block_id=dst_block.block_id
+ )
+ manager._pending_cow_copies.append((src_block, dst_block))
+ out0 = scheduler.schedule()
+ assert out0.kv_cache_block_copies == [block_copy]
+
+ # Exhaust the rest of the pool so the copy endpoints are the only blocks
+ # a new request could receive, then add one that fits exactly in them.
+ pool.get_new_blocks(pool.get_num_free_blocks())
+ late_request = create_requests(
+ num_requests=1,
+ num_tokens=2 * scheduler.block_size,
+ max_tokens=5,
+ req_ids=["late"],
+ )[0]
+ scheduler.add_request(late_request)
+
+ # Step 2 is scheduled while step 1 (which runs the copy) is still in
+ # flight: the retentions are released against the copy's fence, so the
+ # endpoints must not reach the free pool -- the late request must not be
+ # scheduled onto them.
+ out1 = scheduler.schedule()
+ assert src_block.ref_cnt == 1
+ assert dst_block.ref_cnt == 1
+ assert scheduler.deferred_frees
+ assert not out1.scheduled_new_reqs
+
+ # Step 1's output is processed: the copy has run, endpoints return to
+ # the pool and the late request can be scheduled onto them safely.
+ scheduler.update_from_output(out0, _make_model_runner_output(out0))
+ assert src_block.ref_cnt == 0
+ assert dst_block.ref_cnt == 0
+ assert not scheduler.deferred_frees
+ out2 = scheduler.schedule()
+ assert [r.req_id for r in out2.scheduled_new_reqs] == ["late"]
diff --git a/tests/v1/core/test_kv_cache_utils.py b/tests/v1/core/test_kv_cache_utils.py
index 947672c48af6..0c212cb089a7 100644
--- a/tests/v1/core/test_kv_cache_utils.py
+++ b/tests/v1/core/test_kv_cache_utils.py
@@ -2597,3 +2597,63 @@ def test_hma_not_disabled_when_kv_events_enabled():
assert vllm_config.scheduler_config.disable_hybrid_kv_cache_manager is False, (
"kv_events_config must not force-disable the hybrid KV cache manager."
)
+
+
+def test_resolve_block_hashes_gate():
+ # Resolve symbols through the module so they stay consistent with each other
+ # even after other tests reload ``kv_cache_utils``.
+ resolve_block_hashes = kv_cache_utils.resolve_block_hashes
+ BlockHashListWithBlockSize = kv_cache_utils.BlockHashListWithBlockSize
+ # Raw, hash_block_size-granularity hashes (contents are opaque here).
+ raw = [BlockHash(bytes([i])) for i in range(8)]
+
+ # block_size == hash_block_size: always reuse the raw hashes.
+ assert resolve_block_hashes(raw, 2, 2, alignment_tokens=2) is raw
+ assert (
+ resolve_block_hashes(
+ raw, 2, 2, supports_fine_grained_hash_lookup=True, alignment_tokens=2
+ )
+ is raw
+ )
+
+ # Fine-grained manager, partial hits ON (alignment_tokens == hash_block_size
+ # < block_size): keep raw hashes so the manager can scan at hash granularity.
+ assert (
+ resolve_block_hashes(
+ raw, 2, 4, supports_fine_grained_hash_lookup=True, alignment_tokens=2
+ )
+ is raw
+ )
+
+ # Fine-grained manager, partial hits OFF (alignment_tokens ==
+ # scheduler_block_size >= block_size): must fall back to a block-size view,
+ # exactly like the pre-refactor coordinator did.
+ off = resolve_block_hashes(
+ raw, 2, 4, supports_fine_grained_hash_lookup=True, alignment_tokens=4
+ )
+ assert isinstance(off, BlockHashListWithBlockSize)
+ assert off.scale_factor == 2
+
+ # Non-fine-grained manager (e.g. sliding window): always a block-size view
+ # when block_size != hash_block_size, regardless of alignment_tokens.
+ swa = resolve_block_hashes(
+ raw, 2, 4, supports_fine_grained_hash_lookup=False, alignment_tokens=2
+ )
+ assert isinstance(swa, BlockHashListWithBlockSize)
+ assert swa.scale_factor == 2
+
+
+def test_resolve_block_hashes_rejects_mismatched_view():
+ resolve_block_hashes = kv_cache_utils.resolve_block_hashes
+ BlockHashListWithBlockSize = kv_cache_utils.BlockHashListWithBlockSize
+ raw = [BlockHash(bytes([i])) for i in range(8)]
+
+ # A view built at this block_size is returned as-is (idempotent).
+ view = BlockHashListWithBlockSize(raw, 2, 4)
+ assert resolve_block_hashes(view, 2, 4) is view
+
+ # A view built at a different block_size must fail loudly rather than be
+ # silently reinterpreted at the wrong granularity.
+ mismatched = BlockHashListWithBlockSize(raw, 2, 8)
+ with pytest.raises(AssertionError):
+ resolve_block_hashes(mismatched, 2, 4)
diff --git a/tests/v1/core/test_prefix_caching.py b/tests/v1/core/test_prefix_caching.py
index 03d61ec299f1..d0dc8a97d63e 100644
--- a/tests/v1/core/test_prefix_caching.py
+++ b/tests/v1/core/test_prefix_caching.py
@@ -1072,7 +1072,10 @@ def test_hybrid_cache_mamba_align_shared_prefix_detection():
# Next, validate scheduler logic for num_uncached_common_prefix_tokens > 0
# Create minimal mock with just the needed attributes
mock = SimpleNamespace(
- cache_config=SimpleNamespace(block_size=block_size), use_eagle=False
+ cache_config=SimpleNamespace(block_size=block_size),
+ use_eagle=False,
+ hash_block_size=block_size,
+ mamba_partial_cache_hit=False,
)
num_new_tokens_adjusted = Scheduler._mamba_block_aligned_split(
self=mock,
diff --git a/tests/v1/core/test_single_type_kv_cache_manager.py b/tests/v1/core/test_single_type_kv_cache_manager.py
index 609c1428d196..7300d45fbfc0 100644
--- a/tests/v1/core/test_single_type_kv_cache_manager.py
+++ b/tests/v1/core/test_single_type_kv_cache_manager.py
@@ -93,7 +93,7 @@ def run_one_case(block_is_cached, tail_token, expect_length):
kv_cache_spec=chunked_local_attention_spec,
drop_eagle_block=False,
alignment_tokens=block_size,
- )[0]
+ )[0][0]
assert len(computed_blocks) == expect_length
assert all(
@@ -164,7 +164,7 @@ def run_one_case(block_is_cached, expect_length):
kv_cache_spec=sliding_window_spec,
drop_eagle_block=False,
alignment_tokens=block_size,
- )[0]
+ )[0][0]
assert len(computed_blocks) == expect_length
assert all(
@@ -398,13 +398,13 @@ def test_get_num_blocks_to_allocate():
assert (
manager.get_num_blocks_to_allocate(
- "1", 20 * block_size, cached_blocks_1, 0, 20 * block_size
+ "1", 20 * block_size, cached_blocks_1, 0, 0, 20 * block_size
)
== 20
)
assert (
manager.get_num_blocks_to_allocate(
- "2", 20 * block_size, cached_blocks_2, 0, 20 * block_size
+ "2", 20 * block_size, cached_blocks_2, 0, 0, 20 * block_size
)
== 15
)
@@ -434,6 +434,7 @@ def test_evictable_cached_blocks_not_double_allocated():
num_tokens=2 * block_size,
new_computed_blocks=[evictable_block],
total_computed_tokens=block_size,
+ num_local_computed_tokens=block_size,
num_tokens_main_model=2 * block_size,
)
# Free capacity check should count evictable cached blocks, but allocation
@@ -474,13 +475,13 @@ def test_chunked_local_attention_get_num_blocks_to_allocate():
assert (
manager.get_num_blocks_to_allocate(
- "1", 20 * block_size, cached_blocks_1, 0, 20 * block_size
+ "1", 20 * block_size, cached_blocks_1, 0, 0, 20 * block_size
)
== 20
)
assert (
manager.get_num_blocks_to_allocate(
- "2", 20 * block_size, cached_blocks_2, 0, 20 * block_size
+ "2", 20 * block_size, cached_blocks_2, 0, 0, 20 * block_size
)
== 15
)
@@ -524,6 +525,7 @@ def test_predictor_matches_allocator_blocks_calculation_with_admission_cap():
num_tokens=num_tokens,
new_computed_blocks=[],
total_computed_tokens=total_computed,
+ num_local_computed_tokens=0,
num_tokens_main_model=num_tokens,
)
new_blocks = manager.allocate_new_blocks(
diff --git a/tests/v1/kv_connector/unit/test_mooncake_store_coordinator.py b/tests/v1/kv_connector/unit/test_mooncake_store_coordinator.py
index 2ad4b79164ac..6e003798c7a4 100644
--- a/tests/v1/kv_connector/unit/test_mooncake_store_coordinator.py
+++ b/tests/v1/kv_connector/unit/test_mooncake_store_coordinator.py
@@ -37,7 +37,7 @@ def _make_coord(groups, hash_block_size, use_eagle=False, retention_interval=Non
def test_external_cached_block_pool_tautological_returns_present_for_any_hash():
- cmap = ExternalCachedBlockPool()
+ cmap = ExternalCachedBlockPool(16)
h = BlockHash(b"\xaa" * 4)
res = cmap.get_cached_block(h, [0, 1])
assert res is not None
@@ -48,7 +48,7 @@ def test_external_cached_block_pool_tautological_returns_present_for_any_hash():
def test_external_cached_block_pool_hit_all_groups():
h = BlockHash(b"\x11\x22\x33\x44")
- cmap = ExternalCachedBlockPool({(0, bytes(h)), (1, bytes(h))})
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)), (1, bytes(h))})
res = cmap.get_cached_block(h, [0, 1])
assert res is not None
assert len(res) == 2
@@ -58,14 +58,14 @@ def test_external_cached_block_pool_hit_all_groups():
def test_external_cached_block_pool_miss_one_group():
h = BlockHash(b"\x11\x22\x33\x44")
- cmap = ExternalCachedBlockPool({(0, bytes(h))})
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h))})
assert cmap.get_cached_block(h, [0, 1]) is None
def test_external_cached_block_pool_unknown_hash():
h_known = BlockHash(b"\x01" * 4)
h_unknown = BlockHash(b"\x02" * 4)
- cmap = ExternalCachedBlockPool({(0, bytes(h_known))})
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h_known))})
assert cmap.get_cached_block(h_unknown, [0]) is None
@@ -103,7 +103,7 @@ def test_coordinator_single_full_attention_all_hits():
groups = [KVCacheGroupSpec(["L0"], _full(16))]
coord = _make_coord(groups, hash_block_size=16)
hs = _hashes(4)
- cmap = ExternalCachedBlockPool({(0, bytes(h)) for h in hs})
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)) for h in hs})
masks, hit = coord.find_longest_cache_hit(hs, max_length=64, cached_block_pool=cmap)
assert hit == 64
assert masks[0] == [True, True, True, True]
@@ -113,7 +113,7 @@ def test_coordinator_single_full_attention_partial_prefix():
groups = [KVCacheGroupSpec(["L0"], _full(16))]
coord = _make_coord(groups, hash_block_size=16)
hs = _hashes(4)
- cmap = ExternalCachedBlockPool({(0, bytes(hs[0])), (0, bytes(hs[1]))})
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(hs[0])), (0, bytes(hs[1]))})
masks, hit = coord.find_longest_cache_hit(hs, max_length=64, cached_block_pool=cmap)
assert hit == 32
assert masks[0] == [True, True]
@@ -123,7 +123,7 @@ def test_coordinator_single_full_attention_no_hits():
groups = [KVCacheGroupSpec(["L0"], _full(16))]
coord = _make_coord(groups, hash_block_size=16)
hs = _hashes(4)
- cmap = ExternalCachedBlockPool(set())
+ cmap = ExternalCachedBlockPool(16, set())
masks, hit = coord.find_longest_cache_hit(hs, max_length=64, cached_block_pool=cmap)
assert hit == 0
assert masks[0] == []
@@ -135,7 +135,7 @@ def test_coordinator_single_swa_tautological_pool_masks_pre_window():
groups = [KVCacheGroupSpec(["L0"], _swa(block_size=16, sliding_window=32))]
coord = _make_coord(groups, hash_block_size=16)
hs = _hashes(4) # 4 chunks * 16 tokens
- cmap = ExternalCachedBlockPool()
+ cmap = ExternalCachedBlockPool(16)
masks, hit = coord.find_longest_cache_hit(hs, max_length=64, cached_block_pool=cmap)
assert hit == 64
# ceil((sw-1)/block_size) = ceil(31/16) = 2 tail blocks.
@@ -153,7 +153,7 @@ def test_coordinator_hybrid_full_plus_swa_all_hit():
]
coord = _make_coord(groups, hash_block_size=16)
hs = _hashes(4)
- cmap = ExternalCachedBlockPool({(g, bytes(h)) for g in (0, 1) for h in hs})
+ cmap = ExternalCachedBlockPool(16, {(g, bytes(h)) for g in (0, 1) for h in hs})
_masks, hit = coord.find_longest_cache_hit(
hs, max_length=64, cached_block_pool=cmap
)
@@ -169,7 +169,7 @@ def test_coordinator_hybrid_hole_in_full_clips_both():
hs = _hashes(4)
exists = {(0, bytes(hs[0])), (0, bytes(hs[2])), (0, bytes(hs[3]))}
exists |= {(1, bytes(h)) for h in hs}
- cmap = ExternalCachedBlockPool(exists)
+ cmap = ExternalCachedBlockPool(16, exists)
_masks, hit = coord.find_longest_cache_hit(
hs, max_length=64, cached_block_pool=cmap
)
@@ -188,7 +188,7 @@ def test_coordinator_group_block_size_double_hash():
big_hashes = list(chunk_hashes_for_block_size(hs, 16, 32))
exists = {(0, bytes(h)) for h in hs}
exists |= {(1, bytes(bh)) for bh in big_hashes}
- cmap = ExternalCachedBlockPool(exists)
+ cmap = ExternalCachedBlockPool(16, exists)
_masks, hit = coord.find_longest_cache_hit(
hs, max_length=64, cached_block_pool=cmap
)
@@ -405,7 +405,7 @@ def test_lookup_with_eagle_pops_last_full_attention_block():
groups = [KVCacheGroupSpec(["L0"], _full(16))]
coord = _make_coord(groups, hash_block_size=16, use_eagle=True)
hs = _hashes(4)
- cmap = ExternalCachedBlockPool({(0, bytes(h)) for h in hs})
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)) for h in hs})
_masks, hit = coord.find_longest_cache_hit(
hs, max_length=64, cached_block_pool=cmap
)
@@ -426,7 +426,7 @@ def test_load_mask_with_eagle_does_not_double_prune_full_attention():
groups = [KVCacheGroupSpec(["L0"], _full(16))]
coord = _make_coord(groups, hash_block_size=16, use_eagle=True)
hs = _hashes(4)
- cmap = ExternalCachedBlockPool({(0, bytes(h)) for h in hs})
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)) for h in hs})
_masks, hit = coord.find_longest_cache_hit(
hs, max_length=64, cached_block_pool=cmap
)
@@ -450,7 +450,7 @@ def test_load_mask_with_eagle_hybrid_full_plus_swa():
coord = _make_coord(groups, hash_block_size=16, use_eagle=True)
hs = _hashes(4)
exists = {(g, bytes(h)) for g in (0, 1) for h in hs}
- cmap = ExternalCachedBlockPool(exists)
+ cmap = ExternalCachedBlockPool(16, exists)
_masks, hit = coord.find_longest_cache_hit(
hs, max_length=64, cached_block_pool=cmap
)
@@ -469,7 +469,7 @@ def test_load_mask_without_eagle_unchanged():
groups = [KVCacheGroupSpec(["L0"], _full(16))]
coord = _make_coord(groups, hash_block_size=16, use_eagle=False)
hs = _hashes(4)
- cmap = ExternalCachedBlockPool({(0, bytes(h)) for h in hs})
+ cmap = ExternalCachedBlockPool(16, {(0, bytes(h)) for h in hs})
_masks, hit = coord.find_longest_cache_hit(
hs, max_length=64, cached_block_pool=cmap
)
diff --git a/tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py b/tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
index 6dcb3c914bad..d0a50f465796 100644
--- a/tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
+++ b/tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
@@ -65,6 +65,7 @@ def _minimal_vllm_config(cache_block_size=16):
cfg.cache_config.block_size = cache_block_size
cfg.cache_config.num_gpu_blocks = 4
cfg.cache_config.hash_block_size = None
+ cfg.cache_config.prefix_match_unit = None
cfg.cache_config.enable_prefix_caching = True
cfg.parallel_config.prefill_context_parallel_size = 1
cfg.parallel_config.decode_context_parallel_size = 1
diff --git a/vllm/config/cache.py b/vllm/config/cache.py
index 1091ec5f5057..a628e7d7cdd0 100644
--- a/vllm/config/cache.py
+++ b/vllm/config/cache.py
@@ -53,17 +53,17 @@ class CacheConfig:
"""Whether block_size was explicitly provided. Derived automatically."""
user_specified_mamba_block_size: bool = field(default=False, init=False)
"""Whether mamba_block_size was explicitly provided. Derived automatically."""
- hash_block_size: int | None = Field(default=None, gt=0)
- """Block size (in tokens) used for computing Request's block_hashes.
+ prefix_match_unit: int | None = Field(default=None, gt=0)
+ """The finest token boundary (in tokens) a prefix-cache hit can land on.
- This can be set to a finer granularity than the physical KV cache block
- sizes (e.g. 8) as long as every KV cache group's `block_size` is divisible
- by it. This enables prefix-caching keys to be computed at the finest common
- granularity and then merged for larger physical block sizes.
+ Prefix-cache keys are computed every `prefix_match_unit` tokens. It can
+ be set finer than the physical KV cache block sizes (e.g. 32 vs a
+ 1024-token hybrid-model block) as long as every KV cache group's
+ `block_size` is divisible by it, enabling cache hits at boundaries
+ inside a physical block. It controls matching granularity only, not how
+ often states are stored.
- This config is not static default. If left unspecified, vLLM will choose a
- default based on the resolved KV cache groups (typically the smallest KV
- cache block size when there are multiple groups).
+ This equals to the `hash_block_size` used throughout the KV cache code.
"""
gpu_memory_utilization: float = Field(default=0.92, gt=0, le=1)
"""The fraction of GPU memory to be used for the model executor, which can
@@ -209,7 +209,7 @@ def compute_hash(self) -> str:
"enable_prefix_caching",
"prefix_caching_hash_algo",
# Prefix-caching implementation detail (doesn't affect compiled graph).
- "hash_block_size",
+ "prefix_match_unit",
"mamba_page_size_padded",
"skip_page_size_padded",
"user_specified_block_size",
diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py b/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
index 6923ceb24dfc..b08f451d2dfd 100644
--- a/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
+++ b/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
@@ -29,10 +29,15 @@
class ExternalCachedBlockPool:
"""Duck-typed BlockPool backed by a ``(group_id, hash)`` exists set."""
- def __init__(self, exists: set[tuple[int, bytes]] | None = None) -> None:
+ def __init__(
+ self,
+ hash_block_size: int,
+ exists: set[tuple[int, bytes]] | None = None,
+ ) -> None:
# ``exists=None`` is used on the recv side where hit_length is already
# determined and we just want each spec's manager to apply its own mask.
self._exists = exists
+ self.hash_block_size = hash_block_size
self.null_block = KVCacheBlock(block_id=0)
# Dummy ID 1 for present block for duck-typing.
self._present_block = KVCacheBlock(block_id=1)
@@ -166,7 +171,7 @@ def load_mask(
masks, _ = self.find_longest_cache_hit(
block_hashes,
token_len,
- ExternalCachedBlockPool(),
+ ExternalCachedBlockPool(self.hash_block_size),
apply_eagle=False,
)
return masks
@@ -270,7 +275,7 @@ def _find_hit_blocks(
if len(self.attention_groups) == 1:
spec, group_ids, manager_cls = self.attention_groups[0]
hashes = self.block_hashes_for_spec(block_hashes, spec)
- hit_blocks = manager_cls.find_longest_cache_hit(
+ hit_blocks, hit_length = manager_cls.find_longest_cache_hit(
block_hashes=hashes, # type: ignore[arg-type]
max_length=max_length,
kv_cache_group_ids=group_ids,
@@ -283,11 +288,12 @@ def _find_hit_blocks(
blocks_by_group: list[list[KVCacheBlock]] = [[] for _ in range(num_groups)]
for gid, blks in zip(group_ids, hit_blocks, strict=True):
blocks_by_group[gid] = blks
- return tuple(blocks_by_group), len(hit_blocks[0]) * spec.block_size
+ return tuple(blocks_by_group), hit_length
num_groups = len(self.kv_cache_groups)
hit_length = max_length
hit_blocks_by_group: list[list[KVCacheBlock] | None] = [None] * num_groups
+ hit_length_by_group: list[int] = [0] * num_groups
is_simple_hybrid = len(self.attention_groups) == 2 and isinstance(
self.attention_groups[0][0], FullAttentionSpec
@@ -298,10 +304,11 @@ def _find_hit_blocks(
curr_hit_length = hit_length
for idx, (spec, group_ids, manager_cls) in enumerate(self.attention_groups):
- cached = hit_blocks_by_group[group_ids[0]]
+ first_group_id = group_ids[0]
+ cached = hit_blocks_by_group[first_group_id]
if isinstance(spec, FullAttentionSpec) and cached is not None:
- curr_hit_length = (
- curr_hit_length // spec.block_size * spec.block_size
+ curr_hit_length = min(
+ curr_hit_length, hit_length_by_group[first_group_id]
)
continue
@@ -310,7 +317,7 @@ def _find_hit_blocks(
if drop_eagle_block:
_max_length = min(curr_hit_length + spec.block_size, max_length)
hashes = self.block_hashes_for_spec(block_hashes, spec)
- hit_blocks = manager_cls.find_longest_cache_hit(
+ hit_blocks, _new_hit_length = manager_cls.find_longest_cache_hit(
block_hashes=hashes, # type: ignore[arg-type]
max_length=_max_length,
kv_cache_group_ids=group_ids,
@@ -319,7 +326,6 @@ def _find_hit_blocks(
drop_eagle_block=drop_eagle_block,
alignment_tokens=self.lcm_block_size,
)
- _new_hit_length = len(hit_blocks[0]) * spec.block_size
if drop_eagle_block:
eagle_verified.add(idx)
elif _new_hit_length < curr_hit_length:
@@ -327,6 +333,7 @@ def _find_hit_blocks(
curr_hit_length = _new_hit_length
for gid, blocks in zip(group_ids, hit_blocks, strict=True):
hit_blocks_by_group[gid] = blocks
+ hit_length_by_group[gid] = _new_hit_length
if curr_hit_length >= hit_length:
break
@@ -343,6 +350,7 @@ def _find_hit_blocks(
full_blks = hit_blocks_by_group[gid]
assert full_blks is not None
del full_blks[num_blocks:]
+ hit_length_by_group[gid] = hit_length
return (
tuple(blks if blks is not None else [] for blks in hit_blocks_by_group),
diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py b/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
index aa48d5123ef7..38f4cb0c3a1e 100644
--- a/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+++ b/vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
@@ -1523,7 +1523,9 @@ def lookup(self, token_len: int, block_hashes: Sequence[BlockHash]) -> int:
}
_masks, hit_length = self.coord.find_longest_cache_hit(
- block_hashes, token_len, ExternalCachedBlockPool(exists_set)
+ block_hashes,
+ token_len,
+ ExternalCachedBlockPool(self.hash_block_size, exists_set),
)
return hit_length
diff --git a/vllm/engine/arg_utils.py b/vllm/engine/arg_utils.py
index c602eaf8f2ed..742d62ac3698 100644
--- a/vllm/engine/arg_utils.py
+++ b/vllm/engine/arg_utils.py
@@ -693,6 +693,7 @@ class EngineArgs:
mamba_cache_dtype: MambaDType = CacheConfig.mamba_cache_dtype
mamba_ssm_cache_dtype: MambaDType = CacheConfig.mamba_ssm_cache_dtype
mamba_block_size: int | None = get_field(CacheConfig, "mamba_block_size")
+ prefix_match_unit: int | None = get_field(CacheConfig, "prefix_match_unit")
mamba_cache_mode: MambaCacheMode = CacheConfig.mamba_cache_mode
mamba_backend: MambaBackendEnum = MambaBackendEnum.TRITON
@@ -1194,6 +1195,9 @@ def add_cli_args(parser: FlexibleArgumentParser) -> FlexibleArgumentParser:
cache_group.add_argument(
"--mamba-block-size", **cache_kwargs["mamba_block_size"]
)
+ cache_group.add_argument(
+ "--prefix-match-unit", **cache_kwargs["prefix_match_unit"]
+ )
cache_group.add_argument(
"--mamba-cache-mode", **cache_kwargs["mamba_cache_mode"]
)
@@ -1899,6 +1903,7 @@ def create_engine_config(
mamba_cache_dtype=self.mamba_cache_dtype,
mamba_ssm_cache_dtype=self.mamba_ssm_cache_dtype,
mamba_block_size=self.mamba_block_size,
+ prefix_match_unit=self.prefix_match_unit,
mamba_cache_mode=self.mamba_cache_mode,
kv_offloading_size=self.kv_offloading_size,
kv_offloading_backend=self.kv_offloading_backend,
diff --git a/vllm/v1/core/block_pool.py b/vllm/v1/core/block_pool.py
index bc8f2d87adb9..b42c7662d2ba 100644
--- a/vllm/v1/core/block_pool.py
+++ b/vllm/v1/core/block_pool.py
@@ -260,7 +260,9 @@ def cache_full_blocks(
return
new_full_blocks = blocks[num_cached_blocks:num_full_blocks]
assert block_mask is None or len(block_mask) == len(new_full_blocks)
- block_hashes = resolve_block_hashes(request, self.hash_block_size, block_size)
+ block_hashes = resolve_block_hashes(
+ request.block_hashes, self.hash_block_size, block_size
+ )
new_block_hashes = block_hashes[num_cached_blocks:]
new_hashes: list[ExternalBlockHash] | None = (
@@ -390,7 +392,9 @@ def emit_cached_block_events(
if not self.enable_kv_cache_events or num_cached_blocks == 0:
return
- block_hashes = resolve_block_hashes(request, self.hash_block_size, block_size)
+ block_hashes = resolve_block_hashes(
+ request.block_hashes, self.hash_block_size, block_size
+ )
# Collect external hashes and extra_keys for cached blocks.
cached_hashes: list[ExternalBlockHash] = []
@@ -622,6 +626,24 @@ def _insert_block_hash(
)
self.cached_block_hash_to_block.insert(block_hash_with_group_id, block)
+ def move_block_hashes(
+ self,
+ src_block: KVCacheBlock,
+ dst_block: KVCacheBlock,
+ ) -> None:
+ """Re-point ``src_block``'s prefix-cache entries to ``dst_block``.
+
+ Used when the request owning ``src_block`` keeps writing into it
+ : the prefix cache holds a private copy (``dst_block``)
+ under the same hashes instead. Entries stay live; no events emitted.
+ """
+ assert dst_block.block_hash is None
+ assert dst_block.block_id not in self.cached_block_hashes_by_block
+ num_tokens = src_block.block_hash_num_tokens
+ for block_hash in self._remove_cached_block_hashes(src_block):
+ # `num_tokens` only applies to the first (primary) insertion.
+ self._insert_block_hash(block_hash, dst_block, num_tokens=num_tokens)
+
def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
"""Get new blocks from the free block pool.
diff --git a/vllm/v1/core/kv_cache_coordinator.py b/vllm/v1/core/kv_cache_coordinator.py
index 2e978451b885..bf88712aed6e 100644
--- a/vllm/v1/core/kv_cache_coordinator.py
+++ b/vllm/v1/core/kv_cache_coordinator.py
@@ -5,12 +5,11 @@
from typing import NamedTuple
from vllm import envs
+from vllm.utils.math_utils import cdiv
from vllm.v1.core.block_pool import BlockPool
from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
from vllm.v1.core.kv_cache_utils import (
BlockHash,
- BlockHashList,
- BlockHashListWithBlockSize,
KVCacheBlock,
)
from vllm.v1.core.single_type_kv_cache_manager import (
@@ -135,6 +134,7 @@ def get_num_blocks_to_allocate(
new_computed_blocks: tuple[Sequence[KVCacheBlock], ...],
num_encoder_tokens: int,
total_computed_tokens: int,
+ num_local_computed_tokens: int,
num_tokens_main_model: int,
apply_admission_cap: bool = False,
) -> int:
@@ -150,6 +150,8 @@ def get_num_blocks_to_allocate(
num_encoder_tokens: The number of encoder tokens for allocating
blocks for cross-attention.
total_computed_tokens: Include both local and external tokens.
+ num_local_computed_tokens: The number of local prefix-cache computed
+ tokens.
num_tokens_main_model: The number of tokens for the main model (aka target
model in spec decode). w/o spec decode, it is num_tokens;
with spec decode, it is num_tokens - num_lookahead_tokens.
@@ -171,6 +173,7 @@ def get_num_blocks_to_allocate(
num_encoder_tokens,
[],
0,
+ 0,
num_encoder_tokens,
apply_admission_cap=apply_admission_cap,
)
@@ -180,6 +183,7 @@ def get_num_blocks_to_allocate(
num_tokens,
new_computed_blocks[i],
total_computed_tokens,
+ num_local_computed_tokens,
num_tokens_main_model,
apply_admission_cap=apply_admission_cap,
)
@@ -370,7 +374,7 @@ def find_longest_cache_hit(
pass
def new_step_starts(self) -> None:
- """Called when a new step is started."""
+ """Notify each manager that a new step is starting."""
for manager in self.single_type_managers:
manager.new_step_starts()
@@ -483,7 +487,7 @@ def find_longest_cache_hit(
block_hashes: list[BlockHash],
max_cache_hit_length: int,
) -> tuple[tuple[list[KVCacheBlock], ...], int]:
- hit_blocks = self.single_type_managers[0].find_longest_cache_hit(
+ hit_blocks, hit_length = self.single_type_managers[0].find_longest_cache_hit(
block_hashes=block_hashes,
max_length=max_cache_hit_length,
kv_cache_group_ids=[0],
@@ -494,7 +498,7 @@ def find_longest_cache_hit(
dcp_world_size=self.dcp_world_size,
pcp_world_size=self.pcp_world_size,
)
- return hit_blocks, len(hit_blocks[0]) * self.block_size
+ return hit_blocks, hit_length
class SpecGroup(NamedTuple):
@@ -572,8 +576,26 @@ def __init__(
"full-attention and Mamba groups, got: "
f"{type(g.kv_cache_spec).__name__}."
)
+ # Partial hash hits are limited to full-attention + mamba ("align")
+ # without context parallelism.
+ self.enable_partial_hash_hits = dcp_world_size == 1 and any(
+ isinstance(g.kv_cache_spec, MambaSpec)
+ and g.kv_cache_spec.mamba_cache_mode == "align"
+ and g.kv_cache_spec.block_size > hash_block_size
+ for g in kv_cache_config.kv_cache_groups
+ )
self.verify_and_split_kv_cache_groups()
+ @property
+ def _cache_hit_alignment_tokens(self) -> int:
+ # Fine-grained partial hits may return hash-block-aligned lengths;
+ # otherwise it must stay scheduler-block-aligned.
+ return (
+ self.hash_block_size
+ if self.enable_partial_hash_hits
+ else self.scheduler_block_size
+ )
+
def verify_and_split_kv_cache_groups(self) -> None:
"""
Groups KV cache groups by their spec type for efficient batch processing
@@ -617,14 +639,19 @@ def verify_and_split_kv_cache_groups(self) -> None:
self.single_type_managers[gid].use_eagle = True
def cache_blocks(self, request: Request, num_computed_tokens: int) -> None:
- # Cache hits in this coordinator are always a multiple of
- # ``scheduler_block_size`` tokens (see ``find_longest_cache_hit``).
- # Within an aligned region, SWA groups may only consult a subset of blocks
- # per ``scheduler_block_size``-segment so the unused blocks also stay
- # out of the prefix-cache hash map.
- aligned_num_computed_tokens = (
- num_computed_tokens // self.scheduler_block_size * self.scheduler_block_size
- )
+ if self.enable_partial_hash_hits:
+ aligned_num_computed_tokens = num_computed_tokens
+ else:
+ # Cache hits in this coordinator are always a multiple of
+ # ``scheduler_block_size`` tokens (see ``find_longest_cache_hit``).
+ # Within an aligned region, SWA groups may only consult a subset of
+ # blocks per ``scheduler_block_size``-segment so the unused blocks
+ # also stay out of the prefix-cache hash map.
+ aligned_num_computed_tokens = (
+ num_computed_tokens
+ // self.scheduler_block_size
+ * self.scheduler_block_size
+ )
for manager in self.single_type_managers:
num_tokens_to_cache = aligned_num_computed_tokens
# EAGLE groups match one block past each aligned boundary and drop
@@ -667,17 +694,11 @@ def find_longest_cache_hit(
- The number of tokens of the longest cache hit.
"""
- def _get_block_hashes(block_size: int) -> BlockHashList:
- if block_size == self.hash_block_size:
- return block_hashes
- return BlockHashListWithBlockSize(
- block_hashes, self.hash_block_size, block_size
- )
-
num_groups = len(self.kv_cache_config.kv_cache_groups)
hit_length = max_cache_hit_length
longest_hit_length = 0
hit_blocks_by_group: list[list[KVCacheBlock] | None] = [None] * num_groups
+ hit_length_by_group: list[int] = [0] * num_groups
# Simple hybrid (1 full attn + 1 other): one iteration suffices.
# Full attn is always first if it exists.
@@ -696,40 +717,53 @@ def _get_block_hashes(block_size: int) -> BlockHashList:
for idx, (spec, group_ids, manager_cls, use_eagle) in enumerate(
self.attention_groups
):
- group_block_size = self.single_type_managers[group_ids[0]].block_size
- cached_blocks = hit_blocks_by_group[group_ids[0]]
+ first_group_id = group_ids[0]
+ # DCP/PCP shard each block's KV across ranks, so the manager's
+ # effective block size may exceed the spec's.
+ group_block_size = self.single_type_managers[first_group_id].block_size
+ cached_blocks = hit_blocks_by_group[first_group_id]
if isinstance(spec, FullAttentionSpec) and cached_blocks is not None:
# Full attention is downward-closed: we only need to look
# up cached blocks once; on subsequent iterations just trim
# to the (reduced) current hit length.
- curr_hit_length = (
- curr_hit_length // group_block_size * group_block_size
+ curr_hit_length = min(
+ curr_hit_length, hit_length_by_group[first_group_id]
)
continue
drop_eagle_block = use_eagle and idx not in eagle_verified
_max_length = curr_hit_length
- if drop_eagle_block:
- # Eagle needs to match one more block and then pop the last.
+ # Eagle matches one extra drop unit (one hash unit for
+ # fine-grained managers, else one cache block) and then drops
+ # it, landing back at the candidate length. No margin for
+ # mamba: its finder never drops (draft models have no mamba
+ # layers), so the hit would grow past the candidate.
+ if drop_eagle_block and not isinstance(spec, MambaSpec):
+ eagle_margin = (
+ self.hash_block_size
+ if self.enable_partial_hash_hits
+ and manager_cls.supports_fine_grained_hash_lookup
+ and group_block_size > self.hash_block_size
+ else group_block_size
+ )
_max_length = min(
- curr_hit_length + group_block_size, max_cache_hit_length
+ curr_hit_length + eagle_margin, max_cache_hit_length
)
- hit_blocks = manager_cls.find_longest_cache_hit(
- block_hashes=_get_block_hashes(group_block_size),
+ hit_blocks, _new_hit_length = manager_cls.find_longest_cache_hit(
+ block_hashes=block_hashes,
max_length=_max_length,
kv_cache_group_ids=group_ids,
block_pool=self.block_pool,
kv_cache_spec=spec,
drop_eagle_block=drop_eagle_block,
- alignment_tokens=self.scheduler_block_size,
+ alignment_tokens=self._cache_hit_alignment_tokens,
dcp_world_size=(
self.dcp_world_size
if isinstance(spec, FullAttentionSpec)
else 1
),
)
- _new_hit_length = len(hit_blocks[0]) * group_block_size
if drop_eagle_block:
eagle_verified.add(idx)
elif _new_hit_length < curr_hit_length:
@@ -738,6 +772,7 @@ def _get_block_hashes(block_size: int) -> BlockHashList:
curr_hit_length = _new_hit_length
for group_id, blocks in zip(group_ids, hit_blocks):
hit_blocks_by_group[group_id] = blocks
+ hit_length_by_group[group_id] = _new_hit_length
longest_hit_length = max(longest_hit_length, curr_hit_length)
@@ -753,10 +788,11 @@ def _get_block_hashes(block_size: int) -> BlockHashList:
group_block_size = self.single_type_managers[
first_group.group_ids[0]
].block_size
- num_blocks = hit_length // group_block_size
+ num_blocks = cdiv(hit_length, group_block_size)
for group_id in first_group.group_ids:
if (blks := hit_blocks_by_group[group_id]) is not None:
del blks[num_blocks:]
+ hit_length_by_group[group_id] = hit_length
# Uncached shared prefix detection: If any attn. group cached a longer prefix
# than the current prefix, it is an uncached common prefix across requests:
@@ -776,28 +812,20 @@ def find_longest_cache_hit_per_group(
(blocks_per_group, hit_lengths_per_group)
"""
- def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList:
- if kv_cache_spec.block_size == self.hash_block_size:
- return block_hashes
- return BlockHashListWithBlockSize(
- block_hashes, self.hash_block_size, kv_cache_spec.block_size
- )
-
num_groups = len(self.kv_cache_config.kv_cache_groups)
hit_blocks: list[list[KVCacheBlock]] = [[] for _ in range(num_groups)]
hit_lengths: list[int] = [0] * num_groups
for spec, group_ids, manager_cls, use_eagle in self.attention_groups:
- blocks = manager_cls.find_longest_cache_hit(
- block_hashes=_get_block_hashes(spec),
+ blocks, group_hit = manager_cls.find_longest_cache_hit(
+ block_hashes=block_hashes,
max_length=max_cache_hit_length,
kv_cache_group_ids=group_ids,
block_pool=self.block_pool,
kv_cache_spec=spec,
drop_eagle_block=use_eagle,
- alignment_tokens=self.scheduler_block_size,
+ alignment_tokens=self._cache_hit_alignment_tokens,
)
- group_hit = len(blocks[0]) * spec.block_size
for gid, blks in zip(group_ids, blocks):
hit_blocks[gid] = blks
hit_lengths[gid] = group_hit
diff --git a/vllm/v1/core/kv_cache_manager.py b/vllm/v1/core/kv_cache_manager.py
index 4d5d613866ad..4f06b58e3095 100644
--- a/vllm/v1/core/kv_cache_manager.py
+++ b/vllm/v1/core/kv_cache_manager.py
@@ -11,7 +11,7 @@
from vllm.utils.math_utils import cdiv
from vllm.v1.core.kv_cache_coordinator import get_kv_cache_coordinator
from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
-from vllm.v1.core.kv_cache_utils import KVCacheBlock
+from vllm.v1.core.kv_cache_utils import KVCacheBlock, KVCacheBlockCopy
from vllm.v1.kv_cache_interface import (
AttentionSpec,
CrossAttentionSpec,
@@ -404,6 +404,7 @@ def allocate_slots(
new_computed_blocks=new_computed_block_list,
num_encoder_tokens=num_encoder_tokens,
total_computed_tokens=total_computed_tokens,
+ num_local_computed_tokens=num_local_computed_tokens,
num_tokens_main_model=full_num_tokens,
apply_admission_cap=True,
)
@@ -438,6 +439,7 @@ def allocate_slots(
num_encoder_tokens=num_encoder_tokens,
total_computed_tokens=num_local_computed_tokens
+ num_external_computed_tokens,
+ num_local_computed_tokens=num_local_computed_tokens,
num_tokens_main_model=num_tokens_main_model,
)
@@ -665,6 +667,23 @@ def take_new_block_ids(self) -> list[int]:
ids.extend(mgr.take_new_block_ids())
return ids
+ def take_kv_cache_block_copies(
+ self,
+ ) -> tuple[list[KVCacheBlockCopy], list[KVCacheBlock]]:
+ """Drain pending copies and return their retained endpoints."""
+ pending_copies: list[tuple[KVCacheBlock, KVCacheBlock]] = []
+ for mgr in self.coordinator.single_type_managers:
+ pending_copies.extend(mgr.take_pending_cow_copies())
+ copies = [
+ KVCacheBlockCopy(
+ src_block_id=source_block.block_id,
+ dst_block_id=cow_block.block_id,
+ )
+ for source_block, cow_block in pending_copies
+ ]
+ retained_blocks = [block for pair in pending_copies for block in pair]
+ return copies, retained_blocks
+
def new_step_starts(self) -> None:
- """Called when a new step is started."""
+ """Notify the coordinator that a new step is starting."""
self.coordinator.new_step_starts()
diff --git a/vllm/v1/core/kv_cache_utils.py b/vllm/v1/core/kv_cache_utils.py
index 27af83c9a516..8adc47267274 100644
--- a/vllm/v1/core/kv_cache_utils.py
+++ b/vllm/v1/core/kv_cache_utils.py
@@ -10,7 +10,7 @@
from collections.abc import Callable, Iterable, Iterator, Sequence
from dataclasses import dataclass, replace
from functools import partial
-from typing import Any, NewType, TypeAlias, cast, overload
+from typing import Any, NamedTuple, NewType, TypeAlias, cast, overload
from vllm import envs
from vllm.config import VllmConfig
@@ -126,7 +126,7 @@ class KVCacheBlock:
# when the block is full and cached.
_block_hash: BlockHashWithGroupId | None = None
# Number of prefix tokens covered by _block_hash. For full blocks this is
- # the full block boundary; partial aliases can end inside a cache block.
+ # the full block boundary; partial entries can end inside a cache block.
_block_hash_num_tokens: int | None = None
# Used to construct a doubly linked list for free blocks.
@@ -176,6 +176,11 @@ def __repr__(self) -> str:
)
+class KVCacheBlockCopy(NamedTuple):
+ src_block_id: int
+ dst_block_id: int
+
+
class FreeKVCacheBlockQueue:
"""This class organizes a list of KVCacheBlock objects to a doubly linked
list of free blocks. We implement this class instead of using Python
@@ -631,11 +636,11 @@ def resolve_kv_cache_block_sizes(
Mamba groups keep their full per-rank state and are not scaled.
- ``hash_block_size`` is the granularity at which ``Request.block_hashes``
is computed. Single group: equals scheduler block size. Multiple groups:
- ``cache_config.hash_block_size`` override if set, else the GCD of group
- block sizes; every group's block size must be divisible by it. Returns
- the scheduler block size (i.e. disables finer hashing) if block hashing
- is inactive or a mamba group's block size diverges from the cache
- block size (mamba_cache_mode != "align").
+ ``cache_config.prefix_match_unit`` override if set, else the GCD of
+ group block sizes; every group's block size must be divisible by it.
+ Returns the scheduler block size (i.e. disables finer hashing) if block
+ hashing is inactive or a mamba group's block size diverges from the
+ cache block size (mamba_cache_mode != "align").
"""
cache_config = vllm_config.cache_config
dcp = vllm_config.parallel_config.decode_context_parallel_size
@@ -671,14 +676,14 @@ def resolve_kv_cache_block_sizes(
):
return scheduler_block_size, scheduler_block_size
- requested = cache_config.hash_block_size
+ requested = cache_config.prefix_match_unit
hash_block_size = (
requested if requested is not None else math.gcd(*group_block_sizes)
)
if any(bs % hash_block_size != 0 for bs in group_block_sizes):
raise ValueError(
- f"Invalid hash_block_size={hash_block_size}; all KV cache group "
- f"block sizes must be divisible by hash_block_size. "
+ f"Invalid prefix_match_unit={hash_block_size}; all KV cache group "
+ f"block sizes must be divisible by prefix_match_unit. "
f"Got group block sizes={group_block_sizes}."
)
return scheduler_block_size, hash_block_size
@@ -2252,19 +2257,33 @@ def _get_value_at(self, idx: int) -> BlockHash:
def resolve_block_hashes(
- request: Request,
+ block_hashes: BlockHashList,
hash_block_size: int,
block_size: int,
+ *,
+ supports_fine_grained_hash_lookup: bool = False,
+ alignment_tokens: int | None = None,
) -> BlockHashList:
- """Resolve the block-hash view for ``request`` at ``block_size``.
+ """Resolve the block-hash view at ``block_size``.
- When ``block_size`` equals ``hash_block_size``, reuse the request's
- precomputed ``block_hashes`` directly; otherwise recalculate at
- ``block_size`` granularity (``block_size`` must be a multiple of
- ``hash_block_size``, which happens when KV cache groups differ in
- block size).
+ When ``block_size`` equals ``hash_block_size``, reuse the precomputed block
+ hashes directly; otherwise view them at ``block_size`` granularity.
+ Fine-grained lookup keeps the original hashes for partial cache hits.
"""
if block_size == hash_block_size:
- return request.block_hashes
+ return block_hashes
+ if isinstance(block_hashes, BlockHashListWithBlockSize):
+ # Already a block-size view
+ assert block_hashes.scale_factor == block_size // hash_block_size
+ return block_hashes
+ # Fine-grained partial hits keep the raw hashes. The caller passes
+ # alignment_tokens = hash_block_size to enable them, else >= block_size.
+ if (
+ supports_fine_grained_hash_lookup
+ and alignment_tokens is not None
+ and alignment_tokens < block_size
+ and block_size % alignment_tokens == 0
+ ):
+ return block_hashes
assert block_size % hash_block_size == 0
- return BlockHashListWithBlockSize(request.block_hashes, hash_block_size, block_size)
+ return BlockHashListWithBlockSize(block_hashes, hash_block_size, block_size)
diff --git a/vllm/v1/core/sched/output.py b/vllm/v1/core/sched/output.py
index 291e73bc64b3..4401fb050b3f 100644
--- a/vllm/v1/core/sched/output.py
+++ b/vllm/v1/core/sched/output.py
@@ -16,10 +16,12 @@
from vllm.multimodal.inputs import MultiModalFeatureSpec
from vllm.pooling_params import PoolingParams
from vllm.sampling_params import SamplingParams
+ from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
from vllm.v1.request import Request
else:
ECConnectorMetadata = object
KVConnectorMetadata = object
+ KVCacheBlockCopy = object
LoRARequest = object
MultiModalFeatureSpec = object
PoolingParams = object
@@ -240,6 +242,9 @@ class SchedulerOutput:
# preventing stale NaN/data from corrupting attention or SSM computation.
new_block_ids_to_zero: list[int] | None = None
+ # CoW copies to apply after zeroing new blocks and before forward.
+ kv_cache_block_copies: list[KVCacheBlockCopy] | None = None
+
# Dynamic speculative decoding: optimal K chosen by scheduler.
# Number of spec tokens to schedule for the next step.
num_spec_tokens_to_schedule: int = 0
diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py
index 95071408876b..47d3a53d20af 100644
--- a/vllm/v1/core/sched/scheduler.py
+++ b/vllm/v1/core/sched/scheduler.py
@@ -259,6 +259,7 @@ def __init__(
# Create the KV cache manager.
if hash_block_size is None:
hash_block_size = block_size
+ self.hash_block_size = hash_block_size
self.kv_cache_manager = KVCacheManager(
kv_cache_config=kv_cache_config,
max_model_len=self.max_model_len,
@@ -297,6 +298,13 @@ def __init__(
self.need_mamba_block_aligned_split = (
self.has_mamba_layers and self.cache_config.mamba_cache_mode == "align"
)
+ # A finer prefix_match_unit is configured: a mamba partial tail entry
+ # can only be registered by a step ending exactly at the prompt's last
+ # hash boundary, so the split adds that stop.
+ self.mamba_partial_cache_hit = (
+ self.need_mamba_block_aligned_split
+ and self.hash_block_size < self.block_size
+ )
# Counts of non-empty steps scheduled / processed. update_from_output
# is called once per scheduled step in FIFO order, so these stay in sync.
@@ -368,9 +376,23 @@ def _mamba_block_aligned_split(
if self.use_eagle:
last_cache_position = max(last_cache_position - block_size, 0)
num_computed_tokens_after_sched = num_computed_tokens + num_new_tokens
- if num_computed_tokens_after_sched < last_cache_position:
- # align to block_size
- num_new_tokens = num_new_tokens // block_size * block_size
+ next_boundary = (num_computed_tokens // block_size + 1) * block_size
+ if (
+ num_computed_tokens % block_size != 0
+ and next_boundary <= last_cache_position
+ and num_computed_tokens_after_sched > next_boundary
+ ):
+ # Resumed mid-block (partial hash hit): stop the first chunk
+ # at the next block boundary so chunk ends re-align to blocks
+ # and the boundary state is materialized before running past.
+ num_new_tokens = next_boundary - num_computed_tokens
+ elif num_computed_tokens_after_sched < last_cache_position:
+ # Align the chunk END (not its length) to block_size;
+ # identical to flooring the length when the start is
+ # block-aligned. May yield 0 (insufficient budget to reach
+ # the next boundary); the caller then skips the request.
+ aligned_end = num_computed_tokens_after_sched // block_size * block_size
+ num_new_tokens = max(aligned_end - num_computed_tokens, 0)
elif (
num_computed_tokens
< last_cache_position
@@ -378,9 +400,23 @@ def _mamba_block_aligned_split(
):
# force to cache the last chunk
num_new_tokens = last_cache_position - num_computed_tokens
- else:
- # prefill the last few tokens
- pass
+ elif self.mamba_partial_cache_hit:
+ # Prefill of the final partial block: stop once at the
+ # prompt's last hash boundary so the mamba partial tail entry
+ # can be registered.
+ tail_boundary = (
+ request.num_prompt_tokens
+ // self.hash_block_size
+ * self.hash_block_size
+ )
+ if (
+ num_computed_tokens
+ < tail_boundary
+ < num_computed_tokens_after_sched
+ and tail_boundary < request.num_prompt_tokens
+ and tail_boundary > last_cache_position
+ ):
+ num_new_tokens = tail_boundary - num_computed_tokens
# Marconi cache admission optimization:
# cache common prefixes by scheduling num_new_tokens = common prefix length
@@ -1084,6 +1120,16 @@ def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
new_block_ids_to_zero = (
(new_attn_block_ids or None) if self.needs_kv_cache_zeroing else None
)
+ kv_cache_block_copies, cow_retained_blocks = (
+ self.kv_cache_manager.take_kv_cache_block_copies()
+ )
+ if kv_cache_block_copies:
+ # The copies run with this step's execution; the first non-empty
+ # step at or after it gets seq `sched_step_seq + 1` (0-token steps
+ # do not advance the seq), and its completion implies the copies
+ # have run.
+ self._free_cow_retained_blocks(cow_retained_blocks, self.sched_step_seq + 1)
+ pending_kv_cache_block_copies = kv_cache_block_copies or None
# Dynamic speculative decoding: compute optimal K
num_spec_tokens_to_schedule = self.num_spec_tokens
@@ -1108,6 +1154,7 @@ def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:
finished_req_ids=self.finished_req_ids,
free_encoder_mm_hashes=self.encoder_cache_manager.get_freed_mm_hashes(),
new_block_ids_to_zero=new_block_ids_to_zero,
+ kv_cache_block_copies=pending_kv_cache_block_copies,
num_spec_tokens_to_schedule=num_spec_tokens_to_schedule,
)
@@ -2148,11 +2195,23 @@ def _free_request_blocks(self, request: Request):
if blocks:
self.deferred_frees.append((self.sched_step_seq, blocks))
+ def _free_cow_retained_blocks(
+ self, blocks: list[KVCacheBlock], fence_seq: int
+ ) -> None:
+ """Release CoW copy retentions, deferring their return to the block
+ pool while the step that runs the copy may still be in flight.
+ """
+ if not self.defer_block_free or fence_seq <= self.processed_step_seq:
+ self.kv_cache_manager.block_pool.free_blocks(blocks)
+ return
+ self.deferred_frees.append((fence_seq, blocks[::-1]))
+
def _drain_deferred_frees(self):
"""Return deferred blocks whose fence step has completed.
- Entries are appended with monotonically non-decreasing fences, so
- stop at the first one that is still pending.
+ Fences are appended in near-monotonic order (a CoW retention fence
+ can lead request-free fences by one step), so stop at the first
+ pending one; any satisfied entry behind it is merely freed later.
"""
while self.deferred_frees:
fence, _ = self.deferred_frees[0]
@@ -2401,6 +2460,7 @@ def _request_remaining_blocks(self, request: Request) -> int:
new_computed_blocks=self.kv_cache_manager.empty_kv_cache_blocks.blocks,
num_encoder_tokens=0,
total_computed_tokens=request.num_computed_tokens,
+ num_local_computed_tokens=request.num_computed_tokens,
num_tokens_main_model=full_num_tokens,
apply_admission_cap=True,
)
diff --git a/vllm/v1/core/single_type_kv_cache_manager.py b/vllm/v1/core/single_type_kv_cache_manager.py
index 324d0016cd0b..21e3ad8ffda7 100644
--- a/vllm/v1/core/single_type_kv_cache_manager.py
+++ b/vllm/v1/core/single_type_kv_cache_manager.py
@@ -4,13 +4,16 @@
from abc import ABC, abstractmethod
from collections import defaultdict
from collections.abc import Sequence
+from typing import ClassVar
from vllm.utils.math_utils import cdiv
from vllm.v1.core.block_pool import BlockPool
from vllm.v1.core.kv_cache_utils import (
BlockHashList,
+ BlockHashListWithBlockSize,
BlockHashWithGroupId,
KVCacheBlock,
+ resolve_block_hashes,
)
from vllm.v1.kv_cache_interface import (
ChunkedLocalAttentionSpec,
@@ -36,6 +39,8 @@ class SingleTypeKVCacheManager(ABC):
logic of one specific type of attention layer.
"""
+ supports_fine_grained_hash_lookup: ClassVar[bool] = False
+
def __init__(
self,
kv_cache_spec: KVCacheSpec,
@@ -106,16 +111,34 @@ def __init__(
# determining the attention groups.
self.use_eagle = False
+ # Partial-hit copy-on-write bookkeeping. Populated only by fine-grained
+ # managers (full attention, mamba "align"); harmlessly empty elsewhere.
+ self._partial_hit_reqs: dict[str, tuple[int, KVCacheBlock]] = {}
+ self._pending_cow_copies: list[tuple[KVCacheBlock, KVCacheBlock]] = []
+
@classmethod
def _get_num_evictable_blocks(cls, blocks: Sequence[KVCacheBlock]):
return sum(blk.ref_cnt == 0 and not blk.is_null for blk in blocks)
+ def _has_partial_local_hit(
+ self,
+ new_computed_blocks: Sequence[KVCacheBlock],
+ num_local_computed_tokens: int,
+ ) -> bool:
+ # The local prefix-cache hit ends inside one of this manager's
+ # blocks: the shared tail block needs CoW.
+ return (
+ len(new_computed_blocks) > 0
+ and num_local_computed_tokens % self.block_size != 0
+ )
+
def get_num_blocks_to_allocate(
self,
request_id: str,
num_tokens: int,
new_computed_blocks: Sequence[KVCacheBlock],
total_computed_tokens: int,
+ num_local_computed_tokens: int,
num_tokens_main_model: int,
apply_admission_cap: bool = False,
) -> int:
@@ -130,6 +153,8 @@ def get_num_blocks_to_allocate(
prefix caching.
total_computed_tokens: Include both local and external computed
tokens.
+ num_local_computed_tokens: The number of local prefix-cache computed
+ tokens.
num_tokens_main_model: The number of tokens for the main model (aka target
model in spec decode). w/o spec decode, it is num_tokens;
with spec decode, it is num_tokens - num_lookahead_tokens.
@@ -189,6 +214,10 @@ def get_num_blocks_to_allocate(
num_evictable_blocks = self._get_num_evictable_blocks(
new_computed_blocks[num_skipped_new_computed_blocks:]
)
+ if self._has_partial_local_hit(new_computed_blocks, num_local_computed_tokens):
+ # Reserve the extra block that allocate_new_blocks pulls for the
+ # partial-hit CoW redirect.
+ num_new_blocks += 1
return num_new_blocks + num_evictable_blocks
def add_local_computed_blocks(
@@ -242,6 +271,13 @@ def add_local_computed_blocks(
# them so cache_blocks() will not try to re-cache blocks that already
# have a block_hash set.
self.num_cached_block[request_id] = len(req_blocks)
+ if self._has_partial_local_hit(new_computed_blocks, num_local_computed_tokens):
+ # Record the partial tail for the CoW redirect in
+ # allocate_new_blocks; cap the cached count at the full blocks so
+ # cache_blocks() re-caches the private copy once full.
+ block_idx = num_local_computed_tokens // self.block_size
+ self._partial_hit_reqs[request_id] = (block_idx, new_computed_blocks[-1])
+ self.num_cached_block[request_id] = block_idx
def allocate_external_computed_blocks(
self,
@@ -299,17 +335,29 @@ def allocate_new_blocks(
Returns:
The new allocated blocks.
"""
+ cow_blocks: list[KVCacheBlock] = []
+ if request_id in self._partial_hit_reqs:
+ # Partial hit: redirect the shared tail to a private CoW block.
+ # Replacing in place keeps the length-based allocation below
+ # correct; the extra block was reserved by
+ # get_num_blocks_to_allocate.
+ block_idx, source_block = self._partial_hit_reqs.pop(request_id)
+ cow_block = self.block_pool.get_new_blocks(1)[0]
+ self._apply_cow(request_id, block_idx, source_block, cow_block)
+ self.new_block_ids.append(cow_block.block_id)
+ cow_blocks.append(cow_block)
+
req_blocks = self.req_to_blocks[request_id]
num_required_blocks = cdiv(num_tokens, self.block_size)
num_new_blocks = num_required_blocks - len(req_blocks)
if num_new_blocks <= 0:
- return []
+ return cow_blocks
else:
new_blocks = self.block_pool.get_new_blocks(num_new_blocks)
req_blocks.extend(new_blocks)
if self._record_new_block_ids:
self.new_block_ids.extend(b.block_id for b in new_blocks)
- return new_blocks
+ return cow_blocks + new_blocks
def take_new_block_ids(self) -> list[int]:
"""Drain and return block IDs allocated since the last call."""
@@ -317,6 +365,36 @@ def take_new_block_ids(self) -> list[int]:
self.new_block_ids = []
return ids
+ def take_pending_cow_copies(
+ self,
+ ) -> list[tuple[KVCacheBlock, KVCacheBlock]]:
+ """Drain pending CoW source and destination block pairs."""
+ pending_copies = self._pending_cow_copies
+ self._pending_cow_copies = []
+ return pending_copies
+
+ def _apply_cow(
+ self,
+ request_id: str,
+ block_idx: int,
+ source_block: KVCacheBlock,
+ cow_block: KVCacheBlock,
+ ) -> None:
+ """Redirect a partial prefix-cache hit to a private CoW block.
+
+ Both copy endpoints stay retained until the copy has run on the worker,
+ so a same-step free cannot recycle them: ``source_block`` keeps its
+ hit-ref, ``cow_block`` takes an extra ref beyond the one handed to the
+ request.
+ """
+ req_blocks = self.req_to_blocks[request_id]
+ assert block_idx < len(req_blocks)
+ assert req_blocks[block_idx] is source_block
+ assert not source_block.is_null and source_block.ref_cnt > 0
+ req_blocks[block_idx] = cow_block
+ self._pending_cow_copies.append((source_block, cow_block))
+ cow_block.ref_cnt += 1
+
def cache_blocks(
self,
request: Request,
@@ -398,6 +476,7 @@ def pop_blocks_for_free(self, request_id: str) -> list[KVCacheBlock]:
# Default to [] in case a request is freed (aborted) before alloc.
req_blocks = self.req_to_blocks.pop(request_id, [])
self.num_cached_block.pop(request_id, None)
+ self._partial_hit_reqs.pop(request_id, None)
return req_blocks
def free(self, request_id: str) -> None:
@@ -439,7 +518,7 @@ def find_longest_cache_hit(
alignment_tokens: int,
dcp_world_size: int = 1,
pcp_world_size: int = 1,
- ) -> tuple[list[KVCacheBlock], ...]:
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
"""
Get the longest cache hit prefix of the blocks that is not longer than
`max_length`. The prefix should be a common prefix hit for all the
@@ -466,11 +545,9 @@ def find_longest_cache_hit(
pcp_world_size: The world size of prefill context parallelism.
Returns:
- A list of cached blocks with skipped blocks replaced by null block
- for each kv cache group in `kv_cache_group_ids`.
- Return a list of length `len(kv_cache_group_ids)`, where the i-th
- element is a list of cached blocks for the i-th kv cache group
- in `kv_cache_group_ids`.
+ A tuple containing cached blocks and the exact cache-hit length in
+ tokens. The cached block tuple has skipped blocks replaced by null
+ blocks for each kv cache group in `kv_cache_group_ids`.
For example, sliding window manager should return a list like
([NULL, NULL, KVCacheBlock(7), KVCacheBlock(8)]) for block size 4
and sliding window 8 and len(kv_cache_group_ids) = 1.
@@ -558,11 +635,12 @@ def get_num_skipped_tokens(self, num_computed_tokens: int) -> int:
return 0
def new_step_starts(self) -> None:
- # do nothing by default
return None
class FullAttentionManager(SingleTypeKVCacheManager):
+ supports_fine_grained_hash_lookup: ClassVar[bool] = True
+
@classmethod
def find_longest_cache_hit(
cls,
@@ -575,42 +653,131 @@ def find_longest_cache_hit(
alignment_tokens: int,
dcp_world_size: int = 1,
pcp_world_size: int = 1,
- ) -> tuple[list[KVCacheBlock], ...]:
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
assert isinstance(
kv_cache_spec, FullAttentionSpec | ChunkedLocalAttentionSpec
), (
"FullAttentionManager can only be used for full attention "
"and chunked local attention groups"
)
- computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
- [] for _ in range(len(kv_cache_group_ids))
- )
block_size = kv_cache_spec.block_size
if dcp_world_size * pcp_world_size > 1:
+ # DCP/PCP shard each block's KV across ranks; hashes must be
+ # viewed at the sharded (scaled) block size.
block_size *= dcp_world_size * pcp_world_size
- max_num_blocks = max_length // block_size
- for block_hash in itertools.islice(block_hashes, max_num_blocks):
- # block_hashes is a chain of block hashes. If a block hash is not
- # in the cached_block_hash_to_id, the following block hashes are
- # not computed yet for sure.
- if cached_block := block_pool.get_cached_block(
- block_hash, kv_cache_group_ids
- ):
- for computed, cached in zip(computed_blocks, cached_block):
+ block_hashes = resolve_block_hashes(
+ block_hashes,
+ block_pool.hash_block_size,
+ block_size,
+ supports_fine_grained_hash_lookup=cls.supports_fine_grained_hash_lookup,
+ alignment_tokens=alignment_tokens,
+ )
+
+ # Fine-grained mode (alignment_tokens == hash_block_size <
+ # block_size): resolve_block_hashes kept the raw hash-granularity
+ # list so interior boundaries can be probed.
+ fine_grained = (
+ alignment_tokens < block_size and block_size % alignment_tokens == 0
+ )
+ if fine_grained:
+ assert isinstance(block_hashes, list)
+ full_block_hashes: BlockHashList = BlockHashListWithBlockSize(
+ block_hashes, alignment_tokens, block_size
+ )
+ else:
+ full_block_hashes = block_hashes
+
+ computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
+ [] for _ in range(len(kv_cache_group_ids))
+ )
+ # Phase 1: longest run of cached full blocks from the start. A missing
+ # block implies every later block misses too (chained hashes).
+ for block_hash in itertools.islice(full_block_hashes, max_length // block_size):
+ cached_block = block_pool.get_cached_block(block_hash, kv_cache_group_ids)
+ if not cached_block:
+ break
+ for computed, cached in zip(computed_blocks, cached_block):
+ computed.append(cached)
+ hit_length = len(computed_blocks[0]) * block_size
+
+ # Phase 2 (fine-grained only): extend into the first non-full block by
+ # probing its interior hash boundaries high-to-low (longest hit first).
+ if fine_grained:
+ assert isinstance(block_hashes, list)
+ scale_factor = block_size // alignment_tokens
+ first_partial_idx = len(computed_blocks[0]) * scale_factor
+ max_partial_idx = min(
+ first_partial_idx + scale_factor - 1,
+ max_length // alignment_tokens,
+ len(block_hashes),
+ )
+ for fine_idx in range(max_partial_idx - 1, first_partial_idx - 1, -1):
+ cached_tail = block_pool.get_cached_block(
+ block_hashes[fine_idx], kv_cache_group_ids
+ )
+ if not cached_tail:
+ continue
+ for computed, cached in zip(computed_blocks, cached_tail):
computed.append(cached)
- else:
+ hit_length = (fine_idx + 1) * alignment_tokens
break
- if drop_eagle_block and computed_blocks[0]:
- # Need to drop the last matched block if eagle is enabled.
- for computed in computed_blocks:
- computed.pop()
- while (
- block_size != alignment_tokens # Faster for common case.
- and len(computed_blocks[0]) * block_size % alignment_tokens != 0
- ):
- for computed in computed_blocks:
- computed.pop()
- return computed_blocks
+
+ # Eagle needs the tokens right before the generation point recomputed:
+ # drop one hash unit when fine-grained (the tail block's KV is
+ # append-only, so it still covers the reduced length), else one cache
+ # block.
+ if drop_eagle_block and hit_length > 0:
+ hit_length -= min(alignment_tokens, block_size)
+ # Round down to the alignment; a no-op when fine-grained (hits land on
+ # hash boundaries by construction) and when alignment_tokens ==
+ # block_size. Then trim blocks past the new tail.
+ hit_length -= hit_length % alignment_tokens
+ num_blocks = cdiv(hit_length, block_size)
+ for computed in computed_blocks:
+ del computed[num_blocks:]
+ return computed_blocks, hit_length
+
+ def cache_blocks(
+ self,
+ request: Request,
+ num_tokens: int,
+ retention_interval: int | None = None,
+ ) -> None:
+ super().cache_blocks(request, num_tokens, retention_interval=retention_interval)
+ hash_block_size = self.block_pool.hash_block_size
+ if self.block_size == hash_block_size:
+ return
+ self._cache_partial_tail_block(request, num_tokens)
+
+ def _cache_partial_tail_block(
+ self,
+ request: Request,
+ num_tokens: int,
+ ) -> None:
+ """Cache the prompt tail when it ends inside a cache block.
+
+ Only the final prompt hash boundary is registered as a partial
+ prefix-cache entry; intermediate hash boundaries inside the same cache
+ block are intentionally skipped.
+ """
+ hash_block_size = self.block_pool.hash_block_size
+ boundary_tokens = request.num_prompt_tokens // hash_block_size * hash_block_size
+ if boundary_tokens == 0 or boundary_tokens > num_tokens:
+ return
+ if boundary_tokens % self.block_size == 0:
+ return
+
+ blocks = self.req_to_blocks[request.request_id]
+ block_idx = boundary_tokens // self.block_size
+ if block_idx >= len(blocks):
+ return
+ self.block_pool.cache_partial_block(
+ request=request,
+ block=blocks[block_idx],
+ num_tokens=boundary_tokens,
+ kv_cache_group_id=self.kv_cache_group_id,
+ block_size=self.block_size,
+ )
def get_num_common_prefix_blocks(self, running_request_id: str) -> int:
blocks = self.req_to_blocks[running_request_id]
@@ -699,12 +866,23 @@ def find_longest_cache_hit(
alignment_tokens: int,
dcp_world_size: int = 1,
pcp_world_size: int = 1,
- ) -> tuple[list[KVCacheBlock], ...]:
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
assert isinstance(kv_cache_spec, SlidingWindowSpec), (
"SlidingWindowManager can only be used for sliding window groups"
)
assert dcp_world_size == 1, "DCP not support sliding window attn now."
assert pcp_world_size == 1, "PCP not support sliding window attn now."
+ # Fine-grained partial hits are not supported for sliding window now
+ assert alignment_tokens % kv_cache_spec.block_size == 0, (
+ "SlidingWindowManager does not support fine-grained (partial) cache hits"
+ )
+ block_hashes = resolve_block_hashes(
+ block_hashes,
+ block_pool.hash_block_size,
+ kv_cache_spec.block_size,
+ supports_fine_grained_hash_lookup=cls.supports_fine_grained_hash_lookup,
+ alignment_tokens=alignment_tokens,
+ )
# The number of contiguous blocks needed for a prefix cache hit.
sliding_window_contiguous_blocks = cls._contiguous_blocks_for_hit(
@@ -717,7 +895,7 @@ def find_longest_cache_hit(
# sliding_window_contiguous_blocks),
# which is good for low cache hit rate scenarios.
max_num_blocks = max_length // kv_cache_spec.block_size
- computed_blocks = tuple(
+ computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
[block_pool.null_block] * max_num_blocks
for _ in range(len(kv_cache_group_ids))
)
@@ -772,7 +950,8 @@ def find_longest_cache_hit(
):
for computed in computed_blocks:
computed.pop()
- return computed_blocks
+ hit_length = len(computed_blocks[0]) * block_size
+ return computed_blocks, hit_length
@classmethod
def reachable_block_mask(
@@ -893,7 +1072,7 @@ def find_longest_cache_hit(
alignment_tokens: int,
dcp_world_size: int = 1,
pcp_world_size: int = 1,
- ) -> tuple[list[KVCacheBlock], ...]:
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
"""
For chunked local attention, we need to find the longest cache hit
prefix of the blocks that is not longer than `max_length`. The prefix
@@ -942,6 +1121,13 @@ def find_longest_cache_hit(
"KV cache groups with different block sizes are not compatible with "
"chunked local attention now"
)
+ block_hashes = resolve_block_hashes(
+ block_hashes,
+ block_pool.hash_block_size,
+ kv_cache_spec.block_size,
+ supports_fine_grained_hash_lookup=cls.supports_fine_grained_hash_lookup,
+ alignment_tokens=alignment_tokens,
+ )
max_num_blocks = max_length // kv_cache_spec.block_size
if max_length > 0:
local_attention_start_idx = (
@@ -971,7 +1157,8 @@ def find_longest_cache_hit(
computed.append(cached)
else:
break
- return computed_blocks
+ hit_length = len(computed_blocks[0]) * kv_cache_spec.block_size
+ return computed_blocks, hit_length
def get_num_skipped_tokens(self, num_computed_tokens: int) -> int:
"""
@@ -1027,6 +1214,8 @@ def get_num_common_prefix_blocks(self, running_request_id: str) -> int:
class MambaManager(SingleTypeKVCacheManager):
+ supports_fine_grained_hash_lookup: ClassVar[bool] = True
+
def __init__(
self, kv_cache_spec: MambaSpec, block_pool: BlockPool, **kwargs
) -> None:
@@ -1035,9 +1224,9 @@ def __init__(
# recurrent state. Undo the DCP/PCP block_size scaling that the base
# class applies for attention groups whose KV cache is partitioned.
self.block_size = kv_cache_spec.block_size
- self.cached_blocks_this_step: set[BlockHashWithGroupId] = set()
self.mamba_cache_mode = kv_cache_spec.mamba_cache_mode
self.num_speculative_blocks: int = kv_cache_spec.num_speculative_blocks
+ self.cached_blocks_this_step: set[BlockHashWithGroupId] = set()
if self.mamba_cache_mode == "align":
# Mapping from request ID to the index of the block
# allocated in the previous step
@@ -1057,17 +1246,46 @@ def find_longest_cache_hit(
alignment_tokens: int,
dcp_world_size: int = 1,
pcp_world_size: int = 1,
- ) -> tuple[list[KVCacheBlock], ...]:
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
assert isinstance(kv_cache_spec, MambaSpec), (
"MambaManager can only be used for mamba groups"
)
assert dcp_world_size == 1, "DCP not support mamba now."
assert pcp_world_size == 1, "PCP not support mamba now."
+ block_hashes = resolve_block_hashes(
+ block_hashes,
+ block_pool.hash_block_size,
+ kv_cache_spec.block_size,
+ supports_fine_grained_hash_lookup=cls.supports_fine_grained_hash_lookup,
+ alignment_tokens=alignment_tokens,
+ )
computed_blocks: tuple[list[KVCacheBlock], ...] = tuple(
[] for _ in range(len(kv_cache_group_ids))
)
+ hit_length = 0
block_size = kv_cache_spec.block_size
+ if alignment_tokens < block_size and block_size % alignment_tokens == 0:
+ assert isinstance(block_hashes, list)
+ hash_block_size = alignment_tokens
+ scale_factor = block_size // hash_block_size
+ max_num_partial_units = min(
+ max_length // hash_block_size, len(block_hashes)
+ )
+ for fine_idx in range(max_num_partial_units - 1, -1, -1):
+ num_tokens = (fine_idx + 1) * hash_block_size
+ block_hash = block_hashes[fine_idx]
+ if cached_block := block_pool.get_cached_block(
+ block_hash, kv_cache_group_ids
+ ):
+ block_idx = fine_idx // scale_factor
+ for computed, cached in zip(computed_blocks, cached_block):
+ computed.extend([block_pool.null_block] * block_idx)
+ computed.append(cached)
+ hit_length = num_tokens
+ break
+ return computed_blocks, hit_length
+
max_num_blocks = max_length // block_size
# Search from right to left and early stop when a match is found.
for i in range(max_num_blocks - 1, -1, -1):
@@ -1089,9 +1307,10 @@ def find_longest_cache_hit(
# so we insert dummy blocks at the beginning:
computed.extend([block_pool.null_block] * i)
computed.append(cached)
+ hit_length = (i + 1) * block_size
break # we just need the last match - early stopping
- return computed_blocks
+ return computed_blocks, hit_length
@classmethod
def reachable_block_mask(
@@ -1189,6 +1408,7 @@ def get_num_blocks_to_allocate(
num_tokens: int,
new_computed_blocks: Sequence[KVCacheBlock],
total_computed_tokens: int,
+ num_local_computed_tokens: int,
num_tokens_main_model: int,
apply_admission_cap: bool = False,
) -> int:
@@ -1214,6 +1434,7 @@ def get_num_blocks_to_allocate(
num_tokens,
new_computed_blocks,
total_computed_tokens,
+ num_local_computed_tokens,
num_tokens_main_model,
apply_admission_cap=apply_admission_cap,
)
@@ -1235,15 +1456,26 @@ def get_num_blocks_to_allocate(
- len(new_computed_blocks)
- len(self.req_to_blocks[request_id])
)
+ has_partial_hit = (
+ self._has_partial_local_hit(
+ new_computed_blocks, num_local_computed_tokens
+ )
+ or request_id in self._partial_hit_reqs
+ )
+ if has_partial_hit:
+ num_new_blocks = max(num_new_blocks, 0) + 1
if num_new_blocks > 0:
if request_id in self._allocated_block_reqs:
# Old request. Needs at most 1 more blocks as we can reuse the
# speculative blocks in previous step.
- num_new_blocks = 1
+ num_new_blocks = 1 + int(has_partial_hit)
else:
- # First prefill. Allocate 1 block for running state and the
- # speculative blocks.
- num_new_blocks = 1 + self.num_speculative_blocks
+ # First prefill. Allocate 1 block for running state, the
+ # speculative blocks, and one extra block if a partial cache
+ # hit must be copy-on-written before the new tokens run.
+ num_new_blocks = (
+ 1 + self.num_speculative_blocks + int(has_partial_hit)
+ )
num_evictable_computed_blocks = self._get_num_evictable_blocks(
new_computed_blocks
@@ -1275,9 +1507,11 @@ def allocate_new_blocks(
num_required_blocks = (
cdiv(num_tokens, self.block_size) + self.num_speculative_blocks
)
+ partial_hit = self._partial_hit_reqs.get(request_id)
+ has_partial_hit = partial_hit is not None
# `num_required_blocks` might be less than `len(req_blocks)` if blocks are
# over-allocated at last round.
- if num_required_blocks <= len(req_blocks):
+ if num_required_blocks <= len(req_blocks) and not has_partial_hit:
return []
else:
prev_block_len = len(req_blocks)
@@ -1317,14 +1551,42 @@ def allocate_new_blocks(
else:
break
num_new_blocks = num_required_blocks - len(req_blocks)
+ if has_partial_hit:
+ num_new_blocks = max(num_new_blocks, 0) + 1
if blocks_allocated:
- assert num_new_blocks <= 1
+ assert num_new_blocks <= 1 + int(has_partial_hit)
else:
- assert num_new_blocks <= self.num_speculative_blocks + 1
+ assert num_new_blocks <= self.num_speculative_blocks + 1 + int(
+ has_partial_hit
+ )
new_blocks = self.block_pool.get_new_blocks(num_new_blocks)
+ returned_blocks = req_blocks[prev_block_len:]
+ if partial_hit is not None:
+ block_idx, source_block = partial_hit
+ cow_block = new_blocks[0]
+ new_blocks = new_blocks[1:]
+ if blocks_allocated:
+ # The worker block table of a running request is
+ # append-only, so the request must stay on
+ # source_block. Move the cache entry to cow_block
+ # instead; the queued copy fills it before forward
+ # overwrites source_block.
+ assert req_blocks[block_idx] is source_block
+ self.block_pool.move_block_hashes(source_block, cow_block)
+ self._pending_cow_copies.append((source_block, cow_block))
+ source_block.ref_cnt += 1
+ if cow_block.block_hash is not None:
+ # The moved entry is only filled by this step's
+ # copy, so defer same-step hits on it.
+ self.cached_blocks_this_step.add(cow_block.block_hash)
+ else:
+ self._apply_cow(request_id, block_idx, source_block, cow_block)
+ returned_blocks = [cow_block] + returned_blocks
req_blocks.extend(new_blocks)
self._allocated_block_reqs.add(request_id)
- return req_blocks[prev_block_len:]
+ self._partial_hit_reqs.pop(request_id, None)
+ returned_blocks.extend(new_blocks)
+ return returned_blocks
def pop_blocks_for_free(self, request_id: str) -> list[KVCacheBlock]:
if self.mamba_cache_mode == "align":
@@ -1349,6 +1611,10 @@ def cache_blocks(
num_cached_blocks_before = self.num_cached_block.get(request.request_id, 0)
super().cache_blocks(request, num_tokens, retention_interval=retention_interval)
num_cached_blocks_after = self.num_cached_block.get(request.request_id, 0)
+ if self.mamba_cache_mode == "align":
+ partial_hash = self._cache_partial_tail_block(request, num_tokens)
+ if partial_hash is not None:
+ self.cached_blocks_this_step.add(partial_hash)
if num_cached_blocks_after > num_cached_blocks_before:
for block in self.req_to_blocks[request.request_id][
num_cached_blocks_before:num_cached_blocks_after
@@ -1364,6 +1630,44 @@ def cache_blocks(
def new_step_starts(self) -> None:
self.cached_blocks_this_step.clear()
+ def _cache_partial_tail_block(
+ self,
+ request: Request,
+ num_tokens: int,
+ ) -> BlockHashWithGroupId | None:
+ hash_block_size = self.block_pool.hash_block_size
+ if self.block_size == hash_block_size:
+ return None
+ if num_tokens % self.block_size == 0:
+ return None
+ if num_tokens % hash_block_size != 0:
+ return None
+ latest_prompt_hash_boundary = (
+ request.num_prompt_tokens // hash_block_size
+ ) * hash_block_size
+ if num_tokens != latest_prompt_hash_boundary:
+ return None
+
+ block_idx = num_tokens // self.block_size
+ blocks = self.req_to_blocks[request.request_id]
+ if block_idx >= len(blocks):
+ return None
+ source_block = blocks[block_idx]
+ if source_block.is_null:
+ return None
+
+ partial_hash = self.block_pool.cache_partial_block(
+ request=request,
+ block=source_block,
+ num_tokens=num_tokens,
+ kv_cache_group_id=self.kv_cache_group_id,
+ block_size=self.block_size,
+ )
+ if partial_hash is not None:
+ self._partial_hit_reqs[request.request_id] = (block_idx, source_block)
+ self.num_cached_block[request.request_id] = block_idx
+ return partial_hash
+
class CrossAttentionManager(SingleTypeKVCacheManager):
"""Manager for cross-attention KV cache in encoder-decoder models."""
@@ -1415,7 +1719,7 @@ def find_longest_cache_hit(
alignment_tokens: int,
dcp_world_size: int = 1,
pcp_world_size: int = 1,
- ) -> tuple[list[KVCacheBlock], ...]:
+ ) -> tuple[tuple[list[KVCacheBlock], ...], int]:
assert isinstance(kv_cache_spec, CrossAttentionSpec), (
"CrossAttentionManager can only be used for cross-attention groups"
)
@@ -1435,16 +1739,18 @@ def __init__(
block_pool: BlockPool,
enable_caching: bool,
kv_cache_group_id: int,
+ scheduler_block_size: int,
dcp_world_size: int = 1,
pcp_world_size: int = 1,
):
super().__init__(
- kv_cache_spec,
- block_pool,
- enable_caching,
- kv_cache_group_id,
- dcp_world_size,
- pcp_world_size,
+ kv_cache_spec=kv_cache_spec,
+ block_pool=block_pool,
+ enable_caching=enable_caching,
+ kv_cache_group_id=kv_cache_group_id,
+ scheduler_block_size=scheduler_block_size,
+ dcp_world_size=dcp_world_size,
+ pcp_world_size=pcp_world_size,
)
sink_len = kv_cache_spec.sink_len
assert sink_len is not None and sink_len > 0 and sink_len % self.block_size == 0
diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py
index 1f5dd4d2fba2..0a62295629f6 100644
--- a/vllm/v1/worker/gpu/model_runner.py
+++ b/vllm/v1/worker/gpu/model_runner.py
@@ -112,7 +112,7 @@
from vllm.v1.worker.gpu.states import RequestState
from vllm.v1.worker.gpu.structured_outputs import StructuredOutputsWorker
from vllm.v1.worker.lora_model_runner_mixin import LoRAModelRunnerMixin
-from vllm.v1.worker.utils import KVBlockZeroer
+from vllm.v1.worker.utils import KVBlockZeroer, copy_kv_cache_blocks_inplace
logger = init_logger(__name__)
@@ -838,6 +838,15 @@ def update_requests(self, scheduler_output: SchedulerOutput) -> None:
assert self.kv_block_zeroer is not None
self.kv_block_zeroer.zero_block_ids(scheduler_output.new_block_ids_to_zero)
+ # Apply copy-on-write block copies for partial prefix-cache hits, after
+ # zeroing new blocks and before the forward pass reads them.
+ if scheduler_output.kv_cache_block_copies:
+ copy_kv_cache_blocks_inplace(
+ self.kv_caches,
+ self.kv_cache_config.num_blocks,
+ scheduler_output.kv_cache_block_copies,
+ )
+
def prepare_inputs(
self, scheduler_output: SchedulerOutput, batch_desc: BatchExecutionDescriptor
) -> InputBatch:
diff --git a/vllm/v1/worker/gpu_model_runner.py b/vllm/v1/worker/gpu_model_runner.py
index efb302b90f47..cb8ed5f367c3 100644
--- a/vllm/v1/worker/gpu_model_runner.py
+++ b/vllm/v1/worker/gpu_model_runner.py
@@ -230,6 +230,7 @@
KVBlockZeroer,
add_kv_sharing_layers_to_kv_cache_groups,
bind_kv_cache,
+ copy_kv_cache_blocks_inplace,
prepare_kernel_block_sizes,
sanity_check_mm_encoder_outputs,
)
@@ -1188,6 +1189,12 @@ def _update_states(self, scheduler_output: "SchedulerOutput") -> Callable | None
# stale NaN/data from corrupting attention or SSM computation.
if scheduler_output.new_block_ids_to_zero:
self._zero_block_ids(scheduler_output.new_block_ids_to_zero)
+ if scheduler_output.kv_cache_block_copies:
+ copy_kv_cache_blocks_inplace(
+ self.kv_caches,
+ self.kv_cache_config.num_blocks,
+ scheduler_output.kv_cache_block_copies,
+ )
# Free the cached encoder outputs.
for mm_hash in scheduler_output.free_encoder_mm_hashes:
diff --git a/vllm/v1/worker/utils.py b/vllm/v1/worker/utils.py
index 2c2f930001b1..9e60d2ae7fa8 100644
--- a/vllm/v1/worker/utils.py
+++ b/vllm/v1/worker/utils.py
@@ -2,11 +2,12 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import math
from collections import defaultdict
-from collections.abc import Iterable
+from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field
from itertools import product as iprod
from typing import Any
+import numpy as np
import torch
from vllm.config import CacheConfig, VllmConfig
@@ -18,11 +19,13 @@
from vllm.triton_utils import tl, triton
from vllm.utils.math_utils import largest_power_of_2_divisor
from vllm.utils.mem_utils import MemorySnapshot, format_gib
+from vllm.utils.torch_utils import async_tensor_h2d
from vllm.v1.attention.backend import (
AttentionBackend,
AttentionMetadataBuilder,
MultipleOf,
)
+from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
from vllm.v1.kv_cache_interface import (
AttentionSpec,
EncoderOnlyAttentionSpec,
@@ -535,6 +538,45 @@ def bind_kv_cache(
forward_context[layer_name].kv_cache = kv_cache
+def copy_kv_cache_blocks_inplace(
+ kv_caches: Iterable[torch.Tensor | list[torch.Tensor]],
+ num_blocks: int,
+ kv_cache_block_copies: Sequence[KVCacheBlockCopy],
+) -> None:
+ if not kv_cache_block_copies:
+ return
+
+ storage_tensors: list[torch.Tensor] = []
+ seen_storage: set[int] = set()
+ for entry in kv_caches:
+ # Mamba layers hold a list of state tensors; attention layers a single
+ # tensor. Both alias the shared block-major backing storage.
+ tensors = entry if isinstance(entry, (list, tuple)) else (entry,)
+ for tensor in tensors:
+ ptr = tensor.untyped_storage().data_ptr()
+ if ptr in seen_storage:
+ continue
+ seen_storage.add(ptr)
+ storage_tensors.append(tensor)
+
+ if not storage_tensors:
+ return
+ device = storage_tensors[0].device
+ indices_np = np.array(kv_cache_block_copies, dtype=np.int64)
+ indices = async_tensor_h2d(indices_np, device=device)
+ src_indices, dst_indices = indices.unbind(dim=1)
+
+ for tensor in storage_tensors:
+ assert tensor.device == device
+ blocks = torch.empty(0, dtype=torch.uint8, device=device)
+ blocks.set_(tensor.untyped_storage())
+ # Block-major backing storage: block i owns the contiguous byte range
+ # [i * page_size, (i + 1) * page_size).
+ assert blocks.numel() % num_blocks == 0
+ blocks = blocks.view(num_blocks, -1)
+ blocks[dst_indices] = blocks[src_indices]
+
+
def is_residual_scattered_for_sp(
vllm_config: VllmConfig, num_input_tokens: int
) -> bool:
From fc1c548093029f6487bbdc9c612995dfe7621a75 Mon Sep 17 00:00:00 2001
From: vx120 <57470515+vx120@users.noreply.github.com>
Date: Sun, 12 Jul 2026 13:51:53 +0800
Subject: [PATCH 0051/1526] Runtime Draft Weight Update for Speculative
Decoding (#46725)
Signed-off-by: vx120 <893600387@qq.com>
Signed-off-by: vx120 <57470515+vx120@users.noreply.github.com>
Signed-off-by: aoshen02
Co-authored-by: crp0128 <191679376@qq.com>
Co-authored-by: aoshen02
Co-authored-by: Claude Opus 4.8
---
docs/training/weight_transfer/base.md | 4 ++
.../entrypoints/openai/test_openai_schema.py | 1 +
.../worker/test_gpu_worker_weight_transfer.py | 6 ++
vllm/distributed/weight_transfer/base.py | 18 +++++
.../weight_transfer/sparse_nccl_engine.py | 1 +
vllm/engine/protocol.py | 4 ++
vllm/entrypoints/llm.py | 4 ++
vllm/entrypoints/serve/dev/rlhf/api_router.py | 6 ++
vllm/v1/engine/async_llm.py | 4 ++
vllm/v1/worker/gpu/model_runner.py | 6 ++
vllm/v1/worker/gpu_model_runner.py | 11 ++++
vllm/v1/worker/gpu_worker.py | 65 ++++++++++++++++++-
12 files changed, 129 insertions(+), 1 deletion(-)
diff --git a/docs/training/weight_transfer/base.md b/docs/training/weight_transfer/base.md
index 020826496623..f69af8dd6e22 100644
--- a/docs/training/weight_transfer/base.md
+++ b/docs/training/weight_transfer/base.md
@@ -49,6 +49,10 @@ update_request = WeightTransferUpdateRequest(
)
```
+At the LLM/API layer, call `start_draft_weight_update()` instead of
+`start_weight_update()` to target the speculative draft model;
+`update_weights` / `finish_weight_update` are unchanged.
+
### WeightTransferUpdateInfo
The base `WeightTransferUpdateInfo` is a marker class for backend-specific update info:
diff --git a/tests/entrypoints/openai/test_openai_schema.py b/tests/entrypoints/openai/test_openai_schema.py
index 38ea2661c861..2985c5395187 100644
--- a/tests/entrypoints/openai/test_openai_schema.py
+++ b/tests/entrypoints/openai/test_openai_schema.py
@@ -145,6 +145,7 @@ def test_openapi_stateless(case: schemathesis.Case):
if case.operation.path in (
"/init_weight_transfer_engine",
"/start_weight_update",
+ "/start_draft_weight_update",
"/update_weights",
"/finish_weight_update",
):
diff --git a/tests/v1/worker/test_gpu_worker_weight_transfer.py b/tests/v1/worker/test_gpu_worker_weight_transfer.py
index 93c2e916b120..aeb727d9ce32 100644
--- a/tests/v1/worker/test_gpu_worker_weight_transfer.py
+++ b/tests/v1/worker/test_gpu_worker_weight_transfer.py
@@ -19,6 +19,7 @@ def __init__(self, raise_on_update: bool = False):
self.raise_on_update = raise_on_update
self.started = False
self.finished = False
+ self.reset_count = 0
self.update_calls: list[dict] = []
def start_weight_update(self) -> None:
@@ -32,6 +33,9 @@ def update_weights(self, update_info: dict) -> None:
def finish_weight_update(self) -> None:
self.finished = True
+ def reset_weight_update_target(self) -> None:
+ self.reset_count += 1
+
def _make_worker(engine: _RecordingEngine | None) -> Worker:
worker = object.__new__(Worker)
@@ -54,6 +58,7 @@ def test_start_update_finish_delegates_to_engine():
Worker.finish_weight_update(worker)
assert engine.finished is True
+ assert engine.reset_count == 1
assert worker._weight_update_active is False
@@ -85,6 +90,7 @@ def test_update_resets_active_on_error():
Worker.update_weights(worker, {"names": ["w"]})
# A failed update ends the session so the next start is clean.
+ assert engine.reset_count == 1
assert worker._weight_update_active is False
diff --git a/vllm/distributed/weight_transfer/base.py b/vllm/distributed/weight_transfer/base.py
index 5ed99ec54613..6dbd768d253b 100644
--- a/vllm/distributed/weight_transfer/base.py
+++ b/vllm/distributed/weight_transfer/base.py
@@ -73,6 +73,8 @@ class WeightTransferEngine(ABC, Generic[TInitInfo, TUpdateInfo]):
init_info_cls: type[TInitInfo]
update_info_cls: type[TUpdateInfo]
+ supports_draft_weight_update: bool = True
+
def __init__(
self,
config: WeightTransferConfig,
@@ -95,6 +97,22 @@ def __init__(
self.model_config = vllm_config.model_config
self.device = device
self.model = model
+ self._default_model_config = self.model_config
+ self._default_model = model
+
+ def set_weight_update_target(
+ self,
+ model: torch.nn.Module,
+ model_config: Any,
+ ) -> None:
+ """Set the model that will receive the active weight update."""
+ self.model = model
+ self.model_config = model_config
+
+ def reset_weight_update_target(self) -> None:
+ """Restore weight updates to the engine's default target model."""
+ self.model = self._default_model
+ self.model_config = self._default_model_config
def parse_init_info(self, init_dict: dict[str, Any]) -> TInitInfo:
"""
diff --git a/vllm/distributed/weight_transfer/sparse_nccl_engine.py b/vllm/distributed/weight_transfer/sparse_nccl_engine.py
index 669b066a8ea2..2666afcf4e20 100644
--- a/vllm/distributed/weight_transfer/sparse_nccl_engine.py
+++ b/vllm/distributed/weight_transfer/sparse_nccl_engine.py
@@ -99,6 +99,7 @@ class SparseNCCLWeightTransferEngine(
init_info_cls = NCCLWeightTransferInitInfo
update_info_cls = SparseNCCLWeightTransferUpdateInfo
+ supports_draft_weight_update = False
def __init__(
self,
diff --git a/vllm/engine/protocol.py b/vllm/engine/protocol.py
index 7d5cc164f002..c54123bea9e5 100644
--- a/vllm/engine/protocol.py
+++ b/vllm/engine/protocol.py
@@ -248,6 +248,10 @@ async def start_weight_update(self) -> None:
"""Start a new weight update."""
raise NotImplementedError
+ async def start_draft_weight_update(self) -> None:
+ """Start a new weight update targeting the speculative draft model."""
+ raise NotImplementedError
+
async def update_weights(self, request: WeightTransferUpdateRequest) -> None:
"""Batched weight update for RL training."""
raise NotImplementedError
diff --git a/vllm/entrypoints/llm.py b/vllm/entrypoints/llm.py
index a3ed94ee0aa9..014a6700617b 100644
--- a/vllm/entrypoints/llm.py
+++ b/vllm/entrypoints/llm.py
@@ -877,6 +877,10 @@ def start_weight_update(self) -> None:
"""Start a new weight update."""
self.llm_engine.collective_rpc("start_weight_update")
+ def start_draft_weight_update(self) -> None:
+ """Start a new weight update targeting the speculative draft model."""
+ self.llm_engine.collective_rpc("start_draft_weight_update")
+
def update_weights(self, request: WeightTransferUpdateRequest | dict) -> None:
"""
Update the weights of the model.
diff --git a/vllm/entrypoints/serve/dev/rlhf/api_router.py b/vllm/entrypoints/serve/dev/rlhf/api_router.py
index 310e4021ebc2..9e9fc0d3d89e 100644
--- a/vllm/entrypoints/serve/dev/rlhf/api_router.py
+++ b/vllm/entrypoints/serve/dev/rlhf/api_router.py
@@ -133,6 +133,12 @@ async def start_weight_update(raw_request: Request):
return JSONResponse(content={"message": "Weight update started"})
+@router.post("/start_draft_weight_update")
+async def start_draft_weight_update(raw_request: Request):
+ await engine_client(raw_request).start_draft_weight_update()
+ return JSONResponse(content={"message": "Draft weight update started"})
+
+
@router.post("/update_weights")
async def update_weights(raw_request: Request):
try:
diff --git a/vllm/v1/engine/async_llm.py b/vllm/v1/engine/async_llm.py
index 61f02092bd12..8bcd4ba89a4e 100644
--- a/vllm/v1/engine/async_llm.py
+++ b/vllm/v1/engine/async_llm.py
@@ -1084,6 +1084,10 @@ async def start_weight_update(self) -> None:
"""Start a new weight update."""
await self.collective_rpc("start_weight_update")
+ async def start_draft_weight_update(self) -> None:
+ """Start a new weight update targeting the speculative draft model."""
+ await self.collective_rpc("start_draft_weight_update")
+
async def update_weights(self, request: WeightTransferUpdateRequest) -> None:
"""
Batched weight update for RL training.
diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py
index 0a62295629f6..d2e1e0524f1d 100644
--- a/vllm/v1/worker/gpu/model_runner.py
+++ b/vllm/v1/worker/gpu/model_runner.py
@@ -371,6 +371,12 @@ def load_model(self, load_dummy_weights: bool = False, *args, **kwargs) -> None:
def get_model(self) -> nn.Module:
return self.model
+ def get_draft_model(self) -> nn.Module | None:
+ speculator = self.speculator
+ if not isinstance(speculator, DraftModelSpeculator):
+ return None
+ return speculator.model
+
def reload_weights(self, *args, **kwargs) -> None:
# TODO(Wentao): Use full version instead of import when fully migrated to v2
from vllm.v1.worker.gpu_model_runner import GPUModelRunner as GPUModelRunnerV1
diff --git a/vllm/v1/worker/gpu_model_runner.py b/vllm/v1/worker/gpu_model_runner.py
index cb8ed5f367c3..a675fcfffe20 100644
--- a/vllm/v1/worker/gpu_model_runner.py
+++ b/vllm/v1/worker/gpu_model_runner.py
@@ -3282,6 +3282,17 @@ def get_model(self) -> nn.Module:
return self.model.unwrap()
return self.model
+ def get_draft_model(self) -> nn.Module | None:
+ drafter = getattr(self, "drafter", None)
+ if drafter is None:
+ return None
+ model = getattr(drafter, "model", None)
+ if isinstance(
+ model, (CUDAGraphWrapper, UBatchWrapper, BreakableCUDAGraphWrapper)
+ ):
+ return cast(nn.Module, model.unwrap())
+ return cast(nn.Module | None, model)
+
def get_supported_generation_tasks(self) -> list[GenerationTask]:
model = self.get_model()
supported_tasks = list[GenerationTask]()
diff --git a/vllm/v1/worker/gpu_worker.py b/vllm/v1/worker/gpu_worker.py
index 1cd7e2598a03..5fb0c387737a 100644
--- a/vllm/v1/worker/gpu_worker.py
+++ b/vllm/v1/worker/gpu_worker.py
@@ -154,6 +154,7 @@ def __init__(
# Buffers saved before sleep
self._sleep_saved_buffers: dict[str, torch.Tensor] = {}
+ self._sleep_rebuild_draft_metadata_buffers = False
# Weight transfer engine is created in `load_model` once the model
# is available, since the engine needs a reference to the model.
@@ -198,6 +199,11 @@ def sleep(self, level: int = 1) -> None:
self._sleep_saved_buffers = {
name: buffer.cpu().clone() for name, buffer in model.named_buffers()
}
+ draft = self.get_draft_model()
+ inner = getattr(draft, "model", None) if draft is not None else None
+ self._sleep_rebuild_draft_metadata_buffers = inner is not None and hasattr(
+ inner, "_build_fused_kv_buffers"
+ )
self._get_sleep_mode_backend().suspend(level)
@@ -229,6 +235,14 @@ def wake_up(self, tags: list[str] | None = None) -> None:
buffer.data.copy_(self._sleep_saved_buffers[name].data)
self._sleep_saved_buffers = {}
+ if self._sleep_rebuild_draft_metadata_buffers:
+ draft = self.get_draft_model()
+ if draft is not None:
+ inner = getattr(draft, "model", None)
+ if inner is not None and hasattr(inner, "_build_fused_kv_buffers"):
+ inner._build_fused_kv_buffers()
+ self._sleep_rebuild_draft_metadata_buffers = False
+
if tags is None or "kv_cache" in tags:
self.model_runner.post_kv_cache_wake_up()
@@ -915,6 +929,29 @@ def reset_encoder_cache(self) -> None:
def get_model(self) -> nn.Module:
return self.model_runner.get_model()
+ def get_draft_model(self) -> nn.Module | None:
+ return self.model_runner.get_draft_model()
+
+ def _set_draft_weight_update_target(self) -> None:
+ assert self.weight_transfer_engine is not None
+
+ draft_model = self.get_draft_model()
+ if draft_model is None:
+ raise RuntimeError(
+ "Draft model weight update requested, but no draft model is configured."
+ )
+
+ speculative_config = self.speculative_config
+ if speculative_config is None or speculative_config.draft_model_config is None:
+ raise RuntimeError(
+ "Draft model weight update requested, but no draft model "
+ "config is configured."
+ )
+
+ self.weight_transfer_engine.set_weight_update_target(
+ draft_model, speculative_config.draft_model_config
+ )
+
def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
return self.model_runner.get_supported_tasks()
@@ -1179,16 +1216,38 @@ def start_weight_update(self) -> None:
the configured weight transfer engine. The worker only tracks that a
session is active.
"""
+ self._start_weight_update()
+
+ def start_draft_weight_update(self) -> None:
+ """
+ Like start_weight_update, but retargets the engine at the speculative
+ draft model for this session.
+ """
+ self._start_weight_update(is_draft=True)
+
+ def _start_weight_update(self, is_draft: bool = False) -> None:
self._check_weight_transfer_engine()
assert self.weight_transfer_engine is not None
+ if is_draft and not self.weight_transfer_engine.supports_draft_weight_update:
+ raise RuntimeError(
+ f"{type(self.weight_transfer_engine).__name__} does not support "
+ "draft model weight updates."
+ )
+
if self._weight_update_active:
raise RuntimeError(
"start_weight_update called while a weight update is already "
"active. Call finish_weight_update first."
)
- self.weight_transfer_engine.start_weight_update()
+ try:
+ if is_draft:
+ self._set_draft_weight_update_target()
+ self.weight_transfer_engine.start_weight_update()
+ except BaseException:
+ self.weight_transfer_engine.reset_weight_update_target()
+ raise
self._weight_update_active = True
def update_weights(self, update_info: dict) -> None:
@@ -1197,6 +1256,8 @@ def update_weights(self, update_info: dict) -> None:
start_weight_update must be called before update_weights and
finish_weight_update must be called after all chunks have been sent.
+ Every chunk loads into whichever model the session's start_weight_update
+ / start_draft_weight_update call selected.
Args:
update_info: Dictionary containing backend-specific update info
@@ -1213,6 +1274,7 @@ def update_weights(self, update_info: dict) -> None:
self.weight_transfer_engine.update_weights(update_info)
except BaseException:
self._weight_update_active = False
+ self.weight_transfer_engine.reset_weight_update_target()
raise
def finish_weight_update(self) -> None:
@@ -1226,6 +1288,7 @@ def finish_weight_update(self) -> None:
)
self.weight_transfer_engine.finish_weight_update()
+ self.weight_transfer_engine.reset_weight_update_target()
self._weight_update_active = False
def shutdown(self) -> None:
From a02984ed471488c0f0e8f73cab21be4325992d4c Mon Sep 17 00:00:00 2001
From: Canlin Guo
Date: Sun, 12 Jul 2026 14:14:49 +0800
Subject: [PATCH 0052/1526] [Perf][Qwen] Replace MOE all-reduce with
reduce-scatter (#47006)
Signed-off-by: gcanlin
Signed-off-by: yewentao256
Co-authored-by: yewentao256
---
.../layers/mamba/gdn/qwen_gdn_linear_attn.py | 2 +
vllm/model_executor/models/qwen3_5.py | 9 ++
vllm/model_executor/models/qwen3_next.py | 113 ++++++++++++++++--
3 files changed, 113 insertions(+), 11 deletions(-)
diff --git a/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py b/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
index 6e10b9a9932b..9e286c692d94 100644
--- a/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
+++ b/vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
@@ -437,6 +437,7 @@ def __init__(
vllm_config: VllmConfig,
prefix: str = "",
gqa_interleaved_layout=False,
+ reduce_results: bool = True,
) -> None:
super().__init__(config, vllm_config, prefix)
@@ -547,6 +548,7 @@ def __init__(
self.hidden_size,
bias=False,
input_is_parallel=True,
+ reduce_results=reduce_results,
quant_config=self.quant_config,
prefix=f"{prefix}.out_proj",
)
diff --git a/vllm/model_executor/models/qwen3_5.py b/vllm/model_executor/models/qwen3_5.py
index 2a7dc3e3621f..a58c3c4dd718 100644
--- a/vllm/model_executor/models/qwen3_5.py
+++ b/vllm/model_executor/models/qwen3_5.py
@@ -121,10 +121,17 @@ def __init__(
config = vllm_config.model_config.hf_text_config
model_config = vllm_config.model_config
cache_config = vllm_config.cache_config
+ parallel_config = vllm_config.parallel_config
quant_config = vllm_config.quant_config
self.layer_type = layer_type
self.layer_idx = extract_layer_index(prefix)
+ is_moe_layer = config.model_type == "qwen3_5_moe_text"
+ self.use_attn_reduce_scatter_for_moe = (
+ parallel_config.use_sequence_parallel_moe
+ and parallel_config.pipeline_parallel_size == 1
+ and is_moe_layer
+ )
if self.layer_type == "linear_attention":
self.linear_attn = QwenGatedDeltaNetAttention(
@@ -132,6 +139,7 @@ def __init__(
vllm_config=vllm_config,
prefix=f"{prefix}.linear_attn",
gqa_interleaved_layout=False,
+ reduce_results=not self.use_attn_reduce_scatter_for_moe,
)
elif self.layer_type == "full_attention":
self.self_attn = Qwen3NextAttention(
@@ -140,6 +148,7 @@ def __init__(
cache_config=cache_config,
quant_config=quant_config,
prefix=f"{prefix}.self_attn",
+ reduce_results=not self.use_attn_reduce_scatter_for_moe,
)
else:
raise ValueError(f"Invalid layer_type {self.layer_type}")
diff --git a/vllm/model_executor/models/qwen3_next.py b/vllm/model_executor/models/qwen3_next.py
index 9a18cd4aad7b..d87d19f02a62 100644
--- a/vllm/model_executor/models/qwen3_next.py
+++ b/vllm/model_executor/models/qwen3_next.py
@@ -16,6 +16,7 @@
get_pp_group,
get_tensor_model_parallel_world_size,
tensor_model_parallel_all_gather,
+ tensor_model_parallel_reduce_scatter,
)
from vllm.logger import init_logger
from vllm.model_executor.layers.attention import Attention
@@ -192,13 +193,17 @@ def __init__(self, vllm_config: VllmConfig, prefix: str = ""):
else None,
)
- def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
+ def forward(
+ self,
+ hidden_states: torch.Tensor,
+ already_sequence_parallel: bool = False,
+ ) -> torch.Tensor:
# NOTE: hidden_states can have either 1D or 2D shape.
orig_shape = hidden_states.shape
num_tokens, hidden_dim = hidden_states.shape
hidden_states = hidden_states.view(-1, hidden_dim)
- if self.is_sequence_parallel:
+ if self.is_sequence_parallel and not already_sequence_parallel:
hidden_states = sequence_parallel_chunk(hidden_states)
if self.experts.is_internal_router:
@@ -213,7 +218,7 @@ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
hidden_states=hidden_states, router_logits=router_logits
)
- if self.is_sequence_parallel:
+ if self.is_sequence_parallel and not already_sequence_parallel:
final_hidden_states = tensor_model_parallel_all_gather(
final_hidden_states, 0
)
@@ -229,6 +234,7 @@ def __init__(
model_config: ModelConfig | None = None,
cache_config: CacheConfig | None = None,
quant_config: QuantizationConfig | None = None,
+ reduce_results: bool = True,
prefix: str = "",
) -> None:
super().__init__()
@@ -271,6 +277,7 @@ def __init__(
self.total_num_heads * self.head_dim,
config.hidden_size,
bias=False,
+ reduce_results=reduce_results,
quant_config=quant_config,
prefix=f"{prefix}.o_proj",
)
@@ -405,16 +412,31 @@ def __init__(
model_config = vllm_config.model_config
cache_config = vllm_config.cache_config
quant_config = vllm_config.quant_config
+ parallel_config = vllm_config.parallel_config
self.layer_type = layer_type
self.layer_idx = extract_layer_index(prefix)
+ mlp_only_layers = (
+ [] if not hasattr(config, "mlp_only_layers") else config.mlp_only_layers
+ )
+ is_moe_layer = (self.layer_idx not in mlp_only_layers) and (
+ config.num_experts > 0
+ and (self.layer_idx + 1) % config.decoder_sparse_step == 0
+ )
+ self.use_attn_reduce_scatter_for_moe = (
+ parallel_config.use_sequence_parallel_moe
+ and parallel_config.pipeline_parallel_size == 1
+ and is_moe_layer
+ )
+
if self.layer_type == "linear_attention":
self.linear_attn = QwenGatedDeltaNetAttention(
config,
vllm_config=vllm_config,
prefix=f"{prefix}.linear_attn",
gqa_interleaved_layout=True,
+ reduce_results=not self.use_attn_reduce_scatter_for_moe,
)
elif self.layer_type == "full_attention":
self.self_attn = Qwen3NextAttention(
@@ -422,18 +444,13 @@ def __init__(
model_config=model_config,
cache_config=cache_config,
quant_config=quant_config,
+ reduce_results=not self.use_attn_reduce_scatter_for_moe,
prefix=f"{prefix}.self_attn",
)
else:
raise ValueError(f"Invalid layer_type {self.layer_type}")
- mlp_only_layers = (
- [] if not hasattr(config, "mlp_only_layers") else config.mlp_only_layers
- )
- if (self.layer_idx not in mlp_only_layers) and (
- config.num_experts > 0
- and (self.layer_idx + 1) % config.decoder_sparse_step == 0
- ):
+ if is_moe_layer:
self.mlp = Qwen3NextSparseMoeBlock(
vllm_config=vllm_config,
prefix=f"{prefix}.mlp",
@@ -478,12 +495,23 @@ def forward(
positions: torch.Tensor = None,
**kwargs: object,
):
+ full_num_tokens = positions.shape[-1]
+ input_is_sequence_parallel = (
+ self.use_attn_reduce_scatter_for_moe
+ and residual is not None
+ and hidden_states.shape[0] != full_num_tokens
+ )
+
if residual is None:
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states)
else:
hidden_states, residual = self.input_layernorm(hidden_states, residual)
+ if input_is_sequence_parallel:
+ hidden_states = tensor_model_parallel_all_gather(hidden_states, 0)
+ hidden_states = hidden_states[:full_num_tokens]
+
if self.layer_type == "linear_attention":
hidden_states = self.linear_attn(hidden_states=hidden_states)
elif self.layer_type == "full_attention":
@@ -504,9 +532,25 @@ def forward(
self.attn_layer_scale.to(hidden_states.dtype) + 1
)
+ if self.use_attn_reduce_scatter_for_moe:
+ tp_world_size = get_tensor_model_parallel_world_size()
+ # small trick using minus, eg. -17 % 8 = 7
+ sp_pad = (-hidden_states.shape[0]) % tp_world_size
+ # pad if not divisible by world size
+ hidden_states = torch.nn.functional.pad(hidden_states, (0, 0, 0, sp_pad))
+ hidden_states = tensor_model_parallel_reduce_scatter(hidden_states, 0)
+ if not input_is_sequence_parallel:
+ residual = sequence_parallel_chunk(residual)
+
# Fully Connected
hidden_states, residual = self.post_attention_layernorm(hidden_states, residual)
- hidden_states = self.mlp(hidden_states)
+ if self.use_attn_reduce_scatter_for_moe:
+ hidden_states = self.mlp(
+ hidden_states,
+ already_sequence_parallel=True,
+ )
+ else:
+ hidden_states = self.mlp(hidden_states)
if self.layer_scale:
if len(hidden_states.shape) == 2:
@@ -525,6 +569,24 @@ def forward(
return hidden_states, residual
+def _all_gather_hidden_and_residual(
+ hidden_states: torch.Tensor,
+ residual: torch.Tensor | None,
+ full_num_tokens: int,
+ hidden_size: int,
+) -> tuple[torch.Tensor, torch.Tensor | None]:
+ if residual is None:
+ hidden_states = tensor_model_parallel_all_gather(hidden_states, 0)
+ hidden_states = hidden_states[:full_num_tokens]
+ return hidden_states, None
+
+ combined_states = torch.cat([hidden_states, residual], dim=-1)
+ combined_states = tensor_model_parallel_all_gather(combined_states, 0)
+ combined_states = combined_states[:full_num_tokens]
+ hidden_states, residual = combined_states.split([hidden_size, hidden_size], dim=-1)
+ return hidden_states, residual
+
+
@support_torch_compile
class Qwen3NextModel(nn.Module, EagleModelMixin):
hf_to_vllm_mapper = WeightsMapper(
@@ -577,6 +639,8 @@ def get_layer(prefix: str):
else:
self.norm = PPMissingLayer()
+ self.aux_hidden_state_layers: tuple[int, ...] = ()
+
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.embed_tokens(input_ids)
@@ -598,16 +662,36 @@ def forward(
hidden_states = intermediate_tensors["hidden_states"]
residual = intermediate_tensors["residual"]
+ full_num_tokens = positions.shape[-1]
aux_hidden_states = self._maybe_add_hidden_state([], 0, hidden_states, residual)
for layer_idx, layer in enumerate(
islice(self.layers, self.start_layer, self.end_layer),
start=self.start_layer,
):
+ if (
+ hidden_states.shape[0] != full_num_tokens
+ and not layer.use_attn_reduce_scatter_for_moe
+ ):
+ hidden_states, residual = _all_gather_hidden_and_residual(
+ hidden_states,
+ residual,
+ full_num_tokens,
+ self.config.hidden_size,
+ )
hidden_states, residual = layer(
positions=positions,
hidden_states=hidden_states,
residual=residual,
)
+ if (layer_idx + 1) in self.aux_hidden_state_layers and hidden_states.shape[
+ 0
+ ] != full_num_tokens:
+ hidden_states, residual = _all_gather_hidden_and_residual(
+ hidden_states,
+ residual,
+ full_num_tokens,
+ self.config.hidden_size,
+ )
self._maybe_add_hidden_state(
aux_hidden_states, layer_idx + 1, hidden_states, residual
)
@@ -616,6 +700,13 @@ def forward(
return IntermediateTensors(
{"hidden_states": hidden_states, "residual": residual}
)
+ if hidden_states.shape[0] != full_num_tokens:
+ hidden_states, residual = _all_gather_hidden_and_residual(
+ hidden_states,
+ residual,
+ full_num_tokens,
+ self.config.hidden_size,
+ )
hidden_states, _ = self.norm(hidden_states, residual)
if aux_hidden_states:
return hidden_states, aux_hidden_states
From 83762b77b07e97c77f986e4bf5a9474952e47bb3 Mon Sep 17 00:00:00 2001
From: aoshen02
Date: Sun, 12 Jul 2026 14:21:02 +0800
Subject: [PATCH 0053/1526] [Frontend] Add /abort_requests to the RLHF dev API
router (#47173)
Signed-off-by: aoshen02
Co-authored-by: Claude Opus 4.8
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
docs/serving/online_serving/README.md | 1 +
docs/training/async_rl.md | 1 +
docs/usage/security.md | 1 +
rust/src/llm/src/inflight.rs | 7 +++
rust/src/llm/src/lib.rs | 7 ++-
rust/src/server/src/routes/abort_requests.rs | 8 +---
rust/src/server/src/routes/tests.rs | 11 ++---
vllm/entrypoints/serve/dev/rlhf/api_router.py | 45 +++++++++++++++++++
8 files changed, 69 insertions(+), 12 deletions(-)
diff --git a/docs/serving/online_serving/README.md b/docs/serving/online_serving/README.md
index 60476fa5edb5..6d984f1a62d7 100644
--- a/docs/serving/online_serving/README.md
+++ b/docs/serving/online_serving/README.md
@@ -170,6 +170,7 @@ For further details on Weight Transfer, please refer to [this page](../../traini
- `/pause` - Pause generation (causes denial of service)
- `/resume` - Resume generation
- `/is_paused` - Check if generation is paused
+- `/abort_requests` - Abort in-flight requests (all in-flight, or the given `request_ids`) without pausing the scheduler
- `/init_weight_transfer_engine` - Initialize weight transfer engine for RLHF
- `/start_weight_update` - Prepares the inference engine for a weight update.
- `/update_weights` - Update model weights (can alter model behavior)
diff --git a/docs/training/async_rl.md b/docs/training/async_rl.md
index d3be23fe698d..e655f9c39ffe 100644
--- a/docs/training/async_rl.md
+++ b/docs/training/async_rl.md
@@ -42,6 +42,7 @@ When using the vLLM HTTP server, the same functionality is available via:
- `POST /pause?mode=keep` - Pause generation
- `POST /resume` - Resume generation
+- `POST /abort_requests` - Abort in-flight requests without pausing the scheduler (send `{}` to abort all, or `{"request_ids": [...]}`)
!!! note "Data Parallelism"
When using data parallelism with vLLM's **internal load balancer** (i.e. `data_parallel_backend="ray"`), pause and resume are handled automatically across all DP ranks -- a single call is sufficient. When using an **external load balancer** (i.e. multiple independent vLLM instances behind a proxy), you must send pause and resume requests to **every** engine instance individually before and after the weight update.
diff --git a/docs/usage/security.md b/docs/usage/security.md
index d222155b7709..ee49374e7b91 100644
--- a/docs/usage/security.md
+++ b/docs/usage/security.md
@@ -191,6 +191,7 @@ The following endpoints **do not require authentication** even when `--api-key`
- `/pause` - Pause generation (causes denial of service)
- `/resume` - Resume generation
- `/is_paused` - Check if generation is paused
+- `/abort_requests` - Abort in-flight requests (causes loss of in-flight work)
- `/scale_elastic_ep` - Trigger scaling operations
- `/is_scaling_elastic_ep` - Check if scaling is in progress
- `/init_weight_transfer_engine` - Initialize weight transfer engine for RLHF
diff --git a/rust/src/llm/src/inflight.rs b/rust/src/llm/src/inflight.rs
index 37df1441172a..cd4455df31de 100644
--- a/rust/src/llm/src/inflight.rs
+++ b/rust/src/llm/src/inflight.rs
@@ -64,6 +64,13 @@ impl InflightRequests {
.collect()
}
+ /// Collect the internal engine ids of every in-flight request. Used to
+ /// abort all outstanding requests when no external ids are given.
+ pub(crate) fn all_internal_ids(&self) -> Vec {
+ let map = self.map.lock();
+ map.values().flat_map(|internal_ids| internal_ids.keys()).cloned().collect()
+ }
+
#[cfg(test)]
fn is_empty(&self) -> bool {
self.map.lock().is_empty()
diff --git a/rust/src/llm/src/lib.rs b/rust/src/llm/src/lib.rs
index 942bf55c288f..52f575893184 100644
--- a/rust/src/llm/src/lib.rs
+++ b/rust/src/llm/src/lib.rs
@@ -122,7 +122,12 @@ impl Llm {
/// tracking entries themselves are removed when the corresponding output
/// streams are dropped, not here.
pub async fn abort(&self, external_ids: &[String]) -> Result<()> {
- let internal_ids = self.inflight.resolve(external_ids);
+ // Empty `external_ids` means abort every in-flight request.
+ let internal_ids = if external_ids.is_empty() {
+ self.inflight.all_internal_ids()
+ } else {
+ self.inflight.resolve(external_ids)
+ };
if internal_ids.is_empty() {
return Ok(());
}
diff --git a/rust/src/server/src/routes/abort_requests.rs b/rust/src/server/src/routes/abort_requests.rs
index 34fb041c800e..1f300a09c23a 100644
--- a/rust/src/server/src/routes/abort_requests.rs
+++ b/rust/src/server/src/routes/abort_requests.rs
@@ -20,12 +20,8 @@ pub async fn abort_requests(
body: Result, JsonRejection>,
) -> Result {
let Json(body) = body.map_err(|error| ApiError::json_parse_error(error.body_text()))?;
- let request_ids = body.request_ids.ok_or_else(|| {
- ApiError::invalid_request(
- "Missing 'request_ids' in request body".to_string(),
- Some("request_ids"),
- )
- })?;
+ // Empty/missing `request_ids` aborts all in-flight requests.
+ let request_ids = body.request_ids.unwrap_or_default();
state
.chat
diff --git a/rust/src/server/src/routes/tests.rs b/rust/src/server/src/routes/tests.rs
index 802d3abc3d5a..117b7d968d8e 100644
--- a/rust/src/server/src/routes/tests.rs
+++ b/rust/src/server/src/routes/tests.rs
@@ -5276,10 +5276,12 @@ async fn abort_requests_route_returns_ok_for_well_formed_body() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
-async fn abort_requests_route_rejects_missing_request_ids() {
+async fn abort_requests_route_aborts_all_when_request_ids_missing() {
let (app, engine_task) =
test_admin_app_with_engine_script(|_dealer, _push| boxed_test_future(async move {})).await;
+ // Missing `request_ids` means "abort all in-flight requests"; with no
+ // in-flight requests this is a no-op that still succeeds.
let response = app
.clone()
.call(
@@ -5293,11 +5295,10 @@ async fn abort_requests_route_rejects_missing_request_ids() {
.await
.expect("call app");
- assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+ let status = response.status();
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"]["param"], "request_ids");
+ assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
+ assert!(body.is_empty());
engine_task.abort_and_join().await;
}
diff --git a/vllm/entrypoints/serve/dev/rlhf/api_router.py b/vllm/entrypoints/serve/dev/rlhf/api_router.py
index 9e9fc0d3d89e..8a2494a59df2 100644
--- a/vllm/entrypoints/serve/dev/rlhf/api_router.py
+++ b/vllm/entrypoints/serve/dev/rlhf/api_router.py
@@ -91,6 +91,51 @@ async def resume_generation(raw_request: Request) -> JSONResponse:
)
+@router.post("/abort_requests")
+async def abort_requests(raw_request: Request) -> JSONResponse:
+ """Abort in-flight requests without pausing the scheduler.
+
+ Empty/missing ``request_ids`` aborts all in-flight requests.
+ """
+
+ engine = engine_client(raw_request)
+
+ try:
+ body = await raw_request.json()
+ except json.JSONDecodeError as e:
+ raise HTTPException(status_code=400, detail="Invalid JSON format") from e # noqa: B904
+
+ request_ids = body.get("request_ids")
+
+ try:
+ if request_ids:
+ # Body ids are external (user-supplied) request ids.
+ await engine.abort(request_ids)
+ else:
+ # The dev RL server runs AsyncLLM; abort everything it is tracking.
+ # request_states is keyed by internal ids; parent_requests holds
+ # parallel-sampling parents. Abort both as internal ids.
+ from vllm.v1.engine.async_llm import AsyncLLM
+
+ assert isinstance(engine, AsyncLLM)
+ op = engine.output_processor
+ request_ids = [
+ *op.request_states.keys(),
+ *op.parent_requests.keys(),
+ ]
+ await engine.abort(request_ids, internal=True)
+ return JSONResponse(
+ content={"status": "aborted", "aborted": len(request_ids)},
+ status_code=HTTPStatus.OK.value,
+ )
+ except Exception as err: # pragma: no cover - defensive
+ logger.exception("Failed to abort requests")
+ return JSONResponse(
+ content={"error": f"Failed to abort requests: {err}"},
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value,
+ )
+
+
@router.get("/is_paused")
async def is_paused(raw_request: Request) -> JSONResponse:
"""Return the current pause status."""
From 5f8e73cb8b8d41f7a2a5168cddf5b772888fa991 Mon Sep 17 00:00:00 2001
From: Hugo Centeno <133872718+hugo-cen@users.noreply.github.com>
Date: Sun, 12 Jul 2026 11:39:27 +0200
Subject: [PATCH 0054/1526] [Bugfix] Guard mixed-dtype allreduce RMSNorm quant
fusions (#48330)
Signed-off-by: hcenteno
---
.../distributed/test_fusion_all_reduce.py | 33 ++++++++++++++++++-
.../passes/fusion/allreduce_rms_fusion.py | 14 ++++++--
2 files changed, 44 insertions(+), 3 deletions(-)
diff --git a/tests/compile/passes/distributed/test_fusion_all_reduce.py b/tests/compile/passes/distributed/test_fusion_all_reduce.py
index b86018a75559..1aac4b2bec49 100644
--- a/tests/compile/passes/distributed/test_fusion_all_reduce.py
+++ b/tests/compile/passes/distributed/test_fusion_all_reduce.py
@@ -222,6 +222,25 @@ def ops_in_model_before(self):
]
+class TestAllReduceGemmaRMSNormStaticQuantFP8Model(
+ TestAllReduceRMSNormStaticQuantFP8Model
+):
+ def __init__(
+ self,
+ hidden_size=16,
+ token_num=16,
+ eps=1e-6,
+ dtype: torch.dtype = torch.float16,
+ ):
+ super().__init__(hidden_size, token_num, eps, dtype)
+ self.norm = [GemmaRMSNorm(hidden_size, eps) for _ in range(4)]
+ for norm in self.norm:
+ norm.weight.requires_grad_(False)
+
+ def ops_in_model_before(self):
+ return [torch.ops.vllm.all_reduce.default]
+
+
class TestAiterAllReduceRMSNormGroupQuantFP8Model(torch.nn.Module):
"""Exercises the new ROCm AITER AR+RMS+per-group-FP8-quant patterns.
@@ -416,6 +435,15 @@ def ops_in_model_before(self):
reason="Not supported on ROCm platform",
),
),
+ pytest.param(
+ TestAllReduceGemmaRMSNormStaticQuantFP8Model,
+ True,
+ False,
+ marks=pytest.mark.skipif(
+ current_platform.is_rocm(),
+ reason="Not supported on ROCm platform",
+ ),
+ ),
pytest.param(
TestAllReduceRMSNormStaticQuantFP8Model,
False,
@@ -606,7 +634,10 @@ def all_reduce_fusion_pass_on_test_model(
)
backend.check_before_ops(model.ops_in_model_before(), fully_replaced=False)
backend.check_after_ops(model.ops_in_model_after())
- if test_model_cls is TestAllReduceGemmaRMSNormModel:
+ if test_model_cls in (
+ TestAllReduceGemmaRMSNormModel,
+ TestAllReduceGemmaRMSNormStaticQuantFP8Model,
+ ):
fused_op = torch.ops.vllm.flashinfer_trtllm_fused_allreduce_norm.default
fused_nodes = list(find_op_nodes(fused_op, backend.graph_post_pass))
assert fused_nodes
diff --git a/vllm/compilation/passes/fusion/allreduce_rms_fusion.py b/vllm/compilation/passes/fusion/allreduce_rms_fusion.py
index ab4000289252..906dea8101c9 100644
--- a/vllm/compilation/passes/fusion/allreduce_rms_fusion.py
+++ b/vllm/compilation/passes/fusion/allreduce_rms_fusion.py
@@ -752,7 +752,12 @@ def replacement(
return allreduce[4], allreduce[2]
pm.register_replacement(
- pattern, replacement, self.get_inputs(), pm.fwd_only, pm_pass
+ pattern,
+ replacement,
+ self.get_inputs(),
+ pm.fwd_only,
+ pm_pass,
+ extra_check=_norm_input_weight_dtype_match,
)
@@ -941,7 +946,12 @@ def replacement(
return allreduce[4], allreduce[2], allreduce[5]
pm.register_replacement(
- pattern, replacement, self.get_inputs(), pm.fwd_only, pm_pass
+ pattern,
+ replacement,
+ self.get_inputs(),
+ pm.fwd_only,
+ pm_pass,
+ extra_check=_norm_input_weight_dtype_match,
)
From 5c0c987c03593a1c05b8728fb265c94deb3d0a4c Mon Sep 17 00:00:00 2001
From: liranschour
Date: Sun, 12 Jul 2026 13:10:21 +0300
Subject: [PATCH 0055/1526] Make tiering offload region DP-replica aware
(#47987)
Signed-off-by: Liran Schour
Co-authored-by: Or Ozeri
---
tests/v1/kv_offload/cpu/test_gpu_worker.py | 2 +-
.../cpu/test_shared_offload_region.py | 22 +++++++++----------
.../kv_offload/cpu/shared_offload_region.py | 6 ++---
vllm/v1/kv_offload/tiering/spec.py | 16 +++++++++++---
4 files changed, 28 insertions(+), 18 deletions(-)
diff --git a/tests/v1/kv_offload/cpu/test_gpu_worker.py b/tests/v1/kv_offload/cpu/test_gpu_worker.py
index d8ce9093c25d..12dbc57fe97c 100644
--- a/tests/v1/kv_offload/cpu/test_gpu_worker.py
+++ b/tests/v1/kv_offload/cpu/test_gpu_worker.py
@@ -96,7 +96,7 @@ def test_transfer(
SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT,
)
mmap_region = SharedOffloadRegion(
- instance_id=str(uuid.uuid4()),
+ engine_id=str(uuid.uuid4()),
num_blocks=num_cpu_blocks,
rank=0,
kv_bytes_per_block=cpu_page_size,
diff --git a/tests/v1/kv_offload/cpu/test_shared_offload_region.py b/tests/v1/kv_offload/cpu/test_shared_offload_region.py
index f69fcf9a705b..ca7295f1978e 100644
--- a/tests/v1/kv_offload/cpu/test_shared_offload_region.py
+++ b/tests/v1/kv_offload/cpu/test_shared_offload_region.py
@@ -34,7 +34,7 @@ def _set_spawn_method(monkeypatch):
def _make_region(
- instance_id: str,
+ engine_id: str,
num_blocks: int = 4,
cpu_page_size: int = PAGE_SIZE,
num_workers: int = 1,
@@ -42,7 +42,7 @@ def _make_region(
) -> SharedOffloadRegion:
assert cpu_page_size % PAGE_SIZE == 0
return SharedOffloadRegion(
- instance_id=instance_id,
+ engine_id=engine_id,
num_blocks=num_blocks,
rank=rank,
kv_bytes_per_block=num_workers * cpu_page_size,
@@ -57,9 +57,9 @@ def _cleanup_file(path: str) -> None:
@contextlib.contextmanager
-def _region(instance_id: str, **kwargs):
+def _region(engine_id: str, **kwargs):
"""Context manager: create one region, clean up on exit."""
- r = _make_region(instance_id, **kwargs)
+ r = _make_region(engine_id, **kwargs)
try:
yield r
finally:
@@ -69,7 +69,7 @@ def _region(instance_id: str, **kwargs):
@contextlib.contextmanager
def _multi_region(
- instance_id: str,
+ engine_id: str,
num_workers: int,
num_blocks: int = 4,
cpu_page_size: int = PAGE_SIZE,
@@ -77,7 +77,7 @@ def _multi_region(
"""Context manager: create one SharedOffloadRegion per rank, clean up on exit."""
regions = [
SharedOffloadRegion(
- instance_id=instance_id,
+ engine_id=engine_id,
num_blocks=num_blocks,
rank=rank,
kv_bytes_per_block=num_workers * cpu_page_size,
@@ -94,7 +94,7 @@ def _multi_region(
def _race_construct(
- instance_id: str,
+ engine_id: str,
num_workers: int,
num_blocks: int = 4,
cpu_page_size: int = PAGE_SIZE,
@@ -108,7 +108,7 @@ def worker(rank: int) -> None:
barrier.wait() # all threads start at the same instant
try:
regions[rank] = SharedOffloadRegion(
- instance_id=instance_id,
+ engine_id=engine_id,
num_blocks=num_blocks,
rank=rank,
kv_bytes_per_block=num_workers * cpu_page_size,
@@ -127,7 +127,7 @@ def worker(rank: int) -> None:
def _mp_race_construct_and_write(
- instance_id: str,
+ engine_id: str,
num_blocks: int,
rank: int,
num_workers: int,
@@ -141,7 +141,7 @@ def _mp_race_construct_and_write(
parent a window to read the raw mmap before the creator removes the file."""
try:
region = SharedOffloadRegion(
- instance_id=instance_id,
+ engine_id=engine_id,
num_blocks=num_blocks,
rank=rank,
kv_bytes_per_block=num_workers * cpu_page_size,
@@ -308,7 +308,7 @@ def test_create_next_view_multiprocess_slots(iid):
# Parent is rank 0 (creator); child is rank 1 (joiner).
region = SharedOffloadRegion(
- instance_id=iid,
+ engine_id=iid,
num_blocks=num_blocks,
rank=0,
kv_bytes_per_block=num_workers * PAGE_SIZE,
diff --git a/vllm/v1/kv_offload/cpu/shared_offload_region.py b/vllm/v1/kv_offload/cpu/shared_offload_region.py
index d5400e0ca723..290c0d5107d4 100644
--- a/vllm/v1/kv_offload/cpu/shared_offload_region.py
+++ b/vllm/v1/kv_offload/cpu/shared_offload_region.py
@@ -33,14 +33,14 @@ class SharedOffloadRegion:
the rest open the existing file and wait until it reaches the expected
size. Each worker then mmap()s the full file.
- File path: /dev/shm/vllm_offload_{instance_id}.mmap
+ File path: /dev/shm/vllm_offload_{engine_id}.mmap
"""
BLOCK_SIZE_ALIGNMENT: int = mmap.PAGESIZE
def __init__(
self,
- instance_id: str,
+ engine_id: str,
num_blocks: int,
rank: int | None,
kv_bytes_per_block: int,
@@ -53,7 +53,7 @@ def __init__(
self._row_stride = kv_bytes_per_block
self.total_size_bytes = self.num_blocks * self._row_stride
- self.mmap_path = f"/dev/shm/vllm_offload_{instance_id}.mmap"
+ self.mmap_path = f"/dev/shm/vllm_offload_{engine_id}.mmap"
self._creator = False # set True only if this worker creates the file
self.rank = rank
if rank is not None:
diff --git a/vllm/v1/kv_offload/tiering/spec.py b/vllm/v1/kv_offload/tiering/spec.py
index 3dc31a3622ee..5f9e8cdc2379 100644
--- a/vllm/v1/kv_offload/tiering/spec.py
+++ b/vllm/v1/kv_offload/tiering/spec.py
@@ -108,6 +108,13 @@ def __init__(self, vllm_config: VllmConfig, kv_cache_config: KVCacheConfig):
# Scheduler-side mmap (rank=None); kept for cleanup
self._scheduler_mmap: SharedOffloadRegion | None = None
+ # engine_id is unique per DP replica (suffixed with _dp{rank} in both
+ # the Ray and multiprocessing paths), so it names a per-replica offload
+ # region. Non-None is guaranteed by OffloadingSpec.__init__.
+ assert vllm_config.kv_transfer_config is not None
+ assert vllm_config.kv_transfer_config.engine_id is not None
+ self._engine_id: str = vllm_config.kv_transfer_config.engine_id
+
@override
def get_manager(self) -> OffloadingManager:
"""
@@ -124,7 +131,7 @@ def get_manager(self) -> OffloadingManager:
# Create scheduler-side SharedOffloadRegion (rank=None) so the
# primary tier can eagerly create a memoryview over _base.
scheduler_mmap = SharedOffloadRegion(
- instance_id=self.vllm_config.instance_id,
+ engine_id=self._engine_id,
num_blocks=self.num_blocks,
rank=None,
kv_bytes_per_block=self.kv_bytes_per_offloaded_block,
@@ -187,9 +194,12 @@ def get_manager(self) -> OffloadingManager:
@override
def create_worker(self, kv_caches: CanonicalKVCaches) -> CPUOffloadingWorker:
- rank = torch.accelerator.current_device_index()
+ # Fold the global physical device index into the replica-local
+ # [0, world_size) slot range.
+ world_size = self.vllm_config.parallel_config.world_size
+ rank = torch.accelerator.current_device_index() % world_size
worker_mmap = SharedOffloadRegion(
- instance_id=self.vllm_config.instance_id,
+ engine_id=self._engine_id,
num_blocks=self.num_blocks,
rank=rank,
kv_bytes_per_block=self.kv_bytes_per_offloaded_block,
From 370b678a028872e7c7def9aa18cfef3ab1fa750e Mon Sep 17 00:00:00 2001
From: Jiangyun Zhu
Date: Sun, 12 Jul 2026 19:16:55 +0800
Subject: [PATCH 0056/1526] [CI][2/N] reduce CI time (#48394)
Signed-off-by: zjy0516
---
.buildkite/test_areas/attention.yaml | 6 ++++--
.buildkite/test_areas/kernels.yaml | 6 ++++--
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/.buildkite/test_areas/attention.yaml b/.buildkite/test_areas/attention.yaml
index 9c662fec1afc..aadea2908c68 100644
--- a/.buildkite/test_areas/attention.yaml
+++ b/.buildkite/test_areas/attention.yaml
@@ -12,7 +12,8 @@ steps:
- vllm/v1/attention
- tests/v1/attention
commands:
- - pytest -v -s v1/attention
+ - pytest -v -s v1/attention --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT
+ parallelism: 2
mirror:
amd:
device: mi325_1
@@ -38,4 +39,5 @@ steps:
- vllm/v1/attention
- tests/v1/attention
commands:
- - pytest -v -s v1/attention
+ - pytest -v -s v1/attention --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT
+ parallelism: 2
diff --git a/.buildkite/test_areas/kernels.yaml b/.buildkite/test_areas/kernels.yaml
index d9e350f5093a..894cda341193 100644
--- a/.buildkite/test_areas/kernels.yaml
+++ b/.buildkite/test_areas/kernels.yaml
@@ -23,7 +23,8 @@ steps:
- tests/kernels/test_concat_mla_q.py
- tests/kernels/test_fused_qk_norm_rope_gate.py
commands:
- - pytest -v -s kernels/core --ignore=kernels/core/test_minimax_reduce_rms.py kernels/test_concat_mla_q.py kernels/test_fused_qk_norm_rope_gate.py
+ - pytest -v -s kernels/core --ignore=kernels/core/test_minimax_reduce_rms.py kernels/test_concat_mla_q.py kernels/test_fused_qk_norm_rope_gate.py --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT
+ parallelism: 3
- label: Kernels MiniMax Reduce RMS Test (2 GPUs)
key: kernels-minimax-reduce-rms-test-2-gpus
@@ -271,7 +272,8 @@ steps:
- tests/kernels/helion/
commands:
- pip install helion==1.1.0
- - pytest -v -s kernels/helion/
+ - pytest -v -s kernels/helion/ --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT
+ parallelism: 2
- label: Kernels FP8 MoE Test (1xH100)
From 8df14cfc8c8a09b4e57f082e59593a3abce4ffb3 Mon Sep 17 00:00:00 2001
From: omerpaz95 <73347585+omerpaz95@users.noreply.github.com>
Date: Sun, 12 Jul 2026 14:35:33 +0300
Subject: [PATCH 0057/1526] [EC Connector] Add EC Transfer Params (#42433)
Signed-off-by: omerpaz95
Co-authored-by: Or Ozeri
---
.buildkite/test_areas/misc.yaml | 2 +
rust/proto/vllm_grpc.proto | 4 +
rust/src/chat/src/event.rs | 3 +
rust/src/chat/src/output/default/unified.rs | 4 +
rust/src/chat/src/output/harmony/mod.rs | 1 +
rust/src/chat/src/output/harmony/tests.rs | 3 +
rust/src/chat/src/output/mod.rs | 3 +
rust/src/chat/src/output/structured.rs | 12 +-
rust/src/chat/src/stream.rs | 7 ++
rust/src/chat/tests/chat.rs | 2 +
rust/src/chat/tests/roundtrip.rs | 2 +
.../engine-core-client/src/protocol/output.rs | 4 +
.../engine-core-client/src/tests/client.rs | 2 +
.../src/tests/python_compat.py | 1 +
rust/src/llm/src/output.rs | 10 ++
rust/src/llm/tests/generate.rs | 5 +
rust/src/server/src/grpc/convert.rs | 7 ++
rust/src/server/src/grpc/mod.rs | 1 +
rust/src/server/src/grpc/tests.rs | 1 +
.../server/src/routes/http_client_tests.rs | 1 +
.../server/src/routes/inference/generate.rs | 3 +
.../src/routes/inference/generate/convert.rs | 6 +-
.../src/routes/inference/generate/types.rs | 2 +
.../src/routes/openai/chat_completions.rs | 8 ++
.../routes/openai/chat_completions/convert.rs | 6 +-
.../routes/openai/chat_completions/types.rs | 5 +
.../server/src/routes/openai/completions.rs | 7 ++
.../src/routes/openai/completions/convert.rs | 6 +-
.../src/routes/openai/completions/types.rs | 4 +
rust/src/server/src/routes/tests.rs | 5 +
rust/src/server/src/utils.rs | 18 +++
rust/src/text/src/output/decoded.rs | 5 +
rust/src/text/src/output/mod.rs | 7 ++
tests/v1/core/test_async_scheduler.py | 2 +-
tests/v1/core/test_scheduler.py | 2 +-
.../unit/test_ec_transfer_params.py | 118 ++++++++++++++++++
vllm/entrypoints/anthropic/protocol.py | 9 ++
vllm/entrypoints/anthropic/serving.py | 2 +
.../openai/chat_completion/protocol.py | 13 ++
.../openai/chat_completion/serving.py | 1 +
.../entrypoints/openai/completion/protocol.py | 13 ++
vllm/entrypoints/openai/completion/serving.py | 4 +
vllm/entrypoints/openai/responses/context.py | 10 ++
vllm/entrypoints/openai/responses/protocol.py | 13 ++
vllm/entrypoints/openai/responses/serving.py | 1 +
.../scale_out/token_in_token_out/protocol.py | 12 ++
.../scale_out/token_in_token_out/serving.py | 1 +
vllm/envs.py | 12 ++
vllm/outputs.py | 4 +
vllm/v1/core/sched/interface.py | 4 +
vllm/v1/core/sched/scheduler.py | 23 +++-
vllm/v1/engine/__init__.py | 1 +
vllm/v1/engine/core.py | 9 ++
vllm/v1/engine/output_processor.py | 12 +-
vllm/v1/request.py | 5 +
55 files changed, 416 insertions(+), 12 deletions(-)
create mode 100644 tests/v1/ec_connector/unit/test_ec_transfer_params.py
diff --git a/.buildkite/test_areas/misc.yaml b/.buildkite/test_areas/misc.yaml
index 13840c8db2fc..3365d09c7b85 100644
--- a/.buildkite/test_areas/misc.yaml
+++ b/.buildkite/test_areas/misc.yaml
@@ -89,6 +89,7 @@ steps:
- tests/v1/simple_kv_offload
- tests/v1/worker
- tests/v1/kv_connector/unit
+ - tests/v1/ec_connector/unit
- tests/v1/metrics
- tests/entrypoints/openai/correctness/test_lmeval.py
commands:
@@ -101,6 +102,7 @@ steps:
- pytest -v -s v1/simple_kv_offload
- pytest -v -s v1/worker
- pytest -v -s -m 'not cpu_test' v1/kv_connector/unit
+ - pytest -v -s -m 'not cpu_test' v1/ec_connector/unit
- pytest -v -s -m 'not cpu_test' v1/metrics
# Integration test for streaming correctness (requires special branch).
- pip install -U git+https://github.com/vllm-project/lm-evaluation-harness.git@streaming-api
diff --git a/rust/proto/vllm_grpc.proto b/rust/proto/vllm_grpc.proto
index 56c5f36442db..5f6539986d5a 100644
--- a/rust/proto/vllm_grpc.proto
+++ b/rust/proto/vllm_grpc.proto
@@ -107,6 +107,9 @@ message KVCacheParameters {
// KV Connector transfer parameters
google.protobuf.Struct kv_transfer_params = 3;
+
+ // Encoder cache connector transfer parameters
+ google.protobuf.Struct ec_transfer_params = 4;
}
// Controls which extra candidate tokens at each position should be returned
@@ -173,6 +176,7 @@ message FinishInfo {
google.protobuf.Struct kv_transfer_params = 6;
//uint64 seed = 7;
+ google.protobuf.Struct ec_transfer_params = 8;
}
// Info for candidate tokens other than the input/sampled
diff --git a/rust/src/chat/src/event.rs b/rust/src/chat/src/event.rs
index d6b5f8f7624f..9edfac534a07 100644
--- a/rust/src/chat/src/event.rs
+++ b/rust/src/chat/src/event.rs
@@ -202,5 +202,8 @@ pub enum ChatEvent {
finish_reason: FinishReason,
/// Connector-specific KV transfer parameters for disaggregated serving.
kv_transfer_params: Option,
+ /// Connector-specific encoder cache transfer parameters for
+ /// disaggregated serving.
+ ec_transfer_params: Option,
},
}
diff --git a/rust/src/chat/src/output/default/unified.rs b/rust/src/chat/src/output/default/unified.rs
index e9ba676c8c8f..3290be19e660 100644
--- a/rust/src/chat/src/output/default/unified.rs
+++ b/rust/src/chat/src/output/default/unified.rs
@@ -257,6 +257,7 @@ pub(crate) async fn unified_event_stream(
usage: finished.usage,
finish_reason: finished.finish_reason,
kv_transfer_params: finished.kv_transfer_params,
+ ec_transfer_params: finished.ec_transfer_params,
})
.await;
}
@@ -387,6 +388,7 @@ mod tests {
usage: vllm_llm::TokenUsage::default(),
finish_reason: crate::FinishReason::Stop(None),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}
}
@@ -628,6 +630,7 @@ mod tests {
usage: vllm_llm::TokenUsage::default(),
finish_reason: crate::FinishReason::Stop(None),
kv_transfer_params: None,
+ ec_transfer_params: None,
},
]
);
@@ -671,6 +674,7 @@ mod tests {
usage: vllm_llm::TokenUsage::default(),
finish_reason: crate::FinishReason::Stop(None),
kv_transfer_params: None,
+ ec_transfer_params: None,
},
]
);
diff --git a/rust/src/chat/src/output/harmony/mod.rs b/rust/src/chat/src/output/harmony/mod.rs
index 597e3133795f..7f4f7e30d6b1 100644
--- a/rust/src/chat/src/output/harmony/mod.rs
+++ b/rust/src/chat/src/output/harmony/mod.rs
@@ -370,6 +370,7 @@ async fn harmony_assistant_event_stream(
usage: finished.usage,
finish_reason: finished.finish_reason,
kv_transfer_params: finished.kv_transfer_params,
+ ec_transfer_params: finished.ec_transfer_params,
})
.await;
}
diff --git a/rust/src/chat/src/output/harmony/tests.rs b/rust/src/chat/src/output/harmony/tests.rs
index cdb272e2cced..1de4aaf7b261 100644
--- a/rust/src/chat/src/output/harmony/tests.rs
+++ b/rust/src/chat/src/output/harmony/tests.rs
@@ -52,6 +52,7 @@ fn finished() -> Finished {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}
}
@@ -115,6 +116,7 @@ fn interrupted_final_message_is_preserved() {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
})
);
}
@@ -175,6 +177,7 @@ fn interrupted_analysis_message_is_preserved() {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
})
);
}
diff --git a/rust/src/chat/src/output/mod.rs b/rust/src/chat/src/output/mod.rs
index 836b199eb9b7..3b839f971fbe 100644
--- a/rust/src/chat/src/output/mod.rs
+++ b/rust/src/chat/src/output/mod.rs
@@ -48,6 +48,9 @@ pub(crate) enum AssistantEvent {
finish_reason: FinishReason,
/// Connector-specific KV transfer parameters for disaggregated serving.
kv_transfer_params: Option,
+ /// Connector-specific encoder cache transfer parameters for
+ /// disaggregated serving.
+ ec_transfer_params: Option,
},
}
diff --git a/rust/src/chat/src/output/structured.rs b/rust/src/chat/src/output/structured.rs
index 4be7425d9015..908261e4943f 100644
--- a/rust/src/chat/src/output/structured.rs
+++ b/rust/src/chat/src/output/structured.rs
@@ -146,6 +146,7 @@ impl StructuredEventState {
usage: vllm_llm::TokenUsage,
finish_reason: FinishReason,
kv_transfer_params: Option,
+ ec_transfer_params: Option,
) -> Result> {
let mut events = Vec::new();
self.close_open_text_block(&mut events);
@@ -155,6 +156,7 @@ impl StructuredEventState {
usage,
finish_reason,
kv_transfer_params,
+ ec_transfer_params,
});
Ok(events)
}
@@ -296,8 +298,11 @@ pub(crate) async fn structured_chat_event_stream(
usage,
finish_reason,
kv_transfer_params,
+ ec_transfer_params,
} => {
- for next in state.finish(usage, finish_reason, kv_transfer_params)? {
+ for next in
+ state.finish(usage, finish_reason, kv_transfer_params, ec_transfer_params)?
+ {
y.yield_ok(next).await;
}
}
@@ -334,6 +339,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -388,6 +394,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -439,6 +446,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -490,6 +498,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -557,6 +566,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
diff --git a/rust/src/chat/src/stream.rs b/rust/src/chat/src/stream.rs
index fb5c7d3e3f07..8b0a8eed5d07 100644
--- a/rust/src/chat/src/stream.rs
+++ b/rust/src/chat/src/stream.rs
@@ -22,6 +22,9 @@ pub struct CollectedAssistantMessage {
pub finish_reason: FinishReason,
/// Connector-specific KV transfer parameters for disaggregated serving.
pub kv_transfer_params: Option,
+ /// Connector-specific encoder cache transfer parameters for disaggregated
+ /// serving.
+ pub ec_transfer_params: Option,
}
/// Per-request stream of chat events.
@@ -77,6 +80,7 @@ impl ChatEventStream {
usage,
finish_reason,
kv_transfer_params,
+ ec_transfer_params,
} => {
return Ok(CollectedAssistantMessage {
message: done,
@@ -89,6 +93,7 @@ impl ChatEventStream {
usage,
finish_reason,
kv_transfer_params,
+ ec_transfer_params,
});
}
ChatEvent::ToolCallEnd { call, .. } => {
@@ -194,6 +199,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]),
);
@@ -234,6 +240,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}
);
}
diff --git a/rust/src/chat/tests/chat.rs b/rust/src/chat/tests/chat.rs
index 77e93ac5c550..c0325d184bf5 100644
--- a/rust/src/chat/tests/chat.rs
+++ b/rust/src/chat/tests/chat.rs
@@ -50,6 +50,7 @@ fn request_output(
stop_reason,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -75,6 +76,7 @@ fn request_output_with_logprobs(
stop_reason,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
diff --git a/rust/src/chat/tests/roundtrip.rs b/rust/src/chat/tests/roundtrip.rs
index 83cf9002be28..597fbe12ad70 100644
--- a/rust/src/chat/tests/roundtrip.rs
+++ b/rust/src/chat/tests/roundtrip.rs
@@ -676,6 +676,7 @@ fn decoded_completion_stream(
usage: Default::default(),
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}
});
@@ -686,6 +687,7 @@ fn decoded_completion_stream(
usage: Default::default(),
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
});
events.push(DecodedTextEvent::TextDelta {
delta: chunk.delta,
diff --git a/rust/src/engine-core-client/src/protocol/output.rs b/rust/src/engine-core-client/src/protocol/output.rs
index 157343c20b80..930fd25694cf 100644
--- a/rust/src/engine-core-client/src/protocol/output.rs
+++ b/rust/src/engine-core-client/src/protocol/output.rs
@@ -97,6 +97,8 @@ pub struct EngineCoreOutput {
#[serde(default)]
pub kv_transfer_params: Option,
#[serde(default)]
+ pub ec_transfer_params: Option,
+ #[serde(default)]
pub trace_headers: Option,
/// Breakdown of the scheduled prefill computation, set on the first output
/// of a newly scheduled prefill and elided for subsequent decode outputs.
@@ -374,6 +376,7 @@ mod tests {
stop_reason: Some(StopReason::Text("stop".to_string())),
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -426,6 +429,7 @@ mod tests {
stop_reason: None,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
diff --git a/rust/src/engine-core-client/src/tests/client.rs b/rust/src/engine-core-client/src/tests/client.rs
index b433e9060357..5f2a724873ce 100644
--- a/rust/src/engine-core-client/src/tests/client.rs
+++ b/rust/src/engine-core-client/src/tests/client.rs
@@ -226,6 +226,7 @@ fn request_output(
stop_reason: None,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -2517,6 +2518,7 @@ fn python_msgpack_fixtures_match_rust_encoding() {
stop_reason: None,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
diff --git a/rust/src/engine-core-client/src/tests/python_compat.py b/rust/src/engine-core-client/src/tests/python_compat.py
index a3f44ea7f068..0005ff20f920 100755
--- a/rust/src/engine-core-client/src/tests/python_compat.py
+++ b/rust/src/engine-core-client/src/tests/python_compat.py
@@ -91,6 +91,7 @@ class EngineCoreOutput(
stop_reason: int | str | None = None
events: object | None = None
kv_transfer_params: object | None = None
+ ec_transfer_params: object | None = None
trace_headers: object | None = None
prefill_stats: object | None = None
routed_experts: object | None = None
diff --git a/rust/src/llm/src/output.rs b/rust/src/llm/src/output.rs
index d1d7e5f46e5c..4e9d885b1a1a 100644
--- a/rust/src/llm/src/output.rs
+++ b/rust/src/llm/src/output.rs
@@ -38,6 +38,9 @@ pub struct CollectedGenerateOutput {
pub usage: TokenUsage,
/// Connector-specific KV transfer parameters for disaggregated serving.
pub kv_transfer_params: Option,
+ /// Connector-specific encoder cache transfer parameters for disaggregated
+ /// serving.
+ pub ec_transfer_params: Option,
}
/// Prompt-scoped metadata emitted only once on the first [`GenerateOutput`] for
@@ -146,6 +149,9 @@ pub struct GenerateOutput {
pub cached_token_count: usize,
/// Connector-specific KV transfer parameters for disaggregated serving.
pub kv_transfer_params: Option,
+ /// Connector-specific encoder cache transfer parameters for disaggregated
+ /// serving.
+ pub ec_transfer_params: Option,
}
impl GenerateOutput {
@@ -192,6 +198,7 @@ impl GenerateOutput {
finish_reason,
cached_token_count: 0,
kv_transfer_params: None,
+ ec_transfer_params: None,
}
}
}
@@ -280,6 +287,7 @@ impl Stream for GenerateOutputStream {
finish_reason,
cached_token_count,
kv_transfer_params: raw.kv_transfer_params,
+ ec_transfer_params: raw.ec_transfer_params,
};
Poll::Ready(Some(Ok(output)))
@@ -362,6 +370,7 @@ impl> + Send> T {
cached_token_count,
},
kv_transfer_params: None,
+ ec_transfer_params: None,
});
}
@@ -374,6 +383,7 @@ impl> + Send> T {
cached_token_count,
};
collected.kv_transfer_params = output.kv_transfer_params;
+ collected.ec_transfer_params = output.ec_transfer_params;
return Ok(collected);
}
}
diff --git a/rust/src/llm/tests/generate.rs b/rust/src/llm/tests/generate.rs
index 8581b1ac08f5..498a4f0a4a72 100644
--- a/rust/src/llm/tests/generate.rs
+++ b/rust/src/llm/tests/generate.rs
@@ -51,6 +51,7 @@ fn request_output_with_events(
stop_reason: None,
events,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -75,6 +76,7 @@ fn request_output_with_logprobs(
stop_reason: None,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -89,6 +91,7 @@ fn request_output_with_logprobs_and_kv(
new_logprobs: Option,
prompt_logprobs: Option,
kv_transfer_params: Option,
+ ec_transfer_params: Option,
) -> EngineCoreOutput {
EngineCoreOutput {
request_id: request_id.to_string(),
@@ -100,6 +103,7 @@ fn request_output_with_logprobs_and_kv(
stop_reason: None,
events: None,
kv_transfer_params,
+ ec_transfer_params,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -356,6 +360,7 @@ async fn collect_output_aggregates_raw_tokens_logprobs_and_terminal_metadata() {
Some(logprobs_for_position(44, -0.3, 1, 88, -0.4)),
None,
Some(serde_json::json!({"connector": "x"})),
+ None,
),
],
..Default::default()
diff --git a/rust/src/server/src/grpc/convert.rs b/rust/src/server/src/grpc/convert.rs
index 4327221221da..5bea5a52e77f 100644
--- a/rust/src/server/src/grpc/convert.rs
+++ b/rust/src/server/src/grpc/convert.rs
@@ -69,6 +69,11 @@ pub fn to_text_request(
let map = sampling_params.vllm_xargs.get_or_insert_with(Default::default);
map.insert("kv_transfer_params".to_string(), kv_json);
}
+ if let Some(ec_struct) = kv.ec_transfer_params.as_ref() {
+ let ec_json = proto_struct_to_json(ec_struct);
+ let map = sampling_params.vllm_xargs.get_or_insert_with(Default::default);
+ map.insert("ec_transfer_params".to_string(), ec_json);
+ }
if kv.bypass_prefix_cache {
sampling_params.skip_reading_prefix_cache = Some(true);
}
@@ -343,6 +348,7 @@ fn to_finish_info(finished: &Finished, token_ids: &[u32]) -> pb::FinishInfo {
finish_reason,
stop_reason,
kv_transfer_params: finished.kv_transfer_params.as_ref().and_then(json_to_proto_struct),
+ ec_transfer_params: finished.ec_transfer_params.as_ref().and_then(json_to_proto_struct),
}
}
@@ -586,6 +592,7 @@ mod tests {
},
finish_reason: reason,
kv_transfer_params: None,
+ ec_transfer_params: None,
}
}
diff --git a/rust/src/server/src/grpc/mod.rs b/rust/src/server/src/grpc/mod.rs
index 1fcb8674fee1..59ce52dfc7f1 100644
--- a/rust/src/server/src/grpc/mod.rs
+++ b/rust/src/server/src/grpc/mod.rs
@@ -71,6 +71,7 @@ impl pb::generate_server::Generate for GenerateServiceImpl {
usage: collected.usage,
finish_reason: collected.finish_reason,
kv_transfer_params: collected.kv_transfer_params,
+ ec_transfer_params: collected.ec_transfer_params,
};
let outputs = convert::to_sequence_output(
diff --git a/rust/src/server/src/grpc/tests.rs b/rust/src/server/src/grpc/tests.rs
index 83c4de440efc..205f44924511 100644
--- a/rust/src/server/src/grpc/tests.rs
+++ b/rust/src/server/src/grpc/tests.rs
@@ -104,6 +104,7 @@ fn request_output(
stop_reason: None,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
diff --git a/rust/src/server/src/routes/http_client_tests.rs b/rust/src/server/src/routes/http_client_tests.rs
index 6d479854a0b7..dd9af6f1d9a5 100644
--- a/rust/src/server/src/routes/http_client_tests.rs
+++ b/rust/src/server/src/routes/http_client_tests.rs
@@ -100,6 +100,7 @@ fn request_output(
stop_reason: None,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
diff --git a/rust/src/server/src/routes/inference/generate.rs b/rust/src/server/src/routes/inference/generate.rs
index c11e4c79ca53..01dca44a2287 100644
--- a/rust/src/server/src/routes/inference/generate.rs
+++ b/rust/src/server/src/routes/inference/generate.rs
@@ -266,6 +266,7 @@ fn collect_generate(
}],
prompt_logprobs,
kv_transfer_params: collected.kv_transfer_params,
+ ec_transfer_params: collected.ec_transfer_params,
})
}
@@ -404,6 +405,7 @@ mod tests {
finish_reason: None,
cached_token_count: 0,
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
Ok(GenerateOutput {
request_id: String::new(),
@@ -416,6 +418,7 @@ mod tests {
finish_reason: Some(FinishReason::stop_eos()),
cached_token_count: 2,
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
diff --git a/rust/src/server/src/routes/inference/generate/convert.rs b/rust/src/server/src/routes/inference/generate/convert.rs
index 965155b5825f..15cf32a2bf5c 100644
--- a/rust/src/server/src/routes/inference/generate/convert.rs
+++ b/rust/src/server/src/routes/inference/generate/convert.rs
@@ -4,7 +4,7 @@ use super::types::GenerateRequest;
use super::validate;
use crate::error::ApiError;
use crate::lora::LoraModelResolution;
-use crate::utils::{ResolvedRequestContext, merge_kv_transfer_params};
+use crate::utils::{ResolvedRequestContext, merge_ec_transfer_params, merge_kv_transfer_params};
/// Lowered generate request plus the response request ID.
#[derive(Debug, Clone, PartialEq)]
@@ -56,6 +56,10 @@ pub(super) fn prepare_generate_request(
sampling_params.vllm_xargs,
request.kv_transfer_params.as_ref(),
);
+ sampling_params.vllm_xargs = merge_ec_transfer_params(
+ sampling_params.vllm_xargs,
+ request.ec_transfer_params.as_ref(),
+ );
let text_request = TextRequest {
request_id: ctx.request_id.clone(),
diff --git a/rust/src/server/src/routes/inference/generate/types.rs b/rust/src/server/src/routes/inference/generate/types.rs
index 28855968df05..269338ee2bdf 100644
--- a/rust/src/server/src/routes/inference/generate/types.rs
+++ b/rust/src/server/src/routes/inference/generate/types.rs
@@ -22,6 +22,7 @@ pub struct GenerateRequest {
#[serde(default)]
pub priority: i32,
pub kv_transfer_params: Option>,
+ pub ec_transfer_params: Option>,
#[serde(flatten)]
pub other: Map,
}
@@ -66,6 +67,7 @@ pub(super) struct GenerateResponse {
pub choices: Vec,
pub prompt_logprobs: Option>>>,
pub kv_transfer_params: Option,
+ pub ec_transfer_params: Option,
}
/// Mirrors the Python vLLM `Logprob` class used in prompt-logprobs payloads.
diff --git a/rust/src/server/src/routes/openai/chat_completions.rs b/rust/src/server/src/routes/openai/chat_completions.rs
index f0368c5614a7..56ef500c28df 100644
--- a/rust/src/server/src/routes/openai/chat_completions.rs
+++ b/rust/src/server/src/routes/openai/chat_completions.rs
@@ -144,6 +144,7 @@ async fn collect_chat_completion(
usage,
finish_reason,
kv_transfer_params,
+ ec_transfer_params,
} = collected;
let stop_reason = finish_reason.as_stop_reason().map(stop_reason_to_json);
let saw_tool_calls = message.tool_calls().next().is_some();
@@ -224,6 +225,7 @@ async fn collect_chat_completion(
prompt_logprobs,
prompt_token_ids: return_token_ids.then(|| prompt_token_ids.to_vec()),
kv_transfer_params,
+ ec_transfer_params,
})
}
@@ -951,6 +953,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -1031,6 +1034,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -1086,6 +1090,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -1167,6 +1172,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -1300,6 +1306,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
@@ -1381,6 +1388,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
]);
diff --git a/rust/src/server/src/routes/openai/chat_completions/convert.rs b/rust/src/server/src/routes/openai/chat_completions/convert.rs
index d8326b3575e5..8cf5a5dc77c4 100644
--- a/rust/src/server/src/routes/openai/chat_completions/convert.rs
+++ b/rust/src/server/src/routes/openai/chat_completions/convert.rs
@@ -13,7 +13,9 @@ use crate::routes::openai::utils::structured_outputs::convert_from_response_form
use crate::routes::openai::utils::types::{
ChatMessage, ContentPart, MessageContent, Tool, ToolChoice, ToolChoiceValue,
};
-use crate::utils::{ResolvedRequestContext, convert_logit_bias, merge_kv_transfer_params};
+use crate::utils::{
+ ResolvedRequestContext, convert_logit_bias, merge_ec_transfer_params, merge_kv_transfer_params,
+};
/// Lowered chat request plus the public response metadata carried by every SSE
/// chunk.
@@ -132,7 +134,7 @@ pub(super) fn prepare_chat_request(
structured_outputs,
skip_reading_prefix_cache: None,
vllm_xargs: merge_kv_transfer_params(
- request.vllm_xargs,
+ merge_ec_transfer_params(request.vllm_xargs, request.ec_transfer_params.as_ref()),
request.kv_transfer_params.as_ref(),
),
},
diff --git a/rust/src/server/src/routes/openai/chat_completions/types.rs b/rust/src/server/src/routes/openai/chat_completions/types.rs
index f3a4dd241318..be3be36e5ed0 100644
--- a/rust/src/server/src/routes/openai/chat_completions/types.rs
+++ b/rust/src/server/src/routes/openai/chat_completions/types.rs
@@ -232,6 +232,9 @@ pub struct ChatCompletionRequest {
/// KV transfer parameters for disaggregated serving
pub kv_transfer_params: Option>,
+ /// Encoder cache transfer parameters for disaggregated serving
+ pub ec_transfer_params: Option>,
+
/// Additional request parameters with string or numeric values for custom
/// extensions
pub vllm_xargs: Option>,
@@ -299,6 +302,7 @@ impl Default for ChatCompletionRequest {
return_token_ids: None,
cache_salt: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
vllm_xargs: None,
repetition_detection: None,
}
@@ -346,6 +350,7 @@ pub(super) struct ChatCompletionResponse {
pub prompt_logprobs: Option>>>,
pub prompt_token_ids: Option>,
pub kv_transfer_params: Option,
+ pub ec_transfer_params: Option,
}
/// Mirrors the Python vLLM `ChatCompletionResponseChoice` class.
diff --git a/rust/src/server/src/routes/openai/completions.rs b/rust/src/server/src/routes/openai/completions.rs
index 0dd0eadf33d3..16ceb6c169c2 100644
--- a/rust/src/server/src/routes/openai/completions.rs
+++ b/rust/src/server/src/routes/openai/completions.rs
@@ -212,6 +212,7 @@ async fn collect_completion(
usage: Some(usage),
system_fingerprint: None,
kv_transfer_params: collected.kv_transfer_params,
+ ec_transfer_params: collected.ec_transfer_params,
})
}
@@ -682,6 +683,7 @@ mod tests {
"repetition_detected".to_string(),
))),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}),
]);
@@ -786,6 +788,7 @@ mod tests {
},
finish_reason: FinishReason::Length,
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}),
]);
@@ -837,6 +840,7 @@ mod tests {
},
finish_reason: FinishReason::Length,
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}),
]);
@@ -891,6 +895,7 @@ mod tests {
},
finish_reason: FinishReason::Length,
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}),
]);
@@ -962,6 +967,7 @@ mod tests {
},
finish_reason: FinishReason::Length,
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}),
]);
@@ -1035,6 +1041,7 @@ mod tests {
},
finish_reason: FinishReason::Length,
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}),
]);
diff --git a/rust/src/server/src/routes/openai/completions/convert.rs b/rust/src/server/src/routes/openai/completions/convert.rs
index 1355481b49b7..80ae6b06b162 100644
--- a/rust/src/server/src/routes/openai/completions/convert.rs
+++ b/rust/src/server/src/routes/openai/completions/convert.rs
@@ -7,7 +7,9 @@ use crate::error::ApiError;
use crate::lora::LoraModelResolution;
use crate::routes::openai::completions::validate;
use crate::routes::openai::utils::structured_outputs::convert_from_response_format_value;
-use crate::utils::{ResolvedRequestContext, convert_logit_bias, merge_kv_transfer_params};
+use crate::utils::{
+ ResolvedRequestContext, convert_logit_bias, merge_ec_transfer_params, merge_kv_transfer_params,
+};
/// Lowered completion request plus the public response metadata carried by
/// every SSE chunk.
@@ -127,7 +129,7 @@ pub(super) fn prepare_completion_request(
structured_outputs,
skip_reading_prefix_cache: None,
vllm_xargs: merge_kv_transfer_params(
- request.vllm_xargs,
+ merge_ec_transfer_params(request.vllm_xargs, request.ec_transfer_params.as_ref()),
request.kv_transfer_params.as_ref(),
),
},
diff --git a/rust/src/server/src/routes/openai/completions/types.rs b/rust/src/server/src/routes/openai/completions/types.rs
index 32542b8b3513..ce13ca32b9d8 100644
--- a/rust/src/server/src/routes/openai/completions/types.rs
+++ b/rust/src/server/src/routes/openai/completions/types.rs
@@ -174,6 +174,9 @@ pub struct CompletionRequest {
/// KV transfer parameters for disaggregated serving
pub kv_transfer_params: Option>,
+ /// Encoder cache transfer parameters for disaggregated serving
+ pub ec_transfer_params: Option>,
+
/// Additional request parameters with string or numeric values for custom
/// extensions
pub vllm_xargs: Option>,
@@ -209,6 +212,7 @@ pub(super) struct CompletionResponse {
pub usage: Option,
pub system_fingerprint: Option,
pub kv_transfer_params: Option,
+ pub ec_transfer_params: Option,
}
/// Mirrors the Python vLLM `CompletionResponseChoice` class.
diff --git a/rust/src/server/src/routes/tests.rs b/rust/src/server/src/routes/tests.rs
index 117b7d968d8e..f36da7a360da 100644
--- a/rust/src/server/src/routes/tests.rs
+++ b/rust/src/server/src/routes/tests.rs
@@ -76,6 +76,7 @@ fn request_output_with_stop_reason(
stop_reason,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -101,6 +102,7 @@ fn request_output_with_logprobs(
stop_reason,
events: None,
kv_transfer_params: None,
+ ec_transfer_params: None,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -116,6 +118,7 @@ fn request_output_with_logprobs_and_kv(
new_logprobs: Option,
new_prompt_logprobs_tensors: Option,
kv_transfer_params: Option,
+ ec_transfer_params: Option,
) -> EngineCoreOutput {
EngineCoreOutput {
request_id: request_id.to_string(),
@@ -127,6 +130,7 @@ fn request_output_with_logprobs_and_kv(
stop_reason,
events: None,
kv_transfer_params,
+ ec_transfer_params,
trace_headers: None,
prefill_stats: None,
routed_experts: None,
@@ -3634,6 +3638,7 @@ async fn non_stream_raw_generate_returns_token_output_envelope() {
Some(sample_logprobs_for_token(44, 45)),
None,
Some(json!({"connector": "x"})),
+ None,
),
],
..Default::default()
diff --git a/rust/src/server/src/utils.rs b/rust/src/server/src/utils.rs
index 13fa0dfaeecb..6e0b572b9333 100644
--- a/rust/src/server/src/utils.rs
+++ b/rust/src/server/src/utils.rs
@@ -45,6 +45,24 @@ pub fn merge_kv_transfer_params(
xargs
}
+/// Merge `ec_transfer_params` into the `vllm_xargs` map, mirroring the Python
+/// vLLM behavior where `ec_transfer_params` is injected into `extra_args` for
+/// engine-core consumption.
+pub fn merge_ec_transfer_params(
+ mut xargs: Option>,
+ ec_transfer_params: Option<&HashMap>,
+) -> Option> {
+ if let Some(ec_params) = ec_transfer_params {
+ let map = xargs.get_or_insert_with(HashMap::new);
+ map.insert(
+ "ec_transfer_params".to_string(),
+ // This is safe because we know that `ec_params` is already valid JSON.
+ serde_json::to_value(ec_params).unwrap(),
+ );
+ }
+ xargs
+}
+
/// Convert OpenAI-style `logit_bias` with string token-ID keys into the
/// internal `HashMap` representation, validating that every key
/// parses as a `u32`.
diff --git a/rust/src/text/src/output/decoded.rs b/rust/src/text/src/output/decoded.rs
index a9444e459c4c..6f95a7c3fff4 100644
--- a/rust/src/text/src/output/decoded.rs
+++ b/rust/src/text/src/output/decoded.rs
@@ -44,6 +44,9 @@ pub struct Finished {
pub finish_reason: FinishReason,
/// Connector-specific KV transfer parameters for disaggregated serving.
pub kv_transfer_params: Option,
+ /// Connector-specific encoder cache transfer parameters for disaggregated
+ /// serving.
+ pub ec_transfer_params: Option,
}
/// Internal decoded-text event emitted before higher-level assistant
@@ -151,6 +154,7 @@ pub async fn decoded_text_event_stream(
let decoder = decoder.as_mut().unwrap();
let kv_transfer_params = output.kv_transfer_params;
+ let ec_transfer_params = output.ec_transfer_params;
let mut finish_reason = output.finish_reason;
let mut stop_str_matched = false;
let suppress_terminal_stop_token = finish_reason.as_ref().is_some_and(|r| r.is_stop())
@@ -275,6 +279,7 @@ pub async fn decoded_text_event_stream(
},
finish_reason: reason,
kv_transfer_params,
+ ec_transfer_params,
}),
})
.await;
diff --git a/rust/src/text/src/output/mod.rs b/rust/src/text/src/output/mod.rs
index f64d1689f380..2441650a6393 100644
--- a/rust/src/text/src/output/mod.rs
+++ b/rust/src/text/src/output/mod.rs
@@ -26,6 +26,9 @@ pub struct CollectedTextOutput {
pub usage: vllm_llm::TokenUsage,
/// Connector-specific KV transfer parameters for disaggregated serving.
pub kv_transfer_params: Option,
+ /// Connector-specific encoder cache transfer parameters for disaggregated
+ /// serving.
+ pub ec_transfer_params: Option,
}
#[allow(clippy::manual_async_fn, reason = "specify `Send` bound")]
@@ -77,6 +80,7 @@ impl T {
finish_reason: FinishReason::Error,
usage: vllm_llm::TokenUsage::default(),
kv_transfer_params: None,
+ ec_transfer_params: None,
})
};
@@ -85,6 +89,7 @@ impl T {
collected.finish_reason = finished.finish_reason;
collected.usage = finished.usage;
collected.kv_transfer_params = finished.kv_transfer_params;
+ collected.ec_transfer_params = finished.ec_transfer_params;
return Ok(collected);
}
}
@@ -156,6 +161,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}),
]);
@@ -273,6 +279,7 @@ mod tests {
},
finish_reason: FinishReason::stop_eos(),
kv_transfer_params: None,
+ ec_transfer_params: None,
}),
}),
]);
diff --git a/tests/v1/core/test_async_scheduler.py b/tests/v1/core/test_async_scheduler.py
index 9b6f64589614..3997b85f2d1a 100644
--- a/tests/v1/core/test_async_scheduler.py
+++ b/tests/v1/core/test_async_scheduler.py
@@ -294,7 +294,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
def free_request(req, delay_free_blocks=False):
scheduler.finished_req_ids.add(req.request_id)
scheduler.requests.pop(req.request_id, None)
- return None
+ return None, None
scheduler._free_request = Mock(side_effect=free_request)
diff --git a/tests/v1/core/test_scheduler.py b/tests/v1/core/test_scheduler.py
index 900f8a9b06af..157170400b0d 100644
--- a/tests/v1/core/test_scheduler.py
+++ b/tests/v1/core/test_scheduler.py
@@ -2968,7 +2968,7 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
def free_request(req: Request, delay_free_blocks: bool = False):
scheduler.finished_req_ids.add(req.request_id)
scheduler.requests.pop(req.request_id, None)
- return None
+ return None, None
scheduler._free_request = Mock(side_effect=free_request)
diff --git a/tests/v1/ec_connector/unit/test_ec_transfer_params.py b/tests/v1/ec_connector/unit/test_ec_transfer_params.py
new file mode 100644
index 000000000000..f161ba203ceb
--- /dev/null
+++ b/tests/v1/ec_connector/unit/test_ec_transfer_params.py
@@ -0,0 +1,118 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""Sanity tests for ec_transfer_params protocol plumbing.
+
+No running engine required.
+"""
+
+from unittest.mock import MagicMock
+
+from tests.v1.core.utils import create_scheduler
+from vllm.entrypoints.openai.chat_completion.protocol import (
+ ChatCompletionRequest,
+)
+from vllm.outputs import CompletionOutput, RequestOutput
+from vllm.sampling_params import SamplingParams
+from vllm.v1.request import Request, RequestStatus
+
+EC_PARAMS: dict = {"mm_hash_abc": {"peer_host": "10.0.0.1", "peer_port": 5501}}
+
+
+def test_ec_transfer_params_routed_to_sampling_params_extra_args():
+ """ec_transfer_params on the request must land in SamplingParams.extra_args."""
+ req = ChatCompletionRequest(
+ model="test-model",
+ messages=[{"role": "user", "content": "hi"}],
+ max_tokens=5,
+ ec_transfer_params=EC_PARAMS,
+ )
+ sp = req.to_sampling_params(max_tokens=5, default_sampling_params={})
+ assert sp.extra_args is not None
+ assert sp.extra_args.get("ec_transfer_params") == EC_PARAMS
+
+
+def test_request_output_add_propagates_ec_transfer_params():
+ """RequestOutput.add() must carry ec_transfer_params forward to the caller."""
+
+ def _out(ec_params):
+ return RequestOutput(
+ request_id="r1",
+ prompt="p",
+ prompt_token_ids=[1],
+ prompt_logprobs=None,
+ outputs=[
+ CompletionOutput(
+ index=0,
+ text="",
+ token_ids=[],
+ cumulative_logprob=0.0,
+ logprobs=None,
+ finish_reason=None,
+ )
+ ],
+ finished=False,
+ ec_transfer_params=ec_params,
+ )
+
+ accumulated = _out(None)
+ accumulated.add(_out(EC_PARAMS), aggregate=True)
+ assert accumulated.ec_transfer_params == EC_PARAMS
+
+
+def test_request_reads_ec_transfer_params_from_extra_args():
+ """v1 Request must pull ec_transfer_params out of SamplingParams.extra_args."""
+ sp = SamplingParams(extra_args={"ec_transfer_params": EC_PARAMS})
+ req = Request(
+ request_id="r1",
+ prompt_token_ids=[1, 2, 3],
+ sampling_params=sp,
+ pooling_params=None,
+ )
+ assert req.ec_transfer_params == EC_PARAMS
+
+
+def test_free_request_calls_ec_connector_and_surfaces_params():
+ """_free_request must call ec_connector.request_finished() and return its params."""
+ sp = SamplingParams(max_tokens=1)
+ sp.update_from_generation_config({}, 50256)
+ request = Request(
+ request_id="test-req",
+ prompt_token_ids=[1, 2, 3],
+ sampling_params=sp,
+ pooling_params=None,
+ client_index=0,
+ )
+ scheduler = create_scheduler(use_ec_connector=True, ec_role="ec_producer")
+ scheduler.add_request(request)
+ request.status = RequestStatus.FINISHED_STOPPED
+
+ mock_ec = MagicMock()
+ mock_ec.request_finished.return_value = (False, EC_PARAMS)
+ scheduler.ec_connector = mock_ec
+
+ kv_params, ec_params = scheduler._free_request(request)
+
+ mock_ec.request_finished.assert_called_once_with(request)
+ assert ec_params == EC_PARAMS
+ assert kv_params is None
+
+
+def test_free_request_without_ec_connector_returns_none():
+ """When no EC connector is configured, ec_transfer_params must be None."""
+ sp = SamplingParams(max_tokens=1)
+ sp.update_from_generation_config({}, 50256)
+ request = Request(
+ request_id="test-req",
+ prompt_token_ids=[1, 2, 3],
+ sampling_params=sp,
+ pooling_params=None,
+ client_index=0,
+ )
+ scheduler = create_scheduler(use_ec_connector=True, ec_role="ec_producer")
+ scheduler.add_request(request)
+ request.status = RequestStatus.FINISHED_STOPPED
+
+ kv_params, ec_params = scheduler._free_request(request)
+
+ assert ec_params is None
+ assert kv_params is None
diff --git a/vllm/entrypoints/anthropic/protocol.py b/vllm/entrypoints/anthropic/protocol.py
index ae0dd08660d4..a470ab654094 100644
--- a/vllm/entrypoints/anthropic/protocol.py
+++ b/vllm/entrypoints/anthropic/protocol.py
@@ -137,6 +137,12 @@ class AnthropicMessagesRequest(BaseModel):
default=None,
description="KVTransfer parameters used for disaggregated serving.",
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None,
+ description=(
+ "ECTransfer parameters used for encoder-cache disaggregated serving."
+ ),
+ )
chat_template_kwargs: dict[str, Any] | None = Field(
default=None,
description=(
@@ -218,6 +224,9 @@ class AnthropicMessagesResponse(BaseModel):
kv_transfer_params: dict[str, Any] | None = Field(
default=None, description="KVTransfer parameters."
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None, description="ECTransfer parameters."
+ )
def model_post_init(self, __context):
if not self.id:
diff --git a/vllm/entrypoints/anthropic/serving.py b/vllm/entrypoints/anthropic/serving.py
index 2cb4832d4717..d61a917780d9 100644
--- a/vllm/entrypoints/anthropic/serving.py
+++ b/vllm/entrypoints/anthropic/serving.py
@@ -491,6 +491,7 @@ def _build_base_request(
top_p=anthropic_request.top_p,
top_k=anthropic_request.top_k,
kv_transfer_params=anthropic_request.kv_transfer_params,
+ ec_transfer_params=anthropic_request.ec_transfer_params,
chat_template_kwargs=anthropic_request.chat_template_kwargs,
)
@@ -630,6 +631,7 @@ def messages_full_converter(
generator.usage,
),
kv_transfer_params=generator.kv_transfer_params,
+ ec_transfer_params=generator.ec_transfer_params,
)
choice = generator.choices[0]
if choice.finish_reason == "stop":
diff --git a/vllm/entrypoints/openai/chat_completion/protocol.py b/vllm/entrypoints/openai/chat_completion/protocol.py
index cce51157f845..3cbbe1fcac91 100644
--- a/vllm/entrypoints/openai/chat_completion/protocol.py
+++ b/vllm/entrypoints/openai/chat_completion/protocol.py
@@ -134,6 +134,9 @@ class ChatCompletionResponse(OpenAIBaseModel):
kv_transfer_params: dict[str, Any] | None = Field(
default=None, description="KVTransfer parameters."
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None, description="ECTransfer parameters."
+ )
metrics: PerRequestTimingMetrics | None = None
@@ -439,6 +442,13 @@ class ChatCompletionRequest(OpenAIBaseModel):
description="KVTransfer parameters used for disaggregated serving.",
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None,
+ description=(
+ "ECTransfer parameters used for encoder-cache disaggregated serving."
+ ),
+ )
+
vllm_xargs: dict[str, str | int | float | list[str | int | float]] | None = Field(
default=None,
description=(
@@ -665,6 +675,9 @@ def to_sampling_params(
if self.kv_transfer_params:
# Pass in kv_transfer_params via extra_args
extra_args["kv_transfer_params"] = self.kv_transfer_params
+ if self.ec_transfer_params:
+ # Pass in ec_transfer_params via extra_args
+ extra_args["ec_transfer_params"] = self.ec_transfer_params
return SamplingParams.from_optional(
n=self.n,
presence_penalty=self.presence_penalty,
diff --git a/vllm/entrypoints/openai/chat_completion/serving.py b/vllm/entrypoints/openai/chat_completion/serving.py
index eddcf014afee..1e9f58cc2f58 100644
--- a/vllm/entrypoints/openai/chat_completion/serving.py
+++ b/vllm/entrypoints/openai/chat_completion/serving.py
@@ -1057,6 +1057,7 @@ async def chat_completion_full_generator(
),
prompt_text=prompt_text,
kv_transfer_params=final_res.kv_transfer_params,
+ ec_transfer_params=final_res.ec_transfer_params,
metrics=per_request_metrics,
)
diff --git a/vllm/entrypoints/openai/completion/protocol.py b/vllm/entrypoints/openai/completion/protocol.py
index a7b7996fed79..11ff63e8aece 100644
--- a/vllm/entrypoints/openai/completion/protocol.py
+++ b/vllm/entrypoints/openai/completion/protocol.py
@@ -189,6 +189,13 @@ class CompletionRequest(OpenAIBaseModel):
description="KVTransfer parameters used for disaggregated serving.",
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None,
+ description=(
+ "ECTransfer parameters used for encoder-cache disaggregated serving."
+ ),
+ )
+
vllm_xargs: dict[str, str | int | float] | None = Field(
default=None,
description=(
@@ -346,6 +353,9 @@ def to_sampling_params(
if self.kv_transfer_params:
# Pass in kv_transfer_params via extra_args
extra_args["kv_transfer_params"] = self.kv_transfer_params
+ if self.ec_transfer_params:
+ # Pass in ec_transfer_params via extra_args
+ extra_args["ec_transfer_params"] = self.ec_transfer_params
return SamplingParams.from_optional(
n=self.n,
presence_penalty=self.presence_penalty,
@@ -595,6 +605,9 @@ class CompletionResponse(OpenAIBaseModel):
kv_transfer_params: dict[str, Any] | None = Field(
default=None, description="KVTransfer parameters."
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None, description="ECTransfer parameters."
+ )
metrics: PerRequestTimingMetrics | None = None
diff --git a/vllm/entrypoints/openai/completion/serving.py b/vllm/entrypoints/openai/completion/serving.py
index d26a455cc8e7..545ed965fee2 100644
--- a/vllm/entrypoints/openai/completion/serving.py
+++ b/vllm/entrypoints/openai/completion/serving.py
@@ -510,6 +510,7 @@ def request_output_to_completion_response(
num_prompt_tokens = 0
num_generated_tokens = 0
kv_transfer_params = None
+ ec_transfer_params = None
last_final_res = None
for final_res in final_res_batch:
last_final_res = final_res
@@ -632,6 +633,8 @@ def request_output_to_completion_response(
if final_res_batch:
kv_transfer_params = final_res_batch[0].kv_transfer_params
+ ec_transfer_params = final_res_batch[0].ec_transfer_params
+
return CompletionResponse(
id=request_id,
created=created_time,
@@ -640,6 +643,7 @@ def request_output_to_completion_response(
usage=usage,
system_fingerprint=self.system_fingerprint,
kv_transfer_params=kv_transfer_params,
+ ec_transfer_params=ec_transfer_params,
metrics=per_request_metrics,
)
diff --git a/vllm/entrypoints/openai/responses/context.py b/vllm/entrypoints/openai/responses/context.py
index e1d3f4cf7ef4..9bc3161f88d1 100644
--- a/vllm/entrypoints/openai/responses/context.py
+++ b/vllm/entrypoints/openai/responses/context.py
@@ -200,6 +200,7 @@ def __init__(
self.input_messages: list[ResponseRawMessageAndToken] = []
self.kv_transfer_params: dict[str, Any] | None = None
+ self.ec_transfer_params: dict[str, Any] | None = None
def append_output(self, output) -> None:
self.last_output = output
@@ -210,6 +211,8 @@ def append_output(self, output) -> None:
self.num_output_tokens += len(output.outputs[0].token_ids or [])
if output.kv_transfer_params is not None:
self.kv_transfer_params = output.kv_transfer_params
+ if output.ec_transfer_params is not None:
+ self.ec_transfer_params = output.ec_transfer_params
# Accumulate text, token_ids, and logprobs for streaming mode
delta_output = output.outputs[0]
@@ -328,6 +331,7 @@ def __init__(
self.output_messages: list[ResponseRawMessageAndToken] = []
self._accumulated_token_ids: list[int] = []
self.kv_transfer_params: dict[str, Any] | None = None
+ self.ec_transfer_params: dict[str, Any] | None = None
def append_output(self, output: RequestOutput) -> None:
self.num_prompt_tokens = len(output.prompt_token_ids or [])
@@ -336,6 +340,9 @@ def append_output(self, output: RequestOutput) -> None:
if output.kv_transfer_params is not None:
self.kv_transfer_params = output.kv_transfer_params
+ if output.ec_transfer_params is not None:
+ self.ec_transfer_params = output.ec_transfer_params
+
completion = output.outputs[0]
self.finish_reason = completion.finish_reason
@@ -630,6 +637,7 @@ def __init__(
self.is_first_turn = True
self.first_tok_of_message = True
self.kv_transfer_params: dict[str, Any] | None = None
+ self.ec_transfer_params: dict[str, Any] | None = None
def append_output(self, output: RequestOutput) -> None:
if self.first_tok_of_message:
@@ -645,6 +653,8 @@ def append_output(self, output: RequestOutput) -> None:
self._update_decode_token_usage(output)
if output.kv_transfer_params is not None:
self.kv_transfer_params = output.kv_transfer_params
+ if output.ec_transfer_params is not None:
+ self.ec_transfer_params = output.ec_transfer_params
if output.finished:
self.finish_reason = output.outputs[0].finish_reason
diff --git a/vllm/entrypoints/openai/responses/protocol.py b/vllm/entrypoints/openai/responses/protocol.py
index 423068c71ca8..fabc79677c2d 100644
--- a/vllm/entrypoints/openai/responses/protocol.py
+++ b/vllm/entrypoints/openai/responses/protocol.py
@@ -285,6 +285,12 @@ class ResponsesRequest(OpenAIBaseModel):
default=None,
description="KVTransfer parameters used for disaggregated serving.",
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None,
+ description=(
+ "ECTransfer parameters used for encoder-cache disaggregated serving."
+ ),
+ )
chat_template_kwargs: dict[str, Any] | None = Field(
default=None,
description=(
@@ -409,6 +415,8 @@ def to_sampling_params(
extra_args: dict[str, Any] = self.vllm_xargs if self.vllm_xargs else {}
if self.kv_transfer_params:
extra_args["kv_transfer_params"] = self.kv_transfer_params
+ if self.ec_transfer_params:
+ extra_args["ec_transfer_params"] = self.ec_transfer_params
return SamplingParams.from_optional(
temperature=temperature,
@@ -675,6 +683,9 @@ class ResponsesResponse(OpenAIBaseModel):
kv_transfer_params: dict[str, Any] | None = Field(
default=None, description="KVTransfer parameters."
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None, description="ECTransfer parameters."
+ )
# --8<-- [start:responses-response-extra-params]
# These are populated when enable_response_messages is set to True
@@ -720,6 +731,7 @@ def from_request(
input_messages: ResponseInputOutputMessage | None = None,
output_messages: ResponseInputOutputMessage | None = None,
kv_transfer_params: dict[str, Any] | None = None,
+ ec_transfer_params: dict[str, Any] | None = None,
) -> "ResponsesResponse":
incomplete_details: IncompleteDetails | None = None
if status == "incomplete":
@@ -758,6 +770,7 @@ def from_request(
user=request.user,
usage=usage,
kv_transfer_params=kv_transfer_params,
+ ec_transfer_params=ec_transfer_params,
)
diff --git a/vllm/entrypoints/openai/responses/serving.py b/vllm/entrypoints/openai/responses/serving.py
index 3926284d701f..8590afe07fbd 100644
--- a/vllm/entrypoints/openai/responses/serving.py
+++ b/vllm/entrypoints/openai/responses/serving.py
@@ -932,6 +932,7 @@ async def responses_full_generator(
status=status,
usage=usage,
kv_transfer_params=context.kv_transfer_params,
+ ec_transfer_params=context.ec_transfer_params,
)
if request.store:
diff --git a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py
index 233ebf070c52..11308d67c5ec 100644
--- a/vllm/entrypoints/scale_out/token_in_token_out/protocol.py
+++ b/vllm/entrypoints/scale_out/token_in_token_out/protocol.py
@@ -132,6 +132,12 @@ def validate_token_ids(cls, v: list[int]) -> list[int]:
default=None,
description="KVTransfer parameters used for disaggregated serving.",
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None,
+ description=(
+ "ECTransfer parameters used for encoder-cache disaggregated serving."
+ ),
+ )
# Tracks which keys the caller explicitly set inside ``sampling_params``
# when the request was parsed from a JSON body. Lets the server tell
@@ -238,6 +244,12 @@ class GenerateResponse(BaseModel):
default=None,
description="KVTransfer parameters used for disaggregated serving.",
)
+ ec_transfer_params: dict[str, Any] | None = Field(
+ default=None,
+ description=(
+ "ECTransfer parameters used for encoder-cache disaggregated serving."
+ ),
+ )
####### Derender (postprocessing) #######
diff --git a/vllm/entrypoints/scale_out/token_in_token_out/serving.py b/vllm/entrypoints/scale_out/token_in_token_out/serving.py
index ef331a1bf93d..34e9eaeb12d7 100644
--- a/vllm/entrypoints/scale_out/token_in_token_out/serving.py
+++ b/vllm/entrypoints/scale_out/token_in_token_out/serving.py
@@ -330,6 +330,7 @@ async def serve_tokens_full_generator(
usage=usage,
prompt_logprobs=clamp_prompt_logprobs(final_res.prompt_logprobs),
kv_transfer_params=final_res.kv_transfer_params,
+ ec_transfer_params=final_res.ec_transfer_params,
)
# Log complete response if output logging is enabled
diff --git a/vllm/envs.py b/vllm/envs.py
index 33b9f2f14fd8..611752a11b09 100755
--- a/vllm/envs.py
+++ b/vllm/envs.py
@@ -207,6 +207,8 @@
VLLM_DISABLE_REQUEST_ID_RANDOMIZATION: bool = False
VLLM_NIXL_SIDE_CHANNEL_HOST: str = "localhost"
VLLM_NIXL_SIDE_CHANNEL_PORT: int = 5600
+ VLLM_EC_SIDE_CHANNEL_HOST: str = "localhost"
+ VLLM_EC_SIDE_CHANNEL_PORT: int = 5601
VLLM_MOONCAKE_BOOTSTRAP_PORT: int = 8998
VLLM_MOONCAKE_STORE_TIER_LOG: bool = False
VLLM_MOONCAKE_LOAD_RECV_THREADS: int = 1
@@ -1565,6 +1567,16 @@ def _resolve_rust_frontend_path() -> str | None:
"VLLM_NIXL_SIDE_CHANNEL_PORT": lambda: int(
os.getenv("VLLM_NIXL_SIDE_CHANNEL_PORT", "5600")
),
+ # IP address used for the EC connector's ZMQ side channel
+ # (producer ROUTER bind, consumer DEALER dial).
+ "VLLM_EC_SIDE_CHANNEL_HOST": lambda: os.getenv(
+ "VLLM_EC_SIDE_CHANNEL_HOST", "localhost"
+ ),
+ # Port for the EC connector's ZMQ side channel; advertised to peers
+ # via `ec_transfer_params.peer_port` on the producer's response.
+ "VLLM_EC_SIDE_CHANNEL_PORT": lambda: int(
+ os.getenv("VLLM_EC_SIDE_CHANNEL_PORT", "5601")
+ ),
# Port used for Mooncake handshake between remote agents.
"VLLM_MOONCAKE_BOOTSTRAP_PORT": lambda: int(
os.getenv("VLLM_MOONCAKE_BOOTSTRAP_PORT", "8998")
diff --git a/vllm/outputs.py b/vllm/outputs.py
index 2c71d2afb1b5..5a0f0dec8051 100644
--- a/vllm/outputs.py
+++ b/vllm/outputs.py
@@ -104,6 +104,7 @@ class RequestOutput:
None if decoder-only.
num_cached_tokens: The number of tokens with prefix cache hit.
kv_transfer_params: The params for remote K/V transfer.
+ ec_transfer_params: The params for remote encoder-cache transfer.
"""
def __init__(
@@ -121,6 +122,7 @@ def __init__(
num_cached_tokens: int | None = None,
*,
kv_transfer_params: dict[str, Any] | None = None,
+ ec_transfer_params: dict[str, Any] | None = None,
# Forward compatibility, code that uses args added in new release can
# still run with older versions of vLLM without breaking.
**kwargs: Any,
@@ -141,12 +143,14 @@ def __init__(
self.encoder_prompt_token_ids = encoder_prompt_token_ids
self.num_cached_tokens = num_cached_tokens
self.kv_transfer_params = kv_transfer_params
+ self.ec_transfer_params = ec_transfer_params
def add(self, next_output: "RequestOutput", aggregate: bool) -> None:
"""Merge subsequent RequestOutput into this one"""
self.finished |= next_output.finished
self.kv_transfer_params = next_output.kv_transfer_params
+ self.ec_transfer_params = next_output.ec_transfer_params
for next_completion in next_output.outputs:
for i, completion in enumerate(self.outputs):
diff --git a/vllm/v1/core/sched/interface.py b/vllm/v1/core/sched/interface.py
index bc65250f991f..98866154c93b 100644
--- a/vllm/v1/core/sched/interface.py
+++ b/vllm/v1/core/sched/interface.py
@@ -9,6 +9,7 @@
if TYPE_CHECKING:
from vllm.config import VllmConfig
+ from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorBase
from vllm.distributed.kv_transfer.kv_connector.v1 import KVConnectorBase_V1
from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput
from vllm.v1.engine import EngineCoreOutputs
@@ -248,3 +249,6 @@ def shutdown(self) -> None:
def get_kv_connector(self) -> "KVConnectorBase_V1 | None":
return None
+
+ def get_ec_connector(self) -> "ECConnectorBase | None":
+ return None
diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py
index 47d3a53d20af..be2918f4501c 100644
--- a/vllm/v1/core/sched/scheduler.py
+++ b/vllm/v1/core/sched/scheduler.py
@@ -10,6 +10,7 @@
from vllm.compilation.cuda_graph import CUDAGraphStat
from vllm.config import VllmConfig
from vllm.distributed.ec_transfer.ec_connector.base import (
+ ECConnectorBase,
ECConnectorMetadata,
ECConnectorRole,
)
@@ -1675,6 +1676,7 @@ def update_from_output(
new_token_ids = generated_token_ids
pooler_output = pooler_outputs[req_index] if pooler_outputs else None
kv_transfer_params = None
+ ec_transfer_params = None
status_before_stop = request.status
num_output_tokens_before = len(request._output_token_ids)
@@ -1761,7 +1763,7 @@ def update_from_output(
finish_reason = request.get_finished_reason()
finished = self._handle_stopped_request(request)
if finished:
- kv_transfer_params = self._free_request(request)
+ kv_transfer_params, ec_transfer_params = self._free_request(request)
if status_before_stop == RequestStatus.RUNNING:
stopped_running_reqs.add(request)
@@ -1785,6 +1787,7 @@ def update_from_output(
new_token_ids
or pooler_output is not None
or kv_transfer_params
+ or ec_transfer_params
or stopped
):
# Add EngineCoreOutput for this Request.
@@ -1800,6 +1803,7 @@ def update_from_output(
events=request.take_events(),
prefill_stats=request.take_prefill_stats(),
kv_transfer_params=kv_transfer_params,
+ ec_transfer_params=ec_transfer_params,
trace_headers=request.trace_headers,
routed_experts=routed_experts,
num_nans_in_logits=request.num_nans_in_logits,
@@ -2151,11 +2155,21 @@ def finish_requests(
def _free_request(
self, request: Request, delay_free_blocks: bool = False
- ) -> dict[str, Any] | None:
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
assert request.is_finished()
self._inflight_prefills.discard(request)
connector_delay_free_blocks, kv_xfer_params = self._connector_finished(request)
+
+ # EC Connector: mirror the KV hook. The contract requires firing
+ # before the encoder cache is freed so the connector can inspect
+ # per-request state (e.g. which mm_hashes it recorded during
+ # save_caches()) and emit ec_transfer_params for the response body.
+ ec_xfer_params: dict[str, Any] | None = None
+ if self.ec_connector is not None:
+ ec_delay_free, ec_xfer_params = self.ec_connector.request_finished(request)
+ connector_delay_free_blocks |= ec_delay_free
+
self.encoder_cache_manager.free(request)
request_id = request.request_id
self.finished_req_ids.add(request_id)
@@ -2166,7 +2180,7 @@ def _free_request(
if not delay_free_blocks:
self._free_blocks(request)
- return kv_xfer_params
+ return kv_xfer_params, ec_xfer_params
def _free_blocks(self, request: Request):
assert request.is_finished()
@@ -2414,6 +2428,9 @@ def shutdown(self) -> None:
def get_kv_connector(self) -> KVConnectorBase_V1 | None:
return self.connector
+ def get_ec_connector(self) -> ECConnectorBase | None:
+ return self.ec_connector
+
def _connector_finished(
self, request: Request
) -> tuple[bool, dict[str, Any] | None]:
diff --git a/vllm/v1/engine/__init__.py b/vllm/v1/engine/__init__.py
index 38ca8dc6da4d..919402a16ab0 100644
--- a/vllm/v1/engine/__init__.py
+++ b/vllm/v1/engine/__init__.py
@@ -190,6 +190,7 @@ class EngineCoreOutput(
stop_reason: int | str | None = None
events: list[EngineCoreEvent] | None = None
kv_transfer_params: dict[str, Any] | None = None
+ ec_transfer_params: dict[str, Any] | None = None
trace_headers: Mapping[str, str] | None = None
diff --git a/vllm/v1/engine/core.py b/vllm/v1/engine/core.py
index f97f697dedca..8043b708037b 100644
--- a/vllm/v1/engine/core.py
+++ b/vllm/v1/engine/core.py
@@ -400,6 +400,15 @@ def add_request(self, request: Request, request_wave: int = 0):
"Disabling KVTransfer for this request."
)
+ if (
+ request.ec_transfer_params is not None
+ and self.scheduler.get_ec_connector() is None
+ ):
+ logger.warning(
+ "Got ec_transfer_params, but no ECConnector found. "
+ "Disabling ECTransfer for this request."
+ )
+
self.scheduler.add_request(request)
if request.abort_immediately:
# Immediately abort so the connector's request_finished hook runs
diff --git a/vllm/v1/engine/output_processor.py b/vllm/v1/engine/output_processor.py
index e1032cfd1f2b..b676c3cd2d39 100644
--- a/vllm/v1/engine/output_processor.py
+++ b/vllm/v1/engine/output_processor.py
@@ -276,6 +276,7 @@ def make_request_output(
finish_reason: FinishReason | None,
stop_reason: int | str | None,
kv_transfer_params: dict[str, Any] | None = None,
+ ec_transfer_params: dict[str, Any] | None = None,
) -> RequestOutput | PoolingRequestOutput | None:
finished = finish_reason is not None
final_only = self.output_kind == RequestOutputKind.FINAL_ONLY
@@ -327,7 +328,11 @@ def make_request_output(
external_req_id = self.parent_req.external_req_id
return self._new_request_output(
- external_req_id, outputs, finished, kv_transfer_params
+ external_req_id,
+ outputs,
+ finished,
+ kv_transfer_params,
+ ec_transfer_params,
)
def _new_request_output(
@@ -336,6 +341,7 @@ def _new_request_output(
outputs: list[CompletionOutput] | list[PoolingOutput],
finished: bool,
kv_transfer_params: dict[str, Any] | None = None,
+ ec_transfer_params: dict[str, Any] | None = None,
) -> RequestOutput | PoolingRequestOutput:
# If prompt embeds were used, put placeholder prompt token ids
prompt_token_ids = self.prompt_token_ids
@@ -369,6 +375,7 @@ def _new_request_output(
outputs=cast(list[CompletionOutput], outputs),
finished=finished,
kv_transfer_params=kv_transfer_params,
+ ec_transfer_params=ec_transfer_params,
num_cached_tokens=self.num_cached_tokens,
metrics=self.stats,
)
@@ -497,6 +504,7 @@ def abort_requests(self, request_ids: Iterable[str], internal: bool) -> list[str
finish_reason=FinishReason.ABORT,
stop_reason=None,
kv_transfer_params=None,
+ ec_transfer_params=None,
)
):
req_state.queue.put(request_output)
@@ -620,6 +628,7 @@ def process_outputs(
finish_reason = engine_core_output.finish_reason
stop_reason = engine_core_output.stop_reason
kv_transfer_params = engine_core_output.kv_transfer_params
+ ec_transfer_params = engine_core_output.ec_transfer_params
if engine_core_output.routed_experts is not None:
req_state.routed_experts_chunks.append(
engine_core_output.routed_experts
@@ -654,6 +663,7 @@ def process_outputs(
finish_reason,
stop_reason,
kv_transfer_params,
+ ec_transfer_params,
):
if req_state.streaming_input:
request_output.finished = False
diff --git a/vllm/v1/request.py b/vllm/v1/request.py
index 058d498d621d..00f1bdbdceaf 100644
--- a/vllm/v1/request.py
+++ b/vllm/v1/request.py
@@ -100,6 +100,8 @@ def __init__(
# P/D: Connector-specific KV transfer parameters.
self.kv_transfer_params: dict[str, Any] | None = None
+ # E/P/D: Connector-specific encoder-cache transfer parameters.
+ self.ec_transfer_params: dict[str, Any] | None = None
if pooling_params is not None:
# Pooling models.
@@ -115,6 +117,9 @@ def __init__(
self.kv_transfer_params = sampling_params.extra_args.get(
"kv_transfer_params"
)
+ self.ec_transfer_params = sampling_params.extra_args.get(
+ "ec_transfer_params"
+ )
self.kv_cache_report_mode = sampling_params.extra_args.get(
"kv_cache_report_mode", "incremental"
)
From 27c3e579f0e5f345a86e512e26e1231d3689931f Mon Sep 17 00:00:00 2001
From: Bugen Zhao
Date: Sun, 12 Jul 2026 23:34:26 +0800
Subject: [PATCH 0058/1526] [CI][Rust Frontend] Pin cargo tool versions
(#48222)
Signed-off-by: Bugen Zhao
---
.../scripts/run-rust-frontend-cargo-ci.sh | 81 ++++++++-----------
1 file changed, 35 insertions(+), 46 deletions(-)
diff --git a/.buildkite/scripts/run-rust-frontend-cargo-ci.sh b/.buildkite/scripts/run-rust-frontend-cargo-ci.sh
index a5152030d206..215650a07fbb 100755
--- a/.buildkite/scripts/run-rust-frontend-cargo-ci.sh
+++ b/.buildkite/scripts/run-rust-frontend-cargo-ci.sh
@@ -21,16 +21,20 @@ export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}"
export RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}"
export PATH="$CARGO_HOME/bin:$PATH"
+PROTOC_VERSION="${PROTOC_VERSION:-31.1}"
+CARGO_BINSTALL_VERSION="${CARGO_BINSTALL_VERSION:-1.20.1}"
+UV_VERSION="${UV_VERSION:-0.11.28}"
+PYO3_PYTHON_VERSION="${PYO3_PYTHON_VERSION:-3.12}"
+
+CARGO_SORT_VERSION_REQ="${CARGO_SORT_VERSION_REQ:-2}"
+CARGO_DENY_VERSION_REQ="${CARGO_DENY_VERSION_REQ:-0.20}"
+CARGO_NEXTEST_VERSION_REQ="${CARGO_NEXTEST_VERSION_REQ:-0.9}"
+
log_section() {
echo "--- $*"
}
install_protoc() {
- if command -v protoc >/dev/null 2>&1; then
- return
- fi
-
- local version="${PROTOC_VERSION:-31.1}"
local arch
case "$(uname -m)" in
x86_64)
@@ -45,16 +49,17 @@ install_protoc() {
;;
esac
- local url="https://github.com/protocolbuffers/protobuf/releases/download/v${version}/protoc-${version}-linux-${arch}.zip"
+ local url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-${arch}.zip"
local tmp_dir
tmp_dir="$(mktemp -d)"
- log_section "Installing protoc ${version}"
+ log_section "Installing protoc ${PROTOC_VERSION}"
curl -L --proto '=https' --tlsv1.2 -sSf "$url" -o "$tmp_dir/protoc.zip"
mkdir -p "$CARGO_HOME/bin"
unzip -q "$tmp_dir/protoc.zip" bin/protoc 'include/*' -d "$CARGO_HOME"
chmod +x "$CARGO_HOME/bin/protoc"
rm -rf "$tmp_dir"
+ protoc --version
}
rust_toolchain() {
@@ -75,66 +80,48 @@ install_rust_toolchain() {
}
install_cargo_binstall() {
- if command -v cargo-binstall >/dev/null 2>&1; then
- return
- fi
-
- log_section "Installing cargo-binstall"
+ log_section "Installing cargo-binstall ${CARGO_BINSTALL_VERSION}"
curl -L --proto '=https' --tlsv1.2 -sSf \
- https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh \
- | bash
+ "https://raw.githubusercontent.com/cargo-bins/cargo-binstall/v${CARGO_BINSTALL_VERSION}/install-from-binstall-release.sh" \
+ | env BINSTALL_VERSION="$CARGO_BINSTALL_VERSION" bash
+ cargo-binstall -V
}
install_cargo_sort() {
- if command -v cargo-sort >/dev/null 2>&1; then
- return
- fi
-
- log_section "Installing cargo-sort"
- install_cargo_binstall
- cargo binstall --no-confirm cargo-sort
+ log_section "Installing cargo-sort ${CARGO_SORT_VERSION_REQ}"
+ cargo binstall --no-confirm --force "cargo-sort@${CARGO_SORT_VERSION_REQ}"
}
install_cargo_deny() {
- if command -v cargo-deny >/dev/null 2>&1; then
- return
- fi
-
- log_section "Installing cargo-deny"
- install_cargo_binstall
- cargo binstall --no-confirm cargo-deny
+ log_section "Installing cargo-deny ${CARGO_DENY_VERSION_REQ}"
+ cargo binstall --no-confirm --force "cargo-deny@${CARGO_DENY_VERSION_REQ}"
}
install_cargo_nextest() {
- if command -v cargo-nextest >/dev/null 2>&1; then
- return
- fi
-
- log_section "Installing cargo-nextest"
- install_cargo_binstall
- cargo binstall --no-confirm --secure cargo-nextest
+ log_section "Installing cargo-nextest ${CARGO_NEXTEST_VERSION_REQ}"
+ cargo binstall \
+ --no-confirm \
+ --force \
+ --secure \
+ "cargo-nextest@${CARGO_NEXTEST_VERSION_REQ}"
}
install_uv() {
- if command -v uv >/dev/null 2>&1; then
- return
- fi
-
- log_section "Installing uv"
- curl -LsSf --proto '=https' --tlsv1.2 https://astral.sh/uv/install.sh \
+ log_section "Installing uv ${UV_VERSION}"
+ curl -L --proto '=https' --tlsv1.2 -sSf \
+ "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-installer.sh" \
| env UV_INSTALL_DIR="$CARGO_HOME/bin" sh
+ uv --version
}
setup_pyo3_python() {
- local python_version="${PYO3_PYTHON_VERSION:-3.12}"
-
- log_section "Installing Python ${python_version} for PyO3 tests"
- uv python install "$python_version"
+ log_section "Installing Python ${PYO3_PYTHON_VERSION} for PyO3 tests"
+ uv python install "$PYO3_PYTHON_VERSION"
PYO3_PYTHON="$(uv python find \
--managed-python \
--no-project \
--resolve-links \
- "$python_version")"
+ "$PYO3_PYTHON_VERSION")"
export PYO3_PYTHON
local python_libdir
@@ -156,6 +143,7 @@ PY
}
run_style_clippy() {
+ install_cargo_binstall
install_cargo_sort
install_cargo_deny
@@ -186,6 +174,7 @@ run_style_clippy() {
run_tests() {
install_uv
setup_pyo3_python
+ install_cargo_binstall
install_cargo_nextest
log_section "Running cargo nextest"
From 4c81772e8bdf9e37ab2b8adfd80e4308b618fa92 Mon Sep 17 00:00:00 2001
From: AlexHuang
Date: Mon, 13 Jul 2026 01:00:04 +0800
Subject: [PATCH 0059/1526] [Bugfix][KV Offloading] Fix stale transfer_jobs
after reset_cache + harden job completion (#48102)
Signed-off-by: Alex
---
.../kv_transfer/kv_connector/v1/offloading/scheduler.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py b/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
index 75a9696b72cd..f896c9cc4923 100644
--- a/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+++ b/vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
@@ -1310,6 +1310,7 @@ def reset_cache(self) -> None:
for status in self._req_status.values():
for group_state in status.group_states:
group_state.next_stored_block_idx = 0
+ status.transfer_jobs.clear()
# Discard jobs and save job_counter to be able to discard worker responses
self._stale_job_threshold = self._job_counter
From e26264f3ef5987a172162bbf497b8ae7ecbe0108 Mon Sep 17 00:00:00 2001
From: Tanish Malekar <60835372+tanish-malekar@users.noreply.github.com>
Date: Sun, 12 Jul 2026 19:18:03 -0700
Subject: [PATCH 0060/1526] [Kernel] Implement CUDA kernel for
ReLUSquaredActivation (relu^2) (#39058)
Signed-off-by: Tanish Malekar
Co-authored-by: Claude Sonnet 4.6
---
benchmarks/kernels/benchmark_relu_squared.py | 108 +++++++++++++++++++
csrc/libtorch_stable/activation_kernels.cu | 14 +++
csrc/libtorch_stable/ops.h | 2 +
csrc/libtorch_stable/torch_bindings.cpp | 4 +
csrc/ops.h | 2 +
tests/kernels/core/test_activation.py | 2 +
vllm/model_executor/layers/activation.py | 10 +-
7 files changed, 140 insertions(+), 2 deletions(-)
create mode 100644 benchmarks/kernels/benchmark_relu_squared.py
diff --git a/benchmarks/kernels/benchmark_relu_squared.py b/benchmarks/kernels/benchmark_relu_squared.py
new file mode 100644
index 000000000000..00550ca475b8
--- /dev/null
+++ b/benchmarks/kernels/benchmark_relu_squared.py
@@ -0,0 +1,108 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+
+# Benchmark ReLUSquaredActivation: custom CUDA kernel vs forward_native, both
+# eager and under torch.compile (Inductor fuses relu+square into one kernel).
+
+import itertools
+
+import torch
+import torch.nn.functional as F
+
+import vllm.model_executor.layers.activation # noqa: F401
+from vllm.benchmarks.lib.utils import default_vllm_config
+from vllm.triton_utils import triton
+from vllm.utils.argparse_utils import FlexibleArgumentParser
+from vllm.utils.torch_utils import STR_DTYPE_TO_TORCH_DTYPE, set_random_seed
+
+# Capped so the largest tensor stays under 2**31 elements: the shared activation
+# kernel computes the per-token pointer offset (blockIdx.x * d) in 32-bit, which
+# overflows for tensors with >2**32 elements. Realistic token counts are well
+# below this; the kernel-vs-native gap is already clear at these sizes.
+batch_size_range = [1, 16, 128]
+seq_len_range = [1, 16, 64, 1024]
+intermediate_size = [3072, 9728, 12288]
+configs = list(itertools.product(batch_size_range, seq_len_range, intermediate_size))
+
+
+@default_vllm_config()
+def benchmark_relu_squared(
+ batch_size: int,
+ seq_len: int,
+ intermediate_size: int,
+ provider: str,
+ dtype: torch.dtype,
+):
+ device = "cuda"
+ num_tokens = batch_size * seq_len
+ set_random_seed(42)
+ torch.set_default_device(device)
+
+ x = torch.randn(num_tokens, intermediate_size, dtype=dtype, device=device)
+ out = torch.empty_like(x)
+
+ def native(x: torch.Tensor) -> torch.Tensor:
+ return torch.square(F.relu(x))
+
+ # Verify the custom kernel matches the native implementation before timing.
+ ref = native(x)
+ torch.ops._C.relu_squared(out, x)
+ torch.testing.assert_close(out, ref)
+
+ if provider == "custom":
+ # Custom CUDA kernel — single fused kernel.
+ fn = lambda: torch.ops._C.relu_squared(out, x)
+ elif provider == "native":
+ # forward_native, eager — relu and square as separate ops.
+ fn = lambda: native(x)
+ elif provider == "native_compiled":
+ # forward_native under torch.compile — Inductor fuses relu+square.
+ # This is the real production baseline (custom ops are off when
+ # Inductor is enabled), so it is the comparison reviewers care about.
+ compiled = torch.compile(native)
+ compiled(x) # warm up / trigger compilation before timing
+ fn = lambda: compiled(x)
+
+ ms, min_ms, max_ms = triton.testing.do_bench_cudagraph(
+ fn, quantiles=[0.5, 0.2, 0.8]
+ )
+ return ms, max_ms, min_ms
+
+
+if __name__ == "__main__":
+ parser = FlexibleArgumentParser(
+ description="Benchmark ReLUSquaredActivation: custom kernel vs native."
+ )
+ parser.add_argument(
+ "--dtype",
+ type=str,
+ choices=["half", "bfloat16", "float"],
+ default="bfloat16",
+ )
+ args = parser.parse_args()
+
+ dtype = STR_DTYPE_TO_TORCH_DTYPE[args.dtype]
+
+ perf_report = triton.testing.perf_report(
+ triton.testing.Benchmark(
+ x_names=["batch_size", "seq_len", "intermediate_size"],
+ x_vals=configs,
+ line_arg="provider",
+ line_vals=["custom", "native_compiled", "native"],
+ line_names=[
+ "Custom Kernel",
+ "Native (torch.compile)",
+ "Native (eager)",
+ ],
+ styles=[("blue", "-"), ("green", "-"), ("red", "-")],
+ ylabel="ms",
+ plot_name="relu_squared-eager-performance",
+ args={},
+ )
+ )
+
+ perf_report(
+ lambda batch_size, seq_len, intermediate_size, provider: benchmark_relu_squared(
+ batch_size, seq_len, intermediate_size, provider, dtype
+ )
+ ).run(print_data=True)
diff --git a/csrc/libtorch_stable/activation_kernels.cu b/csrc/libtorch_stable/activation_kernels.cu
index e1dc01346055..60b8ca5f382d 100644
--- a/csrc/libtorch_stable/activation_kernels.cu
+++ b/csrc/libtorch_stable/activation_kernels.cu
@@ -669,6 +669,14 @@ __device__ __forceinline__ T gelu_quick_kernel(const T& x) {
return (T)(((float)x) / (1.0f + expf(-1.702f * (float)x)));
}
+template
+__device__ __forceinline__ T relu_squared_kernel(const T& x) {
+ // relu(x)^2 — introduced in https://arxiv.org/abs/2109.08668v2
+ const float f = (float)x;
+ const float val = f > 0.0f ? f : 0.0f;
+ return (T)(val * val);
+}
+
} // namespace vllm
void gelu_new(torch::stable::Tensor& out, // [..., d]
@@ -688,3 +696,9 @@ void gelu_quick(torch::stable::Tensor& out, // [..., d]
{
LAUNCH_ACTIVATION_KERNEL(vllm::gelu_quick_kernel);
}
+
+void relu_squared(torch::stable::Tensor& out, // [..., d]
+ torch::stable::Tensor& input) // [..., d]
+{
+ LAUNCH_ACTIVATION_KERNEL(vllm::relu_squared_kernel);
+}
diff --git a/csrc/libtorch_stable/ops.h b/csrc/libtorch_stable/ops.h
index 4520f4a99ec9..0aeba4147119 100644
--- a/csrc/libtorch_stable/ops.h
+++ b/csrc/libtorch_stable/ops.h
@@ -414,6 +414,8 @@ void gelu_new(torch::stable::Tensor& out, torch::stable::Tensor& input);
void gelu_fast(torch::stable::Tensor& out, torch::stable::Tensor& input);
void gelu_quick(torch::stable::Tensor& out, torch::stable::Tensor& input);
+void relu_squared(torch::stable::Tensor& out, torch::stable::Tensor& input);
+
// INT8 quantization kernels (shared CUDA/ROCm)
void static_scaled_int8_quant(torch::stable::Tensor& out,
torch::stable::Tensor const& input,
diff --git a/csrc/libtorch_stable/torch_bindings.cpp b/csrc/libtorch_stable/torch_bindings.cpp
index 7581769f4f45..67487daf3ee6 100644
--- a/csrc/libtorch_stable/torch_bindings.cpp
+++ b/csrc/libtorch_stable/torch_bindings.cpp
@@ -539,6 +539,9 @@ STABLE_TORCH_LIBRARY_FRAGMENT(_C, ops) {
// Quick GELU implementation.
ops.def("gelu_quick(Tensor! out, Tensor input) -> ()");
+ // relu(x)^2 activation from https://arxiv.org/abs/2109.08668v2
+ ops.def("relu_squared(Tensor! out, Tensor input) -> ()");
+
// Compute int8 quantized tensor for given scaling factor.
ops.def(
"static_scaled_int8_quant(Tensor! result, Tensor input, Tensor scale,"
@@ -715,6 +718,7 @@ STABLE_TORCH_LIBRARY_IMPL(_C, CUDA, ops) {
ops.impl("gelu_new", TORCH_BOX(&gelu_new));
ops.impl("gelu_fast", TORCH_BOX(&gelu_fast));
ops.impl("gelu_quick", TORCH_BOX(&gelu_quick));
+ ops.impl("relu_squared", TORCH_BOX(&relu_squared));
ops.impl("silu_and_mul_with_clamp", TORCH_BOX(&silu_and_mul_clamp));
// INT8 quantization kernels
diff --git a/csrc/ops.h b/csrc/ops.h
index 274cd52bea41..d7ee2d080c48 100644
--- a/csrc/ops.h
+++ b/csrc/ops.h
@@ -43,6 +43,8 @@ void gelu_fast(torch::Tensor& out, torch::Tensor& input);
void gelu_quick(torch::Tensor& out, torch::Tensor& input);
+void relu_squared(torch::Tensor& out, torch::Tensor& input);
+
void static_scaled_int8_quant(torch::Tensor& out, torch::Tensor const& input,
torch::Tensor const& scale,
std::optional const& azp);
diff --git a/tests/kernels/core/test_activation.py b/tests/kernels/core/test_activation.py
index 3f1d45ba8e9e..f698c385fee7 100644
--- a/tests/kernels/core/test_activation.py
+++ b/tests/kernels/core/test_activation.py
@@ -15,6 +15,7 @@
MulAndSilu,
NewGELU,
QuickGELU,
+ ReLUSquaredActivation,
SiluAndMul,
SiluAndMulWithClamp,
SwigluOAIAndMul,
@@ -202,6 +203,7 @@ def test_silu_and_mul_with_clamp(
(FastGELU, torch.ops._C.gelu_fast),
(NewGELU, torch.ops._C.gelu_new),
(QuickGELU, torch.ops._C.gelu_quick),
+ (ReLUSquaredActivation, torch.ops._C.relu_squared),
],
)
@pytest.mark.parametrize("num_tokens", NUM_TOKENS)
diff --git a/vllm/model_executor/layers/activation.py b/vllm/model_executor/layers/activation.py
index 7c3fe4407d60..a8c91af9adc4 100644
--- a/vllm/model_executor/layers/activation.py
+++ b/vllm/model_executor/layers/activation.py
@@ -613,13 +613,19 @@ class ReLUSquaredActivation(CustomOp):
# --8<-- [end:relu2]
+ def __init__(self):
+ super().__init__()
+ if current_platform.is_cuda_alike():
+ self.op = torch.ops._C.relu_squared
+
def forward_native(self, x: torch.Tensor) -> torch.Tensor:
"""PyTorch-native implementation equivalent to forward()."""
return torch.square(F.relu(x))
def forward_cuda(self, x: torch.Tensor) -> torch.Tensor:
- # TODO : implement cuda kernels
- return self.forward_native(x)
+ out = torch.empty_like(x)
+ self.op(out, x)
+ return out
# --8<-- [start:xielu]
From ee5a89f4d7b818af7d92770dbf6aa1b41b503b4a Mon Sep 17 00:00:00 2001
From: Tan Pin Siang
Date: Mon, 13 Jul 2026 10:27:29 +0800
Subject: [PATCH 0061/1526] [ROCm][MiniMax-M3] Add AITER sparse paged attention
(#47287)
Signed-off-by: Tan Pin Siang
Signed-off-by: tjtanaa
Co-authored-by: vllmellm
Co-authored-by: Hongxia Yang
Co-authored-by: Jun Kang Chow
Co-authored-by: tjtanaa
---
...minimax_m3_qknorm_rope_kv_insert_kernel.cu | 201 +++++---
csrc/libtorch_stable/ops.h | 2 +-
csrc/libtorch_stable/torch_bindings.cpp | 2 +-
tests/kernels/attention/test_minimax_m3.py | 41 ++
..._fused_minimax_m3_qknorm_rope_kv_insert.py | 143 +++++-
.../test_minimax_m3_sparse_attn_fp8_scale.py | 189 +++++++
vllm/_custom_ops.py | 7 +
vllm/models/minimax_m3/amd/model.py | 239 ++++++++-
vllm/models/minimax_m3/amd/ops/sparse_attn.py | 73 ++-
vllm/models/minimax_m3/amd/ops/sparse_pa.py | 466 ++++++++++++++++++
.../minimax_m3/amd/sparse_attention_msa.py | 94 ++++
.../minimax_m3/common/ops/sparse_attn.py | 160 ++++++
.../minimax_m3/common/sparse_attention.py | 52 +-
vllm/models/minimax_m3/nvidia/model.py | 7 +
.../minimax_m3/nvidia/sparse_attention_msa.py | 4 +
15 files changed, 1553 insertions(+), 127 deletions(-)
create mode 100644 tests/kernels/test_minimax_m3_sparse_attn_fp8_scale.py
create mode 100644 vllm/models/minimax_m3/amd/ops/sparse_pa.py
create mode 100644 vllm/models/minimax_m3/amd/sparse_attention_msa.py
diff --git a/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu b/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
index a2162a03b835..d8460f032eb2 100644
--- a/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
+++ b/csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
@@ -39,12 +39,10 @@
* The IQ/IK warps address the index_q/index_k sub-blocks *inside* qkv at the
* fixed physical offsets (nq+2*nkv)*128 and (nq+2*nkv+niq)*128.
*
- * Dense vs sparse is a compile-time choice via the ``kIsSparse``/``kInsertKV``
- * template bools (3 instantiations: dense , sparse-profiling
- * , sparse-serving ), so the index slots, the V slots
- * and the cache inserts fold away entirely on paths that don't use them. The
- * dense layer passes no caches/index: norm+RoPE happens in place and the
- * generic ``Attention`` layer owns the cache write.
+ * Dense vs sparse row layout and index-branch processing are separate template
+ * choices. Skip-index-topk reuse layers still have sparse rows and insert main
+ * K/V cache entries, but compile away index_q/index_k work and index-cache
+ * writes.
*
* Q/K and (sparse) index_q/index_k are all rewritten in place inside the fused
* ``qkv`` tensor. Caches (bf16) are scatter-written by slot.
@@ -223,10 +221,25 @@ __device__ __forceinline__ void storeCacheElems(
// model dtype directly. FP8 cache dtypes use the conversion path below.
storeElems(reinterpret_cast(dst), elems);
} else {
-#pragma unroll
+#ifdef USE_ROCM
+ // Match ROCm's model-dtype materialization before FP8 cache conversion.
+ using Converter = vllm::_typeConvert;
+ using rounded_t = typename Converter::hip_type;
+ rounded_t rounded[kElemsPerLane];
+ #pragma unroll
+ for (int i = 0; i < kElemsPerLane; i++) {
+ rounded[i] = Converter::convert(elems[i]);
+ }
+ #pragma unroll
+ for (int i = 0; i < kElemsPerLane; i++) {
+ dst[i] = fp8::scaled_convert(rounded[i], 1.0f);
+ }
+#else
+ #pragma unroll
for (int i = 0; i < kElemsPerLane; i++) {
dst[i] = fp8::scaled_convert(elems[i], 1.0f);
}
+#endif
}
}
@@ -262,20 +275,25 @@ __device__ __forceinline__ void storeElemsFp8(
// Grid: 1D, ceil(num_tokens * slots_per_token / warps_per_block).
// Each warp = one (token, slot).
//
-// `kIsSparse` and `kInsertKV` are compile-time template bools, so all the
-// branch decisions that distinguish the dense layer from the sparse layer
-// (index slots, KV/index inserts, V slots) fold away per instantiation.
-// Three instantiations are built: dense , sparse-profiling
-// and sparse-serving . Slots per token:
+// `kHasIndex`, `kProcessIndex`, and `kInsertKV` are compile-time template
+// bools, so branch decisions that distinguish the dense layer from the sparse
+// layer (index slots, KV/index inserts, V slots) fold away per instantiation.
+// Slots per token:
// Q : nq (always — norm+RoPE)
// K : nkv (always — norm+RoPE; +K-cache insert)
// V : nkv only if kInsertKV (V-cache insert; no warps in dense)
-// IQ: niq only if kIsSparse (norm+RoPE)
-// IK: 1 only if kIsSparse (norm+RoPE; +index-cache insert)
+// IQ: niq only if kProcessIndex (norm+RoPE)
+// IK: 1 only if kProcessIndex (norm+RoPE; +index-cache insert)
// cache_t/kv_dt: main attention KV-cache dtype (auto/fp8). out_idx_t/kFp8Idx:
// indexer index-K cache + index-Q output dtype (scalar_t or e4m3 byte).
+// kHasIndex means the qkv row is laid out as sparse [q|k|v|index_q|index_k].
+// kProcessIndex controls whether this launch actually norms/ropes the index
+// branch and writes index_q/index_k outputs. Skip-index-topk reuse layers keep
+// kHasIndex=true but set kProcessIndex=false.
template
+ typename out_idx_t, bool kHasIndex, bool kInsertKV,
+ bool kProcessIndex,
+ bool kFp8Idx>
__global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
scalar_t* __restrict__ qkv, // [N, qkv_row] in/out (packs index if sparse)
scalar_t* __restrict__ q_out, // [N, nq*128] contiguous, or nullptr
@@ -308,9 +326,12 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
int const laneId = threadIdx.x % 32;
int const globalWarpIdx = blockIdx.x * warpsPerBlock + (threadIdx.x / 32);
+ static_assert(!kProcessIndex || kHasIndex,
+ "index processing requires sparse row layout");
+
// Slot layout (compile-time gated: dense has neither V nor index slots).
int const v_slots = kInsertKV ? nkv : 0;
- int const idx_slots = kIsSparse ? niq + 1 : 0;
+ int const idx_slots = kProcessIndex ? niq + 1 : 0;
int const slots_per_token = nq + nkv + v_slots + idx_slots;
int const tokenIdx = globalWarpIdx / slots_per_token;
@@ -321,14 +342,14 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
int const k_begin = nq;
int const v_begin = nq + nkv; // valid only when kInsertKV
int const iq_begin = nq + nkv + v_slots; // index block start
- int const ik_slot = iq_begin + niq; // valid only when kIsSparse
+ int const ik_slot = iq_begin + niq; // valid only when kProcessIndex
bool const isQ = slot < k_begin;
bool const isK = slot >= k_begin && slot < v_begin;
bool isV = false;
if constexpr (kInsertKV) isV = slot >= v_begin && slot < v_begin + nkv;
bool isIQ = false, isIK = false;
- if constexpr (kIsSparse) {
+ if constexpr (kProcessIndex) {
isIQ = slot >= iq_begin && slot < ik_slot;
isIK = slot == ik_slot;
}
@@ -336,7 +357,7 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
int const dim_base = laneId * kElemsPerLane;
// Physical row width of qkv: the dense layer packs [q|k|v]; the sparse
// layer additionally packs [index_q (niq heads) | index_k (1 head)].
- int const qkv_row = (nq + 2 * nkv + (kIsSparse ? (niq + 1) : 0)) * kHeadDim;
+ int const qkv_row = (nq + 2 * nkv + (kHasIndex ? (niq + 1) : 0)) * kHeadDim;
// ── Resolve source pointer + per-branch parameters. ────────────────────
scalar_t* row_ptr = nullptr; // in-place output location
@@ -367,10 +388,13 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
row_ptr = qkv + static_cast(tokenIdx) * qkv_row +
(nq + 2 * nkv + ih) * kHeadDim;
norm_w = iq_norm_w;
- } else { // isIK -- single shared index key at (nq+2*nkv+niq)*128.
+ } else if (isIK) {
+ // Single shared index key at (nq+2*nkv+niq)*128.
row_ptr = qkv + static_cast(tokenIdx) * qkv_row +
(nq + 2 * nkv + niq) * kHeadDim;
norm_w = ik_norm_w;
+ } else {
+ return;
}
// Store destination. Q and index_q are gathered into dedicated contiguous
@@ -426,9 +450,12 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
// ── Cache inserts (sparse serving only). ───────────────────────────────
if constexpr (kInsertKV) {
// Guard (not early-return) so every thread reaches the PDL trigger below.
- int64_t const sm = (isK || isV)
- ? slot_mapping[tokenIdx]
- : (isIK ? index_slot_mapping[tokenIdx] : -1);
+ int64_t sm = -1;
+ if (isK || isV) {
+ sm = slot_mapping[tokenIdx];
+ } else if constexpr (kProcessIndex) {
+ if (isIK) sm = index_slot_mapping[tokenIdx];
+ }
if (sm >= 0) { // skip padded / unscheduled tokens
if (isIK) {
if constexpr (kFp8Idx) {
@@ -475,12 +502,12 @@ void launchFusedMiniMaxM3(
int const nkv, int const niq, int const block_size,
int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
int64_t const kv_s_dim, bool const has_index, bool const insert_kv,
- bool const fp8_idx, cudaStream_t stream) {
+ bool const process_index, bool const fp8_idx, cudaStream_t stream) {
// Index outputs are scalar_t (bf16) or e4m3 bytes (uint8_t); reinterpret the
// void* pointers per instantiation in the LAUNCH macro.
// Slot count must match the kernel's compile-time gating.
int const v_slots = insert_kv ? nkv : 0;
- int const idx_slots = has_index ? niq + 1 : 0;
+ int const idx_slots = process_index ? niq + 1 : 0;
int const slots_per_token = nq + nkv + v_slots + idx_slots;
constexpr int kBlockSize = 256;
@@ -507,11 +534,12 @@ void launchFusedMiniMaxM3(
config.attrs = attrs;
config.numAttrs = (sm_version >= 90) ? 1 : 0;
- #define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \
+ #define LAUNCH(HAS_INDEX, INSERT, PROCESS_INDEX, FP8, OUT_T) \
cudaLaunchKernelEx( \
&config, \
fusedMiniMaxM3QNormRopeKVInsertKernel, \
+ HAS_INDEX, INSERT, \
+ PROCESS_INDEX, FP8>, \
qkv, q_out, reinterpret_cast(index_q_out), q_norm_w, k_norm_w, \
iq_norm_w, ik_norm_w, cos_sin_cache, positions, slot_mapping, \
index_slot_mapping, kv_cache, reinterpret_cast(index_cache), \
@@ -520,37 +548,45 @@ void launchFusedMiniMaxM3(
#else
// ROCm: standard kernel launch syntax (no PDL/stream serialization).
// clang-format off
- #define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \
- fusedMiniMaxM3QNormRopeKVInsertKernel \
- <<>>( \
- qkv, q_out, reinterpret_cast(index_q_out), q_norm_w, \
- k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
- slot_mapping, index_slot_mapping, kv_cache, \
- reinterpret_cast(index_cache), eps, rotary_dim, \
- num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_head, \
- kv_s_token, kv_s_dim)
+ #define LAUNCH(HAS_INDEX, INSERT, PROCESS_INDEX, FP8, OUT_T) \
+ fusedMiniMaxM3QNormRopeKVInsertKernel< \
+ scalar_t, cache_t, kv_dt, OUT_T, HAS_INDEX, INSERT, PROCESS_INDEX, \
+ FP8><<>>( \
+ qkv, q_out, reinterpret_cast(index_q_out), q_norm_w, \
+ k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
+ slot_mapping, index_slot_mapping, kv_cache, \
+ reinterpret_cast(index_cache), eps, rotary_dim, num_tokens, \
+ nq, nkv, niq, block_size, kv_s_block, kv_s_head, kv_s_token, \
+ kv_s_dim)
// clang-format on
#endif
if (has_index) {
- if (insert_kv) {
+ if (!process_index) {
+ if (insert_kv) {
+ LAUNCH(true, true, false, false, scalar_t);
+ } else {
+ LAUNCH(true, false, false, false, scalar_t);
+ }
+ } else if (insert_kv) {
if (fp8_idx) {
- LAUNCH(true, true, true, uint8_t); // sparse serving, fp8 index outputs
+ LAUNCH(true, true, true, true,
+ uint8_t); // sparse serving, fp8 index outputs
} else {
- LAUNCH(true, true, false, scalar_t); // sparse serving, bf16
+ LAUNCH(true, true, true, false, scalar_t); // sparse serving, bf16
}
} else {
if (fp8_idx) {
- LAUNCH(true, false, true, uint8_t); // sparse profiling, fp8 index_q
+ LAUNCH(true, false, true, true,
+ uint8_t); // sparse profiling, fp8 index_q
} else {
- LAUNCH(true, false, false, scalar_t); // sparse profiling, bf16
+ LAUNCH(true, false, true, false, scalar_t); // sparse profiling, bf16
}
}
} else {
// Dense layer: never has an index branch and never inserts here (the
// generic Attention layer owns the KV insert).
- LAUNCH(false, false, false, scalar_t);
+ LAUNCH(false, false, false, false, scalar_t);
}
#undef LAUNCH
}
@@ -558,6 +594,7 @@ void launchFusedMiniMaxM3(
} // namespace minimax_m3_fused_ops
} // namespace vllm
+// clang-format off
#define CALL_FUSED_MINIMAX_M3(_RAW_T, CACHE_T, KV_DTYPE) \
vllm::minimax_m3_fused_ops::launchFusedMiniMaxM3( \
reinterpret_cast(qkv.data_ptr()), \
@@ -567,24 +604,29 @@ void launchFusedMiniMaxM3(
: nullptr, \
reinterpret_cast(q_norm_weight.data_ptr()), \
reinterpret_cast(k_norm_weight.data_ptr()), \
- has_index ? reinterpret_cast(index_q_norm_weight->data_ptr()) \
- : nullptr, \
- has_index ? reinterpret_cast(index_k_norm_weight->data_ptr()) \
- : nullptr, \
+ process_index \
+ ? reinterpret_cast(index_q_norm_weight->data_ptr()) \
+ : nullptr, \
+ process_index \
+ ? reinterpret_cast(index_k_norm_weight->data_ptr()) \
+ : nullptr, \
reinterpret_cast(cos_sin_cache.data_ptr()), \
reinterpret_cast(positions.data_ptr()), \
insert_kv ? reinterpret_cast(slot_mapping->data_ptr()) \
: nullptr, \
- insert_kv ? reinterpret_cast( \
- effective_index_slot_mapping->data_ptr()) \
- : nullptr, \
+ (insert_kv && process_index) \
+ ? reinterpret_cast( \
+ effective_index_slot_mapping->data_ptr()) \
+ : nullptr, \
insert_kv ? reinterpret_cast(kv_cache->data_ptr()) : nullptr, \
- (insert_kv && has_index) \
+ (insert_kv && process_index) \
? reinterpret_cast(index_cache->data_ptr()) \
: nullptr, \
static_cast