forked from Karylab-cklius/vllm
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a7e5f7dcf | ||
|
|
15cb8e140d | ||
|
|
f007cceb42 | ||
|
|
0a5069e4e3 | ||
|
|
8ce53a616e |
@@ -81,6 +81,29 @@ steps:
|
||||
- exit_status: -10 # Agent was lost
|
||||
limit: 2
|
||||
|
||||
- label: ":docker: Build Zen CPU image"
|
||||
key: image-build-zen-cpu
|
||||
# Minimal confirmation build: run the zen image build on the generic x86
|
||||
# premerge queue to verify it succeeds. Reuses the shared `-cpu` image as its
|
||||
# base, so it must run after the CPU image build.
|
||||
#
|
||||
# No source_file_dependencies: this step always runs for this validation PR
|
||||
# so the build is exercised regardless of which files changed.
|
||||
depends_on:
|
||||
- image-build-cpu
|
||||
agents:
|
||||
queue: small_cpu_queue_premerge
|
||||
commands:
|
||||
- .buildkite/image_build/image_build_zen_cpu.sh $REGISTRY $REPO $BUILDKITE_COMMIT
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
retry:
|
||||
automatic:
|
||||
- exit_status: -1 # Agent was lost
|
||||
limit: 2
|
||||
- exit_status: -10 # Agent was lost
|
||||
limit: 2
|
||||
|
||||
- label: ":docker: Build HPU image"
|
||||
soft_fail: true
|
||||
depends_on: []
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
#
|
||||
# Build the AMD Zen CPU image (vLLM + zentorch) as a two-step layered build:
|
||||
# 1. a CPU base image (vLLM installed) built from docker/Dockerfile.cpu
|
||||
# 2. docker/Dockerfile.zen --target vllm-zen-test -> zen image on top
|
||||
#
|
||||
# The image is (re)built from source every time, mirroring
|
||||
# .buildkite/scripts/hardware_ci/run-cpu-test.sh. It is not pushed to a registry.
|
||||
#
|
||||
# See docker/Dockerfile.zen for the build workflow this mirrors.
|
||||
set -e
|
||||
|
||||
if [[ $# -lt 3 ]]; then
|
||||
echo "Usage: $0 <registry> <repo> <commit>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REGISTRY=$1
|
||||
REPO=$2
|
||||
BUILDKITE_COMMIT=$3
|
||||
|
||||
# Local image tags (not pushed).
|
||||
BASE_IMAGE="$REGISTRY/$REPO:$BUILDKITE_COMMIT-cpu-base-for-zen"
|
||||
IMAGE="$REGISTRY/$REPO:$BUILDKITE_COMMIT-zen-cpu"
|
||||
|
||||
# ZENTORCH_VERSION is optional; when unset the Dockerfile falls back to
|
||||
# installing zentorch via `vllm[zen]`.
|
||||
ZENTORCH_VERSION=${ZENTORCH_VERSION:-}
|
||||
|
||||
# Step 1: build the CPU base image that Dockerfile.zen layers on.
|
||||
echo "--- :docker: Building CPU base image"
|
||||
docker build --file docker/Dockerfile.cpu \
|
||||
--platform linux/amd64 \
|
||||
--build-arg max_jobs=16 \
|
||||
--build-arg buildkite_commit="$BUILDKITE_COMMIT" \
|
||||
--build-arg VLLM_CPU_X86=true \
|
||||
--tag "$BASE_IMAGE" \
|
||||
--target vllm-openai \
|
||||
--progress plain .
|
||||
|
||||
# Step 2: build the zen test image on top of the CPU base.
|
||||
echo "--- :docker: Building Zen test image"
|
||||
docker build --file docker/Dockerfile.zen \
|
||||
--platform linux/amd64 \
|
||||
--build-arg BASE_IMAGE="$BASE_IMAGE" \
|
||||
${ZENTORCH_VERSION:+--build-arg ZENTORCH_VERSION="$ZENTORCH_VERSION"} \
|
||||
--tag "$IMAGE" \
|
||||
--target vllm-zen-test \
|
||||
--progress plain .
|
||||
@@ -9,7 +9,6 @@
|
||||
#
|
||||
# Build targets:
|
||||
# vllm-openai (default): used for serving deployment
|
||||
# vllm-openai-zen: vLLM from source + zentorch from PyPI via vllm[zen]
|
||||
# vllm-test: used for CI tests
|
||||
# vllm-dev: used for development
|
||||
#
|
||||
@@ -305,19 +304,3 @@ LABEL ai.vllm.build.python-version="${PYTHON_VERSION:-3.12}"
|
||||
COPY examples examples
|
||||
|
||||
ENTRYPOINT ["vllm", "serve"]
|
||||
|
||||
|
||||
######################### ZEN CPU PYPI IMAGE #########################
|
||||
FROM vllm-openai AS vllm-openai-zen
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN if [ "$TARGETARCH" != "amd64" ]; then \
|
||||
echo "ERROR: vllm-openai-amd only supports --platform=linux/amd64"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip install "vllm[zen]"
|
||||
|
||||
ENTRYPOINT ["vllm", "serve"]
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
# This Dockerfile builds AMD Zen CPU images for vLLM with zentorch.
|
||||
# It layers on top of the CPU image produced by docker/Dockerfile.cpu.
|
||||
#
|
||||
# Supported platforms:
|
||||
# - linux/amd64 (Zen2 and newer; Zen5 is the canonical CI target)
|
||||
#
|
||||
# Build targets:
|
||||
# vllm-openai-zen (default): vLLM + zentorch for serving
|
||||
# vllm-zen-test : vllm-openai-zen + test deps for CI
|
||||
#
|
||||
# Build workflow (see .buildkite/image_build/image_build_zen_cpu.sh):
|
||||
# 1. Obtain a CPU base image that already has vLLM installed. In CI this is the
|
||||
# shared `<repo>:<commit>-cpu` image (docker/Dockerfile.cpu --target
|
||||
# vllm-test); either the vllm-test or vllm-openai target works as the base
|
||||
# since both install vLLM into the same venv this stage extends.
|
||||
# 2. docker build -f docker/Dockerfile.zen --build-arg BASE_IMAGE=<base>:<tag> \
|
||||
# --target vllm-zen-test -t <out>:<tag> .
|
||||
|
||||
ARG BASE_IMAGE
|
||||
ARG PYTHON_VERSION=3.12
|
||||
ARG ZENTORCH_VERSION
|
||||
|
||||
######################### ZEN OPENAI IMAGE #########################
|
||||
FROM ${BASE_IMAGE} AS vllm-openai-zen
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG ZENTORCH_VERSION
|
||||
|
||||
RUN if [ "${TARGETARCH}" != "amd64" ]; then \
|
||||
echo "ERROR: Dockerfile.zen requires --platform=linux/amd64"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
WORKDIR /vllm-workspace
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
if [ -n "${ZENTORCH_VERSION}" ]; then \
|
||||
uv pip install "zentorch==${ZENTORCH_VERSION}"; \
|
||||
else \
|
||||
uv pip install "vllm[zen]"; \
|
||||
fi
|
||||
|
||||
LABEL org.opencontainers.image.title="vLLM Zen CPU"
|
||||
LABEL org.opencontainers.image.description="vLLM with zentorch for AMD Zen CPUs"
|
||||
LABEL org.opencontainers.image.vendor="vLLM Project"
|
||||
LABEL org.opencontainers.image.source="https://github.com/vllm-project/vllm"
|
||||
LABEL ai.vllm.build.target-arch="${TARGETARCH}"
|
||||
LABEL ai.vllm.build.python-version="${PYTHON_VERSION}"
|
||||
LABEL ai.vllm.build.zentorch-version="${ZENTORCH_VERSION:-unpinned}"
|
||||
|
||||
ENTRYPOINT ["vllm", "serve"]
|
||||
|
||||
######################### ZEN TEST IMAGE #########################
|
||||
FROM vllm-openai-zen AS vllm-zen-test
|
||||
|
||||
WORKDIR /vllm-workspace
|
||||
|
||||
COPY requirements/test/cuda.in requirements/test/zen.in
|
||||
|
||||
RUN sed -i '/mamba_ssm/d' requirements/test/zen.in && \
|
||||
sed -i 's/^torch==.*/torch==2.11.0/g' requirements/test/zen.in && \
|
||||
sed -i 's/torchaudio.*/torchaudio/g' requirements/test/zen.in && \
|
||||
sed -i 's/torchvision.*/torchvision/g' requirements/test/zen.in && \
|
||||
# zentorch parity: keep sentence-transformers pinned where vllm-test-deps did.
|
||||
sed -i 's/^sentence-transformers.*/sentence-transformers==5.3.0/g' requirements/test/zen.in && \
|
||||
uv pip compile requirements/test/zen.in \
|
||||
-o requirements/test/zen.txt \
|
||||
--index-strategy unsafe-best-match --torch-backend cpu
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip install -r requirements/test/zen.txt
|
||||
|
||||
# Reassert zentorch's torch in case test-deps moved it.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
if [ -n "${ZENTORCH_VERSION}" ]; then \
|
||||
uv pip install --force-reinstall --no-deps "zentorch==${ZENTORCH_VERSION}"; \
|
||||
else \
|
||||
uv pip install --force-reinstall --no-deps "vllm[zen]"; \
|
||||
fi
|
||||
|
||||
ADD ./tests/ ./tests/
|
||||
ADD ./examples/ ./examples/
|
||||
ADD ./benchmarks/ ./benchmarks/
|
||||
ADD ./vllm/collect_env.py .
|
||||
ADD ./docker/ ./docker/
|
||||
ADD ./.buildkite/ ./.buildkite/
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv pip install -e tests/vllm_test_utils
|
||||
|
||||
ENV HF_XET_HIGH_PERFORMANCE=1
|
||||
ENV HF_HUB_DOWNLOAD_TIMEOUT=60
|
||||
|
||||
ENTRYPOINT []
|
||||
@@ -75,7 +75,7 @@ vllm serve <model> \
|
||||
| `max_tracker_size` | no | `64000` | single-tier | Max entries in the lookup tracker. |
|
||||
| `secondary_tiers` | no | `[]` | multi-tier | List of secondary tier configs (see below). |
|
||||
| `offload_prompt_only` | no | `true` | both | If `true`, only prompt (prefill) blocks are offloaded; decode blocks are skipped. |
|
||||
| `self_describing_kv_events` | no | `false` | single-tier | Opt-in. When `true` *and* KV cache events are enabled (`--kv-events-config` with `enable_kv_cache_events`), the connector emits self-describing block-granular `BlockStored`/`BlockRemoved` payloads (constituent block hashes, whole-chunk `token_ids`, per-block `block_size`, parent hash, LoRA + group/cache-spec metadata) instead of the placeholder fallback, so external KV-event consumers can index offloaded blocks. Inert unless events are enabled. Currently rejected by `TieringOffloadingSpec`. Full-attention groups only; sliding-window/SSM groups keep the placeholder fallback. In chunk mode (`block_size` > GPU block size, or `blocks_per_chunk` > 1), overlapping chunks re-announce shared per-block hashes, so consumers must reference-count (deduplicate) repeated store/remove announcements. |
|
||||
| `self_describing_kv_events` | no | `false` | both | Opt-in. When `true` *and* KV cache events are enabled (`--kv-events-config` with `enable_kv_cache_events`), the connector emits self-describing block-granular `BlockStored`/`BlockRemoved` payloads (constituent block hashes, whole-chunk `token_ids`, per-block `block_size`, parent hash, LoRA + group/cache-spec metadata) instead of the placeholder fallback, so external KV-event consumers can index offloaded blocks. Inert unless events are enabled. With `TieringOffloadingSpec`, a CPU promotion is self-describing when a local request observes its primary-tier `HIT` before event translation; otherwise its stored event may retain the placeholder, while a later `HIT` can backfill metadata for removal. Pending-removal/re-promotion races and externally initiated promotions may also produce placeholders, and consumers must ignore removals for unknown hashes. Full-attention groups only; sliding-window/SSM groups keep the placeholder fallback. In chunk mode (`block_size` > GPU block size, or `blocks_per_chunk` > 1), overlapping chunks re-announce shared per-block hashes, so consumers must reference-count (deduplicate) repeated store/remove announcements. |
|
||||
| `spec_module_path` | no | — | both | Python import path for a custom `OffloadingSpec` not in the built-in registry. Required only when `spec_name` is not built-in (advanced). |
|
||||
|
||||
## Secondary Tiers
|
||||
|
||||
@@ -6,6 +6,7 @@ import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from io import BytesIO
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
|
||||
import aiohttp
|
||||
@@ -111,6 +112,34 @@ async def test_fetch_image_base64(
|
||||
assert _image_equals(data_image_sync, data_image_async)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_image_keep_original_mode():
|
||||
"""media_io_kwargs can disable the default RGB conversion."""
|
||||
# RGBA image: opaque black pixel on a fully transparent background
|
||||
rgba_image = Image.new("RGBA", (4, 4), (0, 0, 0, 0))
|
||||
rgba_image.putpixel((2, 2), (0, 0, 0, 255))
|
||||
buffer = BytesIO()
|
||||
rgba_image.save(buffer, "PNG")
|
||||
data_url = (
|
||||
f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode('utf-8')}"
|
||||
)
|
||||
|
||||
# Default behavior: RGBA is composited onto a white background
|
||||
default_image = MediaConnector().fetch_image(data_url)
|
||||
assert default_image.mode == "RGB"
|
||||
assert default_image.getpixel((0, 0)) == (255, 255, 255)
|
||||
assert default_image.getpixel((2, 2)) == (0, 0, 0)
|
||||
|
||||
# image_mode=None via media_io_kwargs: original mode is preserved
|
||||
connector = MediaConnector(media_io_kwargs={"image": {"image_mode": None}})
|
||||
image_sync = connector.fetch_image(data_url)
|
||||
image_async = await connector.fetch_image_async(data_url)
|
||||
for image in (image_sync, image_async):
|
||||
assert image.mode == "RGBA"
|
||||
assert image.getpixel((0, 0)) == (0, 0, 0, 0)
|
||||
assert image.getpixel((2, 2)) == (0, 0, 0, 255)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("image_url", TEST_IMAGE_ASSETS, indirect=True)
|
||||
async def test_fetch_image_local_files(image_url: str):
|
||||
|
||||
@@ -80,6 +80,29 @@ def test_image_media_io_rgba_custom_background(tmp_path):
|
||||
assert green_numpy[0][0][2] == 0 # B
|
||||
|
||||
|
||||
def test_image_media_io_no_mode_conversion(tmp_path):
|
||||
"""image_mode=None skips conversion and preserves the original mode."""
|
||||
# RGBA image: opaque black pixel on a fully transparent background
|
||||
rgba_image = Image.new("RGBA", (10, 10), (0, 0, 0, 0))
|
||||
rgba_image.putpixel((5, 5), (0, 0, 0, 255))
|
||||
test_image_path = tmp_path / "test_rgba.png"
|
||||
rgba_image.save(test_image_path)
|
||||
|
||||
# Default behavior: RGBA is composited onto a white background
|
||||
image_io_default = ImageMediaIO()
|
||||
converted_default = image_io_default.load_file(test_image_path)
|
||||
assert converted_default.media.mode == "RGB"
|
||||
assert converted_default.media.getpixel((0, 0)) == (255, 255, 255)
|
||||
assert converted_default.media.getpixel((5, 5)) == (0, 0, 0)
|
||||
|
||||
# image_mode=None: original mode and alpha channel are preserved
|
||||
image_io_keep = ImageMediaIO(image_mode=None)
|
||||
converted_keep = image_io_keep.load_file(test_image_path)
|
||||
assert converted_keep.media.mode == "RGBA"
|
||||
assert converted_keep.media.getpixel((0, 0)) == (0, 0, 0, 0)
|
||||
assert converted_keep.media.getpixel((5, 5)) == (0, 0, 0, 255)
|
||||
|
||||
|
||||
def test_image_media_io_rgba_background_color_validation():
|
||||
"""Test that invalid rgba_background_color values are properly rejected."""
|
||||
|
||||
|
||||
@@ -165,6 +165,38 @@ def test_modelopt_mixed_precision_does_not_quantize_unlisted_fused_sibling():
|
||||
assert config._resolve_quant_algo("model.layers.0.linear_attn.in_proj_ba") is None
|
||||
|
||||
|
||||
def test_modelopt_mixed_precision_composes_gemma4_mappers():
|
||||
from vllm.model_executor.models.gemma4 import Gemma4ForCausalLM
|
||||
from vllm.model_executor.models.gemma4_mm import (
|
||||
Gemma4ForConditionalGeneration,
|
||||
)
|
||||
|
||||
config = _mixed_precision_config(
|
||||
{
|
||||
"model.language_model.layers.0.experts": {
|
||||
"quant_algo": "NVFP4",
|
||||
"group_size": 16,
|
||||
},
|
||||
"model.language_model.layers.1.moe.experts.gate_up_proj": {
|
||||
"quant_algo": "NVFP4",
|
||||
"group_size": 16,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
config.apply_vllm_mapper(
|
||||
Gemma4ForConditionalGeneration.hf_to_vllm_mapper.get_unstacked_mapper()
|
||||
)
|
||||
config.apply_vllm_mapper(Gemma4ForCausalLM.hf_to_vllm_mapper.get_unstacked_mapper())
|
||||
|
||||
expected_prefix = "language_model.model.layers.0.moe.experts"
|
||||
assert set(config.quantized_layers) == {
|
||||
expected_prefix,
|
||||
"language_model.model.layers.1.moe.gate_up_proj",
|
||||
}
|
||||
assert config._resolve_quant_algo(expected_prefix) == "NVFP4"
|
||||
|
||||
|
||||
def test_modelopt_mixed_precision_infers_fused_gate_up_projection():
|
||||
from vllm.model_executor.layers.linear import LinearBase
|
||||
|
||||
|
||||
@@ -7,7 +7,13 @@ import torch
|
||||
|
||||
from tests.v1.kv_connector.unit.utils import create_vllm_config
|
||||
from vllm.config import KVEventsConfig, KVTransferConfig
|
||||
from vllm.distributed.kv_events import MEDIUM_CPU, MEDIUM_FS, BlockRemoved, BlockStored
|
||||
from vllm.distributed.kv_events import (
|
||||
MEDIUM_CPU,
|
||||
MEDIUM_FS,
|
||||
MEDIUM_OBJ,
|
||||
BlockRemoved,
|
||||
BlockStored,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
|
||||
build_offloading_config,
|
||||
)
|
||||
@@ -62,8 +68,9 @@ def _wire_hash(block_hash: BlockHash):
|
||||
return maybe_convert_block_hash(block_hash)
|
||||
|
||||
|
||||
def _request(*, block_hashes: list[BlockHash], token_count: int):
|
||||
def _request(*, block_hashes: list[BlockHash], token_count: int, req_id: str = "req"):
|
||||
req = MagicMock()
|
||||
req.request_id = req_id
|
||||
req.block_hashes = block_hashes
|
||||
req.all_token_ids = list(range(1, token_count + 1))
|
||||
req.lora_request = None
|
||||
@@ -104,10 +111,32 @@ def _record_chunks(
|
||||
return keys
|
||||
|
||||
|
||||
def _record_lookup_chunks(
|
||||
tracker: OffloadingEventsTracker,
|
||||
req,
|
||||
group_config: GroupOffloadConfig,
|
||||
num_chunks: int,
|
||||
) -> list[OffloadKey]:
|
||||
keys: list[OffloadKey] = []
|
||||
hbf = group_config.hashes_per_chunk
|
||||
for chunk_idx in range(num_chunks):
|
||||
tail_hash = req.block_hashes[(chunk_idx + 1) * hbf - 1]
|
||||
assert tail_hash is not None
|
||||
key = make_offload_key(tail_hash, group_config.group_idx)
|
||||
tracker.record_lookup(
|
||||
req,
|
||||
group_config,
|
||||
chunk_idx,
|
||||
key,
|
||||
)
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
|
||||
def _stored_event(
|
||||
keys: list[OffloadKey],
|
||||
locality: Locality | None = None,
|
||||
medium: str = _CPU_MEDIUM,
|
||||
locality: Locality | None = None,
|
||||
) -> OffloadingEvent:
|
||||
return OffloadingEvent(
|
||||
keys=keys,
|
||||
@@ -119,8 +148,8 @@ def _stored_event(
|
||||
|
||||
def _removed_event(
|
||||
keys: list[OffloadKey],
|
||||
locality: Locality | None = None,
|
||||
medium: str = _CPU_MEDIUM,
|
||||
locality: Locality | None = None,
|
||||
) -> OffloadingEvent:
|
||||
return OffloadingEvent(
|
||||
keys=keys,
|
||||
@@ -130,6 +159,21 @@ def _removed_event(
|
||||
)
|
||||
|
||||
|
||||
def _lookup_chunk() -> tuple[
|
||||
OffloadingEventsTracker, MagicMock, GroupOffloadConfig, OffloadKey
|
||||
]:
|
||||
tracker = _tracker()
|
||||
req = _request(block_hashes=[_hash(0)], token_count=4)
|
||||
group_config = _group_config()
|
||||
key = _record_lookup_chunks(
|
||||
tracker,
|
||||
req,
|
||||
group_config,
|
||||
num_chunks=1,
|
||||
)[0]
|
||||
return tracker, req, group_config, key
|
||||
|
||||
|
||||
def test_take_events_forwards_locality_to_rich_store():
|
||||
tracker = _tracker()
|
||||
req = _request(block_hashes=[_hash(0)], token_count=4)
|
||||
@@ -220,18 +264,37 @@ def test_take_events_publishes_routable_block_stored():
|
||||
assert len(tracker._pending_event_metadata) == 6
|
||||
|
||||
|
||||
def test_take_events_factor_gt_1_chunk_store_and_remove():
|
||||
def test_promotion_emits_full_cpu_stored_event():
|
||||
tracker, _, _, key = _lookup_chunk()
|
||||
|
||||
[event] = tracker.take_events([_stored_event([key])])
|
||||
|
||||
assert isinstance(event, BlockStored)
|
||||
assert event.medium == MEDIUM_CPU
|
||||
assert event.block_hashes == [_wire_hash(_hash(0))]
|
||||
assert event.parent_block_hash is None
|
||||
assert event.token_ids == [1, 2, 3, 4]
|
||||
assert event.block_size == 4
|
||||
assert event.lora_id is None
|
||||
assert event.lora_name is None
|
||||
assert event.extra_keys is None
|
||||
assert event.group_idx == 0
|
||||
assert event.kv_cache_spec_kind == KVCacheSpecKind.FULL_ATTENTION.value
|
||||
assert event.kv_cache_spec_sliding_window is None
|
||||
|
||||
|
||||
def test_lookup_promotion_factor_gt_1_store_and_remove():
|
||||
block_size = 4
|
||||
blocks_per_chunk = 3
|
||||
blocks_per_chunk = 2
|
||||
tracker = _tracker()
|
||||
group_config = _group_config(
|
||||
block_size=block_size, blocks_per_chunk=blocks_per_chunk
|
||||
)
|
||||
req = _request(
|
||||
block_hashes=[_hash(i) for i in range(6)],
|
||||
block_hashes=[_hash(i) for i in range(4)],
|
||||
token_count=block_size * blocks_per_chunk * 2,
|
||||
)
|
||||
keys = _record_chunks(tracker, req, group_config, num_chunks=2)
|
||||
keys = _record_lookup_chunks(tracker, req, group_config, num_chunks=2)
|
||||
|
||||
stored = list(tracker.take_events([_stored_event(keys)]))
|
||||
assert len(stored) == 2
|
||||
@@ -293,6 +356,7 @@ def test_take_events_opt_out_keeps_placeholders():
|
||||
group_config = _group_config()
|
||||
req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
|
||||
keys = _record_chunks(tracker, req, group_config, num_chunks=3)
|
||||
_record_lookup_chunks(tracker, req, group_config, num_chunks=3)
|
||||
|
||||
assert not tracker.self_describing_enabled
|
||||
assert not tracker._pending_event_metadata
|
||||
@@ -315,11 +379,21 @@ def test_take_events_opt_out_keeps_placeholders():
|
||||
assert len(events[3].block_hashes) == 3
|
||||
|
||||
|
||||
def test_record_store_skips_sliding_window_group():
|
||||
@pytest.mark.parametrize(
|
||||
"sliding_window_size_in_chunks",
|
||||
[1, 2],
|
||||
ids=["ssm", "sliding-window"],
|
||||
)
|
||||
def test_event_metadata_skips_non_full_attention_group(
|
||||
sliding_window_size_in_chunks: int,
|
||||
):
|
||||
tracker = _tracker()
|
||||
group_config = _group_config(sliding_window_size_in_chunks=2)
|
||||
group_config = _group_config(
|
||||
sliding_window_size_in_chunks=sliding_window_size_in_chunks
|
||||
)
|
||||
req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
|
||||
keys = _record_chunks(tracker, req, group_config, num_chunks=3)
|
||||
_record_lookup_chunks(tracker, req, group_config, num_chunks=3)
|
||||
|
||||
assert not tracker._pending_event_metadata
|
||||
|
||||
@@ -329,6 +403,57 @@ def test_record_store_skips_sliding_window_group():
|
||||
assert events[0].block_size == 0
|
||||
|
||||
|
||||
def test_pending_cpu_removal_consumes_hit_backfill_until_next_hit():
|
||||
tracker = _tracker()
|
||||
block_hashes = [_hash(0), _hash(1)]
|
||||
req = _request(block_hashes=block_hashes, token_count=8)
|
||||
group_config = _group_config(blocks_per_chunk=2)
|
||||
key = _record_chunks(tracker, req, group_config, num_chunks=1)[0]
|
||||
confirmed_meta = tracker._pending_event_metadata[key]
|
||||
lookup_req = _request(
|
||||
block_hashes=block_hashes,
|
||||
token_count=8,
|
||||
req_id="new-request",
|
||||
)
|
||||
|
||||
tracker.record_lookup(
|
||||
lookup_req,
|
||||
group_config,
|
||||
0,
|
||||
key,
|
||||
)
|
||||
assert tracker._pending_event_metadata[key] is confirmed_meta
|
||||
|
||||
removed = list(tracker.take_events([_removed_event([key])]))
|
||||
assert len(removed) == 1
|
||||
assert removed[0].block_hashes == [
|
||||
_wire_hash(_hash(0)),
|
||||
_wire_hash(_hash(1)),
|
||||
]
|
||||
|
||||
stored = list(tracker.take_events([_stored_event([key])]))
|
||||
assert len(stored) == 1
|
||||
assert stored[0].block_size == 0
|
||||
assert stored[0].token_ids == []
|
||||
|
||||
tracker.record_lookup(lookup_req, group_config, 0, key)
|
||||
removed = list(tracker.take_events([_removed_event([key])]))
|
||||
assert removed[0].block_hashes == [
|
||||
_wire_hash(_hash(0)),
|
||||
_wire_hash(_hash(1)),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("medium", [MEDIUM_FS, MEDIUM_OBJ])
|
||||
def test_secondary_stored_event_does_not_mutate_cpu_metadata(medium: str):
|
||||
tracker, _, _, key = _lookup_chunk()
|
||||
expected_metadata = dict(tracker._pending_event_metadata)
|
||||
|
||||
stored = list(tracker.take_events([_stored_event([key], medium)]))
|
||||
assert stored[0].token_ids == [1, 2, 3, 4]
|
||||
assert tracker._pending_event_metadata == expected_metadata
|
||||
|
||||
|
||||
def test_take_events_groups_removed_hashes_by_kv_group():
|
||||
tracker = _tracker()
|
||||
group0_config = _group_config(group_idx=0, blocks_per_chunk=2)
|
||||
@@ -378,7 +503,7 @@ def test_reset_cache_clears_side_table():
|
||||
tracker = _tracker()
|
||||
group_config = _group_config()
|
||||
req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
|
||||
_record_chunks(tracker, req, group_config, num_chunks=3)
|
||||
_record_lookup_chunks(tracker, req, group_config, num_chunks=3)
|
||||
|
||||
assert tracker._pending_event_metadata
|
||||
|
||||
@@ -387,7 +512,7 @@ def test_reset_cache_clears_side_table():
|
||||
assert not tracker._pending_event_metadata
|
||||
|
||||
|
||||
def test_tiering_rejects_self_describing_kv_events():
|
||||
def test_tiering_accepts_self_describing_kv_events():
|
||||
vllm_config = create_vllm_config(
|
||||
block_size=4,
|
||||
max_num_batched_tokens=16,
|
||||
@@ -423,5 +548,9 @@ def test_tiering_rejects_self_describing_kv_events():
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="TieringOffloadingSpec"):
|
||||
TieringOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
|
||||
spec = TieringOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
|
||||
tracker = OffloadingEventsTracker(spec.kv_events_config)
|
||||
|
||||
assert spec.kv_events_config.enable_kv_cache_events
|
||||
assert spec.kv_events_config.self_describing_kv_events
|
||||
assert tracker.self_describing_enabled
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -11,6 +11,7 @@ from tests.v1.kv_connector.unit.offloading_connector.utils import (
|
||||
to_keys,
|
||||
)
|
||||
from tests.v1.kv_connector.unit.utils import EOS_TOKEN_ID
|
||||
from vllm.distributed.kv_events import MEDIUM_CPU, BlockRemoved, BlockStored
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
|
||||
OffloadingConnectorStats,
|
||||
_ConnectorMetricName,
|
||||
@@ -19,6 +20,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
|
||||
OffloadingConnectorScheduler,
|
||||
RequestOffloadState,
|
||||
)
|
||||
from vllm.v1.core.kv_cache_utils import BlockHash
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
KVCacheGroupSpec,
|
||||
@@ -26,6 +28,7 @@ from vllm.v1.kv_cache_interface import (
|
||||
)
|
||||
from vllm.v1.kv_offload.base import (
|
||||
LookupResult,
|
||||
OffloadingEvent,
|
||||
OffloadingManager,
|
||||
OffloadPolicy,
|
||||
ReqContext,
|
||||
@@ -143,6 +146,159 @@ def test_scheduler_reports_lookup_async_delay_on_resolve(request_runner):
|
||||
assert reduced[f"{_ConnectorMetricName.LOOKUP_ASYNC_DELAY}_sum"] > 0
|
||||
|
||||
|
||||
def test_max_offload_tokens_zero_does_not_record_pending_lookups(request_runner):
|
||||
runner = request_runner(
|
||||
block_size=4,
|
||||
num_gpu_blocks=10,
|
||||
async_scheduling=False,
|
||||
)
|
||||
runner.manager.lookup.return_value = LookupResult.RETRY
|
||||
runner.manager.take_events.return_value = []
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output(keys)
|
||||
)
|
||||
|
||||
runner.new_request(
|
||||
token_ids=[1] * 12,
|
||||
kv_transfer_params={"max_offload_tokens": 0},
|
||||
)
|
||||
runner.run(decoded_tokens=[])
|
||||
|
||||
tracker = runner.connector_scheduler._events_tracker
|
||||
assert runner.manager.lookup.call_count == 3
|
||||
assert not tracker._pending_event_metadata
|
||||
assert list(runner.connector_scheduler.take_events()) == []
|
||||
|
||||
runner.manager.lookup.return_value = LookupResult.MISS
|
||||
runner.run(decoded_tokens=[EOS_TOKEN_ID])
|
||||
|
||||
assert not tracker._pending_event_metadata
|
||||
assert list(runner.connector_scheduler.take_events()) == []
|
||||
|
||||
|
||||
def test_abort_before_hit_uses_placeholder_then_later_hit_heals_removal(
|
||||
request_runner,
|
||||
):
|
||||
runner = request_runner(
|
||||
block_size=4,
|
||||
num_gpu_blocks=10,
|
||||
async_scheduling=False,
|
||||
blocks_per_chunk=2,
|
||||
)
|
||||
raw_events: list[OffloadingEvent] = []
|
||||
|
||||
def take_raw_events():
|
||||
yield from raw_events
|
||||
raw_events.clear()
|
||||
|
||||
runner.manager.lookup.return_value = LookupResult.RETRY
|
||||
runner.manager.take_events.side_effect = take_raw_events
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output([])
|
||||
)
|
||||
|
||||
runner.new_request(token_ids=[1] * 8)
|
||||
runner.run(decoded_tokens=[])
|
||||
|
||||
tracker = runner.connector_scheduler._events_tracker
|
||||
assert not tracker._pending_event_metadata
|
||||
key = runner.manager.lookup.call_args.args[0]
|
||||
req_id = str(runner.req_id)
|
||||
req_status = runner.connector_scheduler._req_status[req_id]
|
||||
|
||||
runner.scheduler.finish_requests((req_id,), RequestStatus.FINISHED_ABORTED)
|
||||
|
||||
assert not tracker._pending_event_metadata
|
||||
|
||||
raw_events.append(OffloadingEvent(keys=[key], medium=MEDIUM_CPU, removed=False))
|
||||
events = list(runner.connector_scheduler.take_events())
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], BlockStored)
|
||||
assert events[0].block_size == 0
|
||||
assert events[0].token_ids == []
|
||||
|
||||
runner.manager.lookup.return_value = LookupResult.HIT
|
||||
group_config = runner.connector_scheduler.config.kv_group_configs[0]
|
||||
assert (
|
||||
runner.connector_scheduler._maximal_prefix_lookup(
|
||||
[key],
|
||||
req_status.req_context,
|
||||
req_status.req,
|
||||
group_config,
|
||||
0,
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert key in tracker._pending_event_metadata
|
||||
|
||||
raw_events.append(OffloadingEvent(keys=[key], medium=MEDIUM_CPU, removed=True))
|
||||
[event] = runner.connector_scheduler.take_events()
|
||||
assert isinstance(event, BlockRemoved)
|
||||
assert event.medium == MEDIUM_CPU
|
||||
assert len(event.block_hashes) == 2
|
||||
assert key not in tracker._pending_event_metadata
|
||||
|
||||
|
||||
@pytest.mark.parametrize("blocks_per_chunk", [1, 2])
|
||||
def test_promotion_hit_precedes_stored_event_translation(
|
||||
request_runner,
|
||||
blocks_per_chunk: int,
|
||||
):
|
||||
runner = request_runner(
|
||||
block_size=4,
|
||||
num_gpu_blocks=10,
|
||||
async_scheduling=False,
|
||||
blocks_per_chunk=blocks_per_chunk,
|
||||
)
|
||||
token_ids = [1] * 4 * blocks_per_chunk
|
||||
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output(keys)
|
||||
)
|
||||
runner.new_request(token_ids=token_ids)
|
||||
runner.run(
|
||||
decoded_tokens=[EOS_TOKEN_ID],
|
||||
expected_stored=tuple(range(blocks_per_chunk)),
|
||||
)
|
||||
runner.scheduler.reset_prefix_cache()
|
||||
runner.connector_scheduler._events_tracker.reset()
|
||||
|
||||
raw_events: list[OffloadingEvent] = []
|
||||
|
||||
def lookup(key, req_context):
|
||||
raw_events.append(OffloadingEvent(keys=[key], medium=MEDIUM_CPU, removed=False))
|
||||
return LookupResult.HIT
|
||||
|
||||
def take_raw_events():
|
||||
yield from raw_events
|
||||
raw_events.clear()
|
||||
|
||||
runner.manager.lookup.side_effect = lookup
|
||||
runner.manager.take_events.side_effect = take_raw_events
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output([])
|
||||
)
|
||||
publisher = MagicMock()
|
||||
runner.scheduler.kv_event_publisher = publisher
|
||||
|
||||
runner.new_request(token_ids=token_ids)
|
||||
runner.run(
|
||||
decoded_tokens=[],
|
||||
expected_loaded=tuple(range(blocks_per_chunk)),
|
||||
)
|
||||
|
||||
events = [
|
||||
event
|
||||
for publish_call in publisher.publish.call_args_list
|
||||
for event in publish_call.args[0].events
|
||||
if isinstance(event, BlockStored) and event.medium == MEDIUM_CPU
|
||||
]
|
||||
assert len(events) == 1
|
||||
assert len(events[0].block_hashes) == blocks_per_chunk
|
||||
assert events[0].block_size == 4
|
||||
assert events[0].token_ids == token_ids
|
||||
|
||||
|
||||
@pytest.mark.parametrize("async_scheduling", [True, False])
|
||||
def test_offloading_connector(request_runner, async_scheduling: bool):
|
||||
block_size = 4
|
||||
@@ -241,7 +397,7 @@ def test_offloading_connector(request_runner, async_scheduling: bool):
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output([])
|
||||
)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_loaded=(0, 1, 2))
|
||||
|
||||
# single block lookup with a hit in a middle block
|
||||
@@ -249,7 +405,7 @@ def test_offloading_connector(request_runner, async_scheduling: bool):
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output([])
|
||||
)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_loaded=(3, 4, 5))
|
||||
|
||||
|
||||
@@ -307,7 +463,7 @@ def test_request_preemption(request_runner, async_scheduling: bool):
|
||||
|
||||
# request should now return from preemption
|
||||
# re-load [0, ..., 8] from the CPU and store [9, 10, 11]
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 3
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 3
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output(keys)
|
||||
)
|
||||
@@ -427,7 +583,7 @@ def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling:
|
||||
# start a request to load the first block, but don't complete
|
||||
runner.scheduler.reset_prefix_cache()
|
||||
runner.new_request(token_ids=[0] * tokens_per_chunk)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.run(
|
||||
decoded_tokens=[],
|
||||
complete_transfers=False,
|
||||
@@ -439,7 +595,7 @@ def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling:
|
||||
|
||||
# start a new request to load the same first block
|
||||
runner.new_request(token_ids=[0] * tokens_per_chunk)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.run(
|
||||
decoded_tokens=[],
|
||||
complete_transfers=False,
|
||||
@@ -491,7 +647,7 @@ def test_abort_loading_requests(request_runner, async_scheduling: bool):
|
||||
# start a request to load the first block, but don't complete
|
||||
runner.scheduler.reset_prefix_cache()
|
||||
runner.new_request(token_ids=[0] * tokens_per_chunk)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.run(
|
||||
decoded_tokens=[],
|
||||
complete_transfers=False,
|
||||
@@ -793,73 +949,144 @@ def _make_scheduler_with_lookup(
|
||||
|
||||
scheduler = object.__new__(OffloadingConnectorScheduler)
|
||||
scheduler.manager = manager
|
||||
scheduler._events_tracker = MagicMock()
|
||||
return scheduler
|
||||
|
||||
|
||||
_EMPTY_REQ_CTX = ReqContext(req_id="")
|
||||
_LOOKUP_REQ = MagicMock()
|
||||
_LOOKUP_REQ.request_id = "req"
|
||||
_LOOKUP_GROUP_CONFIG = MagicMock()
|
||||
|
||||
|
||||
def _maximal_lookup(sched, keys, start_chunk_idx: int = 0):
|
||||
return sched._maximal_prefix_lookup(
|
||||
keys,
|
||||
_EMPTY_REQ_CTX,
|
||||
_LOOKUP_REQ,
|
||||
_LOOKUP_GROUP_CONFIG,
|
||||
start_chunk_idx,
|
||||
)
|
||||
|
||||
|
||||
class TestMaximalPrefixLookup:
|
||||
def test_all_hit(self):
|
||||
sched = _make_scheduler_with_lookup({1: LookupResult.HIT, 2: LookupResult.HIT})
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) == 2
|
||||
assert _maximal_lookup(sched, to_keys([1, 2])) == 2
|
||||
|
||||
def test_records_absolute_chunk_indices(self):
|
||||
keys = to_keys([1, 2])
|
||||
sched = _make_scheduler_with_lookup({1: LookupResult.HIT, 2: LookupResult.HIT})
|
||||
|
||||
assert _maximal_lookup(sched, keys, start_chunk_idx=3) == 2
|
||||
assert sched._events_tracker.record_lookup.call_args_list == [
|
||||
call(
|
||||
_LOOKUP_REQ,
|
||||
_LOOKUP_GROUP_CONFIG,
|
||||
3,
|
||||
keys[0],
|
||||
),
|
||||
call(
|
||||
_LOOKUP_REQ,
|
||||
_LOOKUP_GROUP_CONFIG,
|
||||
4,
|
||||
keys[1],
|
||||
),
|
||||
]
|
||||
|
||||
def test_all_miss(self):
|
||||
sched = _make_scheduler_with_lookup({})
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) == 0
|
||||
assert _maximal_lookup(sched, to_keys([1, 2])) == 0
|
||||
sched._events_tracker.record_lookup.assert_not_called()
|
||||
|
||||
def test_partial_prefix(self):
|
||||
sched = _make_scheduler_with_lookup({1: LookupResult.HIT, 2: LookupResult.HIT})
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2, 3]), _EMPTY_REQ_CTX) == 2
|
||||
assert _maximal_lookup(sched, to_keys([1, 2, 3])) == 2
|
||||
|
||||
def test_miss_then_hit(self):
|
||||
sched = _make_scheduler_with_lookup({2: LookupResult.HIT})
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) == 0
|
||||
assert _maximal_lookup(sched, to_keys([1, 2])) == 0
|
||||
|
||||
def test_single_hit(self):
|
||||
sched = _make_scheduler_with_lookup({1: LookupResult.HIT})
|
||||
assert sched._maximal_prefix_lookup(to_keys([1]), _EMPTY_REQ_CTX) == 1
|
||||
assert _maximal_lookup(sched, to_keys([1])) == 1
|
||||
|
||||
def test_empty(self):
|
||||
sched = _make_scheduler_with_lookup({})
|
||||
assert sched._maximal_prefix_lookup([], _EMPTY_REQ_CTX) == 0
|
||||
assert _maximal_lookup(sched, []) == 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pending_result",
|
||||
[LookupResult.RETRY, LookupResult.HIT_PENDING],
|
||||
)
|
||||
def test_pending_result_is_not_recorded(
|
||||
self,
|
||||
pending_result: LookupResult,
|
||||
):
|
||||
sched = _make_scheduler_with_lookup({1: pending_result})
|
||||
|
||||
assert _maximal_lookup(sched, to_keys([1])) is None
|
||||
sched._events_tracker.record_lookup.assert_not_called()
|
||||
|
||||
def test_retry_defers(self):
|
||||
keys = to_keys([1, 2])
|
||||
sched = _make_scheduler_with_lookup(
|
||||
{1: LookupResult.RETRY, 2: LookupResult.HIT}
|
||||
)
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) is None
|
||||
assert _maximal_lookup(sched, keys) is None
|
||||
assert sched.manager.lookup.call_count == 2
|
||||
sched._events_tracker.record_lookup.assert_called_once_with(
|
||||
_LOOKUP_REQ,
|
||||
_LOOKUP_GROUP_CONFIG,
|
||||
1,
|
||||
keys[1],
|
||||
)
|
||||
|
||||
def test_retry_after_hit_defers(self):
|
||||
keys = to_keys([1, 2])
|
||||
sched = _make_scheduler_with_lookup(
|
||||
{1: LookupResult.HIT, 2: LookupResult.RETRY}
|
||||
)
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) is None
|
||||
assert _maximal_lookup(sched, keys) is None
|
||||
sched._events_tracker.record_lookup.assert_called_once_with(
|
||||
_LOOKUP_REQ,
|
||||
_LOOKUP_GROUP_CONFIG,
|
||||
0,
|
||||
keys[0],
|
||||
)
|
||||
|
||||
def test_hit_pending_defers(self):
|
||||
keys = to_keys([1, 2])
|
||||
sched = _make_scheduler_with_lookup(
|
||||
{1: LookupResult.HIT_PENDING, 2: LookupResult.HIT}
|
||||
)
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) is None
|
||||
assert _maximal_lookup(sched, keys) is None
|
||||
assert sched.manager.lookup.call_count == 2
|
||||
sched._events_tracker.record_lookup.assert_called_once_with(
|
||||
_LOOKUP_REQ,
|
||||
_LOOKUP_GROUP_CONFIG,
|
||||
1,
|
||||
keys[1],
|
||||
)
|
||||
|
||||
def test_hit_pending_does_not_stop_scan(self):
|
||||
"""HIT_PENDING defers but does not break — scan continues until miss."""
|
||||
sched = _make_scheduler_with_lookup(
|
||||
{1: LookupResult.HIT_PENDING, 2: LookupResult.MISS, 3: LookupResult.HIT}
|
||||
)
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2, 3]), _EMPTY_REQ_CTX) is None
|
||||
assert _maximal_lookup(sched, to_keys([1, 2, 3])) is None
|
||||
assert sched.manager.lookup.call_count == 2
|
||||
sched._events_tracker.record_lookup.assert_not_called()
|
||||
|
||||
def test_retry_stops_at_miss(self):
|
||||
"""RETRY is treated as hit for iteration, but miss stops the scan."""
|
||||
sched = _make_scheduler_with_lookup(
|
||||
{1: LookupResult.RETRY, 2: LookupResult.MISS, 3: LookupResult.HIT}
|
||||
)
|
||||
assert sched._maximal_prefix_lookup(to_keys([1, 2, 3]), _EMPTY_REQ_CTX) is None
|
||||
assert _maximal_lookup(sched, to_keys([1, 2, 3])) is None
|
||||
# lookup should have been called for blocks 1 and 2 (stops at miss)
|
||||
assert sched.manager.lookup.call_count == 2
|
||||
sched._events_tracker.record_lookup.assert_not_called()
|
||||
|
||||
|
||||
class TestSlidingWindowLookup:
|
||||
@@ -1011,7 +1238,7 @@ def test_request_level_policy_stores_all_blocks(request_runner, async_scheduling
|
||||
|
||||
# New request with 2 offloaded chunks; first matches what's in CPU.
|
||||
runner.new_request(token_ids=[0] * tokens_per_chunk * 2)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output(keys)
|
||||
)
|
||||
@@ -1042,7 +1269,7 @@ def test_loads_do_not_populate_fence_index(request_runner):
|
||||
async_scheduling=False,
|
||||
)
|
||||
runner.new_request(token_ids=[0] * 12)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.run(decoded_tokens=[], complete_transfers=False)
|
||||
assert runner.connector_scheduler._block_id_to_pending_jobs == {}
|
||||
|
||||
@@ -1088,7 +1315,7 @@ def test_fence_at_update_state_after_alloc(request_runner):
|
||||
|
||||
runner.scheduler.reset_prefix_cache()
|
||||
runner.new_request(token_ids=[0] * 4)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output([])
|
||||
)
|
||||
@@ -1139,7 +1366,7 @@ def test_fence_at_build_store_jobs(request_runner):
|
||||
|
||||
runner.scheduler.reset_prefix_cache()
|
||||
runner.new_request(token_ids=[1] * 4)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 0
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 0
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output([])
|
||||
)
|
||||
@@ -1365,7 +1592,7 @@ def test_reset_cache(request_runner, async_scheduling: bool):
|
||||
# Leave the load in-flight so that reset_cache must flush it.
|
||||
runner.scheduler.reset_prefix_cache()
|
||||
runner.new_request(token_ids=[0] * tokens_per_chunk)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output([])
|
||||
)
|
||||
@@ -1553,9 +1780,7 @@ def test_async_preempt_readmit_before_transfer_output_is_deferred(request_runner
|
||||
# preemption batch's ModelRunnerOutput is consumed by update_from_output().
|
||||
free_block_queue.num_free_blocks = num_free_blocks_empty
|
||||
assert runner.scheduler.reset_prefix_cache()
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: len(
|
||||
key
|
||||
)
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: len(keys)
|
||||
|
||||
readmit_output = runner.scheduler.schedule()
|
||||
|
||||
@@ -1669,7 +1894,7 @@ def test_swa_alignment_skip(request_runner, async_scheduling: bool):
|
||||
runner.scheduler.reset_prefix_cache()
|
||||
runner.new_request(token_ids=[0] * num_tokens + [1])
|
||||
runner.manager.lookup.return_value = LookupResult.HIT
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 2
|
||||
runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 2
|
||||
runner.run(
|
||||
decoded_tokens=[EOS_TOKEN_ID],
|
||||
# Group 0: full prefix lookup hits 2 offloaded chunks
|
||||
@@ -1840,6 +2065,13 @@ class TestEagle:
|
||||
req.request_id = "test-req"
|
||||
req.num_tokens = num_tokens
|
||||
req.kv_transfer_params = None
|
||||
num_hash_blocks = max(
|
||||
len(hashes) * scheduler.config.kv_group_configs[idx].hashes_per_chunk
|
||||
for idx, hashes in enumerate(offload_keys_per_group)
|
||||
)
|
||||
req.block_hashes = [BlockHash(str(i).encode()) for i in range(num_hash_blocks)]
|
||||
req.all_token_ids = list(range(num_tokens))
|
||||
req.lora_request = None
|
||||
|
||||
state = RequestOffloadState(
|
||||
config=scheduler.config,
|
||||
|
||||
@@ -61,9 +61,9 @@ def get_offloading_event_group_spec(
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _OffloadEventMetadata:
|
||||
"""BlockStored payload snapshot for one OffloadKey, captured at store
|
||||
time and kept until the matching eviction event. ``medium`` is forwarded
|
||||
from the OffloadingEvent."""
|
||||
"""BlockStored payload snapshot for one OffloadKey, captured while the
|
||||
Request is available and kept until the matching eviction event. ``medium``
|
||||
is forwarded from the OffloadingEvent."""
|
||||
|
||||
# The chunk's constituent block hashes; the last one is the OffloadKey.
|
||||
block_hashes: tuple[BlockHash, ...]
|
||||
@@ -81,10 +81,11 @@ class _OffloadEventMetadata:
|
||||
class OffloadingEventsTracker:
|
||||
"""Tracks offloaded chunks' KV event payloads from store to eviction.
|
||||
|
||||
The scheduler calls :meth:`record_store` from ``_build_store_jobs``
|
||||
while the ``Request`` is available, and routes the manager's raw
|
||||
:class:`OffloadingEvent` stream through :meth:`take_events`. All state
|
||||
is bounded by the CPU pool capacity and cleared by :meth:`reset`.
|
||||
The scheduler calls :meth:`record_store` from ``_build_store_jobs`` and
|
||||
:meth:`record_lookup` for ready primary-tier hits while the ``Request`` is
|
||||
available. Deferred and missing lookups add no state. Under the connector's
|
||||
supported success-only transfer model, entries follow primary allocations
|
||||
until CPU removal translation or :meth:`reset`.
|
||||
"""
|
||||
|
||||
def __init__(self, config: OffloadingKVEventsConfig):
|
||||
@@ -93,8 +94,7 @@ class OffloadingEventsTracker:
|
||||
config.enable_kv_cache_events and config.self_describing_kv_events
|
||||
)
|
||||
|
||||
# OffloadKey -> payload snapshot, kept until the eviction event so
|
||||
# BlockRemoved can fan out. Bounded: one entry per offloaded chunk.
|
||||
# OffloadKey -> payload snapshot, kept until CPU removal or reset.
|
||||
self._pending_event_metadata: dict[OffloadKey, _OffloadEventMetadata] = {}
|
||||
|
||||
def record_store(
|
||||
@@ -116,6 +116,23 @@ class OffloadingEventsTracker:
|
||||
meta = self._build_event_metadata(req, group_config, chunk_idx)
|
||||
self._pending_event_metadata[offload_key] = meta
|
||||
|
||||
def record_lookup(
|
||||
self,
|
||||
req: Request,
|
||||
group_config: "GroupOffloadConfig",
|
||||
chunk_idx: int,
|
||||
offload_key: OffloadKey,
|
||||
) -> None:
|
||||
"""Snapshot metadata for a ready primary-tier lookup hit."""
|
||||
if not self.self_describing_enabled:
|
||||
return
|
||||
if group_config.sliding_window_size_in_chunks is not None:
|
||||
return
|
||||
if offload_key not in self._pending_event_metadata:
|
||||
self._pending_event_metadata[offload_key] = self._build_event_metadata(
|
||||
req, group_config, chunk_idx
|
||||
)
|
||||
|
||||
def take_events(self, events: Iterable[OffloadingEvent]) -> Iterable[KVCacheEvent]:
|
||||
"""Translate raw OffloadingEvents into self-describing KV events.
|
||||
|
||||
@@ -165,7 +182,7 @@ class OffloadingEventsTracker:
|
||||
assert len(chunk_hashes) == hbf
|
||||
|
||||
if group_config.sliding_window_size_in_chunks is not None:
|
||||
# record_store filters these out before calling this helper.
|
||||
# The recording methods filter these out before calling this helper.
|
||||
raise AssertionError("self-describing events only support full attention")
|
||||
|
||||
parent_block_hash: BlockHash | None
|
||||
@@ -232,7 +249,8 @@ class OffloadingEventsTracker:
|
||||
"OffloadingEventsTracker: no event metadata for "
|
||||
"offload key during BlockStored emission; emitting a "
|
||||
"placeholder payload. Expected for non-full-attention "
|
||||
"groups; otherwise indicates a missing populate path."
|
||||
"groups and promotions not observed as a primary-tier "
|
||||
"hit before translation."
|
||||
)
|
||||
yield self._placeholder_stored(key, event.medium, locality)
|
||||
continue
|
||||
|
||||
@@ -463,15 +463,27 @@ class OffloadingConnectorScheduler:
|
||||
del self._req_status[req_id]
|
||||
|
||||
def _maximal_prefix_lookup(
|
||||
self, keys: Iterable[OffloadKey], req_context: ReqContext
|
||||
self,
|
||||
keys: Iterable[OffloadKey],
|
||||
req_context: ReqContext,
|
||||
req: Request,
|
||||
group_config: GroupOffloadConfig,
|
||||
start_chunk_idx: int,
|
||||
) -> int | None:
|
||||
"""Return the number of consecutive offloaded chunks from the start,
|
||||
or None if the backend deferred a lookup."""
|
||||
hit_count = 0
|
||||
defer_lookup = False
|
||||
for key in keys:
|
||||
match self.manager.lookup(key, req_context):
|
||||
for local_idx, key in enumerate(keys):
|
||||
result = self.manager.lookup(key, req_context)
|
||||
match result:
|
||||
case LookupResult.HIT:
|
||||
self._events_tracker.record_lookup(
|
||||
req,
|
||||
group_config,
|
||||
start_chunk_idx + local_idx,
|
||||
key,
|
||||
)
|
||||
hit_count += 1
|
||||
case LookupResult.HIT_PENDING:
|
||||
defer_lookup = True
|
||||
@@ -616,7 +628,11 @@ class OffloadingConnectorScheduler:
|
||||
num_hit_chunks: int | None
|
||||
if sliding_window_size_in_chunks is None:
|
||||
num_hit_chunks = self._maximal_prefix_lookup(
|
||||
offload_keys, req_status.req_context
|
||||
offload_keys,
|
||||
req_status.req_context,
|
||||
req_status.req,
|
||||
group_config,
|
||||
start_chunk_idx,
|
||||
)
|
||||
else:
|
||||
required_window = sliding_window_size_in_chunks
|
||||
|
||||
@@ -84,6 +84,12 @@ from .utils import (
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_GEMMA4_EXPERT_PARENT_MAPPER = WeightsMapper(
|
||||
orig_to_new_regex={
|
||||
re.compile(r"(?<!\.moe)\.experts$"): ".moe.experts",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _remap_gemma4_expert_weight_name(name: str) -> str:
|
||||
return re.sub(r"(?<!\.moe)\.experts\.(\d+)\.", r".moe.experts.\1.", name)
|
||||
@@ -1508,7 +1514,7 @@ class Gemma4Model(nn.Module, EagleModelMixin):
|
||||
class Gemma4ForCausalLM(
|
||||
nn.Module, SupportsLoRA, SupportsPP, MixtureOfExperts, SupportsEagle3
|
||||
):
|
||||
hf_to_vllm_mapper = WeightsMapper(
|
||||
hf_to_vllm_mapper = _GEMMA4_EXPERT_PARENT_MAPPER | WeightsMapper(
|
||||
orig_to_new_prefix={
|
||||
# Gemma4ForConditionalGeneration already loads the text stack
|
||||
# from `model.language_model.*`. We reuse that same checkpoint
|
||||
|
||||
@@ -40,7 +40,10 @@ from vllm.inputs import MultiModalDataDict
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.layernorm import RMSNorm
|
||||
from vllm.model_executor.layers.linear import ReplicatedLinear
|
||||
from vllm.model_executor.models.gemma4 import Gemma4ForCausalLM
|
||||
from vllm.model_executor.models.gemma4 import (
|
||||
_GEMMA4_EXPERT_PARENT_MAPPER,
|
||||
Gemma4ForCausalLM,
|
||||
)
|
||||
from vllm.model_executor.models.module_mapping import MultiModelKeys
|
||||
from vllm.model_executor.models.transformers.utils import recursive_replace_linear
|
||||
from vllm.multimodal import MULTIMODAL_REGISTRY
|
||||
@@ -998,7 +1001,7 @@ class Gemma4ForConditionalGeneration(
|
||||
}
|
||||
|
||||
# Maps checkpoint prefixes to vLLM module paths.
|
||||
hf_to_vllm_mapper = WeightsMapper(
|
||||
hf_to_vllm_mapper = _GEMMA4_EXPERT_PARENT_MAPPER | WeightsMapper(
|
||||
orig_to_new_prefix={
|
||||
# vision tower
|
||||
"model.vision_tower": "vision_tower",
|
||||
@@ -1010,7 +1013,7 @@ class Gemma4ForConditionalGeneration(
|
||||
"model.language_model.": "language_model.model.",
|
||||
"lm_head.": "language_model.lm_head.",
|
||||
"model": "language_model.model",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
|
||||
|
||||
@@ -476,15 +476,17 @@ class MediaConnector:
|
||||
self,
|
||||
image_url: str,
|
||||
*,
|
||||
image_mode: str = "RGB",
|
||||
image_mode: str | None = "RGB",
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Load a PIL image from an HTTP or base64 data URL.
|
||||
|
||||
By default, the image is converted into RGB format.
|
||||
By default, the image is converted into RGB format. Set
|
||||
`media_io_kwargs={"image": {"image_mode": None}}` to keep the
|
||||
original image mode (e.g. preserving the alpha channel).
|
||||
"""
|
||||
image_io = ImageMediaIO(
|
||||
image_mode=image_mode, **self.media_io_kwargs.get("image", {})
|
||||
**({"image_mode": image_mode} | self.media_io_kwargs.get("image", {}))
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -501,15 +503,17 @@ class MediaConnector:
|
||||
self,
|
||||
image_url: str,
|
||||
*,
|
||||
image_mode: str = "RGB",
|
||||
image_mode: str | None = "RGB",
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Asynchronously load a PIL image from an HTTP or base64 data URL.
|
||||
|
||||
By default, the image is converted into RGB format.
|
||||
By default, the image is converted into RGB format. Set
|
||||
`media_io_kwargs={"image": {"image_mode": None}}` to keep the
|
||||
original image mode (e.g. preserving the alpha channel).
|
||||
"""
|
||||
image_io = ImageMediaIO(
|
||||
image_mode=image_mode, **self.media_io_kwargs.get("image", {})
|
||||
**({"image_mode": image_mode} | self.media_io_kwargs.get("image", {}))
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -526,14 +530,14 @@ class MediaConnector:
|
||||
self,
|
||||
video_url: str,
|
||||
*,
|
||||
image_mode: str = "RGB",
|
||||
image_mode: str | None = "RGB",
|
||||
video_processor: str | None = None,
|
||||
) -> tuple[npt.NDArray, dict[str, Any]]:
|
||||
"""
|
||||
Load video from an HTTP or base64 data URL.
|
||||
"""
|
||||
image_io = ImageMediaIO(
|
||||
image_mode=image_mode, **self.media_io_kwargs.get("image", {})
|
||||
**({"image_mode": image_mode} | self.media_io_kwargs.get("image", {}))
|
||||
)
|
||||
video_io_kwargs = dict(self.media_io_kwargs.get("video", {}))
|
||||
if "video_backend" not in video_io_kwargs and (
|
||||
@@ -552,16 +556,18 @@ class MediaConnector:
|
||||
self,
|
||||
video_url: str,
|
||||
*,
|
||||
image_mode: str = "RGB",
|
||||
image_mode: str | None = "RGB",
|
||||
video_processor: str | None = None,
|
||||
) -> tuple[npt.NDArray, dict[str, Any]]:
|
||||
"""
|
||||
Asynchronously load video from an HTTP or base64 data URL.
|
||||
|
||||
By default, the image is converted into RGB format.
|
||||
By default, the image is converted into RGB format. Set
|
||||
`media_io_kwargs={"image": {"image_mode": None}}` to keep the
|
||||
original image mode (e.g. preserving the alpha channel).
|
||||
"""
|
||||
image_io = ImageMediaIO(
|
||||
image_mode=image_mode, **self.media_io_kwargs.get("image", {})
|
||||
**({"image_mode": image_mode} | self.media_io_kwargs.get("image", {}))
|
||||
)
|
||||
video_io_kwargs = dict(self.media_io_kwargs.get("video", {}))
|
||||
if "video_backend" not in video_io_kwargs and (
|
||||
|
||||
@@ -25,9 +25,11 @@ class ImageMediaIO(MediaIO[Image.Image]):
|
||||
error handling.
|
||||
"""
|
||||
|
||||
def __init__(self, image_mode: str = "RGB", **kwargs) -> None:
|
||||
def __init__(self, image_mode: str | None = "RGB", **kwargs) -> None:
|
||||
super().__init__()
|
||||
|
||||
# Target mode for loaded images; `None` keeps the original mode
|
||||
# (i.e. no conversion, alpha channel is preserved as-is).
|
||||
self.image_mode = image_mode
|
||||
# `kwargs` contains custom arguments from
|
||||
# --media-io-kwargs for this modality, merged with
|
||||
@@ -62,7 +64,7 @@ class ImageMediaIO(MediaIO[Image.Image]):
|
||||
"""Convert image mode with custom background color."""
|
||||
if isinstance(image, MediaWithBytes):
|
||||
image = image.media
|
||||
if image.mode == self.image_mode:
|
||||
if self.image_mode is None or image.mode == self.image_mode:
|
||||
return image
|
||||
elif image.mode == "RGBA" and self.image_mode == "RGB":
|
||||
return rgba_to_rgb(image, self.rgba_background_color)
|
||||
|
||||
@@ -58,13 +58,14 @@ def encode_audio_url(
|
||||
def encode_image_base64(
|
||||
image: Image.Image,
|
||||
*,
|
||||
image_mode: str = "RGB",
|
||||
image_mode: str | None = "RGB",
|
||||
format: str = "PNG",
|
||||
) -> str:
|
||||
"""
|
||||
Encode a pillow image to base64 format.
|
||||
|
||||
By default, the image is converted into RGB format before being encoded.
|
||||
Pass `image_mode=None` to keep the original image mode.
|
||||
"""
|
||||
image_io = ImageMediaIO(image_mode=image_mode)
|
||||
return image_io.encode_base64(image, image_format=format)
|
||||
@@ -73,13 +74,14 @@ def encode_image_base64(
|
||||
def encode_image_url(
|
||||
image: Image.Image,
|
||||
*,
|
||||
image_mode: str = "RGB",
|
||||
image_mode: str | None = "RGB",
|
||||
format: str = "PNG",
|
||||
) -> str:
|
||||
"""
|
||||
Encode a pillow image as a data URL.
|
||||
|
||||
By default, the image is converted into RGB format before being encoded.
|
||||
Pass `image_mode=None` to keep the original image mode.
|
||||
"""
|
||||
image_b64 = encode_image_base64(image, image_mode=image_mode, format=format)
|
||||
mimetype = mimetypes.types_map.get("." + format.lower(), "image")
|
||||
|
||||
@@ -972,6 +972,23 @@ class KVCacheConfig:
|
||||
def has_mamba_layers(self) -> bool:
|
||||
return any(isinstance(g.kv_cache_spec, MambaSpec) for g in self.kv_cache_groups)
|
||||
|
||||
@property
|
||||
def has_mixed_precision_kv_cache(self) -> bool:
|
||||
"""Whether attention groups store their KV cache at more than one precision."""
|
||||
kv_cache_precisions = {
|
||||
(group.kv_cache_spec.dtype, group.kv_cache_spec.kv_quant_mode)
|
||||
for group in self.kv_cache_groups
|
||||
if isinstance(group.kv_cache_spec, AttentionSpec)
|
||||
}
|
||||
return len(kv_cache_precisions) > 1
|
||||
|
||||
@property
|
||||
def needs_kv_cache_zeroing(self) -> bool:
|
||||
return self.has_mamba_layers
|
||||
"""Whether newly allocated KV cache blocks must be zeroed before use.
|
||||
|
||||
Required for Mamba layers, whose state is read before it is fully written
|
||||
(#35219), and for mixed-precision caches, where a block reused across
|
||||
groups can be reinterpreted under a different precision and decode stale
|
||||
bytes to NaN/Inf. Uniform-precision caches skip zeroing.
|
||||
"""
|
||||
return self.has_mamba_layers or self.has_mixed_precision_kv_cache
|
||||
|
||||
@@ -136,14 +136,6 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
|
||||
super().__init__(config)
|
||||
# Redeclare for mypy: parent sets this but `--follow-imports skip` hides it
|
||||
self._manager: OffloadingManager | None = None
|
||||
if self.kv_events_config.self_describing_kv_events:
|
||||
raise ValueError(
|
||||
"self_describing_kv_events is not supported by "
|
||||
"TieringOffloadingSpec. Tier promotions can emit primary-tier "
|
||||
"store events that do not correspond to GPU store jobs, so the "
|
||||
"current self-describing side table cannot describe them "
|
||||
"correctly."
|
||||
)
|
||||
|
||||
# Parse secondary tier configurations
|
||||
self.secondary_tier_configs = self.extra_config.get("secondary_tiers", [])
|
||||
|
||||
Reference in New Issue
Block a user