[Mini-VLLM] Remove orphan subsystems: snapshot, CPU workers, dead decoders
- Delete the snapshot package, CLI command, and its launch tests - Delete CPU workers, cpu_communicator, and the hw_agnostic layer package - Delete the EC CPU connector, keeping the EC base glue intact - Delete dead composite/triton_flashinfer backends and third_party/flashmla - Delete dead video decoders, video-bound tests, and orphan utils - Remove the deep_gemm_mega_moe option and dead allreduce fusion pair Co-authored-by: Hinata <hinata@idihore.id> Signed-off-by: MOVZX <movzx@yahoo.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from vllm.model_executor.warmup import jit_warmup_cutedsl_helper
|
||||
from vllm.model_executor.warmup.jit_warmup import kernel_launcher
|
||||
from vllm.model_executor.warmup.jit_warmup_cutedsl_helper import (
|
||||
CuTeDSLLaunchSpec,
|
||||
VllmCuTeDSLJitKernel,
|
||||
)
|
||||
|
||||
|
||||
class _TestCuTeDSLKernel(VllmCuTeDSLJitKernel["_TestCuTeDSLKernel.CompileKey"]):
|
||||
@dataclass(frozen=True)
|
||||
class CompileKey:
|
||||
variant: int
|
||||
|
||||
@staticmethod
|
||||
def kernel(compile_key: CompileKey) -> tuple[str, int]:
|
||||
return "entry", compile_key.variant
|
||||
|
||||
def dispatch(self, *, variant: int) -> CompileKey:
|
||||
return self.CompileKey(variant=variant)
|
||||
|
||||
def get_warmup_keys(self) -> list[CompileKey]:
|
||||
return self._trace_dispatch(self.dispatch)(variant=(1, 2))
|
||||
|
||||
def warmup_inputs(self, compile_key: CompileKey) -> tuple[Any, ...]:
|
||||
return (f"fake-{compile_key.variant}",)
|
||||
|
||||
@kernel_launcher
|
||||
def __call__(
|
||||
self,
|
||||
payload: str,
|
||||
*,
|
||||
variant: int,
|
||||
) -> CuTeDSLLaunchSpec[CompileKey]:
|
||||
compile_key = self.dispatch(variant=variant)
|
||||
return compile_key, (payload,)
|
||||
|
||||
|
||||
def test_cutedsl_launcher_reuses_compiled_executor(monkeypatch) -> None:
|
||||
compile_calls: list[tuple[Any, tuple[Any, ...]]] = []
|
||||
launch_calls: list[tuple[int, tuple[Any, ...]]] = []
|
||||
|
||||
def compile_cutedsl(entry: tuple[str, int], *args: Any) -> Any:
|
||||
compile_calls.append((entry, args))
|
||||
variant = entry[1]
|
||||
|
||||
def executor(*runtime_args: Any) -> tuple[int, tuple[Any, ...]]:
|
||||
launch_calls.append((variant, runtime_args))
|
||||
return variant, runtime_args
|
||||
|
||||
return executor
|
||||
|
||||
monkeypatch.setattr(
|
||||
jit_warmup_cutedsl_helper,
|
||||
"compile_cutedsl",
|
||||
compile_cutedsl,
|
||||
)
|
||||
kernel = _TestCuTeDSLKernel()
|
||||
compile_key = kernel.CompileKey(variant=1)
|
||||
|
||||
kernel.compile(compile_key)
|
||||
kernel.compile(compile_key)
|
||||
assert compile_calls == [(("entry", 1), ("fake-1",))]
|
||||
|
||||
assert kernel("runtime", variant=1) == (1, ("runtime",))
|
||||
assert kernel("other", variant=2) == (2, ("other",))
|
||||
assert compile_calls == [
|
||||
(("entry", 1), ("fake-1",)),
|
||||
(("entry", 2), ("fake-2",)),
|
||||
]
|
||||
assert launch_calls == [(1, ("runtime",)), (2, ("other",))]
|
||||
|
||||
|
||||
def test_cutedsl_launcher_runs_post_launch_finalizer(monkeypatch) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
def compile_cutedsl(entry: tuple[str, int], *args: Any) -> Any:
|
||||
def executor(*runtime_args: Any) -> None:
|
||||
events.append("launch")
|
||||
|
||||
return executor
|
||||
|
||||
class FinalizedKernel(_TestCuTeDSLKernel):
|
||||
@kernel_launcher
|
||||
def __call__(
|
||||
self,
|
||||
payload: str,
|
||||
*,
|
||||
variant: int,
|
||||
) -> CuTeDSLLaunchSpec[_TestCuTeDSLKernel.CompileKey]:
|
||||
compile_key = self.dispatch(variant=variant)
|
||||
|
||||
def finalize() -> str:
|
||||
events.append("finalize")
|
||||
return "output"
|
||||
|
||||
return compile_key, (payload,), "unused", finalize
|
||||
|
||||
monkeypatch.setattr(
|
||||
jit_warmup_cutedsl_helper,
|
||||
"compile_cutedsl",
|
||||
compile_cutedsl,
|
||||
)
|
||||
|
||||
assert FinalizedKernel()("runtime", variant=1) == "output"
|
||||
assert events == ["launch", "finalize"]
|
||||
@@ -1,113 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""End-to-end check of the Transformers backend's hw-agnostic layers.
|
||||
|
||||
Serves a tiny Llama through `vllm_runner` with and without
|
||||
`VLLM_USE_HW_AGNOSTIC` and checks the layer providers and logprobs. Needs a
|
||||
real engine, so it runs in the GPU job. The CPU-only unit tests for the
|
||||
resolution logic (`layers._resolve`) are in `test_layer_registry.py`.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tiny_llama_path(tmp_path_factory):
|
||||
"""A randomly-initialized microscopic Llama saved to disk (with an ungated
|
||||
tokenizer) so vLLM can load it like any local checkpoint."""
|
||||
from transformers import AutoTokenizer, LlamaConfig, LlamaForCausalLM
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained("hf-internal-testing/llama-tokenizer")
|
||||
config = LlamaConfig(
|
||||
vocab_size=tokenizer.vocab_size,
|
||||
hidden_size=128,
|
||||
intermediate_size=256,
|
||||
num_hidden_layers=2,
|
||||
num_attention_heads=4,
|
||||
rms_norm_eps=1e-6,
|
||||
hidden_act="silu",
|
||||
)
|
||||
torch.manual_seed(0)
|
||||
model = LlamaForCausalLM(config)
|
||||
|
||||
path = tmp_path_factory.mktemp("tiny_llama")
|
||||
model.save_pretrained(path)
|
||||
tokenizer.save_pretrained(path)
|
||||
return str(path)
|
||||
|
||||
|
||||
# Registered names of the layers the backend can
|
||||
# currently route to hw-agnostic implementations.
|
||||
_COVERED_LAYERS = ("rms_norm", "silu_and_mul")
|
||||
|
||||
|
||||
def _layer_providers(model) -> dict[str, str]:
|
||||
"""Map each covered layer type present in the model to the provider its
|
||||
implementation came from (``hw_agnostic`` or ``vllm``).
|
||||
"""
|
||||
|
||||
def provider_of(module) -> str | None:
|
||||
for cls in type(module).__mro__:
|
||||
if "hw_agnostic.layers" in cls.__module__:
|
||||
return "hw_agnostic"
|
||||
if ".model_executor.layers." in cls.__module__:
|
||||
return "vllm"
|
||||
return None
|
||||
|
||||
providers: dict[str, str] = {}
|
||||
for module in model.modules():
|
||||
name = getattr(module, "name", None)
|
||||
if (
|
||||
name in _COVERED_LAYERS
|
||||
and name not in providers
|
||||
and (prov := provider_of(module)) is not None
|
||||
):
|
||||
providers[name] = prov
|
||||
return providers
|
||||
|
||||
|
||||
def _serve(vllm_runner, model_path, prompts):
|
||||
"""Serve the model through the backend; return (layer_providers, logprobs)."""
|
||||
with vllm_runner(
|
||||
model_path,
|
||||
model_impl="transformers",
|
||||
max_model_len=64,
|
||||
enforce_eager=True,
|
||||
gpu_memory_utilization=0.3,
|
||||
) as runner:
|
||||
assert runner.llm.llm_engine.model_config.using_transformers_backend()
|
||||
providers = runner.apply_model(_layer_providers)[0]
|
||||
outputs = runner.generate_greedy_logprobs(
|
||||
prompts, max_tokens=32, num_logprobs=5
|
||||
)
|
||||
return providers, outputs
|
||||
|
||||
|
||||
def test_hw_agnostic_matches_vllm_end_to_end(monkeypatch, vllm_runner, tiny_llama_path):
|
||||
"""Serving the tiny model with hw-agnostic layers matches the vLLM baseline."""
|
||||
# spawn: `layers` resolves its symbols at import time, so the worker must
|
||||
# re-import it with VLLM_USE_HW_AGNOSTIC already set (fork would inherit
|
||||
# the parent's already-resolved module).
|
||||
monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
|
||||
# apply_model pickles the introspection function.
|
||||
monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
|
||||
from ..utils import check_logprobs_close
|
||||
|
||||
prompts = ["The capital of France is", "vLLM is"]
|
||||
|
||||
monkeypatch.setenv("VLLM_USE_HW_AGNOSTIC", "0")
|
||||
vllm_providers, vllm_outputs = _serve(vllm_runner, tiny_llama_path, prompts)
|
||||
# Both replaceable layers present in a Llama block must be vLLM's here.
|
||||
assert vllm_providers == {"rms_norm": "vllm", "silu_and_mul": "vllm"}
|
||||
|
||||
monkeypatch.setenv("VLLM_USE_HW_AGNOSTIC", "1")
|
||||
hw_providers, hw_outputs = _serve(vllm_runner, tiny_llama_path, prompts)
|
||||
assert hw_providers == {"rms_norm": "hw_agnostic", "silu_and_mul": "hw_agnostic"}
|
||||
|
||||
check_logprobs_close(
|
||||
outputs_0_lst=vllm_outputs,
|
||||
outputs_1_lst=hw_outputs,
|
||||
name_0="vllm",
|
||||
name_1="hw_agnostic",
|
||||
)
|
||||
@@ -1,233 +0,0 @@
|
||||
# EPD Correctness Test
|
||||
|
||||
This test verifies that EPD (Encoder-Prefill-Decode) disaggregation produces identical outputs to a baseline single instance.
|
||||
|
||||
## What It Tests
|
||||
|
||||
- **Baseline**: Single vLLM instance serving a multimodal model
|
||||
- **EPD (1E+1PD)**: 1 Encoder + 1 Prefill-Decode instance
|
||||
- **Baseline (1P+1D)**: 1 Prefill + 1 Decode instance
|
||||
- **EPD (1E+1P+1D)**: 1 Encoder + 1 Prefill + 1 Decode instance
|
||||
|
||||
The test ensures that disaggregated encoding produces **identical** outputs to the baseline.
|
||||
|
||||
Note that currently PD disaggregation set up may give slightly different results from a single instance. Therefore, we need the result from 1P+1D as the baseline for 1E+1P+1D
|
||||
|
||||
Please refer to [Disaggregated Encoder Feature](../../../../docs/features/disagg_encoder.md) for the detailed explanation for the EPD features.
|
||||
|
||||
## Files
|
||||
|
||||
- `run_epd_correctness_test.sh` - Main test script (starts all instances and runs tests)
|
||||
- `test_epd_correctness.py` - Python test script (compares outputs)
|
||||
|
||||
## Usage
|
||||
|
||||
### Multimodal Prompts (Default)
|
||||
|
||||
```bash
|
||||
cd vllm
|
||||
./tests/v1/ec_connector/integration/run_epd_correctness_test.sh
|
||||
```
|
||||
|
||||
This runs the test with actual multimodal (image) prompts.
|
||||
|
||||
### Text-Only Prompts
|
||||
|
||||
```bash
|
||||
cd vllm
|
||||
USE_MM_PROMPTS=0 ./tests/v1/ec_connector/integration/run_epd_correctness_test.sh
|
||||
```
|
||||
|
||||
This runs a quick test with text-only prompts to verify the setup works.
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
```bash
|
||||
# Use specific GPUs
|
||||
GPU_E=0 GPU_PD=1 GPU_P=1 GPU_D=2 bash ./tests/v1/ec_connector/integration/run_epd_correctness_test.sh
|
||||
|
||||
# Use specific ports
|
||||
ENDPOINT_PORT=10001 bash ./tests/v1/ec_connector/integration/run_epd_correctness_test.sh
|
||||
|
||||
# Use specific model
|
||||
MODEL="Qwen/Qwen2.5-VL-3B-Instruct" bash ./tests/v1/ec_connector/integration/run_epd_correctness_test.sh
|
||||
|
||||
# Use specific storage path
|
||||
EC_SHARED_STORAGE_PATH="/tmp/my_ec_cache" bash ./tests/v1/ec_connector/integration/run_epd_correctness_test.sh
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### NIXL EC failure isolation (1E + 1PD)
|
||||
|
||||
```bash
|
||||
PATH="$PWD/.venv/bin:$PATH" CUDA_VISIBLE_DEVICES=0,1 \
|
||||
.venv/bin/python -m pytest \
|
||||
tests/v1/ec_connector/integration/test_nixl_failure.py -v -s
|
||||
```
|
||||
|
||||
Requires two CUDA GPUs and NIXL. `MODEL` overrides the default
|
||||
`Qwen/Qwen2.5-VL-3B-Instruct`. The test starts real E/PD servers with CUDA
|
||||
graphs enabled and uses the proxy's rewrite helper. Only the failing request's
|
||||
control endpoint is replaced: a ZMQ peer returns `NACK_MISSING` and verifies
|
||||
that the consumer actually requested the encoding.
|
||||
|
||||
Metadata-only requests must return a request-level error; requests retaining
|
||||
the image must succeed through local fallback. After each case, a fresh
|
||||
metadata-only image request must succeed through the real NIXL transfer path.
|
||||
No server restart, proxy retry, cache hit, or timeout race can mask the result.
|
||||
This is a correctness test, not a throughput benchmark.
|
||||
|
||||
### Mooncake EC (1E + 1PD)
|
||||
|
||||
```bash
|
||||
PYTHON_BIN="$PWD/.venv/bin/python" \
|
||||
bash tests/v1/ec_connector/integration/run_epd_mooncake_ec_full_pipeline.sh
|
||||
```
|
||||
|
||||
Requires two GPUs and Mooncake TransferEngine. TCP is the default transport;
|
||||
no RDMA-capable network hardware is required.
|
||||
The script sets `MC_FORCE_TCP=1` in TCP mode so Mooncake cannot auto-select RDMA.
|
||||
The baseline runs first on GPU 0, followed by E on GPU 0 and PD on GPU 1.
|
||||
`MOONCAKE_EC_PROTOCOL=rdma` selects RDMA instead; host-specific transport
|
||||
environment variables should be set by the caller.
|
||||
|
||||
Three black-box cases check fixed short answers and compare with the baseline:
|
||||
|
||||
- One image: read the STOP sign.
|
||||
- Two different images (including a local file): identify flowers and birds.
|
||||
- The same image twice: read both STOP signs.
|
||||
|
||||
By default, all three requests run concurrently for two rounds, exercising
|
||||
shared hashes across requests and reuse after completion. Every response is
|
||||
compared, not just the final round. Set `CONCURRENCY` and `REPEAT` to override.
|
||||
Prefix caching is disabled and CUDA graphs remain enabled. This is a small
|
||||
correctness suite, not a performance benchmark or failure-injection suite.
|
||||
|
||||
`LOG_PATH` and `BASELINE_FILE` select the log directory and reference output.
|
||||
`SKIP_BASELINE=1` reuses a reference generated with the same model and test
|
||||
configuration. `USE_MM_PROMPTS=0` only checks text routing, not EC transfer.
|
||||
|
||||
Buildkite runs this script as `mooncake-ec-tcp-e2e-2-gpus` on two L4 GPUs.
|
||||
The job is defined in `.buildkite/test_areas/disaggregated_mooncake.yaml` and
|
||||
selected for changes to EC, multimodal processing, scheduler/model-runner
|
||||
integration, the proxy, or these tests (subject to normal PR CI approval).
|
||||
It reuses the CI image's Python packages through a system-site-packages venv
|
||||
and installs the CUDA-compatible Mooncake wheel. It uses loopback networking
|
||||
and TCP only, without RDMA devices or peer-memory setup.
|
||||
|
||||
The job fails on startup errors, request errors, or answer mismatches. On
|
||||
failure, the script prints the last 100 lines of each server/proxy log to
|
||||
the CI job log; full files remain under `LOG_PATH` while the container exists.
|
||||
|
||||
### Step 1: Baseline
|
||||
|
||||
1. Start single vLLM instance on GPU
|
||||
2. Run test prompts (multimodal or text-only)
|
||||
3. Save outputs to `.vllm_epd_baseline.txt`
|
||||
4. Shutdown instance
|
||||
|
||||
### Step 2: EPD (1E + 1PD)
|
||||
|
||||
1. Clear encoder cache storage
|
||||
2. Start instances and proxy
|
||||
3. Run same test prompts
|
||||
4. Assert outputs match baseline exactly
|
||||
5. Shutdown instances
|
||||
|
||||
### Step 3: EPD (1E + 1P + 1D)
|
||||
|
||||
1. Clear encoder cache storage
|
||||
2. Start instances and proxy
|
||||
3. Run same test prompts
|
||||
4. Assert outputs match baseline exactly
|
||||
5. Shutdown instances
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### Multimodal Prompts (--use_mm_prompts)
|
||||
|
||||
Tests encoder cache transfer:
|
||||
|
||||
- Single image query
|
||||
- Multiple images in one request
|
||||
- Mixed image and text
|
||||
- Image with detailed questions
|
||||
|
||||
### Text-Only Prompts (default)
|
||||
|
||||
Quick sanity check:
|
||||
|
||||
- Simple text queries
|
||||
- Text-only explanations
|
||||
- Verifies proxy routing works
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### ✅ Test Passes When
|
||||
|
||||
- All disagg outputs match baseline outputs exactly
|
||||
- No errors during instance startup
|
||||
- Encoder cache is properly saved and loaded
|
||||
- Proxy correctly routes requests
|
||||
|
||||
### ❌ Test Fails When
|
||||
|
||||
- Outputs differ between baseline and disagg
|
||||
- Server startup fails
|
||||
- Encoder cache not found (should fall back to local execution)
|
||||
- Proxy routing errors
|
||||
|
||||
## Notes
|
||||
|
||||
- The test uses deterministic generation (`temperature=0.0`, `seed=42`)
|
||||
- Encoder cache should enable exact output reproduction
|
||||
- Test cleans up all instances and cache files after completion
|
||||
- Safe to run multiple times (idempotent)
|
||||
- We setup the PD disagg part with NixlConnector. Please read details about EPD in `examples/disaggregated/disaggregated_encoder/README.md`
|
||||
|
||||
## Requirements
|
||||
|
||||
- Multiple GPUs (3 for 1E+1P+1D, 2 for 1E+1PD, 1 for baseline)
|
||||
- 1E+1P+1D is runnable with 2 GPU by assign E and P on the same GPU now.
|
||||
- Multimodal model (e.g., Qwen2.5-VL-3B-Instruct)
|
||||
- Internet access (for accessing vllm test images)
|
||||
|
||||
## Debugging
|
||||
|
||||
### Check Logs
|
||||
|
||||
Logs and baseline output are saved in `/tmp/` by default.
|
||||
Can be customized by changing the environment variables.
|
||||
|
||||
### Check Encoder Cache
|
||||
|
||||
```bash
|
||||
# Verify cache files are created
|
||||
ls -la $EC_SHARED_STORAGE_PATH/
|
||||
|
||||
# Should see directories with mm_hash names
|
||||
# Each containing encoder_cache.safetensors
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
Run individual components:
|
||||
|
||||
```bash
|
||||
# Baseline only
|
||||
python test_epd_correctness.py \
|
||||
--service_url http://localhost:8000 \
|
||||
--model_name Qwen/Qwen2.5-VL-3B-Instruct \
|
||||
--mode baseline \
|
||||
--baseline_file test_output.txt \
|
||||
--use_mm_prompts
|
||||
|
||||
# Disagg only (requires baseline output file!)
|
||||
python test_epd_correctness.py \
|
||||
--service_url http://localhost:8000 \
|
||||
--model_name Qwen/Qwen2.5-VL-3B-Instruct \
|
||||
--mode disagg \
|
||||
--baseline_file test_output.txt \
|
||||
--use_mm_prompts
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 821 KiB |
@@ -1,505 +0,0 @@
|
||||
#!/bin/bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
#
|
||||
# EPD (Encoder-Prefill-Decode) Correctness Test
|
||||
#
|
||||
# This script tests that EPD disaggregation produces the same outputs as baseline.
|
||||
# It runs:
|
||||
# 1. Baseline: Single vLLM instance
|
||||
# 2. EPD: 1E + 1PD setup
|
||||
# 3. Baseline for (E + P + D): 1P + 1D vLLM instances disagg
|
||||
# 4. EPD: 1E + 1P + 1D setup
|
||||
|
||||
# For GPU usage
|
||||
|
||||
# set -xe
|
||||
|
||||
# Resolve the repository root from the script location instead of `.git`.
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
GIT_ROOT="${GIT_ROOT:-$(cd -- "${SCRIPT_DIR}/../../../.." && pwd -P)}"
|
||||
|
||||
# Model to test
|
||||
MODEL="${MODEL:-Qwen/Qwen2.5-VL-3B-Instruct}"
|
||||
MAX_MODEL_LEN="${MAX_MODEL_LEN:-10240}"
|
||||
GPU_MEMORY_UTILIZATION="${GPU_MEMORY_UTILIZATION:-0.7}"
|
||||
MAX_NUM_SEQS="${MAX_NUM_SEQS:-128}"
|
||||
|
||||
# Set 1 to use multimodal prompts; else to use text-only
|
||||
USE_MM_PROMPTS="${USE_MM_PROMPTS:-1}"
|
||||
USE_TWO_IMAGE_PROMPT="${USE_TWO_IMAGE_PROMPT:-1}"
|
||||
TEST_FLAGS=()
|
||||
if [[ "$USE_MM_PROMPTS" == "1" ]]; then
|
||||
TEST_FLAGS+=(--use_mm_prompts)
|
||||
fi
|
||||
if [[ "$USE_TWO_IMAGE_PROMPT" != "1" ]]; then
|
||||
TEST_FLAGS+=(--skip_two_image_prompt)
|
||||
fi
|
||||
|
||||
# GPU configuration
|
||||
GPU_E="${GPU_E:-0}"
|
||||
GPU_P="${GPU_P:-1}"
|
||||
GPU_D="${GPU_D:-2}"
|
||||
GPU_SINGLE="${GPU_SINGLE:-$GPU_P}"
|
||||
GPU_PD="${GPU_PD:-$GPU_P}"
|
||||
|
||||
# Device platform and affinity environment variable
|
||||
DEVICE_PLATFORM="${DEVICE_PLATFORM:-cuda}"
|
||||
if [[ -z "${DEVICE_AFFINITY_ENV:-}" ]]; then
|
||||
if [[ "${DEVICE_PLATFORM,,}" == "xpu" ]]; then
|
||||
DEVICE_AFFINITY_ENV="ZE_AFFINITY_MASK"
|
||||
else
|
||||
DEVICE_AFFINITY_ENV="CUDA_VISIBLE_DEVICES"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Port
|
||||
ENCODE_PORT="${ENCODE_PORT:-19534}"
|
||||
PREFILL_PORT="${PREFILL_PORT:-19535}"
|
||||
DECODE_PORT="${DECODE_PORT:-19536}"
|
||||
PREFILL_DECODE_PORT="${PREFILL_DECODE_PORT:-19537}"
|
||||
ENDPOINT_PORT="${ENDPOINT_PORT:-10001}"
|
||||
|
||||
# Storage path for encoder cache
|
||||
EC_SHARED_STORAGE_PATH="${EC_SHARED_STORAGE_PATH:-/tmp/ec_cache_test}"
|
||||
TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-600}"
|
||||
|
||||
# Output file for baseline comparison and logs
|
||||
LOG_PATH="${LOG_PATH:-/tmp}"
|
||||
BASELINE_FILE="${BASELINE_FILE:-/tmp/vllm_baseline.txt}"
|
||||
BASELINE_PD_FILE="${BASELINE_PD_FILE:-/tmp/vllm_epd_baseline.txt}"
|
||||
|
||||
mkdir -p "$LOG_PATH"
|
||||
|
||||
# Trap the SIGINT signal (triggered by Ctrl+C)
|
||||
trap 'kill $(jobs -pr)' SIGINT SIGTERM EXIT
|
||||
|
||||
# Wait for server to be ready
|
||||
wait_for_server() {
|
||||
local port=$1
|
||||
timeout "$TIMEOUT_SECONDS" bash -c "
|
||||
until curl -s localhost:${port}/v1/chat/completions > /dev/null; do
|
||||
sleep 1
|
||||
done" && return 0 || return 1
|
||||
}
|
||||
|
||||
# Cleanup function
|
||||
cleanup_instances() {
|
||||
echo "Cleaning up any running vLLM instances..."
|
||||
pkill -f "vllm serve" || true
|
||||
pkill -f "disagg_epd_proxy.py" || true
|
||||
sleep 2
|
||||
}
|
||||
|
||||
# Function to run baseline (single instance)
|
||||
run_baseline() {
|
||||
echo "================================"
|
||||
echo "Running BASELINE (single instance)"
|
||||
echo "================================"
|
||||
|
||||
cleanup_instances
|
||||
rm -rf "$EC_SHARED_STORAGE_PATH"
|
||||
|
||||
local PORT=$ENDPOINT_PORT
|
||||
|
||||
# Start baseline instance
|
||||
echo "Starting baseline instance on GPU $GPU_SINGLE, port $PORT"
|
||||
env "$DEVICE_AFFINITY_ENV=$GPU_SINGLE" vllm serve "$MODEL" \
|
||||
--port "$PORT" \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--enforce-eager \
|
||||
--gpu-memory-utilization 0.9 \
|
||||
--max-num-seqs "$MAX_NUM_SEQS" \
|
||||
--allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
|
||||
> "$LOG_PATH"/baseline.log 2>&1 &
|
||||
|
||||
local BASELINE_PID=$!
|
||||
|
||||
# Wait for baseline to start
|
||||
echo "Waiting for baseline instance to start..."
|
||||
wait_for_server "$PORT"
|
||||
|
||||
curl http://127.0.0.1:"$PORT"/v1/models
|
||||
echo ""
|
||||
|
||||
# Run test in baseline mode
|
||||
echo "Running baseline..."
|
||||
|
||||
python "${GIT_ROOT}/tests/v1/ec_connector/integration/test_epd_correctness.py" \
|
||||
--service_url "http://localhost:$PORT" \
|
||||
--model_name "$MODEL" \
|
||||
--mode baseline \
|
||||
--baseline_file "$BASELINE_FILE" \
|
||||
"${TEST_FLAGS[@]}"
|
||||
|
||||
# Cleanup baseline
|
||||
echo "Stopping baseline instance..."
|
||||
kill $BASELINE_PID 2>/dev/null || true
|
||||
sleep 2
|
||||
cleanup_instances
|
||||
}
|
||||
|
||||
# Function to run EPD with 1E + 1PD
|
||||
run_epd_1e_1pd() {
|
||||
echo "================================"
|
||||
echo "Running EPD (1E + 1PD)"
|
||||
echo "================================"
|
||||
|
||||
cleanup_instances
|
||||
rm -rf "$EC_SHARED_STORAGE_PATH"
|
||||
mkdir -p "$EC_SHARED_STORAGE_PATH"
|
||||
|
||||
local ENCODE_PORT=$ENCODE_PORT
|
||||
local PREFILL_DECODE_PORT=$PREFILL_DECODE_PORT
|
||||
local PROXY_PORT=$ENDPOINT_PORT
|
||||
|
||||
declare -a PIDS=()
|
||||
|
||||
# Start encoder instance
|
||||
echo "Starting encoder instance on GPU $GPU_E, port $ENCODE_PORT"
|
||||
env "$DEVICE_AFFINITY_ENV=$GPU_E" vllm serve "$MODEL" \
|
||||
--port "$ENCODE_PORT" \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--enforce-eager \
|
||||
--gpu-memory-utilization 0.01 \
|
||||
--enable-request-id-headers \
|
||||
--no-enable-prefix-caching \
|
||||
--max-num-batched-tokens 114688 \
|
||||
--max-num-seqs "$MAX_NUM_SEQS" \
|
||||
--allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
|
||||
--ec-transfer-config '{
|
||||
"ec_connector": "ECExampleConnector",
|
||||
"ec_role": "ec_producer",
|
||||
"ec_connector_extra_config": {
|
||||
"shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
|
||||
}
|
||||
}' \
|
||||
> "$LOG_PATH"/1e1pd_encoder.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Start prefill+decode instance
|
||||
echo "Starting PD instance on GPU $GPU_PD, port $PREFILL_DECODE_PORT"
|
||||
env "$DEVICE_AFFINITY_ENV=$GPU_PD" vllm serve "$MODEL" \
|
||||
--port "$PREFILL_DECODE_PORT" \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--enforce-eager \
|
||||
--enable-mm-embeds \
|
||||
--gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
|
||||
--enable-request-id-headers \
|
||||
--max-num-seqs "$MAX_NUM_SEQS" \
|
||||
--allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
|
||||
--ec-transfer-config '{
|
||||
"ec_connector": "ECExampleConnector",
|
||||
"ec_role": "ec_consumer",
|
||||
"ec_connector_extra_config": {
|
||||
"shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
|
||||
}
|
||||
}' \
|
||||
> "$LOG_PATH"/1e1pd_pd.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Wait for instances to start
|
||||
echo "Waiting for encoder instance..."
|
||||
wait_for_server "$ENCODE_PORT"
|
||||
echo "Waiting for PD instance..."
|
||||
wait_for_server "$PREFILL_DECODE_PORT"
|
||||
|
||||
# Start proxy
|
||||
echo "Starting EPD proxy on port $PROXY_PORT"
|
||||
python "${GIT_ROOT}/examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py" \
|
||||
--host "0.0.0.0" \
|
||||
--port "$PROXY_PORT" \
|
||||
--encode-servers-urls "http://localhost:$ENCODE_PORT" \
|
||||
--prefill-servers-urls "disable" \
|
||||
--decode-servers-urls "http://localhost:$PREFILL_DECODE_PORT" \
|
||||
> "$LOG_PATH"/1e1pd_proxy.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Wait for proxy
|
||||
echo "Waiting for proxy..."
|
||||
wait_for_server "$PROXY_PORT"
|
||||
|
||||
curl http://127.0.0.1:"$PROXY_PORT"/v1/models
|
||||
curl http://127.0.0.1:"$PROXY_PORT"/health
|
||||
echo ""
|
||||
|
||||
echo "All EPD (1E+1PD) services are up!"
|
||||
|
||||
# Run test in disagg mode
|
||||
echo "Running EPD (1E+1PD) correctness test..."
|
||||
|
||||
python "${GIT_ROOT}/tests/v1/ec_connector/integration/test_epd_correctness.py" \
|
||||
--service_url "http://localhost:$PROXY_PORT" \
|
||||
--model_name "$MODEL" \
|
||||
--mode disagg \
|
||||
--baseline_file "$BASELINE_FILE" \
|
||||
"${TEST_FLAGS[@]}"
|
||||
|
||||
# Cleanup
|
||||
echo "✓✓ 1E+1PD Correctness Test finished"
|
||||
echo "Stopping EPD (1E+1PD) instances..."
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 2
|
||||
cleanup_instances
|
||||
}
|
||||
|
||||
# Function to run baseline for 1E + 1P + 1D (PD disagg)
|
||||
run_baseline_1p_1d() {
|
||||
echo "================================"
|
||||
echo "Running PD BASELINE (1P + 1D)"
|
||||
echo "================================"
|
||||
|
||||
cleanup_instances
|
||||
rm -rf "$EC_SHARED_STORAGE_PATH"
|
||||
mkdir -p "$EC_SHARED_STORAGE_PATH"
|
||||
|
||||
local PREFILL_PORT=$PREFILL_PORT
|
||||
local DECODE_PORT=$DECODE_PORT
|
||||
local PROXY_PORT=$ENDPOINT_PORT
|
||||
|
||||
declare -a PIDS=()
|
||||
|
||||
# Start prefill instance
|
||||
echo "Starting prefill instance on GPU $GPU_P, port $PREFILL_PORT"
|
||||
env "$DEVICE_AFFINITY_ENV=$GPU_P" \
|
||||
VLLM_NIXL_SIDE_CHANNEL_PORT=5559 \
|
||||
vllm serve "$MODEL" \
|
||||
--port "$PREFILL_PORT" \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--enforce-eager \
|
||||
--gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
|
||||
--enable-request-id-headers \
|
||||
--max-num-seqs "$MAX_NUM_SEQS" \
|
||||
--allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
|
||||
--kv-transfer-config '{
|
||||
"kv_connector": "NixlConnector",
|
||||
"kv_role": "kv_producer"
|
||||
}' \
|
||||
> "$LOG_PATH"/1p1d_prefill.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Start decode instance
|
||||
echo "Starting decode instance on GPU $GPU_D, port $DECODE_PORT"
|
||||
env "$DEVICE_AFFINITY_ENV=$GPU_D" \
|
||||
VLLM_NIXL_SIDE_CHANNEL_PORT=6000 \
|
||||
vllm serve "$MODEL" \
|
||||
--port "$DECODE_PORT" \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--enforce-eager \
|
||||
--gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
|
||||
--enable-request-id-headers \
|
||||
--max-num-seqs "$MAX_NUM_SEQS" \
|
||||
--allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
|
||||
--kv-transfer-config '{
|
||||
"kv_connector": "NixlConnector",
|
||||
"kv_role": "kv_consumer"
|
||||
}' \
|
||||
> "$LOG_PATH"/1p1d_decode.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Wait for instances to start
|
||||
echo "Waiting for prefill instance..."
|
||||
wait_for_server "$PREFILL_PORT"
|
||||
echo "Waiting for decode instance..."
|
||||
wait_for_server "$DECODE_PORT"
|
||||
|
||||
# Start proxy
|
||||
echo "Starting EPD proxy on port $PROXY_PORT"
|
||||
python "${GIT_ROOT}/tests/v1/kv_connector/nixl_integration/toy_proxy_server.py" \
|
||||
--host "0.0.0.0" \
|
||||
--port "$PROXY_PORT" \
|
||||
--prefiller-ports "$PREFILL_PORT" \
|
||||
--decoder-ports "$DECODE_PORT" \
|
||||
> "$LOG_PATH"/1p1d_proxy.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Wait for proxy
|
||||
echo "Waiting for proxy..."
|
||||
wait_for_server "$PROXY_PORT"
|
||||
|
||||
curl http://127.0.0.1:"$PROXY_PORT"/healthcheck
|
||||
echo ""
|
||||
|
||||
echo "All PD (1P+1D) services are up!"
|
||||
|
||||
# Run test in baseline mode
|
||||
echo "Running PD disagg baseline..."
|
||||
|
||||
python "${GIT_ROOT}/tests/v1/ec_connector/integration/test_epd_correctness.py" \
|
||||
--service_url "http://localhost:$PROXY_PORT" \
|
||||
--model_name "$MODEL" \
|
||||
--mode baseline_pd \
|
||||
--baseline_file "$BASELINE_PD_FILE" \
|
||||
"${TEST_FLAGS[@]}"
|
||||
|
||||
# Cleanup
|
||||
echo "Stopping PD (1P+1D) instances..."
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 2
|
||||
cleanup_instances
|
||||
}
|
||||
|
||||
# Function to run EPD with 1E + 1P + 1D
|
||||
run_epd_1e_1p_1d() {
|
||||
echo "================================"
|
||||
echo "Running EPD (1E + 1P + 1D)"
|
||||
echo "================================"
|
||||
|
||||
cleanup_instances
|
||||
rm -rf "$EC_SHARED_STORAGE_PATH"
|
||||
mkdir -p "$EC_SHARED_STORAGE_PATH"
|
||||
|
||||
local ENCODE_PORT=$ENCODE_PORT
|
||||
local PREFILL_PORT=$PREFILL_PORT
|
||||
local DECODE_PORT=$DECODE_PORT
|
||||
local PROXY_PORT=$ENDPOINT_PORT
|
||||
|
||||
declare -a PIDS=()
|
||||
|
||||
# Start encoder instance
|
||||
echo "Starting encoder instance on GPU $GPU_E, port $ENCODE_PORT"
|
||||
env "$DEVICE_AFFINITY_ENV=$GPU_E" vllm serve "$MODEL" \
|
||||
--port "$ENCODE_PORT" \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--enforce-eager \
|
||||
--gpu-memory-utilization 0.01 \
|
||||
--enable-request-id-headers \
|
||||
--no-enable-prefix-caching \
|
||||
--max-num-batched-tokens 114688 \
|
||||
--max-num-seqs "$MAX_NUM_SEQS" \
|
||||
--allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
|
||||
--ec-transfer-config '{
|
||||
"ec_connector": "ECExampleConnector",
|
||||
"ec_role": "ec_producer",
|
||||
"ec_connector_extra_config": {
|
||||
"shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
|
||||
}
|
||||
}' \
|
||||
> "$LOG_PATH"/1e1p1d_encoder.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Start prefill instance
|
||||
echo "Starting prefill instance on GPU $GPU_P, port $PREFILL_PORT"
|
||||
env "$DEVICE_AFFINITY_ENV=$GPU_P" \
|
||||
VLLM_NIXL_SIDE_CHANNEL_PORT=5559 \
|
||||
vllm serve "$MODEL" \
|
||||
--port "$PREFILL_PORT" \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--enforce-eager \
|
||||
--enable-mm-embeds \
|
||||
--gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
|
||||
--enable-request-id-headers \
|
||||
--max-num-seqs "$MAX_NUM_SEQS" \
|
||||
--allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
|
||||
--ec-transfer-config '{
|
||||
"ec_connector": "ECExampleConnector",
|
||||
"ec_role": "ec_consumer",
|
||||
"ec_connector_extra_config": {
|
||||
"shared_storage_path": "'"$EC_SHARED_STORAGE_PATH"'"
|
||||
}
|
||||
}' \
|
||||
--kv-transfer-config '{
|
||||
"kv_connector": "NixlConnector",
|
||||
"kv_role": "kv_producer"
|
||||
}' \
|
||||
> "$LOG_PATH"/1e1p1d_prefill.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Start decode instance
|
||||
echo "Starting decode instance on GPU $GPU_D, port $DECODE_PORT"
|
||||
env "$DEVICE_AFFINITY_ENV=$GPU_D" \
|
||||
VLLM_NIXL_SIDE_CHANNEL_PORT=6000 \
|
||||
vllm serve "$MODEL" \
|
||||
--port "$DECODE_PORT" \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--enforce-eager \
|
||||
--enable-mm-embeds \
|
||||
--gpu-memory-utilization "$GPU_MEMORY_UTILIZATION" \
|
||||
--enable-request-id-headers \
|
||||
--max-num-seqs "$MAX_NUM_SEQS" \
|
||||
--allowed-local-media-path "${GIT_ROOT}"/tests/v1/ec_connector/integration \
|
||||
--kv-transfer-config '{
|
||||
"kv_connector": "NixlConnector",
|
||||
"kv_role": "kv_consumer"
|
||||
}' \
|
||||
> "$LOG_PATH"/1e1p1d_decode.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Wait for instances to start
|
||||
echo "Waiting for encoder instance..."
|
||||
wait_for_server "$ENCODE_PORT"
|
||||
echo "Waiting for prefill instance..."
|
||||
wait_for_server "$PREFILL_PORT"
|
||||
echo "Waiting for decode instance..."
|
||||
wait_for_server "$DECODE_PORT"
|
||||
|
||||
# Start proxy
|
||||
echo "Starting EPD proxy on port $PROXY_PORT"
|
||||
python "${GIT_ROOT}/examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py" \
|
||||
--host "0.0.0.0" \
|
||||
--port "$PROXY_PORT" \
|
||||
--encode-servers-urls "http://localhost:$ENCODE_PORT" \
|
||||
--prefill-servers-urls "http://localhost:$PREFILL_PORT" \
|
||||
--decode-servers-urls "http://localhost:$DECODE_PORT" \
|
||||
> "$LOG_PATH"/1e1p1d_proxy.log 2>&1 &
|
||||
PIDS+=($!)
|
||||
|
||||
# Wait for proxy
|
||||
echo "Waiting for proxy..."
|
||||
wait_for_server "$PROXY_PORT"
|
||||
|
||||
curl http://127.0.0.1:"$PROXY_PORT"/v1/models
|
||||
curl http://127.0.0.1:"$PROXY_PORT"/health
|
||||
echo ""
|
||||
|
||||
echo "All EPD (1E+1P+1D) services are up!"
|
||||
|
||||
# Run test in disagg mode
|
||||
echo "Running EPD (1E+1P+1D) correctness test..."
|
||||
|
||||
python "${GIT_ROOT}/tests/v1/ec_connector/integration/test_epd_correctness.py" \
|
||||
--service_url "http://localhost:$PROXY_PORT" \
|
||||
--model_name "$MODEL" \
|
||||
--mode disagg \
|
||||
--baseline_file "$BASELINE_PD_FILE" \
|
||||
"${TEST_FLAGS[@]}"
|
||||
|
||||
# Cleanup
|
||||
echo "✓✓ 1E+1P+1D Correctness Test finished"
|
||||
echo "Stopping EPD (1E+1P+1D) instances..."
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 2
|
||||
cleanup_instances
|
||||
}
|
||||
|
||||
# Main execution
|
||||
echo "================================"
|
||||
echo "EPD Correctness Test Suite"
|
||||
echo "Model: $MODEL"
|
||||
echo "================================"
|
||||
|
||||
# Step 1: Run baseline
|
||||
run_baseline
|
||||
|
||||
# Step 2: Test 1E + 1PD
|
||||
run_epd_1e_1pd
|
||||
|
||||
# Step 3: Test baseline 1P + 1D
|
||||
run_baseline_1p_1d
|
||||
|
||||
# # Step 4: Test 1E + 1P + 1D
|
||||
run_epd_1e_1p_1d
|
||||
|
||||
# Cleanup output file
|
||||
rm -f "$BASELINE_FILE"
|
||||
rm -f "$BASELINE_PD_FILE"
|
||||
|
||||
echo "================================"
|
||||
echo "✓✓ All EPD correctness tests finished!"
|
||||
echo "================================"
|
||||
@@ -1,323 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
#
|
||||
# Full-stack EPD validation with ECMooncakeConnector (Mooncake TransferEngine):
|
||||
# 1) Single-GPU baseline (multimodal) -> saves baseline JSON
|
||||
# 2) 1 Encoder + 1 PD with Mooncake EC -> compare outputs to baseline
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# ./tests/v1/ec_connector/integration/run_epd_mooncake_ec_full_pipeline.sh
|
||||
#
|
||||
# Env:
|
||||
# MODEL HF model id (default: Qwen/Qwen2.5-VL-3B-Instruct)
|
||||
# GPU_SINGLE / GPU_E / GPU_PD GPU ids (defaults 0 / 0 / 1)
|
||||
# ENDPOINT_PORT, ENCODE_PORT, PREFILL_DECODE_PORT
|
||||
# EC_MOONCAKE_RESERVATION_HOST consumer control host (default 127.0.0.1)
|
||||
# MOONCAKE_EC_PROTOCOL tcp | rdma (default tcp)
|
||||
# USE_MM_PROMPTS 1 (default) or 0 for text-only quick sanity
|
||||
# TIMEOUT_SECONDS wait_for_server timeout (default 1200)
|
||||
# SKIP_BASELINE set to 1 to reuse existing BASELINE_FILE
|
||||
# CONCURRENCY / REPEAT concurrent requests / rounds (defaults 3 / 2)
|
||||
# MAX_MODEL_LEN context length (default 16384)
|
||||
# E_CUDAGRAPH_MM_ENCODER enable encoder CUDA graphs (default 0)
|
||||
# E_MM_ENCODER_ATTN_BACKEND optional encoder attention backend override
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GIT_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)
|
||||
cd "$GIT_ROOT" || exit 1
|
||||
export PYTHONPATH="${GIT_ROOT}:${PYTHONPATH:-}"
|
||||
PYTHON_BIN="${PYTHON_BIN:-${GIT_ROOT}/.venv/bin/python}"
|
||||
|
||||
MODEL="${MODEL:-Qwen/Qwen2.5-VL-3B-Instruct}"
|
||||
USE_MM_PROMPTS="${USE_MM_PROMPTS:-1}"
|
||||
TEST_ARGS=(--concurrency "${CONCURRENCY:-3}" --repeat "${REPEAT:-2}")
|
||||
if [[ "$USE_MM_PROMPTS" == "1" ]]; then
|
||||
TEST_ARGS+=(--use_mm_prompts --mm_smoke_test)
|
||||
fi
|
||||
MAX_MODEL_LEN="${MAX_MODEL_LEN:-16384}"
|
||||
E_ATTN_ARGS=()
|
||||
if [[ -n "${E_MM_ENCODER_ATTN_BACKEND:-}" ]]; then
|
||||
E_ATTN_ARGS+=(--mm-encoder-attn-backend "$E_MM_ENCODER_ATTN_BACKEND")
|
||||
fi
|
||||
case "${E_CUDAGRAPH_MM_ENCODER:-0}" in
|
||||
0) E_COMPILATION_CONFIG='{"cudagraph_mm_encoder":false}' ;;
|
||||
1) E_COMPILATION_CONFIG='{"cudagraph_mm_encoder":true}' ;;
|
||||
*) echo "E_CUDAGRAPH_MM_ENCODER must be 0 or 1"; exit 1 ;;
|
||||
esac
|
||||
|
||||
GPU_SINGLE="${GPU_SINGLE:-0}"
|
||||
GPU_E="${GPU_E:-0}"
|
||||
GPU_PD="${GPU_PD:-1}"
|
||||
|
||||
ENCODE_PORT="${ENCODE_PORT:-19534}"
|
||||
PREFILL_DECODE_PORT="${PREFILL_DECODE_PORT:-19537}"
|
||||
ENDPOINT_PORT="${ENDPOINT_PORT:-10002}"
|
||||
BASELINE_PORT="${BASELINE_PORT:-10003}"
|
||||
|
||||
EC_MOONCAKE_RESERVATION_HOST="${EC_MOONCAKE_RESERVATION_HOST:-127.0.0.1}"
|
||||
EC_MOONCAKE_RESERVATION_PORT="${EC_MOONCAKE_RESERVATION_PORT:-19019}"
|
||||
MOONCAKE_EC_PROTOCOL="${MOONCAKE_EC_PROTOCOL:-tcp}"
|
||||
export EC_MOONCAKE_RESERVATION_HOST
|
||||
export EC_MOONCAKE_RESERVATION_PORT
|
||||
export MOONCAKE_EC_PROTOCOL
|
||||
if [[ "$MOONCAKE_EC_PROTOCOL" == "tcp" ]]; then
|
||||
# TransferEngine may otherwise auto-select RDMA on hosts with an HCA.
|
||||
export MC_FORCE_TCP=1
|
||||
else
|
||||
unset MC_FORCE_TCP
|
||||
fi
|
||||
|
||||
LOG_PATH="${LOG_PATH:-/tmp}"
|
||||
BASELINE_FILE="${BASELINE_FILE:-/tmp/vllm_epd_mooncake_baseline.txt}"
|
||||
TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-1200}"
|
||||
PIDS=()
|
||||
|
||||
mkdir -p "$LOG_PATH"
|
||||
|
||||
VLLM_SERVE=("$PYTHON_BIN" -m vllm.entrypoints.cli.main serve)
|
||||
|
||||
ENC_EC_JSON=$("$PYTHON_BIN" <<PY
|
||||
import json, os
|
||||
print(json.dumps({
|
||||
"ec_connector": "ECMooncakeConnector",
|
||||
"ec_role": "ec_producer",
|
||||
"ec_connector_extra_config": {
|
||||
"mooncake_protocol": os.environ.get("MOONCAKE_EC_PROTOCOL", "tcp"),
|
||||
},
|
||||
}, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
|
||||
PD_EC_JSON=$("$PYTHON_BIN" <<PY
|
||||
import json, os
|
||||
print(json.dumps({
|
||||
"ec_connector": "ECMooncakeConnector",
|
||||
"ec_role": "ec_consumer",
|
||||
"ec_ip": os.environ["EC_MOONCAKE_RESERVATION_HOST"],
|
||||
"ec_port": int(os.environ.get("EC_MOONCAKE_RESERVATION_PORT", "19019")),
|
||||
"ec_connector_extra_config": {
|
||||
"mooncake_protocol": os.environ.get("MOONCAKE_EC_PROTOCOL", "tcp"),
|
||||
},
|
||||
}, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
|
||||
EC_MOONCAKE_RESERVATION_ADDR=$("$PYTHON_BIN" <<'PY'
|
||||
import os
|
||||
|
||||
from vllm.utils.network_utils import make_zmq_path
|
||||
|
||||
print(make_zmq_path(
|
||||
"tcp", os.environ["EC_MOONCAKE_RESERVATION_HOST"],
|
||||
int(os.environ["EC_MOONCAKE_RESERVATION_PORT"]),
|
||||
))
|
||||
PY
|
||||
)
|
||||
|
||||
wait_for_server() {
|
||||
local port=$1
|
||||
local pid=$2
|
||||
local deadline=$((SECONDS + TIMEOUT_SECONDS))
|
||||
while ((SECONDS < deadline)); do
|
||||
kill -0 "$pid" 2>/dev/null || return 1
|
||||
if curl --max-time 2 -fsS "http://localhost:${port}/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cleanup_instances() {
|
||||
if ((${#PIDS[@]} == 0)); then
|
||||
return
|
||||
fi
|
||||
echo "Cleaning up tracked vLLM / proxy processes..."
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill "$pid" 2>/dev/null || true
|
||||
done
|
||||
for _ in {1..10}; do
|
||||
local alive=0
|
||||
for pid in "${PIDS[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
alive=1
|
||||
fi
|
||||
done
|
||||
((alive == 0)) && break
|
||||
sleep 1
|
||||
done
|
||||
for pid in "${PIDS[@]}"; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
fi
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
PIDS=()
|
||||
}
|
||||
|
||||
finish() {
|
||||
local status=$?
|
||||
cleanup_instances
|
||||
if ((status != 0)); then
|
||||
for log in "${LOG_PATH}"/mooncake_epd_*.log; do
|
||||
[[ -f "$log" ]] || continue
|
||||
echo "=== $log (last 100 lines) ==="
|
||||
tail -100 "$log"
|
||||
done
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
trap finish EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
run_baseline() {
|
||||
echo "================================"
|
||||
echo "BASELINE (single vLLM, MM if enabled)"
|
||||
echo "================================"
|
||||
cleanup_instances
|
||||
local PORT=$BASELINE_PORT
|
||||
echo "Starting baseline on GPU $GPU_SINGLE port $PORT"
|
||||
CUDA_VISIBLE_DEVICES="$GPU_SINGLE" "${VLLM_SERVE[@]}" "$MODEL" \
|
||||
--port "$PORT" \
|
||||
--gpu-memory-utilization 0.75 \
|
||||
--max-num-seqs 32 \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--no-enable-prefix-caching \
|
||||
--limit-mm-per-prompt '{"image":2,"video":0}' \
|
||||
--allowed-local-media-path "${GIT_ROOT}/tests/v1/ec_connector/integration" \
|
||||
>"${LOG_PATH}/mooncake_epd_baseline.log" 2>&1 &
|
||||
local BASELINE_PID=$!
|
||||
PIDS+=("$BASELINE_PID")
|
||||
echo "Waiting for baseline..."
|
||||
wait_for_server "$PORT" "$BASELINE_PID" || { echo "Baseline failed to start"; return 1; }
|
||||
curl -s "http://127.0.0.1:${PORT}/v1/models" | head -c 200 || true
|
||||
echo ""
|
||||
"$PYTHON_BIN" "${GIT_ROOT}/tests/v1/ec_connector/integration/test_epd_correctness.py" \
|
||||
--service_url "http://localhost:$PORT" \
|
||||
--model_name "$MODEL" \
|
||||
--mode baseline \
|
||||
--baseline_file "$BASELINE_FILE" \
|
||||
"${TEST_ARGS[@]}"
|
||||
cleanup_instances
|
||||
}
|
||||
|
||||
run_epd_mooncake() {
|
||||
echo "================================"
|
||||
echo "EPD 1E + 1PD with ECMooncakeConnector"
|
||||
echo "Reservation port on consumer: $EC_MOONCAKE_RESERVATION_PORT"
|
||||
echo "Mooncake protocol: $MOONCAKE_EC_PROTOCOL"
|
||||
echo "================================"
|
||||
cleanup_instances
|
||||
|
||||
echo "Starting ENCODER on GPU $GPU_E port $ENCODE_PORT"
|
||||
VLLM_SERVER_DEV_MODE=1 CUDA_VISIBLE_DEVICES="$GPU_E" "${VLLM_SERVE[@]}" "$MODEL" \
|
||||
--port "$ENCODE_PORT" \
|
||||
--gpu-memory-utilization 0.35 \
|
||||
--mm-tensor-ipc torch_shm \
|
||||
--enable-request-id-headers \
|
||||
--no-enable-prefix-caching \
|
||||
--max-num-batched-tokens "$MAX_MODEL_LEN" \
|
||||
--max-num-seqs 32 \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--limit-mm-per-prompt '{"image":2,"video":0}' \
|
||||
--allowed-local-media-path "${GIT_ROOT}/tests/v1/ec_connector/integration" \
|
||||
--ec-transfer-config "$ENC_EC_JSON" \
|
||||
--compilation-config "$E_COMPILATION_CONFIG" \
|
||||
"${E_ATTN_ARGS[@]}" \
|
||||
--worker-extension-cls tests.v1.ec_connector.integration.test_epd_correctness.EncoderGraphTestWorkerExtension \
|
||||
>"${LOG_PATH}/mooncake_epd_encoder.log" 2>&1 &
|
||||
local ENCODER_PID=$!
|
||||
PIDS+=("$ENCODER_PID")
|
||||
|
||||
echo "Starting PD on GPU $GPU_PD port $PREFILL_DECODE_PORT"
|
||||
CUDA_VISIBLE_DEVICES="$GPU_PD" "${VLLM_SERVE[@]}" "$MODEL" \
|
||||
--port "$PREFILL_DECODE_PORT" \
|
||||
--gpu-memory-utilization 0.75 \
|
||||
--mm-tensor-ipc torch_shm \
|
||||
--enable-mm-embeds \
|
||||
--enable-request-id-headers \
|
||||
--max-num-seqs 32 \
|
||||
--max-model-len "$MAX_MODEL_LEN" \
|
||||
--no-enable-prefix-caching \
|
||||
--limit-mm-per-prompt '{"image":2,"video":0}' \
|
||||
--allowed-local-media-path "${GIT_ROOT}/tests/v1/ec_connector/integration" \
|
||||
--ec-transfer-config "$PD_EC_JSON" \
|
||||
>"${LOG_PATH}/mooncake_epd_pd.log" 2>&1 &
|
||||
local PD_PID=$!
|
||||
PIDS+=("$PD_PID")
|
||||
|
||||
echo "Waiting for encoder..."
|
||||
wait_for_server "$ENCODE_PORT" "$ENCODER_PID" || { echo "Encoder failed to start"; return 1; }
|
||||
echo "Waiting for PD..."
|
||||
wait_for_server "$PREFILL_DECODE_PORT" "$PD_PID" || { echo "PD failed to start"; return 1; }
|
||||
|
||||
echo "Starting EPD proxy on $ENDPOINT_PORT"
|
||||
"$PYTHON_BIN" "${GIT_ROOT}/examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py" \
|
||||
--host "0.0.0.0" \
|
||||
--port "$ENDPOINT_PORT" \
|
||||
--encode-servers-urls "http://localhost:$ENCODE_PORT" \
|
||||
--prefill-servers-urls "disable" \
|
||||
--decode-servers-urls "http://localhost:$PREFILL_DECODE_PORT" \
|
||||
--ec-consumer-zmq-addrs \
|
||||
"$EC_MOONCAKE_RESERVATION_ADDR" \
|
||||
>"${LOG_PATH}/mooncake_epd_proxy.log" 2>&1 &
|
||||
local PROXY_PID=$!
|
||||
PIDS+=("$PROXY_PID")
|
||||
|
||||
echo "Waiting for proxy..."
|
||||
wait_for_server "$ENDPOINT_PORT" "$PROXY_PID" || { echo "Proxy failed to start"; return 1; }
|
||||
curl -s "http://127.0.0.1:${ENDPOINT_PORT}/health" || true
|
||||
echo ""
|
||||
|
||||
"$PYTHON_BIN" "${GIT_ROOT}/tests/v1/ec_connector/integration/test_epd_correctness.py" \
|
||||
--service_url "http://localhost:$ENDPOINT_PORT" \
|
||||
--model_name "$MODEL" \
|
||||
--mode disagg \
|
||||
--baseline_file "$BASELINE_FILE" \
|
||||
"${TEST_ARGS[@]}"
|
||||
|
||||
curl -fsS "http://localhost:${ENCODE_PORT}/collective_rpc" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"encoder_graph_stats"}' \
|
||||
>"${LOG_PATH}/encoder_graph_stats.json"
|
||||
"$PYTHON_BIN" - "${LOG_PATH}/encoder_graph_stats.json" "${E_CUDAGRAPH_MM_ENCODER:-0}" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
results = json.load(f)["results"]
|
||||
assert results, "No encoder workers returned graph statistics"
|
||||
for stats in results:
|
||||
if sys.argv[2] == "1":
|
||||
assert stats["is_captured"], stats
|
||||
assert stats["graph_hits"] > 0, stats
|
||||
else:
|
||||
assert stats["graph_hits"] == 0, stats
|
||||
print("Encoder graph statistics:", results)
|
||||
PY
|
||||
|
||||
cleanup_instances
|
||||
}
|
||||
|
||||
echo "================================"
|
||||
echo "1E + 1PD ECMooncake end-to-end correctness"
|
||||
echo "MODEL=$MODEL"
|
||||
echo "================================"
|
||||
|
||||
if [[ "${SKIP_BASELINE:-0}" != "1" ]]; then
|
||||
run_baseline
|
||||
else
|
||||
echo "SKIP_BASELINE=1 -> using existing $BASELINE_FILE"
|
||||
[[ -f "$BASELINE_FILE" ]] || { echo "Missing baseline file"; exit 1; }
|
||||
fi
|
||||
|
||||
run_epd_mooncake
|
||||
|
||||
echo "================================"
|
||||
echo "PASS: Mooncake EC EPD matches baseline"
|
||||
echo "Logs: ${LOG_PATH}/mooncake_epd_*.log"
|
||||
echo "================================"
|
||||
@@ -1,395 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""EPD Correctness Test.
|
||||
|
||||
Tests that EPD (Encoder-Prefill-Decode) disaggregation produces the same
|
||||
outputs as a baseline single instance.
|
||||
|
||||
Usage:
|
||||
# Baseline mode (saves outputs):
|
||||
python test_epd_correctness.py \
|
||||
--service_url http://localhost:8000 \
|
||||
--model_name Qwen/Qwen2.5-VL-3B-Instruct \
|
||||
--mode baseline \
|
||||
--baseline_file .vllm_epd_baseline.txt
|
||||
|
||||
# Disagg mode (compares outputs):
|
||||
python test_epd_correctness.py \
|
||||
--service_url http://localhost:8000 \
|
||||
--model_name Qwen/Qwen2.5-VL-3B-Instruct \
|
||||
--mode disagg \
|
||||
--baseline_file .vllm_epd_baseline.txt
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import openai
|
||||
import requests
|
||||
|
||||
from vllm.assets.image import ImageAsset
|
||||
from vllm.multimodal.utils import encode_image_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
|
||||
|
||||
|
||||
class EncoderGraphTestWorkerExtension:
|
||||
"""Expose encoder replay evidence through the existing test RPC endpoint."""
|
||||
|
||||
model_runner: "MMEncoderModelRunner"
|
||||
|
||||
def encoder_graph_stats(self):
|
||||
from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
|
||||
|
||||
runner = self.model_runner
|
||||
assert isinstance(runner, MMEncoderModelRunner)
|
||||
manager = runner.model_state.encoder_runner.cudagraph_manager
|
||||
if manager is None:
|
||||
return {"is_captured": False, "graph_hits": 0, "graph_misses": 0}
|
||||
return {"is_captured": manager.is_captured(), **manager.get_cumulative_stats()}
|
||||
|
||||
|
||||
MAX_OUTPUT_LEN = 256
|
||||
|
||||
# Sample prompts with multimodal content
|
||||
image_1 = ImageAsset("stop_sign").pil_image.resize((1280, 720))
|
||||
image_2 = ImageAsset("cherry_blossom").pil_image.resize((1280, 720))
|
||||
|
||||
image_local_path = f"{os.path.dirname(os.path.abspath(__file__))}/hato.jpg"
|
||||
|
||||
SAMPLE_PROMPTS_MM: list[dict] = [
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": encode_image_url(image_1)},
|
||||
},
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
],
|
||||
}
|
||||
],
|
||||
"description": "Single image query",
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": encode_image_url(image_2)},
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"file://{image_local_path}"},
|
||||
},
|
||||
{"type": "text", "text": "Describe these 2 images in detail."},
|
||||
],
|
||||
}
|
||||
],
|
||||
"description": "2 images with detailed query",
|
||||
},
|
||||
]
|
||||
|
||||
# Text-only prompts for mixed testing
|
||||
SAMPLE_PROMPTS_TEXT: list[dict] = [
|
||||
{
|
||||
"messages": [{"role": "user", "content": "What is the capital of France?"}],
|
||||
"description": "Simple text-only query",
|
||||
},
|
||||
{
|
||||
"messages": [
|
||||
{"role": "user", "content": "Explain quantum computing in simple terms."}
|
||||
],
|
||||
"description": "Text-only explanation request",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def check_vllm_server(url: str, timeout=5, retries=10) -> bool:
|
||||
"""Check if the vLLM server is ready.
|
||||
|
||||
Args:
|
||||
url: The URL to check (usually /health or /healthcheck endpoint)
|
||||
timeout: Timeout in seconds for each request
|
||||
retries: Number of retries if the server is not ready
|
||||
|
||||
Returns:
|
||||
True if the server is ready, False otherwise
|
||||
|
||||
"""
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
response = requests.get(url, timeout=timeout)
|
||||
if response.status_code == 200:
|
||||
print(f"Server is ready at {url}")
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
f"Attempt {attempt + 1}/{retries}: Server returned "
|
||||
f"status code {response.status_code}"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Attempt {attempt + 1}/{retries}: Error connecting: {e}")
|
||||
time.sleep(2) # Wait before retrying
|
||||
return False
|
||||
|
||||
|
||||
def run_chat_completion(
|
||||
base_url: str,
|
||||
model_name: str,
|
||||
messages: list,
|
||||
max_tokens: int = MAX_OUTPUT_LEN,
|
||||
) -> str:
|
||||
"""Run a chat completion request.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the vLLM server
|
||||
model_name: Name of the model
|
||||
messages: Messages for chat completion
|
||||
max_tokens: Maximum tokens to generate
|
||||
|
||||
Returns:
|
||||
Generated text content
|
||||
|
||||
"""
|
||||
with openai.OpenAI(
|
||||
api_key="EMPTY", base_url=base_url, timeout=120, max_retries=0
|
||||
) as client:
|
||||
completion = client.chat.completions.create(
|
||||
model=model_name,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.0,
|
||||
seed=42,
|
||||
)
|
||||
|
||||
content = completion.choices[0].message.content
|
||||
assert content, "Expected a nonempty completion"
|
||||
return content
|
||||
|
||||
|
||||
def main():
|
||||
"""Main test function."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="EPD correctness test - compare disagg vs baseline"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--service_url",
|
||||
type=str,
|
||||
required=True,
|
||||
help="The vLLM service URL (e.g., http://localhost:8000)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--model_name",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Model name",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
type=str,
|
||||
default="baseline",
|
||||
choices=["baseline", "baseline_pd", "disagg"],
|
||||
help="Mode: baseline/baseline_pd (saves outputs) or disagg (compares outputs)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--baseline_file",
|
||||
type=str,
|
||||
default=".vllm_epd_baseline.txt",
|
||||
help="File to save/load baseline outputs",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--use_mm_prompts",
|
||||
action="store_true",
|
||||
help="Use multimodal prompts (default: use text-only for quick testing)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--skip_two_image_prompt",
|
||||
action="store_true",
|
||||
help="Skip the two-image multimodal prompt",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--mm_smoke_test",
|
||||
action="store_true",
|
||||
help="Use three short, fixed-answer image cases, including duplicate images",
|
||||
)
|
||||
parser.add_argument("--concurrency", type=int, default=1)
|
||||
parser.add_argument("--repeat", type=int, default=1)
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.concurrency < 1 or args.repeat < 1:
|
||||
parser.error("--concurrency and --repeat must be positive")
|
||||
if args.mm_smoke_test and (not args.use_mm_prompts or args.skip_two_image_prompt):
|
||||
parser.error("--mm_smoke_test requires all multimodal prompts")
|
||||
|
||||
print(f"Service URL: {args.service_url}")
|
||||
print(f"Model: {args.model_name}")
|
||||
print(f"Mode: {args.mode}")
|
||||
print(f"Output file: {args.baseline_file}")
|
||||
print(f"Use MM prompts: {args.use_mm_prompts}")
|
||||
|
||||
# Determine health check endpoint
|
||||
if args.mode == "baseline":
|
||||
health_check_url = f"{args.service_url}/health"
|
||||
elif args.mode == "baseline_pd":
|
||||
# Nixl toy proxy use /healthcheck
|
||||
health_check_url = f"{args.service_url}/healthcheck"
|
||||
else:
|
||||
# Disagg EPD proxy uses /health
|
||||
health_check_url = f"{args.service_url}/health"
|
||||
if not os.path.exists(args.baseline_file):
|
||||
raise ValueError(
|
||||
f"In disagg mode, the output file {args.baseline_file} from "
|
||||
"baseline does not exist. Run baseline mode first."
|
||||
)
|
||||
|
||||
# Check if server is ready
|
||||
if not check_vllm_server(health_check_url):
|
||||
raise RuntimeError(f"vLLM server at {args.service_url} is not ready!")
|
||||
|
||||
# Select prompts to use
|
||||
if args.use_mm_prompts:
|
||||
test_prompts = (
|
||||
SAMPLE_PROMPTS_MM[:1] if args.skip_two_image_prompt else SAMPLE_PROMPTS_MM
|
||||
)
|
||||
print("Using multimodal prompts")
|
||||
else:
|
||||
test_prompts = SAMPLE_PROMPTS_TEXT
|
||||
print("Using text-only prompts for quick testing")
|
||||
|
||||
if args.mm_smoke_test:
|
||||
image = SAMPLE_PROMPTS_MM[0]["messages"][0]["content"][0]
|
||||
image_pair = SAMPLE_PROMPTS_MM[1]["messages"][0]["content"][:2]
|
||||
cases = [
|
||||
(
|
||||
"Single image",
|
||||
[image],
|
||||
"What word is on the red road sign? Reply with only that word "
|
||||
"in uppercase.",
|
||||
"STOP",
|
||||
),
|
||||
(
|
||||
"Two different images",
|
||||
image_pair,
|
||||
"Do these pictures contain both flowers and birds? "
|
||||
"Reply with only YES or NO.",
|
||||
"YES",
|
||||
),
|
||||
(
|
||||
"Same image twice",
|
||||
[image, image],
|
||||
"Read the red road sign in each image, in image order. Reply "
|
||||
"with only the two uppercase words separated by a comma and a space.",
|
||||
"STOP, STOP",
|
||||
),
|
||||
]
|
||||
test_prompts = [
|
||||
{
|
||||
"description": description,
|
||||
"expected": expected,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [*images, {"type": "text", "text": question}],
|
||||
}
|
||||
],
|
||||
}
|
||||
for description, images, question, expected in cases
|
||||
]
|
||||
|
||||
# Run completions
|
||||
service_url = f"{args.service_url}/v1"
|
||||
output_strs = {}
|
||||
|
||||
def complete(prompt_data):
|
||||
output = run_chat_completion(
|
||||
base_url=service_url,
|
||||
model_name=args.model_name,
|
||||
messages=prompt_data["messages"],
|
||||
max_tokens=16 if args.mm_smoke_test else MAX_OUTPUT_LEN,
|
||||
)
|
||||
if args.mm_smoke_test:
|
||||
output = output.strip()
|
||||
assert output == prompt_data["expected"], (
|
||||
f"{prompt_data['description']}: expected {prompt_data['expected']!r}, "
|
||||
f"got {output!r}"
|
||||
)
|
||||
return output
|
||||
|
||||
# Each round includes concurrent requests sharing image hashes; later rounds
|
||||
# exercise reuse after previous requests have finished.
|
||||
with ThreadPoolExecutor(max_workers=args.concurrency) as executor:
|
||||
for repeat in range(args.repeat):
|
||||
outputs = executor.map(complete, test_prompts)
|
||||
for prompt_data, output_str in zip(test_prompts, outputs):
|
||||
key = prompt_data["description"]
|
||||
if args.repeat > 1:
|
||||
key = f"{key} (round {repeat + 1})"
|
||||
output_strs[key] = output_str
|
||||
print(f"{key}: {output_str}")
|
||||
|
||||
if args.mode in ("baseline", "baseline_pd"):
|
||||
# Baseline mode: Save outputs
|
||||
print(f"\nSaving baseline outputs to {args.baseline_file}")
|
||||
try:
|
||||
with open(args.baseline_file, "w") as json_file:
|
||||
json.dump(output_strs, json_file, indent=4)
|
||||
print("✅ Baseline outputs saved successfully")
|
||||
except OSError as e:
|
||||
print(f"Error writing to file: {e}")
|
||||
raise
|
||||
else:
|
||||
# Disagg mode: Load and compare outputs
|
||||
print(f"\nLoading baseline outputs from {args.baseline_file}")
|
||||
baseline_outputs = None
|
||||
try:
|
||||
with open(args.baseline_file) as json_file:
|
||||
baseline_outputs = json.load(json_file)
|
||||
except OSError as e:
|
||||
print(f"Error reading from file: {e}")
|
||||
raise
|
||||
|
||||
# Verify outputs match
|
||||
print("\nComparing disagg outputs with baseline...")
|
||||
assert isinstance(baseline_outputs, dict), "Baseline outputs should be a dict"
|
||||
assert len(baseline_outputs) == len(output_strs), (
|
||||
f"Length mismatch: baseline has {len(baseline_outputs)}, "
|
||||
f"disagg has {len(output_strs)}"
|
||||
)
|
||||
|
||||
all_match = True
|
||||
for key, baseline_output in baseline_outputs.items():
|
||||
assert key in output_strs, f"{key} not in disagg outputs"
|
||||
|
||||
disagg_output = output_strs[key]
|
||||
if baseline_output == disagg_output:
|
||||
print(f"✅ {key}: MATCH")
|
||||
else:
|
||||
print(f"❌ {key}: MISMATCH")
|
||||
print(f" Baseline: {baseline_output}")
|
||||
print(f" Disagg: {disagg_output}")
|
||||
all_match = False
|
||||
|
||||
assert all_match, "❌❌Disagg outputs do not match baseline!❌❌"
|
||||
if all_match:
|
||||
print("\n✅ All outputs match! Test PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,389 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for EmbeddingCache.
|
||||
|
||||
Pure integer bookkeeping — no mmap, torch, or CUDA — so these run anywhere.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.embedding_cache import (
|
||||
EmbeddingCache,
|
||||
)
|
||||
|
||||
|
||||
def _cache(num_blocks: int = 8) -> EmbeddingCache:
|
||||
return EmbeddingCache(num_blocks)
|
||||
|
||||
|
||||
# ── alloc ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_alloc_returns_unique_ascending_block_ids():
|
||||
cache = _cache()
|
||||
entry = cache.alloc("a", 3)
|
||||
assert entry is not None
|
||||
assert len(entry.block_ids) == 3
|
||||
assert len(set(entry.block_ids)) == 3
|
||||
assert all(0 <= i < 8 for i in entry.block_ids)
|
||||
# Ascending order lets consumers detect which blocks are adjacent.
|
||||
assert list(entry.block_ids) == sorted(entry.block_ids)
|
||||
|
||||
|
||||
def test_alloc_returns_tuple():
|
||||
cache = _cache()
|
||||
entry = cache.alloc("a", 2)
|
||||
assert isinstance(entry.block_ids, tuple)
|
||||
|
||||
|
||||
def test_alloc_returns_none_when_full():
|
||||
cache = _cache(num_blocks=4)
|
||||
cache.alloc("a", 4)
|
||||
assert cache.alloc("b", 1) is None
|
||||
|
||||
|
||||
def test_alloc_asserts_duplicate_key():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
with pytest.raises(AssertionError, match="duplicate alloc"):
|
||||
cache.alloc("a", 2)
|
||||
|
||||
|
||||
def test_alloc_asserts_n_blocks_exceeds_capacity():
|
||||
cache = _cache(num_blocks=4)
|
||||
with pytest.raises(AssertionError, match="capacity"):
|
||||
cache.alloc("a", 5)
|
||||
|
||||
|
||||
def test_alloc_zero_blocks():
|
||||
cache = _cache()
|
||||
entry = cache.alloc("a", 0)
|
||||
assert entry is not None
|
||||
assert entry.block_ids == ()
|
||||
|
||||
|
||||
def test_alloc_starts_not_ready():
|
||||
cache = _cache()
|
||||
entry = cache.alloc("a", 2)
|
||||
assert not entry.ready
|
||||
assert not entry.evictable
|
||||
|
||||
|
||||
# ── get ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_missing_returns_none():
|
||||
cache = _cache()
|
||||
assert cache.get("a") is None
|
||||
|
||||
|
||||
def test_get_returns_entry():
|
||||
cache = _cache()
|
||||
alloc_entry = cache.alloc("a", 2)
|
||||
got = cache.get("a")
|
||||
assert got is alloc_entry
|
||||
|
||||
|
||||
def test_get_returns_not_ready_entry():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
entry = cache.get("a")
|
||||
assert entry is not None
|
||||
assert not entry.ready
|
||||
|
||||
|
||||
# ── mark_ready ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mark_ready_transitions():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
entry = cache.get("a")
|
||||
assert not entry.ready
|
||||
cache.mark_ready("a")
|
||||
assert entry.ready
|
||||
assert entry.evictable
|
||||
|
||||
|
||||
def test_mark_ready_asserts_already_ready():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
with pytest.raises(AssertionError, match="already-ready"):
|
||||
cache.mark_ready("a")
|
||||
|
||||
|
||||
def test_mark_ready_asserts_missing_key():
|
||||
cache = _cache()
|
||||
with pytest.raises(KeyError):
|
||||
cache.mark_ready("nonexistent")
|
||||
|
||||
|
||||
# ── pin / unpin ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_pin_asserts_not_ready():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
with pytest.raises(AssertionError, match="not-ready"):
|
||||
cache.pin("a")
|
||||
|
||||
|
||||
def test_pin_unpin_basic():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
cache.pin("a")
|
||||
entry = cache.get("a")
|
||||
assert not entry.evictable
|
||||
cache.unpin("a")
|
||||
assert entry.evictable
|
||||
|
||||
|
||||
def test_nested_pin():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
cache.pin("a")
|
||||
cache.pin("a")
|
||||
cache.unpin("a")
|
||||
entry = cache.get("a")
|
||||
assert not entry.evictable # still pinned (count=1)
|
||||
cache.unpin("a")
|
||||
assert entry.evictable
|
||||
|
||||
|
||||
def test_unpin_asserts_on_unpinned():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
with pytest.raises(AssertionError, match="unpinned"):
|
||||
cache.unpin("a")
|
||||
|
||||
|
||||
# ── has_held_entries ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_has_held_entries_empty_cache():
|
||||
cache = _cache()
|
||||
assert cache.has_held_entries() is False
|
||||
|
||||
|
||||
def test_has_held_entries_true_while_not_ready():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2) # not ready → held
|
||||
assert cache.has_held_entries() is True
|
||||
cache.mark_ready("a") # ready + unpinned → settled
|
||||
assert cache.has_held_entries() is False
|
||||
|
||||
|
||||
def test_has_held_entries_true_while_pinned():
|
||||
cache = _cache()
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
assert cache.has_held_entries() is False
|
||||
cache.pin("a") # pinned → held
|
||||
assert cache.has_held_entries() is True
|
||||
cache.unpin("a")
|
||||
assert cache.has_held_entries() is False
|
||||
|
||||
|
||||
# ── eviction ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_eviction_frees_ready_unpinned_fifo():
|
||||
cache = _cache(num_blocks=4)
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
cache.alloc("b", 2)
|
||||
cache.mark_ready("b")
|
||||
# Pool full. Alloc should evict "a" (oldest).
|
||||
entry_c = cache.alloc("c", 2)
|
||||
assert entry_c is not None
|
||||
assert cache.get("a") is None # evicted
|
||||
assert cache.get("b") is not None # still there
|
||||
|
||||
|
||||
def test_eviction_skips_pinned():
|
||||
cache = _cache(num_blocks=4)
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
cache.pin("a")
|
||||
cache.alloc("b", 2)
|
||||
cache.mark_ready("b")
|
||||
# "a" is pinned, so "b" should be evicted instead.
|
||||
entry_c = cache.alloc("c", 2)
|
||||
assert entry_c is not None
|
||||
assert cache.get("a") is not None # pinned, survived
|
||||
assert cache.get("b") is None # evicted
|
||||
cache.unpin("a")
|
||||
|
||||
|
||||
def test_eviction_skips_not_ready():
|
||||
cache = _cache(num_blocks=4)
|
||||
cache.alloc("a", 2) # not ready
|
||||
cache.alloc("b", 2)
|
||||
cache.mark_ready("b")
|
||||
# "a" is not ready (non-evictable), "b" is evictable.
|
||||
entry_c = cache.alloc("c", 2)
|
||||
assert entry_c is not None
|
||||
assert cache.get("a") is not None # not ready, survived
|
||||
assert cache.get("b") is None # evicted
|
||||
cache.mark_ready("a") # clean up
|
||||
|
||||
|
||||
def test_eviction_returns_none_if_all_non_evictable():
|
||||
cache = _cache(num_blocks=4)
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
cache.pin("a")
|
||||
cache.alloc("b", 2) # not ready
|
||||
# Both entries are non-evictable (a=pinned, b=not-ready).
|
||||
assert cache.alloc("c", 1) is None
|
||||
# Fail-fast: nothing was evicted.
|
||||
assert cache.get("a") is not None
|
||||
assert cache.get("b") is not None
|
||||
cache.unpin("a")
|
||||
|
||||
|
||||
def test_fail_fast_no_wasted_eviction():
|
||||
cache = _cache(num_blocks=6)
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a") # evictable: 2 blocks
|
||||
cache.alloc("b", 2) # not ready
|
||||
cache.alloc("c", 2) # not ready
|
||||
# free=0, evictable=2, need 3 -> cannot succeed
|
||||
assert cache.alloc("d", 3) is None
|
||||
# "a" was NOT evicted despite being evictable
|
||||
assert cache.get("a") is not None
|
||||
|
||||
|
||||
def test_eviction_multiple_entries():
|
||||
cache = _cache(num_blocks=6)
|
||||
cache.alloc("a", 2)
|
||||
cache.mark_ready("a")
|
||||
cache.alloc("b", 2)
|
||||
cache.mark_ready("b")
|
||||
cache.alloc("c", 2)
|
||||
cache.mark_ready("c")
|
||||
# Need 4 blocks: must evict both "a" and "b".
|
||||
entry_d = cache.alloc("d", 4)
|
||||
assert entry_d is not None
|
||||
assert cache.get("a") is None
|
||||
assert cache.get("b") is None
|
||||
assert cache.get("c") is not None
|
||||
|
||||
|
||||
# ── thread safety ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_concurrent_pin_unpin():
|
||||
cache = _cache(num_blocks=8)
|
||||
cache.alloc("a", 4)
|
||||
cache.mark_ready("a")
|
||||
errors: list[Exception] = []
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def worker():
|
||||
barrier.wait()
|
||||
try:
|
||||
for _ in range(500):
|
||||
cache.pin("a")
|
||||
cache.unpin("a")
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, errors
|
||||
entry = cache.get("a")
|
||||
assert entry.evictable # all pins balanced
|
||||
|
||||
|
||||
def test_concurrent_alloc_evict():
|
||||
cache = _cache(num_blocks=4)
|
||||
errors: list[Exception] = []
|
||||
barrier = threading.Barrier(4)
|
||||
counter = {"i": 0}
|
||||
counter_lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
barrier.wait()
|
||||
try:
|
||||
for _ in range(200):
|
||||
with counter_lock:
|
||||
key = f"k{counter['i']}"
|
||||
counter["i"] += 1
|
||||
entry = cache.alloc(key, 1)
|
||||
if entry is not None:
|
||||
cache.mark_ready(key)
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, errors
|
||||
|
||||
|
||||
# ── discard ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_discard_not_ready_returns_blocks():
|
||||
cache = EmbeddingCache(num_blocks=4)
|
||||
entry = cache.alloc("h", 3)
|
||||
assert entry is not None
|
||||
cache.discard("h")
|
||||
assert cache.get("h") is None
|
||||
# All blocks reclaimed: a fresh 4-block alloc now fits.
|
||||
assert cache.alloc("h2", 4) is not None
|
||||
|
||||
|
||||
def test_discard_rejects_ready_entry():
|
||||
cache = EmbeddingCache(num_blocks=4)
|
||||
cache.alloc("h", 1)
|
||||
cache.mark_ready("h")
|
||||
with pytest.raises(AssertionError):
|
||||
cache.discard("h")
|
||||
|
||||
|
||||
def test_discard_rejects_absent_key():
|
||||
cache = EmbeddingCache(num_blocks=4)
|
||||
with pytest.raises(KeyError):
|
||||
cache.discard("nope")
|
||||
|
||||
|
||||
# ── pin_if_ready ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_pin_if_ready_pins_and_returns_entry():
|
||||
cache = EmbeddingCache(num_blocks=4)
|
||||
cache.alloc("h", 2)
|
||||
cache.mark_ready("h")
|
||||
entry = cache.pin_if_ready("h")
|
||||
assert entry is not None and entry.ready and len(entry.block_ids) == 2
|
||||
assert cache.alloc("h2", 4) is None # pinned ⇒ not evictable
|
||||
|
||||
|
||||
def test_pin_if_ready_none_when_absent():
|
||||
cache = EmbeddingCache(num_blocks=4)
|
||||
assert cache.pin_if_ready("nope") is None
|
||||
|
||||
|
||||
def test_pin_if_ready_returns_unpinned_entry_when_not_ready():
|
||||
"""The producer needs "present but not ready" told apart from "absent":
|
||||
the first is NACKed as retryable, the second as a miss."""
|
||||
cache = EmbeddingCache(num_blocks=4)
|
||||
cache.alloc("h", 1) # not-ready
|
||||
entry = cache.pin_if_ready("h")
|
||||
assert entry is not None and not entry.ready
|
||||
assert cache.alloc("h2", 4) is None # not-ready ⇒ still not evictable
|
||||
@@ -1,466 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import uuid
|
||||
|
||||
import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
|
||||
from tests.v1.ec_connector.unit.utils import create_ec_vllm_config
|
||||
from vllm.config.ec_transfer import ECRole
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
|
||||
ECCPUWorkerMetadata,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
|
||||
ECSharedRegion,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import ECCPUScheduler
|
||||
|
||||
_N = 16
|
||||
_BS = 64
|
||||
|
||||
|
||||
class _Pos:
|
||||
def __init__(self, offset, length):
|
||||
self.offset = offset
|
||||
self.length = length
|
||||
|
||||
def get_num_embeds(self):
|
||||
return self.length
|
||||
|
||||
|
||||
class _Feature:
|
||||
def __init__(self, mm_hash, length=1, identifier=None):
|
||||
self.mm_hash = mm_hash
|
||||
self.identifier = identifier if identifier is not None else mm_hash
|
||||
self.mm_position = _Pos(0, length)
|
||||
self.modality = "image"
|
||||
|
||||
|
||||
class _Request:
|
||||
_counter = 0
|
||||
|
||||
def __init__(self, features, request_id=None):
|
||||
self.mm_features = features
|
||||
if request_id is None:
|
||||
_Request._counter += 1
|
||||
self.request_id = f"req_{_Request._counter}"
|
||||
else:
|
||||
self.request_id = request_id
|
||||
|
||||
|
||||
class _WorkerOutput:
|
||||
"""Stand-in for ECConnectorOutput carrying a completion report.
|
||||
|
||||
`saves` are mm_hashes; `loads` are transfer ids. One entry per reporting
|
||||
rank, so a list holding the same id twice is two ranks reporting it.
|
||||
"""
|
||||
|
||||
def __init__(self, *, saves=None, loads=None):
|
||||
self.ec_connector_worker_meta = ECCPUWorkerMetadata(
|
||||
completed_saves=list(saves or []),
|
||||
completed_loads=list(loads or []),
|
||||
)
|
||||
|
||||
|
||||
def _make_scheduler(
|
||||
monkeypatch,
|
||||
num_blocks=_N,
|
||||
*,
|
||||
ec_role: ECRole = "ec_both",
|
||||
tensor_parallel_size=1,
|
||||
) -> ECCPUScheduler:
|
||||
region = ECSharedRegion(
|
||||
engine_id=str(uuid.uuid4()),
|
||||
num_blocks=num_blocks,
|
||||
block_size_bytes=_BS,
|
||||
)
|
||||
monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: region)
|
||||
|
||||
return ECCPUScheduler(
|
||||
create_ec_vllm_config(
|
||||
ec_role=ec_role,
|
||||
tensor_parallel_size=tensor_parallel_size,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _load_ids(meta) -> list[int]:
|
||||
"""Transfer ids the scheduler dispatched in this step's metadata."""
|
||||
return [transfer_id for transfer_id, _, _ in meta.loads.values()]
|
||||
|
||||
|
||||
def _load_blocks(meta, mm_hash: str) -> list[int]:
|
||||
"""Block ids the scheduler dispatched for one load."""
|
||||
return meta.loads[mm_hash][1]
|
||||
|
||||
|
||||
def _seed_cached(s: ECCPUScheduler, mm_hash: str, n_blocks: int):
|
||||
"""Pre-populate a ready cache entry backed by real blocks."""
|
||||
s._cache.alloc(mm_hash, n_blocks, (n_blocks, _BS // s._dtype.itemsize))
|
||||
s._cache.mark_ready(mm_hash)
|
||||
|
||||
|
||||
def test_offload_reuse_cycle(monkeypatch):
|
||||
s = _make_scheduler(monkeypatch)
|
||||
# Two blocks, so the reload also pins down that every allocated block id
|
||||
# comes back in meta.loads, not just the first.
|
||||
req = _Request([_Feature("h1", length=2)])
|
||||
|
||||
# Step A: first sight — allocate and save.
|
||||
assert s.has_cache_item("h1") is False
|
||||
assert s.ensure_cache_available(req, 0) is True
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta_a = s.build_connector_meta(scheduler_output=None)
|
||||
assert "h1" in meta_a.saves
|
||||
assert meta_a.loads == {}
|
||||
# Not ready until the worker reports the save memcpy complete.
|
||||
assert s.has_cache_item("h1") is False
|
||||
|
||||
# Worker reports the save copy finished → entry becomes ready.
|
||||
s.update_connector_output(_WorkerOutput(saves=["h1"]))
|
||||
assert s.has_cache_item("h1") is True
|
||||
|
||||
# Step C: reload now works — entry is ready at update_state_after_alloc time.
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta_c = s.build_connector_meta(scheduler_output=None)
|
||||
assert "h1" in meta_c.loads
|
||||
assert _load_blocks(meta_c, "h1") == meta_a.saves["h1"]
|
||||
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_has_cache_item_false_when_not_consumer(monkeypatch):
|
||||
s = _make_scheduler(monkeypatch, ec_role="ec_producer")
|
||||
assert s.has_cache_item("anything") is False
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_connector_keys_on_identifier_not_mm_hash(monkeypatch):
|
||||
"""The connector must key the encoder cache on feature.identifier (what
|
||||
has_cache_item is called with), NOT feature.mm_hash."""
|
||||
s = _make_scheduler(monkeypatch)
|
||||
req = _Request([_Feature("PROC_KEY", length=1, identifier="ENC_KEY")])
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert "ENC_KEY" in meta.saves
|
||||
assert "PROC_KEY" not in meta.saves
|
||||
# Not ready until the completion report.
|
||||
assert s.has_cache_item("ENC_KEY") is False
|
||||
# After the report it becomes ready — keyed on identifier.
|
||||
s.update_connector_output(_WorkerOutput(saves=["ENC_KEY"]))
|
||||
assert s.has_cache_item("ENC_KEY") is True
|
||||
assert s.has_cache_item("PROC_KEY") is False
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_cpu_region_fifo_eviction(monkeypatch):
|
||||
# Region holds exactly 2 one-block encodings.
|
||||
s = _make_scheduler(monkeypatch, num_blocks=2)
|
||||
|
||||
# Fill with A and B, marking each ready via a completion report.
|
||||
for h in ("A", "B"):
|
||||
s.update_state_after_alloc(_Request([_Feature(h, length=1)]), 0)
|
||||
s.build_connector_meta(scheduler_output=None)
|
||||
s.update_connector_output(_WorkerOutput(saves=[h]))
|
||||
assert s.has_cache_item("A") is True
|
||||
assert s.has_cache_item("B") is True
|
||||
|
||||
# Allocate C: must evict A (oldest) to make room.
|
||||
s.update_state_after_alloc(_Request([_Feature("C", length=1)]), 0)
|
||||
s.build_connector_meta(scheduler_output=None)
|
||||
assert s.has_cache_item("A") is False # evicted
|
||||
assert s.has_cache_item("B") is True
|
||||
|
||||
|
||||
def test_multiple_mm_items_per_request(monkeypatch):
|
||||
s = _make_scheduler(monkeypatch)
|
||||
req = _Request([_Feature("h1", length=1), _Feature("h2", length=1)])
|
||||
s.update_state_after_alloc(req, 0)
|
||||
s.update_state_after_alloc(req, 1)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert set(meta.saves) == {"h1", "h2"}
|
||||
|
||||
# Worker reports both saves complete.
|
||||
s.update_connector_output(_WorkerOutput(saves=["h1", "h2"]))
|
||||
assert s.has_cache_item("h1") is True
|
||||
assert s.has_cache_item("h2") is True
|
||||
|
||||
# Now reload both.
|
||||
s.update_state_after_alloc(req, 0)
|
||||
s.update_state_after_alloc(req, 1)
|
||||
meta2 = s.build_connector_meta(scheduler_output=None)
|
||||
assert set(meta2.loads) == {"h1", "h2"}
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_loads_only_serves_hashes_touched_this_step(monkeypatch):
|
||||
s = _make_scheduler(monkeypatch)
|
||||
_seed_cached(s, "a", n_blocks=2)
|
||||
_seed_cached(s, "b", n_blocks=2)
|
||||
|
||||
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert "a" in meta.loads
|
||||
assert "b" not in meta.loads
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_repeated_reload_same_step_loads_once(monkeypatch):
|
||||
"""The same mm_hash requested twice in one step must appear in
|
||||
meta.loads exactly once."""
|
||||
s = _make_scheduler(monkeypatch)
|
||||
_seed_cached(s, "a", n_blocks=2)
|
||||
|
||||
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
|
||||
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert list(meta.loads.keys()).count("a") == 1
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_load_not_emitted_for_uncached_entry(monkeypatch):
|
||||
"""A feature that was never saved must not appear in meta.loads."""
|
||||
s = _make_scheduler(monkeypatch)
|
||||
s.update_state_after_alloc(_Request([_Feature("missing")]), 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert meta.loads == {}
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_load_pin_protects_blocks_until_completion(monkeypatch):
|
||||
"""Loaded blocks stay pinned until the worker reports the load memcpy
|
||||
complete. Observable: a save that needs eviction cannot reclaim a
|
||||
still-pinned loaded entry, but can once the completion report arrives."""
|
||||
# 2 blocks, both occupied by "a" (ready).
|
||||
s = _make_scheduler(monkeypatch, num_blocks=2)
|
||||
_seed_cached(s, "a", n_blocks=2)
|
||||
|
||||
# Load "a" — pins it.
|
||||
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert "a" in meta.loads
|
||||
(transfer_id,) = _load_ids(meta)
|
||||
|
||||
# Try to save "b" (needs 2 blocks, but "a" is pinned) — must fail.
|
||||
s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
|
||||
meta2 = s.build_connector_meta(scheduler_output=None)
|
||||
assert "b" not in meta2.saves # can't evict pinned "a"
|
||||
|
||||
# The one participant reports the load complete → "a" unpins. Now "b" can
|
||||
# evict it.
|
||||
s.update_connector_output(_WorkerOutput(loads=[transfer_id]))
|
||||
s.update_state_after_alloc(_Request([_Feature("b", length=2)]), 0)
|
||||
meta3 = s.build_connector_meta(scheduler_output=None)
|
||||
assert "b" in meta3.saves
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def _can_evict(s: ECCPUScheduler, mm_hash: str, n_blocks: int) -> bool:
|
||||
"""Whether a new save of `mm_hash` can claim `n_blocks`, i.e. whether the
|
||||
blocks currently held by other entries are reclaimable."""
|
||||
s.update_state_after_alloc(_Request([_Feature(mm_hash, length=n_blocks)]), 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
return mm_hash in meta.saves
|
||||
|
||||
|
||||
def test_load_needs_every_participant_before_unpin(monkeypatch):
|
||||
"""With two participating ranks, one report must not release the pin.
|
||||
|
||||
This is the multi-rank case: each rank copies the same CPU blocks into its
|
||||
own GPU memory, so the blocks stay in use until the last rank is done.
|
||||
"""
|
||||
s = _make_scheduler(monkeypatch, num_blocks=2, tensor_parallel_size=2)
|
||||
_seed_cached(s, "a", n_blocks=2)
|
||||
|
||||
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
|
||||
(transfer_id,) = _load_ids(s.build_connector_meta(scheduler_output=None))
|
||||
|
||||
# First rank reports: still in use by the second, so "a" must hold.
|
||||
s.update_connector_output(_WorkerOutput(loads=[transfer_id]))
|
||||
assert _can_evict(s, "b", 2) is False
|
||||
assert s.has_pending_push_work() is True
|
||||
|
||||
# Second rank reports: now it is genuinely free.
|
||||
s.update_connector_output(_WorkerOutput(loads=[transfer_id]))
|
||||
assert s.has_pending_push_work() is False
|
||||
assert _can_evict(s, "c", 2) is True
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_late_report_does_not_release_a_later_load_of_same_hash(monkeypatch):
|
||||
"""A straggling report from one dispatch must not release the pin taken by
|
||||
a subsequent dispatch of the same mm_hash.
|
||||
|
||||
This is what the transfer id is for: mm_hash identifies a cache entry, not
|
||||
a transfer, so it cannot tell the two dispatches apart.
|
||||
"""
|
||||
s = _make_scheduler(monkeypatch, num_blocks=2, tensor_parallel_size=2)
|
||||
_seed_cached(s, "a", n_blocks=2)
|
||||
|
||||
# First dispatch, fully reported by both ranks → pin released.
|
||||
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
|
||||
(first_id,) = _load_ids(s.build_connector_meta(scheduler_output=None))
|
||||
s.update_connector_output(_WorkerOutput(loads=[first_id, first_id]))
|
||||
assert s.has_pending_push_work() is False
|
||||
|
||||
# Second dispatch of the same hash takes a fresh pin.
|
||||
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
|
||||
(second_id,) = _load_ids(s.build_connector_meta(scheduler_output=None))
|
||||
assert second_id != first_id
|
||||
|
||||
# A replayed report for the first dispatch must be ignored entirely.
|
||||
s.update_connector_output(_WorkerOutput(loads=[first_id, first_id]))
|
||||
assert s.has_pending_push_work() is True
|
||||
assert _can_evict(s, "b", 2) is False
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_region_full_skips_save_and_never_blocks(monkeypatch):
|
||||
"""When the region is fully occupied by pinned entries, new saves are
|
||||
silently skipped and ensure_cache_available never blocks."""
|
||||
s = _make_scheduler(monkeypatch, num_blocks=1)
|
||||
_seed_cached(s, "pinned", n_blocks=1)
|
||||
s._cache.pin("pinned")
|
||||
|
||||
req = _Request([_Feature("new", length=1)])
|
||||
assert s.ensure_cache_available(req, 0) is True
|
||||
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert "new" not in meta.saves
|
||||
assert s.has_cache_item("new") is False
|
||||
|
||||
s._cache.unpin("pinned")
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_producer_only_never_emits_loads(monkeypatch):
|
||||
"""A producer-only scheduler must never populate meta.loads, even when
|
||||
entries are ready."""
|
||||
s = _make_scheduler(monkeypatch, ec_role="ec_producer")
|
||||
req = _Request([_Feature("h1", length=1)])
|
||||
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert "h1" in meta.saves
|
||||
assert meta.loads == {}
|
||||
|
||||
s.update_connector_output(_WorkerOutput(saves=["h1"]))
|
||||
|
||||
# Even if we re-encounter the same feature, no load.
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta2 = s.build_connector_meta(scheduler_output=None)
|
||||
assert meta2.loads == {}
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_consumer_only_never_emits_saves(monkeypatch):
|
||||
"""A consumer-only scheduler must never populate meta.saves."""
|
||||
s = _make_scheduler(monkeypatch, ec_role="ec_consumer")
|
||||
_seed_cached(s, "a", n_blocks=1)
|
||||
|
||||
req = _Request([_Feature("a", length=1)])
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert meta.saves == {}
|
||||
assert "a" in meta.loads
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_consumer_only_has_cache_item(monkeypatch):
|
||||
"""Consumer-only: has_cache_item works normally for ready entries."""
|
||||
s = _make_scheduler(monkeypatch, ec_role="ec_consumer")
|
||||
assert s.has_cache_item("a") is False
|
||||
_seed_cached(s, "a", n_blocks=1)
|
||||
assert s.has_cache_item("a") is True
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_save_not_emitted_for_already_cached_entry(monkeypatch):
|
||||
"""An entry already in the cache (from a prior save) must not trigger
|
||||
a second allocation or appear in meta.saves again."""
|
||||
s = _make_scheduler(monkeypatch)
|
||||
req = _Request([_Feature("h1", length=1)])
|
||||
|
||||
# First encounter → save.
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta1 = s.build_connector_meta(scheduler_output=None)
|
||||
assert "h1" in meta1.saves
|
||||
|
||||
# Second encounter (same step or later) → no save.
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta2 = s.build_connector_meta(scheduler_output=None)
|
||||
assert "h1" not in meta2.saves
|
||||
s.shutdown()
|
||||
|
||||
|
||||
# ── completion reporting (update_connector_output) ──────────────────────────
|
||||
|
||||
|
||||
def test_update_connector_output_ignores_foreign_meta(monkeypatch):
|
||||
"""A worker output whose payload is not an ECCPUWorkerMetadata is a
|
||||
no-op — no cache mutation, no crash."""
|
||||
s = _make_scheduler(monkeypatch)
|
||||
_seed_cached(s, "a", n_blocks=1)
|
||||
|
||||
class _Foreign:
|
||||
ec_connector_worker_meta = object()
|
||||
|
||||
s.update_connector_output(_Foreign()) # must not raise
|
||||
assert s.has_cache_item("a") is True
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_update_connector_output_ignores_unknown_report(monkeypatch):
|
||||
"""Reports for a never-seen save hash or a transfer id this scheduler never
|
||||
dispatched are dropped rather than mutating unrelated state."""
|
||||
s = _make_scheduler(monkeypatch)
|
||||
s.update_connector_output(_WorkerOutput(saves=["gone"], loads=[4242]))
|
||||
assert s.has_cache_item("gone") is False
|
||||
s.shutdown()
|
||||
|
||||
|
||||
# ── has_pending_push_work ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_has_pending_push_work_tracks_inflight_save(monkeypatch):
|
||||
"""A dispatched-but-unconfirmed save keeps push work pending until the
|
||||
completion report marks it ready."""
|
||||
s = _make_scheduler(monkeypatch)
|
||||
assert s.has_pending_push_work() is False
|
||||
|
||||
s.update_state_after_alloc(_Request([_Feature("h1", length=1)]), 0)
|
||||
s.build_connector_meta(scheduler_output=None)
|
||||
assert s.has_pending_push_work() is True # save not yet confirmed
|
||||
|
||||
s.update_connector_output(_WorkerOutput(saves=["h1"]))
|
||||
assert s.has_pending_push_work() is False
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_has_pending_push_work_tracks_inflight_load(monkeypatch):
|
||||
"""A dispatched-but-unconfirmed load keeps push work pending until the
|
||||
unpin completion report arrives."""
|
||||
s = _make_scheduler(monkeypatch)
|
||||
_seed_cached(s, "a", n_blocks=1)
|
||||
assert s.has_pending_push_work() is False # ready + unpinned
|
||||
|
||||
s.update_state_after_alloc(_Request([_Feature("a")]), 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert s.has_pending_push_work() is True # pinned, awaiting unpin
|
||||
|
||||
s.update_connector_output(_WorkerOutput(loads=_load_ids(meta)))
|
||||
assert s.has_pending_push_work() is False
|
||||
s.shutdown()
|
||||
|
||||
|
||||
# ── shutdown ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_shutdown_disables_roles_and_cleans_region(monkeypatch):
|
||||
"""After shutdown the scheduler serves no items and the region is
|
||||
cleaned up."""
|
||||
s = _make_scheduler(monkeypatch, num_blocks=2)
|
||||
_seed_cached(s, "a", n_blocks=2)
|
||||
assert s.has_cache_item("a") is True
|
||||
|
||||
s.shutdown()
|
||||
assert s.has_cache_item("a") is False # consumer role disabled
|
||||
@@ -1,71 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorRole
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.connector import ECCPUConnector
|
||||
from vllm.distributed.ec_transfer.ec_connector.factory import ECConnectorFactory
|
||||
|
||||
|
||||
def _cfg():
|
||||
ec = MagicMock()
|
||||
ec.is_ec_producer = True
|
||||
ec.is_ec_consumer = True
|
||||
cfg = MagicMock()
|
||||
cfg.ec_transfer_config = ec
|
||||
return cfg
|
||||
|
||||
|
||||
def test_scheduler_role_builds_only_scheduler(monkeypatch):
|
||||
fake_sched = MagicMock()
|
||||
monkeypatch.setattr(ECCPUConnector, "_make_scheduler", lambda self, cfg: fake_sched)
|
||||
c = ECCPUConnector(_cfg(), ECConnectorRole.SCHEDULER)
|
||||
assert c.connector_scheduler is fake_sched
|
||||
assert c.connector_worker is None
|
||||
c.has_cache_item("x")
|
||||
fake_sched.has_cache_item.assert_called_once_with("x")
|
||||
|
||||
|
||||
def test_worker_role_builds_only_worker(monkeypatch):
|
||||
fake_worker = MagicMock()
|
||||
monkeypatch.setattr(ECCPUConnector, "_make_worker", lambda self, cfg: fake_worker)
|
||||
c = ECCPUConnector(_cfg(), ECConnectorRole.WORKER)
|
||||
assert c.connector_worker is fake_worker
|
||||
assert c.connector_scheduler is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [ECConnectorRole.SCHEDULER, ECConnectorRole.WORKER])
|
||||
def test_v1_model_runner_is_rejected(monkeypatch, role):
|
||||
"""The V1 runner never reports save completions, so refuse to start."""
|
||||
monkeypatch.setattr(
|
||||
ECCPUConnector, "_make_scheduler", lambda self, cfg: MagicMock()
|
||||
)
|
||||
monkeypatch.setattr(ECCPUConnector, "_make_worker", lambda self, cfg: MagicMock())
|
||||
cfg = _cfg()
|
||||
cfg.use_v2_model_runner = False
|
||||
|
||||
with pytest.raises(ValueError, match="V2 model runner"):
|
||||
ECCPUConnector(cfg, role)
|
||||
|
||||
|
||||
def test_factory_registered():
|
||||
cls = ECConnectorFactory._registry["ECCPUConnector"]()
|
||||
assert cls is ECCPUConnector
|
||||
|
||||
|
||||
def test_request_finished_forwards_to_scheduler(monkeypatch):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import vllm.distributed.ec_transfer.ec_connector.cpu.connector as conn_mod # noqa: F401
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorRole
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.connector import ECCPUConnector
|
||||
|
||||
fake_sched = MagicMock()
|
||||
fake_sched.request_finished.return_value = (False, {"h1": {"peer_port": 1}})
|
||||
monkeypatch.setattr(ECCPUConnector, "_make_scheduler", lambda self, cfg: fake_sched)
|
||||
c = ECCPUConnector(_cfg(), ECConnectorRole.SCHEDULER)
|
||||
req = MagicMock()
|
||||
assert c.request_finished(req) == (False, {"h1": {"peer_port": 1}})
|
||||
fake_sched.request_finished.assert_called_once_with(req)
|
||||
@@ -1,396 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for ECSharedRegion (the mmap substrate)."""
|
||||
|
||||
import contextlib
|
||||
import ctypes
|
||||
import errno
|
||||
import mmap
|
||||
import os
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.config import ParallelConfig
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu import common
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
|
||||
ECSharedRegion,
|
||||
_wait_for_file_size,
|
||||
)
|
||||
|
||||
|
||||
def _make_region(num_blocks: int = 8, block_size_bytes: int = 64) -> ECSharedRegion:
|
||||
return ECSharedRegion(
|
||||
engine_id=str(uuid.uuid4()),
|
||||
num_blocks=num_blocks,
|
||||
block_size_bytes=block_size_bytes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def region() -> ECSharedRegion:
|
||||
r = _make_region()
|
||||
yield r
|
||||
r.cleanup()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _region(**kwargs):
|
||||
"""Context manager: create one region, clean up on exit."""
|
||||
r = _make_region(**kwargs)
|
||||
try:
|
||||
yield r
|
||||
finally:
|
||||
r.cleanup()
|
||||
|
||||
|
||||
def _page_residency(mmap_obj: mmap.mmap, length: int) -> list[bool]:
|
||||
"""Return Linux page-residency bits for a writable mmap."""
|
||||
try:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
mincore = libc.mincore
|
||||
except AttributeError:
|
||||
pytest.skip("mincore is unavailable")
|
||||
|
||||
page_count = (length + mmap.PAGESIZE - 1) // mmap.PAGESIZE
|
||||
mincore.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.POINTER(ctypes.c_ubyte),
|
||||
]
|
||||
mincore.restype = ctypes.c_int
|
||||
vector = (ctypes.c_ubyte * page_count)()
|
||||
address = ctypes.addressof(ctypes.c_ubyte.from_buffer(mmap_obj))
|
||||
result = mincore(ctypes.c_void_p(address), length, vector)
|
||||
if result != 0:
|
||||
raise OSError(ctypes.get_errno())
|
||||
return [bool(value & 1) for value in vector]
|
||||
|
||||
|
||||
# ── mmap sharing between two instances ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_second_instance_opens_existing_file_and_shares_memory():
|
||||
instance_id = str(uuid.uuid4())
|
||||
r1 = ECSharedRegion(
|
||||
engine_id=instance_id,
|
||||
num_blocks=4,
|
||||
block_size_bytes=64,
|
||||
)
|
||||
try:
|
||||
r2 = ECSharedRegion(
|
||||
engine_id=instance_id,
|
||||
num_blocks=4,
|
||||
block_size_bytes=64,
|
||||
)
|
||||
try:
|
||||
assert r1._is_creator
|
||||
assert not r2._is_creator
|
||||
# Both map the same physical pages; writes via r1 are visible via r2.
|
||||
r1.blocks[0, :4] = torch.tensor([10, 20, 30, 40], dtype=torch.int8)
|
||||
assert r2.blocks[0, :4].tolist() == [10, 20, 30, 40]
|
||||
finally:
|
||||
r2.cleanup()
|
||||
finally:
|
||||
r1.cleanup()
|
||||
|
||||
|
||||
def test_dense_dp_engines_get_distinct_regions():
|
||||
"""Dense DP engines reset data_parallel_rank to 0 but share instance_id,
|
||||
so the region must be keyed by data_parallel_index."""
|
||||
instance_id = str(uuid.uuid4())
|
||||
regions = []
|
||||
try:
|
||||
for dp_index in range(2):
|
||||
parallel_config = ParallelConfig(
|
||||
data_parallel_size=2, data_parallel_rank=dp_index
|
||||
)
|
||||
parallel_config.reconfigure_for_independent_dp_rank()
|
||||
cfg = MagicMock()
|
||||
cfg.instance_id = instance_id
|
||||
cfg.parallel_config = parallel_config
|
||||
cfg.model_config.dtype = torch.float16
|
||||
cfg.model_config.get_inputs_embeds_size.return_value = 8
|
||||
cfg.ec_transfer_config.ec_connector_extra_config = {"ec_cpu_bytes": 64}
|
||||
regions.append(common.create_ec_shared_region(cfg))
|
||||
|
||||
assert all(r._is_creator for r in regions)
|
||||
assert regions[0]._mmap_path != regions[1]._mmap_path
|
||||
finally:
|
||||
for r in regions:
|
||||
r.cleanup()
|
||||
|
||||
|
||||
def test_only_creator_unlinks_file_on_cleanup():
|
||||
"""Critical contract: if the non-creator unlinks, the creator's mmap path
|
||||
becomes a dangling backing file and a third opener would create a new one
|
||||
out from under the creator."""
|
||||
instance_id = str(uuid.uuid4())
|
||||
r1 = ECSharedRegion(
|
||||
engine_id=instance_id,
|
||||
num_blocks=4,
|
||||
block_size_bytes=64,
|
||||
)
|
||||
path = r1._mmap_path
|
||||
r2 = ECSharedRegion(
|
||||
engine_id=instance_id,
|
||||
num_blocks=4,
|
||||
block_size_bytes=64,
|
||||
)
|
||||
|
||||
# Non-creator goes away first — file must still be on disk for r1.
|
||||
r2.cleanup()
|
||||
assert os.path.exists(path), "non-creator cleanup must not unlink the file"
|
||||
|
||||
# Creator goes away — file is removed.
|
||||
r1.cleanup()
|
||||
assert not os.path.exists(path), "creator cleanup must unlink the file"
|
||||
|
||||
|
||||
# ── _wait_for_file_size ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wait_for_file_size_returns_when_already_big_enough(tmp_path):
|
||||
"""The fast path: file already at expected size — return immediately."""
|
||||
p = tmp_path / "f.bin"
|
||||
p.write_bytes(b"\x00" * 128)
|
||||
fd = os.open(str(p), os.O_RDONLY)
|
||||
try:
|
||||
_wait_for_file_size(fd, expected_size=128, timeout=1.0) # must not raise
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def test_wait_for_file_size_times_out_when_file_stays_empty(tmp_path):
|
||||
p = tmp_path / "f.bin"
|
||||
p.write_bytes(b"")
|
||||
fd = os.open(str(p), os.O_RDONLY)
|
||||
try:
|
||||
with pytest.raises(TimeoutError):
|
||||
_wait_for_file_size(fd, expected_size=4096, timeout=0.05)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
# ── MADV_POPULATE_WRITE pre-faulting / fallback ──────────────────────────────
|
||||
|
||||
|
||||
def test_madvise_success_selects_madvise_population(monkeypatch):
|
||||
"""A successful probe must keep using MADV_POPULATE_WRITE — the fallback
|
||||
helper must never be invoked on a kernel that accepts the advice.
|
||||
|
||||
Spies on both helpers so we can verify call counts: 1 probe call over a
|
||||
single page + 1 populate call over the whole flat region, fallback 0 times.
|
||||
"""
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu import ec_shared_region as esr
|
||||
|
||||
madvise_calls: list[tuple[int, int, int]] = []
|
||||
fallback_calls: list[tuple[int, int]] = []
|
||||
|
||||
def _spy_madvise(mm, off, ln):
|
||||
madvise_calls.append((off, ln, id(mm)))
|
||||
|
||||
def _spy_fallback(mm, off, ln):
|
||||
fallback_calls.append((off, ln))
|
||||
|
||||
monkeypatch.setattr(esr, "_madvise_populate_write", _spy_madvise)
|
||||
monkeypatch.setattr(esr, "_fallback_populate_write", _spy_fallback)
|
||||
|
||||
num_blocks, block_size_bytes = 4, mmap.PAGESIZE
|
||||
with _region(num_blocks=num_blocks, block_size_bytes=block_size_bytes):
|
||||
total = num_blocks * block_size_bytes
|
||||
mmap_id = madvise_calls[0][2]
|
||||
assert madvise_calls == [
|
||||
(0, mmap.PAGESIZE, mmap_id), # probe
|
||||
(0, total, mmap_id), # flat pre-fault of the whole region
|
||||
]
|
||||
assert fallback_calls == [], (
|
||||
"native-path constructor must not invoke the fallback helper"
|
||||
)
|
||||
|
||||
|
||||
def test_madvise_einval_selects_fallback_for_whole_region(monkeypatch):
|
||||
"""An EINVAL probe must select the fallback for the whole flat region.
|
||||
|
||||
This is the regression under test: on Linux < 5.14 the constant is absent
|
||||
from the `mmap` module, so advice=23 reaches a kernel that rejects it and
|
||||
every ECSharedRegion construction used to abort with EINVAL.
|
||||
"""
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu import ec_shared_region as esr
|
||||
|
||||
fallback_calls: list[tuple[int, int]] = []
|
||||
real_fallback = esr._fallback_populate_write
|
||||
|
||||
def _raise_einval(mm, off, ln):
|
||||
raise OSError(errno.EINVAL, "simulated unsupported kernel")
|
||||
|
||||
def _spy_fallback(mm, off, ln):
|
||||
fallback_calls.append((off, ln))
|
||||
return real_fallback(mm, off, ln)
|
||||
|
||||
monkeypatch.setattr(esr, "_madvise_populate_write", _raise_einval)
|
||||
monkeypatch.setattr(esr, "_fallback_populate_write", _spy_fallback)
|
||||
|
||||
num_blocks, block_size_bytes = 4, mmap.PAGESIZE
|
||||
with _region(num_blocks=num_blocks, block_size_bytes=block_size_bytes) as r:
|
||||
assert fallback_calls == [(0, num_blocks * block_size_bytes)]
|
||||
# Region is still fully usable after taking the fallback path.
|
||||
r.blocks[0, :4] = torch.tensor([1, 2, 3, 4], dtype=torch.int8)
|
||||
assert r.blocks[0, :4].tolist() == [1, 2, 3, 4]
|
||||
|
||||
|
||||
def test_non_creator_does_not_probe_or_populate(monkeypatch):
|
||||
"""Only the creator pre-faults. A second opener must not probe the advice
|
||||
nor re-touch pages the creator already populated."""
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu import ec_shared_region as esr
|
||||
|
||||
instance_id = str(uuid.uuid4())
|
||||
r1 = ECSharedRegion(
|
||||
engine_id=instance_id, num_blocks=4, block_size_bytes=mmap.PAGESIZE
|
||||
)
|
||||
try:
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
esr,
|
||||
"_madvise_populate_write",
|
||||
lambda mm, off, ln: calls.append("madvise"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
esr,
|
||||
"_fallback_populate_write",
|
||||
lambda mm, off, ln: calls.append("fallback"),
|
||||
)
|
||||
r2 = ECSharedRegion(
|
||||
engine_id=instance_id, num_blocks=4, block_size_bytes=mmap.PAGESIZE
|
||||
)
|
||||
try:
|
||||
assert not r2._is_creator
|
||||
assert calls == []
|
||||
finally:
|
||||
r2.cleanup()
|
||||
finally:
|
||||
r1.cleanup()
|
||||
|
||||
|
||||
def test_fallback_populate_write_preserves_bytes_and_faults_pages():
|
||||
"""Fallback preserves existing bytes and touches every target page.
|
||||
|
||||
`|= 0` rather than `= 0` matters here: a peer worker may already have
|
||||
written EC data into the shared mmap before we pre-fault it.
|
||||
"""
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu import ec_shared_region as esr
|
||||
|
||||
size = 3 * mmap.PAGESIZE
|
||||
if not hasattr(mmap, "MADV_DONTNEED"):
|
||||
pytest.skip("MADV_DONTNEED is unavailable")
|
||||
|
||||
mmap_obj = mmap.mmap(
|
||||
-1,
|
||||
size,
|
||||
flags=mmap.MAP_SHARED,
|
||||
prot=mmap.PROT_READ | mmap.PROT_WRITE,
|
||||
)
|
||||
try:
|
||||
mmap_obj.madvise(mmap.MADV_DONTNEED, 0, size)
|
||||
if any(_page_residency(mmap_obj, size)):
|
||||
pytest.skip("kernel did not discard anonymous mmap pages")
|
||||
|
||||
mmap_obj[0] = 0xAB
|
||||
assert _page_residency(mmap_obj, size) == [True, False, False]
|
||||
|
||||
esr._fallback_populate_write(mmap_obj, 0, size)
|
||||
|
||||
assert _page_residency(mmap_obj, size) == [True, True, True]
|
||||
assert mmap_obj[0] == 0xAB
|
||||
finally:
|
||||
mmap_obj.close()
|
||||
|
||||
|
||||
def test_madvise_unexpected_oserror_propagates(monkeypatch):
|
||||
"""Only EINVAL triggers the fallback. Other OSErrors (e.g. EIO) must
|
||||
propagate out of __init__, not be silently masked by the fallback branch.
|
||||
"""
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu import ec_shared_region as esr
|
||||
|
||||
monkeypatch.setattr(
|
||||
esr,
|
||||
"_madvise_populate_write",
|
||||
lambda mm, off, ln: (_ for _ in ()).throw(
|
||||
OSError(errno.EIO, "simulated I/O failure")
|
||||
),
|
||||
)
|
||||
engine_id = str(uuid.uuid4())
|
||||
try:
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
ECSharedRegion(
|
||||
engine_id=engine_id, num_blocks=4, block_size_bytes=mmap.PAGESIZE
|
||||
)
|
||||
assert exc_info.value.errno == errno.EIO
|
||||
finally:
|
||||
# __init__ raised past the unlink-owning cleanup(), so drop the
|
||||
# backing file here rather than leaking it into /dev/shm.
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
os.unlink(f"/dev/shm/vllm_ec_{engine_id}.mmap")
|
||||
|
||||
|
||||
# ── pin_memory ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_pin_memory_success_sets_flag(region):
|
||||
"""When cudaHostRegister returns 0, _is_pinned flips to True
|
||||
and cleanup will correspondingly call cudaHostUnregister."""
|
||||
fake_cudart = MagicMock()
|
||||
success = MagicMock()
|
||||
success.value = 0
|
||||
fake_cudart.cudaHostRegister.return_value = success
|
||||
fake_cudart.cudaHostUnregister.return_value = success
|
||||
|
||||
with (
|
||||
patch("torch.cuda.is_available", return_value=True),
|
||||
patch("torch.cuda.cudart", return_value=fake_cudart),
|
||||
):
|
||||
region.pin_memory()
|
||||
assert region._is_pinned is True
|
||||
# cleanup must pair with cudaHostUnregister exactly once.
|
||||
region.cleanup()
|
||||
fake_cudart.cudaHostUnregister.assert_called_once()
|
||||
|
||||
|
||||
def test_pin_memory_failure_leaves_flag_false():
|
||||
"""If cudaHostRegister fails (non-zero), don't pretend it succeeded —
|
||||
cleanup must NOT call cudaHostUnregister on memory we never registered."""
|
||||
r = _make_region()
|
||||
try:
|
||||
fake_cudart = MagicMock()
|
||||
fail = MagicMock()
|
||||
fail.value = 1 # non-zero == error
|
||||
fake_cudart.cudaHostRegister.return_value = fail
|
||||
|
||||
with (
|
||||
patch("torch.cuda.is_available", return_value=True),
|
||||
patch("torch.cuda.cudart", return_value=fake_cudart),
|
||||
):
|
||||
r.pin_memory()
|
||||
assert r._is_pinned is False
|
||||
# Now run cleanup and verify cudaHostUnregister was NOT called.
|
||||
r.cleanup()
|
||||
fake_cudart.cudaHostUnregister.assert_not_called()
|
||||
finally:
|
||||
r.cleanup()
|
||||
|
||||
|
||||
def test_pin_memory_noop_without_cuda(region):
|
||||
"""pin_memory is a no-op when CUDA is not available."""
|
||||
with patch("torch.cuda.is_available", return_value=False):
|
||||
region.pin_memory()
|
||||
assert region._is_pinned is False
|
||||
|
||||
|
||||
# ── cleanup idempotency ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cleanup_is_idempotent(region):
|
||||
region.cleanup()
|
||||
region.cleanup() # fixture calls a third time — must not raise
|
||||
@@ -1,285 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for control-layer ABCs, ZmqClientConnection, and ECSession."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import zmq
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.control.base import (
|
||||
ControlConnection,
|
||||
)
|
||||
|
||||
|
||||
class _ConcreteConn(ControlConnection):
|
||||
"""Minimal concrete subclass for ABC-instantiation tests."""
|
||||
|
||||
def __init__(self):
|
||||
self._alive = True
|
||||
self._sent = []
|
||||
self._inbox = []
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
return self._alive
|
||||
|
||||
def send(self, msg: bytes) -> None:
|
||||
self._sent.append(msg)
|
||||
|
||||
def recv(self) -> list[bytes]:
|
||||
msgs, self._inbox = self._inbox, []
|
||||
return msgs
|
||||
|
||||
def mark_dead(self) -> None:
|
||||
self._alive = False
|
||||
|
||||
def close(self) -> None:
|
||||
self._alive = False
|
||||
|
||||
|
||||
def test_control_connection_abc_cannot_be_instantiated_directly():
|
||||
with pytest.raises(TypeError):
|
||||
ControlConnection() # type: ignore[abstract]
|
||||
|
||||
|
||||
def test_concrete_connection_send_recv():
|
||||
conn = _ConcreteConn()
|
||||
assert conn.alive
|
||||
conn.send(b"hello")
|
||||
conn._inbox.append(b"world")
|
||||
assert conn.recv() == [b"world"]
|
||||
assert conn._sent == [b"hello"]
|
||||
|
||||
|
||||
def test_mark_dead_sets_alive_false():
|
||||
conn = _ConcreteConn()
|
||||
conn.mark_dead()
|
||||
assert not conn.alive
|
||||
|
||||
|
||||
def test_close_sets_alive_false():
|
||||
conn = _ConcreteConn()
|
||||
conn.close()
|
||||
assert not conn.alive
|
||||
|
||||
|
||||
# ── ZmqClientConnection ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq import ( # noqa: E402
|
||||
ZmqClientConnection,
|
||||
ZmqClientTransport,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.utils import PeerAddr # noqa: E402
|
||||
|
||||
|
||||
def _conn(messages: list[list[bytes]], monitor=None) -> ZmqClientConnection:
|
||||
"""Build a ZmqClientConnection whose dealer yields the given frame-lists."""
|
||||
dealer = MagicMock()
|
||||
dealer.recv_multipart.side_effect = [*messages, zmq.Again()]
|
||||
return ZmqClientConnection(dealer=dealer, monitor=monitor)
|
||||
|
||||
|
||||
def test_zmq_client_connection_recv_extracts_payloads_from_envelopes():
|
||||
conn = _conn([[b"", b"msg1"], [b"", b"msg2"]])
|
||||
assert conn.recv() == [b"msg1", b"msg2"]
|
||||
|
||||
|
||||
def test_zmq_client_connection_recv_skips_frames_without_empty_delimiter():
|
||||
# A frame that does not start with b"" is malformed and must be dropped.
|
||||
conn = _conn([[b"", b"ok"], [b"no-delimiter", b"bad"]])
|
||||
assert conn.recv() == [b"ok"]
|
||||
|
||||
|
||||
def test_zmq_client_connection_recv_breaks_on_non_again_exception():
|
||||
# A socket error should stop the drain and return whatever was collected.
|
||||
dealer = MagicMock()
|
||||
dealer.recv_multipart.side_effect = [[b"", b"first"], OSError("broken")]
|
||||
conn = ZmqClientConnection(dealer=dealer, monitor=None)
|
||||
assert conn.recv() == [b"first"]
|
||||
|
||||
|
||||
def test_zmq_client_connection_send_prefixes_empty_delimiter():
|
||||
dealer = MagicMock()
|
||||
conn = ZmqClientConnection(dealer=dealer, monitor=None)
|
||||
conn.send(b"payload")
|
||||
dealer.send_multipart.assert_called_once_with([b"", b"payload"])
|
||||
|
||||
|
||||
def test_zmq_client_connection_mark_dead_does_not_close_socket():
|
||||
# mark_dead signals liveness only; resource teardown is close()'s job.
|
||||
dealer = MagicMock()
|
||||
conn = ZmqClientConnection(dealer=dealer, monitor=None)
|
||||
conn.mark_dead()
|
||||
assert not conn.alive
|
||||
dealer.close.assert_not_called()
|
||||
|
||||
|
||||
def test_zmq_client_connection_close_without_monitor():
|
||||
dealer = MagicMock()
|
||||
conn = ZmqClientConnection(dealer=dealer, monitor=None)
|
||||
conn.close()
|
||||
dealer.close.assert_called_once_with(linger=0)
|
||||
assert not conn.alive
|
||||
|
||||
|
||||
def test_zmq_client_connection_close_with_monitor_disables_then_closes_both():
|
||||
# The disable_monitor → monitor.close → dealer.close sequence must be
|
||||
# respected: closing the dealer before disabling the monitor can cause
|
||||
# a zmq assertion in some versions.
|
||||
dealer = MagicMock()
|
||||
monitor = MagicMock()
|
||||
conn = ZmqClientConnection(dealer=dealer, monitor=monitor)
|
||||
conn.close()
|
||||
dealer.disable_monitor.assert_called_once()
|
||||
monitor.close.assert_called_once_with(linger=0)
|
||||
dealer.close.assert_called_once_with(linger=0)
|
||||
assert not conn.alive
|
||||
|
||||
|
||||
def test_zmq_client_connection_close_is_idempotent():
|
||||
dealer = MagicMock()
|
||||
conn = ZmqClientConnection(dealer=dealer, monitor=None)
|
||||
conn.close()
|
||||
conn.close() # must not raise
|
||||
assert not conn.alive
|
||||
|
||||
|
||||
# ── ZmqClientTransport ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_transport() -> tuple[ZmqClientTransport, MagicMock]:
|
||||
"""Return a ZmqClientTransport with a mocked ZMQ context."""
|
||||
ctx = MagicMock()
|
||||
ctx.socket.return_value = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.zmq.Context",
|
||||
return_value=ctx,
|
||||
),
|
||||
patch(
|
||||
"vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.make_zmq_socket",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.make_zmq_path",
|
||||
return_value="tcp://host:0",
|
||||
),
|
||||
):
|
||||
transport = ZmqClientTransport()
|
||||
transport._ctx = ctx
|
||||
return transport, ctx
|
||||
|
||||
|
||||
def _inject_conn(transport: ZmqClientTransport, addr: PeerAddr) -> ZmqClientConnection:
|
||||
"""Inject a ZmqClientConnection directly into the transport pool."""
|
||||
conn = ZmqClientConnection(dealer=MagicMock(), monitor=MagicMock())
|
||||
transport._connections[addr] = conn
|
||||
return conn
|
||||
|
||||
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.make_zmq_socket")
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.make_zmq_path")
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.zmq.Context")
|
||||
def test_zmq_client_transport_connect_caches_connection(
|
||||
mock_ctx_cls, mock_path, mock_sock
|
||||
):
|
||||
mock_ctx_cls.return_value = MagicMock()
|
||||
mock_ctx_cls.return_value.socket.return_value = MagicMock()
|
||||
mock_sock.return_value = MagicMock()
|
||||
transport = ZmqClientTransport()
|
||||
addr: PeerAddr = ("host", 1234)
|
||||
assert transport.connect(addr) is transport.connect(addr)
|
||||
|
||||
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.make_zmq_socket")
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.make_zmq_path")
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.zmq.Context")
|
||||
def test_zmq_client_transport_connect_creates_independent_conns_per_addr(
|
||||
mock_ctx_cls, mock_path, mock_sock
|
||||
):
|
||||
mock_ctx_cls.return_value = MagicMock()
|
||||
mock_ctx_cls.return_value.socket.return_value = MagicMock()
|
||||
mock_sock.return_value = MagicMock()
|
||||
transport = ZmqClientTransport()
|
||||
conn_a = transport.connect(("host-a", 1111))
|
||||
conn_b = transport.connect(("host-b", 2222))
|
||||
assert conn_a is not conn_b
|
||||
|
||||
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.recv_monitor_message")
|
||||
def test_zmq_client_transport_poll_dead_detects_disconnected_peer(mock_recv_mon):
|
||||
transport, _ = _make_transport()
|
||||
addr: PeerAddr = ("host", 1234)
|
||||
conn = _inject_conn(transport, addr)
|
||||
mock_recv_mon.side_effect = [{"event": zmq.EVENT_DISCONNECTED}, zmq.Again()]
|
||||
|
||||
dead = transport.poll_dead()
|
||||
|
||||
assert addr in dead
|
||||
assert not conn.alive
|
||||
|
||||
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.recv_monitor_message")
|
||||
def test_zmq_client_transport_poll_dead_skips_conn_without_monitor(mock_recv_mon):
|
||||
transport, _ = _make_transport()
|
||||
addr: PeerAddr = ("host", 9999)
|
||||
conn = ZmqClientConnection(dealer=MagicMock(), monitor=None)
|
||||
transport._connections[addr] = conn
|
||||
|
||||
dead = transport.poll_dead()
|
||||
|
||||
assert dead == []
|
||||
mock_recv_mon.assert_not_called()
|
||||
|
||||
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.recv_monitor_message")
|
||||
def test_zmq_client_transport_poll_dead_ignores_non_disconnect_events(mock_recv_mon):
|
||||
transport, _ = _make_transport()
|
||||
addr: PeerAddr = ("host", 1234)
|
||||
conn = _inject_conn(transport, addr)
|
||||
# A CONNECTED event should not trigger a dead notification.
|
||||
mock_recv_mon.side_effect = [{"event": zmq.EVENT_CONNECTED}, zmq.Again()]
|
||||
|
||||
dead = transport.poll_dead()
|
||||
|
||||
assert dead == []
|
||||
assert conn.alive
|
||||
|
||||
|
||||
def test_zmq_client_transport_remove_closes_and_evicts_conn():
|
||||
transport, _ = _make_transport()
|
||||
addr: PeerAddr = ("host", 5678)
|
||||
conn = _inject_conn(transport, addr)
|
||||
|
||||
removed = transport.remove(addr)
|
||||
|
||||
assert removed is conn
|
||||
assert addr not in transport._connections
|
||||
conn.dealer.close.assert_called()
|
||||
|
||||
|
||||
def test_zmq_client_transport_remove_returns_none_for_unknown_addr():
|
||||
transport, _ = _make_transport()
|
||||
assert transport.remove(("ghost", 0)) is None
|
||||
|
||||
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.make_zmq_socket")
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.make_zmq_path")
|
||||
@patch("vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq.zmq.Context")
|
||||
def test_zmq_client_transport_close_closes_all_connections(
|
||||
mock_ctx_cls, mock_path, mock_sock
|
||||
):
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx_cls.return_value = mock_ctx
|
||||
mock_ctx.socket.return_value = MagicMock()
|
||||
dealer_a, dealer_b = MagicMock(), MagicMock()
|
||||
mock_sock.side_effect = [dealer_a, dealer_b]
|
||||
transport = ZmqClientTransport()
|
||||
transport.connect(("a", 1))
|
||||
transport.connect(("b", 2))
|
||||
transport.close()
|
||||
dealer_a.close.assert_called()
|
||||
dealer_b.close.assert_called()
|
||||
assert transport._connections == {}
|
||||
@@ -1,184 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for DataTransport ABC and NixlDataTransport."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.data.base import (
|
||||
DataTransport,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.utils import (
|
||||
serialize_mem_descriptor,
|
||||
)
|
||||
|
||||
_NIXL_PATH = "vllm.distributed.ec_transfer.ec_connector.cpu.data.nixl"
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_nixl_mock(prep_side_effect=None) -> MagicMock:
|
||||
nixl = MagicMock()
|
||||
nixl.get_agent_metadata.return_value = b"agent-meta"
|
||||
nixl.get_reg_descs.return_value = MagicMock()
|
||||
nixl.get_xfer_descs.return_value = MagicMock()
|
||||
nixl.prep_xfer_dlist.side_effect = prep_side_effect or [77, 88]
|
||||
nixl.add_remote_agent.return_value = "remote-agent-1"
|
||||
nixl.get_new_notifs.return_value = {}
|
||||
nixl.check_xfer_state.return_value = "PROC"
|
||||
return nixl
|
||||
|
||||
|
||||
def _make_transport(nixl_mock=None):
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.data.nixl import (
|
||||
NixlDataTransport,
|
||||
)
|
||||
|
||||
mock = nixl_mock or _make_nixl_mock()
|
||||
with (
|
||||
patch(f"{_NIXL_PATH}.NixlWrapper", return_value=mock),
|
||||
patch(f"{_NIXL_PATH}.nixl_agent_config", return_value=MagicMock()),
|
||||
):
|
||||
t = NixlDataTransport("agent", 0, 4, 64, 256)
|
||||
t._nixl = mock
|
||||
return t
|
||||
|
||||
|
||||
# ── ABC ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_data_transport_abc_cannot_be_instantiated():
|
||||
with pytest.raises(TypeError):
|
||||
DataTransport() # type: ignore[abstract]
|
||||
|
||||
|
||||
# ── NixlDataTransport construction ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_nixl_data_transport_registers_memory_on_init():
|
||||
nixl = _make_nixl_mock()
|
||||
_make_transport(nixl)
|
||||
nixl.register_memory.assert_called_once()
|
||||
|
||||
|
||||
def test_nixl_data_transport_unwinds_registration_if_init_fails():
|
||||
"""A failure after register_memory must not leave the region pinned.
|
||||
|
||||
__init__ propagates, so the caller never gets a transport to deregister.
|
||||
"""
|
||||
nixl = _make_nixl_mock(prep_side_effect=RuntimeError("no dlist"))
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.data.nixl import (
|
||||
NixlDataTransport,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(f"{_NIXL_PATH}.NixlWrapper", return_value=nixl),
|
||||
patch(f"{_NIXL_PATH}.nixl_agent_config", return_value=MagicMock()),
|
||||
pytest.raises(RuntimeError, match="no dlist"),
|
||||
):
|
||||
NixlDataTransport("agent", 0, 4, 64, 256)
|
||||
|
||||
nixl.deregister_memory.assert_called_once_with(nixl.get_reg_descs.return_value)
|
||||
|
||||
|
||||
def test_nixl_data_transport_raises_if_nixl_unavailable():
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.data.nixl import (
|
||||
NixlDataTransport,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(f"{_NIXL_PATH}.NixlWrapper", None),
|
||||
patch(f"{_NIXL_PATH}.nixl_agent_config", None),
|
||||
pytest.raises(RuntimeError, match="requires NIXL"),
|
||||
):
|
||||
NixlDataTransport("a", 0, 1, 64, 64)
|
||||
|
||||
|
||||
# ── public accessors ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_agent_metadata_returns_nixl_blob():
|
||||
assert _make_transport().get_agent_metadata() == b"agent-meta"
|
||||
|
||||
|
||||
def test_get_mem_descriptor_roundtrips_block_descs():
|
||||
t = _make_transport()
|
||||
raw = t.get_mem_descriptor()
|
||||
assert isinstance(raw, bytes) and len(raw) > 0
|
||||
|
||||
|
||||
# ── add_remote_peer ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_add_remote_peer_returns_agent_name_and_stores_handle_internally():
|
||||
nixl = _make_nixl_mock(prep_side_effect=[77, 88])
|
||||
t = _make_transport(nixl)
|
||||
mem_desc = serialize_mem_descriptor([(0, 64, 0)])
|
||||
agent_name = t.add_remote_peer(b"meta", mem_desc)
|
||||
assert agent_name == "remote-agent-1"
|
||||
# Dlist handle stored internally; accessible via _peer_handles.
|
||||
assert t._peer_handles["remote-agent-1"] == 88
|
||||
|
||||
|
||||
def test_add_remote_peer_registers_remote_agent():
|
||||
nixl = _make_nixl_mock(prep_side_effect=[77, 88])
|
||||
t = _make_transport(nixl)
|
||||
mem_desc = serialize_mem_descriptor([(0, 64, 0)])
|
||||
t.add_remote_peer(b"fresh-meta", mem_desc)
|
||||
nixl.add_remote_agent.assert_called_once_with(b"fresh-meta")
|
||||
|
||||
|
||||
# ── remove_remote_peer ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_remove_remote_peer_delegates_to_nixl():
|
||||
t = _make_transport()
|
||||
t.remove_remote_peer("agent-x")
|
||||
t._nixl.remove_remote_agent.assert_called_once_with("agent-x")
|
||||
|
||||
|
||||
def test_remove_remote_peer_swallows_exceptions():
|
||||
t = _make_transport()
|
||||
t._nixl.remove_remote_agent.side_effect = RuntimeError("gone")
|
||||
t.remove_remote_peer("agent-x") # must not raise
|
||||
|
||||
|
||||
# ── post_read ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_post_read_raises_on_index_count_mismatch():
|
||||
nixl = _make_nixl_mock(prep_side_effect=[77, 88])
|
||||
t = _make_transport(nixl)
|
||||
mem_desc = serialize_mem_descriptor([(0, 64, 0)])
|
||||
agent = t.add_remote_peer(b"meta", mem_desc)
|
||||
with pytest.raises(ValueError, match="mismatch"):
|
||||
t.post_read([0, 1], agent, [5], notif_msg=b"sid")
|
||||
|
||||
|
||||
def test_post_read_calls_make_prepped_xfer_and_transfer():
|
||||
nixl = _make_nixl_mock(prep_side_effect=[77, 88])
|
||||
t = _make_transport(nixl)
|
||||
mem_desc = serialize_mem_descriptor([(0, 64, 0)])
|
||||
agent = t.add_remote_peer(b"meta", mem_desc)
|
||||
nixl.make_prepped_xfer.return_value = 999
|
||||
handle = t.post_read([0, 1], agent, [5, 6], notif_msg=b"sid")
|
||||
nixl.make_prepped_xfer.assert_called_once()
|
||||
nixl.transfer.assert_called_once_with(999)
|
||||
assert handle == 999
|
||||
|
||||
|
||||
# ── release_xfer_handle / deregister ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_release_xfer_handle_swallows_exceptions():
|
||||
t = _make_transport()
|
||||
t._nixl.release_xfer_handle.side_effect = RuntimeError("stale")
|
||||
t.release_xfer_handle(42) # must not raise
|
||||
|
||||
|
||||
def test_deregister_swallows_exceptions():
|
||||
t = _make_transport()
|
||||
t._nixl.deregister_memory.side_effect = RuntimeError("already gone")
|
||||
t.deregister() # must not raise
|
||||
@@ -1,85 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for the connector-agnostic EC connector metrics plumbing.
|
||||
|
||||
Covers only the base infra: no concrete connector type implements these
|
||||
hooks yet, so every assertion here is about the documented no-op defaults
|
||||
and the container contracts, mirroring the equivalent KV connector tests.
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorRole
|
||||
from vllm.distributed.ec_transfer.ec_connector.example_connector import (
|
||||
ECExampleConnector,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.metrics import (
|
||||
ECConnectorLogging,
|
||||
ECConnectorProm,
|
||||
ECConnectorStats,
|
||||
)
|
||||
from vllm.v1.outputs import ECConnectorOutput
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def example_connector(tmp_path):
|
||||
config = Mock(spec=VllmConfig)
|
||||
config.ec_transfer_config = Mock()
|
||||
config.ec_transfer_config.get_from_extra_config = Mock(return_value=str(tmp_path))
|
||||
config.ec_transfer_config.is_ec_producer = True
|
||||
config.ec_transfer_config.is_ec_consumer = False
|
||||
return ECExampleConnector(vllm_config=config, role=ECConnectorRole.SCHEDULER)
|
||||
|
||||
|
||||
def test_base_stats_methods_raise_not_implemented():
|
||||
"""The base container has no aggregation logic of its own."""
|
||||
stats = ECConnectorStats()
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
stats.reset()
|
||||
with pytest.raises(NotImplementedError):
|
||||
stats.aggregate(stats)
|
||||
with pytest.raises(NotImplementedError):
|
||||
stats.reduce()
|
||||
with pytest.raises(NotImplementedError):
|
||||
stats.is_empty()
|
||||
|
||||
|
||||
def test_connector_hooks_default_to_none(example_connector):
|
||||
"""No connector type overrides these yet, so every hook is a no-op."""
|
||||
assert example_connector.get_ec_connector_stats() is None
|
||||
assert ECExampleConnector.build_ec_connector_stats() is None
|
||||
assert (
|
||||
ECExampleConnector.build_prom_metrics(
|
||||
vllm_config=Mock(spec=VllmConfig),
|
||||
metric_types={},
|
||||
labelnames=[],
|
||||
per_engine_labelvalues={},
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_ec_connector_output_is_empty_reflects_stats_field():
|
||||
output = ECConnectorOutput()
|
||||
assert output.is_empty()
|
||||
|
||||
output.ec_connector_stats = ECConnectorStats(data={"x": 1})
|
||||
assert not output.is_empty()
|
||||
|
||||
|
||||
def test_logging_and_prom_are_no_ops_without_ec_transfer_config():
|
||||
"""A normal run with no EC connector configured must stay silent."""
|
||||
logging = ECConnectorLogging(ec_transfer_config=None)
|
||||
logging.log() # Should not raise, nothing to log.
|
||||
|
||||
vllm_config = Mock(spec=VllmConfig)
|
||||
vllm_config.ec_transfer_config = None
|
||||
prom = ECConnectorProm(vllm_config, labelnames=[], per_engine_labelvalues={})
|
||||
assert prom.prom_metrics is None
|
||||
prom.observe({}) # Should not raise.
|
||||
@@ -1,747 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for ECExampleConnector."""
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import safetensors
|
||||
import torch
|
||||
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorRole
|
||||
from vllm.distributed.ec_transfer.ec_connector.example_connector import (
|
||||
ECExampleConnector,
|
||||
ECExampleConnectorMetadata,
|
||||
MMMeta,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.utils import collect_ec_item_metadata
|
||||
from vllm.multimodal.inputs import MultiModalFeatureSpec, PlaceholderRange
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
|
||||
|
||||
# ------------------ Mock Classes ------------------ #
|
||||
class MockRequest:
|
||||
def __init__(self, request_id, mm_hashes: list[str], token_counts: list[int]):
|
||||
assert len(mm_hashes) == len(token_counts)
|
||||
self.request_id = request_id
|
||||
self._token_counts = token_counts
|
||||
self.mm_features = []
|
||||
for i, mm_hash in enumerate(mm_hashes):
|
||||
feature = MultiModalFeatureSpec(
|
||||
data=None,
|
||||
modality="image",
|
||||
identifier=mm_hash,
|
||||
mm_position=PlaceholderRange(offset=0, length=self._token_counts[i]),
|
||||
)
|
||||
self.mm_features.append(feature)
|
||||
|
||||
def get_num_encoder_embeds(self, input_id: int) -> int:
|
||||
assert input_id < len(self._token_counts)
|
||||
return self._token_counts[input_id]
|
||||
|
||||
|
||||
def test_metadata_preserves_repeated_item_positions():
|
||||
request = MockRequest("request", ["A", "B", "A"], [4, 4, 4])
|
||||
resolver = Mock()
|
||||
resolver.fields_for.return_value = {"image_grid_thw"}
|
||||
for feature in request.mm_features:
|
||||
feature.data = Mock()
|
||||
feature.data.get_data.return_value = {"image_grid_thw": torch.tensor([1, 2, 2])}
|
||||
items = collect_ec_item_metadata(request.mm_features, resolver)
|
||||
assert items == {
|
||||
"A": {"metadata": {"image_grid_thw": [1, 2, 2]}, "item_indices": [0, 2]},
|
||||
"B": {"metadata": {"image_grid_thw": [1, 2, 2]}, "item_indices": [1]},
|
||||
}
|
||||
|
||||
|
||||
def test_audio_metadata_survives_processor_cache_hit():
|
||||
resolver = Mock()
|
||||
resolver.fields_for.return_value = {"audio_num_tokens"}
|
||||
feature = MultiModalFeatureSpec(
|
||||
data=None,
|
||||
modality="audio",
|
||||
identifier="audio",
|
||||
mm_position=PlaceholderRange(
|
||||
offset=0, length=4, is_embed=torch.tensor([False, True, True, False])
|
||||
),
|
||||
)
|
||||
assert collect_ec_item_metadata([feature], resolver) == {
|
||||
"audio": {"metadata": {"audio_num_tokens": [2]}, "item_indices": [0]}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_storage(tmp_path):
|
||||
"""Fixture providing temporary storage path."""
|
||||
return str(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_vllm_config_producer(temp_storage):
|
||||
"""Fixture providing mock VllmConfig for producer role."""
|
||||
config = Mock(spec=VllmConfig)
|
||||
config.ec_transfer_config = Mock()
|
||||
config.ec_transfer_config.get_from_extra_config = Mock(return_value=temp_storage)
|
||||
config.ec_transfer_config.is_ec_producer = True
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_vllm_config_consumer(temp_storage):
|
||||
"""Fixture providing mock VllmConfig for consumer role."""
|
||||
config = Mock(spec=VllmConfig)
|
||||
config.ec_transfer_config = Mock()
|
||||
config.ec_transfer_config.get_from_extra_config = Mock(return_value=temp_storage)
|
||||
config.ec_transfer_config.is_ec_producer = False
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request_with_3_mm():
|
||||
"""Fixture providing mock Request with 3 multimodal items."""
|
||||
request_id = "test_req_123"
|
||||
mm_hashes = ["img_hash_1", "img_hash_2", "img_hash_3"]
|
||||
token_counts = [100, 150, 200]
|
||||
|
||||
request = MockRequest(request_id, mm_hashes, token_counts)
|
||||
return request
|
||||
|
||||
|
||||
# ------------------ Unit Tests ------------------ #
|
||||
class TestECExampleConnectorBasics:
|
||||
"""Test basic EC connector functionality."""
|
||||
|
||||
def test_initialization_producer(self, mock_vllm_config_producer, temp_storage):
|
||||
"""Test connector initializes correctly as producer."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
assert connector.role == ECConnectorRole.SCHEDULER
|
||||
assert connector.is_producer
|
||||
assert connector._storage_path == temp_storage
|
||||
assert connector._mm_datas_need_loads == {}
|
||||
|
||||
def test_initialization_consumer(self, mock_vllm_config_consumer, temp_storage):
|
||||
"""Test connector initializes correctly as consumer."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
assert connector.role == ECConnectorRole.WORKER
|
||||
assert not connector.is_producer
|
||||
assert connector._storage_path == temp_storage
|
||||
|
||||
def test_role_assignment(self, mock_vllm_config_producer):
|
||||
"""Test role is correctly assigned."""
|
||||
scheduler_connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
worker_connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
assert scheduler_connector.role == ECConnectorRole.SCHEDULER
|
||||
assert worker_connector.role == ECConnectorRole.WORKER
|
||||
|
||||
|
||||
class TestCacheExistence:
|
||||
"""Test cache existence checking using has_cache_item() API."""
|
||||
|
||||
def test_has_cache_item_all_exist_3_items(
|
||||
self,
|
||||
mock_vllm_config_producer,
|
||||
mock_vllm_config_consumer,
|
||||
mock_request_with_3_mm,
|
||||
):
|
||||
"""Test has_cache_item returns True when all 3 caches exist."""
|
||||
# Test for producer first
|
||||
producer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
# Create cache files using save_caches (proper way)
|
||||
encoder_cache: dict[str, torch.Tensor] = {}
|
||||
|
||||
for mm_feature in mock_request_with_3_mm.mm_features:
|
||||
mm_hash = mm_feature.identifier
|
||||
encoder_cache[mm_hash] = torch.randn(10, 768)
|
||||
producer.save_caches(encoder_cache, mm_hash)
|
||||
|
||||
# Test using has_cache_item API
|
||||
producer_result = [
|
||||
producer.has_cache_item(mm_feature.identifier)
|
||||
for mm_feature in mock_request_with_3_mm.mm_features
|
||||
]
|
||||
|
||||
# Assert
|
||||
assert len(producer_result) == 3
|
||||
assert all(producer_result), f"Expected all True, got {producer_result}"
|
||||
|
||||
# Also test consumer can check if cache exists
|
||||
consumer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
# Test using has_cache_item API
|
||||
consumer_result = [
|
||||
consumer.has_cache_item(mm_feature.identifier)
|
||||
for mm_feature in mock_request_with_3_mm.mm_features
|
||||
]
|
||||
|
||||
# Assert
|
||||
assert len(consumer_result) == 3
|
||||
assert all(consumer_result), f"Expected all True, got {consumer_result}"
|
||||
|
||||
def test_has_cache_item_none_exist(
|
||||
self, mock_vllm_config_producer, mock_request_with_3_mm
|
||||
):
|
||||
"""Test has_caches returns False when no caches exist."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
# Test without creating any files
|
||||
result = [
|
||||
connector.has_cache_item(mm_feature.identifier)
|
||||
for mm_feature in mock_request_with_3_mm.mm_features
|
||||
]
|
||||
|
||||
# Assert
|
||||
assert len(result) == 3
|
||||
assert not any(result), f"Expected all False, got {result}"
|
||||
|
||||
def test_has_cache_item_partial_exist(
|
||||
self, mock_vllm_config_producer, mock_request_with_3_mm
|
||||
):
|
||||
"""Test has_caches with some caches existing (1 of 3)."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
# Create only the second cache file
|
||||
mm_hash_second = mock_request_with_3_mm.mm_features[1].identifier
|
||||
encoder_cache = {mm_hash_second: torch.randn(10, 768)}
|
||||
connector.save_caches(encoder_cache, mm_hash_second)
|
||||
|
||||
# Test
|
||||
result = [
|
||||
connector.has_cache_item(mm_feature.identifier)
|
||||
for mm_feature in mock_request_with_3_mm.mm_features
|
||||
]
|
||||
|
||||
# Assert
|
||||
assert len(result) == 3
|
||||
assert not result[0] # First doesn't exist
|
||||
assert result[1] # Second exists
|
||||
assert not result[2] # Third doesn't exist
|
||||
|
||||
def test_has_cache_item_miss_creates_no_folder(
|
||||
self, mock_vllm_config_consumer, temp_storage
|
||||
):
|
||||
"""Test a cache miss leaves no empty folder in the shared storage."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
assert not connector.has_cache_item("missing_hash")
|
||||
assert os.listdir(temp_storage) == []
|
||||
|
||||
|
||||
class TestStateManagement:
|
||||
"""Test connector state management."""
|
||||
|
||||
def test_update_state_after_alloc_3_items(
|
||||
self, mock_vllm_config_producer, mock_request_with_3_mm
|
||||
):
|
||||
"""Test state update after allocation for 3 MM items."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
# Initial state should be empty
|
||||
assert len(connector._mm_datas_need_loads) == 0
|
||||
|
||||
# Update state for all 3 items (mock cache existence)
|
||||
with patch.object(connector, "has_cache_item", return_value=True):
|
||||
for i in range(3):
|
||||
connector.update_state_after_alloc(mock_request_with_3_mm, index=i)
|
||||
|
||||
# Check state updated for all 3
|
||||
assert len(connector._mm_datas_need_loads) == 3
|
||||
assert "img_hash_1" in connector._mm_datas_need_loads
|
||||
assert "img_hash_2" in connector._mm_datas_need_loads
|
||||
assert "img_hash_3" in connector._mm_datas_need_loads
|
||||
assert connector._mm_datas_need_loads["img_hash_1"] == 100
|
||||
assert connector._mm_datas_need_loads["img_hash_2"] == 150
|
||||
assert connector._mm_datas_need_loads["img_hash_3"] == 200
|
||||
|
||||
def test_build_connector_meta_3_items(
|
||||
self, mock_vllm_config_producer, mock_request_with_3_mm
|
||||
):
|
||||
"""Test metadata building for 3 MM items."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
# Setup state for all 3 items (mock cache existence)
|
||||
with patch.object(connector, "has_cache_item", return_value=True):
|
||||
for i in range(3):
|
||||
connector.update_state_after_alloc(mock_request_with_3_mm, index=i)
|
||||
|
||||
# Build metadata
|
||||
scheduler_output = Mock(spec=SchedulerOutput)
|
||||
metadata = connector.build_connector_meta(scheduler_output)
|
||||
|
||||
# Assert
|
||||
assert isinstance(metadata, ECExampleConnectorMetadata)
|
||||
assert len(metadata.mm_datas) == 3
|
||||
assert metadata.mm_datas[0].mm_hash == "img_hash_1"
|
||||
assert metadata.mm_datas[0].num_token == 100
|
||||
assert metadata.mm_datas[1].mm_hash == "img_hash_2"
|
||||
assert metadata.mm_datas[1].num_token == 150
|
||||
assert metadata.mm_datas[2].mm_hash == "img_hash_3"
|
||||
assert metadata.mm_datas[2].num_token == 200
|
||||
|
||||
# State should be cleared after building
|
||||
assert len(connector._mm_datas_need_loads) == 0
|
||||
|
||||
def test_build_connector_meta_empty(self, mock_vllm_config_producer):
|
||||
"""Test metadata building with empty state."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
scheduler_output = Mock(spec=SchedulerOutput)
|
||||
metadata = connector.build_connector_meta(scheduler_output)
|
||||
|
||||
assert isinstance(metadata, ECExampleConnectorMetadata)
|
||||
assert len(metadata.mm_datas) == 0
|
||||
|
||||
def test_state_cleared_after_metadata_build(
|
||||
self, mock_vllm_config_producer, mock_request_with_3_mm
|
||||
):
|
||||
"""Test that state is properly cleared after building metadata."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
# Add state (mock cache existence)
|
||||
with patch.object(connector, "has_cache_item", return_value=True):
|
||||
for i in range(3):
|
||||
connector.update_state_after_alloc(mock_request_with_3_mm, index=i)
|
||||
assert len(connector._mm_datas_need_loads) == 3
|
||||
|
||||
# Build metadata (should clear state)
|
||||
scheduler_output = Mock(spec=SchedulerOutput)
|
||||
connector.build_connector_meta(scheduler_output)
|
||||
|
||||
# State should be empty
|
||||
assert len(connector._mm_datas_need_loads) == 0
|
||||
|
||||
# Build again should return empty metadata
|
||||
metadata2 = connector.build_connector_meta(scheduler_output)
|
||||
assert len(metadata2.mm_datas) == 0
|
||||
|
||||
|
||||
class TestCacheSaving:
|
||||
"""Test encoder cache saving (producer only)."""
|
||||
|
||||
def test_save_caches_producer_3_items(
|
||||
self, mock_vllm_config_producer, mock_request_with_3_mm, temp_storage
|
||||
):
|
||||
"""Test cache saving as producer for 3 different MM items."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
# Create and save 3 different caches
|
||||
mm_hashes = [f.identifier for f in mock_request_with_3_mm.mm_features]
|
||||
encoder_cache: dict[str, torch.Tensor] = {}
|
||||
|
||||
for mm_hash in mm_hashes:
|
||||
encoder_cache[mm_hash] = torch.randn(10, 768)
|
||||
connector.save_caches(encoder_cache, mm_hash)
|
||||
|
||||
# Verify all files exist using has_cache_item
|
||||
result = [
|
||||
connector.has_cache_item(mm_feature.identifier)
|
||||
for mm_feature in mock_request_with_3_mm.mm_features
|
||||
]
|
||||
assert all(result), f"Not all caches were saved: {result}"
|
||||
|
||||
# Verify each file's content
|
||||
for mm_hash in mm_hashes:
|
||||
filename = connector._generate_filename_debug(mm_hash)
|
||||
loaded = safetensors.torch.load_file(filename)
|
||||
assert "ec_cache" in loaded
|
||||
assert torch.allclose(loaded["ec_cache"], encoder_cache[mm_hash].cpu())
|
||||
|
||||
def test_save_caches_consumer_skips(self, mock_vllm_config_consumer):
|
||||
"""Test cache saving is skipped for consumer."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
mm_hash = "test_hash_consumer"
|
||||
encoder_cache = {mm_hash: torch.randn(10, 768)}
|
||||
|
||||
# Save should not raise but also not create file
|
||||
connector.save_caches(encoder_cache, mm_hash)
|
||||
|
||||
# Verify file doesn't exist using has_cache_item
|
||||
result = connector.has_cache_item(mm_hash)
|
||||
assert not result, "Consumer should not save caches"
|
||||
|
||||
|
||||
class TestCacheLoading:
|
||||
"""Test encoder cache loading (consumer)."""
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA not available")
|
||||
def test_start_load_caches_consumer_3_items(
|
||||
self,
|
||||
mock_vllm_config_producer,
|
||||
mock_vllm_config_consumer,
|
||||
mock_request_with_3_mm,
|
||||
temp_storage,
|
||||
):
|
||||
"""Test consumer loads 3 caches from storage."""
|
||||
# First, create producer to save caches
|
||||
producer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
# Producer saves 3 caches
|
||||
mm_hashes = [f.identifier for f in mock_request_with_3_mm.mm_features]
|
||||
saved_caches = {}
|
||||
for mm_hash in mm_hashes:
|
||||
saved_caches[mm_hash] = torch.randn(10, 768)
|
||||
producer.save_caches(saved_caches, mm_hash)
|
||||
|
||||
# Now consumer loads
|
||||
consumer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
# Setup metadata for all 3
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
for mm_hash in mm_hashes:
|
||||
metadata.add_mm_data(MMMeta.make_meta(mm_hash, 100))
|
||||
consumer.bind_connector_metadata(metadata)
|
||||
|
||||
# Load
|
||||
encoder_cache: dict[str, torch.Tensor] = {}
|
||||
consumer.start_load_caches(encoder_cache=encoder_cache)
|
||||
|
||||
# Verify all 3 loaded
|
||||
assert len(encoder_cache) == 3
|
||||
for mm_hash in mm_hashes:
|
||||
assert mm_hash in encoder_cache, f"{mm_hash} missing in encoder_cache"
|
||||
assert encoder_cache[mm_hash].is_cuda, (
|
||||
f"{mm_hash} cache is in {encoder_cache[mm_hash].device}"
|
||||
)
|
||||
assert torch.allclose(
|
||||
encoder_cache[mm_hash].cpu(), saved_caches[mm_hash]
|
||||
), f"{mm_hash} cache saved and loaded tesnor are not the same"
|
||||
|
||||
def test_start_load_caches_skip_existing(
|
||||
self, mock_vllm_config_producer, mock_vllm_config_consumer, temp_storage
|
||||
):
|
||||
"""Test cache loading skips already cached items."""
|
||||
# Setup: producer saves cache
|
||||
producer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
mm_hash = "existing_hash"
|
||||
saved_cache = torch.randn(10, 768)
|
||||
producer.save_caches({mm_hash: saved_cache}, mm_hash)
|
||||
|
||||
# Consumer setup
|
||||
consumer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
metadata.add_mm_data(MMMeta.make_meta(mm_hash, 100))
|
||||
consumer.bind_connector_metadata(metadata)
|
||||
|
||||
# Pre-populate encoder_cache with different value
|
||||
existing_cache = torch.randn(5, 512)
|
||||
encoder_cache = {mm_hash: existing_cache}
|
||||
|
||||
# Load (should skip since already exists)
|
||||
with patch("safetensors.torch.load_file") as mock_load:
|
||||
consumer.start_load_caches(encoder_cache=encoder_cache)
|
||||
# Should not call load_file since cache exists
|
||||
mock_load.assert_not_called()
|
||||
|
||||
# Verify original cache unchanged
|
||||
assert torch.equal(encoder_cache[mm_hash], existing_cache)
|
||||
|
||||
def test_start_load_caches_empty_metadata(self, mock_vllm_config_consumer):
|
||||
"""Test loading with empty metadata does nothing."""
|
||||
consumer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
# Setup empty metadata
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
consumer.bind_connector_metadata(metadata)
|
||||
|
||||
# Load (should not raise)
|
||||
encoder_cache: dict[str, torch.Tensor] = {}
|
||||
consumer.start_load_caches(encoder_cache=encoder_cache)
|
||||
|
||||
# Cache should remain empty
|
||||
assert len(encoder_cache) == 0
|
||||
|
||||
def test_start_load_caches_pins_device_to_current_rank(
|
||||
self, mock_vllm_config_producer, mock_vllm_config_consumer
|
||||
):
|
||||
"""Load must pin to the selected device under tensor parallelism."""
|
||||
# Produce a real cache file so the load path has something to open.
|
||||
producer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
mm_hash = "rank_pinning_hash"
|
||||
producer.save_caches({mm_hash: torch.zeros(4, 8)}, mm_hash)
|
||||
|
||||
consumer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
metadata.add_mm_data(MMMeta.make_meta(mm_hash, 100))
|
||||
consumer.bind_connector_metadata(metadata)
|
||||
|
||||
mock_platform = Mock()
|
||||
mock_platform.device_type = "cuda"
|
||||
mock_platform.is_cuda_alike.return_value = True
|
||||
mock_platform.current_device.return_value = 2
|
||||
|
||||
# Simulate running on TP rank 2 without requiring a multi-GPU host.
|
||||
captured: dict = {}
|
||||
|
||||
def fake_load_file(filename, device):
|
||||
captured["device"] = device
|
||||
return {"ec_cache": torch.zeros(4, 8)}
|
||||
|
||||
with (
|
||||
patch("vllm.platforms.current_platform", mock_platform),
|
||||
patch("safetensors.torch.load_file", side_effect=fake_load_file),
|
||||
):
|
||||
encoder_cache: dict[str, torch.Tensor] = {}
|
||||
consumer.start_load_caches(encoder_cache=encoder_cache)
|
||||
|
||||
assert captured["device"] == "cuda:2", (
|
||||
f"Expected device='cuda:2' (pinned to current rank), "
|
||||
f"got {captured['device']!r}. A bare 'cuda' string makes "
|
||||
"safetensors load onto cuda:0, which breaks non-rank-0 TP workers."
|
||||
)
|
||||
|
||||
def test_start_load_caches_non_cuda_platform_uses_device_type(
|
||||
self, mock_vllm_config_producer, mock_vllm_config_consumer
|
||||
):
|
||||
"""Non-CUDA platforms pass `device_type` through unchanged (no rank suffix)."""
|
||||
producer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
mm_hash = "cpu_device_hash"
|
||||
producer.save_caches({mm_hash: torch.zeros(2, 4)}, mm_hash)
|
||||
|
||||
consumer = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
metadata.add_mm_data(MMMeta.make_meta(mm_hash, 100))
|
||||
consumer.bind_connector_metadata(metadata)
|
||||
|
||||
mock_platform = Mock()
|
||||
mock_platform.device_type = "cpu"
|
||||
mock_platform.is_cuda_alike.return_value = False
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def fake_load_file(filename, device):
|
||||
captured["device"] = device
|
||||
return {"ec_cache": torch.zeros(2, 4)}
|
||||
|
||||
with (
|
||||
patch("vllm.platforms.current_platform", mock_platform),
|
||||
patch("safetensors.torch.load_file", side_effect=fake_load_file),
|
||||
):
|
||||
encoder_cache: dict[str, torch.Tensor] = {}
|
||||
consumer.start_load_caches(encoder_cache=encoder_cache)
|
||||
|
||||
assert captured["device"] == "cpu"
|
||||
|
||||
|
||||
class TestFilenameGeneration:
|
||||
"""Test filename and path generation."""
|
||||
|
||||
def test_generate_foldername(self, mock_vllm_config_producer, temp_storage):
|
||||
"""Test folder name generation."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
mm_hash = "test_folder_hash"
|
||||
folder = connector._generate_foldername_debug(mm_hash)
|
||||
|
||||
assert folder == os.path.join(temp_storage, mm_hash)
|
||||
assert os.path.isdir(folder) # Should be created
|
||||
|
||||
def test_generate_filename(self, mock_vllm_config_producer, temp_storage):
|
||||
"""Test filename generation."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
mm_hash = "test_file_hash"
|
||||
filename = connector._generate_filename_debug(mm_hash)
|
||||
|
||||
expected = os.path.join(temp_storage, mm_hash, "encoder_cache.safetensors")
|
||||
assert filename == expected
|
||||
assert os.path.isdir(os.path.dirname(filename)) # Folder created
|
||||
|
||||
def test_generate_filename_consistency(self, mock_vllm_config_producer):
|
||||
"""Test filename generation is consistent."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
mm_hash = "consistency_hash"
|
||||
filename1 = connector._generate_filename_debug(mm_hash)
|
||||
filename2 = connector._generate_filename_debug(mm_hash)
|
||||
|
||||
assert filename1 == filename2
|
||||
|
||||
|
||||
class TestMetadataBindingLifecycle:
|
||||
"""Test metadata binding and clearing lifecycle."""
|
||||
|
||||
def test_bind_connector_metadata(self, mock_vllm_config_consumer):
|
||||
"""Test binding connector metadata."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
metadata.add_mm_data(MMMeta.make_meta("hash_1", 100))
|
||||
|
||||
connector.bind_connector_metadata(metadata)
|
||||
|
||||
assert connector._connector_metadata is metadata
|
||||
|
||||
def test_clear_connector_metadata(self, mock_vllm_config_consumer):
|
||||
"""Test clearing connector metadata."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
connector.bind_connector_metadata(metadata)
|
||||
|
||||
connector.clear_connector_metadata()
|
||||
|
||||
assert connector._connector_metadata is None
|
||||
|
||||
def test_get_connector_metadata(self, mock_vllm_config_consumer):
|
||||
"""Test getting connector metadata."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
connector.bind_connector_metadata(metadata)
|
||||
|
||||
retrieved = connector._get_connector_metadata()
|
||||
|
||||
assert retrieved is metadata
|
||||
|
||||
def test_get_connector_metadata_not_set(self, mock_vllm_config_consumer):
|
||||
"""Test getting metadata when not set raises."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
connector._get_connector_metadata()
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error handling."""
|
||||
|
||||
def test_save_empty_cache(self, mock_vllm_config_producer):
|
||||
"""Test saving empty tensor."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
mm_hash = "empty_hash"
|
||||
encoder_cache = {mm_hash: torch.empty(0)}
|
||||
|
||||
# Should not raise
|
||||
connector.save_caches(encoder_cache, mm_hash)
|
||||
|
||||
def test_load_nonexistent_cache(self, mock_vllm_config_consumer):
|
||||
"""Test loading cache that doesn't exist raises error."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_consumer,
|
||||
role=ECConnectorRole.WORKER,
|
||||
)
|
||||
|
||||
metadata = ECExampleConnectorMetadata()
|
||||
metadata.add_mm_data(MMMeta.make_meta("nonexistent_hash", 100))
|
||||
connector.bind_connector_metadata(metadata)
|
||||
|
||||
encoder_cache: dict[str, torch.Tensor] = {}
|
||||
|
||||
# Should raise FileNotFoundError
|
||||
with pytest.raises(FileNotFoundError):
|
||||
connector.start_load_caches(encoder_cache=encoder_cache)
|
||||
|
||||
def test_has_cache_item_empty_request(self, mock_vllm_config_producer):
|
||||
"""Test has_cache_item with a nonexistent identifier."""
|
||||
connector = ECExampleConnector(
|
||||
vllm_config=mock_vllm_config_producer,
|
||||
role=ECConnectorRole.SCHEDULER,
|
||||
)
|
||||
|
||||
result = connector.has_cache_item("nonexistent_hash")
|
||||
|
||||
assert result is False
|
||||
@@ -1,131 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for ECOutputAggregator."""
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorWorkerMetadata
|
||||
from vllm.distributed.ec_transfer.ec_connector.metrics import ECConnectorStats
|
||||
from vllm.distributed.ec_transfer.ec_connector.utils import ECOutputAggregator
|
||||
from vllm.distributed.kv_transfer.kv_connector.utils import KVOutputAggregator
|
||||
from vllm.v1.outputs import (
|
||||
EMPTY_MODEL_RUNNER_OUTPUT,
|
||||
ECConnectorOutput,
|
||||
KVConnectorOutput,
|
||||
ModelRunnerOutput,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
|
||||
class FakeWorkerMeta(ECConnectorWorkerMetadata):
|
||||
"""Records merge order. `aggregate` returns a new object, as the base class
|
||||
declares: an aggregator discarding the return value would lose the merge.
|
||||
"""
|
||||
|
||||
def __init__(self, saves: list[str]):
|
||||
self.saves = saves
|
||||
|
||||
def aggregate(self, other: "FakeWorkerMeta") -> "FakeWorkerMeta":
|
||||
return FakeWorkerMeta(self.saves + other.saves)
|
||||
|
||||
|
||||
class FakeConnectorStats(ECConnectorStats):
|
||||
"""Records merge order, mirroring FakeWorkerMeta above."""
|
||||
|
||||
def __init__(self, saves: list[str]):
|
||||
self.saves = saves
|
||||
|
||||
def aggregate(self, other: "FakeConnectorStats") -> "FakeConnectorStats":
|
||||
return FakeConnectorStats(self.saves + other.saves)
|
||||
|
||||
|
||||
def _worker_output(ec_output: ECConnectorOutput | None) -> ModelRunnerOutput:
|
||||
return ModelRunnerOutput(
|
||||
req_ids=[], req_id_to_index={}, ec_connector_output=ec_output
|
||||
)
|
||||
|
||||
|
||||
def test_aggregate_folds_every_rank_onto_output_rank():
|
||||
"""EC work done on any rank reaches the scheduler via output_rank's output.
|
||||
|
||||
The middle rank reports no worker metadata or stats: it must neither seed
|
||||
nor clobber either accumulator.
|
||||
"""
|
||||
outputs = [
|
||||
_worker_output(
|
||||
ECConnectorOutput(
|
||||
finished_sending={"mm0"},
|
||||
ec_connector_stats=FakeConnectorStats(["mm0"]),
|
||||
ec_connector_worker_meta=FakeWorkerMeta(["mm0"]),
|
||||
)
|
||||
),
|
||||
_worker_output(ECConnectorOutput(finished_recving={"mm1"})),
|
||||
_worker_output(
|
||||
ECConnectorOutput(
|
||||
ec_connector_stats=FakeConnectorStats(["mm2"]),
|
||||
ec_connector_worker_meta=FakeWorkerMeta(["mm2"]),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
result = ECOutputAggregator().aggregate(outputs, output_rank=2)
|
||||
|
||||
assert result is outputs[2]
|
||||
assert result.ec_connector_output.finished_sending == {"mm0"}
|
||||
assert result.ec_connector_output.finished_recving == {"mm1"}
|
||||
assert result.ec_connector_output.ec_connector_stats.saves == ["mm0", "mm2"]
|
||||
assert result.ec_connector_output.ec_connector_worker_meta.saves == ["mm0", "mm2"]
|
||||
|
||||
|
||||
def test_aggregate_leaves_no_ec_output_when_no_worker_reported():
|
||||
"""Empty per-worker reports must not reach the scheduler as an empty object."""
|
||||
outputs = [_worker_output(ECConnectorOutput()), _worker_output(ECConnectorOutput())]
|
||||
|
||||
result = ECOutputAggregator().aggregate(outputs, output_rank=0)
|
||||
|
||||
assert result is outputs[0]
|
||||
assert result.ec_connector_output is None
|
||||
assert ECOutputAggregator().aggregate([None], output_rank=0) is None
|
||||
|
||||
|
||||
def test_aggregate_does_not_write_through_the_shared_empty_output():
|
||||
"""A rank with nothing to report yields the shared empty output singleton.
|
||||
|
||||
Folding another rank's metadata onto it must not write through to the
|
||||
module-level object, which every later step would then carry.
|
||||
"""
|
||||
outputs = [
|
||||
_worker_output(
|
||||
ECConnectorOutput(ec_connector_worker_meta=FakeWorkerMeta(["mm0"]))
|
||||
),
|
||||
EMPTY_MODEL_RUNNER_OUTPUT,
|
||||
]
|
||||
|
||||
result = ECOutputAggregator().aggregate(outputs, output_rank=1)
|
||||
|
||||
assert EMPTY_MODEL_RUNNER_OUTPUT.ec_connector_output is None
|
||||
assert result is not EMPTY_MODEL_RUNNER_OUTPUT
|
||||
assert result.ec_connector_output.ec_connector_worker_meta.saves == ["mm0"]
|
||||
|
||||
|
||||
def test_chaining_with_kv_aggregator_preserves_both_outputs():
|
||||
"""MultiprocExecutor chains both aggregators and keeps only the last result,
|
||||
so each must merge onto the same output_rank output rather than replace it.
|
||||
"""
|
||||
outputs = [
|
||||
_worker_output(ECConnectorOutput(finished_sending={"mm0"})),
|
||||
_worker_output(None),
|
||||
]
|
||||
outputs[1].kv_connector_output = KVConnectorOutput(invalid_block_ids={7})
|
||||
|
||||
result = None
|
||||
for aggregator in (
|
||||
KVOutputAggregator(expected_finished_count=1),
|
||||
ECOutputAggregator(),
|
||||
):
|
||||
result = aggregator.aggregate(outputs, output_rank=1)
|
||||
|
||||
assert result is outputs[1]
|
||||
assert result.kv_connector_output.invalid_block_ids == {7}
|
||||
assert result.ec_connector_output.finished_sending == {"mm0"}
|
||||
@@ -1,129 +0,0 @@
|
||||
# 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
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
|
||||
# tests.v1.core.utils transitively imports the example KV connector, which
|
||||
# requires the compiled CUDA flash-attention extensions; skip cleanly rather
|
||||
# than erroring at collection when those extensions aren't built.
|
||||
_core_utils = pytest.importorskip(
|
||||
"tests.v1.core.utils",
|
||||
reason="requires compiled CUDA flash-attention extensions",
|
||||
exc_type=ImportError,
|
||||
)
|
||||
create_scheduler = _core_utils.create_scheduler
|
||||
|
||||
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
|
||||
@@ -1,133 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Encoder fan-out fairness in the disaggregated EPD proxy.
|
||||
|
||||
Exercises the REAL ``encoder_rr_assignment`` helper loaded from its
|
||||
``examples/`` path -- no routing logic is re-implemented here, so a future
|
||||
change to that function is what these tests exercise.
|
||||
|
||||
Regression target: the fan-out cursor used to restart at e_urls[0] on every
|
||||
incoming request (``e_urls[i % len(e_urls)] for i in range(len(mm_items))``),
|
||||
so single-item requests -- the common case -- always hit the first encoder
|
||||
instance and left the rest idle. ``encoder_rr_assignment`` threads a cursor
|
||||
across calls so consecutive requests keep advancing through every URL.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROXY_REL = "examples/disaggregated/disaggregated_encoder/disagg_epd_proxy.py"
|
||||
|
||||
|
||||
def _load_proxy_module():
|
||||
path = Path(__file__).parents[4] / PROXY_REL
|
||||
spec = importlib.util.spec_from_file_location("disagg_epd_proxy_under_test", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def proxy():
|
||||
return _load_proxy_module()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def assign(proxy):
|
||||
return proxy.encoder_rr_assignment
|
||||
|
||||
|
||||
def _drive(assign, e_urls, counts):
|
||||
"""Feed a sequence of request item-counts through the cursor, as
|
||||
fanout_encoder_primer does one request at a time."""
|
||||
cursor = 0
|
||||
all_urls = []
|
||||
for count in counts:
|
||||
urls, cursor = assign(e_urls, cursor, count)
|
||||
assert len(urls) == count
|
||||
all_urls.append(urls)
|
||||
return all_urls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n_urls", [1, 2, 3, 5])
|
||||
def test_full_url_space_is_covered_uniformly(assign, n_urls):
|
||||
e_urls = [f"E{i}" for i in range(n_urls)]
|
||||
# Three full cycles of single-item requests.
|
||||
urls = _drive(assign, e_urls, counts=[1] * (n_urls * 3))
|
||||
hits = Counter(u for req in urls for u in req)
|
||||
assert set(hits) == set(e_urls)
|
||||
assert max(hits.values()) == min(hits.values())
|
||||
|
||||
|
||||
def test_single_item_requests_rotate_through_all_encoders(assign):
|
||||
# Exact scenario from the bug report: 3 encoders, one image/request.
|
||||
e_urls = ["E0", "E1", "E2"]
|
||||
routed = _drive(assign, e_urls, counts=[1] * 6)
|
||||
assert [urls[0] for urls in routed] == ["E0", "E1", "E2", "E0", "E1", "E2"]
|
||||
|
||||
|
||||
def test_cursor_stays_contiguous_across_varying_item_counts(assign):
|
||||
e_urls = ["E0", "E1", "E2"]
|
||||
routed = _drive(assign, e_urls, counts=[2, 1, 3, 1])
|
||||
assert routed == [
|
||||
["E0", "E1"],
|
||||
["E2"],
|
||||
["E0", "E1", "E2"],
|
||||
["E0"],
|
||||
]
|
||||
|
||||
|
||||
def test_single_encoder_always_resolves_to_it(assign):
|
||||
urls, next_cursor = assign(["E0"], 0, 4)
|
||||
assert urls == ["E0"] * 4
|
||||
assert next_cursor == 0
|
||||
|
||||
|
||||
def test_generic_e_p_d_routing_without_mooncake_is_supported(proxy):
|
||||
proxy.validate_ec_consumer_routing(["http://prefill"], [])
|
||||
|
||||
|
||||
def test_mooncake_e_pd_routing_is_supported(proxy):
|
||||
proxy.validate_ec_consumer_routing([], ["tcp://decode:19019"])
|
||||
|
||||
|
||||
def test_mooncake_independent_prefill_routing_fails_fast(proxy):
|
||||
with pytest.raises(ValueError, match=r"supports E\+PD only"):
|
||||
proxy.validate_ec_consumer_routing(["http://prefill"], ["tcp://decode:19019"])
|
||||
|
||||
|
||||
def test_decode_rewrite_preserves_engine_reported_ec_hash(proxy):
|
||||
request = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "image_url", "image_url": {"url": "image"}}],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
rewritten = proxy.rewrite_for_decode(
|
||||
request,
|
||||
{
|
||||
0: {
|
||||
"mm_hash": "proxy-uuid",
|
||||
"ec_mm_hash": "engine-derived-hash",
|
||||
"transfer_id": "transfer",
|
||||
"image_grid_thw": [1, 2, 3],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert rewritten["messages"][0]["content"][0]["uuid"] == "proxy-uuid"
|
||||
assert rewritten["messages"][0]["content"][0]["image_embeds"] == {
|
||||
"image_grid_thw": [1, 2, 3]
|
||||
}
|
||||
assert rewritten["ec_transfer_params"]["ec_items"] == [
|
||||
{"mm_hash": "engine-derived-hash", "transfer_id": "transfer"}
|
||||
]
|
||||
@@ -1,96 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for ECCPUConnectorMetadata."""
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import ECConnectorMetadata
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
|
||||
ECCPUConnectorMetadata,
|
||||
)
|
||||
|
||||
|
||||
def test_metadata_is_ec_connector_metadata():
|
||||
"""ECCPUConnectorMetadata must be a subclass of ECConnectorMetadata."""
|
||||
meta = ECCPUConnectorMetadata()
|
||||
assert isinstance(meta, ECConnectorMetadata)
|
||||
|
||||
|
||||
def test_metadata_initialization():
|
||||
"""ECCPUConnectorMetadata initializes with empty saves and loads dicts."""
|
||||
meta = ECCPUConnectorMetadata()
|
||||
assert hasattr(meta, "saves")
|
||||
assert hasattr(meta, "loads")
|
||||
assert meta.saves == {}
|
||||
assert meta.loads == {}
|
||||
|
||||
|
||||
def test_metadata_saves_dict_operations():
|
||||
"""The saves dict supports standard dict operations."""
|
||||
meta = ECCPUConnectorMetadata()
|
||||
|
||||
# Add multiple entries
|
||||
meta.saves["mm_hash_1"] = [0, 1, 2]
|
||||
meta.saves["mm_hash_2"] = [10, 11]
|
||||
|
||||
assert "mm_hash_1" in meta.saves
|
||||
assert "mm_hash_2" in meta.saves
|
||||
assert len(meta.saves) == 2
|
||||
assert meta.saves["mm_hash_1"] == [0, 1, 2]
|
||||
assert meta.saves["mm_hash_2"] == [10, 11]
|
||||
|
||||
|
||||
def test_metadata_loads_carry_a_transfer_id_and_blocks():
|
||||
"""Each load entry is a (transfer_id, block_ids) pair, so the worker can
|
||||
report the transfer rather than the mm_hash."""
|
||||
meta = ECCPUConnectorMetadata()
|
||||
|
||||
meta.loads["mm_hash_1"] = (0, [5, 6, 7])
|
||||
meta.loads["mm_hash_2"] = (1, [100])
|
||||
|
||||
assert len(meta.loads) == 2
|
||||
assert meta.loads["mm_hash_1"] == (0, [5, 6, 7])
|
||||
transfer_id, block_ids = meta.loads["mm_hash_2"]
|
||||
assert transfer_id == 1
|
||||
assert block_ids == [100]
|
||||
|
||||
|
||||
def test_metadata_saves_and_loads_are_independent():
|
||||
"""Saves and loads dicts are independent."""
|
||||
meta = ECCPUConnectorMetadata()
|
||||
|
||||
meta.saves["key"] = [1, 2]
|
||||
meta.loads["key"] = (0, [3, 4])
|
||||
|
||||
# Same key but different dicts should not interfere
|
||||
assert meta.saves["key"] == [1, 2]
|
||||
assert meta.loads["key"] == (0, [3, 4])
|
||||
|
||||
# Modifying one should not affect the other
|
||||
meta.saves["other"] = [99]
|
||||
assert "other" not in meta.loads
|
||||
|
||||
|
||||
def test_metadata_block_indices_are_integers():
|
||||
"""Block indices in saves/loads lists should be integers."""
|
||||
meta = ECCPUConnectorMetadata()
|
||||
|
||||
meta.saves["hash"] = [0, 100, 999, 65535]
|
||||
meta.loads["hash"] = (0, [1, 2, 3])
|
||||
|
||||
for idx in meta.saves["hash"]:
|
||||
assert isinstance(idx, int)
|
||||
for idx in meta.loads["hash"][1]:
|
||||
assert isinstance(idx, int)
|
||||
|
||||
|
||||
def test_metadata_multiple_instances_are_independent():
|
||||
"""Different ECCPUConnectorMetadata instances should not share state."""
|
||||
meta1 = ECCPUConnectorMetadata()
|
||||
meta2 = ECCPUConnectorMetadata()
|
||||
|
||||
meta1.saves["x"] = [1]
|
||||
meta2.saves["y"] = [2]
|
||||
|
||||
assert "x" in meta1.saves
|
||||
assert "x" not in meta2.saves
|
||||
assert "y" not in meta1.saves
|
||||
assert "y" in meta2.saves
|
||||
@@ -1,41 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import msgspec
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.protocol import (
|
||||
EC_CONNECTOR_VERSION,
|
||||
XferAck,
|
||||
XferReq,
|
||||
XferStatus,
|
||||
compute_ec_compatibility_hash,
|
||||
)
|
||||
|
||||
|
||||
def test_xferreq_roundtrip():
|
||||
req = XferReq(mm_hash="h1", compatibility_hash="c1", session_id="s1")
|
||||
data = msgspec.msgpack.encode(req)
|
||||
out = msgspec.msgpack.decode(data, type=XferReq)
|
||||
assert out.mm_hash == "h1"
|
||||
assert out.compatibility_hash == "c1"
|
||||
assert out.connector_version == EC_CONNECTOR_VERSION
|
||||
|
||||
|
||||
def test_xferack_roundtrip_ok():
|
||||
ack = XferAck(
|
||||
mm_hash="h1",
|
||||
status=XferStatus.OK,
|
||||
src_block_indices=[1, 2],
|
||||
agent_metadata=b"meta",
|
||||
mem_descriptor=b"desc",
|
||||
)
|
||||
out = msgspec.msgpack.decode(msgspec.msgpack.encode(ack), type=XferAck)
|
||||
assert out.status == XferStatus.OK
|
||||
assert out.src_block_indices == [1, 2]
|
||||
|
||||
|
||||
def test_compat_hash_deterministic_and_sensitive():
|
||||
a = compute_ec_compatibility_hash("0.1", "m", "float16", 64)
|
||||
b = compute_ec_compatibility_hash("0.1", "m", "float16", 64)
|
||||
c = compute_ec_compatibility_hash("0.1", "m", "float16", 128)
|
||||
assert a == b
|
||||
assert a != c
|
||||
@@ -1,461 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
|
||||
from tests.v1.ec_connector.unit.utils import create_ec_vllm_config
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
|
||||
ECSharedRegion,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import ECCPUScheduler
|
||||
from vllm.multimodal.inputs import (
|
||||
MultiModalFieldElem,
|
||||
MultiModalKwargsItem,
|
||||
MultiModalSharedField,
|
||||
)
|
||||
|
||||
_N, _BS, _HID, _ES = 16, 64, 32, 2
|
||||
|
||||
|
||||
class _Pos:
|
||||
def __init__(self, offset, length):
|
||||
self.offset, self.length = offset, length
|
||||
|
||||
def get_num_embeds(self):
|
||||
return self.length
|
||||
|
||||
|
||||
class _Feature:
|
||||
def __init__(self, mm_hash, length=1, data=None):
|
||||
self.mm_hash = mm_hash
|
||||
self.identifier = mm_hash
|
||||
self.mm_position = _Pos(0, length)
|
||||
self.data = data
|
||||
self.modality = "image"
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, features, params=None, req_id="r1"):
|
||||
self.mm_features = features
|
||||
self.request_id = req_id
|
||||
self.ec_transfer_params = params
|
||||
|
||||
|
||||
class _FakeResults:
|
||||
def __init__(self):
|
||||
self.completed = set()
|
||||
self.tombstoned = set()
|
||||
self.quarantined = set()
|
||||
self.retryable = set()
|
||||
self.settled = []
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Records started transfers; lets the test flip them to completed."""
|
||||
|
||||
def __init__(self):
|
||||
self.started = []
|
||||
self._results = _FakeResults()
|
||||
|
||||
def start_xfer(self, mm_hash, block_indices, deadline):
|
||||
self.started.append(mm_hash)
|
||||
|
||||
def poll(self, messages, now):
|
||||
pass
|
||||
|
||||
def take_results(self):
|
||||
r, self._results = self._results, _FakeResults()
|
||||
return r
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeTransport:
|
||||
"""Stand-in for ZmqClientTransport so _poll_step runs without ZMQ."""
|
||||
|
||||
def poll(self):
|
||||
return {}
|
||||
|
||||
def poll_dead(self):
|
||||
return []
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _consumer_sched(monkeypatch):
|
||||
def _region(cfg):
|
||||
return ECSharedRegion(
|
||||
engine_id="eng-" + str(uuid.uuid4()), num_blocks=_N, block_size_bytes=_BS
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sched_mod, "create_ec_shared_region", _region)
|
||||
|
||||
# ec_enable_nixl defaults to False, so this builds gate-off; the fields
|
||||
# below flip on the NIXL consumer state directly.
|
||||
# No measured widths: a consumer takes each shape from the announcement.
|
||||
s = ECCPUScheduler(
|
||||
create_ec_vllm_config(ec_role="ec_consumer", encoder_output_widths={})
|
||||
)
|
||||
# Turn on NIXL consumer state without constructing real transports.
|
||||
s._nixl_enabled = True
|
||||
s._transport = _FakeTransport()
|
||||
s._data = None
|
||||
s._compat_hash = "c"
|
||||
s._metadata_resolver._cache["image"] = {"image_grid_thw"}
|
||||
return s
|
||||
|
||||
|
||||
def _params(mm_hash, length):
|
||||
return {
|
||||
mm_hash: {
|
||||
"peer_host": "h",
|
||||
"peer_port": 1,
|
||||
"size_bytes": length * _HID * _ES,
|
||||
"shape": [length, _HID],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_new_remote_read_defers_then_completes(monkeypatch):
|
||||
"""The read is sized from the announced shape, not a local width."""
|
||||
width = 4 * _HID
|
||||
s = _consumer_sched(monkeypatch)
|
||||
fake = _FakeSession()
|
||||
|
||||
# Route _start_xfer to our fake session instead of real ZMQ/NIXL, but
|
||||
# still reserve a real not-ready cache entry so mark_ready works.
|
||||
def _fake_start(mm_hash, info, size, shape):
|
||||
assert size == width * _ES
|
||||
assert shape == (1, width)
|
||||
entry = s._cache.alloc(mm_hash, size // _BS, shape)
|
||||
assert entry is not None
|
||||
fake.started.append(mm_hash)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(s, "_start_xfer", _fake_start)
|
||||
params = _params("h1", 1)
|
||||
params["h1"]["size_bytes"] = width * _ES
|
||||
params["h1"]["shape"] = [1, width]
|
||||
req = _Request([_Feature("h1", 1)], params=params)
|
||||
|
||||
# Step 1: unseen remote item -> read started, request deferred.
|
||||
assert s.ensure_cache_available(req, 0) is False
|
||||
assert "h1" in s._in_flight
|
||||
assert "h1" in fake.started
|
||||
s.build_connector_meta(scheduler_output=None) # re-arms _first_in_batch
|
||||
|
||||
# Session reports completion this step.
|
||||
fake._results.completed.add("h1")
|
||||
s._sessions[("h", 1)] = fake
|
||||
|
||||
# Step 2: poll drains completion -> entry marked ready, request admitted.
|
||||
assert s.ensure_cache_available(req, 0) is True
|
||||
assert "h1" not in s._in_flight
|
||||
assert "h1" in s._step_completed
|
||||
entry = s._cache.get("h1")
|
||||
assert entry is not None and entry.ready
|
||||
# The scheduler now allocates encoder input; the ready entry loads through
|
||||
# the same local path as a natively cached encoding.
|
||||
s.update_state_after_alloc(req, 0)
|
||||
meta = s.build_connector_meta(scheduler_output=None)
|
||||
assert "h1" in meta.loads
|
||||
assert meta.loads["h1"][2] == (1, width)
|
||||
assert "h1" not in s._step_completed # cleared by promote
|
||||
|
||||
# Step 3: still cached -> admitted directly, no new transfer.
|
||||
assert s.ensure_cache_available(req, 0) is True
|
||||
s.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [None, {"x": 123}])
|
||||
def test_no_remote_announcement_is_ready(monkeypatch, params):
|
||||
s = _consumer_sched(monkeypatch)
|
||||
assert s.ensure_cache_available(_Request([_Feature("x")], params=params), 0)
|
||||
assert s.take_unavailable_requests() == set()
|
||||
s.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [None, "metadata", "pixels", "embeds", "shm"])
|
||||
def test_failed_read_falls_back_only_with_local_input(monkeypatch, payload):
|
||||
"""A remote miss is fatal only when local model input is unavailable."""
|
||||
s = _consumer_sched(monkeypatch)
|
||||
fake = _FakeSession()
|
||||
|
||||
def _fake_start(mm_hash, info, size, shape):
|
||||
entry = s._cache.alloc(mm_hash, 1, shape)
|
||||
assert entry is not None
|
||||
fake.started.append(mm_hash)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(s, "_start_xfer", _fake_start)
|
||||
fields = {
|
||||
"metadata": {"image_grid_thw": torch.tensor([1, 2, 2])},
|
||||
"pixels": {"pixel_values": torch.ones(1, 3)},
|
||||
"embeds": {"image_embeds": torch.ones(1, _HID)},
|
||||
"shm": {"address": 123, "monotonic_id": 1},
|
||||
}
|
||||
data = (
|
||||
MultiModalKwargsItem(
|
||||
{
|
||||
key: MultiModalFieldElem(value, MultiModalSharedField(batch_size=1))
|
||||
for key, value in fields[payload].items()
|
||||
}
|
||||
)
|
||||
if payload is not None
|
||||
else None
|
||||
)
|
||||
req = _Request([_Feature("h1", 1, data)], params=_params("h1", 1))
|
||||
|
||||
# Step 1: start the read.
|
||||
assert s.ensure_cache_available(req, 0) is False
|
||||
s.build_connector_meta(scheduler_output=None)
|
||||
|
||||
fake._results.tombstoned.add("h1")
|
||||
s._sessions[("h", 1)] = fake
|
||||
|
||||
can_fallback = payload in ("pixels", "embeds", "shm")
|
||||
assert s.ensure_cache_available(req, 0) is can_fallback
|
||||
assert s._cache.get("h1") is None
|
||||
assert "h1" not in s._in_flight
|
||||
assert s.take_unavailable_requests() == (set() if can_fallback else {"r1"})
|
||||
# Drained by the read, so the scheduler cannot abort it twice.
|
||||
assert s.take_unavailable_requests() == set()
|
||||
if can_fallback:
|
||||
# A later scheduling step must not retry the failed remote source.
|
||||
s.build_connector_meta(scheduler_output=None)
|
||||
assert s.ensure_cache_available(req, 0)
|
||||
assert fake.started == ["h1"]
|
||||
s.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("retry", [False, True], ids=["in-flight", "retryable"])
|
||||
def test_remote_wait_budget_survives_retries_and_long_steps(monkeypatch, retry):
|
||||
"""Neither retries nor delayed scheduling may restart the request budget."""
|
||||
s = _consumer_sched(monkeypatch)
|
||||
fake = _FakeSession()
|
||||
s._sessions[("h", 1)] = fake
|
||||
|
||||
def start(mm_hash, info, size, shape):
|
||||
assert s._cache.alloc(mm_hash, 1, shape) is not None
|
||||
fake.started.append(mm_hash)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(s, "_start_xfer", start)
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr(time, "monotonic", lambda: now[0])
|
||||
req = _Request([_Feature("h1")], params=_params("h1", 1))
|
||||
assert not s.ensure_cache_available(req, 0)
|
||||
for elapsed in (20, 40, 121):
|
||||
now[0] = 1000.0 + elapsed
|
||||
if retry:
|
||||
fake._results.retryable.add("h1")
|
||||
s.build_connector_meta(scheduler_output=None)
|
||||
assert not s.ensure_cache_available(req, 0)
|
||||
assert s.take_unavailable_requests() == ({"r1"} if elapsed > 60 else set())
|
||||
assert len(fake.started) == (3 if retry else 1)
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_retryable_read_re_requests_without_admitting(monkeypatch):
|
||||
"""A not-ready NACK defers the request and re-requests the read.
|
||||
|
||||
Unlike a tombstone it never admits the request, because an admitted request
|
||||
whose media was rewritten away upstream has no embedding and no way to
|
||||
recompute one.
|
||||
"""
|
||||
s = _consumer_sched(monkeypatch)
|
||||
fake = _FakeSession()
|
||||
|
||||
def _fake_start(mm_hash, info, size, shape):
|
||||
entry = s._cache.alloc(mm_hash, 1, shape)
|
||||
assert entry is not None
|
||||
fake.started.append(mm_hash)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(s, "_start_xfer", _fake_start)
|
||||
req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
|
||||
|
||||
assert s.ensure_cache_available(req, 0) is False
|
||||
s.build_connector_meta(scheduler_output=None)
|
||||
|
||||
# Producer's save has not landed: retryable, not tombstoned.
|
||||
fake._results.retryable.add("h1")
|
||||
s._sessions[("h", 1)] = fake
|
||||
|
||||
# The entry is released and the read re-issued in the same step, so the
|
||||
# request stays deferred rather than being admitted unfetched.
|
||||
assert s.ensure_cache_available(req, 0) is False
|
||||
assert "h1" in s._in_flight
|
||||
assert "h1" not in s._tombstones
|
||||
assert fake.started.count("h1") == 2
|
||||
s.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fields",
|
||||
[
|
||||
{"size_bytes": 999},
|
||||
{"size_bytes": None},
|
||||
{"size_bytes": "big"},
|
||||
{"size_bytes": _MISSING},
|
||||
{"shape": _MISSING},
|
||||
{"shape": [2, _HID]},
|
||||
],
|
||||
ids=[
|
||||
"size-mismatch",
|
||||
"null-size",
|
||||
"text-size",
|
||||
"no-size",
|
||||
"no-shape",
|
||||
"wrong-rows",
|
||||
],
|
||||
)
|
||||
def test_invalid_announcement_fails_the_request(monkeypatch, fields):
|
||||
"""An unusable announced size or shape fails the request, not the engine."""
|
||||
s = _consumer_sched(monkeypatch)
|
||||
announced = {**_params("h1", 1)["h1"], **fields}
|
||||
announced = {k: v for k, v in announced.items() if v is not _MISSING}
|
||||
req = _Request([_Feature("h1", 1)], params={"h1": announced})
|
||||
assert s.ensure_cache_available(req, 0) is False
|
||||
assert s.take_unavailable_requests() == {"r1"}
|
||||
assert "h1" not in s._in_flight
|
||||
assert s._cache.get("h1") is None
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_deferral_budget_is_per_request(monkeypatch):
|
||||
"""Shared hashes have independent wait budgets and cancellation cleanup."""
|
||||
s = _consumer_sched(monkeypatch)
|
||||
monkeypatch.setattr(s._cache, "alloc", lambda key, n, shape=None: None)
|
||||
params = _params("h1", 1)
|
||||
old = _Request([_Feature("h1", 1)], params=params, req_id="old")
|
||||
new = _Request([_Feature("h1", 1)], params=params, req_id="new")
|
||||
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr(time, "monotonic", lambda: now[0])
|
||||
assert s.ensure_cache_available(old, 0) is False
|
||||
now[0] += sched_mod._ADMIT_DEFER_TIMEOUT_S - 1
|
||||
assert s.ensure_cache_available(new, 0) is False
|
||||
assert s.take_unavailable_requests() == set()
|
||||
|
||||
now[0] += 2
|
||||
# `old` is now past its budget; `new` is not.
|
||||
assert s.ensure_cache_available(new, 0) is False
|
||||
assert s.take_unavailable_requests() == set()
|
||||
assert s.request_finished(new) == (False, None)
|
||||
assert set(s._deferred_since) == {("old", "h1")}
|
||||
assert s.ensure_cache_available(old, 0) is False
|
||||
assert s.take_unavailable_requests() == {"old"}
|
||||
assert "h1" not in s._in_flight
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_metadata_only_entry_admits_without_transfer(monkeypatch):
|
||||
# A producer that reported placeholder metadata but had no cache entry
|
||||
# to transfer omits peer_host/peer_port/size_bytes entirely; the consumer
|
||||
# must fall back to local compute rather than treating that as a size
|
||||
# mismatch.
|
||||
s = _consumer_sched(monkeypatch)
|
||||
metadata_only = {"h1": {"image_grid_thw": [1, 2, 3]}}
|
||||
req = _Request([_Feature("h1", 1)], params=metadata_only)
|
||||
assert s.ensure_cache_available(req, 0) is True
|
||||
assert "h1" not in s._in_flight
|
||||
assert s._cache.get("h1") is None
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_orphan_not_ready_entry_defers_no_realloc(monkeypatch):
|
||||
# Post-quarantine, post-tombstone-consumed orphan: a not-ready cache
|
||||
# entry exists for the mm_hash, but the hash is tracked in none of
|
||||
# _in_flight / _step_completed / _tombstones. Its blocks are held by a
|
||||
# quarantined/settling DMA and must not be re-allocated.
|
||||
s = _consumer_sched(monkeypatch)
|
||||
entry = s._cache.alloc("h1", 1)
|
||||
assert entry is not None and not entry.ready
|
||||
assert "h1" not in s._in_flight
|
||||
assert "h1" not in s._step_completed
|
||||
assert "h1" not in s._tombstones
|
||||
|
||||
# Spy on alloc: record calls, delegate to the real allocator (which
|
||||
# asserts on a duplicate key). A re-alloc for "h1" is the bug.
|
||||
real_alloc = s._cache.alloc
|
||||
calls: list[str] = []
|
||||
|
||||
def _spy_alloc(key, n):
|
||||
calls.append(key)
|
||||
return real_alloc(key, n)
|
||||
|
||||
monkeypatch.setattr(s._cache, "alloc", _spy_alloc)
|
||||
req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
|
||||
|
||||
# Must not raise; the request is deferred while the DMA settles, the
|
||||
# orphan entry is untouched, and alloc is never called again.
|
||||
assert s.ensure_cache_available(req, 0) is False
|
||||
assert calls == []
|
||||
assert s._cache.get("h1") is entry
|
||||
assert not entry.ready
|
||||
assert s.take_unavailable_requests() == set()
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_already_computed_feature_admits_without_transfer(monkeypatch):
|
||||
# A feature entirely within num_computed_tokens is skipped by admit; no
|
||||
# cache lookup/alloc or transfer should be triggered for it.
|
||||
s = _consumer_sched(monkeypatch)
|
||||
calls: list[str] = []
|
||||
real_alloc = s._cache.alloc
|
||||
|
||||
def _spy_alloc(key, n):
|
||||
calls.append(key)
|
||||
return real_alloc(key, n)
|
||||
|
||||
monkeypatch.setattr(s._cache, "alloc", _spy_alloc)
|
||||
# Feature spans [0, 4); with num_computed_tokens=4 it is fully computed.
|
||||
req = _Request([_Feature("h1", 4)], params=_params("h1", 4))
|
||||
|
||||
assert s.ensure_cache_available(req, 4) is True
|
||||
assert "h1" not in s._in_flight
|
||||
assert calls == []
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_local_ready_entry_admits_without_transfer(monkeypatch):
|
||||
# A READY entry already present in the cache is a local hit: admit
|
||||
# returns True and no new transfer is started for that mm_hash.
|
||||
s = _consumer_sched(monkeypatch)
|
||||
entry = s._cache.alloc("h1", 1)
|
||||
assert entry is not None
|
||||
s._cache.mark_ready("h1")
|
||||
req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
|
||||
|
||||
assert s.ensure_cache_available(req, 0) is True
|
||||
assert "h1" not in s._in_flight
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_in_flight_hash_defers_without_second_transfer(monkeypatch):
|
||||
# A mm_hash already tracked in _in_flight must defer the request rather
|
||||
# than starting a duplicate transfer.
|
||||
s = _consumer_sched(monkeypatch)
|
||||
started: list[str] = []
|
||||
|
||||
def _spy_start_xfer(mm_hash, info, size, shape):
|
||||
started.append(mm_hash)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(s, "_start_xfer", _spy_start_xfer)
|
||||
s._in_flight.add("h1")
|
||||
req = _Request([_Feature("h1", 1)], params=_params("h1", 1))
|
||||
|
||||
assert s.ensure_cache_available(req, 0) is False
|
||||
assert started == []
|
||||
s.shutdown()
|
||||
@@ -1,146 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
|
||||
import vllm.distributed.nixl_utils as nixl_utils
|
||||
from tests.v1.ec_connector.unit.utils import create_ec_vllm_config
|
||||
from vllm.config.ec_transfer import ECTransferConfig
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
|
||||
ECSharedRegion,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import ECCPUScheduler
|
||||
from vllm.distributed.nixl_utils import NixlWrapper
|
||||
|
||||
_N = 16
|
||||
_BS = 64
|
||||
|
||||
|
||||
def _region() -> ECSharedRegion:
|
||||
return ECSharedRegion(
|
||||
engine_id="eng-" + str(uuid.uuid4()), num_blocks=_N, block_size_bytes=_BS
|
||||
)
|
||||
|
||||
|
||||
def test_ec_enable_nixl_absent_from_extra_config_by_default():
|
||||
cfg = ECTransferConfig()
|
||||
assert cfg.get_from_extra_config("ec_enable_nixl", False) is False
|
||||
|
||||
|
||||
def test_ec_enable_nixl_read_from_extra_config():
|
||||
cfg = ECTransferConfig(
|
||||
ec_connector="ECCPUConnector",
|
||||
ec_role="ec_both",
|
||||
ec_connector_extra_config={"ec_enable_nixl": True},
|
||||
)
|
||||
assert cfg.get_from_extra_config("ec_enable_nixl", False) is True
|
||||
|
||||
|
||||
def test_gate_off_builds_no_nixl(monkeypatch):
|
||||
monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: _region())
|
||||
# ec_enable_nixl defaults to False.
|
||||
s = ECCPUScheduler(create_ec_vllm_config(ec_role="ec_both"))
|
||||
assert s._nixl_enabled is False
|
||||
assert getattr(s, "_data", None) is None
|
||||
assert getattr(s, "_producer_session", None) is None
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_string_false_in_extra_config_leaves_nixl_off(monkeypatch):
|
||||
"""Extra config is not type coerced, and bool("false") is True."""
|
||||
monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: _region())
|
||||
cfg = create_ec_vllm_config(ec_role="ec_both")
|
||||
cfg.ec_transfer_config.ec_connector_extra_config["ec_enable_nixl"] = "false"
|
||||
s = ECCPUScheduler(cfg)
|
||||
assert s._nixl_enabled is False
|
||||
s.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_missing_nixl_releases_shared_region(monkeypatch):
|
||||
"""A failed constructor leaves no scheduler for the caller to shut down."""
|
||||
# Import before patching to avoid caching the missing-NIXL values.
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.data import nixl # noqa: F401
|
||||
|
||||
region = _region()
|
||||
monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: region)
|
||||
monkeypatch.setattr(nixl_utils, "NixlWrapper", None)
|
||||
monkeypatch.setattr(nixl_utils, "nixl_agent_config", None)
|
||||
cfg = create_ec_vllm_config(ec_role="ec_both")
|
||||
cfg.ec_transfer_config.ec_connector_extra_config["ec_enable_nixl"] = True
|
||||
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="ec_enable_nixl requires NIXL"):
|
||||
ECCPUScheduler(cfg)
|
||||
|
||||
assert not Path(region._mmap_path).exists()
|
||||
assert region._fd is None
|
||||
assert region._mmap_obj is None
|
||||
finally:
|
||||
region.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_control_transport_failure_releases_nixl_and_shared_region(monkeypatch):
|
||||
import vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq as zmq_mod
|
||||
import vllm.distributed.ec_transfer.ec_connector.cpu.data.nixl as data_mod
|
||||
|
||||
class RegisteredData:
|
||||
registered = True
|
||||
|
||||
def deregister(self):
|
||||
self.registered = False
|
||||
|
||||
data = RegisteredData()
|
||||
error = RuntimeError("control transport failed")
|
||||
|
||||
def fail_control_transport():
|
||||
raise error
|
||||
|
||||
region = _region()
|
||||
monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: region)
|
||||
monkeypatch.setattr(nixl_utils, "NixlWrapper", object())
|
||||
monkeypatch.setattr(nixl_utils, "nixl_agent_config", object())
|
||||
monkeypatch.setattr(data_mod, "NixlDataTransport", lambda **kwargs: data)
|
||||
monkeypatch.setattr(zmq_mod, "ZmqClientTransport", fail_control_transport)
|
||||
cfg = create_ec_vllm_config(ec_role="ec_consumer")
|
||||
cfg.model_config.hf_config = None
|
||||
cfg.model_config.get_inputs_embeds_size.return_value = 8
|
||||
cfg.model_config.model = "test-model"
|
||||
cfg.ec_transfer_config.ec_connector_extra_config["ec_enable_nixl"] = True
|
||||
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="control transport failed") as exc:
|
||||
ECCPUScheduler(cfg)
|
||||
|
||||
assert exc.value is error
|
||||
assert not data.registered
|
||||
assert not Path(region._mmap_path).exists()
|
||||
assert region._fd is None
|
||||
assert region._mmap_obj is None
|
||||
finally:
|
||||
region.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.skipif(NixlWrapper is None, reason="Requires NIXL package")
|
||||
def test_gate_on_wires_data_transport_and_producer_session(monkeypatch):
|
||||
# Port 0 lets the OS pick an ephemeral port so the real ZMQ ROUTER bind
|
||||
# in ProducerSession.start() cannot collide with another test/process.
|
||||
monkeypatch.setenv("VLLM_EC_SIDE_CHANNEL_HOST", "127.0.0.1")
|
||||
monkeypatch.setenv("VLLM_EC_SIDE_CHANNEL_PORT", "0")
|
||||
monkeypatch.setattr(sched_mod, "create_ec_shared_region", lambda cfg: _region())
|
||||
cfg = create_ec_vllm_config(ec_role="ec_both")
|
||||
cfg.ec_transfer_config.ec_connector_extra_config["ec_enable_nixl"] = True
|
||||
s = ECCPUScheduler(cfg)
|
||||
try:
|
||||
assert s._nixl_enabled is True
|
||||
assert s._data is not None
|
||||
assert s._compat_hash is not None
|
||||
assert s._producer_session is not None
|
||||
assert s._peer_host == "127.0.0.1"
|
||||
assert s._transport is not None # is_ec_consumer is also True
|
||||
finally:
|
||||
s.shutdown()
|
||||
@@ -1,116 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import uuid
|
||||
|
||||
import vllm.distributed.ec_transfer.ec_connector.cpu.scheduler as sched_mod
|
||||
from tests.v1.ec_connector.unit.utils import create_ec_vllm_config
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
|
||||
ECSharedRegion,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import ECCPUScheduler
|
||||
|
||||
_N, _BS = 16, 64
|
||||
|
||||
|
||||
class _Pos:
|
||||
def __init__(self, offset, length):
|
||||
self.offset, self.length = offset, length
|
||||
|
||||
def get_num_embeds(self):
|
||||
return self.length
|
||||
|
||||
|
||||
class _Feature:
|
||||
def __init__(self, mm_hash, length=1):
|
||||
self.mm_hash = mm_hash
|
||||
self.identifier = mm_hash
|
||||
self.mm_position = _Pos(0, length)
|
||||
self.data = None
|
||||
self.modality = "image"
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, features, req_id="r1"):
|
||||
self.mm_features = features
|
||||
self.request_id = req_id
|
||||
|
||||
|
||||
def _sched_gate_off(monkeypatch):
|
||||
def _region(cfg):
|
||||
return ECSharedRegion(
|
||||
engine_id="eng-" + str(uuid.uuid4()), num_blocks=_N, block_size_bytes=_BS
|
||||
)
|
||||
|
||||
monkeypatch.setattr(sched_mod, "create_ec_shared_region", _region)
|
||||
return ECCPUScheduler(create_ec_vllm_config(ec_role="ec_both"))
|
||||
|
||||
|
||||
def test_request_finished_gate_off_returns_none(monkeypatch):
|
||||
s = _sched_gate_off(monkeypatch)
|
||||
assert s.request_finished(_Request([_Feature("h1")])) == (False, None)
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_request_finished_producer_emits_params(monkeypatch):
|
||||
s = _sched_gate_off(monkeypatch)
|
||||
# Simulate NIXL-enabled producer bookkeeping without building real NIXL.
|
||||
s._nixl_enabled = True
|
||||
s._peer_host, s._peer_port = "1.2.3.4", 5601
|
||||
# feature length=2, hidden_dim=32, element_size=2 -> 128 bytes -> 2 blocks.
|
||||
entry = s._cache.alloc("h1", 2, (2, 32))
|
||||
assert entry is not None
|
||||
s._cache.mark_ready("h1")
|
||||
|
||||
delay, params = s.request_finished(_Request([_Feature("h1", length=2)]))
|
||||
assert delay is False
|
||||
assert entry.evictable
|
||||
assert params == {
|
||||
"h1": {
|
||||
"metadata": {},
|
||||
"item_indices": [0],
|
||||
"peer_host": "1.2.3.4",
|
||||
"peer_port": 5601,
|
||||
"size_bytes": 2 * 32 * 2,
|
||||
"shape": [2, 32],
|
||||
}
|
||||
}
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_request_finished_announces_not_ready_entry(monkeypatch):
|
||||
"""A save whose GPU->mmap copy hasn't been confirmed complete yet is
|
||||
still announced: the entry can't be evicted before it's ready, and a read
|
||||
arriving too early is NACKed NACK_NOT_READY for the consumer to retry —
|
||||
protecting it is not this method's job."""
|
||||
s = _sched_gate_off(monkeypatch)
|
||||
s._nixl_enabled = True
|
||||
s._peer_host, s._peer_port = "1.2.3.4", 5601
|
||||
s._cache.alloc("h1", 2, (2, 32)) # allocated but not marked ready
|
||||
|
||||
delay, params = s.request_finished(_Request([_Feature("h1", length=2)]))
|
||||
assert delay is False
|
||||
assert params == {
|
||||
"h1": {
|
||||
"metadata": {},
|
||||
"item_indices": [0],
|
||||
"peer_host": "1.2.3.4",
|
||||
"peer_port": 5601,
|
||||
"size_bytes": 2 * 32 * 2,
|
||||
"shape": [2, 32],
|
||||
}
|
||||
}
|
||||
s.shutdown()
|
||||
|
||||
|
||||
def test_request_finished_skips_unallocated_entry(monkeypatch):
|
||||
s = _sched_gate_off(monkeypatch)
|
||||
s._nixl_enabled = True
|
||||
s._peer_host, s._peer_port = "1.2.3.4", 5601
|
||||
# No alloc() for "h1" at all — e.g. the cache was full at save time.
|
||||
|
||||
delay, params = s.request_finished(_Request([_Feature("h1", length=2)]))
|
||||
assert delay is False
|
||||
# The item's placeholder metadata is still reported even though there's
|
||||
# no cache entry to transfer (empty "metadata": no fields, no transfer).
|
||||
assert params == {"h1": {"metadata": {}, "item_indices": [0]}}
|
||||
s.shutdown()
|
||||
@@ -1,471 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for ProducerXfer, ProducerSession, ConsumerXfer, ConsumerSession."""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.protocol import (
|
||||
XferAck,
|
||||
XferReq,
|
||||
XferStatus,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.embedding_cache import (
|
||||
EmbeddingCache,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.session import (
|
||||
_CONSUMER_QUARANTINE_TIMEOUT_S,
|
||||
ConsumerSession,
|
||||
ConsumerXfer,
|
||||
ProducerSession,
|
||||
ProducerXfer,
|
||||
XferState,
|
||||
)
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_data(xfer_state: str = "PROC") -> MagicMock:
|
||||
data = MagicMock()
|
||||
data.check_xfer_state.return_value = xfer_state
|
||||
data.post_read.return_value = 99
|
||||
return data
|
||||
|
||||
|
||||
def _ok_ack(
|
||||
mm_hash: str = "h1",
|
||||
session_id: str = "sess-1",
|
||||
src_indices: list | None = None,
|
||||
) -> XferAck:
|
||||
return XferAck(
|
||||
mm_hash=mm_hash,
|
||||
status=XferStatus.OK,
|
||||
session_id=session_id,
|
||||
src_block_indices=src_indices or [0, 1],
|
||||
agent_metadata=b"meta",
|
||||
mem_descriptor=b"desc",
|
||||
)
|
||||
|
||||
|
||||
def _xfer(
|
||||
data: MagicMock | None = None,
|
||||
deadline: float | None = None,
|
||||
consumer_session_id: str = "sess-1",
|
||||
) -> ConsumerXfer:
|
||||
return ConsumerXfer(
|
||||
mm_hash="h1",
|
||||
block_indices=[10, 11],
|
||||
addr=("host", 1234),
|
||||
deadline=deadline if deadline is not None else time.monotonic() + 60,
|
||||
data=data or _make_data(),
|
||||
consumer_session_id=consumer_session_id,
|
||||
)
|
||||
|
||||
|
||||
def _started(
|
||||
data: MagicMock | None = None, deadline: float | None = None
|
||||
) -> ConsumerXfer:
|
||||
"""ConsumerXfer that has already received an OK XferAck."""
|
||||
x = _xfer(data, deadline)
|
||||
x.handle_ack(_ok_ack(session_id="sess-1", src_indices=[5, 6]), agent_name="agent-1")
|
||||
return x
|
||||
|
||||
|
||||
# ── ProducerXfer ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_producer_xfer_not_expired_before_deadline():
|
||||
x = ProducerXfer("h1", [0, 1], deadline=time.monotonic() + 60)
|
||||
assert not x.is_expired()
|
||||
|
||||
|
||||
def test_producer_xfer_expired_after_deadline():
|
||||
x = ProducerXfer("h1", [0, 1], deadline=time.monotonic() - 1)
|
||||
assert x.is_expired()
|
||||
|
||||
|
||||
# ── ProducerSession ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _xfer_req(mm_hash: str = "h1", session_id: str = "sess-1") -> XferReq:
|
||||
return XferReq(
|
||||
mm_hash=mm_hash, compatibility_hash="hash-abc", session_id=session_id
|
||||
)
|
||||
|
||||
|
||||
def _make_producer_session(cache: EmbeddingCache | None = None) -> ProducerSession:
|
||||
data = _make_data()
|
||||
data.get_agent_metadata.return_value = b"meta"
|
||||
data.get_mem_descriptor.return_value = b"desc"
|
||||
return ProducerSession(
|
||||
transport=MagicMock(),
|
||||
data=data,
|
||||
cache=cache or EmbeddingCache(num_blocks=8),
|
||||
compat_hash="hash-abc",
|
||||
)
|
||||
|
||||
|
||||
def test_producer_session_grant_pins_ready_entry_and_returns_blocks():
|
||||
cache = EmbeddingCache(num_blocks=8)
|
||||
cache.alloc("h1", 2)
|
||||
cache.mark_ready("h1")
|
||||
s = _make_producer_session(cache)
|
||||
|
||||
ack = s._grant_or_nack(_xfer_req(mm_hash="h1"))
|
||||
|
||||
assert ack.status == XferStatus.OK
|
||||
entry = cache.get("h1")
|
||||
assert not entry.evictable # pinned by the grant
|
||||
assert list(ack.src_block_indices) == list(entry.block_ids)
|
||||
|
||||
|
||||
def test_producer_session_grant_nacks_missing_entry():
|
||||
s = _make_producer_session()
|
||||
ack = s._grant_or_nack(_xfer_req(mm_hash="nope"))
|
||||
assert ack.status == XferStatus.NACK_MISSING
|
||||
|
||||
|
||||
def test_producer_session_grant_nacks_not_ready_entry_as_retryable():
|
||||
"""An announced entry whose save is still in flight is distinguished from a
|
||||
miss, so the consumer retries instead of falling back to local compute."""
|
||||
cache = EmbeddingCache(num_blocks=8)
|
||||
cache.alloc("h1", 2) # not ready
|
||||
s = _make_producer_session(cache)
|
||||
ack = s._grant_or_nack(_xfer_req(mm_hash="h1"))
|
||||
assert ack.status == XferStatus.NACK_NOT_READY
|
||||
|
||||
|
||||
def test_producer_session_grant_nacks_version_mismatch():
|
||||
cache = EmbeddingCache(num_blocks=8)
|
||||
cache.alloc("h1", 2)
|
||||
cache.mark_ready("h1")
|
||||
s = _make_producer_session(cache)
|
||||
req = XferReq(
|
||||
mm_hash="h1",
|
||||
compatibility_hash="hash-abc",
|
||||
session_id="sess-1",
|
||||
connector_version=-1,
|
||||
)
|
||||
|
||||
ack = s._grant_or_nack(req)
|
||||
|
||||
assert ack.status == XferStatus.NACK_VERSION
|
||||
entry = cache.get("h1")
|
||||
assert entry.evictable # nothing was pinned
|
||||
|
||||
|
||||
def test_producer_session_grant_nacks_compat_hash_mismatch():
|
||||
cache = EmbeddingCache(num_blocks=8)
|
||||
cache.alloc("h1", 2)
|
||||
cache.mark_ready("h1")
|
||||
s = _make_producer_session(cache)
|
||||
req = XferReq(mm_hash="h1", compatibility_hash="wrong-hash", session_id="sess-1")
|
||||
|
||||
ack = s._grant_or_nack(req)
|
||||
|
||||
assert ack.status == XferStatus.NACK_INCOMPAT
|
||||
entry = cache.get("h1")
|
||||
assert entry.evictable # nothing was pinned
|
||||
|
||||
|
||||
def test_producer_session_notif_unpins_via_cache():
|
||||
cache = EmbeddingCache(num_blocks=8)
|
||||
cache.alloc("h1", 2)
|
||||
cache.mark_ready("h1")
|
||||
s = _make_producer_session(cache)
|
||||
s._grant_or_nack(_xfer_req(mm_hash="h1", session_id="sess-1"))
|
||||
entry = cache.get("h1")
|
||||
assert not entry.evictable # pinned
|
||||
|
||||
s._data.get_new_notifs.return_value = {"agent": [b"sess-1:h1"]}
|
||||
s._drain_notifs()
|
||||
|
||||
assert entry.evictable # unpinned via cache
|
||||
assert s._active_xfers == {}
|
||||
|
||||
|
||||
def test_producer_session_sweep_timeouts_unpins_via_cache():
|
||||
cache = EmbeddingCache(num_blocks=8)
|
||||
cache.alloc("h1", 2)
|
||||
cache.mark_ready("h1")
|
||||
s = _make_producer_session(cache)
|
||||
s._grant_or_nack(_xfer_req(mm_hash="h1", session_id="sess-1"))
|
||||
key = next(iter(s._active_xfers))
|
||||
s._active_xfers[key].deadline = time.monotonic() - 1
|
||||
|
||||
s._sweep_timeouts()
|
||||
|
||||
assert cache.get("h1").evictable
|
||||
assert s._active_xfers == {}
|
||||
|
||||
|
||||
# ── ConsumerXfer.handle_ack ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_consumer_xfer_handle_ack_ok_starts_read():
|
||||
data = _make_data()
|
||||
x = _xfer(data)
|
||||
assert x.handle_ack(_ok_ack(), agent_name="agent-1") is True
|
||||
assert x.transfer_handle == 99
|
||||
|
||||
|
||||
def test_consumer_xfer_handle_ack_notif_msg_is_session_id_colon_mm_hash():
|
||||
data = _make_data()
|
||||
x = _xfer(data, consumer_session_id="my-sess")
|
||||
x.handle_ack(_ok_ack(mm_hash="h1"), agent_name="agent-1")
|
||||
_, kwargs = data.post_read.call_args
|
||||
assert kwargs["notif_msg"] == b"my-sess:h1"
|
||||
|
||||
|
||||
def test_consumer_xfer_handle_ack_forwards_correct_args():
|
||||
data = _make_data()
|
||||
x = _xfer(data)
|
||||
x.handle_ack(_ok_ack(src_indices=[5, 6]), agent_name="agent-1")
|
||||
args, _ = data.post_read.call_args
|
||||
assert args[0] == [10, 11] # local block_indices
|
||||
assert args[1] == "agent-1" # agent_name (not a raw handle)
|
||||
assert args[2] == [5, 6] # remote src_block_indices
|
||||
|
||||
|
||||
def test_consumer_xfer_handle_ack_resets_deadline():
|
||||
original = time.monotonic() + 2
|
||||
x = _xfer(deadline=original)
|
||||
x.handle_ack(_ok_ack(), agent_name="agent-1")
|
||||
assert x.deadline > original
|
||||
|
||||
|
||||
def test_consumer_xfer_handle_ack_nack_returns_false():
|
||||
data = _make_data()
|
||||
x = _xfer(data)
|
||||
assert (
|
||||
x.handle_ack(XferAck(mm_hash="h1", status=XferStatus.NACK_MISSING), "a")
|
||||
is False
|
||||
)
|
||||
assert x.transfer_handle is None
|
||||
data.post_read.assert_not_called()
|
||||
|
||||
|
||||
# ── ConsumerXfer.poll — WAITING_ACK ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_consumer_xfer_poll_waiting_ack_before_deadline():
|
||||
assert _xfer().poll(time.monotonic()) == XferState.WAITING_ACK
|
||||
|
||||
|
||||
def test_consumer_xfer_poll_waiting_ack_timeout():
|
||||
assert (
|
||||
_xfer(deadline=time.monotonic() - 1).poll(time.monotonic())
|
||||
== XferState.ACK_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
# ── ConsumerXfer.poll — READING ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_consumer_xfer_poll_reading_proc():
|
||||
assert _started().poll(time.monotonic()) == XferState.READING
|
||||
|
||||
|
||||
def test_consumer_xfer_poll_reading_done_releases_handle():
|
||||
data = _make_data(xfer_state="DONE")
|
||||
x = _started(data)
|
||||
assert x.poll(time.monotonic()) == XferState.DONE
|
||||
data.release_xfer_handle.assert_called_once_with(99)
|
||||
assert x.transfer_handle is None
|
||||
|
||||
|
||||
def test_consumer_xfer_poll_reading_unexpected_state_fails():
|
||||
data = _make_data(xfer_state="ERR")
|
||||
x = _started(data)
|
||||
assert x.poll(time.monotonic()) == XferState.READ_FAILED
|
||||
data.release_xfer_handle.assert_called_once_with(99)
|
||||
|
||||
|
||||
def test_consumer_xfer_poll_reading_exception_fails_gracefully():
|
||||
data = _make_data()
|
||||
data.check_xfer_state.side_effect = RuntimeError("nixl crash")
|
||||
x = _started(data)
|
||||
assert x.poll(time.monotonic()) == XferState.READ_FAILED
|
||||
|
||||
|
||||
def test_consumer_xfer_poll_timeout_quarantines_without_releasing():
|
||||
data = _make_data(xfer_state="PROC")
|
||||
x = _started(data)
|
||||
assert x.poll(time.monotonic() + 9999) == XferState.QUARANTINED
|
||||
data.release_xfer_handle.assert_not_called()
|
||||
assert x.transfer_handle == 99
|
||||
|
||||
|
||||
# ── ConsumerXfer.poll — QUARANTINED ───────────────────────────────────────────
|
||||
|
||||
|
||||
def test_consumer_xfer_quarantined_proc_stays():
|
||||
data = _make_data(xfer_state="PROC")
|
||||
x = _started(data)
|
||||
x.poll(time.monotonic() + 9999) # → QUARANTINED
|
||||
assert x.poll(time.monotonic()) == XferState.QUARANTINED
|
||||
data.release_xfer_handle.assert_not_called()
|
||||
|
||||
|
||||
def test_consumer_xfer_quarantined_terminal_settles():
|
||||
data = _make_data(xfer_state="PROC")
|
||||
x = _started(data)
|
||||
x.poll(time.monotonic() + 9999) # → QUARANTINED
|
||||
data.check_xfer_state.return_value = "DONE"
|
||||
assert x.poll(time.monotonic()) == XferState.SETTLED
|
||||
data.release_xfer_handle.assert_called_once_with(99)
|
||||
assert x.transfer_handle is None
|
||||
|
||||
|
||||
def test_consumer_xfer_quarantined_exception_settles():
|
||||
data = _make_data(xfer_state="PROC")
|
||||
x = _started(data)
|
||||
x.poll(time.monotonic() + 9999)
|
||||
data.check_xfer_state.side_effect = RuntimeError("gone")
|
||||
assert x.poll(time.monotonic()) == XferState.SETTLED
|
||||
|
||||
|
||||
def test_consumer_xfer_quarantined_timeout_evicts_blocks():
|
||||
"""Quarantine timeout should release handle and return READ_FAILED."""
|
||||
data = _make_data(xfer_state="PROC")
|
||||
x = _started(data)
|
||||
# First poll transitions to QUARANTINED (deadline extended by 60s)
|
||||
x.poll(time.monotonic() + 9999)
|
||||
# Second poll with expired quarantine deadline should evict
|
||||
expired_time = time.monotonic() + 9999 + _CONSUMER_QUARANTINE_TIMEOUT_S + 1
|
||||
assert x.poll(expired_time) == XferState.READ_FAILED
|
||||
data.release_xfer_handle.assert_called_once_with(99)
|
||||
assert x.transfer_handle is None
|
||||
|
||||
|
||||
# ── ConsumerXfer.cancel / release ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_consumer_xfer_cancel_valid_in_waiting_ack():
|
||||
_xfer().cancel() # must not raise
|
||||
|
||||
|
||||
def test_consumer_xfer_cancel_raises_when_read_started():
|
||||
with pytest.raises(AssertionError):
|
||||
_started().cancel()
|
||||
|
||||
|
||||
def test_consumer_xfer_release_releases_handle():
|
||||
data = _make_data()
|
||||
x = _started(data)
|
||||
x.release()
|
||||
data.release_xfer_handle.assert_called_once_with(99)
|
||||
|
||||
|
||||
def test_consumer_xfer_release_noop_when_no_handle():
|
||||
data = _make_data()
|
||||
_xfer(data).release()
|
||||
data.release_xfer_handle.assert_not_called()
|
||||
|
||||
|
||||
# ── ConsumerSession ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_consumer_session(data=None):
|
||||
zmq_conn = MagicMock()
|
||||
zmq_conn.recv.return_value = []
|
||||
return ConsumerSession(
|
||||
addr=("host", 1234),
|
||||
zmq_conn=zmq_conn,
|
||||
transport=MagicMock(),
|
||||
data=data or _make_data(),
|
||||
compat_hash="hash-abc",
|
||||
)
|
||||
|
||||
|
||||
def test_consumer_session_has_stable_session_id():
|
||||
s = _make_consumer_session()
|
||||
assert isinstance(s._session_id, str) and len(s._session_id) > 0
|
||||
assert s._session_id == s._session_id # stable
|
||||
|
||||
|
||||
def test_consumer_session_start_xfer_sends_req_with_session_id():
|
||||
import msgspec
|
||||
|
||||
s = _make_consumer_session()
|
||||
s.start_xfer("h1", [0, 1], deadline=time.monotonic() + 10)
|
||||
raw = s._zmq.send.call_args[0][0]
|
||||
req = msgspec.msgpack.decode(raw, type=XferReq)
|
||||
assert req.mm_hash == "h1"
|
||||
assert req.session_id == s._session_id
|
||||
|
||||
|
||||
def test_consumer_session_poll_done_goes_to_completed():
|
||||
import msgspec
|
||||
|
||||
data = _make_data(xfer_state="PROC")
|
||||
s = _make_consumer_session(data)
|
||||
s.start_xfer("h1", [0, 1], deadline=time.monotonic() + 10)
|
||||
|
||||
ack = XferAck(
|
||||
mm_hash="h1",
|
||||
status=XferStatus.OK,
|
||||
session_id=s._session_id,
|
||||
src_block_indices=[5, 6],
|
||||
agent_metadata=b"meta",
|
||||
mem_descriptor=b"desc",
|
||||
)
|
||||
data.add_remote_peer.return_value = "agent-1"
|
||||
raw_ack = msgspec.msgpack.encode(ack)
|
||||
|
||||
data.check_xfer_state.return_value = "DONE"
|
||||
s.poll([raw_ack], time.monotonic())
|
||||
|
||||
results = s.take_results()
|
||||
assert "h1" in results.completed
|
||||
assert not results.tombstoned
|
||||
|
||||
|
||||
def test_consumer_session_poll_nack_goes_to_tombstoned():
|
||||
import msgspec
|
||||
|
||||
s = _make_consumer_session()
|
||||
s.start_xfer("h1", [0, 1], deadline=time.monotonic() + 10)
|
||||
nack = XferAck(mm_hash="h1", status=XferStatus.NACK_MISSING)
|
||||
s.poll([msgspec.msgpack.encode(nack)], time.monotonic())
|
||||
results = s.take_results()
|
||||
assert "h1" in results.tombstoned
|
||||
assert not results.completed
|
||||
|
||||
|
||||
def test_consumer_session_poll_not_ready_nack_goes_to_retryable():
|
||||
"""A not-ready NACK must not tombstone: tombstoning admits the request with
|
||||
no embedding, which is fatal once the media has been rewritten away."""
|
||||
import msgspec
|
||||
|
||||
s = _make_consumer_session()
|
||||
s.start_xfer("h1", [0, 1], deadline=time.monotonic() + 10)
|
||||
nack = XferAck(mm_hash="h1", status=XferStatus.NACK_NOT_READY)
|
||||
s.poll([msgspec.msgpack.encode(nack)], time.monotonic())
|
||||
results = s.take_results()
|
||||
assert "h1" in results.retryable
|
||||
assert not results.tombstoned
|
||||
assert not results.completed
|
||||
|
||||
|
||||
def test_consumer_session_ack_timeout_reports_retry_once():
|
||||
"""A delayed producer ACK is retryable, and its result is drained once."""
|
||||
s = _make_consumer_session()
|
||||
s.start_xfer("h1", [0, 1], deadline=time.monotonic() - 1)
|
||||
s.poll([], time.monotonic())
|
||||
r1 = s.take_results()
|
||||
assert "h1" in r1.retryable
|
||||
assert not r1.tombstoned
|
||||
r2 = s.take_results()
|
||||
assert not r2.retryable # cleared after first take
|
||||
|
||||
|
||||
def test_consumer_session_on_peer_down_cancels_waiting_ack():
|
||||
s = _make_consumer_session()
|
||||
s.start_xfer("h1", [0, 1], deadline=time.monotonic() + 60)
|
||||
s.on_peer_down()
|
||||
results = s.take_results()
|
||||
assert "h1" in results.retryable
|
||||
assert not results.tombstoned
|
||||
@@ -1,54 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for ECCPUConnector scheduler utilities."""
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.utils import (
|
||||
build_block_descs,
|
||||
deserialize_mem_descriptor,
|
||||
serialize_mem_descriptor,
|
||||
)
|
||||
|
||||
# ── build_block_descs ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_id,expected_dev", [(7, 7), (None, 0)])
|
||||
def test_build_block_descs(device_id, expected_dev):
|
||||
kwargs = dict(base_ptr=1000, num_blocks=4, block_size_bytes=256)
|
||||
if device_id is not None:
|
||||
kwargs["device_id"] = device_id
|
||||
descs = build_block_descs(**kwargs)
|
||||
assert len(descs) == 4
|
||||
for i, (addr, size, dev) in enumerate(descs):
|
||||
assert addr == 1000 + i * 256
|
||||
assert size == 256
|
||||
assert dev == expected_dev
|
||||
|
||||
|
||||
def test_build_block_descs_zero_blocks_returns_empty():
|
||||
assert build_block_descs(base_ptr=100, num_blocks=0, block_size_bytes=64) == []
|
||||
|
||||
|
||||
# ── serialize / deserialize_mem_descriptor ───────────────────────────────────
|
||||
|
||||
|
||||
def test_mem_descriptor_roundtrip():
|
||||
descs = [(100, 64, 0), (164, 64, 0), (228, 64, 1)]
|
||||
assert deserialize_mem_descriptor(serialize_mem_descriptor(descs)) == descs
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_value",
|
||||
[
|
||||
[(1, 2)], # 2-tuple instead of 3
|
||||
[("a", "b", "c")], # strings, not ints
|
||||
],
|
||||
)
|
||||
def test_mem_descriptor_rejects_malformed_payload(bad_value):
|
||||
"""Malformed descriptor lists must fail to decode."""
|
||||
encoder = msgspec.msgpack.Encoder()
|
||||
bad_payload = encoder.encode(bad_value)
|
||||
with pytest.raises((msgspec.DecodeError, msgspec.ValidationError)):
|
||||
deserialize_mem_descriptor(bad_payload)
|
||||
@@ -1,115 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for the V2 GPU model runner's EC connector wrapper."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import (
|
||||
ECConnectorBase,
|
||||
ECConnectorMetadata,
|
||||
)
|
||||
from vllm.v1.outputs import EMPTY_MODEL_RUNNER_OUTPUT
|
||||
from vllm.v1.worker.ec_connector_model_runner_mixin import (
|
||||
ECConnectorModelRunnerMixin,
|
||||
)
|
||||
from vllm.v1.worker.gpu.ec_connector import NO_OP_EC_CONNECTOR, ActiveECConnector
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
WORKER_META = object()
|
||||
CONNECTOR_STATS = object()
|
||||
|
||||
|
||||
def _scheduler_output() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
ec_connector_metadata=ECConnectorMetadata(), finished_req_ids=frozenset()
|
||||
)
|
||||
|
||||
|
||||
def _connector(
|
||||
encoder_cache: dict | None = None,
|
||||
is_producer: bool = True,
|
||||
is_consumer: bool = False,
|
||||
) -> tuple[ActiveECConnector, MagicMock]:
|
||||
fake = MagicMock(spec=ECConnectorBase)
|
||||
fake.is_producer = is_producer
|
||||
fake.is_consumer = is_consumer
|
||||
fake.get_finished.return_value = (None, None)
|
||||
fake.build_connector_worker_meta.return_value = WORKER_META
|
||||
fake.get_ec_connector_stats.return_value = CONNECTOR_STATS
|
||||
with patch("vllm.v1.worker.gpu.ec_connector.get_ec_transfer", return_value=fake):
|
||||
return ActiveECConnector(SimpleNamespace(), encoder_cache or {}), fake
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("is_producer", "is_consumer"), [(True, False), (True, True), (False, True)]
|
||||
)
|
||||
def test_saves_newly_added_caches_for_every_producer(is_producer, is_consumer):
|
||||
"""An ec_both node is also a producer: it must offload what it just computed."""
|
||||
encoder_cache = {"mm_old": None}
|
||||
connector, fake = _connector(encoder_cache, is_producer, is_consumer)
|
||||
|
||||
with connector.maybe_get_output(_scheduler_output()):
|
||||
encoder_cache["mm_new"] = None
|
||||
|
||||
saved = [call.kwargs["mm_hash"] for call in fake.save_caches.call_args_list]
|
||||
assert saved == (["mm_new"] if is_producer else [])
|
||||
assert fake.start_save_caches.called == is_producer
|
||||
if is_producer:
|
||||
assert fake.start_save_caches.call_args.kwargs["encoder_cache"] is encoder_cache
|
||||
assert fake.start_load_caches.called == is_consumer
|
||||
|
||||
|
||||
def test_worker_meta_is_reported_on_context_exit():
|
||||
"""Reported in the finally block, so is_empty() sees it only after the exit."""
|
||||
connector, fake = _connector()
|
||||
|
||||
with connector.maybe_get_output(_scheduler_output()) as output:
|
||||
assert output.ec_connector_worker_meta is None
|
||||
|
||||
assert output.ec_connector_worker_meta is WORKER_META
|
||||
assert fake.clear_connector_metadata.called
|
||||
|
||||
|
||||
def test_connector_stats_are_reported_on_context_exit():
|
||||
"""Reported in the finally block, alongside the worker meta."""
|
||||
connector, fake = _connector()
|
||||
|
||||
with connector.maybe_get_output(_scheduler_output()) as output:
|
||||
assert output.ec_connector_stats is None
|
||||
|
||||
assert output.ec_connector_stats is CONNECTOR_STATS
|
||||
|
||||
|
||||
def test_v1_producer_receives_existing_encoder_cache():
|
||||
encoder_cache = {"mm_cached": None}
|
||||
fake = MagicMock(spec=ECConnectorBase)
|
||||
fake.is_producer = True
|
||||
fake.is_consumer = False
|
||||
fake.get_finished.return_value = (None, None)
|
||||
|
||||
module = "vllm.v1.worker.ec_connector_model_runner_mixin"
|
||||
with (
|
||||
patch(f"{module}.has_ec_transfer", return_value=True),
|
||||
patch(f"{module}.get_ec_transfer", return_value=fake),
|
||||
ECConnectorModelRunnerMixin.maybe_get_ec_connector_output(
|
||||
_scheduler_output(), encoder_cache
|
||||
),
|
||||
):
|
||||
pass
|
||||
|
||||
assert fake.start_save_caches.call_args.kwargs["encoder_cache"] is encoder_cache
|
||||
|
||||
|
||||
def test_no_forward_reports_without_running_the_model():
|
||||
connector, _ = _connector()
|
||||
|
||||
output = connector.no_forward(_scheduler_output())
|
||||
|
||||
assert output.ec_connector_output.ec_connector_worker_meta is WORKER_META
|
||||
|
||||
empty = NO_OP_EC_CONNECTOR.no_forward(_scheduler_output())
|
||||
assert empty is EMPTY_MODEL_RUNNER_OUTPUT
|
||||
@@ -1,81 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Shared config builders for EC connector unit tests."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import Mock
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.config import ModelConfig, VllmConfig
|
||||
from vllm.config.ec_transfer import ECRole, ECTransferConfig
|
||||
from vllm.config.parallel import ParallelConfig
|
||||
|
||||
# The scheduler derives its per-load ack count from the PCP size only under the
|
||||
# `mp` backend -- which also keeps the world size off the local GPU count, so
|
||||
# TP > 1 stays expressible on a single-GPU host.
|
||||
_EXECUTOR_BACKEND = "mp"
|
||||
_PCP_SIZE = 1
|
||||
|
||||
|
||||
_EMBED_WIDTH = 32
|
||||
|
||||
|
||||
def create_ec_vllm_config(
|
||||
*,
|
||||
ec_role: ECRole = "ec_both",
|
||||
tensor_parallel_size: int = 1,
|
||||
rank: int = 0,
|
||||
dtype: torch.dtype = torch.float16,
|
||||
encoder_output_widths: dict[str, int] | None = None,
|
||||
) -> Mock:
|
||||
"""Build a `VllmConfig` stand-in for EC connector unit tests.
|
||||
|
||||
`ec_transfer_config` and `parallel_config` are the real config objects, so
|
||||
role derivation and the rank arithmetic the connector depends on behave as
|
||||
they do in production.
|
||||
|
||||
`model_config` supplies a dtype and embedding width without resolving an
|
||||
HF model or inspecting its architecture.
|
||||
|
||||
Args:
|
||||
ec_role: EC role this instance plays. `is_ec_producer` and
|
||||
`is_ec_consumer` are derived from it, as in production.
|
||||
tensor_parallel_size: TP degree.
|
||||
rank: Global rank of this worker.
|
||||
dtype: Encoder cache dtype.
|
||||
encoder_output_widths: Per-modality encoder output width, as a
|
||||
producer measures it at startup. Defaults to the embedding width.
|
||||
|
||||
Returns:
|
||||
A `VllmConfig`-specced mock carrying the configs above.
|
||||
|
||||
"""
|
||||
parallel_config = ParallelConfig(
|
||||
tensor_parallel_size=tensor_parallel_size,
|
||||
prefill_context_parallel_size=_PCP_SIZE,
|
||||
distributed_executor_backend=_EXECUTOR_BACKEND,
|
||||
rank=rank,
|
||||
)
|
||||
ec_transfer_config = ECTransferConfig(
|
||||
# The role properties report False unless a connector is named.
|
||||
ec_connector="ECCPUConnector",
|
||||
ec_role=ec_role,
|
||||
engine_id=str(uuid.uuid4()),
|
||||
)
|
||||
ec_transfer_config.mm_encoder_output_widths = (
|
||||
dict.fromkeys(("image", "video", "audio"), _EMBED_WIDTH)
|
||||
if encoder_output_widths is None
|
||||
else encoder_output_widths
|
||||
)
|
||||
|
||||
model_config = Mock(spec=ModelConfig)
|
||||
model_config.dtype = dtype
|
||||
model_config.get_inputs_embeds_size.return_value = _EMBED_WIDTH
|
||||
|
||||
vllm_config = Mock(spec=VllmConfig)
|
||||
vllm_config.ec_transfer_config = ec_transfer_config
|
||||
vllm_config.parallel_config = parallel_config
|
||||
vllm_config.model_config = model_config
|
||||
vllm_config.instance_id = f"ec-test-{uuid.uuid4()}"
|
||||
return vllm_config
|
||||
@@ -124,7 +124,6 @@ MoEBackend = Literal[
|
||||
"triton",
|
||||
"batched_triton",
|
||||
"deep_gemm",
|
||||
"deep_gemm_mega_moe",
|
||||
"cutlass",
|
||||
"flashinfer_trtllm",
|
||||
"flashinfer_cutlass",
|
||||
@@ -163,7 +162,7 @@ PASSTHROUGH_ALL2ALL_BACKEND: Final = "passthrough"
|
||||
# "Native" here meaning that the backend does not use the fused MoE path.
|
||||
# Currently only relevant for the vLLM provided DeepGEMM MegaMoE.,
|
||||
# TODO: migrate to the fused MoE path ultimately.
|
||||
NATIVE_MEGA_MOE_BACKENDS = frozenset({"deep_gemm_mega_moe"})
|
||||
NATIVE_MEGA_MOE_BACKENDS: frozenset[str] = frozenset()
|
||||
|
||||
# Every megakernel backend. The FlashInfer variants run through the standard
|
||||
# fused-MoE model path; the quantization method binds the kernel.
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch.distributed import ProcessGroup
|
||||
|
||||
from vllm.distributed.utils import pickle
|
||||
from vllm.logger import init_logger
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.platforms.interface import CpuArchEnum
|
||||
|
||||
from .base_device_communicator import DeviceCommunicatorBase
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class CpuCommunicator(DeviceCommunicatorBase):
|
||||
def __init__(
|
||||
self,
|
||||
cpu_group: ProcessGroup,
|
||||
device: torch.device | None = None,
|
||||
device_group: ProcessGroup | None = None,
|
||||
unique_name: str = "",
|
||||
use_all2all: bool = False,
|
||||
):
|
||||
super().__init__(
|
||||
cpu_group, device, device_group, unique_name, use_all2all=use_all2all
|
||||
)
|
||||
self.dist_module = torch.distributed
|
||||
|
||||
if (
|
||||
(
|
||||
current_platform.get_cpu_architecture() == CpuArchEnum.X86
|
||||
or current_platform.get_cpu_architecture() == CpuArchEnum.ARM
|
||||
or current_platform.get_cpu_architecture() == CpuArchEnum.POWERPC
|
||||
or current_platform.get_cpu_architecture() == CpuArchEnum.S390X
|
||||
)
|
||||
and hasattr(torch.ops._C, "init_shm_manager")
|
||||
and (unique_name.startswith("tp") or unique_name.startswith("pp"))
|
||||
and self._all_group_ranks_share_shm_group_name()
|
||||
):
|
||||
self.dist_module = _CPUSHMDistributed(self)
|
||||
elif unique_name.startswith("tp") or unique_name.startswith("pp"):
|
||||
logger.info(
|
||||
"CPU SHM communicator disabled for group %s: ranks do not share "
|
||||
"the same SHM group name, falling back to torch.distributed.",
|
||||
unique_name,
|
||||
)
|
||||
|
||||
# send/recv tensor_dict is only supported through the SHM communicator backend
|
||||
self.supports_tensor_dict = isinstance(self.dist_module, _CPUSHMDistributed)
|
||||
|
||||
if self.use_all2all:
|
||||
if self.all2all_backend not in (
|
||||
"naive",
|
||||
"allgather_reducescatter",
|
||||
): # type: ignore[has-type]
|
||||
logger.warning(
|
||||
"`%s` all2all manager is not supported on CPU. "
|
||||
"Falling back to `allgather_reducescatter` manager.",
|
||||
self.all2all_backend, # type: ignore[has-type]
|
||||
)
|
||||
from .all2all import AgRsAll2AllManager
|
||||
|
||||
self.all2all_manager = AgRsAll2AllManager(self.cpu_group)
|
||||
logger.info("Using allgather_reducescatter all2all manager.")
|
||||
|
||||
def _all_group_ranks_share_shm_group_name(self) -> bool:
|
||||
"""CPUSHM requires all ranks in this group to agree on one SHM group name.
|
||||
This is a lightweight consistency check for VLLM_DIST_IDENT/name inputs.
|
||||
"""
|
||||
local_name = _CPUSHMDistributed.make_group_name(self)
|
||||
names: list[str] = [""] * self.world_size
|
||||
torch.distributed.all_gather_object(
|
||||
names,
|
||||
local_name,
|
||||
group=self.device_group,
|
||||
)
|
||||
return len(set(names)) == 1
|
||||
|
||||
def all_reduce(self, input_):
|
||||
self.dist_module.all_reduce(input_, group=self.device_group)
|
||||
return input_
|
||||
|
||||
def gather(
|
||||
self, input_: torch.Tensor, dst: int = 0, dim: int = -1
|
||||
) -> torch.Tensor | None:
|
||||
"""NOTE: We assume that the input tensor is on the same device across
|
||||
all the ranks.
|
||||
NOTE: `dst` is the local rank of the destination rank.
|
||||
"""
|
||||
world_size = self.world_size
|
||||
assert -input_.dim() <= dim < input_.dim(), (
|
||||
f"Invalid dim ({dim}) for input tensor with shape {input_.size()}"
|
||||
)
|
||||
if dim < 0:
|
||||
# Convert negative dim to positive.
|
||||
dim += input_.dim()
|
||||
|
||||
# Allocate output tensor.
|
||||
if self.rank_in_group == dst:
|
||||
gather_list = [torch.empty_like(input_) for _ in range(world_size)]
|
||||
else:
|
||||
gather_list = None
|
||||
|
||||
# Gather.
|
||||
self.dist_module.gather(
|
||||
input_, gather_list, dst=self.ranks[dst], group=self.device_group
|
||||
)
|
||||
|
||||
if self.rank_in_group == dst:
|
||||
output_tensor = torch.cat(gather_list, dim=dim)
|
||||
else:
|
||||
output_tensor = None
|
||||
return output_tensor
|
||||
|
||||
def all_gather(self, input_: torch.Tensor, dim: int = -1) -> torch.Tensor:
|
||||
if dim < 0:
|
||||
# Convert negative dim to positive.
|
||||
dim += input_.dim()
|
||||
input_size = input_.size()
|
||||
# NOTE: we have to use concat-style all-gather here,
|
||||
# stack-style all-gather has compatibility issues with
|
||||
# torch.compile . see https://github.com/pytorch/pytorch/issues/138795
|
||||
output_size = (input_size[0] * self.world_size,) + input_size[1:]
|
||||
# Allocate output tensor.
|
||||
output_tensor = torch.empty(
|
||||
output_size, dtype=input_.dtype, device=input_.device
|
||||
)
|
||||
# All-gather.
|
||||
self.dist_module.all_gather_into_tensor(
|
||||
output_tensor, input_, group=self.device_group
|
||||
)
|
||||
|
||||
# Reshape
|
||||
output_tensor = output_tensor.reshape((self.world_size,) + input_size)
|
||||
output_tensor = output_tensor.movedim(0, dim)
|
||||
output_tensor = output_tensor.reshape(
|
||||
input_size[:dim]
|
||||
+ (self.world_size * input_size[dim],)
|
||||
+ input_size[dim + 1 :]
|
||||
)
|
||||
return output_tensor
|
||||
|
||||
def send_tensor_dict(
|
||||
self,
|
||||
tensor_dict: dict[str, torch.Tensor | Any],
|
||||
dst: int,
|
||||
) -> None:
|
||||
if not self.supports_tensor_dict:
|
||||
raise NotImplementedError(
|
||||
"CpuCommunicator does not support tensor dict fastpath with "
|
||||
"torch.distributed backend."
|
||||
)
|
||||
return self.dist_module.send_tensor_dict(tensor_dict, dst)
|
||||
|
||||
def recv_tensor_dict(
|
||||
self,
|
||||
src: int,
|
||||
) -> dict[str, torch.Tensor | Any]:
|
||||
if not self.supports_tensor_dict:
|
||||
raise NotImplementedError(
|
||||
"CpuCommunicator does not support tensor dict fastpath with "
|
||||
"torch.distributed backend."
|
||||
)
|
||||
return self.dist_module.recv_tensor_dict(src)
|
||||
|
||||
def dispatch_router_logits(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
router_logits: torch.Tensor,
|
||||
is_sequence_parallel: bool = False,
|
||||
extra_tensors: list[torch.Tensor] | None = None,
|
||||
) -> (
|
||||
tuple[torch.Tensor, torch.Tensor]
|
||||
| tuple[torch.Tensor, torch.Tensor, list[torch.Tensor]]
|
||||
):
|
||||
"""Dispatch the hidden states and router logits to the appropriate device.
|
||||
This is a no-op in the base class.
|
||||
"""
|
||||
assert self.all2all_manager is not None
|
||||
return self.all2all_manager.dispatch_router_logits(
|
||||
hidden_states,
|
||||
router_logits,
|
||||
is_sequence_parallel,
|
||||
extra_tensors,
|
||||
)
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
topk_weights: torch.Tensor,
|
||||
topk_ids: torch.Tensor,
|
||||
is_sequence_parallel: bool = False,
|
||||
extra_tensors: list[torch.Tensor] | None = None,
|
||||
) -> (
|
||||
tuple[torch.Tensor, torch.Tensor, torch.Tensor]
|
||||
| tuple[torch.Tensor, torch.Tensor, torch.Tensor, list[torch.Tensor]]
|
||||
):
|
||||
"""Dispatch the hidden states and topk weights/ids to the appropriate device.
|
||||
This is a no-op in the base class.
|
||||
"""
|
||||
assert self.all2all_manager is not None
|
||||
return self.all2all_manager.dispatch(
|
||||
hidden_states,
|
||||
topk_weights,
|
||||
topk_ids,
|
||||
is_sequence_parallel,
|
||||
extra_tensors=extra_tensors,
|
||||
)
|
||||
|
||||
def combine(
|
||||
self, hidden_states: torch.Tensor, is_sequence_parallel: bool = False
|
||||
) -> torch.Tensor:
|
||||
"""Combine the hidden states and router logits from the appropriate device.
|
||||
This is a no-op in the base class.
|
||||
"""
|
||||
assert self.all2all_manager is not None
|
||||
return self.all2all_manager.combine(
|
||||
hidden_states,
|
||||
is_sequence_parallel,
|
||||
)
|
||||
|
||||
|
||||
class _CPUSHMDistributed:
|
||||
def __init__(self, communicator: CpuCommunicator):
|
||||
self.communicator = communicator
|
||||
|
||||
self.group_name = self.make_group_name(communicator)
|
||||
|
||||
self.handle = self._init_cpu_shm()
|
||||
|
||||
@staticmethod
|
||||
def make_group_name(communicator: CpuCommunicator) -> str:
|
||||
instance_identifier = os.environ["VLLM_DIST_IDENT"]
|
||||
unique_name = communicator.unique_name
|
||||
instance_identifier = f"{instance_identifier}-{unique_name}"
|
||||
group_ranks = [str(rank) for rank in communicator.ranks]
|
||||
shm_group_identifier = f"[{'-'.join(group_ranks)}]"
|
||||
return f"{instance_identifier}-{shm_group_identifier}-cpushm"
|
||||
|
||||
def _init_cpu_shm(self) -> int:
|
||||
thread_num_tensor = torch.tensor(
|
||||
[torch.get_num_threads()],
|
||||
dtype=torch.int64,
|
||||
)
|
||||
torch.distributed.all_reduce(
|
||||
thread_num_tensor,
|
||||
op=torch.distributed.ReduceOp.MIN,
|
||||
group=self.communicator.device_group,
|
||||
)
|
||||
thread_num = thread_num_tensor.item()
|
||||
|
||||
handle = torch.ops._C.init_shm_manager(
|
||||
self.group_name,
|
||||
self.communicator.world_size,
|
||||
self.communicator.rank,
|
||||
thread_num,
|
||||
)
|
||||
torch.distributed.barrier(self.communicator.device_group)
|
||||
torch.ops._C.join_shm_manager(
|
||||
handle,
|
||||
self.group_name,
|
||||
)
|
||||
torch.distributed.barrier(self.communicator.device_group)
|
||||
|
||||
return handle
|
||||
|
||||
def all_reduce(
|
||||
self, input: torch.Tensor, group: ProcessGroup | None = None
|
||||
) -> None:
|
||||
torch.ops._C.shm_allreduce(self.handle, input)
|
||||
|
||||
def gather(
|
||||
self,
|
||||
input: torch.Tensor,
|
||||
gather_list: list[torch.Tensor] | None,
|
||||
dst: int = -1,
|
||||
group: ProcessGroup | None = None,
|
||||
) -> None:
|
||||
# Note: different from the torch gather, here we use local dst rank.
|
||||
torch.ops._C.shm_gather(
|
||||
self.handle,
|
||||
input,
|
||||
gather_list,
|
||||
torch.distributed.get_group_rank(group, dst),
|
||||
)
|
||||
|
||||
def all_gather_into_tensor(
|
||||
self,
|
||||
output: torch.Tensor,
|
||||
input: torch.Tensor,
|
||||
group: ProcessGroup | None = None,
|
||||
) -> None:
|
||||
torch.ops._C.shm_all_gather(self.handle, input, output)
|
||||
|
||||
def send_tensor_dict(
|
||||
self,
|
||||
tensor_dict: dict[str, torch.Tensor | Any],
|
||||
dst: int,
|
||||
) -> None:
|
||||
key_list = list(tensor_dict.keys())
|
||||
value_list = list(tensor_dict.values())
|
||||
size_list = []
|
||||
for v in value_list:
|
||||
if not isinstance(v, torch.Tensor):
|
||||
raise RuntimeError("CpuCommunicator only supports sending tensors.")
|
||||
size_list.append(v.size())
|
||||
key_size_tensor = torch.frombuffer(
|
||||
pickle.dumps([key_list, size_list]), dtype=torch.uint8
|
||||
)
|
||||
value_list.append(key_size_tensor)
|
||||
|
||||
torch.ops._C.shm_send_tensor_list(self.handle, value_list, dst)
|
||||
|
||||
return None
|
||||
|
||||
def recv_tensor_dict(
|
||||
self,
|
||||
src: int,
|
||||
) -> dict[str, torch.Tensor | Any]:
|
||||
tensor_list = torch.ops._C.shm_recv_tensor_list(self.handle, src)
|
||||
|
||||
value_list: list[torch.Tensor] = tensor_list[:-1]
|
||||
key_size_tensor = tensor_list[-1]
|
||||
|
||||
key_size = pickle.loads(key_size_tensor.numpy().tobytes())
|
||||
key_list = key_size[0]
|
||||
size_list = key_size[1]
|
||||
assert len(key_list) == len(size_list)
|
||||
assert len(key_list) == len(value_list)
|
||||
|
||||
tensor_dict: dict[str, torch.Tensor] = {}
|
||||
for key, size, t in zip(key_list, size_list, value_list):
|
||||
tensor_dict[key] = t.view(size)
|
||||
return tensor_dict
|
||||
@@ -1,98 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Shared types for the ECCPUConnector scheduler and worker delegates."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import (
|
||||
ECConnectorMetadata,
|
||||
ECConnectorWorkerMetadata,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.ec_shared_region import (
|
||||
ECSharedRegion,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.config import VllmConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class ECCPUConnectorMetadata(ECConnectorMetadata):
|
||||
"""Per-step scheduler → worker payload for the ECCPUConnector.
|
||||
|
||||
Populated by `ECCPUScheduler.build_connector_meta`; consumed by
|
||||
`ECCPUWorker` via the mixin's `bind_connector_metadata`.
|
||||
"""
|
||||
|
||||
# Producer role: mm_hashes the scheduler has just allocated CPU
|
||||
# blocks for this step; the worker's save_caches copies
|
||||
# encoder_cache[mm_hash] → mmap at these block IDs.
|
||||
saves: dict[str, list[int]] = field(default_factory=dict)
|
||||
|
||||
# Consumer role: mm_hashes whose bytes are available in the local mmap,
|
||||
# mapped to (transfer_id, block_ids, shape); the worker's start_load_caches
|
||||
# copies mmap[block_ids] → GPU encoder_cache and reports the transfer_id
|
||||
# once its copy lands. The id is minted per dispatch, so two loads of the
|
||||
# same mm_hash in different steps are distinct and a late report from the
|
||||
# earlier one cannot release the later one's pin.
|
||||
loads: dict[str, tuple[int, list[int], tuple[int, int]]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ECCPUWorkerMetadata(ECConnectorWorkerMetadata):
|
||||
"""Per-step worker → scheduler payload for the ECCPUConnector.
|
||||
|
||||
Reports the GPU copies that completed this step: saved mm_hashes become
|
||||
safe to mark ready, and loaded transfers become safe to unpin once every
|
||||
participating rank has reported them. Built by
|
||||
`ECCPUWorker.build_connector_worker_meta`; consumed by
|
||||
`ECCPUScheduler.update_connector_output`.
|
||||
|
||||
Loads are reported by transfer id rather than mm_hash because every rank
|
||||
copies the same blocks: `aggregate` concatenates, so an id appears once
|
||||
per reporting rank and the scheduler can count participants off the list.
|
||||
"""
|
||||
|
||||
completed_saves: list[str] = field(default_factory=list)
|
||||
completed_loads: list[int] = field(default_factory=list)
|
||||
|
||||
def aggregate(self, other: ECConnectorWorkerMetadata) -> ECConnectorWorkerMetadata:
|
||||
assert isinstance(other, ECCPUWorkerMetadata)
|
||||
self.completed_saves.extend(other.completed_saves)
|
||||
self.completed_loads.extend(other.completed_loads)
|
||||
return self
|
||||
|
||||
|
||||
def create_ec_shared_region(vllm_config: "VllmConfig") -> ECSharedRegion:
|
||||
"""Build the EC mmap region from `vllm_config`.
|
||||
|
||||
Both `ECCPUScheduler` and `ECCPUWorker` call this to get the same
|
||||
shared region (same engine_id, same block_size_bytes).
|
||||
"""
|
||||
ec_config = vllm_config.ec_transfer_config
|
||||
assert ec_config is not None, "ec_transfer_config required to build region"
|
||||
|
||||
dp_index = vllm_config.parallel_config.data_parallel_index
|
||||
engine_id = f"{vllm_config.instance_id}_dp{dp_index}"
|
||||
|
||||
dtype = vllm_config.model_config.dtype
|
||||
hidden_dim = vllm_config.model_config.get_inputs_embeds_size()
|
||||
element_size = torch.empty(0, dtype=dtype).element_size()
|
||||
block_size_bytes = hidden_dim * element_size
|
||||
|
||||
cpu_bytes = ec_config.ec_connector_extra_config.get("ec_cpu_bytes")
|
||||
if not cpu_bytes:
|
||||
raise ValueError("ec_cpu_bytes must be specified in ec_connector_extra_config")
|
||||
cpu_bytes = int(cpu_bytes)
|
||||
num_blocks = cpu_bytes // block_size_bytes
|
||||
|
||||
return ECSharedRegion(
|
||||
engine_id=engine_id,
|
||||
num_blocks=num_blocks,
|
||||
block_size_bytes=block_size_bytes,
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""ECCPUConnector — CPU encoder-cache offloading.
|
||||
|
||||
A role-routed shell: one instance per process. The scheduler delegate owns the
|
||||
mmap region and offload bookkeeping; the worker delegate owns the mmap view and
|
||||
GPU copy plumbing. An ec_both instance reuses encoder outputs it has already
|
||||
offloaded to CPU instead of recomputing them.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import (
|
||||
ECConnectorBase,
|
||||
ECConnectorRole,
|
||||
ECConnectorWorkerMetadata,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
|
||||
ECCPUConnectorMetadata,
|
||||
)
|
||||
from vllm.logger import init_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.outputs import ECConnectorOutput
|
||||
from vllm.v1.request import Request
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class ECCPUConnector(ECConnectorBase):
|
||||
"""EC connector that offloads encoder cache to a shared CPU mmap region."""
|
||||
|
||||
def __init__(self, vllm_config: "VllmConfig", role: ECConnectorRole) -> None:
|
||||
super().__init__(vllm_config=vllm_config, role=role)
|
||||
|
||||
if not vllm_config.use_v2_model_runner:
|
||||
raise ValueError(
|
||||
"ECCPUConnector requires the V2 model runner. "
|
||||
"Set VLLM_USE_V2_MODEL_RUNNER=1."
|
||||
)
|
||||
|
||||
self.connector_worker = None
|
||||
self.connector_scheduler = None
|
||||
|
||||
if role == ECConnectorRole.WORKER:
|
||||
self.connector_worker = self._make_worker(vllm_config)
|
||||
elif role == ECConnectorRole.SCHEDULER:
|
||||
self.connector_scheduler = self._make_scheduler(vllm_config)
|
||||
else:
|
||||
raise ValueError(f"Unknown ECConnectorRole: {role}")
|
||||
|
||||
# Construction seams.
|
||||
def _make_worker(self, vllm_config: "VllmConfig"):
|
||||
# Deferred import: the worker module touches torch/CUDA at import time
|
||||
# via the region, so keep that cost off the scheduler path.
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.worker import (
|
||||
ECCPUWorker,
|
||||
)
|
||||
|
||||
return ECCPUWorker(vllm_config)
|
||||
|
||||
def _make_scheduler(self, vllm_config: "VllmConfig"):
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler import (
|
||||
ECCPUScheduler,
|
||||
)
|
||||
|
||||
return ECCPUScheduler(vllm_config)
|
||||
|
||||
# Worker-side forwarders.
|
||||
def start_load_caches(
|
||||
self, encoder_cache: dict[str, torch.Tensor], **kwargs
|
||||
) -> None:
|
||||
assert self.connector_worker is not None
|
||||
metadata = self._get_connector_metadata()
|
||||
assert isinstance(metadata, ECCPUConnectorMetadata)
|
||||
self.connector_worker.start_load_caches(
|
||||
encoder_cache, connector_metadata=metadata
|
||||
)
|
||||
|
||||
def save_caches(
|
||||
self, encoder_cache: dict[str, torch.Tensor], mm_hash: str, **kwargs
|
||||
) -> None:
|
||||
assert self.connector_worker is not None
|
||||
metadata = self._get_connector_metadata()
|
||||
assert isinstance(metadata, ECCPUConnectorMetadata)
|
||||
self.connector_worker.save_caches(
|
||||
encoder_cache, mm_hash, connector_metadata=metadata
|
||||
)
|
||||
|
||||
# Scheduler-side forwarders.
|
||||
def has_cache_item(self, identifier: str) -> bool:
|
||||
assert self.connector_scheduler is not None
|
||||
return self.connector_scheduler.has_cache_item(identifier)
|
||||
|
||||
def ensure_cache_available(
|
||||
self, request: "Request", num_computed_tokens: int
|
||||
) -> bool:
|
||||
assert self.connector_scheduler is not None
|
||||
return self.connector_scheduler.ensure_cache_available(
|
||||
request, num_computed_tokens
|
||||
)
|
||||
|
||||
def take_unavailable_requests(self) -> set[str]:
|
||||
assert self.connector_scheduler is not None
|
||||
return self.connector_scheduler.take_unavailable_requests()
|
||||
|
||||
def update_state_after_alloc(self, request: "Request", index: int) -> None:
|
||||
assert self.connector_scheduler is not None
|
||||
self.connector_scheduler.update_state_after_alloc(request, index)
|
||||
|
||||
def build_connector_meta(
|
||||
self, scheduler_output: "SchedulerOutput"
|
||||
) -> ECCPUConnectorMetadata:
|
||||
assert self.connector_scheduler is not None
|
||||
return self.connector_scheduler.build_connector_meta(scheduler_output)
|
||||
|
||||
def request_finished(
|
||||
self, request: "Request"
|
||||
) -> tuple[bool, "dict[str, Any] | None"]:
|
||||
assert self.connector_scheduler is not None
|
||||
return self.connector_scheduler.request_finished(request)
|
||||
|
||||
def get_finished(
|
||||
self, finished_req_ids: set[str]
|
||||
) -> tuple[set[str] | None, set[str] | None]:
|
||||
if self.connector_worker is not None:
|
||||
self.connector_worker.flush_saves()
|
||||
return None, None
|
||||
|
||||
def build_connector_worker_meta(self) -> ECConnectorWorkerMetadata | None:
|
||||
if self.connector_worker is not None:
|
||||
return self.connector_worker.build_connector_worker_meta()
|
||||
return None
|
||||
|
||||
def update_connector_output(self, connector_output: "ECConnectorOutput") -> None:
|
||||
if self.connector_scheduler is not None:
|
||||
self.connector_scheduler.update_connector_output(connector_output)
|
||||
|
||||
def has_pending_push_work(self) -> bool:
|
||||
if self.connector_scheduler is not None:
|
||||
return self.connector_scheduler.has_pending_push_work()
|
||||
return False
|
||||
|
||||
# Shared.
|
||||
def shutdown(self) -> None:
|
||||
if self.connector_scheduler is not None:
|
||||
self.connector_scheduler.shutdown()
|
||||
if self.connector_worker is not None:
|
||||
self.connector_worker.shutdown()
|
||||
@@ -1,37 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Abstract base classes for the EC control plane.
|
||||
|
||||
ControlConnection wraps a single peer channel (one DEALER socket).
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class ControlConnection(ABC):
|
||||
"""Bidirectional message channel to a single remote peer.
|
||||
|
||||
All I/O is non-blocking: send enqueues without blocking; recv drains
|
||||
all buffered inbound messages since the last call.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def alive(self) -> bool:
|
||||
"""True if the connection is still usable."""
|
||||
|
||||
@abstractmethod
|
||||
def send(self, msg: bytes) -> None:
|
||||
"""Enqueue msg for delivery. Must not block."""
|
||||
|
||||
@abstractmethod
|
||||
def recv(self) -> list[bytes]:
|
||||
"""Drain and return all buffered inbound messages."""
|
||||
|
||||
@abstractmethod
|
||||
def mark_dead(self) -> None:
|
||||
"""Signal that the peer disconnected. Sets alive to False."""
|
||||
|
||||
@abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Release all resources. Idempotent."""
|
||||
@@ -1,233 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""ZMQ control-plane implementations for ECCPUConnector.
|
||||
|
||||
ZmqClientConnection — one DEALER socket to a single producer peer.
|
||||
ZmqClientTransport — pool of ZmqClientConnection objects (consumer side).
|
||||
ZmqServerTransport — ROUTER socket (producer side).
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import zmq
|
||||
from zmq.utils.monitor import recv_monitor_message
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.control.base import (
|
||||
ControlConnection,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.utils import PeerAddr
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.network_utils import make_zmq_path, make_zmq_socket
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_HEARTBEAT_IVL_MS = 2000
|
||||
_HEARTBEAT_TIMEOUT_MS = 4000
|
||||
_HEARTBEAT_TTL_MS = 8000
|
||||
|
||||
|
||||
# ── Client side ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZmqClientConnection(ControlConnection):
|
||||
"""One DEALER socket to one producer peer.
|
||||
|
||||
Owns the socket and its optional monitor socket. NIXL state is NOT
|
||||
stored here — it belongs in ECSession above this layer.
|
||||
"""
|
||||
|
||||
dealer: zmq.Socket
|
||||
monitor: zmq.Socket | None
|
||||
_alive: bool = field(default=True, init=False)
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
return self._alive
|
||||
|
||||
def send(self, msg: bytes) -> None:
|
||||
self.dealer.send_multipart([b"", msg])
|
||||
|
||||
def recv(self) -> list[bytes]:
|
||||
msgs: list[bytes] = []
|
||||
while True:
|
||||
try:
|
||||
frames = self.dealer.recv_multipart(flags=zmq.NOBLOCK)
|
||||
except zmq.Again:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("ec: DEALER recv failed")
|
||||
break
|
||||
if len(frames) != 2 or frames[0] != b"":
|
||||
logger.warning(
|
||||
"ec: dropped malformed frame envelope (%d frames)", len(frames)
|
||||
)
|
||||
continue
|
||||
msgs.append(frames[1])
|
||||
return msgs
|
||||
|
||||
def mark_dead(self) -> None:
|
||||
self._alive = False
|
||||
|
||||
def close(self) -> None:
|
||||
if self.monitor is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
self.dealer.disable_monitor()
|
||||
with contextlib.suppress(Exception):
|
||||
self.monitor.close(linger=0)
|
||||
with contextlib.suppress(Exception):
|
||||
self.dealer.close(linger=0)
|
||||
self._alive = False
|
||||
|
||||
|
||||
class ZmqClientTransport:
|
||||
"""Pool of ZmqClientConnection objects — one per producer peer.
|
||||
|
||||
Creates DEALER sockets lazily on first contact. Does not touch NIXL;
|
||||
NIXL registration is handled by ECCPUConsumer once an XferAck arrives.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._ctx = zmq.Context()
|
||||
self._connections: dict[PeerAddr, ZmqClientConnection] = {}
|
||||
|
||||
def connect(self, addr: PeerAddr) -> ZmqClientConnection:
|
||||
"""Return the existing connection for addr, or create a new one."""
|
||||
existing = self._connections.get(addr)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
host, port = addr
|
||||
dealer = make_zmq_socket(
|
||||
ctx=self._ctx,
|
||||
path=make_zmq_path(scheme="tcp", host=host, port=port),
|
||||
socket_type=zmq.DEALER,
|
||||
bind=False,
|
||||
)
|
||||
dealer.setsockopt(zmq.HEARTBEAT_IVL, _HEARTBEAT_IVL_MS)
|
||||
dealer.setsockopt(zmq.HEARTBEAT_TIMEOUT, _HEARTBEAT_TIMEOUT_MS)
|
||||
dealer.setsockopt(zmq.HEARTBEAT_TTL, _HEARTBEAT_TTL_MS)
|
||||
|
||||
monitor_addr = f"inproc://ec-peer-mon-{host}-{port}"
|
||||
dealer.monitor(monitor_addr, zmq.EVENT_DISCONNECTED)
|
||||
monitor = self._ctx.socket(zmq.PAIR)
|
||||
monitor.connect(monitor_addr)
|
||||
|
||||
conn = ZmqClientConnection(dealer=dealer, monitor=monitor)
|
||||
self._connections[addr] = conn
|
||||
return conn
|
||||
|
||||
def poll(self) -> dict[PeerAddr, list[bytes]]:
|
||||
"""Non-blocking drain of messages from every peer's DEALER."""
|
||||
msgs: dict[PeerAddr, list[bytes]] = {}
|
||||
for addr, conn in list(self._connections.items()):
|
||||
conn_msgs = conn.recv()
|
||||
msgs[addr] = conn_msgs
|
||||
return msgs
|
||||
|
||||
def poll_dead(self) -> list[PeerAddr]:
|
||||
"""Non-blocking sweep of peer monitors; return addresses that died."""
|
||||
dead: list[PeerAddr] = []
|
||||
for addr, conn in list(self._connections.items()):
|
||||
if conn.monitor is None:
|
||||
continue
|
||||
try:
|
||||
while True:
|
||||
evt = recv_monitor_message(conn.monitor, flags=zmq.NOBLOCK)
|
||||
if evt["event"] == zmq.EVENT_DISCONNECTED:
|
||||
conn.mark_dead()
|
||||
dead.append(addr)
|
||||
break
|
||||
except zmq.Again:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning("ec: monitor poll failed for %s", addr, exc_info=True)
|
||||
return dead
|
||||
|
||||
def remove(self, addr: PeerAddr) -> ZmqClientConnection | None:
|
||||
"""Remove and close the connection for addr, returning it (or None)."""
|
||||
conn = self._connections.pop(addr, None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
return conn
|
||||
|
||||
def close(self) -> None:
|
||||
for conn in list(self._connections.values()):
|
||||
conn.close()
|
||||
self._connections.clear()
|
||||
try:
|
||||
self._ctx.destroy(linger=0)
|
||||
except Exception:
|
||||
logger.debug("ec: zmq context destroy failed", exc_info=True)
|
||||
|
||||
|
||||
# ── Server side ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ZmqServerTransport:
|
||||
"""ROUTER socket for the producer side. Byte-level only, driven by
|
||||
ProducerSession.
|
||||
|
||||
poll(timeout_ms) waits up to timeout_ms for messages, then drains all that
|
||||
are available; timeout_ms=0 drains whatever has already arrived. send()
|
||||
routes a reply back to a specific peer by ZMQ identity.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, port: int) -> None:
|
||||
self._ctx = zmq.Context()
|
||||
self._router = make_zmq_socket(
|
||||
ctx=self._ctx,
|
||||
path=make_zmq_path(scheme="tcp", host=host, port=port),
|
||||
socket_type=zmq.ROUTER,
|
||||
bind=True,
|
||||
)
|
||||
self._poller = zmq.Poller()
|
||||
self._poller.register(self._router, zmq.POLLIN)
|
||||
|
||||
def poll(self, timeout_ms: int = 0) -> list[tuple[bytes, bytes]]:
|
||||
"""Wait up to timeout_ms for incoming messages, then drain all pending.
|
||||
|
||||
Returns a list of (identity, payload) pairs.
|
||||
"""
|
||||
if timeout_ms > 0:
|
||||
try:
|
||||
events = dict(self._poller.poll(timeout=timeout_ms))
|
||||
except zmq.ContextTerminated:
|
||||
return []
|
||||
except Exception:
|
||||
logger.exception("ec: router poller failed")
|
||||
return []
|
||||
if self._router not in events:
|
||||
return []
|
||||
|
||||
msgs: list[tuple[bytes, bytes]] = []
|
||||
while True:
|
||||
try:
|
||||
identity, _, payload = self._router.recv_multipart(flags=zmq.NOBLOCK)
|
||||
msgs.append((identity, payload))
|
||||
except zmq.Again:
|
||||
break
|
||||
except zmq.ContextTerminated:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("ec: router recv failed")
|
||||
break
|
||||
return msgs
|
||||
|
||||
def send(self, identity: bytes, payload: bytes) -> None:
|
||||
"""Send a reply payload to the peer identified by ZMQ identity."""
|
||||
try:
|
||||
self._router.send_multipart([identity, b"", payload])
|
||||
except Exception:
|
||||
logger.exception("ec: router send failed")
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._router.close(linger=0)
|
||||
except Exception:
|
||||
logger.debug("ec: router close failed", exc_info=True)
|
||||
try:
|
||||
self._ctx.destroy(linger=0)
|
||||
except Exception:
|
||||
logger.debug("ec: zmq context destroy failed", exc_info=True)
|
||||
@@ -1,67 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Abstract base class for the EC data-plane transport (NIXL/UCX)."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class DataTransport(ABC):
|
||||
"""Interface wrapping the NIXL data-plane for EC block transfers.
|
||||
|
||||
Peer lifecycle:
|
||||
add_remote_peer() — register a peer and store its dlist handle internally
|
||||
remove_remote_peer() — deregister and release the stored handle
|
||||
post_read() — issue a READ using the stored handle for agent_name
|
||||
|
||||
The dlist handle (prepped xfer descriptor list) is an implementation detail
|
||||
of the transport and is never exposed to callers.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_agent_metadata(self) -> bytes:
|
||||
"""Return this agent's serialized NIXL metadata blob."""
|
||||
|
||||
@abstractmethod
|
||||
def get_mem_descriptor(self) -> bytes:
|
||||
"""Return the msgpack-encoded block descriptor list for this agent."""
|
||||
|
||||
@abstractmethod
|
||||
def add_remote_peer(self, metadata: bytes, mem_descriptor: bytes) -> str:
|
||||
"""Register a remote peer, prep its READ-source dlist, and return agent_name.
|
||||
|
||||
The dlist handle is stored internally and used by post_read().
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def remove_remote_peer(self, agent_name: str) -> None:
|
||||
"""Deregister a previously registered remote peer and release its handle."""
|
||||
|
||||
@abstractmethod
|
||||
def post_read(
|
||||
self,
|
||||
local_indices: list[int],
|
||||
agent_name: str,
|
||||
remote_indices: list[int],
|
||||
notif_msg: bytes,
|
||||
) -> Any:
|
||||
"""Issue a consumer-initiated READ using the stored dlist for agent_name.
|
||||
|
||||
Returns a transfer handle for use with check_xfer_state / release_xfer_handle.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_new_notifs(self) -> dict[str, list[bytes]]:
|
||||
"""Drain NIXL completion notifications addressed to this agent."""
|
||||
|
||||
@abstractmethod
|
||||
def check_xfer_state(self, handle: Any) -> str:
|
||||
"""Return the NIXL state string: 'DONE', 'PROC', or an error string."""
|
||||
|
||||
@abstractmethod
|
||||
def release_xfer_handle(self, handle: Any) -> None:
|
||||
"""Release a transfer handle returned by post_read."""
|
||||
|
||||
@abstractmethod
|
||||
def deregister(self) -> None:
|
||||
"""Deregister all memory and tear down the NIXL agent."""
|
||||
@@ -1,162 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""NIXL-backed data-plane transport for ECCPUConnector."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.data.base import (
|
||||
DataTransport,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.utils import (
|
||||
build_block_descs,
|
||||
deserialize_mem_descriptor,
|
||||
serialize_mem_descriptor,
|
||||
)
|
||||
from vllm.distributed.nixl_utils import NixlWrapper, nixl_agent_config
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
_NIXL_DRAM = "DRAM"
|
||||
|
||||
|
||||
class NixlDataTransport(DataTransport):
|
||||
"""Adapts NixlWrapper to the DataTransport interface.
|
||||
|
||||
Dlist handles (prepped xfer descriptor lists for remote peers) are stored
|
||||
internally keyed by agent_name. Callers never touch raw NIXL handles —
|
||||
post_read() resolves the handle from agent_name internally.
|
||||
|
||||
NixlWrapper is not thread-safe, and neither is this class. Every call site
|
||||
runs on the scheduler thread, which serializes the transport by
|
||||
construction; transfers are async inside NIXL and are drained from that
|
||||
same thread via get_new_notifs() / check_xfer_state().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: str,
|
||||
base_ptr: int,
|
||||
num_blocks: int,
|
||||
block_size_bytes: int,
|
||||
total_size_bytes: int,
|
||||
num_threads: int = 1,
|
||||
) -> None:
|
||||
if NixlWrapper is None or nixl_agent_config is None:
|
||||
raise RuntimeError(
|
||||
"ECCPUConnector requires NIXL; "
|
||||
"install the `nixl` package or set a different ec_connector."
|
||||
)
|
||||
# agent_name → remote dlist handle (from add_remote_peer, used by post_read)
|
||||
self._peer_handles: dict[str, int] = {}
|
||||
# None until the local region is registered, so deregister() is safe on
|
||||
# a transport whose registration failed part-way.
|
||||
self._reg_descs: Any = None
|
||||
|
||||
self._nixl = NixlWrapper(
|
||||
agent_name,
|
||||
nixl_agent_config(num_threads=num_threads, capture_telemetry=True),
|
||||
)
|
||||
self._register_local_memory(
|
||||
base_ptr, num_blocks, block_size_bytes, total_size_bytes
|
||||
)
|
||||
|
||||
def _register_local_memory(
|
||||
self,
|
||||
base_ptr: int,
|
||||
num_blocks: int,
|
||||
block_size_bytes: int,
|
||||
total_size_bytes: int,
|
||||
) -> None:
|
||||
"""Register the local region with NIXL and prep its transfer dlist.
|
||||
|
||||
Unwinds the registration if a later step fails: __init__ propagates the
|
||||
error, so the caller never receives an object it could deregister with.
|
||||
"""
|
||||
block_descs = build_block_descs(base_ptr, num_blocks, block_size_bytes)
|
||||
self._reg_descs = self._nixl.get_reg_descs(
|
||||
[(base_ptr, total_size_bytes, 0, "")], _NIXL_DRAM
|
||||
)
|
||||
self._nixl.register_memory(self._reg_descs, backends=["UCX"])
|
||||
try:
|
||||
xfer_descs = self._nixl.get_xfer_descs(block_descs, _NIXL_DRAM)
|
||||
self._local_xfer_handle = self._nixl.prep_xfer_dlist(
|
||||
"NIXL_INIT_AGENT", xfer_descs
|
||||
)
|
||||
self._agent_metadata = self._nixl.get_agent_metadata()
|
||||
self._mem_descriptor_bytes = serialize_mem_descriptor(block_descs)
|
||||
except Exception:
|
||||
self.deregister()
|
||||
raise
|
||||
|
||||
def get_agent_metadata(self) -> bytes:
|
||||
return self._agent_metadata
|
||||
|
||||
def get_mem_descriptor(self) -> bytes:
|
||||
return self._mem_descriptor_bytes
|
||||
|
||||
def add_remote_peer(self, metadata: bytes, mem_descriptor: bytes) -> str:
|
||||
"""Register remote peer, prep its dlist, store handle internally.
|
||||
|
||||
Returns agent_name for use in post_read() and remove_remote_peer().
|
||||
"""
|
||||
agent_name = self._nixl.add_remote_agent(metadata)
|
||||
remote_blocks = deserialize_mem_descriptor(mem_descriptor)
|
||||
remote_xfer_descs = self._nixl.get_xfer_descs(remote_blocks, _NIXL_DRAM)
|
||||
self._peer_handles[agent_name] = self._nixl.prep_xfer_dlist(
|
||||
agent_name, remote_xfer_descs
|
||||
)
|
||||
return agent_name
|
||||
|
||||
def remove_remote_peer(self, agent_name: str) -> None:
|
||||
self._peer_handles.pop(agent_name, None)
|
||||
try:
|
||||
self._nixl.remove_remote_agent(agent_name)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"EC: remove_remote_peer failed for %s", agent_name, exc_info=True
|
||||
)
|
||||
|
||||
def post_read(
|
||||
self,
|
||||
local_indices: list[int],
|
||||
agent_name: str,
|
||||
remote_indices: list[int],
|
||||
notif_msg: bytes,
|
||||
) -> Any:
|
||||
if len(local_indices) != len(remote_indices):
|
||||
raise ValueError(
|
||||
f"EC: local/remote block count mismatch "
|
||||
f"({len(local_indices)} vs {len(remote_indices)})"
|
||||
)
|
||||
remote_handle = self._peer_handles[agent_name]
|
||||
handle = self._nixl.make_prepped_xfer(
|
||||
"READ",
|
||||
self._local_xfer_handle,
|
||||
local_indices,
|
||||
remote_handle,
|
||||
remote_indices,
|
||||
notif_msg=notif_msg,
|
||||
)
|
||||
self._nixl.transfer(handle)
|
||||
return handle
|
||||
|
||||
def get_new_notifs(self) -> dict[str, list[bytes]]:
|
||||
return self._nixl.get_new_notifs()
|
||||
|
||||
def check_xfer_state(self, handle: Any) -> str:
|
||||
return self._nixl.check_xfer_state(handle)
|
||||
|
||||
def release_xfer_handle(self, handle: Any) -> None:
|
||||
try:
|
||||
self._nixl.release_xfer_handle(handle)
|
||||
except Exception:
|
||||
logger.warning("EC: release_xfer_handle failed", exc_info=True)
|
||||
|
||||
def deregister(self) -> None:
|
||||
if self._reg_descs is None:
|
||||
return
|
||||
try:
|
||||
self._nixl.deregister_memory(self._reg_descs)
|
||||
except Exception:
|
||||
logger.warning("EC: deregister failed", exc_info=True)
|
||||
self._reg_descs = None
|
||||
@@ -1,193 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Lightweight mmap-backed shared memory region for encoder cache (EC) data.
|
||||
|
||||
Modeled after SharedOffloadRegion (vllm/v1/kv_offload/cpu/) but simplified
|
||||
for EC: flat shared layout, no multi-tensor cursor, no block_size_factor.
|
||||
"""
|
||||
|
||||
import errno
|
||||
import mmap
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# MADV_POPULATE_WRITE was added in Linux 5.14 (value 23).
|
||||
_MADV_POPULATE_WRITE = getattr(mmap, "MADV_POPULATE_WRITE", 23)
|
||||
|
||||
|
||||
def _madvise_populate_write(mmap_obj: mmap.mmap, offset: int, length: int) -> None:
|
||||
mmap_obj.madvise(_MADV_POPULATE_WRITE, offset, length)
|
||||
|
||||
|
||||
def _fallback_populate_write(mmap_obj: mmap.mmap, offset: int, length: int) -> None:
|
||||
# Touch one byte per page via a read-modify-write so existing bytes are
|
||||
# preserved — a peer worker may have already written EC data into this
|
||||
# shared mmap by the time we run on a kernel without MADV_POPULATE_WRITE.
|
||||
arr = np.frombuffer(mmap_obj, dtype=np.uint8)
|
||||
arr[offset : offset + length : mmap.PAGESIZE] |= 0
|
||||
|
||||
|
||||
def _get_populate_write_fn(
|
||||
mmap_obj: mmap.mmap,
|
||||
) -> Callable[[mmap.mmap, int, int], None]:
|
||||
"""Select the pre-faulting method once for this mmap."""
|
||||
try:
|
||||
_madvise_populate_write(mmap_obj, 0, mmap.PAGESIZE)
|
||||
except OSError as e:
|
||||
if e.errno != errno.EINVAL:
|
||||
raise
|
||||
logger.warning(
|
||||
"MADV_POPULATE_WRITE is not supported; falling back to per-page "
|
||||
"writes for EC mmap pre-population. Startup may be slower."
|
||||
)
|
||||
return _fallback_populate_write
|
||||
return _madvise_populate_write
|
||||
|
||||
|
||||
def _wait_for_file_size(fd: int, expected_size: int, timeout: float = 30.0):
|
||||
"""Spin-wait until the file reaches expected_size (creator truncated it)."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
if os.fstat(fd).st_size >= expected_size:
|
||||
return
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for EC mmap file to reach {expected_size} bytes"
|
||||
)
|
||||
time.sleep(0.005)
|
||||
|
||||
|
||||
class ECSharedRegion:
|
||||
"""Flat mmap-backed memory region shared across TP workers for
|
||||
encoder cache blocks.
|
||||
|
||||
Layout: (num_blocks, block_size_bytes) — contiguous, no per-worker
|
||||
interleaving. All workers map the same file and see identical data.
|
||||
|
||||
File path: /dev/shm/vllm_ec_{engine_id}.mmap
|
||||
|
||||
This class owns only the shared memory substrate (mmap lifecycle, the
|
||||
`blocks` view, CUDA host registration). Block allocation and eviction
|
||||
are tracked by `EmbeddingCache` in the scheduler process.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
engine_id: str,
|
||||
num_blocks: int,
|
||||
block_size_bytes: int,
|
||||
) -> None:
|
||||
self.num_blocks = num_blocks
|
||||
self.block_size_bytes = block_size_bytes
|
||||
|
||||
total_size_bytes = num_blocks * block_size_bytes
|
||||
# Path in /dev/shm (tmpfs); unique per engine instance.
|
||||
self._mmap_path = f"/dev/shm/vllm_ec_{engine_id}.mmap"
|
||||
# True for the process that created the file (responsible for unlink).
|
||||
self._is_creator = False
|
||||
# True after successful cudaHostRegister (cleanup must unregister).
|
||||
self._is_pinned = False
|
||||
|
||||
# File descriptor for the shared memory backing file.
|
||||
try:
|
||||
self._fd: int | None = os.open(
|
||||
self._mmap_path, os.O_CREAT | os.O_EXCL | os.O_RDWR, 0o600
|
||||
)
|
||||
os.ftruncate(self._fd, total_size_bytes)
|
||||
self._is_creator = True
|
||||
logger.info(
|
||||
"Created EC mmap file %s (%.2f MiB)",
|
||||
self._mmap_path,
|
||||
total_size_bytes / (1 << 20),
|
||||
)
|
||||
except FileExistsError:
|
||||
self._fd = os.open(self._mmap_path, os.O_RDWR)
|
||||
try:
|
||||
_wait_for_file_size(self._fd, total_size_bytes)
|
||||
except Exception:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
raise
|
||||
logger.info("Opened existing EC mmap file %s", self._mmap_path)
|
||||
|
||||
# MAP_SHARED mmap over _fd; all processes see the same pages.
|
||||
self._mmap_obj: mmap.mmap | None = mmap.mmap(
|
||||
self._fd,
|
||||
total_size_bytes,
|
||||
flags=mmap.MAP_SHARED,
|
||||
prot=mmap.PROT_READ | mmap.PROT_WRITE,
|
||||
)
|
||||
|
||||
if self._is_creator:
|
||||
populate_write_fn = _get_populate_write_fn(self._mmap_obj)
|
||||
populate_write_fn(self._mmap_obj, 0, total_size_bytes)
|
||||
|
||||
# (num_blocks, block_size_bytes) int8 tensor over the mmap buffer.
|
||||
self.blocks: torch.Tensor = torch.frombuffer(
|
||||
memoryview(self._mmap_obj), dtype=torch.int8
|
||||
).view(num_blocks, block_size_bytes)
|
||||
# Cached for cudaHostRegister/Unregister and pointer math.
|
||||
self._blocks_ptr: int = self.blocks.data_ptr()
|
||||
self._blocks_nbytes: int = self.blocks.nbytes
|
||||
|
||||
def pin_memory(self) -> None:
|
||||
"""Register the entire mmap as CUDA pinned memory for fast DMA.
|
||||
|
||||
Each TP worker process owns its own virtual address for the same
|
||||
shared physical pages, so every process must register independently.
|
||||
No-op when CUDA is not available or already pinned.
|
||||
"""
|
||||
if self._is_pinned or not torch.cuda.is_available():
|
||||
return
|
||||
result = torch.cuda.cudart().cudaHostRegister(
|
||||
self._blocks_ptr, self._blocks_nbytes, 0
|
||||
)
|
||||
if result.value != 0:
|
||||
logger.warning(
|
||||
"cudaHostRegister failed (code=%d) — "
|
||||
"transfers will still work but may be slower (unpinned DMA)",
|
||||
result.value,
|
||||
)
|
||||
else:
|
||||
logger.debug("cudaHostRegister %.2f MB", self._blocks_nbytes / 1e6)
|
||||
self._is_pinned = True
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Tear down the region. Lifecycle method; no concurrent access."""
|
||||
logger.info("Starting ECSharedRegion cleanup...")
|
||||
if self._is_creator:
|
||||
try:
|
||||
os.unlink(self._mmap_path)
|
||||
logger.info("Removed EC mmap file %s", self._mmap_path)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to unlink path %s", self._mmap_path, exc_info=True
|
||||
)
|
||||
self._is_creator = False
|
||||
if self._is_pinned:
|
||||
result = torch.cuda.cudart().cudaHostUnregister(self._blocks_ptr)
|
||||
if result.value != 0:
|
||||
logger.warning("cudaHostUnregister failed (code=%d)", result)
|
||||
self._is_pinned = False
|
||||
if hasattr(self, "blocks"):
|
||||
del self.blocks
|
||||
if self._mmap_obj:
|
||||
try:
|
||||
self._mmap_obj.close()
|
||||
except Exception:
|
||||
logger.warning("Failed to close mmap_obj", exc_info=True)
|
||||
self._mmap_obj = None
|
||||
if self._fd is not None:
|
||||
try:
|
||||
os.close(self._fd)
|
||||
except Exception:
|
||||
logger.warning("Failed to close fd %s", self._fd, exc_info=True)
|
||||
self._fd = None
|
||||
@@ -1,91 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Scheduler-side wire types for the ECCPUConnector.
|
||||
|
||||
Two peer-to-peer message types exchanged between consumer and producer over
|
||||
ZMQ (XferReq, XferAck) plus a peer-compatibility fingerprint
|
||||
(compute_ec_compatibility_hash) sent on every XferReq so a producer refuses
|
||||
to serve a mismatched peer.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import msgspec
|
||||
|
||||
# Bump when the wire format or tensor/block layout changes in a
|
||||
# backward-incompatible way. Peers reject mismatched versions.
|
||||
EC_CONNECTOR_VERSION: int = 2
|
||||
|
||||
|
||||
class XferStatus(enum.IntEnum):
|
||||
"""Outcome the producer reports for an XferReq."""
|
||||
|
||||
OK = 0
|
||||
NACK_MISSING = 1 # producer no longer holds the encoding
|
||||
NACK_INCOMPAT = 2 # peer-compatibility hash mismatch
|
||||
NACK_VERSION = 3 # connector wire-version mismatch
|
||||
NACK_INTERNAL = 4 # producer hit an unexpected error
|
||||
NACK_NOT_READY = 5 # announced, but its GPU->mmap save has not landed yet
|
||||
|
||||
|
||||
# The producer expects a retryable condition to clear on its own, so the
|
||||
# consumer re-requests the read rather than giving up on the encoding.
|
||||
RETRYABLE_NACKS = frozenset((XferStatus.NACK_NOT_READY,))
|
||||
# Ordinary outcomes rather than signs of a broken peer.
|
||||
EXPECTED_NACKS = RETRYABLE_NACKS | {XferStatus.NACK_MISSING}
|
||||
|
||||
|
||||
class XferReq( # type: ignore[call-arg]
|
||||
msgspec.Struct,
|
||||
tag="req",
|
||||
omit_defaults=True,
|
||||
):
|
||||
"""Consumer → Producer: I want to READ mm_hash; pin it and tell me where."""
|
||||
|
||||
mm_hash: str
|
||||
compatibility_hash: str
|
||||
# Consumer's session identity. Together with mm_hash it uniquely identifies
|
||||
# this transfer on both sides: the producer keys its active xfers by
|
||||
# (session_id, mm_hash), and the NIXL notif_msg carries the same pair.
|
||||
session_id: str = ""
|
||||
connector_version: int = EC_CONNECTOR_VERSION
|
||||
|
||||
|
||||
class XferAck( # type: ignore[call-arg]
|
||||
msgspec.Struct,
|
||||
tag="ack",
|
||||
omit_defaults=True,
|
||||
):
|
||||
"""Producer → Consumer: response to an XferReq.
|
||||
|
||||
On status == OK: producer has pinned source blocks and returns their
|
||||
indices plus fresh NIXL metadata for the consumer to register and READ.
|
||||
On any NACK_*: optional fields are empty; consumer falls back to local encode.
|
||||
"""
|
||||
|
||||
mm_hash: str
|
||||
status: XferStatus
|
||||
# Consumer's session_id echoed back from XferReq. Together with mm_hash it
|
||||
# identifies the exact transfer; the consumer uses both to build notif_msg.
|
||||
session_id: str = ""
|
||||
src_block_indices: list[int] = []
|
||||
agent_metadata: bytes = b""
|
||||
mem_descriptor: bytes = b""
|
||||
|
||||
|
||||
def compute_ec_compatibility_hash(
|
||||
vllm_version: str,
|
||||
model: str,
|
||||
dtype: str,
|
||||
block_size_bytes: int,
|
||||
) -> str:
|
||||
"""Peer-compatibility fingerprint over the four factors that determine
|
||||
byte-layout compatibility. Producer NACKs any XferReq whose hash differs.
|
||||
"""
|
||||
factors = json.dumps(
|
||||
[vllm_version, model, dtype, block_size_bytes],
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(factors.encode("utf-8")).hexdigest()
|
||||
@@ -1,700 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""ECCPUScheduler — CPU offload scheduler delegate.
|
||||
|
||||
Owns the mmap region and the embedding cache, and handles the producer
|
||||
(GPU->CPU offload) and consumer (CPU->GPU reload) scheduler-side logic
|
||||
for the ECCPUConnector.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Collection
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
|
||||
ECCPUConnectorMetadata,
|
||||
ECCPUWorkerMetadata,
|
||||
create_ec_shared_region,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.embedding_cache import (
|
||||
EmbeddingCache,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.encoder_output_width import (
|
||||
get_encoder_output_width,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.utils import (
|
||||
PlaceholderMetadataResolver,
|
||||
collect_ec_item_metadata,
|
||||
)
|
||||
from vllm.logger import init_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.multimodal.inputs import MultiModalFeatureSpec
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
from vllm.v1.outputs import ECConnectorOutput
|
||||
from vllm.v1.request import Request
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Total remote wait budget per request item, including all retries.
|
||||
_ADMIT_DEFER_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
class _RemoteUnavailable(Exception):
|
||||
"""An item cannot be fetched within its request's remote wait budget."""
|
||||
|
||||
|
||||
class ECCPUScheduler:
|
||||
"""Scheduler delegate for the ECCPUConnector."""
|
||||
|
||||
def __init__(self, vllm_config: "VllmConfig") -> None:
|
||||
self._vllm_config = vllm_config
|
||||
ec_config = vllm_config.ec_transfer_config
|
||||
assert ec_config is not None
|
||||
self._is_producer: bool = ec_config.is_ec_producer
|
||||
self._is_consumer: bool = ec_config.is_ec_consumer
|
||||
|
||||
self._region = create_ec_shared_region(vllm_config)
|
||||
# Block allocator + LRU eviction policy for the shared region.
|
||||
self._cache = EmbeddingCache(self._region.num_blocks)
|
||||
self._metadata_resolver = PlaceholderMetadataResolver(vllm_config.model_config)
|
||||
|
||||
# mm_hash → block IDs allocated this step for GPU→mmap saves.
|
||||
self._pending_saves: dict[str, list[int]] = {}
|
||||
# mm_hash → (transfer_id, block IDs, shape) to load from mmap→GPU.
|
||||
self._pending_loads: dict[str, tuple[int, list[int], tuple[int, int]]] = {}
|
||||
|
||||
# Dispatched loads awaiting completion reports, keyed by transfer id:
|
||||
# transfer_id → (mm_hash, reports still outstanding). The pin taken at
|
||||
# dispatch is released only once the count reaches zero.
|
||||
self._load_acks: dict[int, tuple[str, int]] = {}
|
||||
self._next_transfer_id = 0
|
||||
|
||||
pc = vllm_config.parallel_config
|
||||
# Reports to expect per load. Only pipeline stage 0 runs the EC
|
||||
# connector, so the participants are the tp × pcp ranks of that stage.
|
||||
# Other executors deliver a single rank's output, and expecting reports
|
||||
# that cannot arrive would hold the pin forever.
|
||||
self._expected_load_reports = (
|
||||
pc.tensor_parallel_size * pc.prefill_context_parallel_size
|
||||
if pc.distributed_executor_backend == "mp"
|
||||
else 1
|
||||
)
|
||||
|
||||
self._ec_config = ec_config
|
||||
# NIXL p2p is an option of this connector, not engine-wide behavior, so
|
||||
# it lives in extra config. Extra config is not type coerced, so a value
|
||||
# that arrived as a JSON/CLI string is parsed, not truth-tested.
|
||||
raw_nixl = ec_config.get_from_extra_config("ec_enable_nixl", False)
|
||||
self._nixl_enabled = (
|
||||
raw_nixl
|
||||
if isinstance(raw_nixl, bool)
|
||||
else str(raw_nixl).strip().lower() in ("true", "1", "yes")
|
||||
)
|
||||
# NIXL fields default to None/empty so the gate-off path is untouched.
|
||||
self._data: Any = None
|
||||
self._compat_hash: str | None = None
|
||||
self._first_in_batch = True
|
||||
self._transport: Any = None
|
||||
self._producer_session: Any = None
|
||||
self._sessions: dict = {}
|
||||
self._in_flight: set[str] = set()
|
||||
self._tombstones: set[str] = set()
|
||||
self._step_completed: set[str] = set()
|
||||
# Per-request clocks survive retries and are cleared on completion.
|
||||
self._deferred_since: dict[tuple[str, str], float] = {}
|
||||
# Requests needing a remote encoding that will not arrive. Drained by
|
||||
# take_unavailable_requests(); the scheduler aborts them.
|
||||
self._unrecoverable: set[str] = set()
|
||||
self._peer_host: str | None = None
|
||||
self._peer_port: int | None = None
|
||||
dtype = vllm_config.model_config.dtype
|
||||
assert isinstance(dtype, torch.dtype)
|
||||
self._dtype = dtype
|
||||
self._ack_timeout_s: float = 0.0
|
||||
if self._nixl_enabled:
|
||||
try:
|
||||
self._setup_nixl(vllm_config)
|
||||
except Exception:
|
||||
self.shutdown()
|
||||
raise
|
||||
|
||||
def _setup_nixl(self, vllm_config: "VllmConfig") -> None:
|
||||
# Lazy imports keep nixl/zmq off the gate-off path.
|
||||
from vllm import envs
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq import (
|
||||
ZmqClientTransport,
|
||||
ZmqServerTransport,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.data.nixl import (
|
||||
NixlDataTransport,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.protocol import (
|
||||
compute_ec_compatibility_hash,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.session import (
|
||||
PRODUCER_PIN_LEASE_S,
|
||||
ProducerSession,
|
||||
)
|
||||
from vllm.distributed.nixl_utils import NixlWrapper, nixl_agent_config
|
||||
from vllm.version import __version__ as VLLM_VERSION
|
||||
|
||||
if NixlWrapper is None or nixl_agent_config is None:
|
||||
raise RuntimeError(
|
||||
"ec_enable_nixl requires NIXL; install the `nixl` package or "
|
||||
"remove ec_enable_nixl from ec_connector_extra_config."
|
||||
)
|
||||
engine_id = self._ec_config.engine_id
|
||||
assert engine_id is not None
|
||||
# How long a consumer waits for an XferAck. The producer answers from
|
||||
# its scheduler step, so its reply latency scales with the encoder's
|
||||
# batch size: a deployment whose steps run longer must raise this.
|
||||
self._ack_timeout_s = float(
|
||||
self._ec_config.get_from_extra_config(
|
||||
"consumer_ack_timeout_s", PRODUCER_PIN_LEASE_S
|
||||
)
|
||||
)
|
||||
self._compat_hash = compute_ec_compatibility_hash(
|
||||
vllm_version=VLLM_VERSION,
|
||||
model=str(vllm_config.model_config.model),
|
||||
dtype=str(self._dtype),
|
||||
block_size_bytes=self._region.block_size_bytes,
|
||||
)
|
||||
if self._is_producer:
|
||||
self._peer_host = envs.VLLM_EC_SIDE_CHANNEL_HOST
|
||||
self._peer_port = envs.VLLM_EC_SIDE_CHANNEL_PORT
|
||||
|
||||
self._data = NixlDataTransport(
|
||||
agent_name=engine_id,
|
||||
base_ptr=self._region.blocks.data_ptr(),
|
||||
num_blocks=self._region.num_blocks,
|
||||
block_size_bytes=self._region.block_size_bytes,
|
||||
total_size_bytes=self._region.num_blocks * self._region.block_size_bytes,
|
||||
)
|
||||
if self._is_producer:
|
||||
assert self._peer_host is not None
|
||||
assert self._peer_port is not None
|
||||
self._producer_session = ProducerSession(
|
||||
transport=ZmqServerTransport(
|
||||
host=self._peer_host, port=self._peer_port
|
||||
),
|
||||
data=self._data,
|
||||
cache=self._cache,
|
||||
compat_hash=self._compat_hash,
|
||||
)
|
||||
if self._is_consumer:
|
||||
self._transport = ZmqClientTransport()
|
||||
|
||||
def has_cache_item(self, identifier: str) -> bool:
|
||||
if not self._is_consumer:
|
||||
return False
|
||||
entry = self._cache.get(identifier)
|
||||
return entry is not None and entry.ready
|
||||
|
||||
def ensure_cache_available(
|
||||
self,
|
||||
request: "Request",
|
||||
num_computed_tokens: int,
|
||||
local_cache_hashes: Collection[str] | None = None,
|
||||
) -> bool:
|
||||
if not self._nixl_enabled:
|
||||
return True # CPU offload never blocks.
|
||||
first = self._first_in_batch
|
||||
self._first_in_batch = False
|
||||
if not self._is_consumer:
|
||||
return True
|
||||
if first:
|
||||
self._poll_step()
|
||||
return self._nixl_consumer_admit(request, num_computed_tokens)
|
||||
|
||||
def _nixl_consumer_admit(
|
||||
self, request: "Request", num_computed_tokens: int
|
||||
) -> bool:
|
||||
"""Admit a request once its required remote encodings are ready.
|
||||
|
||||
Each item has a per-request wait budget that survives transfer retries.
|
||||
Unavailable items fall back to local input; without it, the request is
|
||||
reported by take_unavailable_requests() for the scheduler to abort.
|
||||
|
||||
Returns:
|
||||
True if no item needs to wait for a remote encoding. False if any
|
||||
item is pending or the request must be aborted.
|
||||
|
||||
"""
|
||||
if not request.ec_transfer_params:
|
||||
return True
|
||||
now = time.monotonic()
|
||||
pending = False
|
||||
for feature in request.mm_features:
|
||||
try:
|
||||
if not self._admit_item(request, feature, num_computed_tokens, now):
|
||||
pending = True
|
||||
except _RemoteUnavailable as error:
|
||||
if not self._fail_or_fallback(request, feature, str(error)):
|
||||
return False
|
||||
return not pending
|
||||
|
||||
def _admit_item(
|
||||
self,
|
||||
request: "Request",
|
||||
feature: "MultiModalFeatureSpec",
|
||||
num_computed_tokens: int,
|
||||
now: float,
|
||||
) -> bool:
|
||||
"""Check one encoding, starting a remote read if needed.
|
||||
|
||||
Returns:
|
||||
True if already computed, cached, or not remotely sourced. False
|
||||
while waiting for a read or cache space, including across retries.
|
||||
|
||||
Raises:
|
||||
_RemoteUnavailable: The remote source is invalid, the read failed,
|
||||
or the item's total wait budget expired.
|
||||
|
||||
"""
|
||||
pos = feature.mm_position
|
||||
mm_hash = feature.identifier
|
||||
if pos.offset + pos.length <= num_computed_tokens:
|
||||
self._deferred_since.pop((request.request_id, mm_hash), None)
|
||||
return True
|
||||
announced = (request.ec_transfer_params or {}).get(mm_hash)
|
||||
# Without a producer address there is nothing to fetch: the request
|
||||
# carries what the model needs and the encoder runs locally.
|
||||
# `ec_transfer_params` reaches us from the request, so its shape is
|
||||
# checked rather than assumed.
|
||||
remote: dict[str, Any] | None = (
|
||||
announced
|
||||
if isinstance(announced, dict) and "peer_host" in announced
|
||||
else None
|
||||
)
|
||||
|
||||
entry = self._cache.get(mm_hash)
|
||||
if entry is not None and entry.ready:
|
||||
# Local hit: upstream's update_state_after_alloc pins and
|
||||
# loads it through the same path as a natively cached entry.
|
||||
self._deferred_since.pop((request.request_id, mm_hash), None)
|
||||
return True
|
||||
if remote is None:
|
||||
return True
|
||||
since = self._deferred_since.setdefault((request.request_id, mm_hash), now)
|
||||
if now - since > _ADMIT_DEFER_TIMEOUT_S:
|
||||
raise _RemoteUnavailable(f"the remote wait exceeded {now - since:.0f}s")
|
||||
if mm_hash in self._in_flight or mm_hash in self._step_completed:
|
||||
return False
|
||||
|
||||
if mm_hash in self._tombstones:
|
||||
self._tombstones.discard(mm_hash)
|
||||
raise _RemoteUnavailable("the remote read failed")
|
||||
|
||||
if entry is not None:
|
||||
# Present but not ready and not being fetched: its blocks are
|
||||
# held by a quarantined/settling DMA and cannot be reused.
|
||||
return False
|
||||
|
||||
try:
|
||||
size = int(remote["size_bytes"])
|
||||
shape = tuple(int(dim) for dim in remote["shape"])
|
||||
except (KeyError, TypeError, ValueError) as error:
|
||||
raise _RemoteUnavailable("the announced size was unusable") from error
|
||||
# The width is the producer's to report; the row count is not.
|
||||
num_embeds = pos.get_num_embeds()
|
||||
if (
|
||||
len(shape) != 2
|
||||
or shape[0] != num_embeds
|
||||
or size != self._num_blocks(shape) * self._region.block_size_bytes
|
||||
):
|
||||
logger.warning(
|
||||
"EC consumer: size mismatch mm_hash=%s announced=%d shape=%s "
|
||||
"expected %d rows",
|
||||
mm_hash,
|
||||
size,
|
||||
shape,
|
||||
num_embeds,
|
||||
)
|
||||
raise _RemoteUnavailable("the announced size was wrong")
|
||||
|
||||
try:
|
||||
started = self._start_xfer(mm_hash, remote, size, (shape[0], shape[1]))
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"EC consumer: failed to start NIXL xfer mm_hash=%s", mm_hash
|
||||
)
|
||||
raise _RemoteUnavailable("the read could not be started") from error
|
||||
if started:
|
||||
self._in_flight.add(mm_hash)
|
||||
return False
|
||||
|
||||
def _fail_or_fallback(
|
||||
self, request: "Request", feature: "MultiModalFeatureSpec", why: str
|
||||
) -> bool:
|
||||
"""Resolve an unavailable remote encoding and clear its wait budget.
|
||||
|
||||
Returns:
|
||||
True if local media or embeddings are available, removing this
|
||||
item's remote announcement so subsequent steps do not retry it.
|
||||
False if only placeholder metadata (or no input) is available,
|
||||
recording the request for take_unavailable_requests() to drain.
|
||||
|
||||
"""
|
||||
mm_hash = feature.identifier
|
||||
self._deferred_since.pop((request.request_id, mm_hash), None)
|
||||
data = feature.data
|
||||
metadata = (
|
||||
self._metadata_resolver.fields_for(feature.modality) if data else set()
|
||||
)
|
||||
if data and any(
|
||||
value.data is not None for key, value in data.items() if key not in metadata
|
||||
):
|
||||
# Passthrough embeddings bypass the processor cache. SHM address
|
||||
# items therefore refer to media that the worker can encode.
|
||||
request.ec_transfer_params = dict(request.ec_transfer_params or {})
|
||||
request.ec_transfer_params.pop(mm_hash, None)
|
||||
logger.warning(
|
||||
"EC consumer: request %s mm_hash=%s: %s; using local input",
|
||||
request.request_id,
|
||||
mm_hash,
|
||||
why,
|
||||
)
|
||||
return True
|
||||
self._unrecoverable.add(request.request_id)
|
||||
logger.error(
|
||||
"EC consumer: request %s needs remote encoding mm_hash=%s but %s. "
|
||||
"No local model input is available; failing the request.",
|
||||
request.request_id,
|
||||
mm_hash,
|
||||
why,
|
||||
)
|
||||
return False
|
||||
|
||||
def take_unavailable_requests(self) -> set[str]:
|
||||
"""Return and clear IDs of requests that cannot obtain their encodings.
|
||||
|
||||
The scheduler must abort these requests rather than leave them waiting.
|
||||
Requests that can fall back to local input are not included, and each
|
||||
recorded failure is returned only once.
|
||||
"""
|
||||
if not self._unrecoverable:
|
||||
return set()
|
||||
failed = self._unrecoverable
|
||||
self._unrecoverable = set()
|
||||
return failed
|
||||
|
||||
def _start_xfer(
|
||||
self,
|
||||
mm_hash: str,
|
||||
info: "dict[str, Any]",
|
||||
size_bytes: int,
|
||||
shape: tuple[int, int],
|
||||
) -> bool:
|
||||
"""Allocate a not-ready cache entry and start a NIXL READ into it.
|
||||
|
||||
Returns True if the transfer was started. Returns False when the
|
||||
cache cannot accommodate the encoding; admission will retry it.
|
||||
"""
|
||||
from math import ceil
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.session import (
|
||||
ConsumerSession,
|
||||
)
|
||||
|
||||
n_blocks = max(1, ceil(size_bytes / self._region.block_size_bytes))
|
||||
entry = self._cache.alloc(mm_hash, n_blocks, shape)
|
||||
if entry is None:
|
||||
logger.debug(
|
||||
"EC consumer: cache full for mm_hash=%s (%d blocks); deferring",
|
||||
mm_hash,
|
||||
n_blocks,
|
||||
)
|
||||
return False
|
||||
indices = list(entry.block_ids)
|
||||
addr = (info["peer_host"], int(info["peer_port"]))
|
||||
if addr not in self._sessions:
|
||||
zmq_conn = self._transport.connect(addr)
|
||||
assert self._compat_hash is not None
|
||||
self._sessions[addr] = ConsumerSession(
|
||||
addr=addr,
|
||||
zmq_conn=zmq_conn,
|
||||
transport=self._transport,
|
||||
data=self._data,
|
||||
compat_hash=self._compat_hash,
|
||||
)
|
||||
deadline = time.monotonic() + self._ack_timeout_s
|
||||
try:
|
||||
self._sessions[addr].start_xfer(mm_hash, indices, deadline)
|
||||
except Exception:
|
||||
self._cache.discard(mm_hash)
|
||||
raise
|
||||
logger.debug(
|
||||
"EC consumer: starting NIXL xfer mm_hash=%s from %s:%d blocks=%d",
|
||||
mm_hash,
|
||||
addr[0],
|
||||
addr[1],
|
||||
n_blocks,
|
||||
)
|
||||
return True
|
||||
|
||||
def _poll_step(self) -> None:
|
||||
now = time.monotonic()
|
||||
all_messages = self._transport.poll()
|
||||
for addr, session in list(self._sessions.items()):
|
||||
session.poll(all_messages.get(addr, []), now)
|
||||
for addr in self._transport.poll_dead():
|
||||
self._on_peer_down(addr)
|
||||
for session in self._sessions.values():
|
||||
self._process_session_results(session)
|
||||
|
||||
def _process_session_results(self, session: Any) -> None:
|
||||
r = session.take_results()
|
||||
for mm_hash in r.completed:
|
||||
self._in_flight.discard(mm_hash)
|
||||
self._cache.mark_ready(mm_hash)
|
||||
self._step_completed.add(mm_hash)
|
||||
logger.debug("EC consumer: NIXL xfer complete mm_hash=%s", mm_hash)
|
||||
for mm_hash in r.tombstoned:
|
||||
self._in_flight.discard(mm_hash)
|
||||
self._cache.discard(mm_hash)
|
||||
self._tombstones.add(mm_hash)
|
||||
logger.debug("EC consumer: NIXL xfer failed mm_hash=%s", mm_hash)
|
||||
for mm_hash in r.quarantined:
|
||||
# DMA still running: keep the blocks reserved (entry stays
|
||||
# not-ready, hence non-evictable) until the xfer settles.
|
||||
self._in_flight.discard(mm_hash)
|
||||
self._tombstones.add(mm_hash)
|
||||
logger.debug(
|
||||
"EC consumer: NIXL xfer mm_hash=%s quarantined; DMA still running",
|
||||
mm_hash,
|
||||
)
|
||||
for mm_hash in r.retryable:
|
||||
# Release this attempt's blocks, retaining the request wait budget.
|
||||
self._in_flight.discard(mm_hash)
|
||||
self._cache.discard(mm_hash)
|
||||
logger.debug(
|
||||
"EC consumer: NIXL xfer mm_hash=%s retryable; will re-request",
|
||||
mm_hash,
|
||||
)
|
||||
for mm_hash, _block_indices in r.settled:
|
||||
self._cache.discard(mm_hash)
|
||||
logger.debug(
|
||||
"EC consumer: quarantined NIXL xfer mm_hash=%s settled", mm_hash
|
||||
)
|
||||
|
||||
def _on_peer_down(self, addr: Any) -> None:
|
||||
session = self._sessions.pop(addr, None)
|
||||
if session is None:
|
||||
return
|
||||
session.on_peer_down()
|
||||
self._process_session_results(session)
|
||||
session.close()
|
||||
logger.info("EC consumer: producer peer down addr=%s", addr)
|
||||
|
||||
def _promote_completed_reads(self) -> None:
|
||||
"""Drain the just-completed set built during ``_poll_step``.
|
||||
|
||||
Reads are marked ready in ``_process_session_results``, so a deferred
|
||||
request is re-admitted by ``ensure_cache_available`` and loaded through
|
||||
the same local path as a natively cached entry
|
||||
(``update_state_after_alloc`` -> ``_pending_loads``). No explicit
|
||||
pin/load bookkeeping is needed here beyond clearing the set.
|
||||
"""
|
||||
self._step_completed.clear()
|
||||
|
||||
def _num_blocks(self, shape: tuple[int, ...]) -> int:
|
||||
size_bytes = shape[0] * shape[1] * self._dtype.itemsize
|
||||
block_size = self._region.block_size_bytes
|
||||
return (size_bytes + block_size - 1) // block_size
|
||||
|
||||
def update_state_after_alloc(self, request: "Request", index: int) -> None:
|
||||
feature = request.mm_features[index]
|
||||
mm_hash = feature.identifier
|
||||
|
||||
if self._is_producer and self._cache.get(mm_hash) is None:
|
||||
shape = (
|
||||
feature.mm_position.get_num_embeds(),
|
||||
get_encoder_output_width(self._vllm_config, feature.modality),
|
||||
)
|
||||
entry = self._cache.alloc(mm_hash, self._num_blocks(shape), shape)
|
||||
if entry is not None:
|
||||
self._pending_saves[mm_hash] = list(entry.block_ids)
|
||||
|
||||
if self._is_consumer and mm_hash not in self._pending_loads:
|
||||
entry = self._cache.get(mm_hash)
|
||||
if entry is not None and entry.ready:
|
||||
assert entry.shape is not None
|
||||
self._cache.pin(mm_hash)
|
||||
transfer_id = self._next_transfer_id
|
||||
self._next_transfer_id += 1
|
||||
self._pending_loads[mm_hash] = (
|
||||
transfer_id,
|
||||
list(entry.block_ids),
|
||||
entry.shape,
|
||||
)
|
||||
self._load_acks[transfer_id] = (mm_hash, self._expected_load_reports)
|
||||
|
||||
def build_connector_meta(
|
||||
self, scheduler_output: "SchedulerOutput"
|
||||
) -> ECCPUConnectorMetadata:
|
||||
meta = ECCPUConnectorMetadata()
|
||||
if self._is_producer:
|
||||
if self._nixl_enabled and self._producer_session is not None:
|
||||
self._producer_session.poll_step()
|
||||
meta.saves = self._pending_saves
|
||||
self._pending_saves = {}
|
||||
if self._is_consumer:
|
||||
if self._nixl_enabled:
|
||||
self._promote_completed_reads()
|
||||
meta.loads = self._pending_loads
|
||||
self._pending_loads = {}
|
||||
if self._nixl_enabled:
|
||||
self._first_in_batch = True
|
||||
return meta
|
||||
|
||||
def update_connector_output(self, connector_output: "ECConnectorOutput") -> None:
|
||||
"""Apply the worker's memcpy-completion report to the cache.
|
||||
|
||||
Completed saves become safe to mark ready. A load is unpinned once
|
||||
every participating rank has reported its transfer id; reports for a
|
||||
transfer that has already been released, or for one this scheduler
|
||||
never dispatched, are ignored.
|
||||
"""
|
||||
meta = connector_output.ec_connector_worker_meta
|
||||
if not isinstance(meta, ECCPUWorkerMetadata):
|
||||
return
|
||||
for mm_hash in meta.completed_saves:
|
||||
entry = self._cache.get(mm_hash)
|
||||
if entry is None:
|
||||
logger.debug(
|
||||
"EC producer: worker reported completed save for unknown "
|
||||
"mm_hash=%s (already discarded/evicted?)",
|
||||
mm_hash,
|
||||
)
|
||||
elif not entry.ready:
|
||||
self._cache.mark_ready(mm_hash)
|
||||
logger.debug("EC producer: mm_hash=%s marked ready", mm_hash)
|
||||
for transfer_id in meta.completed_loads:
|
||||
pending = self._load_acks.get(transfer_id)
|
||||
if pending is None:
|
||||
continue
|
||||
mm_hash, outstanding = pending
|
||||
if outstanding > 1:
|
||||
self._load_acks[transfer_id] = (mm_hash, outstanding - 1)
|
||||
continue
|
||||
# Drop the entry before unpinning so a replayed report is treated
|
||||
# as stale rather than releasing the pin a second time.
|
||||
del self._load_acks[transfer_id]
|
||||
self._cache.unpin(mm_hash)
|
||||
logger.debug("EC consumer: mm_hash=%s unpinned after load", mm_hash)
|
||||
|
||||
def has_pending_push_work(self) -> bool:
|
||||
"""Keep the engine stepping so this connector's polls keep running.
|
||||
|
||||
With NIXL enabled the engine tick is the only driver of
|
||||
``ProducerSession.poll_step()``, and a producer's work arrives from a
|
||||
remote consumer, so nothing local would otherwise wake it. Without NIXL
|
||||
this only has to outlive dispatched saves and loads, so the engine can
|
||||
quiesce once the worker has confirmed them.
|
||||
"""
|
||||
return self._nixl_enabled or self._cache.has_held_entries()
|
||||
|
||||
def request_finished(
|
||||
self, request: "Request"
|
||||
) -> tuple[bool, "dict[str, Any] | None"]:
|
||||
for feature in request.mm_features:
|
||||
self._deferred_since.pop((request.request_id, feature.identifier), None)
|
||||
if not (self._nixl_enabled and self._is_producer):
|
||||
return False, None
|
||||
if not request.mm_features:
|
||||
return False, None
|
||||
|
||||
items = collect_ec_item_metadata(request.mm_features, self._metadata_resolver)
|
||||
|
||||
for feature in request.mm_features:
|
||||
mm_hash = feature.identifier
|
||||
entry = self._cache.get(mm_hash)
|
||||
if entry is None:
|
||||
# Never saved, or evicted since. Publishing placeholder
|
||||
# metadata without an address invites an orchestrator to
|
||||
# rewrite the media into a reference to an encoding no
|
||||
# consumer can fetch, leaving the decoder nothing to embed.
|
||||
# Publish neither, so the media stays on the request.
|
||||
items[mm_hash]["metadata"] = {}
|
||||
logger.debug(
|
||||
"EC producer: mm_hash=%s absent at request_finished; "
|
||||
"announcing no metadata so the media is not rewritten away",
|
||||
mm_hash,
|
||||
)
|
||||
continue
|
||||
# Announce even if the save's GPU->mmap copy hasn't been
|
||||
# confirmed complete yet: a not-ready entry can't be evicted, so
|
||||
# it will still be here by the time a consumer's XferReq arrives.
|
||||
# A read arriving before the save lands is NACKed NACK_NOT_READY,
|
||||
# which the consumer retries on a later step rather than treating
|
||||
# as a miss.
|
||||
assert entry.shape is not None
|
||||
size_bytes = len(entry.block_ids) * self._region.block_size_bytes
|
||||
items[mm_hash].update(
|
||||
peer_host=self._peer_host,
|
||||
peer_port=self._peer_port,
|
||||
size_bytes=size_bytes,
|
||||
shape=list(entry.shape),
|
||||
)
|
||||
logger.debug(
|
||||
"EC producer: announcing NIXL-readable encodings req_id=%s items=%s",
|
||||
request.request_id,
|
||||
items,
|
||||
)
|
||||
return False, items
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._pending_saves.clear()
|
||||
self._pending_loads.clear()
|
||||
self._load_acks.clear()
|
||||
self._deferred_since.clear()
|
||||
|
||||
self._is_producer = False
|
||||
self._is_consumer = False
|
||||
|
||||
if self._nixl_enabled:
|
||||
self._teardown_nixl()
|
||||
|
||||
try:
|
||||
self._region.cleanup()
|
||||
except Exception:
|
||||
logger.debug("ec: region cleanup failed", exc_info=True)
|
||||
|
||||
def _teardown_nixl(self) -> None:
|
||||
"""Close the control sockets and release the NIXL agent.
|
||||
|
||||
Guards each step and clears what it releases, so it is safe on a
|
||||
scheduler whose NIXL setup failed part-way and safe to call twice.
|
||||
The shared region is not touched — shutdown() owns that.
|
||||
"""
|
||||
if self._producer_session is not None:
|
||||
try:
|
||||
self._producer_session.close()
|
||||
except Exception:
|
||||
logger.debug("ec: producer session close failed", exc_info=True)
|
||||
self._producer_session = None
|
||||
|
||||
for session in list(self._sessions.values()):
|
||||
try:
|
||||
session.close()
|
||||
except Exception:
|
||||
logger.debug("ec: consumer session close failed", exc_info=True)
|
||||
self._sessions.clear()
|
||||
|
||||
if self._transport is not None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except Exception:
|
||||
logger.debug("ec: client transport close failed", exc_info=True)
|
||||
self._transport = None
|
||||
|
||||
if self._data is not None:
|
||||
try:
|
||||
self._data.deregister()
|
||||
except Exception:
|
||||
logger.debug("ec: deregister failed", exc_info=True)
|
||||
self._data = None
|
||||
@@ -1,184 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""EmbeddingCache — named-entry block cache with FIFO eviction.
|
||||
|
||||
Manages a fixed pool of block IDs keyed by content identity (mm_hash).
|
||||
Entries transition through: not-ready → ready (evictable) → pinned.
|
||||
Eviction targets ready + unpinned entries in FIFO order.
|
||||
|
||||
All public methods are thread-safe.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
class CacheEntry:
|
||||
"""A single cache entry. Read `.block_ids`, `.shape` and `.ready` freely;
|
||||
mutations only through EmbeddingCache methods.
|
||||
|
||||
`shape` is the encoder output the blocks hold, so a load can restore it
|
||||
without knowing how wide this model's encoder output is.
|
||||
"""
|
||||
|
||||
__slots__ = ("block_ids", "shape", "_pin_count")
|
||||
|
||||
def __init__(
|
||||
self, block_ids: tuple[int, ...], shape: tuple[int, int] | None = None
|
||||
) -> None:
|
||||
self.block_ids = block_ids
|
||||
self.shape = shape
|
||||
self._pin_count = -1 # not ready
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self._pin_count >= 0
|
||||
|
||||
@property
|
||||
def evictable(self) -> bool:
|
||||
return self._pin_count == 0
|
||||
|
||||
def mark_ready(self):
|
||||
self._pin_count = 0
|
||||
|
||||
def pin(self):
|
||||
self._pin_count += 1
|
||||
|
||||
|
||||
class EmbeddingCache:
|
||||
"""Fixed-size block cache with FIFO eviction.
|
||||
|
||||
Entries are keyed by content identity (e.g. mm_hash). Blocks are
|
||||
allocated from a free-set; when space is needed, ready + unpinned
|
||||
entries are evicted oldest-first.
|
||||
|
||||
The caller is responsible for deciding *when* to call ``mark_ready``
|
||||
(e.g. after enough engine steps have elapsed for the worker to have
|
||||
completed the write).
|
||||
"""
|
||||
|
||||
def __init__(self, num_blocks: int) -> None:
|
||||
self._num_blocks = num_blocks
|
||||
self._lock = threading.Lock()
|
||||
# Available block IDs, LIFO stack.
|
||||
self._free_blocks: list[int] = list(range(num_blocks))
|
||||
# Key → live entry (all states: not-ready, ready, pinned).
|
||||
self._entries: dict[str, CacheEntry] = {}
|
||||
# Ready + unpinned entries in insertion order (FIFO eviction).
|
||||
self._entries_free_list: OrderedDict[str, None] = OrderedDict()
|
||||
# Total blocks reclaimable via eviction (fail-fast check).
|
||||
self._evictable_block_count: int = 0
|
||||
|
||||
def get(self, key: str) -> CacheEntry | None:
|
||||
"""Return the entry for *key*, or None if not present."""
|
||||
with self._lock:
|
||||
return self._entries.get(key)
|
||||
|
||||
def has_held_entries(self) -> bool:
|
||||
"""True if any entry is not-ready or pinned (an in-flight transfer).
|
||||
|
||||
Held entries are exactly those absent from the eviction free list, so
|
||||
they represent saves awaiting mark_ready or loads awaiting unpin.
|
||||
"""
|
||||
with self._lock:
|
||||
return len(self._entries) != len(self._entries_free_list)
|
||||
|
||||
def alloc(
|
||||
self, key: str, n_blocks: int, shape: tuple[int, int] | None = None
|
||||
) -> CacheEntry | None:
|
||||
"""Allocate *n_blocks* for *key*, evicting as needed.
|
||||
|
||||
The entry starts not-ready. Returns None if there is not enough
|
||||
space even after evicting all evictable entries.
|
||||
"""
|
||||
with self._lock:
|
||||
assert key not in self._entries, (
|
||||
f"EmbeddingCache: duplicate alloc for {key!r}"
|
||||
)
|
||||
assert n_blocks <= self._num_blocks, (
|
||||
f"EmbeddingCache: {n_blocks} blocks requested but capacity "
|
||||
f"is {self._num_blocks}"
|
||||
)
|
||||
if len(self._free_blocks) + self._evictable_block_count < n_blocks:
|
||||
return None
|
||||
self._evict_until(n_blocks)
|
||||
# Canonical ascending order: keeps a freed entry's blocks contiguous
|
||||
# in the free list when it is evicted, and lets consumers detect
|
||||
# which of an entry's blocks are adjacent.
|
||||
block_ids = tuple(sorted(self._free_blocks.pop() for _ in range(n_blocks)))
|
||||
entry = CacheEntry(block_ids, shape)
|
||||
self._entries[key] = entry
|
||||
return entry
|
||||
|
||||
def mark_ready(self, key: str) -> None:
|
||||
"""Mark an entry as ready (data is CPU-visible)."""
|
||||
with self._lock:
|
||||
entry = self._entries[key]
|
||||
assert entry._pin_count == -1, (
|
||||
f"EmbeddingCache: mark_ready on already-ready entry {key!r}"
|
||||
)
|
||||
entry.mark_ready()
|
||||
self._entries_free_list[key] = None
|
||||
self._evictable_block_count += len(entry.block_ids)
|
||||
|
||||
def pin(self, key: str) -> None:
|
||||
"""Pin an entry (prevent eviction)."""
|
||||
with self._lock:
|
||||
entry = self._entries[key]
|
||||
assert entry.ready, f"EmbeddingCache: pin of not-ready entry {key!r}"
|
||||
if entry.evictable:
|
||||
del self._entries_free_list[key]
|
||||
self._evictable_block_count -= len(entry.block_ids)
|
||||
entry.pin()
|
||||
|
||||
def pin_if_ready(self, key: str) -> CacheEntry | None:
|
||||
"""Atomically pin *key* if present and ready; return its entry.
|
||||
|
||||
Returns None when the key is absent. An entry returned with `ready`
|
||||
False was not pinned: its save is still in flight, which the producer
|
||||
reports differently from a miss because such a read can be retried.
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None or not entry.ready:
|
||||
return entry
|
||||
if entry.evictable:
|
||||
del self._entries_free_list[key]
|
||||
self._evictable_block_count -= len(entry.block_ids)
|
||||
entry.pin()
|
||||
return entry
|
||||
|
||||
def unpin(self, key: str) -> None:
|
||||
"""Unpin an entry. Asserts currently pinned."""
|
||||
with self._lock:
|
||||
entry = self._entries[key]
|
||||
assert entry._pin_count > 0, (
|
||||
f"EmbeddingCache: unpin of unpinned entry {key!r}"
|
||||
)
|
||||
entry._pin_count -= 1
|
||||
if entry._pin_count == 0:
|
||||
self._entries_free_list[key] = None
|
||||
self._evictable_block_count += len(entry.block_ids)
|
||||
|
||||
def discard(self, key: str) -> None:
|
||||
"""Remove a not-ready in-flight entry, returning its blocks to the pool.
|
||||
|
||||
Used when an in-flight fill (e.g. a NIXL READ) fails before the entry
|
||||
is marked ready. Asserts the entry is present and not ready — ready or
|
||||
pinned entries are reclaimed through eviction/unpin, not discard.
|
||||
"""
|
||||
with self._lock:
|
||||
entry = self._entries[key]
|
||||
assert entry._pin_count == -1, (
|
||||
f"EmbeddingCache: discard of ready/pinned entry {key!r}"
|
||||
)
|
||||
del self._entries[key]
|
||||
self._free_blocks.extend(entry.block_ids)
|
||||
|
||||
def _evict_until(self, n_blocks: int) -> None:
|
||||
"""Evict ready+unpinned entries FIFO until enough space. Lock held."""
|
||||
while len(self._free_blocks) < n_blocks:
|
||||
key, _ = self._entries_free_list.popitem(last=False)
|
||||
entry = self._entries.pop(key)
|
||||
self._evictable_block_count -= len(entry.block_ids)
|
||||
self._free_blocks.extend(entry.block_ids)
|
||||
@@ -1,644 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Session objects for the ECCPUConnector.
|
||||
|
||||
Layer hierarchy: connector → session → transport (connection)
|
||||
|
||||
Two long-lived sessions:
|
||||
|
||||
ProducerSession — owns ZmqServerTransport. The connector calls poll_step()
|
||||
once per engine step, which drains the transport without blocking and
|
||||
feeds the raw bytes to poll(). poll() decodes XferReqs, grants or NACKs
|
||||
them, pins source blocks, sends XferAck replies, drains NIXL completion
|
||||
notifications, and sweeps expired xfers.
|
||||
|
||||
ConsumerSession — owns one ZmqClientConnection and the NIXL agent
|
||||
registration for that peer. The connector calls poll(messages, now)
|
||||
once per engine step with the raw bytes collected for this peer.
|
||||
poll() decodes XferAcks, dispatches them to ConsumerXfer objects,
|
||||
and advances all active xfer state machines.
|
||||
|
||||
Internal data classes:
|
||||
|
||||
ProducerXfer — one pinned read grant, keyed by session_id.
|
||||
ConsumerXfer — state machine for one NIXL READ, keyed by mm_hash.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.control.zmq import (
|
||||
ZmqClientConnection,
|
||||
ZmqClientTransport,
|
||||
ZmqServerTransport,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.data.base import (
|
||||
DataTransport,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.protocol import (
|
||||
EC_CONNECTOR_VERSION,
|
||||
EXPECTED_NACKS,
|
||||
RETRYABLE_NACKS,
|
||||
XferAck,
|
||||
XferReq,
|
||||
XferStatus,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.scheduler.embedding_cache import (
|
||||
EmbeddingCache,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.utils import PeerAddr
|
||||
from vllm.logger import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
PRODUCER_PIN_LEASE_S = 30.0
|
||||
_CONSUMER_READ_TIMEOUT_S = 20.0
|
||||
_CONSUMER_QUARANTINE_TIMEOUT_S = 60.0
|
||||
|
||||
|
||||
# ── XferState ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class XferState(enum.Enum):
|
||||
"""Lifecycle states of a single ConsumerXfer."""
|
||||
|
||||
WAITING_ACK = "waiting_ack" # XferReq sent, awaiting XferAck
|
||||
READING = "reading" # NIXL READ in flight
|
||||
DONE = "done" # READ completed; promote to local cache
|
||||
ACK_TIMEOUT = "ack_timeout" # XferAck never arrived; free blocks + retry
|
||||
READ_FAILED = "read_failed" # Unexpected NIXL state; free blocks + tombstone
|
||||
QUARANTINED = "quarantined" # Read timed out, NIXL unabortable; keep blocks
|
||||
SETTLED = "settled" # Quarantined DMA done; safe to free blocks
|
||||
|
||||
|
||||
# ── ProducerXfer ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProducerXfer:
|
||||
"""One pinned read grant on the producer side.
|
||||
|
||||
Pure data — lifecycle operations (pin/unpin) are performed by ProducerSession.
|
||||
Keyed by (consumer_session_id, mm_hash) in ProducerSession._active_xfers.
|
||||
"""
|
||||
|
||||
mm_hash: str
|
||||
block_indices: list[int]
|
||||
deadline: float
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
return time.monotonic() > self.deadline
|
||||
|
||||
|
||||
# ── ConsumerXfer ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsumerXfer:
|
||||
"""State machine for one NIXL READ on the consumer side.
|
||||
|
||||
transfer_handle is the in-flight NIXL handle returned by post_read() —
|
||||
used for check_xfer_state() / release_xfer_handle(). The dlist handle
|
||||
for the remote peer lives inside DataTransport, not here.
|
||||
"""
|
||||
|
||||
mm_hash: str
|
||||
block_indices: list[int]
|
||||
addr: PeerAddr
|
||||
deadline: float
|
||||
data: DataTransport
|
||||
consumer_session_id: str # session identity; combined with mm_hash for notif_msg
|
||||
|
||||
transfer_handle: Any | None = field(default=None)
|
||||
_quarantined: bool = field(default=False, init=False)
|
||||
|
||||
def handle_ack(self, ack: XferAck, agent_name: str) -> bool:
|
||||
"""Issue the NIXL READ on an OK XferAck. Returns False on any NACK."""
|
||||
if ack.status != XferStatus.OK:
|
||||
return False
|
||||
# notif_msg encodes both session_id and mm_hash so the producer can
|
||||
# do a direct (session_id, mm_hash) → ProducerXfer lookup on completion.
|
||||
notif_msg = f"{self.consumer_session_id}:{self.mm_hash}".encode()
|
||||
self.transfer_handle = self.data.post_read(
|
||||
self.block_indices,
|
||||
agent_name,
|
||||
ack.src_block_indices,
|
||||
notif_msg=notif_msg,
|
||||
)
|
||||
self.deadline = time.monotonic() + _CONSUMER_READ_TIMEOUT_S
|
||||
logger.debug(
|
||||
"EC consumer: NIXL READ posted mm_hash=%s agent=%s local_blocks=%d "
|
||||
"remote_blocks=%d",
|
||||
self.mm_hash,
|
||||
agent_name,
|
||||
len(self.block_indices),
|
||||
len(ack.src_block_indices),
|
||||
)
|
||||
return True
|
||||
|
||||
def poll(self, now: float) -> XferState:
|
||||
if self._quarantined:
|
||||
return self._poll_quarantined(now)
|
||||
if self.transfer_handle is None:
|
||||
return (
|
||||
XferState.ACK_TIMEOUT if now > self.deadline else XferState.WAITING_ACK
|
||||
)
|
||||
return self._poll_reading(now)
|
||||
|
||||
def _poll_reading(self, now: float) -> XferState:
|
||||
assert self.transfer_handle is not None
|
||||
try:
|
||||
state = self.data.check_xfer_state(self.transfer_handle)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"EC Connector: check_xfer_state failed for mm_hash=%s", self.mm_hash
|
||||
)
|
||||
self.data.release_xfer_handle(self.transfer_handle)
|
||||
self.transfer_handle = None
|
||||
return XferState.READ_FAILED
|
||||
if state == "DONE":
|
||||
self.data.release_xfer_handle(self.transfer_handle)
|
||||
self.transfer_handle = None
|
||||
logger.debug("EC consumer: NIXL READ completed mm_hash=%s", self.mm_hash)
|
||||
return XferState.DONE
|
||||
if state == "PROC":
|
||||
if now > self.deadline:
|
||||
self._quarantined = True
|
||||
self.deadline = now + _CONSUMER_QUARANTINE_TIMEOUT_S
|
||||
logger.warning(
|
||||
"EC Connector: READ for mm_hash=%s from %s:%d timed out; "
|
||||
"quarantining",
|
||||
self.mm_hash,
|
||||
self.addr[0],
|
||||
self.addr[1],
|
||||
)
|
||||
return XferState.QUARANTINED
|
||||
return XferState.READING
|
||||
logger.warning(
|
||||
"EC Connector: READ for mm_hash=%s unexpected NIXL state %r",
|
||||
self.mm_hash,
|
||||
state,
|
||||
)
|
||||
self.data.release_xfer_handle(self.transfer_handle)
|
||||
self.transfer_handle = None
|
||||
return XferState.READ_FAILED
|
||||
|
||||
def _poll_quarantined(self, now: float) -> XferState:
|
||||
if self.transfer_handle is None:
|
||||
return XferState.SETTLED
|
||||
# If quarantine has been too long, assume NIXL is stuck and evict blocks.
|
||||
if now > self.deadline:
|
||||
self.data.release_xfer_handle(self.transfer_handle)
|
||||
self.transfer_handle = None
|
||||
logger.warning(
|
||||
"EC Connector: quarantined mm_hash=%s from %s:%d timed out; "
|
||||
"evicting blocks",
|
||||
self.mm_hash,
|
||||
self.addr[0],
|
||||
self.addr[1],
|
||||
)
|
||||
return XferState.READ_FAILED
|
||||
try:
|
||||
state = self.data.check_xfer_state(self.transfer_handle)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"EC Connector: check_xfer_state failed for quarantined mm_hash=%s",
|
||||
self.mm_hash,
|
||||
)
|
||||
state = None
|
||||
if state == "PROC":
|
||||
return XferState.QUARANTINED
|
||||
self.data.release_xfer_handle(self.transfer_handle)
|
||||
self.transfer_handle = None
|
||||
logger.debug(
|
||||
"EC Connector: quarantined mm_hash=%s settled (state=%s)",
|
||||
self.mm_hash,
|
||||
state,
|
||||
)
|
||||
return XferState.SETTLED
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel a WAITING_ACK xfer (peer down). No tombstone — caller allows retry."""
|
||||
assert self.transfer_handle is None, "cancel() requires WAITING_ACK state"
|
||||
|
||||
def release(self) -> None:
|
||||
"""Force-release the transfer handle. Shutdown only."""
|
||||
if self.transfer_handle is not None:
|
||||
self.data.release_xfer_handle(self.transfer_handle)
|
||||
self.transfer_handle = None
|
||||
|
||||
|
||||
# ── ProducerSession ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ProducerSession:
|
||||
"""Long-lived producer session. Owns ZmqServerTransport.
|
||||
|
||||
poll_step() runs once per engine step: drains the transport without
|
||||
blocking, decodes XferReqs, grants or NACKs each one, drains NIXL completion
|
||||
notifications, and sweeps expired xfers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: ZmqServerTransport,
|
||||
data: DataTransport,
|
||||
cache: EmbeddingCache,
|
||||
compat_hash: str,
|
||||
) -> None:
|
||||
self._transport = transport
|
||||
self._data = data
|
||||
self._cache = cache # EmbeddingCache — single block owner
|
||||
self._compat_hash = compat_hash
|
||||
|
||||
# Key: "{consumer_session_id}:{mm_hash}" — matches the NIXL notif_msg exactly,
|
||||
# so completion notification lookup is a direct dict pop with no parsing.
|
||||
self._active_xfers: dict[str, ProducerXfer] = {}
|
||||
|
||||
self._req_decoder = msgspec.msgpack.Decoder(XferReq)
|
||||
self._req_encoder = msgspec.msgpack.Encoder()
|
||||
|
||||
def close(self) -> None:
|
||||
for xfer in self._active_xfers.values():
|
||||
self._cache.unpin(xfer.mm_hash) # release any still-held pins
|
||||
self._active_xfers.clear()
|
||||
self._transport.close()
|
||||
|
||||
def poll_step(self) -> None:
|
||||
"""Drain the transport without blocking and process what arrived."""
|
||||
self.poll(self._transport.poll(timeout_ms=0))
|
||||
|
||||
def poll(self, messages: list[tuple[bytes, bytes]]) -> None:
|
||||
"""Decode and process inbound XferReqs, drain NIXL notifs, sweep timeouts.
|
||||
|
||||
messages: (identity, payload) pairs from ZmqServerTransport.poll().
|
||||
"""
|
||||
for identity, payload in messages:
|
||||
try:
|
||||
req = self._req_decoder.decode(payload)
|
||||
except (msgspec.DecodeError, msgspec.ValidationError):
|
||||
logger.warning("EC Connector: dropped malformed XferReq")
|
||||
continue
|
||||
ack = self._grant_or_nack(req)
|
||||
self._transport.send(identity, self._req_encoder.encode(ack))
|
||||
|
||||
self._drain_notifs()
|
||||
self._sweep_timeouts()
|
||||
|
||||
def _grant_or_nack(self, req: XferReq) -> XferAck:
|
||||
if req.connector_version != EC_CONNECTOR_VERSION:
|
||||
logger.warning(
|
||||
"EC Connector: incompatible version for mm_hash=%s", req.mm_hash
|
||||
)
|
||||
return XferAck(mm_hash=req.mm_hash, status=XferStatus.NACK_VERSION)
|
||||
if req.compatibility_hash != self._compat_hash:
|
||||
logger.warning(
|
||||
"EC Connector: incompatible compat hash for mm_hash=%s", req.mm_hash
|
||||
)
|
||||
return XferAck(mm_hash=req.mm_hash, status=XferStatus.NACK_INCOMPAT)
|
||||
if not req.session_id:
|
||||
# Grants are keyed by (session_id, mm_hash), so an omitted id from
|
||||
# one peer aliases another's: the first completion would unpin
|
||||
# blocks the other peer is still reading.
|
||||
logger.warning(
|
||||
"EC Connector: XferReq without session_id for mm_hash=%s", req.mm_hash
|
||||
)
|
||||
return XferAck(mm_hash=req.mm_hash, status=XferStatus.NACK_INTERNAL)
|
||||
entry = self._cache.pin_if_ready(req.mm_hash)
|
||||
if entry is None or not entry.ready:
|
||||
status = (
|
||||
XferStatus.NACK_MISSING if entry is None else XferStatus.NACK_NOT_READY
|
||||
)
|
||||
logger.log(
|
||||
logging.INFO if status in EXPECTED_NACKS else logging.WARNING,
|
||||
"EC Connector: mm_hash=%s not serveable in local cache (%s); NACKing",
|
||||
req.mm_hash,
|
||||
status.name,
|
||||
)
|
||||
return XferAck(mm_hash=req.mm_hash, status=status)
|
||||
block_indices = list(entry.block_ids)
|
||||
key = f"{req.session_id}:{req.mm_hash}"
|
||||
if key in self._active_xfers:
|
||||
# This peer is re-requesting a grant it still holds, having timed
|
||||
# out waiting. Overwriting it drops the pin taken for it, which
|
||||
# neither a completion nor the sweep would ever release.
|
||||
self._cache.unpin(req.mm_hash)
|
||||
self._active_xfers[key] = ProducerXfer(
|
||||
mm_hash=req.mm_hash,
|
||||
block_indices=block_indices,
|
||||
deadline=time.monotonic() + PRODUCER_PIN_LEASE_S,
|
||||
)
|
||||
logger.debug(
|
||||
"EC producer: serving NIXL READ request mm_hash=%s key=%s blocks=%d",
|
||||
req.mm_hash,
|
||||
key,
|
||||
len(block_indices),
|
||||
)
|
||||
return XferAck(
|
||||
mm_hash=req.mm_hash,
|
||||
status=XferStatus.OK,
|
||||
session_id=req.session_id, # echo consumer's session_id back
|
||||
src_block_indices=block_indices,
|
||||
agent_metadata=self._data.get_agent_metadata(),
|
||||
mem_descriptor=self._data.get_mem_descriptor(),
|
||||
)
|
||||
|
||||
def _drain_notifs(self) -> None:
|
||||
try:
|
||||
notifs = self._data.get_new_notifs()
|
||||
except Exception:
|
||||
logger.exception("EC Connector: get_new_notifs failed")
|
||||
return
|
||||
for msgs in notifs.values():
|
||||
for msg in msgs:
|
||||
try:
|
||||
key = msg.decode("utf-8") # "{session_id}:{mm_hash}"
|
||||
except Exception:
|
||||
logger.exception("EC Connector: Dropped malformed NIXL notif")
|
||||
continue
|
||||
xfer = self._active_xfers.pop(key, None)
|
||||
if xfer is not None:
|
||||
self._cache.unpin(xfer.mm_hash)
|
||||
logger.debug(
|
||||
"EC producer: NIXL READ completed mm_hash=%s key=%s",
|
||||
xfer.mm_hash,
|
||||
key,
|
||||
)
|
||||
|
||||
def _sweep_timeouts(self) -> None:
|
||||
for key, xfer in list(self._active_xfers.items()):
|
||||
if xfer.is_expired():
|
||||
logger.warning(
|
||||
"EC producer: consumer did not complete NIXL READ "
|
||||
"mm_hash=%s key=%s within the pin lease; releasing "
|
||||
"pinned blocks",
|
||||
xfer.mm_hash,
|
||||
key,
|
||||
)
|
||||
self._cache.unpin(xfer.mm_hash)
|
||||
del self._active_xfers[key]
|
||||
|
||||
|
||||
# ── ConsumerSession ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsumerSessionResults:
|
||||
"""Results from ConsumerSession.take_results()."""
|
||||
|
||||
completed: set[str] # promote to local cache
|
||||
tombstoned: set[str] # free blocks + prevent retry this step
|
||||
quarantined: set[str] # timed-out, DMA still running: prevent retry
|
||||
# but DO NOT free blocks — session holds them
|
||||
retryable: set[str] # free blocks, allow another attempt (not ready, peer down)
|
||||
settled: list[tuple[str, list[int]]] # (mm_hash, block_indices) quarantine cleared
|
||||
|
||||
|
||||
class ConsumerSession:
|
||||
"""Long-lived per-producer session on the consumer side.
|
||||
|
||||
Owns the ZMQ DEALER connection and the NIXL agent registration for one
|
||||
producer peer. The connector calls poll(messages, now) once per engine
|
||||
step with the raw bytes collected from this peer's DEALER socket.
|
||||
|
||||
poll() decodes XferAcks, dispatches them to the right ConsumerXfer,
|
||||
advances all active xfer state machines, and drains quarantined xfers.
|
||||
Results accumulate in internal sets; call take_results() to collect them.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
addr: PeerAddr,
|
||||
zmq_conn: ZmqClientConnection,
|
||||
transport: ZmqClientTransport,
|
||||
data: DataTransport,
|
||||
compat_hash: str,
|
||||
) -> None:
|
||||
self._addr = addr
|
||||
self._zmq = zmq_conn
|
||||
self._transport = transport
|
||||
self._data = data
|
||||
self._compat_hash = compat_hash
|
||||
self._session_id = str(
|
||||
uuid.uuid4()
|
||||
) # stable identity for (session_id:mm_hash) keys
|
||||
|
||||
self._nixl_agent_name: str | None = None
|
||||
self._nixl_metadata_bytes: bytes | None = None
|
||||
|
||||
self._xfers: dict[str, ConsumerXfer] = {} # mm_hash → xfer
|
||||
self._quarantined: list[ConsumerXfer] = [] # DMA pending, to poll
|
||||
self._newly_quarantined: set[str] = set() # quarantined since last take_results
|
||||
|
||||
self._completed: set[str] = set()
|
||||
self._tombstoned: set[str] = set()
|
||||
self._retryable: set[str] = set()
|
||||
self._settled: list[tuple[str, list[int]]] = []
|
||||
|
||||
self._encoder = msgspec.msgpack.Encoder()
|
||||
self._decoder = msgspec.msgpack.Decoder(XferAck)
|
||||
|
||||
@property
|
||||
def addr(self) -> PeerAddr:
|
||||
return self._addr
|
||||
|
||||
def start_xfer(
|
||||
self, mm_hash: str, block_indices: list[int], deadline: float
|
||||
) -> None:
|
||||
"""Create a ConsumerXfer and send the XferReq over the connection."""
|
||||
xfer = ConsumerXfer(
|
||||
mm_hash=mm_hash,
|
||||
block_indices=block_indices,
|
||||
addr=self._addr,
|
||||
deadline=deadline,
|
||||
data=self._data,
|
||||
consumer_session_id=self._session_id,
|
||||
)
|
||||
self._zmq.send(
|
||||
self._encoder.encode(
|
||||
XferReq(
|
||||
mm_hash=mm_hash,
|
||||
compatibility_hash=self._compat_hash,
|
||||
session_id=self._session_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
self._xfers[mm_hash] = xfer
|
||||
logger.debug(
|
||||
"EC Connector: XferReq sent mm_hash=%s to %s:%d",
|
||||
mm_hash,
|
||||
self._addr[0],
|
||||
self._addr[1],
|
||||
)
|
||||
|
||||
def poll(self, messages: list[bytes], now: float) -> None:
|
||||
"""Decode inbound XferAcks, dispatch to xfers, and advance all state machines.
|
||||
|
||||
messages: raw bytes from ZmqClientTransport.poll() for this peer.
|
||||
Results accumulate until take_results() is called.
|
||||
"""
|
||||
for raw in messages:
|
||||
try:
|
||||
ack = self._decoder.decode(raw)
|
||||
except (msgspec.DecodeError, msgspec.ValidationError):
|
||||
logger.warning(
|
||||
"EC Connector: dropped malformed XferAck from %s:%d",
|
||||
self._addr[0],
|
||||
self._addr[1],
|
||||
)
|
||||
continue
|
||||
self._dispatch_ack(ack)
|
||||
|
||||
for mm_hash, xfer in list(self._xfers.items()):
|
||||
state = xfer.poll(now)
|
||||
if state == XferState.DONE:
|
||||
self._completed.add(mm_hash)
|
||||
del self._xfers[mm_hash]
|
||||
elif state == XferState.ACK_TIMEOUT:
|
||||
# The scheduler bounds retries with a per-request deadline.
|
||||
logger.warning(
|
||||
"EC consumer: no XferAck for mm_hash=%s from %s:%d in time; "
|
||||
"will re-request",
|
||||
mm_hash,
|
||||
self._addr[0],
|
||||
self._addr[1],
|
||||
)
|
||||
self._retryable.add(mm_hash)
|
||||
del self._xfers[mm_hash]
|
||||
elif state == XferState.READ_FAILED:
|
||||
self._tombstoned.add(mm_hash)
|
||||
del self._xfers[mm_hash]
|
||||
elif state == XferState.QUARANTINED:
|
||||
# DMA may still be writing — caller must NOT free blocks.
|
||||
self._quarantined.append(xfer)
|
||||
self._newly_quarantined.add(mm_hash)
|
||||
del self._xfers[mm_hash]
|
||||
|
||||
self._drain_quarantine(now)
|
||||
|
||||
def _dispatch_ack(self, ack: XferAck) -> None:
|
||||
xfer = self._xfers.get(ack.mm_hash)
|
||||
if xfer is None or xfer.transfer_handle is not None:
|
||||
return # unknown mm_hash or duplicate ack
|
||||
|
||||
if ack.status != XferStatus.OK:
|
||||
logger.log(
|
||||
logging.INFO if ack.status in EXPECTED_NACKS else logging.WARNING,
|
||||
"EC Connector: NACK %s from %s:%d for mm_hash=%s",
|
||||
ack.status.name,
|
||||
self._addr[0],
|
||||
self._addr[1],
|
||||
ack.mm_hash,
|
||||
)
|
||||
del self._xfers[ack.mm_hash]
|
||||
# The producer announced this encoding and its save is still in
|
||||
# flight, so another attempt on a later step can be granted. Every
|
||||
# other NACK is terminal for this mm_hash.
|
||||
if ack.status in RETRYABLE_NACKS:
|
||||
self._retryable.add(ack.mm_hash)
|
||||
else:
|
||||
self._tombstoned.add(ack.mm_hash)
|
||||
return
|
||||
|
||||
try:
|
||||
agent_name = self._ensure_registered(ack.agent_metadata, ack.mem_descriptor)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"EC Connector: failed to register peer %s:%d for mm_hash=%s",
|
||||
self._addr[0],
|
||||
self._addr[1],
|
||||
ack.mm_hash,
|
||||
)
|
||||
del self._xfers[ack.mm_hash]
|
||||
self._tombstoned.add(ack.mm_hash)
|
||||
return
|
||||
|
||||
if not xfer.handle_ack(ack, agent_name):
|
||||
del self._xfers[ack.mm_hash]
|
||||
self._tombstoned.add(ack.mm_hash)
|
||||
|
||||
def _ensure_registered(self, metadata: bytes, mem_descriptor: bytes) -> str:
|
||||
"""Register or re-register the peer in DataTransport. Returns agent_name."""
|
||||
if self._nixl_agent_name is not None and self._nixl_metadata_bytes == metadata:
|
||||
return self._nixl_agent_name
|
||||
if self._nixl_agent_name is not None:
|
||||
logger.info(
|
||||
"EC Connector: producer %s:%d restarted; re-registering",
|
||||
self._addr[0],
|
||||
self._addr[1],
|
||||
)
|
||||
self._data.remove_remote_peer(self._nixl_agent_name)
|
||||
agent_name = self._data.add_remote_peer(metadata, mem_descriptor)
|
||||
self._nixl_agent_name = agent_name
|
||||
self._nixl_metadata_bytes = metadata
|
||||
logger.debug(
|
||||
"EC Connector: registered peer %s:%d agent=%s",
|
||||
self._addr[0],
|
||||
self._addr[1],
|
||||
agent_name,
|
||||
)
|
||||
return agent_name
|
||||
|
||||
def _drain_quarantine(self, now: float) -> None:
|
||||
still_pending: list[ConsumerXfer] = []
|
||||
for xfer in list(self._quarantined):
|
||||
state = xfer.poll(now)
|
||||
if state == XferState.SETTLED:
|
||||
self._settled.append((xfer.mm_hash, xfer.block_indices))
|
||||
else:
|
||||
still_pending.append(xfer)
|
||||
self._quarantined = still_pending
|
||||
|
||||
def on_peer_down(self) -> None:
|
||||
"""WAITING_ACK xfers → cancel (retry allowed); READING xfers → quarantine."""
|
||||
for mm_hash, xfer in list(self._xfers.items()):
|
||||
if xfer.transfer_handle is None:
|
||||
xfer.cancel()
|
||||
self._retryable.add(mm_hash)
|
||||
else:
|
||||
self._quarantined.append(xfer)
|
||||
self._newly_quarantined.add(mm_hash)
|
||||
self._xfers.clear()
|
||||
|
||||
def take_results(self) -> ConsumerSessionResults:
|
||||
"""Return and clear all accumulated results since the last call."""
|
||||
results = ConsumerSessionResults(
|
||||
completed=self._completed,
|
||||
tombstoned=self._tombstoned,
|
||||
quarantined=self._newly_quarantined,
|
||||
retryable=self._retryable,
|
||||
settled=self._settled,
|
||||
)
|
||||
self._completed = set()
|
||||
self._tombstoned = set()
|
||||
self._newly_quarantined = set()
|
||||
self._retryable = set()
|
||||
self._settled = []
|
||||
return results
|
||||
|
||||
def has_in_flight(self) -> bool:
|
||||
return bool(self._xfers) or bool(self._quarantined)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release all transfer handles, deregister NIXL peer, close connection.
|
||||
|
||||
Connection teardown is routed through the transport so the pooled
|
||||
connection is dropped in lock-step with the session — a session and its
|
||||
DEALER connection have a 1:1 lifecycle, so a later connect() to the same
|
||||
peer builds a fresh socket instead of handing back this dead one.
|
||||
"""
|
||||
for xfer in self._xfers.values():
|
||||
xfer.release()
|
||||
self._xfers.clear()
|
||||
for xfer in self._quarantined:
|
||||
xfer.release()
|
||||
self._quarantined.clear()
|
||||
if self._nixl_agent_name is not None:
|
||||
self._data.remove_remote_peer(self._nixl_agent_name)
|
||||
self._transport.remove(self._addr)
|
||||
@@ -1,47 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Supporting utilities for the ECCPUConnector scheduler."""
|
||||
|
||||
import msgspec
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
# (host, port) pair identifying a remote peer's ZMQ side-channel endpoint.
|
||||
PeerAddr = tuple[str, int]
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
# Msgpack (de)serialization for the list-of-(addr, size, device_id)
|
||||
# descriptor form that NIXL's `get_xfer_descs` expects.
|
||||
_MemDescList = list[tuple[int, int, int]]
|
||||
|
||||
_mem_desc_encoder = msgspec.msgpack.Encoder()
|
||||
_mem_desc_decoder = msgspec.msgpack.Decoder(_MemDescList)
|
||||
|
||||
|
||||
def serialize_mem_descriptor(descs: _MemDescList) -> bytes:
|
||||
return _mem_desc_encoder.encode(descs)
|
||||
|
||||
|
||||
def deserialize_mem_descriptor(payload: bytes) -> _MemDescList:
|
||||
return _mem_desc_decoder.decode(payload)
|
||||
|
||||
|
||||
def build_block_descs(
|
||||
base_ptr: int,
|
||||
num_blocks: int,
|
||||
block_size_bytes: int,
|
||||
device_id: int = 0,
|
||||
) -> _MemDescList:
|
||||
"""Per-block `(addr, size, device_id)` tuples for an `ECSharedRegion`.
|
||||
|
||||
Handed to `nixl.get_xfer_descs` to build a dlist where descriptor `i`
|
||||
addresses block `i` in the mmap. This makes a WRITE to block indices
|
||||
`[i1, i2, ...]` a simple `make_prepped_xfer(..., [i1, i2])` on both
|
||||
ends — no per-request address math.
|
||||
"""
|
||||
return [
|
||||
(base_ptr + i * block_size_bytes, block_size_bytes, device_id)
|
||||
for i in range(num_blocks)
|
||||
]
|
||||
@@ -1,455 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Worker-side of the ECCPUConnector.
|
||||
|
||||
Thin, stateless across steps: opens the shared mmap region and uses the
|
||||
per-step connector metadata (`ECCPUConnectorMetadata`) to decide which
|
||||
blocks to copy in each direction.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from itertools import chain
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from vllm._custom_ops import swap_blocks_batch
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.common import (
|
||||
ECCPUConnectorMetadata,
|
||||
ECCPUWorkerMetadata,
|
||||
create_ec_shared_region,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.cpu.worker.descriptor_buffers import (
|
||||
DescriptorBufferPool,
|
||||
DescriptorBuffers,
|
||||
)
|
||||
from vllm.distributed.parallel_state import (
|
||||
get_pcp_group,
|
||||
get_tensor_model_parallel_rank,
|
||||
)
|
||||
from vllm.logger import init_logger
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.utils.platform_utils import is_pin_memory_available
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.config import VllmConfig
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class ECCPUTransferDirection(Enum):
|
||||
HOST_TO_DEVICE = auto()
|
||||
DEVICE_TO_HOST = auto()
|
||||
|
||||
|
||||
def _coalesce_runs(
|
||||
block_ids: Sequence[int],
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Split block ids into runs that are consecutive in the region.
|
||||
|
||||
Returns `(slots, first_blocks, num_blocks)`, one element per run, where
|
||||
`slots` is the run's offset in the flat block sequence. Per-descriptor
|
||||
overhead dominates these copies, so describing a run with one descriptor
|
||||
rather than one per block is worth an order of magnitude.
|
||||
|
||||
Both callers derive the non-region side of a run from its slot, so that
|
||||
side advances in lockstep with the region and only the region has to be
|
||||
split. `EmbeddingCache` hands out ascending ids, which is what makes runs
|
||||
findable at all.
|
||||
"""
|
||||
ids = np.fromiter(block_ids, dtype=np.int64, count=len(block_ids))
|
||||
breaks = np.flatnonzero(np.diff(ids) != 1) + 1
|
||||
slots = np.concatenate(([0], breaks))
|
||||
ends = np.concatenate((breaks, [ids.size]))
|
||||
return slots, ids[slots], ends - slots
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transfer:
|
||||
"""A batched copy in flight on a dedicated GPU stream.
|
||||
|
||||
Its descriptor buffers and stream are held until `end_event` fires; only
|
||||
then are the buffers and stream safe to recycle and the `completions` safe
|
||||
to report. `start_event`/`end_event` bracket the copy so its elapsed time
|
||||
can be measured on completion.
|
||||
|
||||
`completions` is what the scheduler is told about this copy: mm_hashes for
|
||||
saves, transfer ids for loads.
|
||||
"""
|
||||
|
||||
start_event: torch.Event
|
||||
end_event: torch.Event
|
||||
completions: list[str] | list[int]
|
||||
bufs: DescriptorBuffers
|
||||
stream: torch.Stream
|
||||
num_bytes: int
|
||||
|
||||
|
||||
class ECCPUWorker:
|
||||
"""Worker-side delegate for the ECCPUConnector.
|
||||
|
||||
- Producer role: copies `encoder_cache[mm_hash]` → `mmap[block_ids]`
|
||||
for each entry in `metadata.saves`. Descriptor buffers are filled
|
||||
directly in `save_caches`; the actual DMA is issued as a single
|
||||
batched call in `flush_saves`.
|
||||
- Consumer role: copies `mmap[block_ids]` → `encoder_cache[mm_hash]`
|
||||
for all entries in `metadata.loads` via a single `swap_blocks_batch`
|
||||
call on a dedicated stream.
|
||||
- On `ec_both` nodes both paths run back-to-back in a single step.
|
||||
|
||||
Every batched copy runs on a stream drawn from a pool so it overlaps the
|
||||
compute stream, bracketed by a start/end event pair. The stream, events,
|
||||
and descriptor buffers are recycled once the end event fires.
|
||||
|
||||
Both paths move memory between the two streams, so each buffer is registered
|
||||
against the stream that is not its own: save sources against the save stream,
|
||||
load destinations against the compute stream. Without that, the caching
|
||||
allocator would be free to reissue memory a copy or a queued read still
|
||||
needs, since it only tracks the stream a buffer was allocated on.
|
||||
"""
|
||||
|
||||
def __init__(self, vllm_config: "VllmConfig") -> None:
|
||||
self._region = create_ec_shared_region(vllm_config)
|
||||
# Model dtype; used to reinterpret raw int8 blocks on load.
|
||||
self._dtype = vllm_config.model_config.dtype
|
||||
|
||||
self._pin_shared_region()
|
||||
|
||||
# All TP/PCP ranks hold identical encoder output. Only one rank
|
||||
# per mmap needs to write — saves host memory bandwidth.
|
||||
# DCP is a subdivision of TP, so tp_rank==0 covers it.
|
||||
self._is_save_rank = (
|
||||
get_tensor_model_parallel_rank() == 0 and get_pcp_group().rank_in_group == 0
|
||||
)
|
||||
|
||||
# Descriptor buffer pool (recycled across steps, shared by both paths).
|
||||
self._buf_pool = DescriptorBufferPool()
|
||||
|
||||
# Active save buffer being filled during save_caches calls this step.
|
||||
self._save_bufs: DescriptorBuffers | None = None
|
||||
self._save_count: int = 0
|
||||
# Bytes accumulated into the active save buffer this step, for logging.
|
||||
self._save_bytes: int = 0
|
||||
# mm_hashes accumulated into the active save buffer this step.
|
||||
self._save_mm_hashes: list[str] = []
|
||||
# Stream the active save buffer's copy will run on, held from the moment
|
||||
# the buffer opens so `save_caches` can register its sources against it.
|
||||
self._save_stream: torch.Stream | None = None
|
||||
|
||||
# Batched copies whose end event has not yet fired. Buffers and stream
|
||||
# stay held until the event fires; the completions are reported to the
|
||||
# scheduler only then. Separate per direction because saves and loads
|
||||
# can be in flight concurrently.
|
||||
self._inflight_saves: deque[Transfer] = deque()
|
||||
self._inflight_loads: deque[Transfer] = deque()
|
||||
|
||||
# Streams and timing events recycled across steps to avoid per-step
|
||||
# create/destroy churn. Shared by both directions.
|
||||
self._stream_pool: list[torch.Stream] = []
|
||||
self._event_pool: list[torch.Event] = []
|
||||
|
||||
def _acquire_stream(self) -> torch.Stream:
|
||||
if self._stream_pool:
|
||||
return self._stream_pool.pop()
|
||||
return current_platform.Stream()
|
||||
|
||||
def _acquire_event(self) -> torch.Event:
|
||||
return (
|
||||
self._event_pool.pop()
|
||||
if self._event_pool
|
||||
else torch.Event(enable_timing=True)
|
||||
)
|
||||
|
||||
def _collect_finished(self, inflight: deque[Transfer], direction: str) -> list:
|
||||
"""Pop transfers whose end event has fired, recycle their stream,
|
||||
events, and buffers, and return their completions.
|
||||
|
||||
The front check is conservative: a transfer is reported only once its
|
||||
own end event fires, so completions are always genuinely done, and each
|
||||
is reported exactly once because the transfer is popped as it is
|
||||
reported. A later transfer that happens to finish first simply waits
|
||||
behind the front and is reported on a subsequent poll.
|
||||
"""
|
||||
done: list = []
|
||||
while inflight and inflight[0].end_event.query():
|
||||
transfer = inflight.popleft()
|
||||
done.extend(transfer.completions)
|
||||
elapsed_ms = transfer.start_event.elapsed_time(transfer.end_event)
|
||||
logger.debug(
|
||||
"EC %s: %d entr%s (%d bytes) took %.3f ms",
|
||||
direction,
|
||||
len(transfer.completions),
|
||||
"y" if len(transfer.completions) == 1 else "ies",
|
||||
transfer.num_bytes,
|
||||
elapsed_ms,
|
||||
)
|
||||
self._buf_pool.release(transfer.bufs)
|
||||
self._stream_pool.append(transfer.stream)
|
||||
self._event_pool.append(transfer.start_event)
|
||||
self._event_pool.append(transfer.end_event)
|
||||
return done
|
||||
|
||||
def _pin_shared_region(self) -> None:
|
||||
"""Prepare the shared region for device transfers."""
|
||||
if is_pin_memory_available():
|
||||
self._region.pin_memory()
|
||||
|
||||
def _submit_transfer(
|
||||
self,
|
||||
bufs: DescriptorBuffers,
|
||||
count: int,
|
||||
direction: ECCPUTransferDirection,
|
||||
) -> None:
|
||||
"""Submit a transfer on the current backend stream."""
|
||||
src_ptrs = bufs.src_ptrs[:count]
|
||||
dst_ptrs = bufs.dst_ptrs[:count]
|
||||
sizes = bufs.sizes[:count]
|
||||
if direction == ECCPUTransferDirection.HOST_TO_DEVICE:
|
||||
swap_blocks_batch(
|
||||
src_ptrs,
|
||||
dst_ptrs,
|
||||
sizes,
|
||||
is_src_access_order_any=True,
|
||||
)
|
||||
else:
|
||||
swap_blocks_batch(src_ptrs, dst_ptrs, sizes)
|
||||
|
||||
def _shutdown_transfer_backend(self) -> None:
|
||||
"""Release backend-specific transfer resources."""
|
||||
|
||||
def save_caches(
|
||||
self,
|
||||
encoder_cache: dict[str, torch.Tensor],
|
||||
mm_hash: str,
|
||||
connector_metadata: ECCPUConnectorMetadata,
|
||||
) -> None:
|
||||
"""Fill descriptor buffers directly for batched flush.
|
||||
|
||||
Registers `encoder_cache[mm_hash]` against the stream the copy will run
|
||||
on, so the caching allocator will not hand that memory to another
|
||||
allocation before the copy has read it. The descriptor buffers hold raw
|
||||
addresses, and the caller is free to evict the entry as soon as this
|
||||
step's saves are dispatched.
|
||||
"""
|
||||
if not self._is_save_rank:
|
||||
return
|
||||
block_ids = connector_metadata.saves.get(mm_hash)
|
||||
if block_ids is None:
|
||||
return
|
||||
|
||||
src = encoder_cache[mm_hash]
|
||||
total_bytes = src.numel() * src.element_size()
|
||||
block_size = self._region.block_size_bytes
|
||||
allocated_bytes = len(block_ids) * block_size
|
||||
assert total_bytes <= allocated_bytes, (
|
||||
f"EC: encoder output exceeds allocated blocks for mm_hash={mm_hash}: "
|
||||
f"{total_bytes} bytes but only {allocated_bytes} allocated "
|
||||
f"({len(block_ids)} blocks × {block_size}). "
|
||||
f"shape={list(src.shape)}"
|
||||
)
|
||||
|
||||
if self._save_bufs is None:
|
||||
total = sum(len(v) for v in connector_metadata.saves.values())
|
||||
self._save_bufs = self._buf_pool.acquire(total)
|
||||
self._save_stream = self._acquire_stream()
|
||||
self._save_mm_hashes = []
|
||||
|
||||
assert self._save_count + len(block_ids) <= self._save_bufs.src_ptrs.numel()
|
||||
|
||||
bufs = self._save_bufs
|
||||
assert self._save_stream is not None
|
||||
src.record_stream(self._save_stream)
|
||||
# Device addresses are unsigned and can have the high bit set (XPU USM),
|
||||
# so they are added up in uint64; signed arithmetic rejects them.
|
||||
src_base = np.uint64(src.view(-1).view(torch.uint8).data_ptr())
|
||||
dst_base = self._region.blocks.data_ptr()
|
||||
# Copy only the tensor bytes, excluding any block padding.
|
||||
n_used = -(-total_bytes // block_size)
|
||||
if n_used:
|
||||
slots, first_blocks, num_blocks = _coalesce_runs(block_ids[:n_used])
|
||||
run_bytes = num_blocks * block_size
|
||||
# The output can stop partway into the final run.
|
||||
run_bytes[-1] = total_bytes - slots[-1] * block_size
|
||||
bufs.add_copies(
|
||||
self._save_count,
|
||||
src_base + slots.astype(np.uint64) * np.uint64(block_size),
|
||||
dst_base + first_blocks * block_size,
|
||||
run_bytes,
|
||||
)
|
||||
self._save_count += slots.size
|
||||
|
||||
self._save_bytes += total_bytes
|
||||
self._save_mm_hashes.append(mm_hash)
|
||||
|
||||
def flush_saves(self) -> None:
|
||||
"""Flush all accumulated saves in a single swap_blocks_batch call.
|
||||
|
||||
Runs the copy on the stream the batch acquired when it opened, gated
|
||||
behind the compute stream that produced the encoder outputs, brackets it
|
||||
with start/end events, and enqueues the batch as in-flight. The stream,
|
||||
events, and descriptor buffers are recycled once the end event fires (see
|
||||
`_collect_finished`), which is also when the saved mm_hashes become
|
||||
safe to mark ready.
|
||||
"""
|
||||
if self._save_count == 0:
|
||||
return
|
||||
|
||||
bufs = self._save_bufs
|
||||
stream = self._save_stream
|
||||
assert bufs is not None and stream is not None
|
||||
n = self._save_count
|
||||
num_bytes = self._save_bytes
|
||||
|
||||
# Gate the GPU→CPU copy behind the compute stream: it reads the encoder
|
||||
# outputs the model just produced there.
|
||||
stream.wait_stream(current_platform.current_stream())
|
||||
start_event = self._acquire_event()
|
||||
end_event = self._acquire_event()
|
||||
with current_platform.stream(stream):
|
||||
start_event.record(stream)
|
||||
self._submit_transfer(
|
||||
bufs,
|
||||
n,
|
||||
ECCPUTransferDirection.DEVICE_TO_HOST,
|
||||
)
|
||||
end_event.record(stream)
|
||||
|
||||
self._inflight_saves.append(
|
||||
Transfer(
|
||||
start_event=start_event,
|
||||
end_event=end_event,
|
||||
completions=self._save_mm_hashes,
|
||||
bufs=bufs,
|
||||
stream=stream,
|
||||
num_bytes=num_bytes,
|
||||
)
|
||||
)
|
||||
|
||||
self._save_bufs = None
|
||||
self._save_stream = None
|
||||
self._save_count = 0
|
||||
self._save_bytes = 0
|
||||
self._save_mm_hashes = []
|
||||
|
||||
def start_load_caches(
|
||||
self,
|
||||
encoder_cache: dict[str, torch.Tensor],
|
||||
connector_metadata: ECCPUConnectorMetadata,
|
||||
) -> None:
|
||||
"""Consumer path: single batched copy of all loads from mmap→GPU.
|
||||
|
||||
The destination is registered against the compute stream that consumes
|
||||
it, so the caching allocator will not hand that memory to a later load
|
||||
while reads of it are still queued there.
|
||||
"""
|
||||
if not connector_metadata.loads:
|
||||
return
|
||||
|
||||
block_size = self._region.block_size_bytes
|
||||
blocks = self._region.blocks
|
||||
dtype = self._dtype
|
||||
device_type = current_platform.device_type
|
||||
src_base = blocks.data_ptr()
|
||||
|
||||
# Copy every dispatched load. The scheduler routes an input here only
|
||||
# after missing the GPU encoder cache, so a resident hit is not expected;
|
||||
# copying unconditionally means every participating rank reports each
|
||||
# transfer exactly once, which is what the scheduler counts to release
|
||||
# the pin.
|
||||
# Ordered by first block so entries that neighbour each other in the
|
||||
# region land next to each other in the destination and coalesce into
|
||||
# one descriptor. Every loop below consumes this same order.
|
||||
load_items = sorted(
|
||||
connector_metadata.loads.items(), key=lambda kv: kv[1][1][0]
|
||||
)
|
||||
total_blocks = sum(len(block_ids) for _, (_, block_ids, _) in load_items)
|
||||
|
||||
stream = self._acquire_stream()
|
||||
compute_stream = current_platform.current_stream()
|
||||
start_event = self._acquire_event()
|
||||
end_event = self._acquire_event()
|
||||
with current_platform.stream(stream):
|
||||
# Single contiguous destination buffer for all loads.
|
||||
dst_buf = torch.empty(
|
||||
total_blocks, block_size, dtype=torch.int8, device=device_type
|
||||
)
|
||||
dst_buf.record_stream(compute_stream)
|
||||
# See save_caches: device addresses are added up in uint64.
|
||||
dst_buf_base = np.uint64(dst_buf.data_ptr())
|
||||
|
||||
bufs = self._buf_pool.acquire(total_blocks)
|
||||
slots, first_blocks, num_blocks = _coalesce_runs(
|
||||
list(chain.from_iterable(ids for _, (_, ids, _) in load_items))
|
||||
)
|
||||
bufs.add_copies(
|
||||
0,
|
||||
src_base + first_blocks * block_size,
|
||||
dst_buf_base + slots.astype(np.uint64) * np.uint64(block_size),
|
||||
num_blocks * block_size,
|
||||
)
|
||||
op_idx = slots.size
|
||||
|
||||
start_event.record(stream)
|
||||
self._submit_transfer(
|
||||
bufs,
|
||||
op_idx,
|
||||
ECCPUTransferDirection.HOST_TO_DEVICE,
|
||||
)
|
||||
end_event.record(stream)
|
||||
self._inflight_loads.append(
|
||||
Transfer(
|
||||
start_event=start_event,
|
||||
end_event=end_event,
|
||||
completions=[tid for _, (tid, _, _) in load_items],
|
||||
bufs=bufs,
|
||||
stream=stream,
|
||||
num_bytes=total_blocks * block_size,
|
||||
)
|
||||
)
|
||||
|
||||
# Slice contiguous buffer into per-hash views.
|
||||
offset = 0
|
||||
for mm_hash, (_, block_ids, shape) in load_items:
|
||||
n = len(block_ids)
|
||||
flat = dst_buf[offset : offset + n].view(dtype).reshape(-1)
|
||||
encoder_cache[mm_hash] = flat[: shape[0] * shape[1]].view(shape)
|
||||
offset += n
|
||||
|
||||
current_platform.current_stream().wait_stream(stream)
|
||||
|
||||
def build_connector_worker_meta(self) -> ECCPUWorkerMetadata | None:
|
||||
"""Report the GPU copies that completed on this rank this step: saved
|
||||
mm_hashes and loaded transfer ids.
|
||||
|
||||
Returns None when nothing finished, so the scheduler sees no payload.
|
||||
"""
|
||||
completed_saves = self._collect_finished(self._inflight_saves, "save")
|
||||
completed_loads = self._collect_finished(self._inflight_loads, "load")
|
||||
if not completed_saves and not completed_loads:
|
||||
return None
|
||||
return ECCPUWorkerMetadata(
|
||||
completed_saves=completed_saves,
|
||||
completed_loads=completed_loads,
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
for transfer in (*self._inflight_saves, *self._inflight_loads):
|
||||
transfer.end_event.synchronize()
|
||||
self._shutdown_transfer_backend()
|
||||
self._save_bufs = None
|
||||
self._save_stream = None
|
||||
self._save_count = 0
|
||||
self._save_bytes = 0
|
||||
self._save_mm_hashes = []
|
||||
self._inflight_saves.clear()
|
||||
self._inflight_loads.clear()
|
||||
self._stream_pool.clear()
|
||||
self._event_pool.clear()
|
||||
try:
|
||||
self._region.cleanup()
|
||||
except Exception:
|
||||
logger.debug("EC: worker region cleanup failed", exc_info=True)
|
||||
@@ -1,90 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Reusable pool of (src_ptrs, dst_ptrs, sizes) tensor triples.
|
||||
|
||||
Used by ECCPUWorker to batch swap_blocks_batch descriptors without
|
||||
per-step allocation overhead.
|
||||
"""
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
# CUDA/ROCm cache_kernels.cu requires int64 pointers; the XPU DMA engine
|
||||
# requires uint64 (see vllm._custom_ops.swap_blocks_batch).
|
||||
_PTR_DTYPE = torch.uint64 if current_platform.is_xpu() else torch.int64
|
||||
|
||||
|
||||
class DescriptorBuffers(NamedTuple):
|
||||
src_ptrs: torch.Tensor
|
||||
dst_ptrs: torch.Tensor
|
||||
sizes: torch.Tensor
|
||||
# Numpy aliases of the three tensors, written via add_copies().
|
||||
src_np: np.ndarray
|
||||
dst_np: np.ndarray
|
||||
sizes_np: np.ndarray
|
||||
|
||||
def add_copies(
|
||||
self,
|
||||
first: int,
|
||||
sources: np.ndarray,
|
||||
destinations: np.ndarray,
|
||||
byte_counts: np.ndarray,
|
||||
) -> None:
|
||||
"""Store a batch of copies in these buffers, the first at *first*.
|
||||
|
||||
One copy is one source address, one destination address, and a number
|
||||
of bytes. `swap_blocks_batch` performs every copy held in the buffers
|
||||
in a single call, so a caller fills in all of a step's copies before
|
||||
asking for any of them to run, and passes *first* to say where its own
|
||||
copies begin.
|
||||
|
||||
Whole batches go in at once because storing a single number into a
|
||||
torch tensor costs microseconds. Adding copies one at a time can take
|
||||
longer than performing them.
|
||||
|
||||
TODO(torch>=2.14): write the tensors directly and drop these numpy
|
||||
aliases once the minimum supported torch is 2.14. They exist only
|
||||
because torch's setitem unpacks the value as a signed long long before
|
||||
2.14 (pytorch#191458), rejecting XPU USM addresses >= 2**63, while the
|
||||
two's-complement rewrite those versions accept is in turn rejected by
|
||||
uint64 from 2.14 on. Numpy casts against the array's own type and so
|
||||
works on either. Byte counts are small enough to need none of this
|
||||
care, and use their alias only to match the addresses.
|
||||
"""
|
||||
last = first + len(sources)
|
||||
self.src_np[first:last] = sources
|
||||
self.dst_np[first:last] = destinations
|
||||
self.sizes_np[first:last] = byte_counts
|
||||
|
||||
|
||||
class DescriptorBufferPool:
|
||||
"""Pool of descriptor buffer triples for swap_blocks_batch.
|
||||
|
||||
Each buffer is a `DescriptorBuffers` namedtuple of three 1-D tensors
|
||||
(dtype `_PTR_DTYPE`, platform-dependent) of equal length, paired with
|
||||
numpy aliases used to fill them. Buffers are recycled across steps; if a
|
||||
returned buffer is too small it is discarded and a fresh one allocated.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# LIFO stack of idle buffer triples.
|
||||
self._pool: list[DescriptorBuffers] = []
|
||||
|
||||
def acquire(self, n: int) -> DescriptorBuffers:
|
||||
"""Get a buffer triple with capacity >= *n*."""
|
||||
if self._pool:
|
||||
bufs = self._pool.pop()
|
||||
if bufs.src_ptrs.numel() >= n:
|
||||
return bufs
|
||||
src, dst, sizes = (torch.empty(n, dtype=_PTR_DTYPE) for _ in range(3))
|
||||
return DescriptorBuffers(
|
||||
src, dst, sizes, src.numpy(), dst.numpy(), sizes.numpy()
|
||||
)
|
||||
|
||||
def release(self, bufs: DescriptorBuffers) -> None:
|
||||
"""Return a buffer triple to the pool for reuse."""
|
||||
self._pool.append(bufs)
|
||||
@@ -1,231 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import safetensors
|
||||
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed.ec_transfer.ec_connector.base import (
|
||||
ECConnectorBase,
|
||||
ECConnectorMetadata,
|
||||
ECConnectorRole,
|
||||
)
|
||||
from vllm.distributed.ec_transfer.ec_connector.utils import (
|
||||
PlaceholderMetadataResolver,
|
||||
collect_ec_item_metadata,
|
||||
)
|
||||
from vllm.logger import init_logger
|
||||
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
|
||||
from vllm.v1.core.sched.output import SchedulerOutput
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.v1.request import Request
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MMMeta:
|
||||
mm_hash: str
|
||||
num_token: int
|
||||
|
||||
@staticmethod
|
||||
def make_meta(mm_hash, num_token) -> "MMMeta":
|
||||
return MMMeta(mm_hash=mm_hash, num_token=num_token)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ECExampleConnectorMetadata(ECConnectorMetadata):
|
||||
mm_datas: list[MMMeta]
|
||||
|
||||
def __init__(self):
|
||||
self.mm_datas = []
|
||||
|
||||
def add_mm_data(self, mm_data: MMMeta):
|
||||
self.mm_datas.append(mm_data)
|
||||
|
||||
|
||||
class ECExampleConnector(ECConnectorBase):
|
||||
# NOTE: This is Simple debug implementation of the EC connector.
|
||||
# It save / load the EC cache to / from the disk.
|
||||
|
||||
def __init__(self, vllm_config: "VllmConfig", role: ECConnectorRole):
|
||||
super().__init__(vllm_config=vllm_config, role=role)
|
||||
# req_id -> index
|
||||
self._mm_datas_need_loads: dict[str, int] = {}
|
||||
self._metadata_resolver = PlaceholderMetadataResolver(vllm_config.model_config)
|
||||
transfer_config = vllm_config.ec_transfer_config
|
||||
if transfer_config is not None:
|
||||
self._storage_path = transfer_config.get_from_extra_config(
|
||||
"shared_storage_path", "/tmp"
|
||||
)
|
||||
logger.debug(transfer_config)
|
||||
logger.debug("Shared storage path is %s", self._storage_path)
|
||||
else:
|
||||
raise ValueError("ec_transfer_config must be set for ECConnectorBase")
|
||||
|
||||
def start_load_caches(self, encoder_cache, **kwargs) -> None:
|
||||
"""Start loading the cache from the connector into vLLM's encoder cache.
|
||||
|
||||
This method loads the encoder cache based on metadata provided by the scheduler.
|
||||
It is called before `_gather_mm_embeddings` for the EC Connector. For EC,
|
||||
the `encoder_cache` and `mm_hash` are stored in `kwargs`.
|
||||
|
||||
Args:
|
||||
encoder_cache (dict[str, torch.Tensor]): A dictionary mapping multimodal
|
||||
data hashes (`mm_hash`) to encoder cache tensors.
|
||||
kwargs (dict): Additional keyword arguments for the connector.
|
||||
|
||||
"""
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
# Get the metadata
|
||||
metadata: ECConnectorMetadata = self._get_connector_metadata()
|
||||
assert isinstance(metadata, ECExampleConnectorMetadata)
|
||||
assert encoder_cache is not None
|
||||
if metadata is None:
|
||||
logger.warning(
|
||||
"In connector.start_load_caches, but the connector metadata is None"
|
||||
)
|
||||
return
|
||||
# A bare "cuda" makes safetensors load onto cuda:0. Pin the load to the
|
||||
# selected device so tensor-parallel workers load the cache locally.
|
||||
device = current_platform.device_type
|
||||
if current_platform.is_cuda_alike():
|
||||
device = f"{device}:{current_platform.current_device()}"
|
||||
# Load the EC for each mm data
|
||||
for mm_data in metadata.mm_datas:
|
||||
if mm_data.mm_hash in encoder_cache:
|
||||
continue
|
||||
filename = self._generate_filename_debug(mm_data.mm_hash)
|
||||
with gpu_sync_allowed():
|
||||
ec_cache = safetensors.torch.load_file(filename, device=device)[
|
||||
"ec_cache"
|
||||
]
|
||||
encoder_cache[mm_data.mm_hash] = ec_cache
|
||||
logger.debug("Success load encoder cache for hash %s", mm_data.mm_hash)
|
||||
|
||||
def save_caches(self, encoder_cache, mm_hash, **kwargs) -> None:
|
||||
"""Save the encoder cache to the connector.
|
||||
|
||||
This method saves the encoder cache from the worker's local storage
|
||||
to shared storage or another external connector.
|
||||
|
||||
Args:
|
||||
encoder_cache (dict[str, torch.Tensor]): A dictionary mapping multimodal
|
||||
data hashes (`mm_hash`) to encoder cache tensors.
|
||||
mm_hash (str): The hash of the multimodal data whose cache is being saved.
|
||||
kwargs (dict): Additional keyword arguments for the connector.
|
||||
|
||||
"""
|
||||
# Return if it is PD Instance
|
||||
if not self.is_producer:
|
||||
return
|
||||
filename = self._generate_filename_debug(mm_hash)
|
||||
ec_cache = encoder_cache[mm_hash]
|
||||
with gpu_sync_allowed():
|
||||
tensors = {"ec_cache": ec_cache.detach().cpu()}
|
||||
safetensors.torch.save_file(tensors, filename)
|
||||
logger.debug("Save cache successful for mm_hash %s", mm_hash)
|
||||
|
||||
def has_cache_item(
|
||||
self,
|
||||
identifier: str,
|
||||
) -> bool:
|
||||
"""Check if cache exist externally for the media.
|
||||
|
||||
Args:
|
||||
identifier (str): the identifier of the media.
|
||||
|
||||
Returns:
|
||||
Bool indicate that media exists in cache or not
|
||||
|
||||
"""
|
||||
return self._found_match_for_mm_data(identifier)
|
||||
|
||||
def update_state_after_alloc(
|
||||
self,
|
||||
request: "Request",
|
||||
index: int,
|
||||
) -> None:
|
||||
"""Update ECConnector state after encoder cache allocation."""
|
||||
mm_hash = request.mm_features[index].identifier
|
||||
# Only load cache if it is consumer and cache exists
|
||||
if not self.is_consumer or not self.has_cache_item(mm_hash):
|
||||
return
|
||||
num_encoder_token = request.get_num_encoder_embeds(index)
|
||||
self._mm_datas_need_loads[mm_hash] = num_encoder_token
|
||||
|
||||
def build_connector_meta(
|
||||
self,
|
||||
scheduler_output: SchedulerOutput,
|
||||
) -> ECConnectorMetadata:
|
||||
"""Build the connector metadata for this step.
|
||||
|
||||
This function should NOT modify any fields in the scheduler_output.
|
||||
Also, calling this function will reset the state of the connector.
|
||||
This only build for load mm_data only
|
||||
Args:
|
||||
scheduler_output (SchedulerOutput): the scheduler output object.
|
||||
"""
|
||||
meta = ECExampleConnectorMetadata()
|
||||
for mm_hash, num_encoder_token in self._mm_datas_need_loads.items():
|
||||
meta.add_mm_data(MMMeta.make_meta(mm_hash, num_encoder_token))
|
||||
self._mm_datas_need_loads.clear()
|
||||
return meta
|
||||
|
||||
def request_finished(
|
||||
self,
|
||||
request: "Request",
|
||||
) -> tuple[bool, dict[str, Any] | None]:
|
||||
"""Report each item's cache key and grid so a consumer can skip the
|
||||
image transform.
|
||||
|
||||
A consumer only needs the grid to size the prompt's placeholder range;
|
||||
the embedding itself arrives through this connector. Reporting the grid
|
||||
the producer actually computed keeps the two sides in agreement without
|
||||
the caller re-deriving it from the raw media.
|
||||
"""
|
||||
if not self.is_producer:
|
||||
return False, None
|
||||
|
||||
items = collect_ec_item_metadata(request.mm_features, self._metadata_resolver)
|
||||
if not items:
|
||||
return False, None
|
||||
return False, items
|
||||
|
||||
# ==============================
|
||||
# Helper functions
|
||||
# ==============================
|
||||
|
||||
def _found_match_for_mm_data(self, mm_hash) -> bool:
|
||||
"""Check if the cache is hit for the request."""
|
||||
filename = self._generate_filename_debug(mm_hash, create_folder=False)
|
||||
return os.path.exists(filename)
|
||||
|
||||
def _generate_foldername_debug(
|
||||
self,
|
||||
mm_hash: str,
|
||||
create_folder: bool = True, # <- now defaults to True
|
||||
) -> str:
|
||||
"""Return the folder in which the cache for this mm_hash lives.
|
||||
If `create_folder` is True (default) the directory is created
|
||||
recursively the first time it is needed.
|
||||
"""
|
||||
foldername = os.path.join(self._storage_path, mm_hash)
|
||||
if create_folder:
|
||||
os.makedirs(foldername, exist_ok=True)
|
||||
return foldername
|
||||
|
||||
def _generate_filename_debug(
|
||||
self,
|
||||
mm_hash: str,
|
||||
create_folder: bool = True,
|
||||
) -> str:
|
||||
"""Return the full path of the safetensors file for this mm_hash.
|
||||
If `create_folder` is True (default) the parent directory is created.
|
||||
"""
|
||||
foldername = self._generate_foldername_debug(mm_hash, create_folder)
|
||||
return os.path.join(foldername, "encoder_cache.safetensors")
|
||||
@@ -47,7 +47,6 @@ def main():
|
||||
import vllm.entrypoints.cli.preload
|
||||
import vllm.entrypoints.cli.run_batch
|
||||
import vllm.entrypoints.cli.serve
|
||||
import vllm.entrypoints.cli.snapshot
|
||||
from vllm.entrypoints.serve.utils.api_utils import (
|
||||
VLLM_SUBCMD_PARSER_EPILOG,
|
||||
cli_env_setup,
|
||||
@@ -62,11 +61,9 @@ def main():
|
||||
vllm.entrypoints.cli.download_kernels,
|
||||
vllm.entrypoints.cli.preload,
|
||||
vllm.entrypoints.cli.run_batch,
|
||||
vllm.entrypoints.cli.snapshot,
|
||||
]
|
||||
|
||||
if sys.argv[1:2] != ["snapshot"]:
|
||||
cli_env_setup()
|
||||
cli_env_setup()
|
||||
|
||||
parser = FlexibleArgumentParser(
|
||||
description="vLLM CLI",
|
||||
@@ -81,12 +78,7 @@ def main():
|
||||
subparsers = parser.add_subparsers(required=False, dest="subparser")
|
||||
cmds = {}
|
||||
for cmd_module in CMD_MODULES:
|
||||
if cmd_module is vllm.entrypoints.cli.snapshot:
|
||||
new_cmds = cmd_module.cmd_init(
|
||||
create_requested=sys.argv[1:3] == ["snapshot", "create"]
|
||||
)
|
||||
else:
|
||||
new_cmds = cmd_module.cmd_init()
|
||||
new_cmds = cmd_module.cmd_init()
|
||||
for cmd in new_cmds:
|
||||
cmd.subparser_init(subparsers).set_defaults(dispatch_function=cmd.cmd)
|
||||
cmds[cmd.name] = cmd
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
from vllm.entrypoints.cli.types import CLISubcommand
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||
else:
|
||||
FlexibleArgumentParser = argparse.ArgumentParser
|
||||
|
||||
|
||||
def validate_create_args(args: argparse.Namespace) -> None:
|
||||
model = str(getattr(args, "model_tag", None) or args.model)
|
||||
revision = str(getattr(args, "revision", None) or "")
|
||||
if (
|
||||
Path(model).exists()
|
||||
or len(revision) != 40
|
||||
or any(character not in "0123456789abcdefABCDEF" for character in revision)
|
||||
):
|
||||
raise ValueError(
|
||||
"snapshot create requires an immutable remote model and "
|
||||
"40-character --revision"
|
||||
)
|
||||
parallel_sizes = (
|
||||
("tensor parallel", args.tensor_parallel_size),
|
||||
("pipeline parallel", args.pipeline_parallel_size),
|
||||
("data parallel", args.data_parallel_size),
|
||||
("prefill context parallel", args.prefill_context_parallel_size),
|
||||
)
|
||||
for name, size in parallel_sizes:
|
||||
if size != 1:
|
||||
raise ValueError(f"snapshot create currently requires {name} size 1")
|
||||
unsupported_frontend = (
|
||||
args.api_server_count not in (None, 1)
|
||||
or args.grpc
|
||||
or args.headless
|
||||
or args.api_key
|
||||
or os.environ.get("VLLM_API_KEY")
|
||||
or args.uds
|
||||
or args.middleware
|
||||
)
|
||||
if unsupported_frontend or any(
|
||||
(
|
||||
args.ssl_keyfile,
|
||||
args.ssl_certfile,
|
||||
args.ssl_ca_certs,
|
||||
args.enable_ssl_refresh,
|
||||
args.ssl_ciphers,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"snapshot create requires one unauthenticated plaintext HTTP server"
|
||||
)
|
||||
if args.logprobs_mode in {"raw_logits", "processed_logits"}:
|
||||
raise ValueError("snapshot canary requires a log-probability mode")
|
||||
if any(
|
||||
getattr(args, name, None) is not None
|
||||
for name in ("speculative_config", "spec_method", "spec_model", "spec_tokens")
|
||||
):
|
||||
raise ValueError("snapshot create does not support speculative decoding")
|
||||
if platform.system() != "Linux" or platform.machine() != "x86_64":
|
||||
raise ValueError("snapshot create requires Linux x86_64")
|
||||
|
||||
|
||||
def run_create(args: argparse.Namespace) -> None:
|
||||
validate_create_args(args)
|
||||
from vllm.snapshot.controller import create_snapshot
|
||||
|
||||
create_snapshot(args)
|
||||
|
||||
|
||||
def run_inspect(args: argparse.Namespace) -> None:
|
||||
from vllm.snapshot.manifest import inspect_snapshot
|
||||
|
||||
print(json.dumps(inspect_snapshot(Path(args.snapshot_dir)), indent=2))
|
||||
|
||||
|
||||
def run_restore(args: argparse.Namespace) -> None:
|
||||
from vllm.snapshot.controller import restore_snapshot
|
||||
|
||||
restore_snapshot(args)
|
||||
|
||||
|
||||
class SnapshotSubcommand(CLISubcommand):
|
||||
"""The `snapshot` subcommand for the vLLM CLI."""
|
||||
|
||||
name = "snapshot"
|
||||
|
||||
def __init__(self, *, create_requested: bool = False) -> None:
|
||||
self.create_requested = create_requested
|
||||
|
||||
@staticmethod
|
||||
def cmd(args: argparse.Namespace) -> None:
|
||||
args.snapshot_dispatch(args)
|
||||
|
||||
def subparser_init(
|
||||
self, subparsers: argparse._SubParsersAction
|
||||
) -> FlexibleArgumentParser:
|
||||
parser = subparsers.add_parser(
|
||||
self.name,
|
||||
help="Create, inspect, or restore an initialized vLLM snapshot.",
|
||||
usage="vllm snapshot <create|inspect|restore> [options]",
|
||||
)
|
||||
actions = parser.add_subparsers(required=True, dest="snapshot_action")
|
||||
|
||||
create_parser = actions.add_parser(
|
||||
"create", help="Create a snapshot from an initialized TP1 engine."
|
||||
)
|
||||
if self.create_requested:
|
||||
from vllm.entrypoints.launchers.cli_args import make_arg_parser
|
||||
|
||||
create_parser = make_arg_parser(create_parser)
|
||||
else:
|
||||
create_parser.add_argument("model_tag", nargs="?")
|
||||
create_parser.add_argument("--snapshot-dir", required=True)
|
||||
create_parser.set_defaults(snapshot_dispatch=run_create)
|
||||
|
||||
inspect_parser = actions.add_parser(
|
||||
"inspect", help="Inspect a snapshot without restoring it."
|
||||
)
|
||||
inspect_parser.add_argument("snapshot_dir")
|
||||
inspect_parser.set_defaults(snapshot_dispatch=run_inspect)
|
||||
|
||||
restore_parser = actions.add_parser(
|
||||
"restore", help="Restore a same-host TP1 snapshot."
|
||||
)
|
||||
restore_parser.add_argument("snapshot_dir")
|
||||
restore_parser.add_argument("--host", default=None)
|
||||
restore_parser.add_argument("--port", type=int, default=8000)
|
||||
restore_parser.set_defaults(snapshot_dispatch=run_restore)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def cmd_init(*, create_requested: bool = False) -> list[CLISubcommand]:
|
||||
return [SnapshotSubcommand(create_requested=create_requested)]
|
||||
@@ -1,2 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
@@ -1,314 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import functools
|
||||
import inspect
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm.config import get_cached_compilation_config
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.utils import maybe_disable_graph_partition
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Dictionary of all custom ops (classes, indexed by registered name).
|
||||
# To check if an op with a name is enabled, call .enabled() on the class.
|
||||
# Examples:
|
||||
# - MyOp.enabled()
|
||||
# - op_registry["my_op"].enabled()
|
||||
op_registry: dict[str, type["CustomOp"] | type["PluggableLayer"]] = {}
|
||||
op_registry_oot: dict[str, type["CustomOp"] | type["PluggableLayer"]] = {}
|
||||
|
||||
|
||||
def maybe_get_oot_by_class(class_type: type) -> type:
|
||||
class_name = class_type.__name__
|
||||
if class_name in op_registry_oot:
|
||||
return op_registry_oot[class_name]
|
||||
return class_type
|
||||
|
||||
|
||||
class PluggableLayer(nn.Module):
|
||||
"""Base class for pluggable layers.
|
||||
|
||||
A PluggableLayer is a *module-composing* abstraction: it may instantiate other
|
||||
``torch.nn.Module`` objects as sub-layers, and its functionality depends on
|
||||
these sub-layers following a generalized invocation sequence. Also, it is stateful
|
||||
and may hold parameters or buffers.
|
||||
|
||||
Unlike :class:`CustomOp`, PluggableLayer does NOT provide per-platform
|
||||
``forward_*`` dispatch. Instead, it supports out-of-tree (OOT) replacement
|
||||
of the entire layer class at instantiation time, allowing customized
|
||||
initialization and submodule composition.
|
||||
"""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
try:
|
||||
layer_class_name = cls.__name__
|
||||
except AttributeError:
|
||||
raise TypeError(
|
||||
f"Cannot instantiate '{cls.__name__}': its 'name' attribute "
|
||||
f"was not set, possibly because it was not decorated with "
|
||||
f"@PluggableLayer.register, or it's the PluggableLayer itself."
|
||||
) from None
|
||||
|
||||
if layer_class_name not in op_registry_oot:
|
||||
layer_cls_to_instantiate = cls
|
||||
else:
|
||||
layer_cls_to_instantiate = op_registry_oot[layer_class_name]
|
||||
logger.debug(
|
||||
"Instantiating pluggable layer: %s using %s",
|
||||
layer_class_name,
|
||||
str(layer_cls_to_instantiate),
|
||||
)
|
||||
return super().__new__(layer_cls_to_instantiate)
|
||||
|
||||
# Decorator to register pluggable layers.
|
||||
@classmethod
|
||||
def register(cls, name: str):
|
||||
def decorator(op_cls):
|
||||
assert name not in op_registry, f"Duplicate op name: {name}"
|
||||
op_cls.name = name
|
||||
op_registry[name] = op_cls
|
||||
return op_cls
|
||||
|
||||
return decorator
|
||||
|
||||
# Decorator to register out-of-tree(oot) pluggable layers.
|
||||
# For OOT pluggable layers:
|
||||
# if in-tree layer class is registered with an oot_custom_layer,
|
||||
# the oot_custom_layer will be used instead.
|
||||
@classmethod
|
||||
def register_oot(cls, _decorated_layer_cls=None, name: str | None = None):
|
||||
def decorator(layer_cls):
|
||||
reg_name = name if name is not None else cls.__name__
|
||||
assert reg_name not in op_registry_oot, f"Duplicate layer name: {reg_name}"
|
||||
layer_cls.name = reg_name
|
||||
op_registry_oot[reg_name] = layer_cls
|
||||
return layer_cls
|
||||
|
||||
if _decorated_layer_cls is None:
|
||||
# Called with parentheses: @PluggableLayer.register_oot()
|
||||
# or @PluggableLayer.register_oot(name="...")
|
||||
return decorator
|
||||
elif isinstance(_decorated_layer_cls, type): # Check if it's a class
|
||||
# Called without parentheses: @PluggableLayer.register_oot
|
||||
return decorator(_decorated_layer_cls)
|
||||
else:
|
||||
raise TypeError("Decorator can only be applied to classes.")
|
||||
|
||||
|
||||
class CustomOp(nn.Module):
|
||||
"""Base class for custom ops.
|
||||
Dispatches the forward method to the appropriate backend.
|
||||
"""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
try:
|
||||
op_name = cls.__name__
|
||||
except AttributeError:
|
||||
raise TypeError(
|
||||
f"Cannot instantiate '{cls.__name__}': its 'name' attribute "
|
||||
f"was not set, possibly because it was not decorated with "
|
||||
f"@CustomOp.register, or it's the CustomOp base class itself."
|
||||
) from None
|
||||
|
||||
if op_name not in op_registry_oot:
|
||||
op_cls_to_instantiate = cls
|
||||
else:
|
||||
op_cls_to_instantiate = op_registry_oot[op_name]
|
||||
logger.debug(
|
||||
"Instantiating custom op: %s using %s",
|
||||
op_name,
|
||||
str(op_cls_to_instantiate),
|
||||
)
|
||||
return super().__new__(op_cls_to_instantiate)
|
||||
|
||||
def __init__(self, *, enforce_enable: bool = False, compile_native: bool = False):
|
||||
super().__init__()
|
||||
self._enforce_enable = enforce_enable
|
||||
self._forward_method = self.dispatch_forward(compile_native=compile_native)
|
||||
|
||||
def forward(self, *args, **kwargs):
|
||||
return self._forward_method(*args, **kwargs)
|
||||
|
||||
def forward_native(self, *args, **kwargs):
|
||||
"""PyTorch-native implementation; OOT plugins override via
|
||||
:meth:`forward_oot`."""
|
||||
raise NotImplementedError
|
||||
|
||||
def forward_oot(self, *args, **kwargs):
|
||||
# By default, OOT ops fall back to the PyTorch-native implementation.
|
||||
return self.forward_native(*args, **kwargs)
|
||||
|
||||
def dispatch_forward(self, compile_native: bool):
|
||||
# The hw-agnostic CustomOp dispatches only between forward_native
|
||||
# (in-tree, used on every platform) and forward_oot (overridden by
|
||||
# an OOT plugin via register_oot).
|
||||
compilation_config = get_cached_compilation_config()
|
||||
|
||||
# NOTE(shen-shanshan): CustomOp object can be enforce enabled, e.g.,
|
||||
# enable device-specific kernels in ViT models when enabling graph
|
||||
# mode. By default, it will follow the compilation_config to determine
|
||||
# whether enable itself.
|
||||
# This enforce_enable mechanism will be removed after we adding a
|
||||
# separate compilation_config for multi-modal part.
|
||||
enabled = self._enforce_enable or self.enabled()
|
||||
if enabled:
|
||||
compilation_config.enabled_custom_ops.update([self.__class__.name])
|
||||
else:
|
||||
compilation_config.disabled_custom_ops.update([self.__class__.name])
|
||||
|
||||
if not enabled:
|
||||
# Compile forward_native to avoid eager torch ops if inside
|
||||
# opaque torch custom op (e.g. fused_moe, unified_attention, etc.)
|
||||
return self.maybe_compile(self.forward_native, enable=compile_native)
|
||||
|
||||
if current_platform.is_out_of_tree():
|
||||
return self.forward_oot
|
||||
return self.forward_native
|
||||
|
||||
def maybe_compile(self, fn, *, enable: bool = True):
|
||||
"""Compile fn if compilation enabled.
|
||||
Useful for CustomOp instances called from within a torch custom op,
|
||||
meaning the forward call is hidden from the model-level torch.compile.
|
||||
|
||||
NOTE: this does not enable fusion across ops, so opaque custom ops
|
||||
should still be unwrapped wherever possible.
|
||||
"""
|
||||
from vllm.config.compilation import CompilationMode
|
||||
|
||||
# Do not compile if compilation disabled
|
||||
if not enable:
|
||||
return fn
|
||||
|
||||
# Do not compile if global compilation disabled
|
||||
compilation_config = get_cached_compilation_config()
|
||||
if compilation_config.mode == CompilationMode.NONE:
|
||||
return fn
|
||||
|
||||
# If eager backend is used, do not compile either
|
||||
if compilation_config.backend == "eager":
|
||||
return fn
|
||||
|
||||
compile_options = maybe_disable_graph_partition(
|
||||
current_platform.simple_compile_backend
|
||||
)
|
||||
backend = current_platform.simple_compile_backend
|
||||
|
||||
dynamic_arg_dims = getattr(self.__class__, "_dynamic_arg_dims", None)
|
||||
if dynamic_arg_dims is not None:
|
||||
compiled_fn = torch.compile(
|
||||
fn,
|
||||
dynamic=False,
|
||||
backend=backend,
|
||||
options=compile_options,
|
||||
)
|
||||
sig = inspect.signature(fn)
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
bound = sig.bind(*args, **kwargs)
|
||||
bound.apply_defaults()
|
||||
for name, dims in dynamic_arg_dims.items():
|
||||
arg = bound.arguments.get(name)
|
||||
if arg is not None and isinstance(arg, torch.Tensor):
|
||||
dims_list = [dims] if isinstance(dims, int) else dims
|
||||
for d in dims_list:
|
||||
real_d = arg.ndim + d if d < 0 else d
|
||||
torch._dynamo.mark_dynamic(arg, real_d)
|
||||
return compiled_fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
# dynamic=True to avoid recompilations
|
||||
return torch.compile(
|
||||
fn,
|
||||
dynamic=True,
|
||||
backend=backend,
|
||||
options=compile_options,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def enabled(cls) -> bool:
|
||||
# if no name, then it was not registered
|
||||
compilation_config = get_cached_compilation_config()
|
||||
custom_ops = compilation_config.custom_ops
|
||||
if not hasattr(cls, "name"):
|
||||
logger.warning_once(
|
||||
"Custom op %s was not registered, which means it won't appear "
|
||||
"in the op registry. It will be enabled/disabled based on the "
|
||||
"global settings.",
|
||||
cls.__name__,
|
||||
)
|
||||
return CustomOp.default_on()
|
||||
|
||||
enabled = f"+{cls.name}" in custom_ops
|
||||
disabled = f"-{cls.name}" in custom_ops
|
||||
assert not (enabled and disabled), f"Cannot enable and disable {cls.name}"
|
||||
|
||||
return (CustomOp.default_on() or enabled) and not disabled
|
||||
|
||||
@staticmethod
|
||||
def default_on() -> bool:
|
||||
"""Behavior controlled by `CompilationConfig.custom_ops`: On by default if
|
||||
'all', off by default if 'none'.
|
||||
When PyTorch Inductor is used, 'none' is the default value,
|
||||
otherwise 'all'.
|
||||
"""
|
||||
compilation_config = get_cached_compilation_config()
|
||||
count_none = compilation_config.custom_ops.count("none")
|
||||
count_all = compilation_config.custom_ops.count("all")
|
||||
assert count_none + count_all == 1
|
||||
|
||||
return not count_none > 0 or count_all > 0
|
||||
|
||||
# Decorator to register custom ops.
|
||||
@classmethod
|
||||
def register(
|
||||
cls,
|
||||
name: str,
|
||||
dynamic_arg_dims: dict[str, int | list[int]] | None = None,
|
||||
):
|
||||
def decorator(op_cls):
|
||||
assert name not in op_registry, f"Duplicate op name: {name}"
|
||||
op_cls.name = name
|
||||
op_cls._dynamic_arg_dims = dynamic_arg_dims
|
||||
op_registry[name] = op_cls
|
||||
return op_cls
|
||||
|
||||
return decorator
|
||||
|
||||
# Decorator to register out-of-tree(oot) custom ops.
|
||||
# For OOT custom ops:
|
||||
# if in-tree layer class is registered with an oot_custom_op layer,
|
||||
# the oot_custom_op layer will be used instead.
|
||||
# Example:
|
||||
# - @UnquantizedFusedMoEMethod.register_oot
|
||||
# class HPUUnquantizedFusedMoEMethod(UnquantizedFusedMoEMethod)
|
||||
# or
|
||||
# - @CustomOP.register_oot(name="UnquantizedFusedMoEMethod")
|
||||
@classmethod
|
||||
def register_oot(cls, _decorated_op_cls=None, name: str | None = None):
|
||||
def decorator(op_cls):
|
||||
reg_name = name if name is not None else cls.__name__
|
||||
assert reg_name not in op_registry_oot, f"Duplicate op name: {reg_name}"
|
||||
op_cls.name = reg_name
|
||||
op_registry_oot[reg_name] = op_cls
|
||||
return op_cls
|
||||
|
||||
if _decorated_op_cls is None:
|
||||
# Called with parentheses: @CustomOP.register_oot()
|
||||
# or @CustomOP.register_oot(name="...")
|
||||
# So, _decorated_op_cls is None.
|
||||
# We return the actual decorator function.
|
||||
return decorator
|
||||
elif isinstance(_decorated_op_cls, type): # Check if it's a class
|
||||
# Called without parentheses: @CustomOP.register_oot
|
||||
# The first argument is the class itself.
|
||||
# We call the 'decorator' function immediately with the class.
|
||||
return decorator(_decorated_op_cls)
|
||||
else:
|
||||
# Handle other unexpected cases if necessary
|
||||
raise TypeError("Decorator can only be applied to classes.")
|
||||
@@ -1,2 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
@@ -1,27 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm.model_executor.hw_agnostic.custom_op import CustomOp
|
||||
|
||||
|
||||
@CustomOp.register("silu_and_mul")
|
||||
class SiluAndMul(CustomOp):
|
||||
"""SwiGLU: ``x -> silu(x[:d]) * x[d:]`` where ``d = x.shape[-1] // 2``."""
|
||||
|
||||
def forward_native(self, x: torch.Tensor) -> torch.Tensor:
|
||||
d = x.shape[-1] // 2
|
||||
return F.silu(x[..., :d]) * x[..., d:]
|
||||
|
||||
|
||||
# Activation-and-mul ops keyed by HF activation name.
|
||||
_ACTIVATION_AND_MUL_REGISTRY = {
|
||||
"silu": SiluAndMul,
|
||||
"swish": SiluAndMul,
|
||||
}
|
||||
|
||||
|
||||
def get_act_and_mul_fn(act_fn_name: str) -> CustomOp:
|
||||
"""Build the hw-agnostic activation-and-mul op named `act_fn_name`."""
|
||||
return _ACTIVATION_AND_MUL_REGISTRY[act_fn_name.lower()]()
|
||||
@@ -1,90 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch import Tensor
|
||||
|
||||
from vllm.model_executor.hw_agnostic.custom_op import CustomOp
|
||||
|
||||
|
||||
@CustomOp.register("rms_norm")
|
||||
class RMSNorm(CustomOp):
|
||||
"""``x -> w * x / sqrt(E[x^2] + eps)``. With ``residual``, fuses
|
||||
``residual += x`` then RMSNorm and returns ``(normalized, residual)``."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hidden_size: int,
|
||||
eps: float = 1e-6,
|
||||
var_hidden_size: int | None = None,
|
||||
has_weight: bool = True,
|
||||
dtype: torch.dtype | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.hidden_size = hidden_size
|
||||
self.variance_epsilon = eps
|
||||
self.variance_size_override = (
|
||||
None if var_hidden_size == hidden_size else var_hidden_size
|
||||
)
|
||||
weight_dtype = dtype or torch.get_default_dtype()
|
||||
self.has_weight = has_weight
|
||||
weight = torch.ones(hidden_size, dtype=weight_dtype)
|
||||
if has_weight:
|
||||
self.weight = nn.Parameter(weight)
|
||||
else:
|
||||
self.register_buffer("weight", weight, persistent=False)
|
||||
|
||||
def _rms_norm(
|
||||
self,
|
||||
x: Tensor,
|
||||
weight: Tensor | None,
|
||||
epsilon: float,
|
||||
variance_size: int | None = None,
|
||||
) -> Tensor:
|
||||
"""Weighted root-mean-square layer normalization"""
|
||||
orig_dtype = x.dtype
|
||||
x = x.to(torch.float32)
|
||||
x_var = x if variance_size is None else x[..., :variance_size]
|
||||
variance = x_var.pow(2).mean(dim=-1, keepdim=True)
|
||||
x = x * torch.rsqrt(variance + epsilon)
|
||||
if weight is not None:
|
||||
x = x.to(weight.dtype) * weight
|
||||
return x.to(orig_dtype)
|
||||
|
||||
def _fused_add_rms_norm(
|
||||
self,
|
||||
x: Tensor,
|
||||
x_residual: Tensor,
|
||||
weight: Tensor | None,
|
||||
epsilon: float,
|
||||
variance_size: int | None = None,
|
||||
) -> tuple[Tensor, Tensor]:
|
||||
"""Fused add and weighted root-mean-square layer normalization"""
|
||||
orig_dtype = x.dtype
|
||||
x = x.to(torch.float32)
|
||||
x = x + x_residual.to(torch.float32)
|
||||
x_residual = x.to(orig_dtype)
|
||||
|
||||
x_var = x if variance_size is None else x[..., :variance_size]
|
||||
variance = x_var.pow(2).mean(dim=-1, keepdim=True)
|
||||
x = x * torch.rsqrt(variance + epsilon)
|
||||
if weight is not None:
|
||||
x = x.to(weight.dtype) * weight
|
||||
return x.to(orig_dtype), x_residual
|
||||
|
||||
def forward_native(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
residual: torch.Tensor | None = None,
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
weight = self.weight if self.has_weight else None
|
||||
epsilon = self.variance_epsilon
|
||||
variance_size = self.variance_size_override
|
||||
|
||||
if residual is None:
|
||||
return self._rms_norm(x, weight, epsilon, variance_size)
|
||||
else:
|
||||
return self._fused_add_rms_norm(x, residual, weight, epsilon, variance_size)
|
||||
|
||||
def extra_repr(self) -> str:
|
||||
return f"hidden_size={self.hidden_size}, eps={self.variance_epsilon}"
|
||||
@@ -1,190 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Manual fusion of tensor-parallel all-reduce with the following GemmaRMSNorm.
|
||||
|
||||
Under tensor parallelism a ``RowParallelLinear`` (e.g. attention ``o_proj``)
|
||||
produces a per-rank partial sum that is all-reduced, and the result is then fed
|
||||
into a ``GemmaRMSNorm`` that adds the residual and normalizes. flashinfer ships a
|
||||
kernel that fuses all-reduce + residual-add + RMSNorm into a single launch; this
|
||||
helper drives it directly (no torch.compile pass) for models that run eager.
|
||||
|
||||
Scope: attention output only, no quantization. When the flashinfer fast path is
|
||||
not applicable (TP==1, flashinfer/NVSwitch unavailable, unsupported dtype, or an
|
||||
oversize batch) it falls back to ``all_reduce`` + ``GemmaRMSNorm``, which is
|
||||
numerically identical to the unfused model path.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.distributed.communication_op import tensor_model_parallel_all_reduce
|
||||
from vllm.distributed.parallel_state import (
|
||||
get_tensor_model_parallel_rank,
|
||||
get_tensor_model_parallel_world_size,
|
||||
get_tp_group,
|
||||
)
|
||||
from vllm.model_executor.layers.layernorm import GemmaRMSNorm
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
MiB = 1024 * 1024
|
||||
|
||||
# flashinfer fused all-reduce + RMSNorm is wired as a registered custom op in
|
||||
# allreduce_rms_fusion; both that op and the workspace helpers only exist when
|
||||
# flashinfer.comm.allreduce_fusion is importable.
|
||||
try:
|
||||
from vllm.compilation.passes.fusion.allreduce_rms_fusion import (
|
||||
flashinfer_trtllm_fused_allreduce_norm,
|
||||
)
|
||||
from vllm.distributed.device_communicators.flashinfer_all_reduce import (
|
||||
flashinfer_comm,
|
||||
get_fi_ar_workspace,
|
||||
)
|
||||
|
||||
_AR_RESIDUAL_RMS_NORM = (
|
||||
flashinfer_comm.AllReduceFusionPattern.kARResidualRMSNorm
|
||||
if flashinfer_comm is not None
|
||||
else None
|
||||
)
|
||||
except (ImportError, AttributeError):
|
||||
flashinfer_trtllm_fused_allreduce_norm = None # type: ignore[assignment]
|
||||
get_fi_ar_workspace = None # type: ignore[assignment]
|
||||
_AR_RESIDUAL_RMS_NORM = None
|
||||
|
||||
|
||||
_FI_SUPPORTED_DTYPES = (torch.bfloat16, torch.float16)
|
||||
|
||||
|
||||
def _max_token_num(tp_size: int, hidden_size: int, dtype: torch.dtype) -> int | None:
|
||||
"""Workspace token budget for flashinfer fused all-reduce, or None if the
|
||||
current world size / device is unsupported. Mirrors ``FlashInferAllReduce``."""
|
||||
from vllm.config.compilation import PassConfig
|
||||
|
||||
max_size_mb = PassConfig.default_fi_allreduce_fusion_max_size_mb().get(tp_size)
|
||||
if not max_size_mb:
|
||||
return None
|
||||
element_size = torch.tensor([], dtype=dtype).element_size()
|
||||
return int(max_size_mb * MiB) // (hidden_size * element_size)
|
||||
|
||||
|
||||
def _can_use_flashinfer(hidden_states: torch.Tensor, tp_size: int) -> tuple[bool, int]:
|
||||
"""Whether the flashinfer fused path applies; returns (ok, max_token_num)."""
|
||||
if (
|
||||
flashinfer_trtllm_fused_allreduce_norm is None
|
||||
or get_fi_ar_workspace is None
|
||||
or _AR_RESIDUAL_RMS_NORM is None
|
||||
):
|
||||
return False, 0
|
||||
if (
|
||||
not hidden_states.is_cuda
|
||||
or hidden_states.dim() != 2
|
||||
or not hidden_states.is_contiguous()
|
||||
or hidden_states.dtype not in _FI_SUPPORTED_DTYPES
|
||||
):
|
||||
return False, 0
|
||||
|
||||
num_tokens, hidden_size = hidden_states.shape
|
||||
max_token_num = _max_token_num(tp_size, hidden_size, hidden_states.dtype)
|
||||
if max_token_num is None or num_tokens > max_token_num:
|
||||
return False, 0
|
||||
|
||||
# Lazily create / fetch the (globally cached) workspace; returns None on
|
||||
# GPUs without NVSwitch, in which case we fall back gracefully.
|
||||
workspace = get_fi_ar_workspace(
|
||||
world_size=tp_size,
|
||||
rank=get_tensor_model_parallel_rank(),
|
||||
max_token_num=max_token_num,
|
||||
hidden_dim=hidden_size,
|
||||
dtype=hidden_states.dtype,
|
||||
group=get_tp_group().cpu_group,
|
||||
)
|
||||
if workspace is None:
|
||||
return False, 0
|
||||
# The token-count bound above uses the whole workspace budget, but a backend
|
||||
# may use only a fraction of it per call (mnnvl rotates through three Lamport
|
||||
# buffers). Ask the workspace so we don't admit tensors that the kernel will
|
||||
# reject with "The buffer size in the given workspace is insufficient".
|
||||
if not workspace.is_buffer_size_sufficient(
|
||||
tp_size=tp_size,
|
||||
num_tokens=num_tokens,
|
||||
hidden_dim=hidden_size,
|
||||
dtype=hidden_states.dtype,
|
||||
):
|
||||
return False, 0
|
||||
return True, max_token_num
|
||||
|
||||
|
||||
def _can_use_aiter_fused_ar_rms(hidden_states: torch.Tensor) -> bool:
|
||||
if not current_platform.is_rocm():
|
||||
return False
|
||||
from vllm._aiter_ops import rocm_aiter_ops
|
||||
|
||||
if not rocm_aiter_ops.is_custom_all_reduce_enabled():
|
||||
return False
|
||||
if (
|
||||
hidden_states.dim() != 2
|
||||
or not hidden_states.is_contiguous()
|
||||
or hidden_states.dtype not in _FI_SUPPORTED_DTYPES
|
||||
):
|
||||
return False
|
||||
aiter_ar = rocm_aiter_ops.get_aiter_allreduce()
|
||||
if aiter_ar is None or aiter_ar.disabled:
|
||||
return False
|
||||
total_bytes = hidden_states.numel() * hidden_states.element_size()
|
||||
if total_bytes > aiter_ar.effective_max_size():
|
||||
return False
|
||||
if not aiter_ar.should_custom_ar(hidden_states):
|
||||
return False
|
||||
return aiter_ar.use_1stage_fused_ar_rms(hidden_states)
|
||||
|
||||
|
||||
def fused_allreduce_gemma_rms_norm(
|
||||
hidden_states: torch.Tensor,
|
||||
residual: torch.Tensor,
|
||||
norm: GemmaRMSNorm,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""All-reduce ``hidden_states`` + add ``residual`` + GemmaRMSNorm, fused.
|
||||
|
||||
``hidden_states`` is the per-rank *partial* (un-reduced) output of a
|
||||
row-parallel linear; ``norm`` is the GemmaRMSNorm applied right after.
|
||||
Returns ``(normed_output, new_residual)``, equivalent to
|
||||
``norm(all_reduce(hidden_states), residual)``.
|
||||
"""
|
||||
tp_size = get_tensor_model_parallel_world_size()
|
||||
if tp_size == 1:
|
||||
# No all-reduce needed; identical to the unfused path.
|
||||
return norm(hidden_states, residual)
|
||||
|
||||
ok, max_token_num = _can_use_flashinfer(hidden_states, tp_size)
|
||||
if ok:
|
||||
norm_out = torch.empty_like(hidden_states)
|
||||
# With norm_out provided, the kernel writes the new residual
|
||||
# (all_reduce(hidden_states) + residual) into the hidden_states buffer
|
||||
# and the normalized result into norm_out, leaving `residual` untouched.
|
||||
flashinfer_trtllm_fused_allreduce_norm(
|
||||
allreduce_in=hidden_states,
|
||||
residual=residual,
|
||||
rms_gamma=norm.weight,
|
||||
rms_eps=norm.variance_epsilon,
|
||||
world_size=tp_size,
|
||||
weight_bias=1.0, # GemmaRMSNorm-style
|
||||
launch_with_pdl=True,
|
||||
fp32_acc=True,
|
||||
max_token_num=max_token_num,
|
||||
pattern_code=_AR_RESIDUAL_RMS_NORM,
|
||||
norm_out=norm_out,
|
||||
)
|
||||
return norm_out, hidden_states
|
||||
|
||||
if _can_use_aiter_fused_ar_rms(hidden_states):
|
||||
from vllm._aiter_ops import rocm_aiter_ops
|
||||
|
||||
return rocm_aiter_ops.get_fused_allreduce_rmsnorm_op()(
|
||||
input_=hidden_states,
|
||||
residual=residual,
|
||||
weight=norm.weight,
|
||||
epsilon=norm.variance_epsilon,
|
||||
gemma_norm=True,
|
||||
)
|
||||
|
||||
# Fallback: explicit all-reduce + GemmaRMSNorm (matches the unfused model).
|
||||
reduced = tensor_model_parallel_all_reduce(hidden_states)
|
||||
return norm(reduced, residual)
|
||||
@@ -1,857 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""DeepGEMM MegaMoE experts: fused EP dispatch, expert GEMMs and combine.
|
||||
|
||||
Each ``MEGA_MOE_*`` format is one row of the SM100 feature matrix. MX weights
|
||||
and activations carry one UE8M0 scale per 1x32 block. NVFP4 weights carry one
|
||||
E4M3 scale per 1x16 block plus a per-expert FP32 global scale; NVFP4 dispatch
|
||||
scales each token by its own FP32 scale.
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from inspect import signature
|
||||
from typing import Any, ClassVar, Literal
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
import vllm.envs as envs
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.distributed import get_ep_group
|
||||
from vllm.distributed.eplb.eplb_state import EplbLayerState
|
||||
from vllm.forward_context import get_forward_context, is_forward_context_available
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.layers.fused_moe.prepare_megamoe import (
|
||||
prepare_megamoe_inputs,
|
||||
prepare_megamoe_nvfp4_inputs,
|
||||
)
|
||||
from vllm.model_executor.layers.fused_moe.router.base_router import (
|
||||
eplb_map_to_physical_and_record,
|
||||
)
|
||||
from vllm.model_executor.layers.quantization.utils.quant_utils import (
|
||||
QuantKey,
|
||||
kMxfp8Dynamic,
|
||||
kNvfp4DynamicToken,
|
||||
)
|
||||
from vllm.model_executor.models.utils import extract_layer_index
|
||||
from vllm.model_executor.utils import set_weight_attrs
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.v1.worker.ubatching import dbo_current_ubatch_id
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MegaMoEActivation:
|
||||
"""Gated activation fused into the MegaMoE L1 epilogue.
|
||||
|
||||
``swiglu`` computes ``gate * sigmoid(alpha * gate) * (up + beta)`` after
|
||||
clamping gate from above and up symmetrically to ``clamp``; this covers
|
||||
plain, DeepSeek-V4 (``clamp``) and GPT-OSS style (``alpha``/``beta``)
|
||||
experts. ``situ`` is Kimi K3's SiTU with gate tanh scale ``alpha`` and
|
||||
linear tanh scale ``beta``; ``beta=0`` leaves the linear branch unscaled.
|
||||
"""
|
||||
|
||||
name: Literal["swiglu", "situ"] = "swiglu"
|
||||
clamp: float | None = None
|
||||
alpha: float = 1.0
|
||||
beta: float = 0.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.name not in ("swiglu", "situ"):
|
||||
raise ValueError(f"Unsupported MegaMoE activation {self.name!r}.")
|
||||
if not (math.isfinite(self.alpha) and math.isfinite(self.beta)):
|
||||
raise ValueError(f"MegaMoE activation scales must be finite: {self}.")
|
||||
if self.clamp is not None and not self.clamp >= 0:
|
||||
raise ValueError(f"MegaMoE activation clamp must be >= 0: {self}.")
|
||||
if self.name == "situ" and (
|
||||
self.clamp is not None or self.alpha <= 0 or self.beta < 0
|
||||
):
|
||||
raise ValueError(
|
||||
"SiTU takes no clamp, a positive alpha and a non-negative beta; "
|
||||
f"got {self}."
|
||||
)
|
||||
|
||||
def kernel_kwargs(self) -> dict[str, Any]:
|
||||
# Defaults stay implicit so DeepGEMM builds without alpha/beta still work.
|
||||
kwargs: dict[str, Any] = {"activation_clamp": self.clamp}
|
||||
if self.name != "swiglu":
|
||||
kwargs["activation"] = self.name
|
||||
if self.alpha != 1.0:
|
||||
kwargs["activation_alpha"] = self.alpha
|
||||
if self.beta != 0.0:
|
||||
kwargs["activation_beta"] = self.beta
|
||||
return kwargs
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MegaMoEWeightSpec:
|
||||
"""Storage of one MegaMoE weight in the kernel layout."""
|
||||
|
||||
dtype: torch.dtype
|
||||
# Weight values packed per storage element along K (2 for FP4).
|
||||
values_per_element: int = 1
|
||||
# K values per block scale; None for unscaled weights.
|
||||
scale_group_k: int | None = None
|
||||
# Block scale dtype: UE8M0 (MX) or E4M3 (NVFP4).
|
||||
scale_dtype: torch.dtype | None = None
|
||||
|
||||
@property
|
||||
def has_global_scale(self) -> bool:
|
||||
"""E4M3 block scales pair with an FP32 per-tensor scale."""
|
||||
return self.scale_dtype == torch.float8_e4m3fn
|
||||
|
||||
|
||||
_MXFP4_WEIGHT = MegaMoEWeightSpec(
|
||||
torch.int8,
|
||||
values_per_element=2,
|
||||
scale_group_k=32,
|
||||
scale_dtype=torch.float8_e8m0fnu,
|
||||
)
|
||||
_MXFP8_WEIGHT = MegaMoEWeightSpec(
|
||||
torch.float8_e4m3fn, scale_group_k=32, scale_dtype=torch.float8_e8m0fnu
|
||||
)
|
||||
_NVFP4_WEIGHT = MegaMoEWeightSpec(
|
||||
torch.int8,
|
||||
values_per_element=2,
|
||||
scale_group_k=16,
|
||||
scale_dtype=torch.float8_e4m3fn,
|
||||
)
|
||||
_BF16_WEIGHT = MegaMoEWeightSpec(torch.bfloat16)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MegaMoEWeightFormat:
|
||||
"""One routed-expert precision of the MegaMoE feature matrix."""
|
||||
|
||||
name: str
|
||||
# DeepGEMM MMA tag; it also keys the symmetric-buffer layout.
|
||||
mma_type: str
|
||||
kernel: str
|
||||
routed: MegaMoEWeightSpec
|
||||
# Precisions a shared expert may have to run inside the same launch.
|
||||
shared: tuple[MegaMoEWeightSpec, ...]
|
||||
# Quantization of the dispatched hidden states; None dispatches BF16.
|
||||
hidden_quant: QuantKey | None
|
||||
activations: frozenset[str]
|
||||
|
||||
@property
|
||||
def uses_nvfp4_api(self) -> bool:
|
||||
"""``nvfp4_mega_moe`` has its own symmetric buffer, BLOCK_M and scales."""
|
||||
return self.kernel == "nvfp4_mega_moe"
|
||||
|
||||
|
||||
MEGA_MOE_MXFP4 = MegaMoEWeightFormat(
|
||||
name="mxfp4",
|
||||
mma_type="fp8xfp4",
|
||||
kernel="fp8_fp4_mega_moe",
|
||||
routed=_MXFP4_WEIGHT,
|
||||
shared=(_MXFP8_WEIGHT,),
|
||||
hidden_quant=kMxfp8Dynamic,
|
||||
activations=frozenset({"swiglu", "situ"}),
|
||||
)
|
||||
MEGA_MOE_MXFP8 = MegaMoEWeightFormat(
|
||||
name="mxfp8",
|
||||
mma_type="fp8xfp8",
|
||||
kernel="fp8_fp4_mega_moe",
|
||||
routed=_MXFP8_WEIGHT,
|
||||
shared=(_MXFP8_WEIGHT,),
|
||||
hidden_quant=kMxfp8Dynamic,
|
||||
activations=frozenset({"swiglu", "situ"}),
|
||||
)
|
||||
MEGA_MOE_NVFP4 = MegaMoEWeightFormat(
|
||||
name="nvfp4",
|
||||
mma_type="nvfp4",
|
||||
kernel="nvfp4_mega_moe",
|
||||
routed=_NVFP4_WEIGHT,
|
||||
shared=(_MXFP8_WEIGHT, _BF16_WEIGHT),
|
||||
hidden_quant=kNvfp4DynamicToken,
|
||||
activations=frozenset({"swiglu"}),
|
||||
)
|
||||
MEGA_MOE_BF16 = MegaMoEWeightFormat(
|
||||
name="bf16",
|
||||
mma_type="bf16xbf16",
|
||||
kernel="bf16_mega_moe",
|
||||
routed=_BF16_WEIGHT,
|
||||
shared=(_BF16_WEIGHT,),
|
||||
hidden_quant=None,
|
||||
activations=frozenset({"swiglu"}),
|
||||
)
|
||||
MEGA_MOE_WEIGHT_FORMATS = {
|
||||
fmt.name: fmt
|
||||
for fmt in (MEGA_MOE_MXFP4, MEGA_MOE_MXFP8, MEGA_MOE_NVFP4, MEGA_MOE_BF16)
|
||||
}
|
||||
|
||||
|
||||
def check_deep_gemm_mega_moe_supported(
|
||||
device: torch.device, hidden_size: int, intermediate_size: int
|
||||
) -> None:
|
||||
"""Raise unless DeepGEMM MegaMoE can run on ``device`` with these shapes.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the device architecture is unsupported.
|
||||
ValueError: If the shapes are unsupported.
|
||||
|
||||
"""
|
||||
if not current_platform.is_device_capability_family(
|
||||
100, device_id=device.index or 0
|
||||
):
|
||||
raise NotImplementedError("DeepGEMM MegaMoE requires SM100 GPUs.")
|
||||
if hidden_size % 128 != 0 or intermediate_size % 128 != 0:
|
||||
raise ValueError(
|
||||
"DeepGEMM MegaMoE requires hidden and intermediate sizes "
|
||||
"to be multiples of 128."
|
||||
)
|
||||
|
||||
|
||||
def _linear_weight_scale(linear: nn.Module) -> torch.Tensor | None:
|
||||
scale = getattr(linear, "weight_scale", None)
|
||||
return getattr(linear, "weight_scale_inv", None) if scale is None else scale
|
||||
|
||||
|
||||
def _matches_weight_spec(linear: nn.Module, spec: MegaMoEWeightSpec) -> bool:
|
||||
"""Whether a checkpoint linear can be written into a ``spec`` weight."""
|
||||
weight, scale = linear.weight, _linear_weight_scale(linear)
|
||||
if weight.dtype != spec.dtype or weight.dim() != 2:
|
||||
return False
|
||||
if spec.scale_group_k is None:
|
||||
return scale is None
|
||||
if scale is None or scale.dim() != 2:
|
||||
return False
|
||||
(rows, cols), (scale_rows, scale_cols) = weight.shape, scale.shape
|
||||
scale_dtypes = {spec.scale_dtype}
|
||||
if spec.scale_dtype == torch.float8_e8m0fnu:
|
||||
scale_dtypes.add(torch.uint8)
|
||||
return (
|
||||
scale.dtype in scale_dtypes
|
||||
and rows % scale_rows == 0
|
||||
and cols % scale_cols == 0
|
||||
and (cols // scale_cols) % spec.scale_group_k == 0
|
||||
)
|
||||
|
||||
|
||||
class DeepGemmMegaMoEExperts(nn.Module):
|
||||
"""Routed experts, and optionally the shared expert, as one MegaMoE launch.
|
||||
|
||||
Parameters are registered in the kernel layout and every checkpoint shard is
|
||||
written straight into it, so loading (and reloading) needs no post-load
|
||||
transform. Whether the shared expert runs inside the launch is decided here,
|
||||
before any weight loads; a fused layer owns the shared-expert weights and
|
||||
the caller drops its shared MLP.
|
||||
|
||||
NVFP4 experts also take per-expert ``weight_scale_2`` (``w1`` and ``w3``
|
||||
must match: one L1 scale per expert) and ``w2`` ``input_scale``; the kernel
|
||||
quantizes every L2 input with the max of the latter over all loaded
|
||||
experts. Dispatch uses per-token scales, so ``w1``/``w3`` ``input_scale``
|
||||
are not loaded.
|
||||
|
||||
Args:
|
||||
num_experts: Physical experts across EP ranks, redundant included.
|
||||
activation: Activation fused into the L1 epilogue.
|
||||
weight_format: Routed-expert precision, a row of the feature matrix.
|
||||
shared_experts: Shared MLP to fuse if it is unsharded and in one of
|
||||
the ``weight_format.shared`` precisions; ``None`` never fuses.
|
||||
|
||||
"""
|
||||
|
||||
_symm_buffers: ClassVar[dict[tuple, Any]] = {}
|
||||
_synchronized_ep_groups: ClassVar[set[tuple[int, int]]] = set()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vllm_config: VllmConfig,
|
||||
*,
|
||||
num_experts: int,
|
||||
num_local_experts: int,
|
||||
experts_start_idx: int,
|
||||
top_k: int,
|
||||
hidden_size: int,
|
||||
intermediate_size: int,
|
||||
activation: MegaMoEActivation,
|
||||
weight_format: MegaMoEWeightFormat,
|
||||
shared_experts: nn.Module | None = None,
|
||||
num_logical_experts: int | None = None,
|
||||
fast_math: bool = True,
|
||||
prefix: str = "",
|
||||
):
|
||||
super().__init__()
|
||||
if activation.name not in weight_format.activations:
|
||||
raise NotImplementedError(
|
||||
f"DeepGEMM MegaMoE {weight_format.name} experts support "
|
||||
f"{sorted(weight_format.activations)}, not {activation.name!r}."
|
||||
)
|
||||
device = torch.get_default_device()
|
||||
if device.type != current_platform.device_type: # meta under ipc_cache
|
||||
device = torch.device(
|
||||
current_platform.device_type, torch.accelerator.current_device_index()
|
||||
)
|
||||
check_deep_gemm_mega_moe_supported(device, hidden_size, intermediate_size)
|
||||
|
||||
self.prefix = prefix
|
||||
self.capture_fn: Callable[[torch.Tensor], None] | None = None
|
||||
self.num_experts = num_experts
|
||||
self.num_local_experts = num_local_experts
|
||||
self.experts_start_idx = experts_start_idx
|
||||
self.experts_end_idx = experts_start_idx + num_local_experts
|
||||
self.num_logical_experts = (
|
||||
num_logical_experts if num_logical_experts is not None else num_experts
|
||||
)
|
||||
self.top_k = top_k
|
||||
self.hidden_size = hidden_size
|
||||
self.intermediate_size = intermediate_size
|
||||
self.max_num_tokens = vllm_config.scheduler_config.max_num_batched_tokens
|
||||
self.weight_format = weight_format
|
||||
self.activation = activation
|
||||
self.fast_math = fast_math
|
||||
self._kernel_kwargs = activation.kernel_kwargs()
|
||||
self.eplb_state = EplbLayerState()
|
||||
# L2 input scale of every logical expert; the NVFP4 kernel takes the max.
|
||||
self._w2_input_scales = torch.zeros(self.num_logical_experts, device="cpu")
|
||||
self.l2_activation_scale = 1.0
|
||||
# The first of each expert's w1/w3 weight_scale_2 until the other loads.
|
||||
self._pending_l1_scales: dict[int, tuple[str, float]] = {}
|
||||
|
||||
self.num_shared_experts, self.shared_spec = self._fusable_shared_experts(
|
||||
shared_experts
|
||||
)
|
||||
self._create_weights()
|
||||
for name, param in self.named_parameters(recurse=False):
|
||||
shared = name.startswith("shared_")
|
||||
loader = self.shared_weight_loader if shared else self.weight_loader
|
||||
set_weight_attrs(param, {"weight_loader": loader})
|
||||
if not shared and name.endswith("weight_scale"):
|
||||
param.quant_method = "block"
|
||||
|
||||
# Register in the static forward context so the custom-op wrapper
|
||||
# can look up this module by name from within a torch.compile graph.
|
||||
compilation_config = vllm_config.compilation_config
|
||||
if prefix in compilation_config.static_forward_context:
|
||||
raise ValueError(f"Duplicate layer name: {prefix}")
|
||||
compilation_config.static_forward_context[prefix] = self
|
||||
|
||||
@property
|
||||
def has_fused_shared_experts(self) -> bool:
|
||||
return self.num_shared_experts > 0
|
||||
|
||||
def _fusable_shared_experts(
|
||||
self, shared_experts: nn.Module | None
|
||||
) -> tuple[int, MegaMoEWeightSpec | None]:
|
||||
"""How many shared experts the launch fuses, and in which precision."""
|
||||
if shared_experts is None:
|
||||
return 0, None
|
||||
gate_up, down = shared_experts.gate_up_proj, shared_experts.down_proj
|
||||
hidden, inter = self.hidden_size, self.intermediate_size
|
||||
shared_size = down.weight.shape[-1]
|
||||
spec = next(
|
||||
(
|
||||
spec
|
||||
for spec in self.weight_format.shared
|
||||
if all(_matches_weight_spec(lin, spec) for lin in (gate_up, down))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if (
|
||||
tuple(gate_up.weight.shape) != (2 * shared_size, hidden)
|
||||
or tuple(down.weight.shape) != (hidden, shared_size)
|
||||
or shared_size % inter
|
||||
):
|
||||
reason = (
|
||||
f"needs an unsharded shared MLP over hidden size {hidden} with a "
|
||||
f"multiple of {inter} intermediate; got {tuple(gate_up.weight.shape)}"
|
||||
f" and {tuple(down.weight.shape)}"
|
||||
)
|
||||
elif spec is None:
|
||||
scale = _linear_weight_scale(gate_up)
|
||||
supported = ", ".join(
|
||||
f"{s.dtype}{' with 1x32 UE8M0 scales' if s.scale_group_k else ''}"
|
||||
for s in self.weight_format.shared
|
||||
)
|
||||
reason = (
|
||||
f"{self.weight_format.name} experts fuse {supported} shared "
|
||||
f"experts; got weight {gate_up.weight.dtype} and scale "
|
||||
f"{None if scale is None else (tuple(scale.shape), scale.dtype)}"
|
||||
)
|
||||
elif not self._kernel_fuses_shared_experts(spec):
|
||||
reason = (
|
||||
"the installed DeepGEMM Python API is older than the vLLM source. "
|
||||
"Rebuild the vendored _deep_gemm_C extension to enable it"
|
||||
)
|
||||
else:
|
||||
return shared_size // inter, spec
|
||||
logger.warning_once("Disabling MegaMoE shared-expert fusion: %s.", reason)
|
||||
return 0, None
|
||||
|
||||
def _kernel_fuses_shared_experts(self, spec: MegaMoEWeightSpec) -> bool:
|
||||
from vllm.utils.deep_gemm import _import_deep_gemm
|
||||
|
||||
deep_gemm = _import_deep_gemm()
|
||||
fmt = self.weight_format
|
||||
buffer_api = "get_symm_buffer_for_mega_moe"
|
||||
block_m_api = "get_block_m_for_mega_moe"
|
||||
if fmt.uses_nvfp4_api:
|
||||
buffer_api = "NVFP4SymmBuffer"
|
||||
block_m_api = "get_block_m_for_nvfp4_mega_moe"
|
||||
try:
|
||||
api = {
|
||||
*signature(getattr(deep_gemm, buffer_api)).parameters,
|
||||
*signature(getattr(deep_gemm, fmt.kernel)).parameters,
|
||||
}
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return False
|
||||
# Scaled shared inputs are laid out by the runtime BLOCK_M.
|
||||
return {
|
||||
"num_shared_experts",
|
||||
"shared_l1_weights",
|
||||
"shared_l2_weights",
|
||||
} <= api and (spec.scale_group_k is None or hasattr(deep_gemm, block_m_api))
|
||||
|
||||
def _create_weights(self) -> None:
|
||||
"""Register the routed and fused shared-expert parameters.
|
||||
|
||||
Routed parameters are indexed by local expert on dim 0, which EPLB moves.
|
||||
"""
|
||||
e, h, i = self.num_local_experts, self.hidden_size, self.intermediate_size
|
||||
fmt = self.weight_format
|
||||
self._register_weight("w13", (e, 2 * i, h), fmt.routed)
|
||||
self._register_weight("w2", (e, h, i), fmt.routed)
|
||||
if fmt.routed.has_global_scale:
|
||||
# w13_weight_scale_2 is the kernel's L1 alpha (per-token dispatch).
|
||||
for name in ("w13_weight_scale_2", "w2_weight_scale_2", "w2_input_scale"):
|
||||
scale = torch.zeros(e, dtype=torch.float32)
|
||||
self.register_parameter(name, nn.Parameter(scale, requires_grad=False))
|
||||
# w2 global weight scale times the L2 activation scale, per launch.
|
||||
l2_alpha = torch.zeros(e, dtype=torch.float32)
|
||||
self.register_buffer("_l2_alpha", l2_alpha, persistent=False)
|
||||
if self.shared_spec is not None:
|
||||
# The shared expert has the layout of one routed expert slice.
|
||||
s = i * self.num_shared_experts
|
||||
self._register_weight("shared_l1", (2 * s, h), self.shared_spec)
|
||||
self._register_weight("shared_l2", (h, s), self.shared_spec)
|
||||
|
||||
def _register_weight(
|
||||
self, name: str, shape: tuple[int, ...], spec: MegaMoEWeightSpec
|
||||
) -> None:
|
||||
def register(name: str, data: torch.Tensor) -> None:
|
||||
self.register_parameter(name, nn.Parameter(data, requires_grad=False))
|
||||
|
||||
*outer, n, k = shape
|
||||
register(
|
||||
f"{name}_weight",
|
||||
torch.zeros(*outer, n, k // spec.values_per_element, dtype=spec.dtype),
|
||||
)
|
||||
if spec.scale_group_k is not None:
|
||||
# Block scales, MN-major, four per int32 along K.
|
||||
scale_k = k // (4 * spec.scale_group_k)
|
||||
scale = torch.zeros(*outer, scale_k, n, dtype=torch.int32)
|
||||
register(f"{name}_weight_scale", scale.transpose(-1, -2))
|
||||
|
||||
def _kernel_weights(
|
||||
self, name: str
|
||||
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
weight = getattr(self, f"{name}_weight")
|
||||
scale = getattr(self, f"{name}_weight_scale", None)
|
||||
return weight if scale is None else (weight, scale)
|
||||
|
||||
@property
|
||||
def l1_weights(self) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
return self._kernel_weights("w13")
|
||||
|
||||
@property
|
||||
def l2_weights(self) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
|
||||
return self._kernel_weights("w2")
|
||||
|
||||
def _map_global_expert_id(self, expert_id: int) -> list[int]:
|
||||
"""Local slots that logical expert ``expert_id`` occupies on this rank."""
|
||||
physical_ids: list[int] = []
|
||||
for p in range(self.experts_start_idx, self.experts_end_idx):
|
||||
if p % self.num_logical_experts == expert_id:
|
||||
physical_ids.append(p - self.experts_start_idx)
|
||||
return physical_ids
|
||||
|
||||
def weight_loader(
|
||||
self,
|
||||
param: nn.Parameter,
|
||||
loaded_weight: torch.Tensor,
|
||||
weight_name: str,
|
||||
shard_id: str,
|
||||
expert_id: int,
|
||||
return_success: bool = False,
|
||||
) -> bool | None:
|
||||
if not loaded_weight.is_meta:
|
||||
self._record_global_scale(weight_name, loaded_weight, shard_id, expert_id)
|
||||
local_expert_ids = self._map_global_expert_id(expert_id)
|
||||
param_prefix = "w2_" if shard_id == "w2" else "w13_"
|
||||
if not local_expert_ids or param_prefix not in weight_name:
|
||||
return False if return_success else None
|
||||
is_global_scale = weight_name.endswith(("weight_scale_2", "input_scale"))
|
||||
is_scale = weight_name.endswith("weight_scale")
|
||||
for local_expert_id in local_expert_ids:
|
||||
if is_global_scale:
|
||||
param.data[local_expert_id].copy_(loaded_weight.reshape(()))
|
||||
else:
|
||||
self._load_weight(
|
||||
param.data[local_expert_id], loaded_weight, shard_id, is_scale
|
||||
)
|
||||
return True if return_success else None
|
||||
|
||||
def _record_global_scale(
|
||||
self, weight_name: str, scale: torch.Tensor, shard_id: str, expert_id: int
|
||||
) -> None:
|
||||
"""Track NVFP4 global scales the kernel takes one of per launch or expert.
|
||||
|
||||
Every rank sees the scales of all experts, so ranks agree and EPLB can
|
||||
move experts.
|
||||
"""
|
||||
if weight_name.endswith("w2_input_scale"):
|
||||
self._w2_input_scales[expert_id] = scale.max()
|
||||
self.l2_activation_scale = float(self._w2_input_scales.max()) or 1.0
|
||||
elif weight_name.endswith("w13_weight_scale_2"):
|
||||
# Compare w1 and w3 of the same load, not the live (maybe stale) slot.
|
||||
prev = self._pending_l1_scales.pop(expert_id, None)
|
||||
value = float(scale)
|
||||
if prev is None or prev[0] == shard_id:
|
||||
self._pending_l1_scales[expert_id] = (shard_id, value)
|
||||
elif not math.isclose(value, prev[1], rel_tol=1e-5):
|
||||
raise ValueError(
|
||||
f"MegaMoE NVFP4 expert {expert_id}: {prev[0]} weight_scale_2 "
|
||||
f"{prev[1]} != {shard_id} weight_scale_2 {value}; the kernel "
|
||||
"takes one L1 scale per expert."
|
||||
)
|
||||
|
||||
def load_shared_expert_weight(self, name: str, loaded_weight: torch.Tensor) -> str:
|
||||
"""Load one shared-expert checkpoint tensor; return the parameter name."""
|
||||
if ".w2." in name or ".down_proj." in name:
|
||||
param_name = "shared_l2_weight"
|
||||
elif ".w1." in name or ".w3." in name:
|
||||
param_name = "shared_l1_weight"
|
||||
else:
|
||||
raise ValueError(f"Unexpected shared-expert tensor: {name}")
|
||||
if "weight_scale" in name:
|
||||
param_name += "_scale"
|
||||
param = getattr(self, param_name)
|
||||
param.weight_loader(param, loaded_weight, name)
|
||||
return f"{name.partition('shared_experts.')[0]}experts.{param_name}"
|
||||
|
||||
def shared_weight_loader(
|
||||
self, param: nn.Parameter, loaded_weight: torch.Tensor, weight_name: str
|
||||
) -> None:
|
||||
shard_id = (
|
||||
"w1" if ".w1." in weight_name else "w3" if ".w3." in weight_name else "w2"
|
||||
)
|
||||
is_scale = "weight_scale" in weight_name
|
||||
self._load_weight(param.data, loaded_weight, shard_id, is_scale)
|
||||
|
||||
def _load_weight(
|
||||
self,
|
||||
dst: torch.Tensor,
|
||||
src: torch.Tensor,
|
||||
shard_id: str,
|
||||
is_scale: bool,
|
||||
) -> None:
|
||||
"""Write checkpoint shard ``src`` into ``dst`` in the kernel layout.
|
||||
|
||||
``dst`` is one routed expert's slice or a shared-expert parameter.
|
||||
"""
|
||||
if shard_id not in ("w1", "w2", "w3"):
|
||||
raise ValueError(f"Unsupported expert shard id: {shard_id}")
|
||||
rows = dst.shape[0] // (1 if shard_id == "w2" else 2)
|
||||
if is_scale:
|
||||
src = self._pack_scale(src, rows, dst.shape[1] * 4)
|
||||
else:
|
||||
src = src.view(dst.dtype)
|
||||
if tuple(src.shape) != (rows, dst.shape[1]):
|
||||
raise ValueError(
|
||||
f"MegaMoE {shard_id} weight shape {tuple(src.shape)} does not "
|
||||
f"match the kernel shard ({rows}, {dst.shape[1]})"
|
||||
)
|
||||
dst, src = self._kernel_layout_shard(dst, src, shard_id, is_scale)
|
||||
dst.copy_(src)
|
||||
|
||||
@staticmethod
|
||||
def _pack_scale(sf: torch.Tensor, rows: int, cols: int) -> torch.Tensor:
|
||||
"""Expand block scales to the kernel granularity, four per int32 along K."""
|
||||
sf = sf.view(torch.uint8)
|
||||
if rows % sf.shape[0] or cols % sf.shape[1]:
|
||||
raise ValueError(
|
||||
f"Scale shape {tuple(sf.shape)} does not tile ({rows}, {cols})"
|
||||
)
|
||||
sf = sf.repeat_interleave(rows // sf.shape[0], 0)
|
||||
sf = sf.repeat_interleave(cols // sf.shape[1], 1)
|
||||
return sf.contiguous().clone().view(torch.int32)
|
||||
|
||||
@staticmethod
|
||||
def _kernel_layout_shard(
|
||||
dst: torch.Tensor,
|
||||
src: torch.Tensor,
|
||||
shard_id: str,
|
||||
is_scale: bool,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Pair a checkpoint shard with its destination in the MegaMoE layout."""
|
||||
k = src.shape[-1]
|
||||
if shard_id == "w2":
|
||||
if not is_scale:
|
||||
return dst, src
|
||||
# UTCCP: scale row 128*b + 32*j + i -> 128*b + 4*i + j.
|
||||
return dst.view(-1, 32, 4, k).transpose(1, 2), src.reshape(-1, 4, 32, k)
|
||||
half = 0 if shard_id == "w1" else 1
|
||||
if not is_scale:
|
||||
# Interleave gate/up by 8 rows: r -> (r // 8) * 16 + 8 * half + r % 8.
|
||||
return dst.view(-1, 2, 8, k)[:, half], src.reshape(-1, 8, k)
|
||||
# Interleave + UTCCP: 64b + 16j + 8q + m -> 128b + 64q + 32half + 4m + j.
|
||||
dst = dst.view(-1, 2, 2, 8, 4, k)[:, :, half].permute(0, 3, 1, 2, 4)
|
||||
return dst, src.reshape(-1, 4, 2, 8, k)
|
||||
|
||||
def get_symm_buffer(self):
|
||||
from vllm.utils.deep_gemm import _import_deep_gemm
|
||||
|
||||
deep_gemm = _import_deep_gemm()
|
||||
group = get_ep_group().device_group
|
||||
key = (
|
||||
id(group),
|
||||
torch.accelerator.current_device_index(),
|
||||
self.num_experts,
|
||||
self.max_num_tokens,
|
||||
self.top_k,
|
||||
self.hidden_size,
|
||||
self.intermediate_size,
|
||||
self.num_shared_experts,
|
||||
None if self.shared_spec is None else self.shared_spec.dtype,
|
||||
self.weight_format.mma_type,
|
||||
self.activation.name,
|
||||
)
|
||||
symm_buffer = self._symm_buffers.get(key)
|
||||
if symm_buffer is None:
|
||||
args = (
|
||||
group,
|
||||
self.num_experts,
|
||||
self.max_num_tokens,
|
||||
self.top_k,
|
||||
self.hidden_size,
|
||||
self.intermediate_size,
|
||||
)
|
||||
if self.weight_format.uses_nvfp4_api:
|
||||
symm_buffer = deep_gemm.NVFP4SymmBuffer(
|
||||
*args,
|
||||
num_shared_experts=self.num_shared_experts,
|
||||
shared_dtype=torch.float8_e4m3fn
|
||||
if self.shared_spec is None
|
||||
else self.shared_spec.dtype,
|
||||
)
|
||||
else:
|
||||
symm_buffer = deep_gemm.get_symm_buffer_for_mega_moe(
|
||||
*args,
|
||||
num_shared_experts=self.num_shared_experts,
|
||||
mma_type=self.weight_format.mma_type,
|
||||
activation=self.activation.name,
|
||||
)
|
||||
self._symm_buffers[key] = symm_buffer
|
||||
return symm_buffer
|
||||
|
||||
def get_block_m(self, num_tokens: int) -> int:
|
||||
"""BLOCK_M the MegaMoE scheduler runs ``num_tokens`` local tokens with."""
|
||||
from vllm.utils.deep_gemm import _import_deep_gemm
|
||||
|
||||
deep_gemm = _import_deep_gemm()
|
||||
args = (
|
||||
get_ep_group().world_size,
|
||||
self.num_experts,
|
||||
self.get_symm_buffer().num_max_tokens_per_rank,
|
||||
num_tokens,
|
||||
self.top_k,
|
||||
)
|
||||
if self.weight_format.uses_nvfp4_api:
|
||||
return deep_gemm.get_block_m_for_nvfp4_mega_moe(*args)
|
||||
return deep_gemm.get_block_m_for_mega_moe(*args, self.weight_format.mma_type)
|
||||
|
||||
def set_eplb_state(
|
||||
self,
|
||||
moe_layer_idx: int,
|
||||
expert_load_view: torch.Tensor,
|
||||
logical_to_physical_map: torch.Tensor,
|
||||
logical_replica_count: torch.Tensor,
|
||||
) -> None:
|
||||
self.eplb_state.set_layer_state(
|
||||
moe_layer_idx,
|
||||
expert_load_view,
|
||||
logical_to_physical_map,
|
||||
logical_replica_count,
|
||||
)
|
||||
|
||||
def get_expert_weights(self) -> list[torch.Tensor]:
|
||||
def _to_eplb_view(name: str, t: torch.Tensor) -> torch.Tensor:
|
||||
"""Return a (num_local_experts, -1) view with contiguous memory layout."""
|
||||
assert t.shape[0] == self.num_local_experts
|
||||
if t.is_contiguous():
|
||||
return t.view(self.num_local_experts, -1)
|
||||
elif t.dim() == 3 and t.stride(1) == 1 and t.stride(2) == t.shape[1]:
|
||||
# scales have shape (E, M, N) with memory layout (E, N, M)
|
||||
back = torch.transpose(t, 1, 2)
|
||||
assert back.is_contiguous()
|
||||
return back.view(self.num_local_experts, -1)
|
||||
|
||||
raise AssertionError(
|
||||
f"MegaMoE EPLB {name}: non-contiguous expert tensor with "
|
||||
f"unexpected layout shape={tuple(t.shape)} "
|
||||
f"stride={tuple(t.stride())} dtype={t.dtype}"
|
||||
)
|
||||
|
||||
return [
|
||||
_to_eplb_view(name, param)
|
||||
for name, param in self.named_parameters(recurse=False)
|
||||
if not name.startswith("shared_")
|
||||
]
|
||||
|
||||
def update_expert_map(self) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def layer_id(self) -> int:
|
||||
return extract_layer_index(self.prefix)
|
||||
|
||||
def _synchronize_first_launch(self) -> None:
|
||||
ep_group = get_ep_group()
|
||||
key = (id(ep_group.cpu_group), torch.accelerator.current_device_index())
|
||||
if key in self._synchronized_ep_groups:
|
||||
return
|
||||
torch.accelerator.synchronize()
|
||||
torch.distributed.barrier(group=ep_group.cpu_group)
|
||||
self._synchronized_ep_groups.add(key)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
topk_weights: torch.Tensor,
|
||||
topk_ids: torch.Tensor,
|
||||
shared_experts: nn.Module | None = None,
|
||||
) -> torch.Tensor:
|
||||
"""Run the routed and shared experts on this rank's tokens.
|
||||
|
||||
Args:
|
||||
hidden_states: BF16 ``[num_tokens, hidden_size]`` activations.
|
||||
topk_weights: ``[num_tokens, top_k]`` routing weights.
|
||||
topk_ids: ``[num_tokens, top_k]`` logical expert ids.
|
||||
shared_experts: Unfused shared MLP, run on ``hidden_states`` and
|
||||
added to the output. Must be ``None`` when this layer fused the
|
||||
shared expert at construction.
|
||||
|
||||
Returns:
|
||||
BF16 ``[num_tokens, hidden_size]`` routed plus shared expert output.
|
||||
|
||||
"""
|
||||
if shared_experts is not None and self.has_fused_shared_experts:
|
||||
raise ValueError(
|
||||
f"{self.prefix} fuses the shared expert into MegaMoE; "
|
||||
"pass shared_experts=None."
|
||||
)
|
||||
num_tokens = hidden_states.shape[0]
|
||||
if num_tokens > self.max_num_tokens:
|
||||
raise ValueError(
|
||||
f"MegaMoE got {num_tokens} tokens, but the symmetric buffer was "
|
||||
f"sized for {self.max_num_tokens}."
|
||||
)
|
||||
self._synchronize_first_launch()
|
||||
|
||||
is_padding = None
|
||||
if envs.VLLM_MOE_SKIP_PADDING and is_forward_context_available():
|
||||
is_padding = get_forward_context().is_padding
|
||||
if is_padding is not None:
|
||||
is_padding = is_padding[:num_tokens]
|
||||
|
||||
if self.capture_fn is not None:
|
||||
self.capture_fn(topk_ids)
|
||||
|
||||
# EPLB: map logical expert IDs to physical replicas and record load.
|
||||
eplb_state = self.eplb_state
|
||||
if eplb_state.logical_to_physical_map is not None:
|
||||
assert eplb_state.expert_load_view is not None
|
||||
assert eplb_state.logical_replica_count is not None
|
||||
assert eplb_state.should_record_tensor is not None
|
||||
if is_padding is not None:
|
||||
topk_ids = torch.where(is_padding.unsqueeze(1), -1, topk_ids)
|
||||
topk_ids = eplb_map_to_physical_and_record(
|
||||
topk_ids=topk_ids,
|
||||
expert_load_view=eplb_state.expert_load_view,
|
||||
logical_to_physical_map=eplb_state.logical_to_physical_map,
|
||||
logical_replica_count=eplb_state.logical_replica_count,
|
||||
record_enabled=eplb_state.should_record_tensor,
|
||||
num_unpadded_tokens=eplb_state.num_unpadded_tokens_tensors[
|
||||
dbo_current_ubatch_id()
|
||||
]
|
||||
if eplb_state.num_unpadded_tokens_tensors is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
from vllm.utils.deep_gemm import _import_deep_gemm
|
||||
|
||||
deep_gemm = _import_deep_gemm()
|
||||
fmt = self.weight_format
|
||||
symm_buffer = self.get_symm_buffer()
|
||||
shared_x_sf = None
|
||||
shared_block_m = None
|
||||
if self.shared_spec is not None and self.shared_spec.scale_group_k:
|
||||
shared_x_sf = symm_buffer.shared_l1_acts_sf
|
||||
shared_block_m = self.get_block_m(num_tokens)
|
||||
kwargs = dict(self._kernel_kwargs)
|
||||
if fmt.uses_nvfp4_api:
|
||||
l2_activation_scale = self.l2_activation_scale
|
||||
prepare_megamoe_nvfp4_inputs(
|
||||
hidden_states,
|
||||
topk_weights,
|
||||
topk_ids,
|
||||
symm_buffer.x[:num_tokens],
|
||||
symm_buffer.x_sf[:num_tokens],
|
||||
symm_buffer.x_scales[:num_tokens],
|
||||
symm_buffer.topk_idx[:num_tokens],
|
||||
symm_buffer.topk_weights[:num_tokens],
|
||||
is_padding=is_padding,
|
||||
shared_x=symm_buffer.shared_l1_acts[:num_tokens]
|
||||
if self.has_fused_shared_experts
|
||||
else None,
|
||||
shared_x_sf=shared_x_sf,
|
||||
shared_block_m=shared_block_m,
|
||||
w2_global_scale=self.w2_weight_scale_2,
|
||||
l2_alpha=self._l2_alpha,
|
||||
l2_activation_scale=l2_activation_scale,
|
||||
)
|
||||
kwargs.update(
|
||||
l1_alpha=self.w13_weight_scale_2,
|
||||
l2_alpha=self._l2_alpha,
|
||||
l2_activation_scale=l2_activation_scale,
|
||||
use_x_scales=True,
|
||||
)
|
||||
else:
|
||||
quantized = fmt.hidden_quant is not None
|
||||
prepare_megamoe_inputs(
|
||||
hidden_states,
|
||||
topk_weights,
|
||||
topk_ids,
|
||||
symm_buffer.x[:num_tokens],
|
||||
symm_buffer.x_sf[:num_tokens] if quantized else None,
|
||||
symm_buffer.topk_idx[:num_tokens],
|
||||
symm_buffer.topk_weights[:num_tokens],
|
||||
is_padding=is_padding,
|
||||
shared_x_sf=shared_x_sf,
|
||||
shared_block_m=shared_block_m,
|
||||
hidden_quant=fmt.hidden_quant,
|
||||
)
|
||||
|
||||
if self.has_fused_shared_experts:
|
||||
kwargs["shared_l1_weights"] = self._kernel_weights("shared_l1")
|
||||
kwargs["shared_l2_weights"] = self._kernel_weights("shared_l2")
|
||||
y = torch.empty_like(hidden_states, dtype=torch.bfloat16)
|
||||
getattr(deep_gemm, fmt.kernel)(
|
||||
y,
|
||||
self.l1_weights,
|
||||
self.l2_weights,
|
||||
symm_buffer,
|
||||
fast_math=self.fast_math,
|
||||
**kwargs,
|
||||
)
|
||||
if shared_experts is not None:
|
||||
y += shared_experts(hidden_states)
|
||||
return y
|
||||
|
||||
|
||||
DeepGemmMegaMoEExperts.weight_loader.supports_moe_loading = True # type: ignore[attr-defined]
|
||||
@@ -1,79 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any, ClassVar, Generic, TypeAlias, TypeVar
|
||||
|
||||
from vllm.model_executor.warmup.jit_warmup import (
|
||||
VllmJitKernel,
|
||||
)
|
||||
|
||||
DEFAULT_CUTEDSL_COMPILE_OPTIONS = "--enable-tvm-ffi"
|
||||
|
||||
CompileKeyT = TypeVar("CompileKeyT")
|
||||
CuTeDSLLaunchSpec: TypeAlias = (
|
||||
tuple[CompileKeyT, tuple[Any, ...]]
|
||||
| tuple[CompileKeyT, tuple[Any, ...], Any]
|
||||
| tuple[
|
||||
CompileKeyT,
|
||||
tuple[Any, ...],
|
||||
Any,
|
||||
Callable[[], Any],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def cutedsl_fake_stream(*, use_tvm_ffi_env_stream: bool = True) -> Any:
|
||||
import cutlass.cute as cute
|
||||
|
||||
return cute.runtime.make_fake_stream(use_tvm_ffi_env_stream=use_tvm_ffi_env_stream)
|
||||
|
||||
|
||||
def compile_cutedsl(
|
||||
entry: Callable[..., Any],
|
||||
*args: Any,
|
||||
options: str = DEFAULT_CUTEDSL_COMPILE_OPTIONS,
|
||||
use_tvm_ffi_env_stream: bool = True,
|
||||
) -> Any:
|
||||
import cutlass.cute as cute
|
||||
|
||||
return cute.compile(
|
||||
entry,
|
||||
*args,
|
||||
cutedsl_fake_stream(use_tvm_ffi_env_stream=use_tvm_ffi_env_stream),
|
||||
options=options,
|
||||
)
|
||||
|
||||
|
||||
class VllmCuTeDSLJitKernel(VllmJitKernel[CompileKeyT], Generic[CompileKeyT]):
|
||||
"""CuTeDSL owner whose compiled executor is shared by warmup and runtime."""
|
||||
|
||||
kernel: ClassVar[Any]
|
||||
bind_launch_inputs = False
|
||||
|
||||
@abstractmethod
|
||||
def warmup_inputs(self, compile_key: CompileKeyT) -> tuple[Any, ...]:
|
||||
"""Return fake arguments that compile one executor specialization."""
|
||||
raise NotImplementedError
|
||||
|
||||
def compile(self, compile_key: CompileKeyT) -> None:
|
||||
if compile_key in self._compiled_cache:
|
||||
return
|
||||
self._compiled_cache[compile_key] = compile_cutedsl(
|
||||
self.kernel(compile_key),
|
||||
*self.warmup_inputs(compile_key),
|
||||
)
|
||||
|
||||
def launch(
|
||||
self,
|
||||
launch_spec: CuTeDSLLaunchSpec[CompileKeyT],
|
||||
_inputs: Mapping[str, Any],
|
||||
) -> Any:
|
||||
# (compile_key, args), optionally followed by output and epilogue.
|
||||
compile_key, launch_args = launch_spec[:2]
|
||||
executor = self._get_or_compile(compile_key)
|
||||
result = executor(*launch_args)
|
||||
if len(launch_spec) == 4:
|
||||
return launch_spec[3]()
|
||||
return launch_spec[2] if len(launch_spec) == 3 else result
|
||||
@@ -1,171 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Compile-only helpers for TileLang JIT warmup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable, Iterator, Mapping, MutableMapping
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from functools import wraps
|
||||
from typing import Any, ClassVar, Generic, TypeAlias, TypeVar, cast
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.model_executor.warmup.jit_warmup import VllmJitKernel
|
||||
|
||||
CompileKeyT = TypeVar("CompileKeyT")
|
||||
# (kernel_args, launch_args), optionally followed by keywords and a result.
|
||||
TileLangLaunchSpec: TypeAlias = (
|
||||
tuple[tuple[Any, ...], tuple[Any, ...]]
|
||||
| tuple[tuple[Any, ...], tuple[Any, ...], Mapping[str, Any] | None]
|
||||
| tuple[tuple[Any, ...], tuple[Any, ...], Mapping[str, Any] | None, Any]
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _quiet_tilelang_warmup_logs() -> Iterator[None]:
|
||||
loggers = [
|
||||
logging.getLogger("tilelang.cache.kernel_cache"),
|
||||
logging.getLogger("tilelang.jit.kernel"),
|
||||
]
|
||||
previous_levels = [logger.level for logger in loggers]
|
||||
try:
|
||||
for logger in loggers:
|
||||
logger.setLevel(logging.ERROR)
|
||||
yield
|
||||
finally:
|
||||
for logger, level in zip(loggers, previous_levels):
|
||||
logger.setLevel(level)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TileLangWarmupTensor:
|
||||
"""Minimal tensor-like object accepted by TileLang compile().
|
||||
|
||||
TileLang builds its cache key and TIR from tensor dtype, shape and stride.
|
||||
This object deliberately has no storage, so compile-only warmup does not
|
||||
allocate GPU memory and does not launch the kernel.
|
||||
"""
|
||||
|
||||
dtype: torch.dtype
|
||||
shape: tuple[int, ...] = (1,)
|
||||
strides: tuple[int, ...] | None = field(default=None)
|
||||
|
||||
def stride(self) -> tuple[int, ...]:
|
||||
if self.strides is not None:
|
||||
return self.strides
|
||||
|
||||
strides: list[int] = []
|
||||
stride = 1
|
||||
for size in reversed(self.shape):
|
||||
strides.append(stride)
|
||||
stride *= size
|
||||
return tuple(reversed(strides))
|
||||
|
||||
def new_empty(
|
||||
self,
|
||||
shape: tuple[int, ...],
|
||||
*,
|
||||
dtype: torch.dtype | None = None,
|
||||
device: Any = None,
|
||||
) -> TileLangWarmupTensor:
|
||||
return TileLangWarmupTensor(dtype or self.dtype, shape)
|
||||
|
||||
|
||||
def make_tilelang_warmup_tensor(
|
||||
dtype: torch.dtype,
|
||||
*shape: int,
|
||||
strides: tuple[int, ...] | None = None,
|
||||
) -> TileLangWarmupTensor:
|
||||
return TileLangWarmupTensor(dtype=dtype, shape=tuple(shape), strides=strides)
|
||||
|
||||
|
||||
def _tilelang_call_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
call_kwargs = dict(kwargs)
|
||||
tune_params = call_kwargs.pop("__tune_params", {})
|
||||
if isinstance(tune_params, dict):
|
||||
call_kwargs.update(tune_params)
|
||||
return call_kwargs
|
||||
|
||||
|
||||
def compile_tilelang(jit_impl: Any, *args: Any, **kwargs: Any) -> None:
|
||||
"""Compile one TileLang specialization and populate its call cache.
|
||||
|
||||
TileLang's ``compile()`` materializes the kernel without launching it.
|
||||
We also store the compiled kernel in ``_kernel_cache`` using the same
|
||||
parsed key as ``__call__`` so runtime does not report a cache miss for an
|
||||
already materialized specialization.
|
||||
"""
|
||||
with _quiet_tilelang_warmup_logs():
|
||||
compiled = jit_impl.compile(*args, **kwargs)
|
||||
func = getattr(jit_impl, "func", None)
|
||||
parse_args = getattr(func, "parse_args", None)
|
||||
cache = getattr(jit_impl, "_kernel_cache", None)
|
||||
if not callable(parse_args) or not isinstance(cache, MutableMapping):
|
||||
return
|
||||
|
||||
key, _ = parse_args(*args, **_tilelang_call_kwargs(kwargs))
|
||||
cache[key] = compiled
|
||||
|
||||
|
||||
class VllmTileLangJitKernel(VllmJitKernel[CompileKeyT], Generic[CompileKeyT]):
|
||||
"""TileLang owner whose runtime launch specification is reused for warmup."""
|
||||
|
||||
kernel: ClassVar[Any]
|
||||
_warming_key: CompileKeyT | None = None
|
||||
|
||||
@abstractmethod
|
||||
def warmup_inputs(self, compile_key: CompileKeyT) -> dict[str, Any]:
|
||||
"""Return runtime-shaped inputs that reproduce one compile key."""
|
||||
raise NotImplementedError
|
||||
|
||||
def compile(self, compile_key: CompileKeyT) -> None:
|
||||
self._warming_key = compile_key
|
||||
try:
|
||||
cast(Callable[..., Any], self)(**self.warmup_inputs(compile_key))
|
||||
finally:
|
||||
self._warming_key = None
|
||||
|
||||
def launch(
|
||||
self,
|
||||
jit_impl: Any,
|
||||
args: tuple[Any, ...],
|
||||
kwargs: Mapping[str, Any] | None = None,
|
||||
) -> Any:
|
||||
call_kwargs = dict(kwargs or {})
|
||||
if self._warming_key is not None:
|
||||
return compile_tilelang(jit_impl, *args, **call_kwargs)
|
||||
return jit_impl(*args, **call_kwargs)
|
||||
|
||||
|
||||
def kernel_launcher(
|
||||
call_fn: Callable[..., TileLangLaunchSpec],
|
||||
) -> Callable[..., Any]:
|
||||
"""Launch TileLang from declarative kernel and runtime argument tuples."""
|
||||
|
||||
@wraps(call_fn)
|
||||
def wrapper(
|
||||
self: VllmTileLangJitKernel[Any],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
launch_spec = call_fn(self, *args, **kwargs)
|
||||
kernel_args, launch_args = launch_spec[:2]
|
||||
if self._warming_key is not None and kernel_args not in (
|
||||
(),
|
||||
(self._warming_key,),
|
||||
):
|
||||
raise RuntimeError(
|
||||
"TileLang warmup inputs produced a different compile key: "
|
||||
f"expected {self._warming_key!r}, got kernel arguments "
|
||||
f"{kernel_args!r}"
|
||||
)
|
||||
launch_kwargs = launch_spec[2] if len(launch_spec) > 2 else None
|
||||
output = launch_spec[3] if len(launch_spec) > 3 else None
|
||||
result = self.launch(self.kernel(*kernel_args), launch_args, launch_kwargs)
|
||||
return output if len(launch_spec) > 3 else result
|
||||
|
||||
return wrapper
|
||||
@@ -1,63 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Fused all-reduce + residual-add + RMSNorm for eager model paths.
|
||||
|
||||
This recovers a fusion that vLLM's torch.compile passes would normally do but
|
||||
that doesn't fire for models running eager (or under a breakable CUDA graph).
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.distributed import (
|
||||
get_tensor_model_parallel_world_size,
|
||||
tensor_model_parallel_all_reduce,
|
||||
)
|
||||
from vllm.model_executor.layers.fused_allreduce_gemma_rms_norm import (
|
||||
_AR_RESIDUAL_RMS_NORM,
|
||||
_can_use_flashinfer,
|
||||
flashinfer_trtllm_fused_allreduce_norm,
|
||||
)
|
||||
from vllm.model_executor.layers.layernorm import RMSNorm
|
||||
|
||||
|
||||
def fused_allreduce_rms_norm(
|
||||
hidden_states: torch.Tensor,
|
||||
residual: torch.Tensor,
|
||||
norm: RMSNorm,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
"""All-reduce + add residual + (standard) RMSNorm, fused via flashinfer.
|
||||
|
||||
``hidden_states`` is the per-rank *partial* output of a row-parallel linear
|
||||
run with ``reduce_results=False``; ``norm`` is the RMSNorm applied right
|
||||
after. Returns ``(normed_output, new_residual)``, equivalent to
|
||||
``norm(all_reduce(hidden_states), residual)``. Falls back to an explicit
|
||||
all-reduce + RMSNorm when the flashinfer fast path is unavailable.
|
||||
"""
|
||||
tp_size = get_tensor_model_parallel_world_size()
|
||||
if tp_size == 1:
|
||||
return norm(hidden_states, residual)
|
||||
|
||||
if flashinfer_trtllm_fused_allreduce_norm is not None:
|
||||
ok, max_token_num = _can_use_flashinfer(hidden_states, tp_size)
|
||||
if ok:
|
||||
norm_out = torch.empty_like(hidden_states)
|
||||
# With norm_out provided, the kernel writes the new residual
|
||||
# (all_reduce(hidden_states) + residual) into the hidden_states
|
||||
# buffer and the normalized result into norm_out.
|
||||
flashinfer_trtllm_fused_allreduce_norm(
|
||||
allreduce_in=hidden_states,
|
||||
residual=residual,
|
||||
rms_gamma=norm.weight,
|
||||
rms_eps=norm.variance_epsilon,
|
||||
world_size=tp_size,
|
||||
weight_bias=0.0, # standard RMSNorm (Gemma would use 1.0)
|
||||
launch_with_pdl=True,
|
||||
fp32_acc=True,
|
||||
max_token_num=max_token_num,
|
||||
pattern_code=_AR_RESIDUAL_RMS_NORM,
|
||||
norm_out=norm_out,
|
||||
)
|
||||
return norm_out, hidden_states
|
||||
|
||||
reduced = tensor_model_parallel_all_reduce(hidden_states)
|
||||
return norm(reduced, residual)
|
||||
@@ -195,7 +195,6 @@ class VideoLoader:
|
||||
|
||||
|
||||
VIDEO_LOADER_REGISTRY = VideoLoaderRegistry()
|
||||
VIDEO_LOADER_REGISTRY.register_gpu_codec("deepstream")
|
||||
|
||||
|
||||
@VIDEO_LOADER_REGISTRY.register("opencv")
|
||||
|
||||
@@ -12,24 +12,15 @@ from .base import (
|
||||
check_frame_pixel_limit,
|
||||
)
|
||||
|
||||
VideoDecoderBackend = Literal["opencv", "torchcodec", "pynvvideocodec", "deepstream"]
|
||||
VideoDecoderBackend = Literal["opencv", "pynvvideocodec"]
|
||||
|
||||
_BACKEND_OPTION_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"opencv": {},
|
||||
"torchcodec": {
|
||||
"num_ffmpeg_threads": 0,
|
||||
"seek_mode": "exact",
|
||||
"device": "cpu",
|
||||
},
|
||||
PYNVVIDEOCODEC_VIDEO_BACKEND: {
|
||||
"hw_decoders": PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
|
||||
"max_width": None,
|
||||
"max_height": None,
|
||||
},
|
||||
"deepstream": {
|
||||
"pool_size": None,
|
||||
"timeout_sec": 120.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import threading
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import numpy.typing as npt
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from .base import VideoSourceMetadata, VideoTargetMetadata, check_frame_pixel_limit
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def decode_deepstream(
|
||||
loader_cls,
|
||||
data: bytes,
|
||||
target: VideoTargetMetadata,
|
||||
sampling_kwargs: dict,
|
||||
*,
|
||||
pool_size: int | None = None,
|
||||
timeout_sec: float = 120.0,
|
||||
) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
|
||||
from nvidia.deepstream_videodecode import probe_metadata
|
||||
|
||||
total_frames, original_fps, duration, _width, _height, codec = probe_metadata(data)
|
||||
check_frame_pixel_limit(_width, _height)
|
||||
source = loader_cls._prepare_source(
|
||||
VideoSourceMetadata(total_frames, original_fps, duration)
|
||||
)
|
||||
frame_idx = loader_cls.compute_frames_index_to_sample(
|
||||
source=source, target=target, **sampling_kwargs
|
||||
)
|
||||
frames, valid = DeepStreamVideoBackendMixin.decode_indices(
|
||||
data,
|
||||
frame_idx,
|
||||
source,
|
||||
codec=codec,
|
||||
pool_size=pool_size,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
return frames, source, frame_idx, valid
|
||||
|
||||
|
||||
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 mixin, 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 backend. 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
|
||||
@@ -1,133 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import torch
|
||||
|
||||
from vllm.logger import init_logger
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.utils.import_utils import PlaceholderModule, check_torchcodec_available
|
||||
|
||||
from .base import (
|
||||
VideoSourceMetadata,
|
||||
VideoTargetMetadata,
|
||||
check_frame_pixel_limit,
|
||||
)
|
||||
|
||||
try:
|
||||
from torchcodec.decoders import VideoDecoder
|
||||
except (ImportError, RuntimeError):
|
||||
VideoDecoder = PlaceholderModule("torchcodec").placeholder_attr( # type: ignore[assignment]
|
||||
"decoders.VideoDecoder"
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
def decode_torchcodec(
|
||||
loader_cls,
|
||||
data: bytes,
|
||||
target: VideoTargetMetadata,
|
||||
sampling_kwargs: dict,
|
||||
*,
|
||||
num_ffmpeg_threads: int = 0,
|
||||
seek_mode: Literal["exact", "approximate"] = "exact",
|
||||
device: str = "cpu",
|
||||
) -> tuple[npt.NDArray | torch.Tensor, VideoSourceMetadata, list[int], list[int]]:
|
||||
check_torchcodec_available()
|
||||
decoder = TorchCodecVideoBackendMixin.make_torchcodec_decoder(
|
||||
data,
|
||||
num_ffmpeg_threads=num_ffmpeg_threads,
|
||||
seek_mode=seek_mode,
|
||||
device=device,
|
||||
)
|
||||
check_frame_pixel_limit(
|
||||
decoder.metadata.width or 0,
|
||||
decoder.metadata.height or 0,
|
||||
)
|
||||
source = loader_cls._prepare_source(
|
||||
TorchCodecVideoBackendMixin.get_torchcodec_metadata(decoder)
|
||||
)
|
||||
frame_idx = loader_cls.compute_frames_index_to_sample(
|
||||
source=source, target=target, **sampling_kwargs
|
||||
)
|
||||
frames, valid = TorchCodecVideoBackendMixin.decode_torchcodec_frames(
|
||||
decoder, frame_idx, device=device
|
||||
)
|
||||
return frames, source, frame_idx, valid
|
||||
|
||||
|
||||
class TorchCodecVideoBackendMixin:
|
||||
"""TorchCodec (FFmpeg-backed, PyTorch-native) codec utilities.
|
||||
|
||||
Builds a :class:`~torchcodec.decoders.VideoDecoder` over the in-memory
|
||||
bytes and extracts the sampled indices with a single batched
|
||||
``get_frames_at`` call, while releasing the GIL during decode.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def make_torchcodec_decoder(
|
||||
data: bytes,
|
||||
*,
|
||||
num_ffmpeg_threads: int = 0,
|
||||
seek_mode: Literal["exact", "approximate"] = "exact",
|
||||
device: str = "cpu",
|
||||
) -> "VideoDecoder":
|
||||
torch_device = torch.device(device)
|
||||
if torch_device.type == "cuda" and not current_platform.is_cuda():
|
||||
raise ValueError(
|
||||
f"torchcodec video decoding on device {device!r} requires "
|
||||
"a CUDA-capable platform."
|
||||
)
|
||||
elif torch_device.type not in ("cpu", "cuda"):
|
||||
raise ValueError(
|
||||
f"torchcodec video decoding only supports 'cpu' and 'cuda' "
|
||||
f"devices, got {device!r}."
|
||||
)
|
||||
# NHWC matches the (num_frames, H, W, 3) uint8 RGB layout the rest
|
||||
# of the pipeline expects, avoiding a transpose.
|
||||
return VideoDecoder(
|
||||
data,
|
||||
dimension_order="NHWC",
|
||||
num_ffmpeg_threads=num_ffmpeg_threads,
|
||||
seek_mode=seek_mode,
|
||||
device=device,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_torchcodec_metadata(decoder: "VideoDecoder") -> VideoSourceMetadata:
|
||||
md = decoder.metadata
|
||||
total_frames = md.num_frames or 0
|
||||
fps = float(md.average_fps) if md.average_fps else 0.0
|
||||
duration = float(md.duration_seconds) if md.duration_seconds else 0.0
|
||||
if total_frames == 0 and duration > 0 and fps > 0:
|
||||
total_frames = int(duration * fps)
|
||||
return VideoSourceMetadata(total_frames, fps, duration)
|
||||
|
||||
@staticmethod
|
||||
def decode_torchcodec_frames(
|
||||
decoder: "VideoDecoder",
|
||||
frame_indices: list[int],
|
||||
*,
|
||||
device: str = "cpu",
|
||||
) -> tuple[npt.NDArray | torch.Tensor, list[int]]:
|
||||
"""Decode the requested indices in one batched, index-exact call."""
|
||||
if not frame_indices:
|
||||
return np.empty((0,), dtype=np.uint8), []
|
||||
# Note: torchcodec releases the GIL for the entire call
|
||||
batch = decoder.get_frames_at(frame_indices)
|
||||
frames = batch.data
|
||||
if (
|
||||
torch.device(device).type != "cpu"
|
||||
and (status := getattr(decoder, "cpu_fallback", None)) is not None
|
||||
and bool(status)
|
||||
):
|
||||
logger.warning_once(
|
||||
"torchcodec could not use NVDEC for this video and "
|
||||
"decoded on CPU instead; check codec support and "
|
||||
"libnvcuvid."
|
||||
)
|
||||
return frames, list(frame_indices)
|
||||
@@ -1,400 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import copy
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Generic, TypeAlias, TypeVar
|
||||
|
||||
from torch._C._autograd import DeviceType, _KinetoEvent, _ProfilerResult
|
||||
from torch._C._profiler import _EventType, _ExperimentalConfig, _ProfilerEvent
|
||||
from torch.autograd.profiler import FunctionEvent
|
||||
from torch.profiler import ProfilerActivity, profile
|
||||
|
||||
from vllm.profiler.utils import (
|
||||
TablePrinter,
|
||||
event_has_module,
|
||||
event_is_torch_op,
|
||||
event_module_repr,
|
||||
event_torch_op_stack_trace,
|
||||
indent_string,
|
||||
)
|
||||
from vllm.utils.import_utils import PlaceholderModule
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
pd = PlaceholderModule("pandas")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ModuleTreeNode:
|
||||
event: _ProfilerEvent
|
||||
parent: "_ModuleTreeNode | None" = None
|
||||
children: list["_ModuleTreeNode"] = field(default_factory=list)
|
||||
trace: str = ""
|
||||
|
||||
@property
|
||||
def is_leaf(self):
|
||||
return self.event.children is None or len(self.event.children) == 0
|
||||
|
||||
@property
|
||||
def is_torch_op(self):
|
||||
return event_is_torch_op(self.event)
|
||||
|
||||
@property
|
||||
def is_cuda(self):
|
||||
return (
|
||||
self.event.tag == _EventType.Kineto
|
||||
and self.event.typed[1].device_type == DeviceType.CUDA
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SummaryStatsEntry:
|
||||
name: str
|
||||
cuda_time_us: float
|
||||
pct_cuda_time: float
|
||||
invocations: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelStatsEntry:
|
||||
name: str
|
||||
cpu_time_us: float
|
||||
cuda_time_us: float
|
||||
pct_cuda_time: float
|
||||
trace: str
|
||||
|
||||
|
||||
StatsEntry: TypeAlias = ModelStatsEntry | SummaryStatsEntry
|
||||
StatsEntryT = TypeVar("StatsEntryT", bound=StatsEntry)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StatsTreeNode(Generic[StatsEntryT]):
|
||||
entry: StatsEntryT
|
||||
children: list["_StatsTreeNode[StatsEntryT]"] = field(default_factory=list)
|
||||
parent: "_StatsTreeNode[StatsEntryT] | None" = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayerwiseProfileResults(profile):
|
||||
_kineto_results: _ProfilerResult
|
||||
_kineto_event_correlation_map: dict[int, list[_KinetoEvent]] = field(init=False)
|
||||
_event_correlation_map: dict[int, list[FunctionEvent]] = field(init=False)
|
||||
_module_tree: list[_ModuleTreeNode] = field(init=False)
|
||||
_model_stats_tree: list[_StatsTreeNode[ModelStatsEntry]] = field(init=False)
|
||||
_summary_stats_tree: list[_StatsTreeNode[SummaryStatsEntry]] = field(init=False)
|
||||
|
||||
# profile metadata
|
||||
num_running_seqs: int | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
self._build_correlation_map()
|
||||
self._build_module_tree()
|
||||
self._build_stats_trees()
|
||||
|
||||
def print_model_table(self, column_widths: dict[str, int] | None = None):
|
||||
_column_widths = dict(
|
||||
name=60, cpu_time_us=12, cuda_time_us=12, pct_cuda_time=12, trace=60
|
||||
)
|
||||
if column_widths:
|
||||
_column_widths.update(**column_widths)
|
||||
filtered_model_table = [
|
||||
(depth, row)
|
||||
for depth, row in self._flatten_stats_tree(self._model_stats_tree)
|
||||
if row.cuda_time_us > 0 or row.cpu_time_us > 0
|
||||
]
|
||||
TablePrinter(ModelStatsEntry, _column_widths).print_table(
|
||||
self._indent_row_names_based_on_depth(
|
||||
filtered_model_table,
|
||||
indent_style=lambda indent: "|" + "-" * indent + " ",
|
||||
)
|
||||
)
|
||||
|
||||
def print_summary_table(self, column_widths: dict[str, int] | None = None):
|
||||
_column_widths = dict(
|
||||
name=80, cuda_time_us=12, pct_cuda_time=12, invocations=15
|
||||
)
|
||||
if column_widths:
|
||||
_column_widths.update(**column_widths)
|
||||
filtered_summary_table = [
|
||||
(depth, row)
|
||||
for depth, row in self._flatten_stats_tree(self._summary_stats_tree)
|
||||
if row.cuda_time_us > 0
|
||||
]
|
||||
TablePrinter(SummaryStatsEntry, _column_widths).print_table(
|
||||
self._indent_row_names_based_on_depth(
|
||||
filtered_summary_table,
|
||||
indent_style=lambda indent: "|" + "-" * indent + " ",
|
||||
)
|
||||
)
|
||||
|
||||
def export_model_stats_table_csv(self, filename: str):
|
||||
df = pd.DataFrame(
|
||||
[asdict(row) for _, row in self._flatten_stats_tree(self._model_stats_tree)]
|
||||
)
|
||||
df.to_csv(filename)
|
||||
|
||||
def export_summary_stats_table_csv(self, filename: str):
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
asdict(row)
|
||||
for _, row in self._flatten_stats_tree(self._summary_stats_tree)
|
||||
]
|
||||
)
|
||||
df.to_csv(filename)
|
||||
|
||||
def convert_stats_to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"metadata": {"num_running_seqs": self.num_running_seqs},
|
||||
"summary_stats": self._convert_stats_tree_to_dict(self._summary_stats_tree),
|
||||
"model_stats": self._convert_stats_tree_to_dict(self._model_stats_tree),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _indent_row_names_based_on_depth(
|
||||
depths_rows: list[tuple[int, StatsEntryT]],
|
||||
indent_style: Callable[[int], str] | str = " ",
|
||||
):
|
||||
indented_rows: list[StatsEntryT] = []
|
||||
for depth, row in depths_rows:
|
||||
if row.cuda_time_us == 0:
|
||||
continue
|
||||
indented_row: StatsEntryT = copy.deepcopy(row)
|
||||
indented_row.name = indent_string(indented_row.name, depth, indent_style)
|
||||
indented_rows.append(indented_row)
|
||||
return indented_rows
|
||||
|
||||
def _build_correlation_map(self):
|
||||
self._kineto_event_correlation_map = defaultdict(list)
|
||||
for event in self._kineto_results.events():
|
||||
self._kineto_event_correlation_map[event.correlation_id()].append(event)
|
||||
|
||||
def _build_module_tree(self):
|
||||
self._module_tree = []
|
||||
event_tree = self._kineto_results.experimental_event_tree()
|
||||
|
||||
def _df_traversal(
|
||||
event: _ProfilerEvent, curr_node: _ModuleTreeNode | None = None
|
||||
):
|
||||
# For the tensor parallel case for now only look at task 1
|
||||
if event.start_tid != 1:
|
||||
return
|
||||
|
||||
if event_has_module(event):
|
||||
node = _ModuleTreeNode(event=event, parent=curr_node)
|
||||
if curr_node:
|
||||
curr_node.children.append(node)
|
||||
else:
|
||||
self._module_tree.append(node)
|
||||
curr_node = node
|
||||
|
||||
is_leaf = event.children is None or len(event.children) == 0
|
||||
if is_leaf and curr_node:
|
||||
node = _ModuleTreeNode(
|
||||
event=event,
|
||||
parent=curr_node,
|
||||
trace=event_torch_op_stack_trace(
|
||||
event, until=lambda x: event_has_module(x)
|
||||
),
|
||||
)
|
||||
curr_node.children.append(node)
|
||||
curr_node = node
|
||||
|
||||
for child in event.children:
|
||||
_df_traversal(child, curr_node)
|
||||
|
||||
for root in event_tree:
|
||||
_df_traversal(root)
|
||||
|
||||
def _get_kineto_gpu_event(self, node: _ModuleTreeNode):
|
||||
if node.event.tag != _EventType.Kineto:
|
||||
return None
|
||||
correlated_kineto_events = self._kineto_event_correlation_map.get(
|
||||
node.event.correlation_id, []
|
||||
)
|
||||
iterator = (
|
||||
x
|
||||
for x in correlated_kineto_events
|
||||
if x.device_type() == DeviceType.CUDA and x.name() == node.event.name
|
||||
)
|
||||
return next(iterator, None)
|
||||
|
||||
def _cumulative_cuda_time(self, node: _ModuleTreeNode):
|
||||
"""Return cuda time in microseconds."""
|
||||
|
||||
def _cumulative_cuda_time_recursive(node: _ModuleTreeNode):
|
||||
if node.is_leaf and (gpu_kineto_event := self._get_kineto_gpu_event(node)):
|
||||
return gpu_kineto_event.duration_ns() / 1000.0
|
||||
else:
|
||||
cumulative_cuda_time = 0
|
||||
for child in node.children:
|
||||
cumulative_cuda_time += _cumulative_cuda_time_recursive(child)
|
||||
return cumulative_cuda_time
|
||||
|
||||
return _cumulative_cuda_time_recursive(node)
|
||||
|
||||
def _total_cuda_time(self):
|
||||
return sum([self._cumulative_cuda_time(root) for root in self._module_tree])
|
||||
|
||||
def _build_stats_trees(self):
|
||||
summary_dict: dict[tuple[str, ...], _StatsTreeNode[SummaryStatsEntry]] = {}
|
||||
total_cuda_time = self._total_cuda_time()
|
||||
|
||||
def pct_cuda_time(cuda_time_us):
|
||||
return (cuda_time_us / total_cuda_time) * 100
|
||||
|
||||
def build_summary_stats_tree_df(
|
||||
node: _ModuleTreeNode,
|
||||
parent: _StatsTreeNode[SummaryStatsEntry] | None = None,
|
||||
summary_trace: tuple[str, ...] = (),
|
||||
) -> _StatsTreeNode[SummaryStatsEntry] | None:
|
||||
if event_has_module(node.event):
|
||||
name = event_module_repr(node.event)
|
||||
cuda_time_us = self._cumulative_cuda_time(node)
|
||||
elif gpu_kineto_event := self._get_kineto_gpu_event(node):
|
||||
name = gpu_kineto_event.name()
|
||||
cuda_time_us = gpu_kineto_event.duration_ns() / 1000.0
|
||||
else:
|
||||
return None
|
||||
|
||||
summary_trace = summary_trace + (name,)
|
||||
if summary_trace in summary_dict:
|
||||
entry = summary_dict[summary_trace].entry
|
||||
entry.cuda_time_us += cuda_time_us
|
||||
entry.invocations += 1
|
||||
entry.pct_cuda_time = pct_cuda_time(entry.cuda_time_us)
|
||||
else:
|
||||
new_node = _StatsTreeNode(
|
||||
entry=SummaryStatsEntry(
|
||||
name=name,
|
||||
cuda_time_us=cuda_time_us,
|
||||
pct_cuda_time=pct_cuda_time(cuda_time_us),
|
||||
invocations=1,
|
||||
),
|
||||
parent=parent,
|
||||
)
|
||||
if parent:
|
||||
parent.children.append(new_node)
|
||||
summary_dict[summary_trace] = new_node
|
||||
|
||||
for child in node.children:
|
||||
build_summary_stats_tree_df(
|
||||
child, summary_dict[summary_trace], summary_trace
|
||||
)
|
||||
|
||||
return summary_dict[summary_trace]
|
||||
|
||||
self._summary_stats_tree = []
|
||||
for root in self._module_tree:
|
||||
summary_node = build_summary_stats_tree_df(root)
|
||||
if summary_node is not None:
|
||||
self._summary_stats_tree.append(summary_node)
|
||||
|
||||
def build_model_stats_tree_df(
|
||||
node: _ModuleTreeNode,
|
||||
parent: _StatsTreeNode[ModelStatsEntry] | None = None,
|
||||
) -> _StatsTreeNode[ModelStatsEntry] | None:
|
||||
if event_has_module(
|
||||
node.event,
|
||||
):
|
||||
name = event_module_repr(node.event)
|
||||
cuda_time_us = self._cumulative_cuda_time(node)
|
||||
cpu_time_us = node.event.duration_time_ns / 1000
|
||||
trace = ""
|
||||
elif gpu_kineto_event := self._get_kineto_gpu_event(node):
|
||||
name = gpu_kineto_event.name()
|
||||
cuda_time_us = gpu_kineto_event.duration_ns() / 1000.0
|
||||
cpu_time_us = 0
|
||||
trace = node.trace
|
||||
else:
|
||||
return None
|
||||
|
||||
new_node = _StatsTreeNode(
|
||||
entry=ModelStatsEntry(
|
||||
name=name,
|
||||
cpu_time_us=cpu_time_us,
|
||||
cuda_time_us=cuda_time_us,
|
||||
pct_cuda_time=pct_cuda_time(cuda_time_us),
|
||||
trace=trace,
|
||||
),
|
||||
parent=parent,
|
||||
)
|
||||
if parent:
|
||||
parent.children.append(new_node)
|
||||
|
||||
for child in node.children:
|
||||
build_model_stats_tree_df(child, new_node)
|
||||
|
||||
return new_node
|
||||
|
||||
self._model_stats_tree = []
|
||||
for root in self._module_tree:
|
||||
model_node = build_model_stats_tree_df(root)
|
||||
if model_node is not None:
|
||||
self._model_stats_tree.append(model_node)
|
||||
|
||||
def _flatten_stats_tree(
|
||||
self, tree: list[_StatsTreeNode[StatsEntryT]]
|
||||
) -> list[tuple[int, StatsEntryT]]:
|
||||
entries: list[tuple[int, StatsEntryT]] = []
|
||||
|
||||
def df_traversal(node: _StatsTreeNode[StatsEntryT], depth: int = 0):
|
||||
entries.append((depth, node.entry))
|
||||
for child in node.children:
|
||||
df_traversal(child, depth=depth + 1)
|
||||
|
||||
for root in tree:
|
||||
df_traversal(root)
|
||||
|
||||
return entries
|
||||
|
||||
def _convert_stats_tree_to_dict(
|
||||
self, tree: list[_StatsTreeNode[StatsEntryT]]
|
||||
) -> list[dict[str, Any]]:
|
||||
root_dicts: list[dict[str, Any]] = []
|
||||
|
||||
def df_traversal(
|
||||
node: _StatsTreeNode[StatsEntryT], curr_json_list: list[dict[str, Any]]
|
||||
):
|
||||
curr_json_list.append({"entry": asdict(node.entry), "children": []})
|
||||
for child in node.children:
|
||||
df_traversal(child, curr_json_list[-1]["children"])
|
||||
|
||||
for root in tree:
|
||||
df_traversal(root, root_dicts)
|
||||
|
||||
return root_dicts
|
||||
|
||||
|
||||
class layerwise_profile(profile):
|
||||
def __init__(self, num_running_seqs: int | None = None):
|
||||
"""Layerwise profile constructor.
|
||||
|
||||
Args:
|
||||
num_running_seqs (Optional[int], optional): When given,
|
||||
num_running_seqs will be passed to LayerProfileResults
|
||||
for metadata update. Defaults to None.
|
||||
|
||||
"""
|
||||
super().__init__(
|
||||
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
|
||||
record_shapes=True,
|
||||
with_stack=True,
|
||||
with_modules=True,
|
||||
experimental_config=_ExperimentalConfig(verbose=True),
|
||||
)
|
||||
|
||||
self.num_running_seqs = num_running_seqs
|
||||
|
||||
def __enter__(self):
|
||||
return super().__enter__()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
super().__exit__(exc_type, exc_val, exc_tb)
|
||||
self.results = LayerwiseProfileResults(
|
||||
self.profiler.kineto_results, num_running_seqs=self.num_running_seqs
|
||||
)
|
||||
@@ -1,141 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import dataclasses
|
||||
from collections.abc import Callable
|
||||
|
||||
from _typeshed import DataclassInstance
|
||||
from torch._C._profiler import _EventType, _ProfilerEvent, _TensorMetadata
|
||||
|
||||
#
|
||||
# String / Print Manipulation
|
||||
#
|
||||
|
||||
|
||||
def trim_string_back(string: str, width: int) -> str:
|
||||
if len(string) > width:
|
||||
offset = len(string) - width + 3
|
||||
string = string[:-offset]
|
||||
if len(string) > 3:
|
||||
string = string + "..."
|
||||
return string
|
||||
|
||||
|
||||
class TablePrinter:
|
||||
def __init__(self, row_cls: type[DataclassInstance], column_widths: dict[str, int]):
|
||||
self.row_cls = row_cls
|
||||
self.fieldnames = [x.name for x in dataclasses.fields(row_cls)]
|
||||
self.column_widths = column_widths
|
||||
assert set(self.column_widths.keys()) == set(self.fieldnames)
|
||||
|
||||
def print_table(self, rows: list[DataclassInstance]):
|
||||
self._print_header()
|
||||
self._print_line()
|
||||
for row in rows:
|
||||
self._print_row(row)
|
||||
|
||||
def _print_header(self):
|
||||
for i, f in enumerate(self.fieldnames):
|
||||
last = i == len(self.fieldnames) - 1
|
||||
col_width = self.column_widths[f]
|
||||
print(
|
||||
trim_string_back(f, col_width).ljust(col_width),
|
||||
end=" | " if not last else "\n",
|
||||
)
|
||||
|
||||
def _print_row(self, row):
|
||||
assert isinstance(row, self.row_cls)
|
||||
|
||||
for i, f in enumerate(self.fieldnames):
|
||||
last = i == len(self.fieldnames) - 1
|
||||
col_width = self.column_widths[f]
|
||||
val = getattr(row, f)
|
||||
|
||||
val_str = ""
|
||||
if isinstance(val, str):
|
||||
val_str = trim_string_back(val, col_width).ljust(col_width)
|
||||
elif type(val) in [float, int]:
|
||||
val_str = f"{float(val):>.2f}".rjust(col_width)
|
||||
else:
|
||||
val_str = f"{val}".rjust(col_width)
|
||||
print(val_str, end=" | " if not last else "\n")
|
||||
|
||||
def _print_line(self):
|
||||
total_col_width = 0
|
||||
for column_width in self.column_widths.values():
|
||||
total_col_width += column_width
|
||||
print("=" * (total_col_width + 3 * (len(self.column_widths) - 1)))
|
||||
|
||||
|
||||
def indent_string(
|
||||
string: str, indent: int, indent_style: Callable[[int], str] | str = " "
|
||||
) -> str:
|
||||
if indent:
|
||||
if isinstance(indent_style, str):
|
||||
return indent_style * indent + string
|
||||
else:
|
||||
return indent_style(indent) + string
|
||||
else:
|
||||
return string
|
||||
|
||||
|
||||
#
|
||||
# _ProfilerEvent utils
|
||||
#
|
||||
|
||||
|
||||
def event_has_module(event: _ProfilerEvent) -> bool:
|
||||
event_type, typed_event = event.typed
|
||||
if event_type == _EventType.PyCall:
|
||||
return typed_event.module is not None
|
||||
return False
|
||||
|
||||
|
||||
def event_is_torch_op(event: _ProfilerEvent) -> bool:
|
||||
return event.tag == _EventType.TorchOp
|
||||
|
||||
|
||||
def event_arg_repr(arg) -> str:
|
||||
if arg is None or type(arg) in [float, int, bool, str]:
|
||||
return f"{arg}"
|
||||
elif isinstance(arg, list):
|
||||
return f"[{', '.join([event_arg_repr(x) for x in arg])}]"
|
||||
elif isinstance(arg, tuple):
|
||||
return f"({', '.join([event_arg_repr(x) for x in arg])})"
|
||||
else:
|
||||
assert isinstance(arg, _TensorMetadata), f"Unsupported type: {type(arg)}"
|
||||
sizes_str = ", ".join([str(x) for x in arg.sizes])
|
||||
return f"{str(arg.dtype).replace('torch.', '')}[{sizes_str}]"
|
||||
|
||||
|
||||
def event_torch_op_repr(event: _ProfilerEvent) -> str:
|
||||
assert event.tag == _EventType.TorchOp
|
||||
args_str = ", ".join([event_arg_repr(x) for x in event.typed[1].inputs])
|
||||
return f"{event.name}({args_str})".replace("aten::", "")
|
||||
|
||||
|
||||
def event_module_repr(event: _ProfilerEvent) -> str:
|
||||
assert event_has_module(event)
|
||||
module = event.typed[1].module
|
||||
if module.parameters and len(module.parameters) > 0:
|
||||
args_str = ", ".join(
|
||||
[f"{x[0]}={event_arg_repr(x[1])}" for x in module.parameters]
|
||||
)
|
||||
return f"{module.cls_name}({args_str})"
|
||||
else:
|
||||
return module.cls_name
|
||||
|
||||
|
||||
def event_torch_op_stack_trace(
|
||||
curr_event: _ProfilerEvent, until: Callable[[_ProfilerEvent], bool]
|
||||
) -> str:
|
||||
trace = ""
|
||||
curr_event = curr_event.parent
|
||||
while curr_event and not until(curr_event):
|
||||
if event_is_torch_op(curr_event):
|
||||
if len(trace) > 0:
|
||||
trace += " <- "
|
||||
trace += event_torch_op_repr(curr_event)
|
||||
curr_event = curr_event.parent
|
||||
|
||||
return trace
|
||||
@@ -1,383 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Inkling chat-encoding core.
|
||||
|
||||
Pure implementation of Inkling chat rendering, kept deliberately free of
|
||||
vLLM imports: it depends only on the :class:`InklingTextTokenizer` protocol
|
||||
and speaks OpenAI-style message dicts. If a standalone Inkling
|
||||
input-processing library becomes available, this module is the unit to
|
||||
swap out — the swap point is marked in ``vllm/renderers/inkling.py``.
|
||||
|
||||
Multimodal parts follow the contract of vLLM's Inkling multimodal processor
|
||||
(``InklingMultiModalProcessor`` anchors prompt updates on the bare
|
||||
content-kind marker and inserts the per-patch placeholder run itself):
|
||||
|
||||
* image parts emit only ``<|content_image|>`` — no seed placeholder id;
|
||||
* audio parts emit ``<|content_audio_input|><|audio_end|>`` — no seed
|
||||
placeholder id between them.
|
||||
|
||||
``reasoning_effort`` (a float in [0, 0.99], sourced from
|
||||
``chat_template_kwargs`` only) renders the ``Thinking effort level:``
|
||||
system control block after the tool declarations and initial system messages.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from typing import Any, Protocol
|
||||
|
||||
END_OF_TEXT = "<|endoftext|>"
|
||||
MESSAGE_USER = "<|message_user|>"
|
||||
MESSAGE_MODEL = "<|message_model|>"
|
||||
MESSAGE_SYSTEM = "<|message_system|>"
|
||||
MESSAGE_TOOL = "<|message_tool|>"
|
||||
CONTENT_TEXT = "<|content_text|>"
|
||||
CONTENT_IMAGE = "<|content_image|>"
|
||||
CONTENT_MODEL_END_SAMPLING = "<|content_model_end_sampling|>"
|
||||
CONTENT_THINKING = "<|content_thinking|>"
|
||||
CONTENT_AUDIO_INPUT = "<|content_audio_input|>"
|
||||
CONTENT_TOOL_ERROR = "<|content_tool_error|>"
|
||||
CONTENT_XML = "<|content_xml|>"
|
||||
CONTENT_INVOKE_TOOL_JSON = "<|content_invoke_tool_json|>"
|
||||
CONTENT_INVOKE_TOOL_TEXT = "<|content_invoke_tool_text|>"
|
||||
END_MESSAGE = "<|end_message|>"
|
||||
AUDIO_END = "<|audio_end|>"
|
||||
|
||||
ROLE_MESSAGE_TOKENS: dict[str, str] = {
|
||||
"user": MESSAGE_USER,
|
||||
"assistant": MESSAGE_MODEL,
|
||||
"system": MESSAGE_SYSTEM,
|
||||
# The developer role folds into the system role on the Inkling wire.
|
||||
"developer": MESSAGE_SYSTEM,
|
||||
"tool": MESSAGE_TOOL,
|
||||
}
|
||||
|
||||
# Alternate vocab spellings per semantic token. The Inkling HF tokenizer
|
||||
# exposes some semantic slots as ``<|unused_NNNNNN|>`` tokens (notably
|
||||
# CONTENT_XML = <|unused_200024|>), so ID resolution must try these
|
||||
# spellings in order.
|
||||
SPECIAL_TOKEN_SPELLINGS: dict[str, tuple[str, ...]] = {
|
||||
MESSAGE_USER: (MESSAGE_USER,),
|
||||
MESSAGE_MODEL: (MESSAGE_MODEL,),
|
||||
MESSAGE_SYSTEM: (MESSAGE_SYSTEM,),
|
||||
MESSAGE_TOOL: (MESSAGE_TOOL,),
|
||||
CONTENT_TEXT: (CONTENT_TEXT,),
|
||||
CONTENT_IMAGE: (CONTENT_IMAGE,),
|
||||
CONTENT_MODEL_END_SAMPLING: (CONTENT_MODEL_END_SAMPLING,),
|
||||
CONTENT_THINKING: (CONTENT_THINKING,),
|
||||
CONTENT_AUDIO_INPUT: (CONTENT_AUDIO_INPUT,),
|
||||
CONTENT_XML: (CONTENT_XML, "<|unused_200024|>"),
|
||||
CONTENT_INVOKE_TOOL_JSON: (CONTENT_INVOKE_TOOL_JSON,),
|
||||
END_MESSAGE: (END_MESSAGE,),
|
||||
AUDIO_END: (AUDIO_END,),
|
||||
}
|
||||
|
||||
|
||||
class InklingTextTokenizer(Protocol):
|
||||
"""Structural tokenizer contract required by the renderer."""
|
||||
|
||||
def encode_text(self, text: str) -> list[int]: ...
|
||||
|
||||
def encode_special(self, token: str) -> int: ...
|
||||
|
||||
|
||||
# OpenAI content-part type spellings that mean image / audio (rendering only
|
||||
# needs the kind, not the bytes — the bytes are handled by the MM processor).
|
||||
_IMAGE_PART_TYPES = frozenset({"image", "input_image", "image_url"})
|
||||
_AUDIO_PART_TYPES = frozenset({"audio", "input_audio", "audio_url"})
|
||||
|
||||
_MAX_REASONING_EFFORT = 0.99
|
||||
|
||||
|
||||
def render_inkling_messages(
|
||||
messages: Sequence[Mapping[str, Any]],
|
||||
tokenizer: InklingTextTokenizer,
|
||||
*,
|
||||
add_generation_prompt: bool = True,
|
||||
tools: Sequence[Mapping[str, Any]] | None = None,
|
||||
reasoning_effort: float | None = None,
|
||||
) -> list[int]:
|
||||
"""Render chat messages to Inkling input ids.
|
||||
|
||||
PURE renderer: emits Inkling framing plus bare media markers; media
|
||||
encoding and placeholder expansion happen later in the MM processor.
|
||||
``add_generation_prompt`` appends the assistant turn opener so the
|
||||
model continues into the response.
|
||||
"""
|
||||
input_ids: list[int] = []
|
||||
tool_call_id_to_name: dict[str, str] = {}
|
||||
|
||||
# Request-level tools plus per-developer-message tools (Rust renderer
|
||||
# semantics) are declared in a single leading system block.
|
||||
all_tools = list(tools or [])
|
||||
for message in messages:
|
||||
if message.get("role") == "developer":
|
||||
all_tools.extend(message.get("tools") or [])
|
||||
|
||||
if all_tools:
|
||||
_append_message(
|
||||
input_ids,
|
||||
tokenizer,
|
||||
"system",
|
||||
"xml",
|
||||
_tool_declare_json(all_tools),
|
||||
author_name="tool_declare",
|
||||
)
|
||||
|
||||
for message in messages:
|
||||
role = _expect_role(message)
|
||||
if role not in {"system", "developer"} and reasoning_effort is not None:
|
||||
_append_reasoning_effort(input_ids, tokenizer, reasoning_effort)
|
||||
reasoning_effort = None
|
||||
|
||||
if role == "tool":
|
||||
tool_name = message.get("name") or tool_call_id_to_name.get(
|
||||
str(message.get("tool_call_id") or ""), ""
|
||||
)
|
||||
_append_message(
|
||||
input_ids,
|
||||
tokenizer,
|
||||
"tool",
|
||||
"text",
|
||||
_flatten_text_content(message.get("content")),
|
||||
author_name=str(tool_name),
|
||||
)
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
reasoning_content = message.get("reasoning")
|
||||
if reasoning_content is None:
|
||||
reasoning_content = message.get("reasoning_content")
|
||||
if reasoning_content:
|
||||
if not isinstance(reasoning_content, str):
|
||||
raise TypeError(
|
||||
"assistant reasoning_content must be a string for "
|
||||
"Inkling rendering"
|
||||
)
|
||||
_append_message(
|
||||
input_ids,
|
||||
tokenizer,
|
||||
"assistant",
|
||||
"thinking",
|
||||
reasoning_content,
|
||||
)
|
||||
|
||||
for kind, text in _iter_render_parts(message.get("content")):
|
||||
_append_message(input_ids, tokenizer, role, kind, text)
|
||||
|
||||
if role == "assistant":
|
||||
for tool_call in message.get("tool_calls") or []:
|
||||
name, args = _tool_call_name_and_args(tool_call)
|
||||
tool_call_id = _as_mapping(tool_call).get("id")
|
||||
if tool_call_id:
|
||||
tool_call_id_to_name[str(tool_call_id)] = name
|
||||
_append_message(
|
||||
input_ids,
|
||||
tokenizer,
|
||||
"assistant",
|
||||
"invoke_tool_json",
|
||||
_tool_call_json(name, args),
|
||||
author_name=name,
|
||||
)
|
||||
|
||||
input_ids.append(tokenizer.encode_special(CONTENT_MODEL_END_SAMPLING))
|
||||
|
||||
if reasoning_effort is not None:
|
||||
_append_reasoning_effort(input_ids, tokenizer, reasoning_effort)
|
||||
|
||||
if add_generation_prompt:
|
||||
input_ids.append(tokenizer.encode_special(MESSAGE_MODEL))
|
||||
return input_ids
|
||||
|
||||
|
||||
def _append_reasoning_effort(
|
||||
input_ids: list[int],
|
||||
tokenizer: InklingTextTokenizer,
|
||||
reasoning_effort: float,
|
||||
) -> None:
|
||||
_append_message(
|
||||
input_ids,
|
||||
tokenizer,
|
||||
"system",
|
||||
"text",
|
||||
_thinking_effort_text(reasoning_effort),
|
||||
)
|
||||
|
||||
|
||||
def _thinking_effort_text(reasoning_effort: Any) -> str:
|
||||
if isinstance(reasoning_effort, bool) or not isinstance(
|
||||
reasoning_effort, (int, float)
|
||||
):
|
||||
raise TypeError(
|
||||
"Inkling reasoning_effort must be a number in [0.0, 0.99], "
|
||||
f"got {type(reasoning_effort).__name__}"
|
||||
)
|
||||
value = float(reasoning_effort)
|
||||
if not 0.0 <= value <= _MAX_REASONING_EFFORT:
|
||||
raise ValueError(
|
||||
f"Inkling reasoning_effort must be in [0.0, 0.99], got {value}"
|
||||
)
|
||||
|
||||
effort_text = f"{value:.2f}".rstrip("0").rstrip(".")
|
||||
if effort_text in {"0", "-0"}:
|
||||
effort_text = "0.0"
|
||||
return f"Thinking effort level: {effort_text}"
|
||||
|
||||
|
||||
def _append_message(
|
||||
input_ids: list[int],
|
||||
tokenizer: InklingTextTokenizer,
|
||||
role: str,
|
||||
kind: str,
|
||||
text: str,
|
||||
*,
|
||||
author_name: str | None = None,
|
||||
) -> None:
|
||||
input_ids.append(tokenizer.encode_special(ROLE_MESSAGE_TOKENS[role]))
|
||||
if author_name:
|
||||
input_ids.extend(tokenizer.encode_text(author_name))
|
||||
|
||||
if kind == "text":
|
||||
input_ids.append(tokenizer.encode_special(CONTENT_TEXT))
|
||||
input_ids.extend(tokenizer.encode_text(text))
|
||||
elif kind == "image":
|
||||
# Bare marker only: the MM processor replaces the marker with
|
||||
# `marker + placeholder * num_patches` (see mm_preprocess.py).
|
||||
input_ids.append(tokenizer.encode_special(CONTENT_IMAGE))
|
||||
elif kind == "audio":
|
||||
input_ids.append(tokenizer.encode_special(CONTENT_AUDIO_INPUT))
|
||||
input_ids.append(tokenizer.encode_special(AUDIO_END))
|
||||
elif kind == "thinking":
|
||||
input_ids.append(tokenizer.encode_special(CONTENT_THINKING))
|
||||
input_ids.extend(tokenizer.encode_text(text))
|
||||
elif kind == "xml":
|
||||
input_ids.append(tokenizer.encode_special(CONTENT_XML))
|
||||
input_ids.extend(tokenizer.encode_text(text))
|
||||
elif kind == "invoke_tool_json":
|
||||
input_ids.append(tokenizer.encode_special(CONTENT_INVOKE_TOOL_JSON))
|
||||
input_ids.extend(tokenizer.encode_text(text))
|
||||
else:
|
||||
raise ValueError(f"unsupported Inkling render part kind: {kind!r}")
|
||||
|
||||
input_ids.append(tokenizer.encode_special(END_MESSAGE))
|
||||
|
||||
|
||||
def _iter_render_parts(content: Any) -> Iterator[tuple[str, str]]:
|
||||
"""Yield (kind, text) per content part: kind in {text, image, audio}."""
|
||||
if content is None:
|
||||
return
|
||||
if isinstance(content, str):
|
||||
if content:
|
||||
yield ("text", content)
|
||||
return
|
||||
if not isinstance(content, Sequence) or isinstance(content, (bytes, bytearray)):
|
||||
raise TypeError("message content must be a string or a sequence of parts")
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
yield ("text", part)
|
||||
continue
|
||||
if not isinstance(part, Mapping):
|
||||
raise TypeError(f"content part must be mapping, got {type(part).__name__}")
|
||||
ptype = part.get("type")
|
||||
if ptype in (None, "text", "input_text"):
|
||||
text = part.get("text", "")
|
||||
yield ("text", text if isinstance(text, str) else "")
|
||||
elif ptype in _IMAGE_PART_TYPES:
|
||||
yield ("image", "")
|
||||
elif ptype in _AUDIO_PART_TYPES:
|
||||
yield ("audio", "")
|
||||
else:
|
||||
raise ValueError(f"unsupported content part type: {ptype!r}")
|
||||
|
||||
|
||||
def _flatten_text_content(content: Any) -> str:
|
||||
"""Flatten a tool-response content (string or text parts) to text."""
|
||||
if content is None:
|
||||
return ""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
parts: list[str] = []
|
||||
for kind, text in _iter_render_parts(content):
|
||||
if kind != "text":
|
||||
raise ValueError(
|
||||
"Inkling tool response content must be text, "
|
||||
f"got a part of kind {kind!r}"
|
||||
)
|
||||
parts.append(text)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _expect_role(message: Mapping[str, Any]) -> str:
|
||||
role = message.get("role")
|
||||
if role not in ROLE_MESSAGE_TOKENS:
|
||||
raise ValueError(
|
||||
f"unsupported Inkling message role {role!r}; "
|
||||
f"expected one of {sorted(ROLE_MESSAGE_TOKENS)}"
|
||||
)
|
||||
return str(role)
|
||||
|
||||
|
||||
def _as_mapping(value: Any) -> Mapping[str, Any]:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
if hasattr(value, "model_dump"):
|
||||
dumped = value.model_dump()
|
||||
if isinstance(dumped, Mapping):
|
||||
return dumped
|
||||
raise TypeError(f"expected mapping, got {type(value).__name__}")
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(
|
||||
_sort_json(value),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def _sort_json(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _sort_json(value[key]) for key in sorted(value)}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [_sort_json(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _tool_declare_json(tools: Sequence[Mapping[str, Any]]) -> str:
|
||||
tool_specs = []
|
||||
for tool_value in tools:
|
||||
tool = _as_mapping(tool_value)
|
||||
function = _as_mapping(tool.get("function", {}))
|
||||
tool_specs.append(
|
||||
{
|
||||
"description": function.get("description") or "",
|
||||
"name": function["name"],
|
||||
"parameters": function.get("parameters") or {},
|
||||
"type": tool.get("type", "function"),
|
||||
}
|
||||
)
|
||||
return _canonical_json(tool_specs)
|
||||
|
||||
|
||||
def _tool_call_name_and_args(tool_call_value: Any) -> tuple[str, Mapping[str, Any]]:
|
||||
tool_call = _as_mapping(tool_call_value)
|
||||
function = _as_mapping(tool_call.get("function", {}))
|
||||
name = function.get("name")
|
||||
if not isinstance(name, str):
|
||||
raise TypeError("tool call function name must be a string")
|
||||
|
||||
raw_args = function.get("arguments") or {}
|
||||
if isinstance(raw_args, str):
|
||||
args = json.loads(raw_args) if raw_args.strip() else {}
|
||||
else:
|
||||
args = raw_args
|
||||
if not isinstance(args, Mapping):
|
||||
raise TypeError("tool call function arguments must decode to an object")
|
||||
return name, args
|
||||
|
||||
|
||||
def _tool_call_json(name: str, args: Mapping[str, Any]) -> str:
|
||||
name_json = json.dumps(name, ensure_ascii=False, allow_nan=False)
|
||||
return f'{{"name":{name_json},"args":{_canonical_json(args)}}}'
|
||||
@@ -1,4 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Create and restore explicitly compatible vLLM engine snapshots."""
|
||||
@@ -1,132 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Create and restore transaction orchestration for local snapshots."""
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from vllm.snapshot.manifest import (
|
||||
read_manifest,
|
||||
validate_artifact_root,
|
||||
validate_identity,
|
||||
)
|
||||
from vllm.snapshot.runtime import (
|
||||
LocalSnapshotTools,
|
||||
SnapshotCreateError,
|
||||
SnapshotRestoreError,
|
||||
_error_detail,
|
||||
)
|
||||
from vllm.snapshot.types import Oracle, oracles_match
|
||||
|
||||
|
||||
def _child_engine_argv(
|
||||
args: argparse.Namespace, engine_argv: tuple[str, ...] | None
|
||||
) -> tuple[str, ...]:
|
||||
"""Build the child argv from the first argv source that is present.
|
||||
|
||||
In order: an explicit ``engine_argv`` from the caller, the process argv
|
||||
tail when invoked as ``vllm snapshot create ...``, then the bare model from
|
||||
the parsed args. Whichever source wins, snapshot options are stripped and
|
||||
sleep mode is appended.
|
||||
"""
|
||||
if not engine_argv:
|
||||
process_argv = tuple(sys.argv[1:])
|
||||
if process_argv[:2] == ("snapshot", "create"):
|
||||
engine_argv = process_argv[2:]
|
||||
else:
|
||||
model = getattr(args, "model_tag", None) or getattr(args, "model", None)
|
||||
if not model:
|
||||
raise SnapshotCreateError("snapshot create requires a model argument")
|
||||
engine_argv = (str(model),)
|
||||
child_argv: list[str] = []
|
||||
iterator = iter(engine_argv)
|
||||
for item in iterator:
|
||||
option, separator, _ = item.partition("=")
|
||||
option = option.replace("_", "-")
|
||||
if len(option) > 2 and "--snapshot-dir".startswith(option):
|
||||
if not separator:
|
||||
next(iterator, None)
|
||||
else:
|
||||
child_argv.append(item)
|
||||
if "--enable-sleep-mode" not in child_argv:
|
||||
child_argv.append("--enable-sleep-mode")
|
||||
return tuple(child_argv)
|
||||
|
||||
|
||||
def create_snapshot(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
engine_argv: tuple[str, ...] | None = None,
|
||||
tools: LocalSnapshotTools | None = None,
|
||||
) -> None:
|
||||
target = Path(args.snapshot_dir).absolute()
|
||||
toolset = tools or LocalSnapshotTools()
|
||||
toolset.preflight("create", target)
|
||||
if target.exists() or target.is_symlink():
|
||||
raise SnapshotCreateError(f"snapshot target already exists: {target}")
|
||||
validate_artifact_root(target, creating=True)
|
||||
target.parent.mkdir(mode=0o700, parents=False, exist_ok=True)
|
||||
validate_artifact_root(target.parent, creating=False)
|
||||
target.mkdir(mode=0o700)
|
||||
validate_artifact_root(target, creating=False)
|
||||
published = False
|
||||
root_pid: int | None = None
|
||||
try:
|
||||
child_argv = _child_engine_argv(args, engine_argv)
|
||||
root_pid = toolset.launch_child(target, child_argv)
|
||||
oracle = toolset.wait_ready(target, root_pid)
|
||||
inventory = toolset.inventory(root_pid, target)
|
||||
toolset.dump(target, inventory)
|
||||
toolset.verify_dead(inventory)
|
||||
manifest = toolset.make_manifest(args, child_argv, inventory, oracle, target)
|
||||
toolset.publish(target, manifest)
|
||||
published = True
|
||||
except BaseException:
|
||||
if root_pid is not None:
|
||||
toolset.abort_create(root_pid)
|
||||
raise
|
||||
finally:
|
||||
# Runs even when abort_create raised, so no partial artifact is left.
|
||||
if not published and target.exists():
|
||||
shutil.rmtree(target)
|
||||
|
||||
|
||||
def restore_snapshot(
|
||||
args: argparse.Namespace, *, tools: LocalSnapshotTools | None = None
|
||||
) -> None:
|
||||
artifact = Path(args.snapshot_dir).absolute()
|
||||
toolset = tools or LocalSnapshotTools()
|
||||
toolset.preflight("restore", artifact)
|
||||
manifest = read_manifest(artifact)
|
||||
validate_identity(manifest, toolset.current_identity(manifest.gpu_uuid))
|
||||
|
||||
root_pid: int | None = None
|
||||
try:
|
||||
root_pid = toolset.restore(artifact, manifest)
|
||||
toolset.release(artifact, args.host, args.port)
|
||||
toolset.wait_listener(root_pid, args.host, args.port)
|
||||
actual = toolset.request_oracle(args.host, args.port, manifest)
|
||||
expected = Oracle(
|
||||
token_ids=manifest.oracle_token_ids,
|
||||
text=manifest.oracle_text,
|
||||
sampled_token_logprob=manifest.oracle_sampled_token_logprob,
|
||||
)
|
||||
if not oracles_match(expected, actual):
|
||||
raise SnapshotRestoreError(
|
||||
f"snapshot oracle mismatch: expected={expected!r}, actual={actual!r}"
|
||||
)
|
||||
toolset.complete_restore(root_pid)
|
||||
except BaseException as error:
|
||||
if root_pid is not None:
|
||||
try:
|
||||
toolset.cleanup(root_pid)
|
||||
except BaseException as cleanup_error:
|
||||
raise SnapshotRestoreError(
|
||||
f"snapshot restore failed: {_error_detail(error)}; "
|
||||
f"cleanup failed: {_error_detail(cleanup_error)}"
|
||||
) from error
|
||||
if isinstance(error, SnapshotRestoreError):
|
||||
raise
|
||||
raise SnapshotRestoreError(str(error)) from error
|
||||
@@ -1,276 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal, Self
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
StrictFloat,
|
||||
StrictInt,
|
||||
StrictStr,
|
||||
ValidationError,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
class SnapshotCompatibilityError(RuntimeError):
|
||||
"""The snapshot identity does not match the requested runtime."""
|
||||
|
||||
|
||||
class SnapshotSecurityError(RuntimeError):
|
||||
"""The snapshot artifact does not meet the private-path contract."""
|
||||
|
||||
|
||||
_MAX_PID = 2**31 - 1
|
||||
_PositivePid = Annotated[int, Field(gt=0, le=_MAX_PID)]
|
||||
_OracleTokenId = Annotated[int, Field(ge=0)]
|
||||
|
||||
|
||||
class SnapshotRuntimeIdentity(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
|
||||
|
||||
vllm_version: str
|
||||
python_version: str
|
||||
torch_version: str
|
||||
cuda_runtime: str
|
||||
driver_version: str
|
||||
criu_version: str
|
||||
cuda_checkpoint_sha256: str
|
||||
kernel_release: str
|
||||
host_id: str
|
||||
gpu_name: str
|
||||
gpu_uuid: str
|
||||
environment: tuple[tuple[str, str], ...]
|
||||
|
||||
|
||||
class SnapshotManifest(SnapshotRuntimeIdentity):
|
||||
schema_version: Literal[1]
|
||||
boundary: Literal["post-engine-init-reloadable-state-released"]
|
||||
created_at: str
|
||||
artifact_bytes: Annotated[int, Field(ge=0)]
|
||||
model: str
|
||||
served_model_name: str
|
||||
model_revision: str
|
||||
tokenizer_revision: str
|
||||
engine_argv: tuple[str, ...]
|
||||
process_tree: Annotated[tuple[_PositivePid, ...], Field(min_length=1)]
|
||||
cuda_holders: Annotated[tuple[_PositivePid, ...], Field(min_length=1)]
|
||||
external_cache_files: tuple[tuple[str, str], ...] = ()
|
||||
oracle_token_ids: Annotated[
|
||||
tuple[_OracleTokenId, ...], Field(min_length=1, max_length=1)
|
||||
]
|
||||
oracle_text: str
|
||||
oracle_sampled_token_logprob: float
|
||||
|
||||
@field_validator("schema_version", mode="before")
|
||||
@classmethod
|
||||
def _require_schema_version_one(cls, value: Any) -> Any:
|
||||
if type(value) is not int or value != 1:
|
||||
raise ValueError("schema version must be integer 1")
|
||||
return value
|
||||
|
||||
@field_validator("oracle_sampled_token_logprob", mode="before")
|
||||
@classmethod
|
||||
def _require_finite_float(cls, value: Any) -> Any:
|
||||
if type(value) is not float or not math.isfinite(value):
|
||||
raise ValueError("sampled token log probability must be a finite float")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_process_relationships(self) -> Self:
|
||||
if len(set(self.process_tree)) != len(self.process_tree):
|
||||
raise ValueError("process_tree contains duplicate PIDs")
|
||||
if len(set(self.cuda_holders)) != len(self.cuda_holders):
|
||||
raise ValueError("cuda_holders contains duplicate PIDs")
|
||||
if not set(self.cuda_holders).issubset(self.process_tree):
|
||||
raise ValueError("cuda_holders must be a subset of process_tree")
|
||||
return self
|
||||
|
||||
|
||||
class ReleaseMarker(BaseModel):
|
||||
"""Release marker the controller writes; unknown keys are ignored."""
|
||||
|
||||
release: Literal[True]
|
||||
host: StrictStr | None = None
|
||||
port: Annotated[StrictInt, Field(ge=1, le=65535)]
|
||||
|
||||
@field_validator("release", mode="before")
|
||||
@classmethod
|
||||
def _require_json_true(cls, value: Any) -> Any:
|
||||
# A literal accepts 1 as True; the barrier requires JSON true itself.
|
||||
if value is not True:
|
||||
raise ValueError("release must be JSON true")
|
||||
return value
|
||||
|
||||
|
||||
class ReadyMarker(BaseModel):
|
||||
"""Ready marker the child writes; unknown keys are ignored."""
|
||||
|
||||
token_ids: tuple[StrictInt, ...]
|
||||
text: StrictStr
|
||||
sampled_token_logprob: StrictFloat | StrictInt
|
||||
|
||||
|
||||
def validate_identity(
|
||||
expected: SnapshotRuntimeIdentity, actual: SnapshotRuntimeIdentity
|
||||
) -> None:
|
||||
"""Require an exact match for every field that can affect compatibility."""
|
||||
# ``expected`` may be a manifest, so compare the identity fields only.
|
||||
fields = set(SnapshotRuntimeIdentity.model_fields)
|
||||
expected_values = expected.model_dump(include=fields)
|
||||
actual_values = actual.model_dump(include=fields)
|
||||
differing = [
|
||||
name
|
||||
for name in SnapshotRuntimeIdentity.model_fields
|
||||
if expected_values[name] != actual_values[name]
|
||||
]
|
||||
if differing:
|
||||
raise SnapshotCompatibilityError(f"snapshot mismatch: {', '.join(differing)}")
|
||||
|
||||
|
||||
def _existing_path_chain(path: Path):
|
||||
current = path
|
||||
while True:
|
||||
if current.exists() or current.is_symlink():
|
||||
yield current
|
||||
if current.parent == current:
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _is_trusted_sticky_directory(path_stat: os.stat_result) -> bool:
|
||||
return (
|
||||
path_stat.st_uid == 0
|
||||
and stat.S_ISDIR(path_stat.st_mode)
|
||||
and bool(path_stat.st_mode & stat.S_ISVTX)
|
||||
)
|
||||
|
||||
|
||||
def validate_artifact_root(path: Path, *, creating: bool) -> None:
|
||||
"""Validate that an artifact path cannot be replaced by another user."""
|
||||
path = Path(os.path.abspath(path))
|
||||
if path.is_symlink():
|
||||
raise SnapshotSecurityError(f"snapshot path is a symlink: {path}")
|
||||
if not creating and not path.exists():
|
||||
raise SnapshotSecurityError(f"snapshot path does not exist: {path}")
|
||||
|
||||
effective_uid = os.geteuid()
|
||||
for component in _existing_path_chain(path):
|
||||
component_stat = component.lstat()
|
||||
if stat.S_ISLNK(component_stat.st_mode):
|
||||
raise SnapshotSecurityError(
|
||||
f"snapshot path contains a symlink: {component}"
|
||||
)
|
||||
if component_stat.st_uid not in (0, effective_uid):
|
||||
raise SnapshotSecurityError(
|
||||
f"snapshot path has an untrusted owner: {component}"
|
||||
)
|
||||
if component_stat.st_mode & (
|
||||
stat.S_IWGRP | stat.S_IWOTH
|
||||
) and not _is_trusted_sticky_directory(component_stat):
|
||||
raise SnapshotSecurityError(
|
||||
f"snapshot path has a group- or world-writable ancestor: {component}"
|
||||
)
|
||||
|
||||
if path.exists():
|
||||
path_stat = path.lstat()
|
||||
if not stat.S_ISDIR(path_stat.st_mode):
|
||||
raise SnapshotSecurityError(f"snapshot path is not a directory: {path}")
|
||||
if path_stat.st_uid != effective_uid:
|
||||
raise SnapshotSecurityError(
|
||||
f"snapshot directory is not owned by the current user: {path}"
|
||||
)
|
||||
if stat.S_IMODE(path_stat.st_mode) & 0o077:
|
||||
raise SnapshotSecurityError(
|
||||
f"snapshot directory must have mode 0700: {path}"
|
||||
)
|
||||
|
||||
|
||||
def _validation_path(error: ValidationError) -> str:
|
||||
detail = error.errors(include_url=False)[0]
|
||||
location = detail["loc"]
|
||||
if not location:
|
||||
return "JSON" if detail["type"] == "json_invalid" else "root"
|
||||
return ".".join(map(str, location))
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
directory_fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, payload: object, *, overwrite: bool = False) -> None:
|
||||
temporary = path.with_name(f"{path.name}.tmp")
|
||||
if overwrite:
|
||||
temporary.unlink(missing_ok=True)
|
||||
elif path.exists() or path.is_symlink():
|
||||
raise FileExistsError(path)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
output.write(
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.replace(temporary, path)
|
||||
_fsync_directory(path.parent)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def write_manifest_atomic(path: Path, manifest: SnapshotManifest) -> None:
|
||||
"""Write a new private manifest and make its directory entry durable."""
|
||||
path = Path(path)
|
||||
validate_artifact_root(path, creating=False)
|
||||
destination = path / "manifest.json"
|
||||
try:
|
||||
_write_json_atomic(destination, manifest.model_dump(mode="json"))
|
||||
except FileExistsError as error:
|
||||
raise SnapshotSecurityError(
|
||||
f"manifest already exists: {destination}"
|
||||
) from error
|
||||
|
||||
|
||||
def read_manifest(path: Path) -> SnapshotManifest:
|
||||
"""Read a snapshot published by atomically installing its manifest."""
|
||||
path = Path(path)
|
||||
validate_artifact_root(path, creating=False)
|
||||
manifest_path = path / "manifest.json"
|
||||
if manifest_path.is_symlink():
|
||||
raise SnapshotSecurityError(f"manifest is a symlink: {manifest_path}")
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
file_descriptor = os.open(manifest_path, flags)
|
||||
except FileNotFoundError as error:
|
||||
raise SnapshotSecurityError(
|
||||
f"manifest does not exist: {manifest_path}"
|
||||
) from error
|
||||
try:
|
||||
with os.fdopen(file_descriptor, encoding="utf-8") as manifest_file:
|
||||
payload = manifest_file.read()
|
||||
except UnicodeDecodeError as error:
|
||||
raise SnapshotCompatibilityError("invalid snapshot manifest: JSON") from error
|
||||
try:
|
||||
return SnapshotManifest.model_validate_json(payload, strict=True)
|
||||
except ValidationError as error:
|
||||
raise SnapshotCompatibilityError(
|
||||
f"invalid snapshot manifest: {_validation_path(error)}"
|
||||
) from error
|
||||
|
||||
|
||||
def inspect_snapshot(path: Path) -> dict[str, object]:
|
||||
return read_manifest(path).model_dump(mode="json")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,252 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vllm.snapshot.manifest import ReleaseMarker, _validation_path, _write_json_atomic
|
||||
from vllm.snapshot.types import Oracle, oracles_match
|
||||
|
||||
_CANARY_PROMPT = "The capital of France is"
|
||||
|
||||
|
||||
class SnapshotCanaryError(RuntimeError):
|
||||
"""The initialized engine did not produce a valid snapshot oracle."""
|
||||
|
||||
|
||||
class SnapshotBarrierError(RuntimeError):
|
||||
"""The controller supplied an invalid snapshot release marker."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ListenerConfig:
|
||||
host: str | None
|
||||
port: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlArgs:
|
||||
ready_file: Path
|
||||
release_file: Path
|
||||
release_timeout_s: float
|
||||
|
||||
|
||||
def write_ready_atomic(path: Path, oracle: Oracle) -> None:
|
||||
_write_json_atomic(
|
||||
path,
|
||||
{
|
||||
"sampled_token_logprob": oracle.sampled_token_logprob,
|
||||
"token_ids": oracle.token_ids,
|
||||
"text": oracle.text,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def read_release_marker(path: Path) -> ListenerConfig:
|
||||
path = Path(path)
|
||||
path_stat = path.lstat()
|
||||
if stat.S_ISLNK(path_stat.st_mode):
|
||||
raise SnapshotBarrierError("release marker must not be a symlink")
|
||||
try:
|
||||
payload = json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise SnapshotBarrierError("release marker is not valid JSON") from error
|
||||
try:
|
||||
marker = ReleaseMarker.model_validate(payload)
|
||||
except ValidationError as error:
|
||||
raise SnapshotBarrierError(
|
||||
f"release marker is invalid: {_validation_path(error)}"
|
||||
) from error
|
||||
return ListenerConfig(host=marker.host, port=marker.port)
|
||||
|
||||
|
||||
def parse_control_args(argv: list[str]) -> tuple[ControlArgs, list[str]]:
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("--ready-file", type=Path, required=True)
|
||||
parser.add_argument("--release-file", type=Path, required=True)
|
||||
parser.add_argument("--release-timeout-s", type=float, default=900.0)
|
||||
control_args, remaining = parser.parse_known_args(argv)
|
||||
if remaining and remaining[0] == "--":
|
||||
remaining = remaining[1:]
|
||||
if control_args.release_timeout_s <= 0:
|
||||
raise ValueError("release timeout must be positive")
|
||||
return (
|
||||
ControlArgs(
|
||||
ready_file=control_args.ready_file,
|
||||
release_file=control_args.release_file,
|
||||
release_timeout_s=control_args.release_timeout_s,
|
||||
),
|
||||
remaining,
|
||||
)
|
||||
|
||||
|
||||
async def _release_reloadable_state(engine: Any) -> None:
|
||||
"""Discard model and KV state before the process image is captured."""
|
||||
await engine.sleep(level=2)
|
||||
|
||||
|
||||
async def _restore_reloadable_state(engine: Any) -> None:
|
||||
"""Rebuild state discarded by ``_release_reloadable_state``."""
|
||||
await engine.wake_up(tags=["weights"])
|
||||
await engine.collective_rpc("reload_weights")
|
||||
await engine.wake_up(tags=["kv_cache"])
|
||||
|
||||
|
||||
def oracle_from_request_output(request_output: Any) -> Oracle:
|
||||
try:
|
||||
candidate = request_output.outputs[0]
|
||||
token_ids = tuple(candidate.token_ids)
|
||||
(sampled_token_id,) = token_ids
|
||||
return Oracle(
|
||||
token_ids=token_ids,
|
||||
text=candidate.text,
|
||||
sampled_token_logprob=candidate.logprobs[0][sampled_token_id].logprob,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise SnapshotCanaryError(
|
||||
"snapshot canary must produce exactly one finite token logprob"
|
||||
) from error
|
||||
except (AttributeError, IndexError, KeyError, TypeError) as error:
|
||||
raise SnapshotCanaryError(
|
||||
"snapshot canary did not return sampled token logprob"
|
||||
) from error
|
||||
|
||||
|
||||
async def run_engine_canary(engine: Any) -> Oracle:
|
||||
from vllm import SamplingParams
|
||||
|
||||
final_output = None
|
||||
sampling_params = SamplingParams(
|
||||
temperature=0,
|
||||
min_tokens=1,
|
||||
max_tokens=1,
|
||||
seed=0,
|
||||
logprobs=0,
|
||||
)
|
||||
async for output in engine.generate(
|
||||
_CANARY_PROMPT,
|
||||
sampling_params,
|
||||
request_id="vllm-snapshot-canary",
|
||||
):
|
||||
final_output = output
|
||||
if final_output is None:
|
||||
raise SnapshotCanaryError("canary generation returned no output")
|
||||
return oracle_from_request_output(final_output)
|
||||
|
||||
|
||||
def detach_snapshot_streams() -> None:
|
||||
"""Remove launch-log descriptors from the reusable process image."""
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
sink = os.open(os.devnull, os.O_WRONLY)
|
||||
try:
|
||||
os.dup2(sink, sys.stdout.fileno())
|
||||
os.dup2(sink, sys.stderr.fileno())
|
||||
finally:
|
||||
os.close(sink)
|
||||
|
||||
|
||||
async def wait_for_release_marker(
|
||||
path: Path,
|
||||
*,
|
||||
timeout_s: float,
|
||||
poll_interval_s: float = 0.05,
|
||||
) -> ListenerConfig:
|
||||
if timeout_s <= 0:
|
||||
raise ValueError("release timeout must be positive")
|
||||
if poll_interval_s <= 0:
|
||||
raise ValueError("release poll interval must be positive")
|
||||
remaining_s = timeout_s
|
||||
while not path.exists():
|
||||
if remaining_s <= 0:
|
||||
raise TimeoutError(f"release marker not found before timeout: {path}")
|
||||
delay_s = min(poll_interval_s, remaining_s)
|
||||
await asyncio.sleep(delay_s)
|
||||
remaining_s -= delay_s
|
||||
return read_release_marker(path)
|
||||
|
||||
|
||||
def parse_vllm_args(argv: list[str]) -> Any:
|
||||
from vllm.entrypoints.launchers.cli_args import (
|
||||
make_arg_parser,
|
||||
validate_parsed_serve_args,
|
||||
)
|
||||
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||
|
||||
parser = FlexibleArgumentParser(prog="python -m vllm.snapshot.server")
|
||||
args = make_arg_parser(parser).parse_args(argv)
|
||||
if args.model_tag is not None:
|
||||
args.model = args.model_tag
|
||||
if args.grpc or args.headless:
|
||||
raise ValueError("snapshot server requires the HTTP frontend")
|
||||
if args.api_server_count not in (None, 1):
|
||||
raise ValueError("snapshot server supports one API server")
|
||||
args.api_server_count = None
|
||||
validate_parsed_serve_args(args)
|
||||
return args
|
||||
|
||||
|
||||
async def run_vllm_snapshot_child(control: ControlArgs, args: Any) -> None:
|
||||
from vllm.entrypoints.launchers.api_server.entry import (
|
||||
build_and_serve,
|
||||
build_async_engine_client,
|
||||
)
|
||||
from vllm.entrypoints.launchers.launcher import setup_server
|
||||
|
||||
args.enable_sleep_mode = True
|
||||
|
||||
async with build_async_engine_client(args) as engine:
|
||||
oracle = await run_engine_canary(engine)
|
||||
try:
|
||||
await _release_reloadable_state(engine)
|
||||
await _restore_reloadable_state(engine)
|
||||
rehearsal_oracle = await run_engine_canary(engine)
|
||||
if not oracles_match(oracle, rehearsal_oracle):
|
||||
raise SnapshotCanaryError("snapshot rehearsal changed canary output")
|
||||
await _release_reloadable_state(engine)
|
||||
except SnapshotCanaryError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SnapshotCanaryError("snapshot rehearsal failed") from error
|
||||
|
||||
detach_snapshot_streams()
|
||||
write_ready_atomic(control.ready_file, oracle)
|
||||
listener = await wait_for_release_marker(
|
||||
control.release_file,
|
||||
timeout_s=control.release_timeout_s,
|
||||
)
|
||||
args.host = listener.host
|
||||
args.port = listener.port
|
||||
await _restore_reloadable_state(engine)
|
||||
listen_address, sock = setup_server(args, reuse_port=False)
|
||||
try:
|
||||
shutdown_task = await build_and_serve(
|
||||
engine,
|
||||
listen_address,
|
||||
sock,
|
||||
args,
|
||||
)
|
||||
await shutdown_task
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
control, remaining = parse_control_args(sys.argv[1:] if argv is None else argv)
|
||||
args = parse_vllm_args(remaining)
|
||||
import uvloop
|
||||
|
||||
uvloop.run(run_vllm_snapshot_child(control, args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,37 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Oracle:
|
||||
token_ids: tuple[int, ...]
|
||||
text: str
|
||||
sampled_token_logprob: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.token_ids) != 1:
|
||||
raise ValueError("snapshot oracle must contain exactly one token")
|
||||
value = self.sampled_token_logprob
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (int, float))
|
||||
or not math.isfinite(value)
|
||||
):
|
||||
raise ValueError("sampled token logprob must be finite")
|
||||
object.__setattr__(self, "sampled_token_logprob", float(value))
|
||||
|
||||
|
||||
def oracles_match(expected: Oracle, actual: Oracle) -> bool:
|
||||
return (
|
||||
expected.token_ids == actual.token_ids
|
||||
and expected.text == actual.text
|
||||
and math.isclose(
|
||||
expected.sampled_token_logprob,
|
||||
actual.sampled_token_logprob,
|
||||
rel_tol=0.0,
|
||||
abs_tol=1e-3,
|
||||
)
|
||||
)
|
||||
@@ -1,459 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# adapted from https://github.com/deepseek-ai/DeepSeek-OCR/blob/main/DeepSeek-OCR-master/DeepSeek-OCR-vllm/process/image_process.py
|
||||
# and https://github.com/deepseek-ai/DeepSeek-OCR-2/blob/main/DeepSeek-OCR2-master/DeepSeek-OCR2-vllm/process/image_process.py
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
import torch
|
||||
import torchvision.transforms as T
|
||||
from PIL import Image, ImageOps
|
||||
from transformers import BatchFeature, LlamaTokenizerFast
|
||||
from transformers.processing_utils import ProcessorMixin
|
||||
|
||||
# TODO(Isotr0py): change modes for variants
|
||||
# see: https://github.com/deepseek-ai/DeepSeek-OCR/blob/8cf003d38821fa1b19c73da3bd1b0dc262ea8136/DeepSeek-OCR-master/DeepSeek-OCR-vllm/config.py#L1-L6
|
||||
# Tiny: base_size = 512, image_size = 512, crop_mode = False
|
||||
# Small: base_size = 640, image_size = 640, crop_mode = False
|
||||
# Base: base_size = 1024, image_size = 1024, crop_mode = False
|
||||
# Large: base_size = 1280, image_size = 1280, crop_mode = False
|
||||
# Gundam: base_size = 1024, image_size = 640, crop_mode = True
|
||||
BASE_SIZE = 1024
|
||||
IMAGE_SIZE = 640
|
||||
CROP_MODE = True
|
||||
|
||||
# TODO(Isotr0py): Expose as mm_kwargs
|
||||
MIN_CROPS = 2
|
||||
MAX_CROPS = 6 # max:9; If your GPU memory is small, it is recommended to set it to 6.
|
||||
|
||||
|
||||
def find_closest_aspect_ratio(aspect_ratio, target_ratios, width, height, image_size):
|
||||
best_ratio_diff = float("inf")
|
||||
best_ratio = (1, 1)
|
||||
area = width * height
|
||||
for ratio in target_ratios:
|
||||
target_aspect_ratio = ratio[0] / ratio[1]
|
||||
ratio_diff = abs(aspect_ratio - target_aspect_ratio)
|
||||
if ratio_diff < best_ratio_diff:
|
||||
best_ratio_diff = ratio_diff
|
||||
best_ratio = ratio
|
||||
elif ratio_diff == best_ratio_diff:
|
||||
if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]:
|
||||
best_ratio = ratio
|
||||
return best_ratio
|
||||
|
||||
|
||||
def calculate_aspect_ratios(
|
||||
min_num: int = MIN_CROPS, max_num: int = MAX_CROPS
|
||||
) -> list[tuple[int, int]]:
|
||||
target_ratios: set[tuple[int, int]] = set(
|
||||
(i, j)
|
||||
for n in range(min_num, max_num + 1)
|
||||
for i in range(1, n + 1)
|
||||
for j in range(1, n + 1)
|
||||
if i * j <= max_num and i * j >= min_num
|
||||
)
|
||||
sorted_target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])
|
||||
return sorted_target_ratios
|
||||
|
||||
|
||||
def count_tiles(
|
||||
orig_width,
|
||||
orig_height,
|
||||
min_num=MIN_CROPS,
|
||||
max_num=MAX_CROPS,
|
||||
image_size=640,
|
||||
use_thumbnail=False,
|
||||
):
|
||||
aspect_ratio = orig_width / orig_height
|
||||
|
||||
# calculate the existing image aspect ratio
|
||||
target_ratios = calculate_aspect_ratios(min_num, max_num)
|
||||
|
||||
# find the closest aspect ratio to the target
|
||||
target_aspect_ratio = find_closest_aspect_ratio(
|
||||
aspect_ratio, target_ratios, orig_width, orig_height, image_size
|
||||
)
|
||||
|
||||
return target_aspect_ratio
|
||||
|
||||
|
||||
def dynamic_preprocess(
|
||||
image, min_num=MIN_CROPS, max_num=MAX_CROPS, image_size=640, use_thumbnail=False
|
||||
):
|
||||
orig_width, orig_height = image.size
|
||||
aspect_ratio = orig_width / orig_height
|
||||
|
||||
# calculate the existing image aspect ratio
|
||||
target_ratios = calculate_aspect_ratios(min_num, max_num)
|
||||
|
||||
# find the closest aspect ratio to the target
|
||||
target_aspect_ratio = find_closest_aspect_ratio(
|
||||
aspect_ratio, target_ratios, orig_width, orig_height, image_size
|
||||
)
|
||||
|
||||
# calculate the target width and height
|
||||
target_width = image_size * target_aspect_ratio[0]
|
||||
target_height = image_size * target_aspect_ratio[1]
|
||||
blocks = target_aspect_ratio[0] * target_aspect_ratio[1]
|
||||
|
||||
# resize the image
|
||||
resized_img = image.resize((target_width, target_height))
|
||||
processed_images = []
|
||||
for i in range(blocks):
|
||||
box = (
|
||||
(i % (target_width // image_size)) * image_size,
|
||||
(i // (target_width // image_size)) * image_size,
|
||||
((i % (target_width // image_size)) + 1) * image_size,
|
||||
((i // (target_width // image_size)) + 1) * image_size,
|
||||
)
|
||||
# split the image
|
||||
split_img = resized_img.crop(box)
|
||||
processed_images.append(split_img)
|
||||
assert len(processed_images) == blocks
|
||||
if use_thumbnail and len(processed_images) != 1:
|
||||
thumbnail_img = image.resize((image_size, image_size))
|
||||
processed_images.append(thumbnail_img)
|
||||
return processed_images, target_aspect_ratio
|
||||
|
||||
|
||||
class ImageTransform:
|
||||
def __init__(
|
||||
self,
|
||||
mean: tuple[float, float, float] = (0.5, 0.5, 0.5),
|
||||
std: tuple[float, float, float] = (0.5, 0.5, 0.5),
|
||||
normalize: bool = True,
|
||||
):
|
||||
self.mean = mean
|
||||
self.std = std
|
||||
self.normalize = normalize
|
||||
|
||||
transform_pipelines = [T.ToTensor()]
|
||||
|
||||
if normalize:
|
||||
transform_pipelines.append(T.Normalize(mean, std))
|
||||
|
||||
self.transform = T.Compose(transform_pipelines)
|
||||
|
||||
def __call__(self, pil_img: Image.Image):
|
||||
x = self.transform(pil_img)
|
||||
return x
|
||||
|
||||
|
||||
class DeepseekOCRProcessor(ProcessorMixin):
|
||||
tokenizer_class = ("LlamaTokenizer", "LlamaTokenizerFast")
|
||||
attributes = ["tokenizer"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer: LlamaTokenizerFast,
|
||||
patch_size: int = 16,
|
||||
downsample_ratio: int = 4,
|
||||
image_mean: tuple[float, float, float] = (0.5, 0.5, 0.5),
|
||||
image_std: tuple[float, float, float] = (0.5, 0.5, 0.5),
|
||||
normalize: bool = True,
|
||||
image_token: str = "<image>",
|
||||
pad_token: str = "<|▁pad▁|>",
|
||||
add_special_token: bool = False,
|
||||
sft_format: str = "deepseek",
|
||||
mask_prompt: bool = True,
|
||||
ignore_id: int = -100,
|
||||
image_size: int = IMAGE_SIZE,
|
||||
base_size: int = BASE_SIZE,
|
||||
strategy: Literal["v1", "v2"] = "v1",
|
||||
max_crops: int = MAX_CROPS,
|
||||
**kwargs,
|
||||
):
|
||||
self.image_size = image_size
|
||||
self.base_size = base_size
|
||||
self.max_crops = max_crops
|
||||
|
||||
# image token calculation strategy for
|
||||
# Deepseek-OCR and Deepseek-OCR-2
|
||||
self.strategy = strategy
|
||||
assert strategy in ["v1", "v2"], "Only 'v1' and 'v2' strategies are supported."
|
||||
|
||||
self.patch_size = 16
|
||||
self.image_mean = image_mean
|
||||
self.image_std = image_std
|
||||
self.normalize = normalize
|
||||
self.downsample_ratio = 4
|
||||
|
||||
self.image_transform = ImageTransform(
|
||||
mean=image_mean, std=image_std, normalize=normalize
|
||||
)
|
||||
|
||||
self.tokenizer = tokenizer
|
||||
self.tokenizer.padding_side = "left" # must set this,padding side with make a difference in batch inference # noqa: E501
|
||||
|
||||
# add the pad_token as special token to use 'tokenizer.pad_token'
|
||||
# and 'tokenizer.pad_token_id'
|
||||
if self.tokenizer.pad_token is None:
|
||||
self.tokenizer.add_special_tokens({"pad_token": pad_token})
|
||||
|
||||
# add image token
|
||||
self.image_token_id = self.tokenizer.vocab.get(image_token)
|
||||
self.image_token = image_token
|
||||
self.pad_token = pad_token
|
||||
self.add_special_token = add_special_token
|
||||
self.sft_format = sft_format
|
||||
self.mask_prompt = mask_prompt
|
||||
self.ignore_id = ignore_id
|
||||
|
||||
super().__init__(
|
||||
tokenizer,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@property
|
||||
def bos_id(self):
|
||||
return self.tokenizer.bos_token_id
|
||||
|
||||
@property
|
||||
def eos_id(self):
|
||||
return self.tokenizer.eos_token_id
|
||||
|
||||
@property
|
||||
def pad_id(self):
|
||||
return self.tokenizer.pad_token_id
|
||||
|
||||
def encode(self, text: str, bos: bool = True, eos: bool = False):
|
||||
t = self.tokenizer.encode(text, add_special_tokens=False)
|
||||
if bos:
|
||||
t = [self.bos_id] + t
|
||||
if eos:
|
||||
t = t + [self.eos_id]
|
||||
return t
|
||||
|
||||
def decode(self, t: list[int], **kwargs) -> str:
|
||||
return self.tokenizer.decode(t, **kwargs)
|
||||
|
||||
def process_one(
|
||||
self,
|
||||
prompt: str,
|
||||
images: list[Image.Image],
|
||||
crop_mode: bool = CROP_MODE,
|
||||
):
|
||||
"""Args:
|
||||
prompt (str): the formatted prompt;
|
||||
images (List[ImageType]): the list of images;
|
||||
crop_mode (bool): if True, then crop the image;
|
||||
|
||||
Returns:
|
||||
outputs (BaseProcessorOutput): the output of the processor,
|
||||
- input_ids (torch.LongTensor): [N + image tokens]
|
||||
- target_ids (torch.LongTensor): [N + image tokens]
|
||||
- pixel_values (torch.FloatTensor): [n_patches, 3, H, W]
|
||||
- image_id (int): the id of the image token
|
||||
- num_image_tokens (List[int]): the number of image tokens
|
||||
|
||||
"""
|
||||
if prompt is None or images is None:
|
||||
raise ValueError("prompt and images must be used at the same time.")
|
||||
|
||||
sft_format = prompt
|
||||
|
||||
(
|
||||
input_ids,
|
||||
pixel_values,
|
||||
images_crop,
|
||||
images_seq_mask,
|
||||
images_spatial_crop,
|
||||
num_image_tokens,
|
||||
_,
|
||||
) = self.tokenize_with_images(
|
||||
conversation=sft_format,
|
||||
images=images,
|
||||
bos=True,
|
||||
eos=True,
|
||||
cropping=crop_mode,
|
||||
)
|
||||
|
||||
prepare = BatchFeature(
|
||||
data=dict(
|
||||
input_ids=input_ids,
|
||||
pixel_values=pixel_values,
|
||||
images_crop=images_crop,
|
||||
images_seq_mask=images_seq_mask,
|
||||
images_spatial_crop=images_spatial_crop,
|
||||
num_image_tokens=num_image_tokens,
|
||||
),
|
||||
tensor_type="pt",
|
||||
)
|
||||
return prepare
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
prompt: str,
|
||||
images: list[Image.Image],
|
||||
crop_mode: bool = CROP_MODE,
|
||||
**kwargs,
|
||||
):
|
||||
prepare = self.process_one(
|
||||
prompt=prompt,
|
||||
images=images,
|
||||
crop_mode=crop_mode,
|
||||
)
|
||||
|
||||
return prepare
|
||||
|
||||
def tokenize_with_images(
|
||||
self,
|
||||
conversation: str,
|
||||
images: list[Image.Image],
|
||||
bos: bool = True,
|
||||
eos: bool = True,
|
||||
cropping: bool = True,
|
||||
):
|
||||
"""Tokenize text with <image> tags."""
|
||||
num_image_tags = conversation.count(self.image_token)
|
||||
if num_image_tags != len(images):
|
||||
raise ValueError(
|
||||
f"Number of {self.image_token!r} tokens in prompt "
|
||||
f"({num_image_tags}) does not match number of images "
|
||||
f"({len(images)})."
|
||||
)
|
||||
text_splits = conversation.split(self.image_token)
|
||||
images_list, images_crop_list, images_seq_mask, images_spatial_crop = (
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
)
|
||||
image_shapes = []
|
||||
num_image_tokens = []
|
||||
tokenized_str = []
|
||||
for text_sep, image in zip(text_splits, images):
|
||||
tokenized_sep = self.encode(text_sep, bos=False, eos=False)
|
||||
tokenized_str += tokenized_sep
|
||||
images_seq_mask += [False] * len(tokenized_sep)
|
||||
|
||||
image_shapes.append(image.size)
|
||||
|
||||
images_crop_raw = []
|
||||
if image.size[0] <= self.image_size and image.size[1] <= self.image_size:
|
||||
crop_ratio = [1, 1]
|
||||
elif cropping:
|
||||
images_crop_raw, crop_ratio = dynamic_preprocess(
|
||||
image, image_size=self.image_size, max_num=self.max_crops
|
||||
)
|
||||
else:
|
||||
crop_ratio = [1, 1]
|
||||
|
||||
if not cropping:
|
||||
image = image.resize((self.image_size, self.image_size))
|
||||
|
||||
global_view = ImageOps.pad(
|
||||
image,
|
||||
(self.base_size, self.base_size),
|
||||
color=tuple(int(x * 255) for x in self.image_transform.mean),
|
||||
)
|
||||
images_list.append(self.image_transform(global_view))
|
||||
|
||||
num_width_tiles, num_height_tiles = crop_ratio
|
||||
images_spatial_crop.append([num_width_tiles, num_height_tiles])
|
||||
|
||||
if num_width_tiles > 1 or num_height_tiles > 1:
|
||||
for cropped_image in images_crop_raw:
|
||||
images_crop_list.append(self.image_transform(cropped_image))
|
||||
|
||||
num_queries = math.ceil(
|
||||
(self.image_size // self.patch_size) / self.downsample_ratio
|
||||
)
|
||||
num_queries_base = math.ceil(
|
||||
(self.base_size // self.patch_size) / self.downsample_ratio
|
||||
)
|
||||
|
||||
num_tokens_base = (
|
||||
(num_queries_base * (num_queries_base + 1))
|
||||
if self.strategy == "v1"
|
||||
else num_queries_base * num_queries_base
|
||||
)
|
||||
tokenized_image = [self.image_token_id] * num_tokens_base
|
||||
|
||||
tokenized_image += [self.image_token_id]
|
||||
if num_width_tiles > 1 or num_height_tiles > 1:
|
||||
num_tokens_per_row = (
|
||||
num_queries * num_width_tiles + 1
|
||||
if self.strategy == "v1"
|
||||
else num_queries * num_width_tiles
|
||||
)
|
||||
local_row = [self.image_token_id] * num_tokens_per_row
|
||||
tokenized_image += local_row * (num_queries * num_height_tiles)
|
||||
tokenized_str += tokenized_image
|
||||
images_seq_mask += [True] * len(tokenized_image)
|
||||
num_image_tokens.append(len(tokenized_image))
|
||||
|
||||
"""process the last text split"""
|
||||
tokenized_sep = self.encode(text_splits[-1], bos=False, eos=False)
|
||||
tokenized_str += tokenized_sep
|
||||
images_seq_mask += [False] * len(tokenized_sep)
|
||||
|
||||
"""add the bos and eos tokens"""
|
||||
if bos:
|
||||
tokenized_str = [self.bos_id] + tokenized_str
|
||||
images_seq_mask = [False] + images_seq_mask
|
||||
if eos:
|
||||
tokenized_str = tokenized_str + [self.eos_id]
|
||||
images_seq_mask = images_seq_mask + [False]
|
||||
|
||||
assert len(tokenized_str) == len(images_seq_mask), (
|
||||
f"tokenize_with_images func: tokenized_str's length {len(tokenized_str)} "
|
||||
f"is not equal to images_seq_mask's length {len(images_seq_mask)}."
|
||||
)
|
||||
|
||||
masked_tokenized_str = []
|
||||
for token_index in tokenized_str:
|
||||
if token_index != self.image_token_id:
|
||||
masked_tokenized_str.append(token_index)
|
||||
else:
|
||||
masked_tokenized_str.append(self.ignore_id)
|
||||
|
||||
assert (
|
||||
len(tokenized_str) == len(images_seq_mask) == len(masked_tokenized_str)
|
||||
), (
|
||||
f"tokenized_str's length {len(tokenized_str)}, "
|
||||
f"input_ids' length {len(masked_tokenized_str)}, "
|
||||
f"images_seq_mask's length {len(images_seq_mask)}, are not equal."
|
||||
)
|
||||
|
||||
input_ids = torch.LongTensor(tokenized_str)
|
||||
target_ids = torch.LongTensor(masked_tokenized_str)
|
||||
images_seq_mask = torch.tensor(images_seq_mask, dtype=torch.bool)
|
||||
|
||||
# set input_ids < 0 | input_ids == self.image_token_id as ignore_id
|
||||
target_ids[(input_ids < 0) | (input_ids == self.image_token_id)] = (
|
||||
self.ignore_id
|
||||
)
|
||||
input_ids[input_ids < 0] = self.pad_id
|
||||
|
||||
# Remove the ending eos token
|
||||
assert input_ids[-1] == self.eos_id
|
||||
input_ids = input_ids[:-1]
|
||||
target_ids = target_ids[:-1]
|
||||
images_seq_mask = images_seq_mask[:-1]
|
||||
|
||||
if len(images_list) == 0:
|
||||
pixel_values = torch.zeros((0, 3, self.base_size, self.base_size))
|
||||
images_spatial_crop = torch.zeros((0, 2), dtype=torch.long)
|
||||
images_crop = torch.zeros((0, 3, self.image_size, self.image_size))
|
||||
else:
|
||||
pixel_values = torch.stack(images_list, dim=0)
|
||||
images_spatial_crop = torch.tensor(images_spatial_crop, dtype=torch.long)
|
||||
if images_crop_list:
|
||||
images_crop = torch.stack(images_crop_list, dim=0)
|
||||
else:
|
||||
images_crop = torch.zeros((0, 3, self.image_size, self.image_size))
|
||||
|
||||
input_ids = input_ids.unsqueeze(0)
|
||||
|
||||
return (
|
||||
input_ids,
|
||||
pixel_values,
|
||||
images_crop,
|
||||
images_seq_mask,
|
||||
images_spatial_crop,
|
||||
num_image_tokens,
|
||||
image_shapes,
|
||||
)
|
||||
@@ -1,585 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
# adapted from https://huggingface.co/OpenGVLab/InternVL2-4B/blob/main/modeling_internvl_chat.py
|
||||
# --------------------------------------------------------
|
||||
# InternVL
|
||||
# Copyright (c) 2023 OpenGVLab
|
||||
# Licensed under The MIT License [see LICENSE for details]
|
||||
# --------------------------------------------------------
|
||||
|
||||
import numpy.typing as npt
|
||||
import torch
|
||||
import torchvision.transforms as T
|
||||
from PIL import Image
|
||||
from transformers import (
|
||||
BaseVideoProcessor,
|
||||
BatchFeature,
|
||||
ImageProcessingMixin,
|
||||
TensorType,
|
||||
)
|
||||
from transformers.processing_utils import ProcessorMixin
|
||||
|
||||
from vllm.multimodal.image import convert_image_mode
|
||||
from vllm.multimodal.processing import PromptUpdateDetails, cached_encode
|
||||
from vllm.tokenizers.hf import HfTokenizer
|
||||
|
||||
IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
||||
IMAGENET_STD = (0.229, 0.224, 0.225)
|
||||
|
||||
|
||||
# adapted from https://huggingface.co/OpenGVLab/InternVL2-1B
|
||||
def build_transform(input_size: int):
|
||||
MEAN, STD = IMAGENET_MEAN, IMAGENET_STD
|
||||
return T.Compose(
|
||||
[
|
||||
T.Lambda(lambda img: convert_image_mode(img, "RGB")),
|
||||
T.Resize(
|
||||
(input_size, input_size), interpolation=T.InterpolationMode.BICUBIC
|
||||
),
|
||||
T.ToTensor(),
|
||||
T.Normalize(mean=MEAN, std=STD),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# adapted from https://huggingface.co/OpenGVLab/InternVL2-1B
|
||||
def find_closest_aspect_ratio(
|
||||
aspect_ratio: float,
|
||||
target_ratios: list[tuple[int, int]],
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
image_size: int,
|
||||
) -> tuple[int, int]:
|
||||
best_ratio_diff = float("inf")
|
||||
best_ratio = (1, 1)
|
||||
area = width * height
|
||||
for ratio in target_ratios:
|
||||
target_aspect_ratio = ratio[0] / ratio[1]
|
||||
ratio_diff = abs(aspect_ratio - target_aspect_ratio)
|
||||
if ratio_diff < best_ratio_diff:
|
||||
best_ratio_diff = ratio_diff
|
||||
best_ratio = ratio
|
||||
elif ratio_diff == best_ratio_diff:
|
||||
if area > 0.5 * image_size * image_size * ratio[0] * ratio[1]:
|
||||
best_ratio = ratio
|
||||
return best_ratio
|
||||
|
||||
|
||||
def resolve_internvl_min_max_num(
|
||||
*,
|
||||
min_dynamic_patch: int,
|
||||
max_dynamic_patch: int,
|
||||
dynamic_image_size: bool,
|
||||
use_thumbnail: bool,
|
||||
) -> tuple[int, int]:
|
||||
min_dynamic_patch = min_dynamic_patch if dynamic_image_size else 1
|
||||
max_dynamic_patch = max_dynamic_patch if dynamic_image_size else 1
|
||||
|
||||
if use_thumbnail and max_dynamic_patch != 1:
|
||||
max_dynamic_patch += 1
|
||||
|
||||
return min_dynamic_patch, max_dynamic_patch
|
||||
|
||||
|
||||
def get_internvl_target_ratios(
|
||||
min_num: int,
|
||||
max_num: int,
|
||||
) -> list[tuple[int, int]]:
|
||||
target_ratios = {
|
||||
(i, j)
|
||||
for n in range(min_num, max_num + 1)
|
||||
for i in range(1, n + 1)
|
||||
for j in range(1, n + 1)
|
||||
if min_num <= i * j <= max_num
|
||||
}
|
||||
return sorted(target_ratios, key=lambda x: x[0] * x[1])
|
||||
|
||||
|
||||
def calculate_internvl_targets(
|
||||
*,
|
||||
orig_width: int,
|
||||
orig_height: int,
|
||||
target_ratios: list[tuple[int, int]],
|
||||
image_size: int,
|
||||
use_thumbnail: bool,
|
||||
) -> tuple[int, int, int]:
|
||||
aspect_ratio = orig_width / orig_height
|
||||
|
||||
# find the closest aspect ratio to the target
|
||||
target_aspect_ratio = find_closest_aspect_ratio(
|
||||
aspect_ratio,
|
||||
target_ratios,
|
||||
width=orig_width,
|
||||
height=orig_height,
|
||||
image_size=image_size,
|
||||
)
|
||||
|
||||
# calculate the target width and height
|
||||
target_width = image_size * target_aspect_ratio[0]
|
||||
target_height = image_size * target_aspect_ratio[1]
|
||||
blocks = target_aspect_ratio[0] * target_aspect_ratio[1]
|
||||
|
||||
# add thumbnail image if num_blocks != 1
|
||||
if use_thumbnail and blocks != 1:
|
||||
blocks += 1
|
||||
|
||||
return blocks, target_width, target_height
|
||||
|
||||
|
||||
# adapted from https://huggingface.co/OpenGVLab/InternVL2-1B
|
||||
def dynamic_preprocess_internvl(
|
||||
image: Image.Image,
|
||||
*,
|
||||
target_ratios: list[tuple[int, int]],
|
||||
image_size: int,
|
||||
use_thumbnail: bool,
|
||||
) -> list[Image.Image]:
|
||||
orig_width, orig_height = image.size
|
||||
|
||||
# calculate the number of blocks without thumbnail
|
||||
blocks, target_width, target_height = calculate_internvl_targets(
|
||||
orig_width=orig_width,
|
||||
orig_height=orig_height,
|
||||
target_ratios=target_ratios,
|
||||
image_size=image_size,
|
||||
use_thumbnail=False,
|
||||
)
|
||||
|
||||
# resize the image
|
||||
resized_img = image.resize((target_width, target_height))
|
||||
processed_images = []
|
||||
for i in range(blocks):
|
||||
box = (
|
||||
(i % (target_width // image_size)) * image_size,
|
||||
(i // (target_width // image_size)) * image_size,
|
||||
((i % (target_width // image_size)) + 1) * image_size,
|
||||
((i // (target_width // image_size)) + 1) * image_size,
|
||||
)
|
||||
# split the image
|
||||
split_img = resized_img.crop(box)
|
||||
processed_images.append(split_img)
|
||||
|
||||
assert len(processed_images) == blocks
|
||||
|
||||
if use_thumbnail and len(processed_images) != 1:
|
||||
thumbnail_img = image.resize((image_size, image_size))
|
||||
processed_images.append(thumbnail_img)
|
||||
|
||||
return processed_images
|
||||
|
||||
|
||||
# adapted from https://huggingface.co/OpenGVLab/InternVL2-1B
|
||||
def image_to_pixel_values_internvl(
|
||||
image: Image.Image,
|
||||
*,
|
||||
input_size: int,
|
||||
min_num: int,
|
||||
max_num: int,
|
||||
use_thumbnail: bool,
|
||||
) -> torch.Tensor:
|
||||
target_ratios = get_internvl_target_ratios(min_num, max_num)
|
||||
|
||||
transform = build_transform(input_size=input_size)
|
||||
images = dynamic_preprocess_internvl(
|
||||
image,
|
||||
target_ratios=target_ratios,
|
||||
image_size=input_size,
|
||||
use_thumbnail=use_thumbnail,
|
||||
)
|
||||
|
||||
pixel_values = torch.stack([transform(image) for image in images])
|
||||
return pixel_values
|
||||
|
||||
|
||||
# adapted from https://huggingface.co/OpenGVLab/InternVL2-1B
|
||||
def video_to_pixel_values_internvl(
|
||||
video: npt.NDArray,
|
||||
*,
|
||||
input_size: int,
|
||||
min_num: int,
|
||||
max_num: int,
|
||||
use_thumbnail: bool,
|
||||
) -> torch.Tensor:
|
||||
target_ratios = get_internvl_target_ratios(min_num, max_num)
|
||||
|
||||
transform = build_transform(input_size=input_size)
|
||||
frames_list = list[Image.Image]()
|
||||
for frame in video:
|
||||
pil_frame = dynamic_preprocess_internvl(
|
||||
Image.fromarray(frame, mode="RGB"),
|
||||
target_ratios=target_ratios,
|
||||
image_size=input_size,
|
||||
use_thumbnail=use_thumbnail,
|
||||
)
|
||||
assert len(pil_frame) == 1
|
||||
frames_list.extend(pil_frame)
|
||||
|
||||
pixel_values = torch.stack([transform(image) for image in frames_list])
|
||||
return pixel_values
|
||||
|
||||
|
||||
class InternVLImageProcessor(ImageProcessingMixin):
|
||||
def __init__(
|
||||
self,
|
||||
image_size: int,
|
||||
min_dynamic_patch: int,
|
||||
max_dynamic_patch: int,
|
||||
dynamic_image_size: bool,
|
||||
use_thumbnail: bool,
|
||||
) -> None:
|
||||
self.image_size = image_size
|
||||
self.min_dynamic_patch = min_dynamic_patch
|
||||
self.max_dynamic_patch = max_dynamic_patch
|
||||
self.dynamic_image_size = dynamic_image_size
|
||||
self.use_thumbnail = use_thumbnail
|
||||
|
||||
def resolve_min_max_num(
|
||||
self,
|
||||
*,
|
||||
min_dynamic_patch: int | None = None,
|
||||
max_dynamic_patch: int | None = None,
|
||||
dynamic_image_size: bool | None = None,
|
||||
use_thumbnail: bool | None = None,
|
||||
) -> tuple[int, int]:
|
||||
if min_dynamic_patch is None:
|
||||
min_dynamic_patch = self.min_dynamic_patch
|
||||
if max_dynamic_patch is None:
|
||||
max_dynamic_patch = self.max_dynamic_patch
|
||||
if dynamic_image_size is None:
|
||||
dynamic_image_size = self.dynamic_image_size
|
||||
if use_thumbnail is None:
|
||||
use_thumbnail = self.use_thumbnail
|
||||
|
||||
return resolve_internvl_min_max_num(
|
||||
min_dynamic_patch=min_dynamic_patch,
|
||||
max_dynamic_patch=max_dynamic_patch,
|
||||
dynamic_image_size=dynamic_image_size,
|
||||
use_thumbnail=use_thumbnail,
|
||||
)
|
||||
|
||||
def _images_to_pixel_values_lst(
|
||||
self,
|
||||
images: list[Image.Image],
|
||||
min_dynamic_patch: int | None = None,
|
||||
max_dynamic_patch: int | None = None,
|
||||
dynamic_image_size: bool | None = None,
|
||||
) -> list[torch.Tensor]:
|
||||
if min_dynamic_patch is None:
|
||||
min_dynamic_patch = self.min_dynamic_patch
|
||||
if max_dynamic_patch is None:
|
||||
max_dynamic_patch = self.max_dynamic_patch
|
||||
if dynamic_image_size is None:
|
||||
dynamic_image_size = self.dynamic_image_size
|
||||
|
||||
min_num, max_num = resolve_internvl_min_max_num(
|
||||
min_dynamic_patch=min_dynamic_patch,
|
||||
max_dynamic_patch=max_dynamic_patch,
|
||||
dynamic_image_size=dynamic_image_size,
|
||||
use_thumbnail=False, # Applied in image_to_pixel_values
|
||||
)
|
||||
|
||||
return [
|
||||
image_to_pixel_values_internvl(
|
||||
image,
|
||||
input_size=self.image_size,
|
||||
min_num=min_num,
|
||||
max_num=max_num,
|
||||
use_thumbnail=self.use_thumbnail,
|
||||
)
|
||||
for image in images
|
||||
]
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
images: Image.Image | list[Image.Image],
|
||||
*,
|
||||
min_dynamic_patch: int | None = None,
|
||||
max_dynamic_patch: int | None = None,
|
||||
dynamic_image_size: bool | None = None,
|
||||
return_tensors: str | TensorType | None = None,
|
||||
**kwargs,
|
||||
) -> BatchFeature:
|
||||
images_lst = [images] if not isinstance(images, list) else images
|
||||
|
||||
pixel_values_lst = self._images_to_pixel_values_lst(
|
||||
images_lst,
|
||||
min_dynamic_patch=min_dynamic_patch,
|
||||
max_dynamic_patch=max_dynamic_patch,
|
||||
dynamic_image_size=dynamic_image_size,
|
||||
)
|
||||
|
||||
image_inputs = {
|
||||
"pixel_values_flat": torch.cat(pixel_values_lst),
|
||||
"image_num_patches": torch.tensor([len(item) for item in pixel_values_lst]),
|
||||
}
|
||||
return BatchFeature(image_inputs, tensor_type=return_tensors)
|
||||
|
||||
|
||||
class InternVLVideoProcessor(BaseVideoProcessor):
|
||||
def __init__(
|
||||
self,
|
||||
image_size: int,
|
||||
) -> None:
|
||||
self.image_size = image_size
|
||||
|
||||
def _videos_to_pixel_values_lst(
|
||||
self,
|
||||
videos: list[npt.NDArray],
|
||||
) -> list[torch.Tensor]:
|
||||
return [
|
||||
video_to_pixel_values_internvl(
|
||||
video,
|
||||
input_size=self.image_size,
|
||||
min_num=1,
|
||||
max_num=1,
|
||||
use_thumbnail=False,
|
||||
)
|
||||
for video in videos
|
||||
]
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
videos: npt.NDArray | list[npt.NDArray],
|
||||
*,
|
||||
return_tensors: str | TensorType | None = None,
|
||||
**kwargs,
|
||||
) -> BatchFeature:
|
||||
videos_lst = [videos] if not isinstance(videos, list) else videos
|
||||
|
||||
pixel_values_lst = self._videos_to_pixel_values_lst(videos_lst)
|
||||
|
||||
image_inputs = {
|
||||
"pixel_values_flat_video": torch.cat(pixel_values_lst),
|
||||
"video_num_patches": torch.tensor([len(item) for item in pixel_values_lst]),
|
||||
}
|
||||
return BatchFeature(image_inputs, tensor_type=return_tensors)
|
||||
|
||||
|
||||
class InternVLProcessor(ProcessorMixin):
|
||||
"""This model doesn't define its own HF processor,
|
||||
so we implement our own one here.
|
||||
|
||||
The code to insert image tokens is based on:
|
||||
https://huggingface.co/OpenGVLab/InternVL2-1B/blob/main/modeling_internvl_chat.py#L252
|
||||
|
||||
Code for video processing is adapted from video example:
|
||||
https://huggingface.co/OpenGVLab/InternVL3-1B#inference-with-transformers
|
||||
"""
|
||||
|
||||
attributes = ["image_processor", "tokenizer", "video_processor"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_processor: InternVLImageProcessor,
|
||||
tokenizer: HfTokenizer,
|
||||
video_processor: InternVLVideoProcessor | None = None,
|
||||
*,
|
||||
image_seq_length: int,
|
||||
start_image_token: str = "<img>",
|
||||
end_image_token: str = "</img>",
|
||||
ctx_image_token: str = "<IMG_CONTEXT>",
|
||||
ctx_video_token: str | None = None,
|
||||
) -> None:
|
||||
self.image_processor = image_processor
|
||||
self.tokenizer = tokenizer
|
||||
self.video_processor = video_processor
|
||||
|
||||
self.image_seq_length = image_seq_length
|
||||
self.start_image_token = start_image_token
|
||||
self.end_image_token = end_image_token
|
||||
self.ctx_image_token = ctx_image_token
|
||||
self.ctx_video_token = ctx_video_token
|
||||
|
||||
self.start_image_token_id = tokenizer.convert_tokens_to_ids(start_image_token)
|
||||
self.end_image_token_id = tokenizer.convert_tokens_to_ids(end_image_token)
|
||||
self.ctx_image_token_id = tokenizer.convert_tokens_to_ids(ctx_image_token)
|
||||
self.ctx_video_token_id = (
|
||||
None
|
||||
if ctx_video_token is None
|
||||
else tokenizer.convert_tokens_to_ids(ctx_video_token)
|
||||
)
|
||||
|
||||
def resolve_target_ratios(
|
||||
self,
|
||||
*,
|
||||
min_dynamic_patch: int | None = None,
|
||||
max_dynamic_patch: int | None = None,
|
||||
dynamic_image_size: bool | None = None,
|
||||
use_thumbnail: bool | None = None,
|
||||
) -> list[tuple[int, int]]:
|
||||
min_num, max_num = self.image_processor.resolve_min_max_num(
|
||||
min_dynamic_patch=min_dynamic_patch,
|
||||
max_dynamic_patch=max_dynamic_patch,
|
||||
dynamic_image_size=dynamic_image_size,
|
||||
use_thumbnail=use_thumbnail,
|
||||
)
|
||||
|
||||
return get_internvl_target_ratios(min_num, max_num)
|
||||
|
||||
def get_num_image_tokens(
|
||||
self,
|
||||
*,
|
||||
image_width: int,
|
||||
image_height: int,
|
||||
) -> int:
|
||||
image_processor = self.image_processor
|
||||
target_ratios = self.resolve_target_ratios(
|
||||
use_thumbnail=False, # Applied in calculate_targets
|
||||
)
|
||||
|
||||
num_patches, _, _ = calculate_internvl_targets(
|
||||
orig_width=image_width,
|
||||
orig_height=image_height,
|
||||
image_size=image_processor.image_size,
|
||||
target_ratios=target_ratios,
|
||||
use_thumbnail=image_processor.use_thumbnail,
|
||||
)
|
||||
|
||||
return num_patches * self.image_seq_length
|
||||
|
||||
def get_image_repl(
|
||||
self,
|
||||
num_patches: int | None,
|
||||
num_features: int | None = None,
|
||||
) -> PromptUpdateDetails:
|
||||
if num_patches is None:
|
||||
assert num_features is not None
|
||||
else:
|
||||
num_features = num_patches * self.image_seq_length
|
||||
|
||||
repl_features = self.ctx_image_token * num_features
|
||||
repl_full = self.start_image_token + repl_features + self.end_image_token
|
||||
|
||||
full_ids = cached_encode(self.tokenizer, repl_full, add_special_tokens=False)
|
||||
|
||||
return PromptUpdateDetails.select_token_id(full_ids, self.ctx_image_token_id)
|
||||
|
||||
def get_video_repl(self, num_patches: int) -> PromptUpdateDetails:
|
||||
assert self.ctx_video_token is not None
|
||||
|
||||
repl_features = self.ctx_video_token * self.image_seq_length
|
||||
repl_features_with_sep = (
|
||||
self.start_image_token + repl_features + self.end_image_token
|
||||
)
|
||||
# num_patches is equal to num_frames
|
||||
repl_full = "".join(
|
||||
[f"Frame{i + 1}: {repl_features_with_sep}" for i in range(num_patches)]
|
||||
)
|
||||
|
||||
full_ids = cached_encode(self.tokenizer, repl_full, add_special_tokens=False)
|
||||
assert self.ctx_video_token_id is not None
|
||||
|
||||
return PromptUpdateDetails.select_token_id(full_ids, self.ctx_video_token_id)
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str | list[str] | None = None,
|
||||
images: Image.Image | list[Image.Image] | None = None,
|
||||
videos: npt.NDArray | list[npt.NDArray] | None = None,
|
||||
*,
|
||||
min_dynamic_patch: int | None = None,
|
||||
max_dynamic_patch: int | None = None,
|
||||
dynamic_image_size: bool | None = None,
|
||||
return_tensors: str | TensorType | None = None,
|
||||
**kwargs,
|
||||
) -> BatchFeature:
|
||||
if images is not None:
|
||||
image_inputs = self.image_processor(
|
||||
images=images,
|
||||
min_dynamic_patch=min_dynamic_patch,
|
||||
max_dynamic_patch=max_dynamic_patch,
|
||||
dynamic_image_size=dynamic_image_size,
|
||||
return_tensors=return_tensors,
|
||||
)
|
||||
image_num_patches = image_inputs["image_num_patches"]
|
||||
else:
|
||||
image_inputs = {}
|
||||
image_num_patches = []
|
||||
|
||||
if videos is not None:
|
||||
if self.video_processor is None:
|
||||
raise ValueError("This model does not support video inputs")
|
||||
|
||||
video_inputs = self.video_processor(
|
||||
videos=videos,
|
||||
return_tensors=return_tensors,
|
||||
)
|
||||
video_num_patches = video_inputs["video_num_patches"]
|
||||
else:
|
||||
video_inputs = {}
|
||||
video_num_patches = []
|
||||
|
||||
if text is not None:
|
||||
if not isinstance(text, list):
|
||||
text = [text]
|
||||
|
||||
if image_inputs:
|
||||
image_token = "<image>"
|
||||
image_index = 0
|
||||
processed_text = list[str]()
|
||||
replace_strings = list[str]()
|
||||
|
||||
for prompt in text:
|
||||
new_prompt = prompt
|
||||
|
||||
while image_token in new_prompt:
|
||||
new_prompt = new_prompt.replace(image_token, "<placeholder>", 1)
|
||||
image_repl = self.get_image_repl(image_num_patches[image_index])
|
||||
# Convert the token IDs back to text for the
|
||||
# string-based replacement below
|
||||
replace_strings.append(
|
||||
self.tokenizer.decode(
|
||||
image_repl.full, skip_special_tokens=False
|
||||
)
|
||||
)
|
||||
image_index += 1
|
||||
|
||||
while "<placeholder>" in new_prompt:
|
||||
replace_str = replace_strings.pop(0)
|
||||
new_prompt = new_prompt.replace("<placeholder>", replace_str, 1)
|
||||
|
||||
processed_text.append(new_prompt)
|
||||
|
||||
text = processed_text
|
||||
|
||||
if video_inputs:
|
||||
video_token = "<video>"
|
||||
video_index = 0
|
||||
processed_text = list[str]()
|
||||
replace_strings = list[str]()
|
||||
|
||||
assert video_token is not None
|
||||
|
||||
for prompt in text:
|
||||
new_prompt = prompt
|
||||
|
||||
while video_token in new_prompt:
|
||||
new_prompt = new_prompt.replace(video_token, "<placeholder>", 1)
|
||||
video_repl = self.get_video_repl(video_num_patches[video_index])
|
||||
# Convert the token IDs back to text for the
|
||||
# string-based replacement below
|
||||
replace_strings.append(
|
||||
self.tokenizer.decode(
|
||||
video_repl.full, skip_special_tokens=False
|
||||
)
|
||||
)
|
||||
video_index += 1
|
||||
|
||||
while "<placeholder>" in new_prompt:
|
||||
replace_str = replace_strings.pop(0)
|
||||
new_prompt = new_prompt.replace("<placeholder>", replace_str, 1)
|
||||
|
||||
processed_text.append(new_prompt)
|
||||
|
||||
text = processed_text
|
||||
|
||||
text_inputs = self.tokenizer(text, return_tensors=return_tensors)
|
||||
else:
|
||||
text_inputs = {}
|
||||
|
||||
return BatchFeature(
|
||||
data={**text_inputs, **image_inputs, **video_inputs},
|
||||
tensor_type=return_tensors,
|
||||
)
|
||||
@@ -1,361 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Optimized CPU image processor for Kimi-K2.5/K2.6 vision chunks."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pybase64 as base64
|
||||
import torch
|
||||
from PIL import Image
|
||||
from transformers.image_processing_utils import BaseImageProcessor, BatchFeature
|
||||
from transformers.utils import TensorType
|
||||
|
||||
from vllm.multimodal.image import open_image
|
||||
from vllm.utils.import_utils import is_numba_available
|
||||
from vllm.utils.jit_monitor import numba_workqueue_threading_layer
|
||||
|
||||
_IDENTITY_LUT = np.tile(np.arange(256, dtype=np.uint8)[:, None], (1, 3))
|
||||
|
||||
if is_numba_available():
|
||||
from numba import njit, prange
|
||||
|
||||
@njit(parallel=True, cache=True)
|
||||
def _write_fused_patches(
|
||||
frames: np.ndarray,
|
||||
out: np.ndarray,
|
||||
out_offset: int,
|
||||
new_h: int,
|
||||
new_w: int,
|
||||
padded_h: int,
|
||||
padded_w: int,
|
||||
patch_size: int,
|
||||
normalize_lut: np.ndarray,
|
||||
) -> None:
|
||||
# frames: [T, new_h, new_w, 3] uint8, without padding.
|
||||
# out: [total_patches, 3, patch_size, patch_size] float32.
|
||||
t_size = frames.shape[0]
|
||||
patch_h = padded_h // patch_size
|
||||
patch_w = padded_w // patch_size
|
||||
total = t_size * padded_h * padded_w * 3
|
||||
hwc = padded_h * padded_w * 3
|
||||
wc = padded_w * 3
|
||||
|
||||
for linear in prange(total):
|
||||
t = linear // hwc
|
||||
rem = linear - t * hwc
|
||||
y = rem // wc
|
||||
rem = rem - y * wc
|
||||
x = rem // 3
|
||||
c = rem - x * 3
|
||||
|
||||
value = frames[t, y, x, c] if y < new_h and x < new_w else 0
|
||||
|
||||
patch_idx = (
|
||||
out_offset
|
||||
+ t * patch_h * patch_w
|
||||
+ (y // patch_size) * patch_w
|
||||
+ (x // patch_size)
|
||||
)
|
||||
out[patch_idx, c, y % patch_size, x % patch_size] = normalize_lut[value, c]
|
||||
|
||||
else:
|
||||
|
||||
def _write_fused_patches(*args: Any, **kwargs: Any) -> None:
|
||||
raise RuntimeError("numba is required for fused Kimi image preprocessing")
|
||||
|
||||
|
||||
def navit_resize_image(
|
||||
width: int,
|
||||
height: int,
|
||||
patch_size: int,
|
||||
merge_kernel_size: int,
|
||||
in_patch_limit: int,
|
||||
patch_limit_on_one_side: int,
|
||||
fixed_output_tokens: int | None,
|
||||
) -> dict[str, int]:
|
||||
s1 = math.sqrt(
|
||||
in_patch_limit
|
||||
/ (max(1.0, width // patch_size) * max(1.0, height // patch_size))
|
||||
)
|
||||
s2 = patch_limit_on_one_side * patch_size / width
|
||||
s3 = patch_limit_on_one_side * patch_size / height
|
||||
scale = min(1.0, s1, s2, s3)
|
||||
new_w = min(max(1, int(width * scale)), patch_limit_on_one_side * patch_size)
|
||||
new_h = min(max(1, int(height * scale)), patch_limit_on_one_side * patch_size)
|
||||
|
||||
factor = merge_kernel_size * patch_size
|
||||
pad_height = (factor - new_h % factor) % factor
|
||||
pad_width = (factor - new_w % factor) % factor
|
||||
|
||||
if fixed_output_tokens is not None:
|
||||
num_tokens = fixed_output_tokens
|
||||
else:
|
||||
token_height = (new_h + pad_height) // factor
|
||||
token_width = (new_w + pad_width) // factor
|
||||
num_tokens = token_height * token_width
|
||||
|
||||
return {
|
||||
"num_tokens": num_tokens,
|
||||
"new_width": new_w,
|
||||
"new_height": new_h,
|
||||
"pad_width": pad_width,
|
||||
"pad_height": pad_height,
|
||||
"sampled_nframes": 1,
|
||||
}
|
||||
|
||||
|
||||
def navit_resize_video(
|
||||
width: int,
|
||||
height: int,
|
||||
nframes: int,
|
||||
avg_fps: float,
|
||||
sample_fps: float,
|
||||
patch_size: int,
|
||||
merge_kernel_size: int,
|
||||
in_patch_limit_each_frame: int,
|
||||
patch_limit_on_one_side: int,
|
||||
in_patch_limit_total: int | None,
|
||||
max_num_frames_each_video: int | None,
|
||||
fixed_output_tokens_each_frame: int | None,
|
||||
) -> dict[str, int]:
|
||||
sample_fps = min(sample_fps, avg_fps)
|
||||
sampled_nframes = max(round(nframes * sample_fps / avg_fps), 1)
|
||||
if max_num_frames_each_video is not None:
|
||||
sampled_nframes = min(sampled_nframes, max_num_frames_each_video)
|
||||
|
||||
if in_patch_limit_total is not None:
|
||||
in_patch_limit_each_frame = min(
|
||||
round(in_patch_limit_total / sampled_nframes),
|
||||
in_patch_limit_each_frame,
|
||||
)
|
||||
|
||||
ret = navit_resize_image(
|
||||
width,
|
||||
height,
|
||||
patch_size,
|
||||
merge_kernel_size,
|
||||
in_patch_limit_each_frame,
|
||||
patch_limit_on_one_side,
|
||||
fixed_output_tokens_each_frame,
|
||||
)
|
||||
ret["sampled_nframes"] = sampled_nframes
|
||||
return ret
|
||||
|
||||
|
||||
def _to_pil(data: Any) -> Image.Image:
|
||||
if hasattr(data, "media") and hasattr(data, "original_bytes"):
|
||||
data = data.media
|
||||
if isinstance(data, Image.Image):
|
||||
return data if data.mode == "RGB" else data.convert("RGB")
|
||||
if isinstance(data, str):
|
||||
if data.startswith("data:"):
|
||||
raw_base64 = data.split(",", 1)[1]
|
||||
return open_image(io.BytesIO(base64.b64decode(raw_base64))).convert("RGB")
|
||||
return open_image(data).convert("RGB")
|
||||
if isinstance(data, bytes):
|
||||
return open_image(io.BytesIO(data)).convert("RGB")
|
||||
raise ValueError(f"Unsupported data type: {type(data)}")
|
||||
|
||||
|
||||
def _ensure_media_type(media: dict[str, Any]) -> dict[str, Any]:
|
||||
if media["type"] == "image":
|
||||
media["image"] = _to_pil(media["image"])
|
||||
return media
|
||||
if media["type"] == "video_chunk":
|
||||
media["video_chunk"] = [_to_pil(frame) for frame in media["video_chunk"]]
|
||||
return media
|
||||
raise ValueError(f"Unsupported media type: {media['type']}")
|
||||
|
||||
|
||||
class KimiK25FusedVisionProcessor(BaseImageProcessor):
|
||||
model_type = "kimi_k25"
|
||||
|
||||
def __init__(self, media_proc_cfg: dict[str, Any], **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
media_proc_cfg = dict(media_proc_cfg)
|
||||
merge_kernel_size = media_proc_cfg["merge_kernel_size"]
|
||||
if isinstance(merge_kernel_size, (list, tuple)):
|
||||
media_proc_cfg["merge_kernel_size"] = int(merge_kernel_size[0])
|
||||
self.media_proc_cfg = media_proc_cfg
|
||||
self.num_frames_per_chunk = media_proc_cfg["temporal_merge_kernel_size"]
|
||||
values = np.arange(256, dtype=np.float32)[:, None]
|
||||
image_mean = np.asarray(media_proc_cfg["image_mean"], dtype=np.float32)
|
||||
image_std_inv = 1.0 / np.asarray(media_proc_cfg["image_std"], dtype=np.float32)
|
||||
self.normalize_lut = (values / 255.0 - image_mean[None, :]) * image_std_inv[
|
||||
None, :
|
||||
]
|
||||
|
||||
def media_tokens_calculator(self, media: dict[str, Any]) -> int:
|
||||
media = _ensure_media_type(media)
|
||||
ret = self.get_resize_config(media)
|
||||
return ret["num_tokens"]
|
||||
|
||||
def get_resize_config(self, media_input: dict[str, Any]) -> dict[str, int]:
|
||||
if media_input["type"] == "image":
|
||||
width, height = media_input["image"].size
|
||||
return navit_resize_image(
|
||||
width,
|
||||
height,
|
||||
self.media_proc_cfg["patch_size"],
|
||||
self.media_proc_cfg["merge_kernel_size"],
|
||||
self.media_proc_cfg["in_patch_limit"],
|
||||
self.media_proc_cfg["patch_limit_on_one_side"],
|
||||
self.media_proc_cfg["fixed_output_tokens"],
|
||||
)
|
||||
|
||||
if media_input["type"] == "video_chunk":
|
||||
frame = media_input["video_chunk"][0]
|
||||
width, height = frame.size
|
||||
num_frames = len(media_input["video_chunk"])
|
||||
in_patch_limit_each_frame = self.media_proc_cfg["in_patch_limit_each_frame"]
|
||||
if in_patch_limit_each_frame is None:
|
||||
in_patch_limit_each_frame = self.media_proc_cfg["in_patch_limit"]
|
||||
|
||||
return navit_resize_video(
|
||||
width,
|
||||
height,
|
||||
num_frames,
|
||||
1.0,
|
||||
math.inf,
|
||||
self.media_proc_cfg["patch_size"],
|
||||
self.media_proc_cfg["merge_kernel_size"],
|
||||
in_patch_limit_each_frame,
|
||||
self.media_proc_cfg["patch_limit_on_one_side"],
|
||||
self.media_proc_cfg["in_patch_limit_video"],
|
||||
None,
|
||||
self.media_proc_cfg["fixed_output_tokens"],
|
||||
)
|
||||
|
||||
raise ValueError(f"Unsupported type: {media_input['type']}")
|
||||
|
||||
@staticmethod
|
||||
def resize_image(image: Image.Image, new_width: int, new_height: int) -> np.ndarray:
|
||||
image = image.resize((new_width, new_height), resample=Image.Resampling.BICUBIC)
|
||||
return np.asarray(image)
|
||||
|
||||
def preprocess(
|
||||
self,
|
||||
medias: list[dict[str, Any]],
|
||||
return_tensors: str | TensorType | None = None,
|
||||
do_rescale: bool = True,
|
||||
do_normalize: bool = True,
|
||||
) -> BatchFeature:
|
||||
assert do_rescale == do_normalize
|
||||
device_normalize = not do_normalize
|
||||
|
||||
if not isinstance(medias, list):
|
||||
medias = [medias]
|
||||
if not medias:
|
||||
return BatchFeature(data={}, tensor_type=return_tensors)
|
||||
|
||||
if njit is None:
|
||||
raise RuntimeError("numba is required for fused Kimi image preprocessing")
|
||||
|
||||
patch_size = int(self.media_proc_cfg["patch_size"])
|
||||
prepared = []
|
||||
grid_thws_np = np.empty((len(medias), 3), dtype=np.int64)
|
||||
total_patches = 0
|
||||
|
||||
for idx, item in enumerate(medias):
|
||||
item = _ensure_media_type(item)
|
||||
resize_config = self.get_resize_config(item)
|
||||
new_width = resize_config["new_width"]
|
||||
new_height = resize_config["new_height"]
|
||||
pad_width = resize_config["pad_width"]
|
||||
pad_height = resize_config["pad_height"]
|
||||
padded_width = new_width + pad_width
|
||||
padded_height = new_height + pad_height
|
||||
|
||||
if item["type"] == "image":
|
||||
image_np = self.resize_image(item["image"], new_width, new_height)
|
||||
frames = image_np[np.newaxis, ...]
|
||||
elif item["type"] == "video_chunk":
|
||||
frames = np.stack(
|
||||
[
|
||||
self.resize_image(frame, new_width, new_height)
|
||||
for frame in item["video_chunk"]
|
||||
],
|
||||
axis=0,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported type: {item['type']}")
|
||||
|
||||
t_size = frames.shape[0]
|
||||
grid_h = padded_height // patch_size
|
||||
grid_w = padded_width // patch_size
|
||||
grid_thws_np[idx, 0] = t_size
|
||||
grid_thws_np[idx, 1] = grid_h
|
||||
grid_thws_np[idx, 2] = grid_w
|
||||
|
||||
num_patches = t_size * grid_h * grid_w
|
||||
prepared.append(
|
||||
(
|
||||
frames,
|
||||
new_height,
|
||||
new_width,
|
||||
padded_height,
|
||||
padded_width,
|
||||
num_patches,
|
||||
)
|
||||
)
|
||||
total_patches += num_patches
|
||||
|
||||
lut = _IDENTITY_LUT if device_normalize else self.normalize_lut
|
||||
pixel_values_np = np.empty(
|
||||
(total_patches, 3, patch_size, patch_size), dtype=lut.dtype
|
||||
)
|
||||
out_offset = 0
|
||||
with numba_workqueue_threading_layer():
|
||||
for (
|
||||
frames,
|
||||
new_height,
|
||||
new_width,
|
||||
padded_height,
|
||||
padded_width,
|
||||
num_patches,
|
||||
) in prepared:
|
||||
_write_fused_patches(
|
||||
frames,
|
||||
pixel_values_np,
|
||||
out_offset,
|
||||
new_height,
|
||||
new_width,
|
||||
padded_height,
|
||||
padded_width,
|
||||
patch_size,
|
||||
lut,
|
||||
)
|
||||
out_offset += num_patches
|
||||
|
||||
data = {
|
||||
"pixel_values": torch.from_numpy(pixel_values_np),
|
||||
"grid_thws": torch.from_numpy(grid_thws_np),
|
||||
}
|
||||
return BatchFeature(data=data, tensor_type=return_tensors)
|
||||
|
||||
def __repr__(self):
|
||||
return f"KimiK25FusedVisionProcessor(media_proc_cfg={self.media_proc_cfg})"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
output = super().to_dict()
|
||||
output["media_proc_cfg"] = self.media_proc_cfg
|
||||
if "media_processor" in output:
|
||||
del output["media_processor"]
|
||||
return output
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config_dict: dict[str, Any], **kwargs):
|
||||
config = config_dict.copy()
|
||||
media_proc_cfg = config.pop("media_proc_cfg", {})
|
||||
return cls(media_proc_cfg=media_proc_cfg, **config, **kwargs)
|
||||
|
||||
def to_json_string(self):
|
||||
dictionary = self.to_dict()
|
||||
for key, value in dictionary.items():
|
||||
if hasattr(value, "tolist"):
|
||||
dictionary[key] = value.tolist()
|
||||
return json.dumps(dictionary, indent=2, sort_keys=True) + "\n"
|
||||
@@ -1,582 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""HuggingFace processor for the MuseGlimmer multimodal model (text + image + video).
|
||||
|
||||
This packages MuseGlimmer's vision preprocessing and token-span expansion the standard
|
||||
HF way, so you can do:
|
||||
|
||||
processor = AutoProcessor.from_pretrained(hf_dir, trust_remote_code=True)
|
||||
messages = [{"role": "user", "content": [
|
||||
{"type": "image"},
|
||||
{"type": "text", "text": "Describe this image."},
|
||||
]}]
|
||||
text = processor.apply_chat_template(messages, add_generation_prompt=True)
|
||||
inputs = processor(text=text, images=[pil_image], return_tensors="pt")
|
||||
out = model.generate(**inputs, max_new_tokens=256)
|
||||
|
||||
It reproduces the exact token layout the model expects:
|
||||
|
||||
image -> <|image_start|> + <|patch|> * N + <|image_end|>
|
||||
video -> <|vid_start|>
|
||||
( "Time: X.Xs" + <|video|> * P [+ <|vid_frame_separator|>] )*
|
||||
+ <|vid_end|>
|
||||
|
||||
where N = floor(H/(patch*ds)) * floor(W/(patch*ds)) (<= 4096) for images and
|
||||
P = the same grid count per temporal frame-group (<= 144) for video, with the
|
||||
target (H, W) chosen by MuseGlimmerImageProcessor.compute_image_size /
|
||||
MuseGlimmerVideoProcessor.compute_video_frame_size (the same grid logic as
|
||||
modeling_muse_glimmer.MuseGlimmerVisionEncoder).
|
||||
|
||||
The chat template emits one sentinel per media item -- ``<|image|>`` for images,
|
||||
``<|video|>`` for videos -- which ``MuseGlimmerProcessor.__call__`` expands into the
|
||||
spans above using each media item's computed token count. Vision features are
|
||||
returned in ``pixel_values`` (a list of per-image / per-frame-group tensors) in
|
||||
the order the sentinels appear, matching how ``MuseGlimmerModel`` merges them at the
|
||||
``<|patch|>`` / ``<|video|>`` positions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torchvision import transforms as T
|
||||
from transformers.feature_extraction_utils import BatchFeature
|
||||
from transformers.image_processing_utils import BaseImageProcessor
|
||||
from transformers.processing_utils import ProcessorMixin
|
||||
from transformers.video_processing_utils import BaseVideoProcessor
|
||||
|
||||
# Single-token sentinels emitted by the chat template, one per media item.
|
||||
IMAGE_SENTINEL = "<|image|>" # id 200090; absent from the image/video spans
|
||||
VIDEO_SENTINEL = "<|video|>" # id 200091; also the per-frame token (see __call__)
|
||||
|
||||
# Jinja chat template (multimodal superset of convert_muse_glimmer_to_hf.CHAT_TEMPLATE).
|
||||
# Message content may be a plain string OR a list of parts
|
||||
# ({"type": "text"|"image"|"video", ...}); image/video parts render as a single
|
||||
# sentinel that __call__ expands.
|
||||
MUSE_GLIMMER_MM_CHAT_TEMPLATE = (
|
||||
"{{- bos_token -}}"
|
||||
"{%- macro render_parts(content) -%}"
|
||||
"{%- if content is string -%}{{- content -}}"
|
||||
"{%- else -%}"
|
||||
"{%- for part in content -%}"
|
||||
"{%- if part['type'] == 'image' -%}{{- '<|image|>' -}}"
|
||||
"{%- elif part['type'] == 'video' -%}{{- '<|video|>' -}}"
|
||||
"{%- elif part['type'] == 'text' -%}{{- part['text'] -}}"
|
||||
"{%- endif -%}"
|
||||
"{%- endfor -%}"
|
||||
"{%- endif -%}"
|
||||
"{%- endmacro -%}"
|
||||
# At inference (add_generation_prompt), inject a default system message when
|
||||
# the caller supplied none. Full-transcript rendering
|
||||
# (add_generation_prompt=False) is left unchanged.
|
||||
"{%- set ns = namespace(has_system=false) -%}"
|
||||
"{%- for m in messages -%}"
|
||||
"{%- if m['role'] == 'system' -%}{%- set ns.has_system = true -%}{%- endif -%}"
|
||||
"{%- endfor -%}"
|
||||
"{%- if add_generation_prompt and not ns.has_system -%}"
|
||||
"{{- '<|start|>system<|message|>You are a helpful assistant.<|eot|>' -}}"
|
||||
"{%- endif -%}"
|
||||
"{%- for message in messages -%}"
|
||||
"{%- set role = message['role'] -%}"
|
||||
"{%- if role == 'assistant' -%}"
|
||||
"{%- set recipient = message.get('recipient') -%}"
|
||||
"{%- set end_turn = message.get('end_turn') -%}"
|
||||
"{%- if end_turn is none -%}"
|
||||
"{%- set end_turn = not (recipient and recipient != 'user') -%}"
|
||||
"{%- endif -%}"
|
||||
"{{- '<|start|>assistant' -}}"
|
||||
"{%- if recipient -%}{{- ' to=' + recipient -}}{%- endif -%}"
|
||||
"{{- '<|message|>' -}}{{- render_parts(message['content']) -}}"
|
||||
"{{- ('<|eot|>' if end_turn else '<|eom|>') -}}"
|
||||
"{%- elif role == 'tool' -%}"
|
||||
"{%- set name = message.get('name', '') -%}"
|
||||
# Tool content is emitted as-is (string body or interleaved image/text parts).
|
||||
# Any <tool_output ...> wrapper is baked into the SFT data content; the
|
||||
# tokenizer does not add it, so the template must not either.
|
||||
"{{- '<|start|>tool ' + name + '<|message|>' -}}"
|
||||
"{{- render_parts(message['content']) -}}"
|
||||
"{{- '<|eot|>' -}}"
|
||||
"{%- else -%}"
|
||||
"{%- set header = role -%}"
|
||||
"{%- if message.get('name') -%}"
|
||||
"{%- set header = role + ' ' + message['name'] -%}"
|
||||
"{%- endif -%}"
|
||||
"{{- '<|start|>' + header + '<|message|>' -}}"
|
||||
"{{- render_parts(message['content']) -}}"
|
||||
"{{- '<|eot|>' -}}"
|
||||
"{%- endif -%}"
|
||||
"{%- endfor -%}"
|
||||
"{%- if add_generation_prompt -%}{{- '<|start|>assistant' -}}{%- endif -%}"
|
||||
)
|
||||
|
||||
|
||||
def _grid_size(
|
||||
img_w: int, img_h: int, patch_hw: int, max_tokens: int
|
||||
) -> tuple[int, int, int]:
|
||||
"""Pick the integer (H, W) grid closest to the aspect ratio under the token cap.
|
||||
|
||||
Replicates MuseGlimmerVisionEncoder._compute_grid_size
|
||||
(modeling_muse_glimmer.py) so the processor needs no torch model import.
|
||||
Returns (target_h, target_w, n_tokens).
|
||||
"""
|
||||
i_nph = img_h / patch_hw
|
||||
i_npw = img_w / patch_hw
|
||||
ratio = i_npw / i_nph if i_nph > 0 else 1.0
|
||||
if i_nph * i_npw > max_tokens:
|
||||
i_nph = (max_tokens / ratio) ** 0.5
|
||||
i_npw = i_nph * ratio
|
||||
candidates = list(
|
||||
set(
|
||||
itertools.product(
|
||||
[math.floor(i_nph), math.ceil(i_nph)],
|
||||
[math.floor(i_npw), math.ceil(i_npw)],
|
||||
)
|
||||
)
|
||||
)
|
||||
candidates = [
|
||||
(nph, npw)
|
||||
for nph, npw in candidates
|
||||
if nph >= 1 and npw >= 1 and nph * npw <= max_tokens
|
||||
]
|
||||
if not candidates:
|
||||
candidates = [(max(1, round(i_nph)), max(1, round(i_npw)))]
|
||||
nph, npw = min(candidates, key=lambda c: abs(c[0] / c[1] - img_h / img_w))
|
||||
return nph * patch_hw, npw * patch_hw, nph * npw
|
||||
|
||||
|
||||
class MuseGlimmerImageProcessor(BaseImageProcessor):
|
||||
"""Resize + normalize MuseGlimmer images and compute patch-token counts.
|
||||
|
||||
Variable-resolution: each image is resized to the grid that best matches its
|
||||
aspect ratio under the per-image token cap, then normalized with mean/std 0.5.
|
||||
Returns per-image tensors (not stacked) because MuseGlimmer consumes a list of
|
||||
variable-size images. Video frames are handled by MuseGlimmerVideoProcessor.
|
||||
"""
|
||||
|
||||
model_input_names = ["pixel_values"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
patch_size: int = 14,
|
||||
downsample_factor: int = 2,
|
||||
max_image_tokens: int = 4096,
|
||||
image_mean: float = 0.5,
|
||||
image_std: float = 0.5,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.patch_size = patch_size
|
||||
self.downsample_factor = downsample_factor
|
||||
self.max_image_tokens = max_image_tokens
|
||||
self.image_mean = image_mean
|
||||
self.image_std = image_std
|
||||
|
||||
def _to_norm_tensor(self, image: Image.Image) -> torch.Tensor:
|
||||
# Functional normalize -- no stored Normalize object, so the processor
|
||||
# stays JSON-serializable for to_dict() / save_pretrained.
|
||||
return T.functional.normalize(
|
||||
T.functional.to_tensor(image),
|
||||
[self.image_mean] * 3,
|
||||
[self.image_std] * 3,
|
||||
)
|
||||
|
||||
# -- size computation (mirrors modeling_muse_glimmer.MuseGlimmerVisionEncoder) --
|
||||
def compute_image_size(self, img_w: int, img_h: int) -> tuple[int, int, int]:
|
||||
ph = self.patch_size * self.downsample_factor
|
||||
return _grid_size(img_w, img_h, ph, self.max_image_tokens)
|
||||
|
||||
# -- preprocessing ---------------------------------------------------------
|
||||
def preprocess_image(self, image: Image.Image) -> tuple[torch.Tensor, int]:
|
||||
"""Return (pixel tensor [3, H, W], n_patch_tokens) for one image."""
|
||||
image = image.convert("RGB")
|
||||
target_h, target_w, n_tokens = self.compute_image_size(
|
||||
image.width, image.height
|
||||
)
|
||||
image = image.resize((target_w, target_h), Image.LANCZOS)
|
||||
return self._to_norm_tensor(image), n_tokens
|
||||
|
||||
|
||||
# torchcodec is the training-faithful video decoder; other decoders (torchvision /
|
||||
# PyAV) seek + color-convert differently and diverge from training. Optional at
|
||||
# import time so text/image paths load without it; decode_video raises if used.
|
||||
try:
|
||||
import torchcodec
|
||||
except Exception:
|
||||
torchcodec = None
|
||||
|
||||
|
||||
class MuseGlimmerVideoProcessor(BaseVideoProcessor):
|
||||
"""MuseGlimmer video preprocessing behind the standard HF
|
||||
``AutoVideoProcessor`` API.
|
||||
|
||||
Exposes MuseGlimmer's training-faithful video handling as a first-class video
|
||||
processor so you can call ``processor(videos="clip.mp4")`` and downstream
|
||||
users can discover it via the standard HF ``AutoVideoProcessor`` API plus a
|
||||
``video_preprocessor_config.json``. It wraps:
|
||||
|
||||
* torchcodec decode (matches training; other decoders diverge),
|
||||
* uniform frame sampling to a whole multiple of ``patch_temporal``,
|
||||
* real per-group PTS timestamps (rendered as ``Time: X.Xs`` by
|
||||
MuseGlimmerProcessor),
|
||||
* ``patch_temporal`` frame-grouping (frames cat on the channel axis ->
|
||||
``[patch_temporal * 3, H, W]``; the encoder detects video by channel count).
|
||||
|
||||
It deliberately overrides ``preprocess`` instead of the BaseVideoProcessor fast
|
||||
pipeline (group_videos_by_shape / smart_resize / single stacked tensor): MuseGlimmer
|
||||
consumes a LIST of variable-size group tensors and needs the real per-group PTS,
|
||||
neither of which the stacked fast path models.
|
||||
"""
|
||||
|
||||
model_input_names = ["pixel_values"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
patch_size: int = 14,
|
||||
downsample_factor: int = 2,
|
||||
patch_temporal: int = 2,
|
||||
max_video_frame_tokens: int = 144,
|
||||
image_mean: float = 0.5,
|
||||
image_std: float = 0.5,
|
||||
video_num_frames: int = 96,
|
||||
video_sampling_fps: float = 2.0,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.patch_size = patch_size
|
||||
self.downsample_factor = downsample_factor
|
||||
self.patch_temporal = patch_temporal
|
||||
self.max_video_frame_tokens = max_video_frame_tokens
|
||||
self.image_mean = image_mean
|
||||
self.image_std = image_std
|
||||
self.video_num_frames = video_num_frames
|
||||
self.video_sampling_fps = video_sampling_fps
|
||||
|
||||
def _to_norm_tensor(self, image: Image.Image) -> torch.Tensor:
|
||||
return T.functional.normalize(
|
||||
T.functional.to_tensor(image),
|
||||
[self.image_mean] * 3,
|
||||
[self.image_std] * 3,
|
||||
)
|
||||
|
||||
def decode_video(self, video_path: str) -> tuple[list[Image.Image], list[float]]:
|
||||
"""Sample frames + per-group PTS with torchcodec (the training decode path).
|
||||
|
||||
``timestamps[g]`` is the ACTUAL decoded PTS of the first frame in temporal
|
||||
group ``g``; ``len(frames)`` is a whole multiple of ``patch_temporal``.
|
||||
"""
|
||||
if torchcodec is None:
|
||||
raise RuntimeError(
|
||||
"torchcodec is required for video decoding (it matches the training "
|
||||
"decode path). See the Environment Setup steps in hf/README.md."
|
||||
)
|
||||
pt = self.patch_temporal
|
||||
reader = torchcodec.decoders.VideoDecoder(video_path)
|
||||
total = len(reader)
|
||||
assert reader.metadata.average_fps is not None, (
|
||||
f"Video has no FPS metadata: {video_path}"
|
||||
)
|
||||
fps = reader.metadata.average_fps
|
||||
assert self.video_sampling_fps and self.video_sampling_fps > 0, (
|
||||
f"video_sampling_fps must be positive, got {self.video_sampling_fps}"
|
||||
)
|
||||
n = min(
|
||||
int(total * self.video_sampling_fps / fps), self.video_num_frames, total
|
||||
)
|
||||
n = max(pt, (n // pt) * pt)
|
||||
n = min(n, total)
|
||||
if n < pt:
|
||||
raise ValueError(
|
||||
f"Video has only {total} decodable frame(s) but needs at least "
|
||||
f"{pt} (one temporal patch): {video_path}"
|
||||
)
|
||||
indices = torch.linspace(0, total - 1, n).long().tolist()
|
||||
frames: list[Image.Image] = []
|
||||
timestamps: list[float] = []
|
||||
for j, i in enumerate(indices):
|
||||
fr = reader[i]
|
||||
frames.append(
|
||||
Image.fromarray(fr.data.permute(1, 2, 0).numpy()).convert("RGB")
|
||||
)
|
||||
if j % pt == 0:
|
||||
pts = getattr(fr, "pts_seconds", None)
|
||||
timestamps.append(float(pts) if pts is not None else i / fps)
|
||||
return frames, timestamps
|
||||
|
||||
def compute_video_frame_size(self, img_w: int, img_h: int) -> tuple[int, int, int]:
|
||||
"""Pick (H, W, tokens_per_frame) for a video frame under the per-frame cap.
|
||||
|
||||
Mirrors MuseGlimmerImageProcessor.compute_image_size but uses the (smaller)
|
||||
per-frame video token budget; shares the same ``_grid_size`` grid logic as
|
||||
modeling_muse_glimmer.MuseGlimmerVisionEncoder.
|
||||
"""
|
||||
ph = self.patch_size * self.downsample_factor
|
||||
return _grid_size(img_w, img_h, ph, self.max_video_frame_tokens)
|
||||
|
||||
def _group_frames(
|
||||
self, frames: list[Image.Image]
|
||||
) -> tuple[list[torch.Tensor], int, int]:
|
||||
"""Resize/normalize frames and cat ``patch_temporal`` frames per group.
|
||||
|
||||
Returns (group_tensors, n_groups, tokens_per_group).
|
||||
"""
|
||||
pt = self.patch_temporal
|
||||
if pt <= 0:
|
||||
raise ValueError(f"patch_temporal must be positive, got {pt}")
|
||||
if not frames:
|
||||
raise ValueError("video must contain at least one frame")
|
||||
if padding := (-len(frames)) % pt:
|
||||
frames = [*frames, *([frames[-1]] * padding)]
|
||||
first = frames[0].convert("RGB")
|
||||
target_h, target_w, n_tokens = self.compute_video_frame_size(
|
||||
first.width, first.height
|
||||
)
|
||||
groups: list[torch.Tensor] = []
|
||||
for i in range(0, len(frames), pt):
|
||||
grp = [
|
||||
self._to_norm_tensor(
|
||||
frames[i + j]
|
||||
.convert("RGB")
|
||||
.resize((target_w, target_h), Image.LANCZOS)
|
||||
)
|
||||
for j in range(pt)
|
||||
]
|
||||
groups.append(torch.cat(grp, dim=0))
|
||||
return groups, len(groups), n_tokens
|
||||
|
||||
@staticmethod
|
||||
def _normalize_videos(videos) -> list:
|
||||
"""Normalize the ``videos`` arg to a list of per-video items.
|
||||
|
||||
A video item is a path (str/Path) or a list of PIL frames. Accepts a
|
||||
single video (one path, or one list of frames) or a list of those.
|
||||
"""
|
||||
if isinstance(videos, (str, Path)):
|
||||
return [videos]
|
||||
if videos and not isinstance(videos[0], (list, tuple, str, Path)):
|
||||
return [videos] # a single list of PIL frames
|
||||
return list(videos or [])
|
||||
|
||||
def preprocess_one(
|
||||
self,
|
||||
video: str | Path | list[Image.Image],
|
||||
timestamps: list[float] | None = None,
|
||||
) -> tuple[list[torch.Tensor], int, int, list[float]]:
|
||||
"""Decode (if a path) + group ONE video.
|
||||
|
||||
Returns (group_tensors, n_groups, tokens_per_group, group_timestamps). This
|
||||
is the single per-video implementation shared by ``preprocess`` (the HF
|
||||
AutoVideoProcessor batch API) and ``MuseGlimmerProcessor.__call__`` (prompt
|
||||
building), so the two paths cannot drift. For a path, frames + real
|
||||
per-group PTS come from ``decode_video``; for pre-decoded frames the given
|
||||
``timestamps`` are used verbatim (empty if not supplied).
|
||||
"""
|
||||
if isinstance(video, (str, Path)):
|
||||
frames, ts = self.decode_video(str(video))
|
||||
else:
|
||||
frames = [f.convert("RGB") for f in video]
|
||||
ts = list(timestamps) if timestamps is not None else []
|
||||
groups, n_groups, tokens_per_group = self._group_frames(frames)
|
||||
return groups, n_groups, tokens_per_group, ts
|
||||
|
||||
def preprocess(
|
||||
self,
|
||||
videos,
|
||||
video_timestamps: list[list[float]] | None = None,
|
||||
return_tensors: str | None = "pt",
|
||||
**kwargs,
|
||||
) -> BatchFeature:
|
||||
"""Preprocess video file path(s) or pre-decoded frame list(s).
|
||||
|
||||
Accepts a single video (path str, or list of PIL frames) or a list of
|
||||
those. Returns a BatchFeature carrying, in video order:
|
||||
* ``pixel_values`` -- flat list of [pt*3, H, W] group tensors,
|
||||
* ``video_num_groups`` -- groups per video,
|
||||
* ``video_tokens_per_group`` -- patch-token count per group per video,
|
||||
* ``video_timestamps`` -- per-group PTS per video,
|
||||
i.e. exactly what MuseGlimmerProcessor needs to build the
|
||||
``<|vid_start|> ( Time: X.Xs <|video|>*P [sep] )* <|vid_end|>`` block.
|
||||
"""
|
||||
videos = self._normalize_videos(videos)
|
||||
pixel_values: list[torch.Tensor] = []
|
||||
num_groups: list[int] = []
|
||||
tokens_per_group: list[int] = []
|
||||
out_ts: list[list[float]] = []
|
||||
for idx, v in enumerate(videos):
|
||||
ts_in = video_timestamps[idx] if video_timestamps else None
|
||||
groups, ng, tpg, ts = self.preprocess_one(v, ts_in)
|
||||
pixel_values += groups
|
||||
num_groups.append(ng)
|
||||
tokens_per_group.append(tpg)
|
||||
out_ts.append(ts)
|
||||
batch = BatchFeature(
|
||||
data={
|
||||
"video_num_groups": num_groups,
|
||||
"video_tokens_per_group": tokens_per_group,
|
||||
"video_timestamps": out_ts,
|
||||
},
|
||||
tensor_type=None,
|
||||
)
|
||||
# Variable-size list -- BatchFeature would fail to stack into one tensor.
|
||||
batch["pixel_values"] = pixel_values
|
||||
return batch
|
||||
|
||||
|
||||
class MuseGlimmerProcessor(ProcessorMixin):
|
||||
"""Bundle MuseGlimmerImageProcessor + MuseGlimmerVideoProcessor + tokenizer;
|
||||
expand media sentinels into spans.
|
||||
|
||||
Images go through ``image_processor`` (MuseGlimmerImageProcessor) and videos through
|
||||
``video_processor`` (MuseGlimmerVideoProcessor) -- one preprocessing implementation
|
||||
each, no overlap.
|
||||
"""
|
||||
|
||||
attributes = ["image_processor", "video_processor", "tokenizer"]
|
||||
image_processor_class = "AutoImageProcessor"
|
||||
video_processor_class = "AutoVideoProcessor"
|
||||
tokenizer_class = "TokenizersBackend"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
image_processor=None,
|
||||
video_processor=None,
|
||||
tokenizer=None,
|
||||
chat_template=None,
|
||||
**kwargs,
|
||||
):
|
||||
if image_processor is None:
|
||||
image_processor = MuseGlimmerImageProcessor()
|
||||
if video_processor is None:
|
||||
video_processor = MuseGlimmerVideoProcessor()
|
||||
super().__init__(
|
||||
image_processor,
|
||||
video_processor,
|
||||
tokenizer,
|
||||
chat_template=chat_template or MUSE_GLIMMER_MM_CHAT_TEMPLATE,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _sid(self, token: str) -> int:
|
||||
return self.tokenizer.convert_tokens_to_ids(token)
|
||||
|
||||
def _image_block(self, n_tokens: int) -> list[int]:
|
||||
return (
|
||||
[self._sid("<|image_start|>")]
|
||||
+ [self._sid("<|patch|>")] * n_tokens
|
||||
+ [self._sid("<|image_end|>")]
|
||||
)
|
||||
|
||||
def _video_block(
|
||||
self,
|
||||
n_groups: int,
|
||||
tokens_per_group: int,
|
||||
timestamps: list[float] | None = None,
|
||||
) -> list[int]:
|
||||
"""Per-group ``Time: X.Xs`` + <|video|>*P, separated/terminated.
|
||||
|
||||
``timestamps`` (one per temporal group, in seconds) should be the ACTUAL
|
||||
decoded frame times -- training renders the real factored frame PTS, not a
|
||||
uniform grid. Falls back to ``g*patch_temporal/fps`` only when timestamps
|
||||
are not supplied (e.g. callers that don't track frame times); that
|
||||
approximation can differ from training by the per-frame jitter.
|
||||
"""
|
||||
vid = self._sid("<|video|>")
|
||||
sep = self._sid("<|vid_frame_separator|>")
|
||||
pt = self.video_processor.patch_temporal
|
||||
fps = self.video_processor.video_sampling_fps
|
||||
block = [self._sid("<|vid_start|>")]
|
||||
for g in range(n_groups):
|
||||
ts = timestamps[g] if timestamps is not None else g * pt / fps
|
||||
block += self.tokenizer.encode(f"Time: {ts:.1f}s", add_special_tokens=False)
|
||||
block += [vid] * tokens_per_group
|
||||
block.append(sep if g < n_groups - 1 else self._sid("<|vid_end|>"))
|
||||
return block
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text: str | list[str] | None = None,
|
||||
images: list[Image.Image] | None = None,
|
||||
videos: list[list[Image.Image] | str] | None = None,
|
||||
video_timestamps: list[list[float]] | None = None,
|
||||
return_tensors: str | None = "pt",
|
||||
**kwargs,
|
||||
) -> BatchFeature:
|
||||
if text is None:
|
||||
raise ValueError(
|
||||
"`text` is required (use apply_chat_template to build it)."
|
||||
)
|
||||
if isinstance(text, (list, tuple)):
|
||||
if len(text) != 1:
|
||||
raise ValueError(
|
||||
"MuseGlimmerProcessor supports a single text sample per call."
|
||||
)
|
||||
text = text[0]
|
||||
|
||||
images = list(images or [])
|
||||
videos = list(videos or [])
|
||||
image_sentinel = self._sid(IMAGE_SENTINEL)
|
||||
video_sentinel = self._sid(VIDEO_SENTINEL)
|
||||
|
||||
# Preprocess media up front so we can expand sentinels in document order.
|
||||
# Images via image_processor; videos via video_processor (each video item
|
||||
# is a path -- decoded here -- or a pre-decoded list of PIL frames).
|
||||
prepped_images = [self.image_processor.preprocess_image(im) for im in images]
|
||||
prepped_videos = [
|
||||
self.video_processor.preprocess_one(
|
||||
v, video_timestamps[i] if video_timestamps else None
|
||||
)
|
||||
for i, v in enumerate(videos)
|
||||
]
|
||||
|
||||
ids = self.tokenizer.encode(text, add_special_tokens=False)
|
||||
n_img = sum(1 for t in ids if t == image_sentinel)
|
||||
n_vid = sum(1 for t in ids if t == video_sentinel)
|
||||
if n_img != len(prepped_images):
|
||||
raise ValueError(
|
||||
f"{n_img} image sentinel(s) in text but "
|
||||
f"{len(prepped_images)} image(s) given."
|
||||
)
|
||||
if n_vid != len(prepped_videos):
|
||||
raise ValueError(
|
||||
f"{n_vid} video sentinel(s) in text but "
|
||||
f"{len(prepped_videos)} video(s) given."
|
||||
)
|
||||
|
||||
out_ids: list[int] = []
|
||||
pixel_values: list[torch.Tensor] = []
|
||||
img_i = vid_i = 0
|
||||
for tid in ids:
|
||||
if tid == image_sentinel:
|
||||
tensor, n_tokens = prepped_images[img_i]
|
||||
img_i += 1
|
||||
out_ids += self._image_block(n_tokens)
|
||||
pixel_values.append(tensor)
|
||||
elif tid == video_sentinel:
|
||||
groups, n_groups, tokens_per_group, ts = prepped_videos[vid_i]
|
||||
vid_i += 1
|
||||
out_ids += self._video_block(n_groups, tokens_per_group, ts or None)
|
||||
pixel_values += groups
|
||||
else:
|
||||
out_ids.append(tid)
|
||||
|
||||
data: dict = {
|
||||
"input_ids": [out_ids],
|
||||
"attention_mask": [[1] * len(out_ids)],
|
||||
}
|
||||
batch = BatchFeature(data=data, tensor_type=return_tensors)
|
||||
# Keep pixel_values as a list of variable-size tensors (MuseGlimmerModel
|
||||
# consumes a list); BatchFeature would fail to stack them into one tensor.
|
||||
if pixel_values:
|
||||
batch["pixel_values"] = pixel_values
|
||||
return batch
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MuseGlimmerImageProcessor",
|
||||
"MuseGlimmerVideoProcessor",
|
||||
"MuseGlimmerProcessor",
|
||||
"MUSE_GLIMMER_MM_CHAT_TEMPLATE",
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Image processor for Unlimited-OCR (baidu/Unlimited-OCR)."""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from vllm.logger import init_logger
|
||||
from vllm.transformers_utils.processors.deepseek_ocr import DeepseekOCRProcessor
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class UnlimitedOCRProcessor(DeepseekOCRProcessor):
|
||||
"""DeepseekOCRProcessor variant for Unlimited-OCR.
|
||||
|
||||
The only behavioural difference from the base processor is a multi-image
|
||||
safeguard: when more than one image is present, crop ("gundam") mode is
|
||||
disabled.
|
||||
|
||||
Because the effective crop flag then depends on *how many* images are in the
|
||||
request, the per-item processing output is no longer invariant of sibling
|
||||
images. ``UnlimitedOCRMultiModalProcessor`` accounts for this by bypassing
|
||||
the multimodal processing cache for multi-image requests (see its
|
||||
``_cached_apply_hf_processor``), so the two paths stay consistent.
|
||||
|
||||
DeepSeek-OCR does *not* have this restriction because its ``max_crops=6`` is
|
||||
small enough to be safe for multi-image use.
|
||||
"""
|
||||
|
||||
def tokenize_with_images(
|
||||
self,
|
||||
conversation: str,
|
||||
images: list[Image.Image],
|
||||
bos: bool = True,
|
||||
eos: bool = True,
|
||||
cropping: bool = True,
|
||||
):
|
||||
if len(images) > 1 and cropping:
|
||||
logger.warning_once(
|
||||
"Unlimited-OCR: crop mode is not supported for multi-image "
|
||||
"input. Falling back to cropping=False."
|
||||
)
|
||||
cropping = False
|
||||
return super().tokenize_with_images(
|
||||
conversation, images, bos=bos, eos=eos, cropping=cropping
|
||||
)
|
||||
@@ -1,444 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Contains replacement functions to fallback Triton usages in CPU backend."""
|
||||
|
||||
import ctypes
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
# For _compute_slot_mapping_kernel in vllm/v1/worker/block_table.py
|
||||
def _compute_slot_mapping_kernel_impl(
|
||||
num_tokens: int,
|
||||
max_num_tokens: int,
|
||||
query_start_loc: torch.Tensor, # [num_reqs + 1], int32
|
||||
positions: torch.Tensor, # [num_tokens], int64
|
||||
block_table: torch.Tensor, # [max_num_reqs, max_num_blocks_per_req], int32
|
||||
block_table_stride: int, # max_num_blocks_per_req
|
||||
slot_mapping: torch.Tensor, # [max_num_tokens], int64
|
||||
KV_CACHE_BLOCK_SIZE: int,
|
||||
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."
|
||||
torch.ops._C.compute_slot_mapping_kernel_impl(
|
||||
query_start_loc,
|
||||
positions,
|
||||
block_table,
|
||||
slot_mapping,
|
||||
KV_CACHE_BLOCK_SIZE,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_int64(t: torch.Tensor) -> torch.Tensor:
|
||||
return t if t.dtype == torch.int64 else t.to(torch.int64)
|
||||
|
||||
|
||||
def _eagle_prepare_inputs_padded_kernel_impl(
|
||||
cu_num_draft_tokens,
|
||||
valid_sampled_tokens_count,
|
||||
query_start_loc_gpu,
|
||||
token_indices_to_sample,
|
||||
num_rejected_tokens_gpu,
|
||||
num_reqs,
|
||||
):
|
||||
# C++ expects int64 for cu_num_draft_tokens, valid_sampled_tokens_count,
|
||||
# and num_rejected_tokens_gpu, but Python allocates them as int32.
|
||||
orig_rejected_dtype = num_rejected_tokens_gpu.dtype
|
||||
rejected_i64 = (
|
||||
num_rejected_tokens_gpu
|
||||
if orig_rejected_dtype == torch.int64
|
||||
else num_rejected_tokens_gpu.to(torch.int64)
|
||||
)
|
||||
torch.ops._C.eagle_prepare_inputs_padded_kernel_impl(
|
||||
_ensure_int64(cu_num_draft_tokens),
|
||||
_ensure_int64(valid_sampled_tokens_count),
|
||||
query_start_loc_gpu,
|
||||
token_indices_to_sample,
|
||||
rejected_i64,
|
||||
num_reqs,
|
||||
)
|
||||
if orig_rejected_dtype != torch.int64:
|
||||
num_rejected_tokens_gpu.copy_(rejected_i64.to(orig_rejected_dtype))
|
||||
|
||||
|
||||
def _eagle_prepare_next_token_padded_kernel_impl(
|
||||
sampled_token_ids,
|
||||
discard_request_mask,
|
||||
backup_next_token_ids,
|
||||
next_token_ids,
|
||||
valid_sampled_tokens_count,
|
||||
vocab_size,
|
||||
num_sampled_tokens_per_req,
|
||||
num_reqs,
|
||||
stride=None,
|
||||
BLOCK_SIZE_TOKENS=None,
|
||||
):
|
||||
# C++ reads all integer tensors as int64_t*. Output tensors are written
|
||||
# in-place so we create int64 copies, call C++, and copy back.
|
||||
orig_next_dtype = next_token_ids.dtype
|
||||
orig_valid_dtype = valid_sampled_tokens_count.dtype
|
||||
next_i64 = _ensure_int64(next_token_ids)
|
||||
valid_i64 = _ensure_int64(valid_sampled_tokens_count)
|
||||
torch.ops._C.eagle_prepare_next_token_padded_kernel_impl(
|
||||
_ensure_int64(sampled_token_ids),
|
||||
discard_request_mask,
|
||||
_ensure_int64(backup_next_token_ids),
|
||||
next_i64,
|
||||
valid_i64,
|
||||
vocab_size,
|
||||
num_sampled_tokens_per_req,
|
||||
num_reqs,
|
||||
)
|
||||
if orig_next_dtype != torch.int64:
|
||||
next_token_ids.copy_(next_i64.to(orig_next_dtype))
|
||||
if orig_valid_dtype != torch.int64:
|
||||
valid_sampled_tokens_count.copy_(valid_i64.to(orig_valid_dtype))
|
||||
|
||||
|
||||
def _eagle_step_slot_mapping_metadata_kernel_impl(
|
||||
positions,
|
||||
block_table,
|
||||
stride,
|
||||
seq_lens,
|
||||
out_clamped_positions,
|
||||
out_slot_mapping,
|
||||
block_size,
|
||||
max_model_len,
|
||||
n_blocks_per_req,
|
||||
PAD_ID,
|
||||
batch_size=None,
|
||||
):
|
||||
assert batch_size is None or batch_size == positions.shape[0], (
|
||||
f"batch_size mismatch: {batch_size} vs positions.shape[0]={positions.shape[0]}"
|
||||
)
|
||||
torch.ops._C.eagle_step_slot_mapping_metadata_kernel_impl(
|
||||
positions,
|
||||
block_table,
|
||||
seq_lens,
|
||||
out_clamped_positions,
|
||||
out_slot_mapping,
|
||||
block_size,
|
||||
max_model_len,
|
||||
PAD_ID,
|
||||
)
|
||||
|
||||
|
||||
def _copy_and_expand_eagle_inputs_kernel_impl(
|
||||
target_token_ids_ptr,
|
||||
target_positions_ptr,
|
||||
next_token_ids_ptr,
|
||||
out_input_ids_ptr,
|
||||
out_positions_ptr,
|
||||
out_is_rejected_token_mask_ptr,
|
||||
out_is_masked_token_mask_ptr,
|
||||
out_new_token_indices_ptr,
|
||||
out_hidden_state_mapping_ptr,
|
||||
query_start_loc_ptr,
|
||||
query_end_loc_ptr,
|
||||
padding_token_id,
|
||||
parallel_drafting_token_id,
|
||||
total_input_tokens,
|
||||
num_padding_slots_per_request,
|
||||
shift_input_ids,
|
||||
BLOCK_SIZE_TOKENS=None,
|
||||
BLOCK_SIZE_REQS=None,
|
||||
):
|
||||
"""Adapter between Triton kernel call convention and C++ implementation.
|
||||
|
||||
The Triton kernel uses '_ptr' suffixed parameter names and compile-time
|
||||
constants (BLOCK_SIZE_TOKENS, BLOCK_SIZE_REQS) which are not needed by
|
||||
the C++ implementation. C++ reads token id tensors as int64_t*.
|
||||
Output tensors that are int32 need copy-back after C++ writes int64.
|
||||
"""
|
||||
orig_ids_dtype = out_input_ids_ptr.dtype
|
||||
orig_pos_dtype = out_positions_ptr.dtype
|
||||
out_ids_i64 = _ensure_int64(out_input_ids_ptr)
|
||||
out_pos_i64 = _ensure_int64(out_positions_ptr)
|
||||
torch.ops._C.copy_and_expand_eagle_inputs_kernel_impl(
|
||||
_ensure_int64(target_token_ids_ptr),
|
||||
_ensure_int64(target_positions_ptr),
|
||||
_ensure_int64(next_token_ids_ptr),
|
||||
out_ids_i64,
|
||||
out_pos_i64,
|
||||
out_is_rejected_token_mask_ptr,
|
||||
out_is_masked_token_mask_ptr,
|
||||
out_new_token_indices_ptr,
|
||||
out_hidden_state_mapping_ptr,
|
||||
query_start_loc_ptr,
|
||||
query_end_loc_ptr,
|
||||
padding_token_id,
|
||||
parallel_drafting_token_id,
|
||||
total_input_tokens,
|
||||
num_padding_slots_per_request,
|
||||
shift_input_ids,
|
||||
)
|
||||
if orig_ids_dtype != torch.int64:
|
||||
out_input_ids_ptr.copy_(out_ids_i64.to(orig_ids_dtype))
|
||||
if orig_pos_dtype != torch.int64:
|
||||
out_positions_ptr.copy_(out_pos_i64.to(orig_pos_dtype))
|
||||
|
||||
|
||||
def _copy_and_expand_dflash_inputs_kernel_impl(
|
||||
next_token_ids_ptr,
|
||||
target_positions_ptr,
|
||||
out_input_ids_ptr,
|
||||
out_context_positions_ptr,
|
||||
out_query_positions_ptr,
|
||||
out_context_slot_mapping_ptr,
|
||||
out_query_slot_mapping_ptr,
|
||||
out_token_indices_ptr,
|
||||
block_table_ptr,
|
||||
block_table_stride,
|
||||
query_start_loc_ptr,
|
||||
num_rejected_tokens_ptr,
|
||||
parallel_drafting_token_id,
|
||||
block_size,
|
||||
num_query_per_req,
|
||||
num_speculative_tokens,
|
||||
total_input_tokens,
|
||||
BLOCK_SIZE=None,
|
||||
HAS_NUM_REJECTED=False,
|
||||
):
|
||||
"""Adapter between the DFlash Triton launch and the C++ CPU op."""
|
||||
assert block_table_stride == block_table_ptr.stride(0), (
|
||||
"block_table_stride mismatch: "
|
||||
f"{block_table_stride} vs {block_table_ptr.stride(0)}"
|
||||
)
|
||||
|
||||
orig_ids_dtype = out_input_ids_ptr.dtype
|
||||
orig_context_positions_dtype = out_context_positions_ptr.dtype
|
||||
orig_query_positions_dtype = out_query_positions_ptr.dtype
|
||||
orig_context_slot_mapping_dtype = out_context_slot_mapping_ptr.dtype
|
||||
orig_query_slot_mapping_dtype = out_query_slot_mapping_ptr.dtype
|
||||
out_ids_i64 = _ensure_int64(out_input_ids_ptr)
|
||||
out_context_positions_i64 = _ensure_int64(out_context_positions_ptr)
|
||||
out_query_positions_i64 = _ensure_int64(out_query_positions_ptr)
|
||||
out_context_slot_mapping_i64 = _ensure_int64(out_context_slot_mapping_ptr)
|
||||
out_query_slot_mapping_i64 = _ensure_int64(out_query_slot_mapping_ptr)
|
||||
rejected_i64 = _ensure_int64(num_rejected_tokens_ptr) if HAS_NUM_REJECTED else None
|
||||
|
||||
if hasattr(torch.ops._C, "copy_and_expand_dflash_inputs_kernel_impl"):
|
||||
torch.ops._C.copy_and_expand_dflash_inputs_kernel_impl(
|
||||
_ensure_int64(next_token_ids_ptr),
|
||||
_ensure_int64(target_positions_ptr),
|
||||
out_ids_i64,
|
||||
out_context_positions_i64,
|
||||
out_query_positions_i64,
|
||||
out_context_slot_mapping_i64,
|
||||
out_query_slot_mapping_i64,
|
||||
out_token_indices_ptr,
|
||||
block_table_ptr,
|
||||
query_start_loc_ptr,
|
||||
rejected_i64,
|
||||
parallel_drafting_token_id,
|
||||
block_size,
|
||||
num_query_per_req,
|
||||
num_speculative_tokens,
|
||||
total_input_tokens,
|
||||
HAS_NUM_REJECTED,
|
||||
)
|
||||
else:
|
||||
next_ids_i64 = _ensure_int64(next_token_ids_ptr)
|
||||
target_positions_i64 = _ensure_int64(target_positions_ptr)
|
||||
block_table_stride = block_table_ptr.stride(0)
|
||||
num_reqs = query_start_loc_ptr.shape[0] - 1
|
||||
|
||||
for req_idx in range(num_reqs):
|
||||
ctx_start = int(query_start_loc_ptr[req_idx].item())
|
||||
ctx_end = int(query_start_loc_ptr[req_idx + 1].item())
|
||||
num_ctx = ctx_end - ctx_start
|
||||
valid_ctx_end = ctx_end
|
||||
if rejected_i64 is not None:
|
||||
valid_ctx_end -= int(rejected_i64[req_idx].item())
|
||||
# Guard against out-of-bounds: ensure valid_ctx_end > ctx_start.
|
||||
valid_ctx_end = max(valid_ctx_end, ctx_start + 1)
|
||||
|
||||
last_pos = int(target_positions_i64[valid_ctx_end - 1].item())
|
||||
|
||||
for j in range(num_ctx):
|
||||
ctx_idx = ctx_start + j
|
||||
ctx_pos_idx = min(ctx_idx, total_input_tokens - 1)
|
||||
position = int(target_positions_i64[ctx_pos_idx].item())
|
||||
block_num = min(position // block_size, block_table_stride - 1)
|
||||
block_id = int(block_table_ptr[req_idx, block_num].item())
|
||||
slot = block_id * block_size + (position % block_size)
|
||||
|
||||
out_context_positions_i64[ctx_idx] = position
|
||||
out_context_slot_mapping_i64[ctx_idx] = slot
|
||||
|
||||
for query_off in range(num_query_per_req):
|
||||
query_out = req_idx * num_query_per_req + query_off
|
||||
position = last_pos + 1 + query_off
|
||||
block_num = min(position // block_size, block_table_stride - 1)
|
||||
block_id = int(block_table_ptr[req_idx, block_num].item())
|
||||
slot = block_id * block_size + (position % block_size)
|
||||
|
||||
out_query_positions_i64[query_out] = position
|
||||
out_query_slot_mapping_i64[query_out] = slot
|
||||
out_ids_i64[query_out] = (
|
||||
int(next_ids_i64[req_idx].item())
|
||||
if query_off == 0
|
||||
else parallel_drafting_token_id
|
||||
)
|
||||
|
||||
if query_off > 0:
|
||||
sample_out_idx = req_idx * num_speculative_tokens + (query_off - 1)
|
||||
out_token_indices_ptr[sample_out_idx] = query_out
|
||||
|
||||
if orig_ids_dtype != torch.int64:
|
||||
out_input_ids_ptr.copy_(out_ids_i64.to(orig_ids_dtype))
|
||||
if orig_context_positions_dtype != torch.int64:
|
||||
out_context_positions_ptr.copy_(
|
||||
out_context_positions_i64.to(orig_context_positions_dtype)
|
||||
)
|
||||
if orig_query_positions_dtype != torch.int64:
|
||||
out_query_positions_ptr.copy_(
|
||||
out_query_positions_i64.to(orig_query_positions_dtype)
|
||||
)
|
||||
if orig_context_slot_mapping_dtype != torch.int64:
|
||||
out_context_slot_mapping_ptr.copy_(
|
||||
out_context_slot_mapping_i64.to(orig_context_slot_mapping_dtype)
|
||||
)
|
||||
if orig_query_slot_mapping_dtype != torch.int64:
|
||||
out_query_slot_mapping_ptr.copy_(
|
||||
out_query_slot_mapping_i64.to(orig_query_slot_mapping_dtype)
|
||||
)
|
||||
|
||||
|
||||
def _rejection_greedy_sample_kernel_impl(
|
||||
output_token_ids,
|
||||
cu_num_draft_tokens,
|
||||
draft_token_ids,
|
||||
target_argmax,
|
||||
bonus_token_ids,
|
||||
is_greedy,
|
||||
max_spec_len,
|
||||
uniform_probs=None,
|
||||
synthetic_conditional_rates=None,
|
||||
SYNTHETIC_MODE=False,
|
||||
):
|
||||
# C++ kernel expects int64 for all integer tensors.
|
||||
# Note: uniform_probs, synthetic_conditional_rates, and SYNTHETIC_MODE are
|
||||
# passed by the rejection sampler for synthetic mode support, but are not
|
||||
# yet implemented in the C++ CPU kernel. We accept them here to maintain
|
||||
# compatibility with the kernel calling convention.
|
||||
assert not SYNTHETIC_MODE, "Synthetic acceptance not supported with CPU sampling"
|
||||
orig_dtype = output_token_ids.dtype
|
||||
output_token_ids_i64 = _ensure_int64(output_token_ids)
|
||||
torch.ops._C.rejection_greedy_sample_kernel_impl(
|
||||
output_token_ids_i64,
|
||||
_ensure_int64(cu_num_draft_tokens),
|
||||
_ensure_int64(draft_token_ids),
|
||||
_ensure_int64(target_argmax),
|
||||
_ensure_int64(bonus_token_ids),
|
||||
is_greedy,
|
||||
max_spec_len,
|
||||
)
|
||||
if orig_dtype != torch.int64:
|
||||
output_token_ids.copy_(output_token_ids_i64.to(orig_dtype))
|
||||
|
||||
|
||||
def _rejection_random_sample_kernel_impl(
|
||||
output_token_ids,
|
||||
cu_num_draft_tokens,
|
||||
draft_token_ids,
|
||||
draft_probs,
|
||||
target_probs,
|
||||
bonus_token_ids,
|
||||
recovered_token_ids,
|
||||
uniform_probs,
|
||||
is_greedy,
|
||||
max_spec_len,
|
||||
vocab_size,
|
||||
synthetic_conditional_rates=None,
|
||||
NO_DRAFT_PROBS=False,
|
||||
SYNTHETIC_MODE=False,
|
||||
):
|
||||
# C++ kernel expects int64 for all integer tensors and float32 for probs.
|
||||
# uniform_probs is intentionally float64 in Python to avoid exact-zero
|
||||
# samples; cast to float32 here for C++ compatibility.
|
||||
# Note: synthetic_conditional_rates and SYNTHETIC_MODE are passed by the
|
||||
# rejection sampler for synthetic mode support, but are not yet implemented
|
||||
# in the C++ CPU kernel. We accept them here to maintain compatibility with
|
||||
# the kernel calling convention.
|
||||
assert not SYNTHETIC_MODE, "Synthetic acceptance not supported with CPU sampling"
|
||||
orig_dtype = output_token_ids.dtype
|
||||
output_token_ids_i64 = _ensure_int64(output_token_ids)
|
||||
torch.ops._C.rejection_random_sample_kernel_impl(
|
||||
output_token_ids_i64,
|
||||
_ensure_int64(cu_num_draft_tokens),
|
||||
_ensure_int64(draft_token_ids),
|
||||
draft_probs,
|
||||
target_probs,
|
||||
_ensure_int64(bonus_token_ids),
|
||||
_ensure_int64(recovered_token_ids),
|
||||
uniform_probs.to(torch.float32),
|
||||
is_greedy,
|
||||
max_spec_len,
|
||||
vocab_size,
|
||||
NO_DRAFT_PROBS,
|
||||
)
|
||||
if orig_dtype != torch.int64:
|
||||
output_token_ids.copy_(output_token_ids_i64.to(orig_dtype))
|
||||
|
||||
|
||||
def _expand_kernel_impl(
|
||||
output,
|
||||
input_val,
|
||||
cu_num_tokens,
|
||||
replace_from,
|
||||
replace_to,
|
||||
MAX_NUM_TOKENS=None,
|
||||
):
|
||||
torch.ops._C.expand_kernel_impl(
|
||||
_ensure_int64(output),
|
||||
_ensure_int64(input_val),
|
||||
_ensure_int64(cu_num_tokens),
|
||||
replace_from,
|
||||
replace_to,
|
||||
)
|
||||
|
||||
|
||||
def _sample_recovered_tokens_kernel_impl(
|
||||
output_token_ids,
|
||||
cu_num_draft_tokens,
|
||||
draft_token_ids,
|
||||
draft_probs,
|
||||
target_probs,
|
||||
inv_q,
|
||||
vocab_size,
|
||||
BLOCK_SIZE=None,
|
||||
NO_DRAFT_PROBS=False,
|
||||
USE_FP64_GUMBEL=False,
|
||||
):
|
||||
# USE_FP64_GUMBEL only controls the gumbel-noise precision, which the caller
|
||||
# has already applied to `inv_q` (fp64 vs fp32). The CPU kernel consumes
|
||||
# `inv_q` directly, so the flag is accepted for interface parity and the
|
||||
# value is read at its existing dtype.
|
||||
# C++ reads integer tensors as int64_t*; ensure correct dtype.
|
||||
orig_dtype = output_token_ids.dtype
|
||||
output_i64 = _ensure_int64(output_token_ids)
|
||||
torch.ops._C.sample_recovered_tokens_kernel_impl(
|
||||
output_i64,
|
||||
_ensure_int64(cu_num_draft_tokens),
|
||||
_ensure_int64(draft_token_ids),
|
||||
draft_probs,
|
||||
target_probs,
|
||||
# C++ kernel reads inv_q as float32.
|
||||
inv_q.to(torch.float32),
|
||||
vocab_size,
|
||||
NO_DRAFT_PROBS,
|
||||
)
|
||||
if orig_dtype != torch.int64:
|
||||
output_token_ids.copy_(output_i64.to(orig_dtype))
|
||||
|
||||
|
||||
def _batch_memcpy_impl(src_ptrs, dst_ptrs, sizes, BLOCK_SIZE=None):
|
||||
# BLOCK_SIZE is unused; kept for signature parity with the Triton kernel.
|
||||
for src, dst, size in zip(src_ptrs.tolist(), dst_ptrs.tolist(), sizes.tolist()):
|
||||
ctypes.memmove(dst, src, size)
|
||||
@@ -1,510 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Reusable attention backend composition and routing policies."""
|
||||
|
||||
import functools
|
||||
import math
|
||||
from typing import ClassVar, Protocol, cast
|
||||
|
||||
import torch
|
||||
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.v1.attention.backend import (
|
||||
AttentionBackend,
|
||||
AttentionCGSupport,
|
||||
AttentionImpl,
|
||||
AttentionMetadataBuilder,
|
||||
CommonAttentionMetadata,
|
||||
)
|
||||
from vllm.v1.attention.backends.utils import get_supported_kv_cache_layouts
|
||||
from vllm.v1.kv_cache_interface import KVCacheSpec
|
||||
|
||||
|
||||
def requires_mm_prefix(
|
||||
metadata: CommonAttentionMetadata, *, unclamped_window: bool = False
|
||||
) -> bool:
|
||||
ranges = metadata.mm_req_doc_ranges
|
||||
# With one query per request there are no future keys to unmask. This
|
||||
# also keeps single-token prefills on the captured FlashInfer path.
|
||||
if not ranges or (metadata.max_query_len <= 1 and not unclamped_window):
|
||||
return False
|
||||
seq_lens = metadata.seq_lens_cpu_upper_bound
|
||||
assert seq_lens is not None
|
||||
starts = metadata.query_start_loc_cpu
|
||||
for req_idx, spans in ranges.items():
|
||||
query_len = int(starts[req_idx + 1] - starts[req_idx])
|
||||
if query_len == 0 or (query_len == 1 and not unclamped_window):
|
||||
continue
|
||||
end = int(seq_lens[req_idx])
|
||||
begin = end - query_len
|
||||
# An unclamped image can also expose keys behind the sliding window.
|
||||
if unclamped_window and any(
|
||||
start < stop and start < end and stop >= begin for start, stop in spans
|
||||
):
|
||||
return True
|
||||
# Prefill lengths are exact. Historical image ranges must not divert
|
||||
# later text/decode queries to the image backend.
|
||||
if any(
|
||||
start < stop and start < end - 1 and stop > begin for start, stop in spans
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_unclamped_window(layers) -> bool:
|
||||
return any(
|
||||
getattr(layer, "sliding_window", None) is not None
|
||||
and not getattr(layer, "mm_prefix_clamp_sliding_window", False)
|
||||
for layer in layers
|
||||
)
|
||||
|
||||
|
||||
class CompositeAttentionRouting(Protocol):
|
||||
"""Select a child backend and declare routing constraints for composition."""
|
||||
|
||||
capture_variant: ClassVar[int]
|
||||
"""Child used during graph capture; replay must select the same child."""
|
||||
|
||||
def __init__(self, layer_names: list[str], vllm_config: VllmConfig) -> None: ...
|
||||
|
||||
def select(self, metadata: CommonAttentionMetadata) -> int:
|
||||
"""Return 0 for the first child or 1 for the second child."""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def get_cudagraph_support(
|
||||
vllm_config: VllmConfig, kv_cache_spec: KVCacheSpec
|
||||
) -> AttentionCGSupport:
|
||||
"""Limit graph support to batches whose routing stays invariant."""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def variant_uses_mm_prefix(variant: int) -> bool:
|
||||
"""Whether this child must support the model's image-mask requirement."""
|
||||
...
|
||||
|
||||
|
||||
class MMPrefixAttentionRouting(CompositeAttentionRouting):
|
||||
"""Route image masks to variant zero and causal queries to variant one."""
|
||||
|
||||
def __init__(self, layer_names: list[str], vllm_config: VllmConfig) -> None:
|
||||
layers = vllm_config.compilation_config.static_forward_context
|
||||
self.unclamped_window = _has_unclamped_window(
|
||||
layers[name] for name in layer_names
|
||||
)
|
||||
|
||||
def select(self, metadata: CommonAttentionMetadata) -> int:
|
||||
return int(
|
||||
not requires_mm_prefix(metadata, unclamped_window=self.unclamped_window)
|
||||
)
|
||||
|
||||
capture_variant: ClassVar[int] = 1
|
||||
|
||||
@staticmethod
|
||||
def get_cudagraph_support(
|
||||
vllm_config: VllmConfig, kv_cache_spec: KVCacheSpec
|
||||
) -> AttentionCGSupport:
|
||||
if _has_unclamped_window(
|
||||
vllm_config.compilation_config.static_forward_context.values()
|
||||
):
|
||||
return AttentionCGSupport.NEVER
|
||||
return AttentionCGSupport.UNIFORM_SINGLE_TOKEN_DECODE
|
||||
|
||||
@staticmethod
|
||||
def variant_uses_mm_prefix(variant: int) -> bool:
|
||||
return variant == 0
|
||||
|
||||
|
||||
def _intersect_kernel_block_sizes(
|
||||
first: type[AttentionBackend],
|
||||
second: type[AttentionBackend],
|
||||
kv_cache_spec: KVCacheSpec | None = None,
|
||||
):
|
||||
from vllm.v1.attention.backend import MultipleOf
|
||||
|
||||
result: list[int | MultipleOf] = []
|
||||
seen: set[tuple[type, int]] = set()
|
||||
for lhs in first.get_supported_kernel_block_sizes(kv_cache_spec):
|
||||
for rhs in second.get_supported_kernel_block_sizes(kv_cache_spec):
|
||||
candidate: int | MultipleOf | None = None
|
||||
if isinstance(lhs, MultipleOf) and isinstance(rhs, MultipleOf):
|
||||
candidate = MultipleOf(math.lcm(lhs.base, rhs.base))
|
||||
elif isinstance(lhs, MultipleOf) and isinstance(rhs, int):
|
||||
candidate = rhs if rhs % lhs.base == 0 else None
|
||||
elif isinstance(lhs, int) and isinstance(rhs, MultipleOf):
|
||||
candidate = lhs if lhs % rhs.base == 0 else None
|
||||
elif lhs == rhs:
|
||||
candidate = lhs
|
||||
if candidate is None:
|
||||
continue
|
||||
value = candidate.base if isinstance(candidate, MultipleOf) else candidate
|
||||
key = (type(candidate), value)
|
||||
if key not in seen:
|
||||
result.append(candidate)
|
||||
seen.add(key)
|
||||
return result
|
||||
|
||||
|
||||
@functools.cache
|
||||
def create_composite_attention_backend(
|
||||
general_backend: type[AttentionBackend],
|
||||
causal_backend: type[AttentionBackend],
|
||||
*,
|
||||
name: str,
|
||||
backend_name: str,
|
||||
module: str,
|
||||
routing_policy: type[CompositeAttentionRouting],
|
||||
head_sizes: tuple[int, ...] = (),
|
||||
kernel_block_sizes: tuple[int, ...] = (),
|
||||
device_major: int | None = None,
|
||||
) -> type[AttentionBackend]:
|
||||
"""Compose two backends with shared storage and an explicit routing policy."""
|
||||
if general_backend.full_cls_name() == causal_backend.full_cls_name():
|
||||
return general_backend
|
||||
general_impl_cls = general_backend.get_impl_cls()
|
||||
causal_impl_cls = causal_backend.get_impl_cls()
|
||||
general_builder_cls = general_backend.get_builder_cls()
|
||||
causal_builder_cls = causal_backend.get_builder_cls()
|
||||
if (
|
||||
general_backend.forward_includes_kv_cache_update
|
||||
or causal_backend.forward_includes_kv_cache_update
|
||||
):
|
||||
raise ValueError("Composite backends require separate KV cache updates")
|
||||
|
||||
class CompositeAttentionImpl(AttentionImpl):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.general_impl = cast(AttentionImpl, general_impl_cls(*args, **kwargs))
|
||||
try:
|
||||
self.causal_impl = cast(AttentionImpl, causal_impl_cls(*args, **kwargs))
|
||||
except TypeError as e:
|
||||
raise ValueError(
|
||||
f"Causal attention backend {causal_backend.get_name()} "
|
||||
f"does not accept the attention arguments required by "
|
||||
f"the general backend {general_backend.get_name()}: {e}"
|
||||
) from e
|
||||
for name in (
|
||||
"num_heads",
|
||||
"num_kv_heads",
|
||||
"head_size",
|
||||
"scale",
|
||||
"kv_cache_dtype",
|
||||
):
|
||||
if hasattr(self.general_impl, name):
|
||||
setattr(self, name, getattr(self.general_impl, name))
|
||||
self.supports_quant_query_input = (
|
||||
self.general_impl.supports_quant_query_input
|
||||
and self.causal_impl.supports_quant_query_input
|
||||
)
|
||||
self.supports_pcp = self.general_impl.supports_pcp
|
||||
self.supports_dcp = self.causal_impl.supports_dcp
|
||||
self.can_return_lse_for_decode = self.causal_impl.can_return_lse_for_decode
|
||||
self.lse_base_on_e = self.causal_impl.lse_base_on_e
|
||||
self.need_to_return_lse_for_decode = (
|
||||
self.causal_impl.need_to_return_lse_for_decode
|
||||
)
|
||||
self.supports_mtp_with_cp_non_trivial_interleave_size = (
|
||||
self.causal_impl.supports_mtp_with_cp_non_trivial_interleave_size
|
||||
)
|
||||
|
||||
def get_impl_for_metadata(self, attn_metadata):
|
||||
return (
|
||||
self.causal_impl
|
||||
if getattr(attn_metadata, "_attention_backend_variant", 0)
|
||||
else self.general_impl
|
||||
)
|
||||
|
||||
def get_impl_variants(self):
|
||||
return self.general_impl, self.causal_impl
|
||||
|
||||
def forward(
|
||||
self,
|
||||
layer,
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
kv_cache,
|
||||
attn_metadata,
|
||||
output,
|
||||
output_scale=None,
|
||||
output_block_scale=None,
|
||||
):
|
||||
impl = self.get_impl_for_metadata(attn_metadata)
|
||||
return impl.forward(
|
||||
layer,
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
kv_cache,
|
||||
attn_metadata,
|
||||
output,
|
||||
output_scale,
|
||||
output_block_scale,
|
||||
)
|
||||
|
||||
def process_weights_after_loading(self, act_dtype: torch.dtype):
|
||||
for impl in self.get_impl_variants():
|
||||
impl.process_weights_after_loading(act_dtype)
|
||||
|
||||
def fused_output_quant_supported(self, quant_key):
|
||||
return all(
|
||||
impl.fused_output_quant_supported(quant_key)
|
||||
for impl in self.get_impl_variants()
|
||||
)
|
||||
|
||||
def fused_qk_norm_rope_kvcache_supported(self):
|
||||
return self.general_impl.fused_qk_norm_rope_kvcache_supported()
|
||||
|
||||
def do_qk_norm_rope_kvcache_update(self, *args, **kwargs):
|
||||
method = self.general_impl.do_qk_norm_rope_kvcache_update
|
||||
return method(*args, **kwargs)
|
||||
|
||||
def fused_rope_kvcache_supported(self):
|
||||
return self.general_impl.fused_rope_kvcache_supported()
|
||||
|
||||
def do_rope_and_kv_cache_update(self, *args, **kwargs):
|
||||
method = self.general_impl.do_rope_and_kv_cache_update
|
||||
return method(*args, **kwargs)
|
||||
|
||||
def do_kv_cache_update(self, *args, **kwargs):
|
||||
method = self.general_impl.do_kv_cache_update # type: ignore[attr-defined]
|
||||
return method(*args, **kwargs)
|
||||
|
||||
class CompositeAttentionMetadataBuilder(AttentionMetadataBuilder):
|
||||
requires_block_table_width = (
|
||||
general_builder_cls.requires_block_table_width
|
||||
or causal_builder_cls.requires_block_table_width
|
||||
)
|
||||
|
||||
def __init__(self, kv_cache_spec, layer_names, vllm_config, device, **kwargs):
|
||||
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
|
||||
args = (kv_cache_spec, layer_names, vllm_config, device)
|
||||
# Only the variant that asked for the extra ctor kwargs receives them.
|
||||
self.general_builder = general_builder_cls(
|
||||
*args,
|
||||
**(kwargs if general_builder_cls.requires_block_table_width else {}),
|
||||
)
|
||||
self.causal_builder = causal_builder_cls(
|
||||
*args,
|
||||
**(kwargs if causal_builder_cls.requires_block_table_width else {}),
|
||||
)
|
||||
self.reorder_batch_threshold = min(
|
||||
(
|
||||
builder.reorder_batch_threshold
|
||||
for builder in self._builders
|
||||
if builder.reorder_batch_threshold is not None
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
self.routing = routing_policy(layer_names, vllm_config)
|
||||
|
||||
@classmethod
|
||||
def get_cudagraph_support(cls, vllm_config, kv_cache_spec):
|
||||
return min(
|
||||
general_builder_cls.get_cudagraph_support(vllm_config, kv_cache_spec),
|
||||
causal_builder_cls.get_cudagraph_support(vllm_config, kv_cache_spec),
|
||||
routing_policy.get_cudagraph_support(vllm_config, kv_cache_spec),
|
||||
key=lambda support: support.value,
|
||||
)
|
||||
|
||||
def _builder(self, common_attn_metadata):
|
||||
backend_variant = self.routing.select(common_attn_metadata)
|
||||
builder = self.causal_builder if backend_variant else self.general_builder
|
||||
return builder, backend_variant
|
||||
|
||||
def _build(self, method, common_attn_metadata, **kwargs):
|
||||
builder, backend_variant = self._builder(common_attn_metadata)
|
||||
metadata = getattr(builder, method)(
|
||||
common_attn_metadata=common_attn_metadata, **kwargs
|
||||
)
|
||||
metadata._attention_backend_variant = backend_variant
|
||||
return metadata
|
||||
|
||||
def build(self, common_prefix_len, common_attn_metadata, fast_build=False):
|
||||
return self._build(
|
||||
"build",
|
||||
common_attn_metadata,
|
||||
common_prefix_len=common_prefix_len,
|
||||
fast_build=fast_build,
|
||||
)
|
||||
|
||||
def build_for_cudagraph_capture(self, common_attn_metadata):
|
||||
variant = routing_policy.capture_variant
|
||||
metadata = self._builders[variant].build_for_cudagraph_capture(
|
||||
common_attn_metadata
|
||||
)
|
||||
metadata._attention_backend_variant = variant
|
||||
return metadata
|
||||
|
||||
@property
|
||||
def _builders(self):
|
||||
return self.general_builder, self.causal_builder
|
||||
|
||||
# Forward the optional workspace protocol so that a wrapped builder
|
||||
# which allocates one (FlashInfer) still joins the runner's cross-group
|
||||
# sharing instead of allocating a second buffer behind the composite.
|
||||
def _get_workspace_buffer(self):
|
||||
for builder in self._builders:
|
||||
if hasattr(builder, "_get_workspace_buffer"):
|
||||
workspace = builder._get_workspace_buffer()
|
||||
# The runner only calls the getter for the group that
|
||||
# provides the buffer, so hand it to the sibling here.
|
||||
self.set_workspace_buffer(workspace)
|
||||
return workspace
|
||||
return None
|
||||
|
||||
def set_workspace_buffer(self, workspace_buffer):
|
||||
for builder in self._builders:
|
||||
if hasattr(builder, "set_workspace_buffer"):
|
||||
builder.set_workspace_buffer(workspace_buffer)
|
||||
|
||||
class CompositeAttentionBackend(AttentionBackend):
|
||||
forward_includes_kv_cache_update = False
|
||||
general_backend_cls = general_backend
|
||||
causal_backend_cls = causal_backend
|
||||
|
||||
@staticmethod
|
||||
def get_name():
|
||||
return backend_name
|
||||
|
||||
@staticmethod
|
||||
def get_impl_cls():
|
||||
return CompositeAttentionImpl
|
||||
|
||||
@staticmethod
|
||||
def get_builder_cls():
|
||||
return CompositeAttentionMetadataBuilder
|
||||
|
||||
@staticmethod
|
||||
def get_supported_kernel_block_sizes(
|
||||
kv_cache_spec: KVCacheSpec | None = None,
|
||||
):
|
||||
if kernel_block_sizes:
|
||||
return [
|
||||
size
|
||||
for size in kernel_block_sizes
|
||||
if all(
|
||||
backend.supports_block_size(size)
|
||||
for backend in (general_backend, causal_backend)
|
||||
)
|
||||
]
|
||||
return _intersect_kernel_block_sizes(
|
||||
general_backend, causal_backend, kv_cache_spec
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def supports_block_size(cls, block_size):
|
||||
return bool(
|
||||
cls.get_supported_kernel_block_sizes()
|
||||
) and super().supports_block_size(block_size)
|
||||
|
||||
@classmethod
|
||||
def get_supported_head_sizes(cls):
|
||||
return list(head_sizes)
|
||||
|
||||
@classmethod
|
||||
def supports_head_size(cls, head_size):
|
||||
return (not head_sizes or head_size in head_sizes) and all(
|
||||
backend.supports_head_size(head_size)
|
||||
for backend in (general_backend, causal_backend)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def supports_compute_capability(cls, capability):
|
||||
return (device_major is None or capability.major == device_major) and all(
|
||||
backend.supports_compute_capability(capability)
|
||||
for backend in (general_backend, causal_backend)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def supports_dtype(cls, dtype):
|
||||
return all(
|
||||
backend.supports_dtype(dtype)
|
||||
for backend in (general_backend, causal_backend)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def supports_kv_cache_dtype(cls, dtype):
|
||||
return all(
|
||||
backend.supports_kv_cache_dtype(dtype)
|
||||
for backend in (general_backend, causal_backend)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def supports_mm_prefix(cls):
|
||||
return all(
|
||||
not routing_policy.variant_uses_mm_prefix(variant)
|
||||
or backend.supports_mm_prefix()
|
||||
for variant, backend in enumerate((general_backend, causal_backend))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def supports_sliding_window(cls):
|
||||
return all(
|
||||
backend.supports_sliding_window()
|
||||
for backend in (general_backend, causal_backend)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def supports_rswa(cls):
|
||||
return all(
|
||||
backend.supports_rswa() for backend in (general_backend, causal_backend)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def supports_pcp(cls):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def supports_dcp(cls):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def supports_device_cpu_query_lens_mismatch(cls):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def supports_combination(
|
||||
cls,
|
||||
head_size,
|
||||
dtype,
|
||||
kv_cache_dtype,
|
||||
block_size,
|
||||
use_mla,
|
||||
has_sink,
|
||||
use_sparse,
|
||||
use_mm_prefix,
|
||||
device_capability,
|
||||
):
|
||||
for variant, backend in enumerate((general_backend, causal_backend)):
|
||||
reason = backend.supports_combination(
|
||||
head_size,
|
||||
dtype,
|
||||
kv_cache_dtype,
|
||||
block_size,
|
||||
use_mla,
|
||||
has_sink,
|
||||
use_sparse,
|
||||
use_mm_prefix and routing_policy.variant_uses_mm_prefix(variant),
|
||||
device_capability,
|
||||
)
|
||||
if reason:
|
||||
return f"{backend.get_name()}: {reason}"
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def customize_spec(cls, spec):
|
||||
general_spec = general_backend.customize_spec(spec)
|
||||
if general_spec != causal_backend.customize_spec(spec):
|
||||
raise ValueError("Composite backends require the same KV cache spec")
|
||||
return general_spec
|
||||
|
||||
@classmethod
|
||||
def supported_kv_cache_layouts(cls):
|
||||
return tuple(
|
||||
get_supported_kv_cache_layouts((general_backend, causal_backend))
|
||||
)
|
||||
|
||||
CompositeAttentionBackend.__name__ = name
|
||||
CompositeAttentionBackend.__qualname__ = name
|
||||
CompositeAttentionBackend.__module__ = module
|
||||
return CompositeAttentionBackend
|
||||
@@ -1,22 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Triton image-mask and FlashInfer causal attention composite."""
|
||||
|
||||
from vllm.v1.attention.backends.composite import (
|
||||
MMPrefixAttentionRouting,
|
||||
create_composite_attention_backend,
|
||||
)
|
||||
from vllm.v1.attention.backends.flashinfer import FlashInferBackend
|
||||
from vllm.v1.attention.backends.triton_attn import TritonAttentionBackend
|
||||
|
||||
TritonFlashInferBackend = create_composite_attention_backend(
|
||||
TritonAttentionBackend,
|
||||
FlashInferBackend,
|
||||
name="TritonFlashInferBackend",
|
||||
backend_name="TRITON_FLASHINFER",
|
||||
module=__name__,
|
||||
routing_policy=MMPrefixAttentionRouting,
|
||||
head_sizes=(256, 512),
|
||||
kernel_block_sizes=(64,),
|
||||
device_major=10,
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from collections.abc import Sequence
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
class UvaBuffer:
|
||||
def __init__(self, size: int | Sequence[int], dtype: torch.dtype):
|
||||
self.cpu = torch.zeros(size, dtype=dtype, device="cpu")
|
||||
self.np = self.cpu.numpy()
|
||||
|
||||
def uva(self, n: int | None = None):
|
||||
return self.cpu[:n] if n is not None else self.cpu
|
||||
@@ -1,16 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from vllm.logger import init_logger
|
||||
from vllm.v1.worker.gpu.model_runner import GPUModelRunner
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class CPUModelRunner(GPUModelRunner):
|
||||
# TBD: Whether need to move this to Worker?
|
||||
def warming_up_model(self) -> None:
|
||||
logger.info("Warming up model for the compilation...")
|
||||
# Only generate graph for the generic shape
|
||||
self.profile_run()
|
||||
logger.info("Warming up done.")
|
||||
@@ -1,104 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
# isort: skip_file
|
||||
# ruff: noqa: E402
|
||||
# mypy: disable-error-code="misc, assignment"
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Patch torch APIs
|
||||
import torch
|
||||
|
||||
|
||||
def noop(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Distinct no-op so empty_cache does not alias synchronize: Dynamo's
|
||||
# handle_synchronize is keyed on that object and asserts on CPU-only hosts.
|
||||
def empty_cache_noop(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def fake_pin_memory(self: torch.Tensor, *args: Any, **kwargs: Any) -> torch.Tensor:
|
||||
return self
|
||||
|
||||
|
||||
class _EventPlaceholder:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.record = noop
|
||||
self.wait = noop
|
||||
self.synchronize = noop
|
||||
|
||||
|
||||
class _StreamPlaceholder:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.wait_stream = noop
|
||||
self.wait_event = noop
|
||||
self.record_event = noop
|
||||
self.synchronize = noop
|
||||
self.query = lambda: True
|
||||
self.device = torch.device("cpu")
|
||||
|
||||
def __enter__(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
|
||||
from vllm.utils.cpu_resource_utils import get_memory_node_info
|
||||
|
||||
|
||||
def get_memory_info(*args: Any, **kwargs: Any) -> tuple[int, int]:
|
||||
meminfo = get_memory_node_info()
|
||||
return meminfo.available_memory, meminfo.total_memory
|
||||
|
||||
|
||||
torch.Event = _EventPlaceholder
|
||||
torch.cuda.Event = _EventPlaceholder
|
||||
torch.cuda.Stream = _StreamPlaceholder
|
||||
torch.cuda.set_stream = noop
|
||||
torch.cuda.current_stream = lambda *args, **kwargs: _StreamPlaceholder()
|
||||
torch.cuda.stream = lambda *args, **kwargs: _StreamPlaceholder()
|
||||
torch.accelerator.synchronize = noop
|
||||
torch.accelerator.empty_cache = empty_cache_noop
|
||||
torch.Tensor.pin_memory = fake_pin_memory
|
||||
torch.Tensor.record_stream = noop
|
||||
torch.accelerator.get_memory_info = get_memory_info
|
||||
|
||||
# Patch vLLM torch utils
|
||||
import vllm.utils.torch_utils as torch_utils
|
||||
|
||||
|
||||
def async_tensor_h2d(
|
||||
data: list | np.ndarray | torch.Tensor,
|
||||
device: str | torch.device | None = None,
|
||||
dtype: torch.dtype | None = None,
|
||||
out: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
if isinstance(data, np.ndarray):
|
||||
data = torch.from_numpy(data)
|
||||
if not isinstance(data, torch.Tensor):
|
||||
data = torch.tensor(data, dtype=dtype, device="cpu")
|
||||
elif out is None:
|
||||
return data.to(dtype=dtype)
|
||||
return data if out is None else out.copy_(data)
|
||||
|
||||
|
||||
torch_utils.async_tensor_h2d = async_tensor_h2d
|
||||
|
||||
# Patch model runner APIs
|
||||
import vllm.v1.worker.gpu.buffer_utils as gpu_buffer_utils
|
||||
import vllm.v1.worker.cpu.buffer_utils as cpu_buffer_utils
|
||||
|
||||
gpu_buffer_utils.UvaBuffer = cpu_buffer_utils.UvaBuffer
|
||||
|
||||
# Patch Triton
|
||||
from vllm.triton_utils import HAS_TRITON, tl
|
||||
|
||||
if HAS_TRITON:
|
||||
tl.debug_barrier = noop
|
||||
@@ -1,188 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm.config import (
|
||||
CompilationMode,
|
||||
VllmConfig,
|
||||
)
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.model_loader import get_model
|
||||
from vllm.tracing import instrument
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig
|
||||
from vllm.v1.utils import CpuGpuBuffer
|
||||
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class CPUModelRunner(GPUModelRunner):
|
||||
def __init__(self, vllm_config: VllmConfig, device: torch.device):
|
||||
# avoid calling accelerator APIs for methods inherited from super class
|
||||
_set_torch_accelerator_to_noop()
|
||||
|
||||
with _torch_cuda_wrapper():
|
||||
super().__init__(vllm_config, device)
|
||||
|
||||
assert device == torch.device("cpu")
|
||||
# Note: speculative decoding is now supported on CPU with C++ native impls
|
||||
|
||||
self.use_cuda_graph = False
|
||||
|
||||
self._postprocess_tensors()
|
||||
|
||||
def _postprocess_tensors(self) -> None:
|
||||
# Note: replace device tensors with cpu tensors
|
||||
def replace_tensor(obj: Any, cpu_attr_name: str, device_attr_name) -> None:
|
||||
cpu_tensor = getattr(obj, cpu_attr_name, None)
|
||||
device_tensor = getattr(obj, device_attr_name, None)
|
||||
if isinstance(cpu_tensor, torch.Tensor) and isinstance(
|
||||
device_tensor, torch.Tensor
|
||||
):
|
||||
setattr(obj, device_attr_name, cpu_tensor)
|
||||
|
||||
for v in vars(self).values():
|
||||
if isinstance(v, CpuGpuBuffer):
|
||||
v.gpu = v.cpu
|
||||
|
||||
for k, v in vars(self.input_batch).items():
|
||||
if k.endswith("_cpu_tensor") and isinstance(v, torch.Tensor):
|
||||
replace_tensor(self.input_batch, k, k[:-11])
|
||||
|
||||
for block_table in self.input_batch.block_table.block_tables:
|
||||
for v in vars(block_table).values():
|
||||
if isinstance(v, CpuGpuBuffer):
|
||||
v.gpu = v.cpu
|
||||
|
||||
@instrument(span_name="Loading (CPU)")
|
||||
def load_model(self, load_dummy_weights: bool = False) -> None:
|
||||
if load_dummy_weights:
|
||||
raise ValueError(
|
||||
"Loading dummy weights (needed for elastic EP scale-up) "
|
||||
"Is not supported by the CPU Model Runner."
|
||||
)
|
||||
logger.info("Starting to load model %s...", self.model_config.model)
|
||||
self.model = get_model(vllm_config=self.vllm_config)
|
||||
|
||||
if self.lora_config:
|
||||
self.model = self.load_lora_model(self.model, self.vllm_config, self.device)
|
||||
|
||||
if hasattr(self, "drafter"):
|
||||
logger.info_once("Loading drafter model...")
|
||||
self.drafter.load_model(self.model)
|
||||
|
||||
self._setup_eagle3_aux_hidden_state_outputs()
|
||||
|
||||
def get_model(self) -> nn.Module:
|
||||
return self.model
|
||||
|
||||
@instrument(span_name="Warmup (CPU)")
|
||||
def warming_up_model(self) -> None:
|
||||
if self.vllm_config.compilation_config.mode == CompilationMode.NONE:
|
||||
return
|
||||
logger.info("Warming up model for the compilation...")
|
||||
# Only generate graph for the generic shape
|
||||
with _set_global_compilation_settings(self.vllm_config):
|
||||
self.profile_run()
|
||||
logger.info("Warming up done.")
|
||||
|
||||
def initialize_kv_cache(
|
||||
self,
|
||||
kv_cache_config: KVCacheConfig,
|
||||
is_profiling: bool = False,
|
||||
kv_cache_allocation_context: AbstractContextManager | None = None,
|
||||
) -> None:
|
||||
super().initialize_kv_cache(
|
||||
kv_cache_config,
|
||||
is_profiling,
|
||||
kv_cache_allocation_context=kv_cache_allocation_context,
|
||||
)
|
||||
|
||||
if self.speculative_config:
|
||||
if self.speculative_config.use_eagle():
|
||||
logger.info("EAGLE drafter KV cache initialized for CPU backend")
|
||||
elif self.speculative_config.uses_draft_model():
|
||||
logger.info("Draft model KV cache initialized for CPU backend")
|
||||
|
||||
def _sync_device(self) -> None:
|
||||
pass
|
||||
|
||||
def _zero_block_ids(self, block_ids: list[int]) -> None:
|
||||
# Zero full-attention blocks to prevent stale data corruption on partial writes.
|
||||
# Encoder-only (runner-only) layers are not FullAttentionSpec, so the
|
||||
# spec filter below already excludes them; no runner-only skip needed.
|
||||
seen_ptrs: set[int] = set()
|
||||
for group in self.kv_cache_config.kv_cache_groups:
|
||||
if not isinstance(group.kv_cache_spec, FullAttentionSpec):
|
||||
continue
|
||||
for layer_name in group.layer_names:
|
||||
ctx = self.compilation_config.static_forward_context.get(layer_name)
|
||||
if ctx is None:
|
||||
continue
|
||||
kv = ctx.kv_cache
|
||||
if not isinstance(kv, torch.Tensor):
|
||||
continue
|
||||
if kv.data_ptr() in seen_ptrs:
|
||||
continue
|
||||
seen_ptrs.add(kv.data_ptr())
|
||||
for block_id in block_ids:
|
||||
kv[block_id].zero_()
|
||||
|
||||
def _to_list(self, sampled_token_ids: torch.Tensor) -> list[list[int]]:
|
||||
"""CPU-safe version: direct tolist() without CUDA events."""
|
||||
return sampled_token_ids.tolist()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _torch_cuda_wrapper():
|
||||
class _EventPlaceholder:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.record = lambda: None
|
||||
self.synchronize = lambda: None
|
||||
|
||||
class _StreamPlaceholder:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self.wait_stream = lambda *a, **kw: None
|
||||
self.device = torch.device("cpu")
|
||||
|
||||
cuda_event = torch.Event
|
||||
cuda_stream = torch.cuda.Stream
|
||||
try:
|
||||
torch.Event = _EventPlaceholder
|
||||
torch.cuda.Stream = _StreamPlaceholder
|
||||
yield
|
||||
finally:
|
||||
torch.Event = cuda_event
|
||||
torch.cuda.Stream = cuda_stream
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _set_global_compilation_settings(config: VllmConfig):
|
||||
import torch._inductor.config as torch_inductor_config
|
||||
|
||||
inductor_config = config.compilation_config.inductor_compile_config
|
||||
# Note: The MKLDNN and CPPGEMM backend requires freezing parameters.
|
||||
freezing_value = torch_inductor_config.freezing
|
||||
try:
|
||||
if inductor_config.get("max_autotune", False):
|
||||
torch_inductor_config.freezing = True
|
||||
yield
|
||||
finally:
|
||||
torch_inductor_config.freezing = freezing_value
|
||||
|
||||
|
||||
def _set_torch_accelerator_to_noop() -> None:
|
||||
def noop(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
# Distinct no-op so empty_cache does not alias synchronize: Dynamo's
|
||||
# handle_synchronize is keyed on that object and asserts on CPU-only hosts.
|
||||
def empty_cache_noop(*args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
torch.accelerator.synchronize = noop
|
||||
torch.accelerator.empty_cache = empty_cache_noop
|
||||
@@ -1,291 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
# Must be imported firstly
|
||||
import vllm.v1.worker.cpu.shm # noqa # isort: skip
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import psutil
|
||||
import torch
|
||||
|
||||
from vllm import envs
|
||||
from vllm.config import CompilationMode, VllmConfig
|
||||
from vllm.logger import init_logger
|
||||
from vllm.platforms import CpuArchEnum, current_platform
|
||||
from vllm.utils.cpu_resource_utils import (
|
||||
get_allowed_cpu_list,
|
||||
get_memory_node_info,
|
||||
get_visible_memory_node,
|
||||
)
|
||||
from vllm.utils.mem_utils import format_gib
|
||||
from vllm.utils.torch_utils import set_random_seed
|
||||
from vllm.v1.worker.cpu_model_runner import CPUModelRunner
|
||||
from vllm.v1.worker.gpu_worker import Worker, init_worker_distributed_environment
|
||||
from vllm.v1.worker.worker_base import CompilationTimes
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class CPUWorker(Worker):
|
||||
def __init__(
|
||||
self,
|
||||
vllm_config: VllmConfig,
|
||||
local_rank: int,
|
||||
rank: int,
|
||||
distributed_init_method: str,
|
||||
is_driver_worker: bool = False,
|
||||
):
|
||||
# TODO: use numactl for process setup
|
||||
# TODO: optimize for `interleaved` policy
|
||||
# Bind memory node
|
||||
allowed_memory_nodes = get_visible_memory_node()
|
||||
allowed_cpu_list = get_allowed_cpu_list()
|
||||
cpu_core = allowed_cpu_list[0]
|
||||
|
||||
# TODO: some CI hosts are not correctly set, change to assertion
|
||||
# after fix
|
||||
if cpu_core.numa_node not in allowed_memory_nodes:
|
||||
logger.warning(
|
||||
"Node %s is not in available memory nodes %s.",
|
||||
cpu_core.numa_node,
|
||||
allowed_memory_nodes,
|
||||
)
|
||||
|
||||
# On s390x, numa_node may be a synthetic book ID that doesn't
|
||||
# correspond to a real memory node. Fall back to first visible node.
|
||||
if cpu_core.numa_node in allowed_memory_nodes:
|
||||
memory_node = cpu_core.numa_node
|
||||
else:
|
||||
logger.warning(
|
||||
"CPU group key %s is not a valid memory node. "
|
||||
"Falling back to memory node %s.",
|
||||
cpu_core.numa_node,
|
||||
allowed_memory_nodes[0],
|
||||
)
|
||||
memory_node = allowed_memory_nodes[0]
|
||||
|
||||
torch.ops._C.init_cpu_memory_env([memory_node])
|
||||
|
||||
memory_status = get_memory_node_info(memory_node)
|
||||
memory_fraction = vllm_config.cache_config.gpu_memory_utilization
|
||||
self.requested_cpu_memory = math.ceil(
|
||||
memory_status.total_memory * memory_fraction
|
||||
)
|
||||
available_memory = memory_status.available_memory
|
||||
|
||||
if (
|
||||
vllm_config.cache_config.kv_cache_memory_bytes is None
|
||||
and self.requested_cpu_memory > available_memory
|
||||
):
|
||||
raise ValueError(
|
||||
f"Available memory on node {cpu_core.numa_node} "
|
||||
f"({format_gib(available_memory)}/"
|
||||
f"{format_gib(memory_status.total_memory)} GiB) on startup "
|
||||
f"is less than desired CPU memory utilization "
|
||||
f"({vllm_config.cache_config.gpu_memory_utilization}, "
|
||||
f"{format_gib(self.requested_cpu_memory)} GiB). "
|
||||
"On the CPU backend, the `--gpu-memory-utilization` flag "
|
||||
"controls the fraction of CPU memory reserved (despite its "
|
||||
"name). To resolve: decrease `--gpu-memory-utilization` "
|
||||
"(e.g. `--gpu-memory-utilization 0.5`) "
|
||||
"or reduce CPU memory used by other processes."
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
vllm_config,
|
||||
local_rank,
|
||||
rank,
|
||||
distributed_init_method,
|
||||
is_driver_worker=is_driver_worker,
|
||||
)
|
||||
|
||||
self.parallel_config.disable_custom_all_reduce = True
|
||||
|
||||
def init_device(self):
|
||||
self.device = torch.device("cpu")
|
||||
|
||||
# Check whether critical libraries are loaded
|
||||
def check_preloaded_libs(name: str) -> bool:
|
||||
ld_preload_list = os.environ.get("LD_PRELOAD", "")
|
||||
if name not in ld_preload_list:
|
||||
logger.warning(
|
||||
"%s is not found in LD_PRELOAD. "
|
||||
"For best performance, please follow the section "
|
||||
"`set LD_PRELOAD` in "
|
||||
"https://docs.vllm.ai/en/latest/getting_started/installation/cpu/ "
|
||||
"to setup required pre-loaded libraries.",
|
||||
name,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
if sys.platform.startswith("linux"):
|
||||
check_preloaded_libs("libtcmalloc")
|
||||
if current_platform.get_cpu_architecture() == CpuArchEnum.X86:
|
||||
iomp_loaded = check_preloaded_libs("libiomp")
|
||||
if not iomp_loaded and self.vllm_config.speculative_config is not None:
|
||||
logger.warning(
|
||||
"Speculative decoding on CPU without Intel OpenMP in "
|
||||
"LD_PRELOAD will cause significant performance loss. "
|
||||
"Please follow the section `set LD_PRELOAD` in "
|
||||
"https://docs.vllm.ai/en/latest/getting_started/"
|
||||
"installation/cpu/ "
|
||||
"to setup libiomp5.",
|
||||
)
|
||||
|
||||
def skip_set_num_threads(x: int):
|
||||
logger.warning(
|
||||
"CPU backend doesn't allow to use "
|
||||
"`torch.set_num_threads` after the thread binding, skip it."
|
||||
)
|
||||
|
||||
torch.set_num_threads = skip_set_num_threads
|
||||
|
||||
init_method = self.distributed_init_method
|
||||
os.environ["VLLM_DIST_IDENT"] = (
|
||||
os.path.basename(init_method.removeprefix("file://"))
|
||||
if init_method.startswith("file://")
|
||||
else init_method.split(":")[-1]
|
||||
)
|
||||
# Initialize the distributed environment.
|
||||
init_worker_distributed_environment(
|
||||
self.vllm_config,
|
||||
self.rank,
|
||||
self.distributed_init_method,
|
||||
self.local_rank,
|
||||
current_platform.dist_backend,
|
||||
)
|
||||
if self.use_v2_model_runner:
|
||||
logger.info_once("Using V2 Model Runner")
|
||||
|
||||
# Set random seed.
|
||||
set_random_seed(self.model_config.seed)
|
||||
|
||||
# Construct the model runner
|
||||
if self.use_v2_model_runner:
|
||||
from vllm.v1.worker.cpu.model_runner import (
|
||||
CPUModelRunner as CPUModelRunnerV2,
|
||||
)
|
||||
|
||||
self.model_runner: CPUModelRunner = CPUModelRunnerV2( # type: ignore
|
||||
self.vllm_config, self.device
|
||||
)
|
||||
else:
|
||||
self.model_runner = CPUModelRunner(self.vllm_config, torch.device("cpu"))
|
||||
|
||||
def sleep(self, level: int = 1) -> None:
|
||||
logger.warning("sleep mode is not supported on CPU, ignore it.")
|
||||
pass
|
||||
|
||||
def wake_up(self, tags: list[str] | None = None) -> None:
|
||||
logger.warning("sleep mode is not supported on CPU, ignore it.")
|
||||
pass
|
||||
|
||||
def discard(self, tags: tuple[str, ...]) -> None:
|
||||
raise RuntimeError("Selective discard is not supported on CPU.")
|
||||
|
||||
def _should_warm_up_model(self) -> bool:
|
||||
# VLLM_CPU_CI_ENV always skips warmup to save CI time.
|
||||
if envs.VLLM_CPU_CI_ENV:
|
||||
return False
|
||||
# With eager (CompilationMode.NONE) execution and an explicit KV
|
||||
# cache size, warmup serves no purpose: there's no compiled graph to
|
||||
# prewarm, and the auto KV-cache-size calc below -- which relies on
|
||||
# a warmup forward pass having already bumped RSS to a realistic
|
||||
# steady-state value -- is bypassed whenever the size is explicit.
|
||||
return not (
|
||||
self.compilation_config.mode == CompilationMode.NONE
|
||||
and self.cache_config.kv_cache_memory_bytes is not None
|
||||
)
|
||||
|
||||
def determine_available_memory(self) -> int:
|
||||
if self._should_warm_up_model():
|
||||
self.model_runner.warming_up_model()
|
||||
|
||||
allowed_cpu_list = get_allowed_cpu_list()
|
||||
cpu_core = allowed_cpu_list[0]
|
||||
|
||||
memory_status = get_memory_node_info(cpu_core.numa_node)
|
||||
available_memory = memory_status.available_memory
|
||||
explicit_kv_cache_size = self.cache_config.kv_cache_memory_bytes
|
||||
|
||||
kv_cache_size = None
|
||||
msg = None
|
||||
if explicit_kv_cache_size is not None:
|
||||
if explicit_kv_cache_size > available_memory:
|
||||
raise ValueError(
|
||||
f"Available memory on node {cpu_core.numa_node} "
|
||||
f"({format_gib(available_memory)}/"
|
||||
f"{format_gib(memory_status.total_memory)} GiB) on kv cache"
|
||||
f" allocation is less than requested memory for kv "
|
||||
f"({format_gib(explicit_kv_cache_size)} GiB). "
|
||||
"Decrease --kv-cache-memory-bytes, VLLM_CPU_KVCACHE_SPACE, "
|
||||
"or reduce CPU memory used by other processes."
|
||||
)
|
||||
kv_cache_size = explicit_kv_cache_size
|
||||
msg = (
|
||||
f"Explicitly set ({format_gib(kv_cache_size)}/"
|
||||
f"{format_gib(memory_status.total_memory)}) GiB for KV cache "
|
||||
f"on node {cpu_core.numa_node}."
|
||||
)
|
||||
else:
|
||||
consumed_memory = psutil.Process(os.getpid()).memory_info().rss
|
||||
requested_memory_for_kv = int(self.requested_cpu_memory - consumed_memory)
|
||||
if requested_memory_for_kv <= 0:
|
||||
# The worker's own footprint already fills its reservation, so
|
||||
# freeing memory elsewhere cannot help: only a larger
|
||||
# reservation or an explicit KV cache size can.
|
||||
in_use = math.ceil(consumed_memory / memory_status.total_memory * 100)
|
||||
raise ValueError(
|
||||
f"The worker already uses {format_gib(consumed_memory)} GiB "
|
||||
"(model weights and runtime), more than the "
|
||||
f"{format_gib(self.requested_cpu_memory)} GiB of CPU memory "
|
||||
f"reserved for it on node {cpu_core.numa_node}, leaving none "
|
||||
"for the KV cache. On the CPU backend, the "
|
||||
"`--gpu-memory-utilization` flag controls the fraction of CPU "
|
||||
"memory reserved (despite its name). To resolve: increase "
|
||||
f"`--gpu-memory-utilization` to more than {in_use / 100:.2f}, "
|
||||
"or set the KV cache size explicitly with "
|
||||
"`--kv-cache-memory-bytes` or VLLM_CPU_KVCACHE_SPACE."
|
||||
)
|
||||
if requested_memory_for_kv > available_memory:
|
||||
raise ValueError(
|
||||
f"Available memory on node {cpu_core.numa_node} "
|
||||
f"({format_gib(available_memory)}/"
|
||||
f"{format_gib(memory_status.total_memory)} GiB) on kv cache"
|
||||
f" allocation is less than requested memory for kv "
|
||||
f"({format_gib(requested_memory_for_kv)}/"
|
||||
f"{format_gib(self.requested_cpu_memory)} GiB). "
|
||||
"Reduce CPU memory used by other processes."
|
||||
)
|
||||
kv_cache_size = requested_memory_for_kv
|
||||
msg = (
|
||||
f"Auto set ({format_gib(kv_cache_size)}/"
|
||||
f"{format_gib(memory_status.total_memory)}) GiB for KV cache "
|
||||
f"on node {cpu_core.numa_node}, with "
|
||||
f"{format_gib(self.requested_cpu_memory)} GiB requested memory"
|
||||
f" for the worker. {format_gib(consumed_memory)} GiB"
|
||||
f" memory was consumed by non-kv usages."
|
||||
)
|
||||
|
||||
logger.info(msg)
|
||||
|
||||
return kv_cache_size
|
||||
|
||||
def compile_or_warm_up_model(self) -> CompilationTimes:
|
||||
# Note: the model has been compiled in determine_available_memory(),
|
||||
# Only compile here for models without kv cache
|
||||
if len(self.model_runner.kv_caches) == 0 and self._should_warm_up_model():
|
||||
self.model_runner.warming_up_model()
|
||||
# Reset the seed to ensure that the random state is not affected by
|
||||
# the model initialization and profiling.
|
||||
set_random_seed(
|
||||
self.model_config.seed, self.parallel_config.data_parallel_index
|
||||
)
|
||||
return CompilationTimes(
|
||||
language_model=self.compilation_config.compilation_time,
|
||||
encoder=self.compilation_config.encoder_compilation_time,
|
||||
)
|
||||
Reference in New Issue
Block a user