Hidden states extraction improvements (#43805)

Signed-off-by: Fynn Schmitt-Ulms <fschmitt@redhat.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
Fynn Schmitt-Ulms
2026-06-11 09:44:45 -04:00
committed by GitHub
co-authored by mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
parent ab3a1fd2e6
commit ebc6ef971a
7 changed files with 454 additions and 233 deletions
@@ -92,7 +92,6 @@ def run_baseline(
llm = LLM(
model=model,
enable_prefix_caching=False,
enable_chunked_prefill=False,
**extra_args,
)
sampling_params = SamplingParams(max_tokens=1)
@@ -194,7 +193,6 @@ async def _run_extraction_async(
engine_args = AsyncEngineArgs(
model=model,
enable_prefix_caching=False,
enable_chunked_prefill=False,
max_num_batched_tokens=40960,
max_model_len=40960,
speculative_config={
@@ -19,7 +19,6 @@ from vllm.distributed.kv_transfer.kv_connector.v1 import (
with tempfile.TemporaryDirectory() as tmpdir:
llm = LLM(
model="Qwen/Qwen3-8B",
enable_chunked_prefill=False,
speculative_config={
"method": "extract_hidden_states",
"num_speculative_tokens": 1,
@@ -59,17 +58,58 @@ For improved performance, it is recommended to use a RAM-mounted file system suc
```bash
vllm serve Qwen/Qwen3-8B \
--speculative_config '{"method": "extract_hidden_states", "num_speculative_tokens": 1, "draft_model_config": {"hf_config": {"eagle_aux_hidden_state_layer_ids": [1, 2, 3, 4]}}}' \
--kv_transfer_config '{"kv_connector": "ExampleHiddenStatesConnector", "kv_role": "kv_producer", "kv_connector_extra_config": {"shared_storage_path": "/dev/shm/hidden_states"}}' \
--no-enable-chunked-prefill
--kv_transfer_config '{"kv_connector": "ExampleHiddenStatesConnector", "kv_role": "kv_producer", "kv_connector_extra_config": {"shared_storage_path": "/dev/shm/hidden_states"}}'
```
## Per-Request Options
Both offline and online modes support per-request options via `kv_transfer_params`:
| Parameter | Default | Description |
| --- | --- | --- |
| `hidden_states_path` | Auto-generated | Custom file path for saving hidden states. If not set, files are saved to `<shared_storage_path>/<request_id>.safetensors`. Requires `allow_custom_save_path` to be enabled in the server config. |
| `include_output_tokens` | `False` | When `True`, save hidden states for both prompt and generated output tokens. When `False`, only prompt token hidden states are saved. |
### Offline usage
Pass per-request options via `extra_args` on `SamplingParams`:
```python
SamplingParams(
max_tokens=32,
extra_args={
"kv_transfer_params": {
"hidden_states_path": "/tmp/my_output.safetensors",
"include_output_tokens": True,
}
},
)
```
### Online usage
Pass `kv_transfer_params` as a top-level field in the API request:
```json
{
"model": "Qwen/Qwen3-8B",
"messages": [{"role": "user", "content": "Hello"}],
"max_tokens": 32,
"kv_transfer_params": {
"hidden_states_path": "/tmp/my_output.safetensors",
"include_output_tokens": true
}
}
```
## Configuration
The `kv_connector_extra_config` dict accepts these options:
The `kv_connector_extra_config` dict accepts these server-level options:
| Parameter | Default | Description |
| --- | --- | --- |
| `shared_storage_path` | `/tmp` | Directory where hidden state files are saved |
| `shared_storage_path` | `/tmp` | Directory where hidden state files are saved (used when `hidden_states_path` is not set per-request) |
| `allow_custom_save_path` | `False` | Allow API clients to specify custom file paths via `hidden_states_path`. When disabled, client-provided paths are ignored with a warning. Enable only with trusted clients — custom paths can write to arbitrary locations on the server. |
| `num_writer_threads` | `8` | Thread pool size for async disk writes |
| `use_synchronization_lock` | `True` | Use file locks so concurrent readers block until writes complete. Can be disabled for batch generation where synchronization is not needed. |
@@ -1,5 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
import tempfile
from vllm import LLM, SamplingParams
@@ -18,7 +19,6 @@ from vllm.distributed.kv_transfer.kv_connector.v1 import (
with tempfile.TemporaryDirectory() as tmpdirname:
llm = LLM(
model="Qwen/Qwen3-8B", # Your target model
enable_chunked_prefill=False, # required
speculative_config={
"method": "extract_hidden_states",
"num_speculative_tokens": 1,
@@ -38,13 +38,30 @@ with tempfile.TemporaryDirectory() as tmpdirname:
kv_role="kv_producer",
kv_connector_extra_config={
"shared_storage_path": tmpdirname,
"allow_custom_save_path": True,
},
),
)
prompts = ["Generate a sentence with hidden states", "Write a python function"]
sampling_params = SamplingParams(max_tokens=1)
outputs = llm.generate(prompts, sampling_params)
# One request uses defaults, the other uses a custom save path and
# includes output token hidden states via per-request kv_transfer_params.
sampling_params_list = [
SamplingParams(max_tokens=1),
SamplingParams(
max_tokens=10,
extra_args={
"kv_transfer_params": {
"hidden_states_path": os.path.join(
tmpdirname, "custom_output.safetensors"
),
"include_output_tokens": True,
}
},
),
]
outputs = llm.generate(prompts, sampling_params_list)
for output in outputs:
print("\nPrompt:", output.prompt)
@@ -52,16 +69,16 @@ with tempfile.TemporaryDirectory() as tmpdirname:
hidden_states_path = output.kv_transfer_params.get("hidden_states_path")
assert hidden_states_path is not None
print("Prompt hidden states path:", hidden_states_path)
print("Hidden states path:", hidden_states_path)
obj = example_hidden_states_connector.load_hidden_states(hidden_states_path)
token_ids = obj["token_ids"]
hidden_states = obj["hidden_states"]
print("Extracted token ids:", token_ids) # Matches prompt token ids
print("Extracted token ids:", token_ids)
print(
"Extracted hidden states shape:", hidden_states.shape
) # [prompt_len, num_extracted_layers, hidden_size]
) # [num_tokens, num_extracted_layers, hidden_size]
print("Extracted hidden states:", hidden_states)
example_hidden_states_connector.cleanup_hidden_states(hidden_states_path)
@@ -1,44 +1,40 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import gc
import os
import tempfile
import pytest
import torch
from safetensors import safe_open
from tests.utils import create_new_process_for_each_test, multi_gpu_test
from vllm import LLM, ModelRegistry, SamplingParams
from vllm.distributed.kv_transfer.kv_connector.v1 import (
example_hidden_states_connector,
)
def get_and_check_output(output, expected_shape):
assert output.kv_transfer_params is not None
hidden_states_path = output.kv_transfer_params.get("hidden_states_path")
assert hidden_states_path is not None
assert os.path.exists(hidden_states_path)
# Load and verify the saved tensors
with safe_open(hidden_states_path, "pt") as f:
# Check that token_ids and hidden_states are present
tensor_names = f.keys()
assert "token_ids" in tensor_names
assert "hidden_states" in tensor_names
obj = example_hidden_states_connector.load_hidden_states(hidden_states_path)
token_ids = obj["token_ids"]
hidden_states = obj["hidden_states"]
token_ids = f.get_tensor("token_ids")
hidden_states = f.get_tensor("hidden_states")
prompt_token_ids = output.prompt_token_ids
assert torch.equal(token_ids, torch.tensor(prompt_token_ids))
prompt_token_ids = output.prompt_token_ids
assert torch.equal(token_ids, torch.tensor(prompt_token_ids))
assert hidden_states.shape == expected_shape
assert hidden_states.shape == expected_shape
# Verify hidden_states are not all zeros (i.e., they were actually computed)
assert not torch.allclose(hidden_states, torch.zeros_like(hidden_states))
# Verify hidden_states are not all zeros (i.e., they were actually computed)
assert not torch.allclose(hidden_states, torch.zeros_like(hidden_states))
return token_ids, hidden_states
@pytest.fixture(scope="module")
@pytest.fixture
def predictable_llama_config_path(tmp_path_factory):
"""Create a minimal LlamaConfig for PredictableLlamaForCausalLM."""
from transformers import LlamaConfig, LlamaTokenizerFast
@@ -53,7 +49,7 @@ def predictable_llama_config_path(tmp_path_factory):
num_hidden_layers=24, # Enough layers to test various layer_ids
num_attention_heads=4,
num_key_value_heads=4,
max_position_embeddings=128,
max_position_embeddings=1024,
architectures=["PredictableLlamaForCausalLM"],
)
@@ -85,24 +81,25 @@ def register_predictable_model():
def test_extract_hidden_states_with_predictable_dummy_model(
predictable_llama_config_path, tmp_path, monkeypatch
):
"""Comprehensive test using a predictable dummy model with synthetic weights.
"""Test hidden-state extraction with a predictable dummy model.
The PredictableLlamaForCausalLM outputs deterministic hidden states where
each layer produces values equal to (layer_index). This test verifies:
1. Hidden states are correctly extracted from requested layers
2. Values match the expected predictable pattern
3. Layer ordering is preserved correctly (non-sequential layer IDs)
4. Multiple prompts of different lengths produce consistent layer values
Tests 3 scenarios:
1. **Basic extraction**: non-sequential layer ordering, multiple prompts
of varying length — verifies correct layer association and
deterministic values.
2. **Chunked prefill**: max_num_batched_tokens=128 with ~500-token
prompts so each is split across multiple scheduler iterations —
verifies hidden states are reassembled correctly.
3. **Per-request options**: custom hidden_states_path and
include_output_tokens — verifies per-request kv_transfer_params
plumbing.
"""
# Force fork so the engine worker inherits the autouse fixture's
# ModelRegistry.register_model("PredictableLlamaForCausalLM", ...).
# Spawn (the CI default) starts a fresh Python process that wouldn't
# see the registration.
monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "fork")
# Test with non-sequential layer ordering to verify correct association
layer_ids = [5, 2, 10]
num_layers = len(layer_ids)
max_num_batched_tokens = 128
llm = LLM(
model=predictable_llama_config_path,
@@ -116,16 +113,21 @@ def test_extract_hidden_states_with_predictable_dummy_model(
kv_transfer_config={
"kv_connector": "ExampleHiddenStatesConnector",
"kv_role": "kv_producer",
"kv_connector_extra_config": {"shared_storage_path": tmp_path},
"kv_connector_extra_config": {
"shared_storage_path": tmp_path,
"allow_custom_save_path": True,
},
},
max_model_len=128,
max_model_len=1024,
max_num_batched_tokens=max_num_batched_tokens,
enforce_eager=True,
enable_chunked_prefill=False,
trust_remote_code=True,
load_format="dummy", # Don't try to load real weights
load_format="dummy",
)
# Test with multiple prompts of different lengths
hidden_size = llm.llm_engine.model_config.get_hidden_size()
# --- Scenario 1: basic extraction with non-sequential layers ----------
prompts = [
"Short",
"Medium length",
@@ -133,15 +135,10 @@ def test_extract_hidden_states_with_predictable_dummy_model(
"Much longer prompt with many tokens", # repeated prompt
]
sampling_params = SamplingParams(max_tokens=1, temperature=0.0)
hidden_size = llm.llm_engine.model_config.get_hidden_size()
outputs = llm.generate(prompts, sampling_params)
del llm
gc.collect()
assert len(outputs) == len(prompts)
for output in outputs:
# hidden_states shape is [prompt_len, num_hidden_layers, hidden_size]
expected_shape = (
len(output.prompt_token_ids),
num_layers,
@@ -156,12 +153,100 @@ def test_extract_hidden_states_with_predictable_dummy_model(
torch.full_like(layer_hidden, layer_id),
atol=1e-5,
), (
f"Layer {layer_id} at position {idx} should output {float(layer_id)}, "
f"but got mean={layer_hidden.mean():.3f}, "
f"min={layer_hidden.min():.3f}, max={layer_hidden.max():.3f}"
f"Layer {layer_id} at position {idx} should output "
f"{float(layer_id)}, but got mean="
f"{layer_hidden.mean():.3f}, min="
f"{layer_hidden.min():.3f}, max={layer_hidden.max():.3f}"
)
# --- Scenario 2: chunked prefill with long prompts --------------------
long_prompt = " ".join(["word"] * 500)
chunked_prompts = [
long_prompt,
long_prompt + " extra tokens here",
"Short",
]
outputs = llm.generate(chunked_prompts, sampling_params)
assert len(outputs) == len(chunked_prompts)
for output in outputs:
prompt_len = len(output.prompt_token_ids)
expected_shape = (prompt_len, num_layers, hidden_size)
_token_ids, hidden_states = get_and_check_output(output, expected_shape)
for idx, layer_id in enumerate(layer_ids):
layer_hidden = hidden_states[:, idx, :]
assert torch.allclose(
layer_hidden,
torch.full_like(layer_hidden, layer_id),
atol=1e-5,
), (
f"Layer {layer_id} at position {idx} should output "
f"{float(layer_id)}, but got mean="
f"{layer_hidden.mean():.3f}, min="
f"{layer_hidden.min():.3f}, max="
f"{layer_hidden.max():.3f}. "
f"prompt_len={prompt_len}, "
f"max_num_batched_tokens={max_num_batched_tokens}"
)
# --- Scenario 3: per-request options ----------------------------------
max_tokens = 5
custom_path = os.path.join(tmp_path, "subdir", "custom.safetensors")
sampling_params_list = [
SamplingParams(max_tokens=max_tokens, temperature=0.0),
SamplingParams(
max_tokens=max_tokens,
temperature=0.0,
extra_args={
"kv_transfer_params": {
"hidden_states_path": custom_path,
"include_output_tokens": True,
}
},
),
]
per_req_prompts = ["Short", "Medium length"]
outputs = llm.generate(per_req_prompts, sampling_params_list)
# First output: prompt-only hidden states, default path
out0 = outputs[0]
path0 = out0.kv_transfer_params["hidden_states_path"]
assert path0 != custom_path
obj0 = example_hidden_states_connector.load_hidden_states(path0)
assert torch.equal(obj0["token_ids"], torch.tensor(out0.prompt_token_ids))
assert obj0["hidden_states"].shape == (
len(out0.prompt_token_ids),
num_layers,
hidden_size,
)
example_hidden_states_connector.cleanup_hidden_states(path0)
# Second output: prompt + output tokens, custom path
out1 = outputs[1]
assert out1.kv_transfer_params["hidden_states_path"] == custom_path
obj1 = example_hidden_states_connector.load_hidden_states(custom_path)
token_ids = obj1["token_ids"]
hidden_states = obj1["hidden_states"]
# The final output token was never an input to the model, so its hidden
# state is not in the cache — hence the -1.
total_tokens = len(out1.prompt_token_ids) + len(out1.outputs[0].token_ids) - 1
assert token_ids.shape[0] == total_tokens
assert hidden_states.shape == (total_tokens, num_layers, hidden_size)
# Verify predictable layer values hold for all tokens (prompt + output)
for idx, layer_id in enumerate(layer_ids):
layer_hidden = hidden_states[:, idx, :]
assert torch.allclose(
layer_hidden,
torch.full_like(layer_hidden, layer_id),
atol=1e-5,
)
example_hidden_states_connector.cleanup_hidden_states(custom_path)
@create_new_process_for_each_test()
def test_extract_hidden_states_qwen35_hybrid_smoke(tmp_path):
"""Smoke test for Qwen3.5 hybrid (mamba + full-attention) models.
Uses load_format="dummy" to just check shape/plumbing.
@@ -185,7 +270,6 @@ def test_extract_hidden_states_qwen35_hybrid_smoke(tmp_path):
},
max_model_len=256,
enforce_eager=True,
enable_chunked_prefill=False,
gpu_memory_utilization=0.4,
load_format="dummy",
)
@@ -193,19 +277,68 @@ def test_extract_hidden_states_qwen35_hybrid_smoke(tmp_path):
prompts = ["Hello world", "Test prompt with several tokens"]
sampling_params = SamplingParams(max_tokens=1, temperature=0.0)
outputs = llm.generate(prompts, sampling_params)
del llm
gc.collect()
assert len(outputs) == len(prompts)
for output in outputs:
assert output.kv_transfer_params is not None
hidden_states_path = output.kv_transfer_params.get("hidden_states_path")
assert hidden_states_path is not None
assert os.path.exists(hidden_states_path)
with safe_open(hidden_states_path, "pt") as f:
token_ids = f.get_tensor("token_ids")
hidden_states = f.get_tensor("hidden_states")
obj = example_hidden_states_connector.load_hidden_states(hidden_states_path)
token_ids = obj["token_ids"]
hidden_states = obj["hidden_states"]
assert torch.equal(token_ids, torch.tensor(output.prompt_token_ids))
assert hidden_states.shape == (
len(output.prompt_token_ids),
len(layer_ids),
hidden_size,
)
@pytest.mark.timeout(60)
@multi_gpu_test(num_gpus=2)
@create_new_process_for_each_test()
def test_extract_hidden_states_tp2():
"""Test that hidden states extraction works with tensor_parallel_size=2."""
tmp_dir = tempfile.mkdtemp()
layer_ids = [5, 11, 17]
hidden_size = 1024 # Qwen/Qwen3-0.6B hidden_size
llm = LLM(
model="Qwen/Qwen3-0.6B",
tensor_parallel_size=2,
speculative_config={
"method": "extract_hidden_states",
"num_speculative_tokens": 1,
"draft_model_config": {
"hf_config": {"eagle_aux_hidden_state_layer_ids": layer_ids}
},
},
kv_transfer_config={
"kv_connector": "ExampleHiddenStatesConnector",
"kv_role": "kv_producer",
"kv_connector_extra_config": {"shared_storage_path": tmp_dir},
},
max_model_len=256,
enforce_eager=True,
gpu_memory_utilization=0.4,
load_format="dummy",
)
prompts = ["Hello world", "Test prompt with several tokens"]
sampling_params = SamplingParams(max_tokens=1, temperature=0.0)
outputs = llm.generate(prompts, sampling_params)
assert len(outputs) == len(prompts)
for output in outputs:
assert output.kv_transfer_params is not None
hidden_states_path = output.kv_transfer_params.get("hidden_states_path")
assert hidden_states_path is not None
obj = example_hidden_states_connector.load_hidden_states(hidden_states_path)
token_ids = obj["token_ids"]
hidden_states = obj["hidden_states"]
assert torch.equal(token_ids, torch.tensor(output.prompt_token_ids))
assert hidden_states.shape == (
@@ -69,7 +69,6 @@ def _create_proposer(
scheduler_config=SchedulerConfig(
max_model_len=model_config.max_model_len,
is_encoder_decoder=model_config.is_encoder_decoder,
enable_chunked_prefill=False,
),
attention_config=AttentionConfig(),
)
@@ -120,7 +119,6 @@ def test_proposer_initialization_missing_layer_ids():
scheduler_config=SchedulerConfig(
max_model_len=model_config.max_model_len,
is_encoder_decoder=model_config.is_encoder_decoder,
enable_chunked_prefill=False,
),
attention_config=AttentionConfig(),
)
-11
View File
@@ -756,17 +756,6 @@ class VllmConfig:
Right now, this function reads the offloading settings from
CacheConfig and configures the KVTransferConfig accordingly.
"""
# Check if KV connector requires chunked prefill to be disabled.
if (
self.kv_transfer_config is not None
and self.kv_transfer_config.kv_connector == "ExampleHiddenStatesConnector"
and self.scheduler_config.enable_chunked_prefill
):
raise ValueError(
"ExampleHiddenStatesConnector does not support chunked prefill. "
"Please disable chunked prefill (--no-enable-chunked-prefill)."
)
# KV offloading is only activated when kv_offloading_size is set.
if (kv_offloading_size := self.cache_config.kv_offloading_size) is None:
return
@@ -19,10 +19,10 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
KVConnectorRole,
SupportsHMA,
)
from vllm.forward_context import get_forward_context
from vllm.distributed.parallel_state import get_tensor_model_parallel_rank
from vllm.logger import init_logger
from vllm.v1.attention.backend import AttentionMetadata
from vllm.v1.core.sched.output import NewRequestData, SchedulerOutput
from vllm.v1.core.sched.output import SchedulerOutput
if TYPE_CHECKING:
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
@@ -76,43 +76,20 @@ def cleanup_hidden_states(path: str, keep_hidden_states: bool = False) -> None:
@dataclass
class ReqMeta:
# Request ID
class PendingSave:
req_id: str
# Request filename
filename: str
# Request tokens
token_ids: torch.Tensor
# Whether this request is a new request or partially computed already
new_req: bool
@staticmethod
def make_meta(
req_id: str,
filename: str,
token_ids: list[int],
new_req: bool,
) -> "ReqMeta":
return ReqMeta(
req_id=req_id,
filename=filename,
token_ids=torch.tensor(token_ids),
new_req=new_req,
)
block_ids: list[int]
@dataclass
class ExampleHiddenStatesConnectorMetadata(KVConnectorMetadata):
requests: list[ReqMeta] = field(default_factory=list)
def add_request(
self,
req_id: str,
filename: str,
token_ids: list[int],
new_req: bool = True,
) -> None:
self.requests.append(ReqMeta.make_meta(req_id, filename, token_ids, new_req))
pending_saves: list[PendingSave] = field(default_factory=list)
# req_id → filename for newly scheduled requests — the worker pre-creates
# lock files for these so the lock exists before the client receives the
# output path.
new_req_filenames: dict[str, str] = field(default_factory=dict)
class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
@@ -167,9 +144,16 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
getattr(spec_config, "eagle_aux_hidden_state_layer_ids", [])
)
# Scheduler-side state
self._pending_saves: dict[str, PendingSave] = {}
self._request_filenames: dict[str, str] = {}
self._active_requests: dict[str, NewRequestData] = {}
self._req_blocks: dict[str, list[int]] = {}
# Worker-side state (set by register_kv_caches).
self._kv_cache: torch.Tensor | None = None
self._hs_group_idx: int = 0
# Only TP rank 0 writes hidden states to disk; other TP ranks no-op.
# Set in register_kv_caches (after distributed init).
self._is_tp_rank_zero: bool = True
# Async write infrastructure (worker-side).
# Dedicated CUDA stream for DtoH copies so they don't block
@@ -184,14 +168,23 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
# Whether to use a filesystem lock when writing files to shared storage.
# This is necessary for online transfer clients to avoid incomplete reads,
# but can be disabled for offline tasks that run tasks in batches to completion
self.allow_custom_save_path = self._kv_transfer_config.get_from_extra_config(
"allow_custom_save_path", False
)
if self.allow_custom_save_path:
logger.warning(
"allow_custom_save_path is enabled. API clients can write "
"hidden states to arbitrary paths on the server filesystem. "
"Only enable this with trusted clients."
)
self.use_lock = self._kv_transfer_config.get_from_extra_config(
"use_synchronization_lock", True
)
# (tensors_dict, copy_done_event, filename, req_id) queued by
# save_kv_layer, submitted to thread pool by wait_for_save.
self._pending_copies: list[
tuple[dict[str, torch.Tensor], torch.cuda.Event, str, str]
] = []
# req_id → open fd on the .lock file with LOCK_EX held.
# Pre-created in wait_for_save when a request first arrives,
# consumed by _submit_async_write which passes the fd to the
# thread pool worker for release after writing.
self._lock_fds: dict[str, int] = {}
# req_id → in-flight disk-write Future for that req_id.
self._req_futures: dict[str, Future] = {}
# req_id → CUDA event marking completion of the DtoH copy. Once
@@ -218,42 +211,28 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
def wait_for_layer_load(self, layer_name: str) -> None:
pass # Store-only connector — nothing to load
def wait_for_save(self):
"""Submit pending async copies to the thread pool for disk write.
def wait_for_save(self) -> None:
"""Pre-create lock files for newly arrived requests.
For each pending write we acquire an exclusive flock on a
companion ``.lock`` file **before** submitting to the thread pool.
The thread worker releases the lock after the data file is fully
written. Clients call :func:`load_hidden_states` which takes a
shared flock — the kernel sleeps the client until the writer is
done. Because ``wait_for_save`` runs before the worker returns
output to the scheduler, the lock file is guaranteed to exist
(and be held) by the time the client receives the path.
The lock can be disabled via the "use_synchronization_lock" extra config.
This runs on the worker BEFORE the scheduler returns the output
path to the client, guaranteeing that the lock file exists (and
LOCK_EX is held) by the time the client tries to open it.
"""
for tensors, event, filename, req_id in self._pending_copies:
prior = self._req_futures.get(req_id)
assert prior is None, "Found another KV transfer request with same req_id!"
lock_fd = None
if self.use_lock:
# Create/open the lock file and acquire an exclusive lock.
# The lock is held by this fd; the thread worker will close
# the fd after writing, which releases the lock.
lock_path = filename + ".lock"
lock_fd = os.open(
lock_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644
)
fcntl.flock(lock_fd, fcntl.LOCK_EX)
future = self._executor.submit(
self._write_tensors, tensors, event, filename, lock_fd
)
self._req_copy_events[req_id] = event
self._req_futures[req_id] = future
future.add_done_callback(partial(self._on_write_done, req_id))
self._pending_copies.clear()
if not self._is_tp_rank_zero:
return
if not self.use_lock or not self.has_connector_metadata():
return
metadata = self._get_connector_metadata()
if not isinstance(metadata, ExampleHiddenStatesConnectorMetadata):
return
for req_id, filename in metadata.new_req_filenames.items():
if req_id in self._lock_fds:
continue
lock_path = filename + ".lock"
os.makedirs(os.path.dirname(lock_path), exist_ok=True)
lock_fd = os.open(lock_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644)
fcntl.flock(lock_fd, fcntl.LOCK_EX)
self._lock_fds[req_id] = lock_fd
def _on_write_done(self, req_id: str, future: Future) -> None:
"""Surface any exception from the disk-write thread and drop the
@@ -264,6 +243,9 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
logger.error("Hidden-states write failed for req_id=%s: %r", req_id, exc)
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
# Delay tp rank0 initialization until after distributed init
self._is_tp_rank_zero = get_tensor_model_parallel_rank() == 0
from vllm.model_executor.models.extract_hidden_states import (
CacheOnlyAttentionLayer,
)
@@ -276,6 +258,14 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
assert len(self.cache_layers) == 1, (
f"Expected 1 CacheOnlyAttentionLayer, got {len(self.cache_layers)}"
)
self._kv_cache = kv_caches[self.cache_layers[0]]
# Find the KV cache group index for hidden states
if self._kv_cache_config is not None:
for i, group in enumerate(self._kv_cache_config.kv_cache_groups):
if self.cache_layers[0] in group.layer_names:
self._hs_group_idx = i
break
@staticmethod
def _write_tensors(
@@ -304,35 +294,33 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
attn_metadata: AttentionMetadata,
**kwargs: Any,
) -> None:
"""Start saving the KV cache of the layer from vLLM's paged buffer
to the connector.
# Hidden states are already cached by CacheOnlyAttentionLayer during
# forward. Extraction happens in get_finished once all tokens are done.
pass
Launches an async DtoH copy on a dedicated CUDA stream. The
actual disk write is deferred to wait_for_save() which submits
it to a thread pool.
def _submit_async_write(
self,
pending: PendingSave,
) -> None:
"""Extract hidden states from KV cache and submit async DtoH + disk write.
Args:
layer_name (str): the name of the layer.
kv_layer (torch.Tensor): the paged KV buffer of the current
layer in vLLM.
attn_metadata (AttentionMetadata): the attention metadata.
**kwargs: additional arguments for the save operation.
Called from get_finished for each request that has finished generating.
"""
if layer_name not in self.cache_layers:
if not self._is_tp_rank_zero:
return
assert self._kv_cache is not None
from vllm.model_executor.models.extract_hidden_states import (
CacheOnlyAttentionMetadata,
# Compute slot mapping from block_ids
block_ids_t = torch.tensor(pending.block_ids, dtype=torch.long)
num_blocks = block_ids_t.shape[0]
block_offsets = torch.arange(0, self._block_size, dtype=torch.long)
slot_mapping = (
block_offsets.reshape((1, self._block_size))
+ block_ids_t.reshape((num_blocks, 1)) * self._block_size
)
slot_mapping = slot_mapping.flatten()
assert isinstance(attn_metadata, CacheOnlyAttentionMetadata), (
"ExampleHiddenStatesConnector only supports CacheOnlyAttentionBackend"
)
connector_metadata = self._get_connector_metadata()
assert isinstance(connector_metadata, ExampleHiddenStatesConnectorMetadata)
os.makedirs(self._storage_path, exist_ok=True)
num_tokens = pending.token_ids.shape[0]
copy_stream = self._get_copy_stream()
@@ -341,39 +329,56 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
ready_event.record()
copy_stream.wait_event(ready_event)
slot_mapping = get_forward_context().slot_mapping[layer_name] # type: ignore
offset = 0
for request in connector_metadata.requests:
num_tokens = request.token_ids.shape[0]
with torch.cuda.stream(copy_stream):
req_slot_mapping_gpu = slot_mapping[offset : offset + num_tokens]
assert req_slot_mapping_gpu.device == kv_layer.device
offset += num_tokens
hidden_states_gpu = extract_from_kv_cache(
kv_layer, req_slot_mapping_gpu, num_tokens
)
# Async DtoH copy into pinned host memory.
pinned_hs = torch.empty_like(
hidden_states_gpu, device="cpu", pin_memory=True
)
pinned_hs.copy_(hidden_states_gpu, non_blocking=True)
# Record completion of this copy on the copy stream.
copy_done = torch.cuda.Event()
copy_done.record(copy_stream)
# token_ids is already on CPU (created in ReqMeta.make_meta).
assert not request.token_ids.is_cuda, (
"Expected token_ids on CPU, got CUDA tensor"
with torch.cuda.stream(copy_stream):
# Move the CPU slot_mapping to GPU on the copy stream so the
# implicit H2D inside fancy indexing doesn't sync the default
# stream.
slot_mapping_gpu = slot_mapping.to(
device=self._kv_cache.device, non_blocking=True
)
tensors = {
"hidden_states": pinned_hs,
"token_ids": request.token_ids.clone(),
}
self._pending_copies.append(
(tensors, copy_done, request.filename, request.req_id)
hidden_states_gpu = extract_from_kv_cache(
self._kv_cache, slot_mapping_gpu, num_tokens
)
# Async DtoH copy into pinned host memory.
pinned_hs = torch.empty_like(
hidden_states_gpu, device="cpu", pin_memory=True
)
pinned_hs.copy_(hidden_states_gpu, non_blocking=True)
# Record completion of this copy on the copy stream.
copy_done = torch.cuda.Event()
copy_done.record(copy_stream)
# token_ids is already on CPU (created in request_finished).
assert not pending.token_ids.is_cuda, (
"Expected token_ids on CPU, got CUDA tensor"
)
tensors = {
"hidden_states": pinned_hs,
"token_ids": pending.token_ids.clone(),
}
# Submit to thread pool for disk write.
prior = self._req_futures.get(pending.req_id)
assert prior is None, "Found another KV transfer request with same req_id!"
os.makedirs(os.path.dirname(pending.filename), exist_ok=True)
# Use the pre-created lock fd from wait_for_save (already holds
# LOCK_EX). Falls back to creating one here if use_lock is True
# but no pre-created fd exists (shouldn't happen in normal flow).
lock_fd = self._lock_fds.pop(pending.req_id, None)
if lock_fd is None and self.use_lock:
lock_path = pending.filename + ".lock"
lock_fd = os.open(lock_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644)
fcntl.flock(lock_fd, fcntl.LOCK_EX)
future = self._executor.submit(
self._write_tensors, tensors, copy_done, pending.filename, lock_fd
)
self._req_copy_events[pending.req_id] = copy_done
self._req_futures[pending.req_id] = future
future.add_done_callback(partial(self._on_write_done, pending.req_id))
# ==============================
# Scheduler-side methods
@@ -421,17 +426,34 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
scheduler_output (SchedulerOutput): the scheduler output object.
"""
meta = ExampleHiddenStatesConnectorMetadata()
# Transfer pending saves into metadata (scheduler → worker bridge)
meta.pending_saves = list(self._pending_saves.values())
self._pending_saves.clear()
# Resolve save paths for new requests and tell the worker so it can
# pre-create lock files before the client receives the output path.
for new_req in scheduler_output.scheduled_new_reqs:
token_ids = new_req.prompt_token_ids or []
filename = os.path.join(self._storage_path, f"{new_req.req_id}.safetensors")
meta.add_request(
new_req.req_id,
filename=filename,
token_ids=token_ids,
default_path = os.path.join(
self._storage_path, f"{new_req.req_id}.safetensors"
)
kv_params = (
new_req.sampling_params.extra_args.get("kv_transfer_params")
if new_req.sampling_params and new_req.sampling_params.extra_args
else None
) or {}
custom_path = kv_params.get("hidden_states_path")
if custom_path is not None and not self.allow_custom_save_path:
logger.warning(
"Request %s provided hidden_states_path but "
"allow_custom_save_path is disabled. Ignoring "
"custom path and using default.",
new_req.req_id,
)
custom_path = None
filename = custom_path or default_path
self._request_filenames[new_req.req_id] = filename
self._active_requests[new_req.req_id] = new_req
self._req_blocks[new_req.req_id] = list(new_req.block_ids[0])
meta.new_req_filenames[new_req.req_id] = filename
return meta
@@ -444,35 +466,54 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
Called exactly once when a request has finished, before its blocks are
freed.
The connector may assumes responsibility for freeing the blocks
asynchronously by returning True.
Returns:
True if the request is being saved/sent asynchronously and blocks
should not be freed until the request_id is returned from
get_finished().
Optional KVTransferParams to be included in the request outputs
returned by the engine.
Returns True to delay block freeing until get_finished extracts
the hidden states from the KV cache.
"""
req_id = request.request_id
req_filename = self._request_filenames.pop(req_id, None)
_ = self._active_requests.pop(req_id, None)
_ = self._req_blocks.pop(req_id, None)
return True, {"hidden_states_path": req_filename}
filename = self._request_filenames.pop(req_id)
kv_params = request.kv_transfer_params or {}
if kv_params.get("include_output_tokens", False):
# Exclude the final token — it was the model's output, never an
# input to a forward pass, so its hidden state is not in the cache.
token_ids = torch.tensor(list(request.all_token_ids)[:-1])
elif request.prompt_token_ids is not None:
token_ids = torch.tensor(request.prompt_token_ids)
else:
logger.warning(
"Request %s has no prompt_token_ids (prompt_embeds only). "
"Saved token_ids will be empty.",
req_id,
)
token_ids = torch.tensor([], dtype=torch.long)
self._pending_saves[req_id] = PendingSave(
req_id=req_id,
filename=filename,
token_ids=token_ids,
block_ids=list(block_ids),
)
return True, {"hidden_states_path": filename}
def get_finished(
self, finished_req_ids: set[str]
) -> tuple[set[str] | None, set[str] | None]:
"""Poll DtoH-copy completion for requests that finished generating.
"""Extract hidden states and poll DtoH-copy completion.
The scheduler passes finished_req_ids to tell the worker which
requests are done generating. We accumulate these across calls
and return a request as "finished sending" once its DtoH copy
event is complete (or if it never had a pending copy). The
subsequent disk write may still be in flight; clients block on
the per-file flock to wait for it.
On the worker side, connector metadata carries pending saves from the
scheduler. For each one we extract from the KV cache and launch an
async DtoH copy + thread-pool disk write.
We then poll accumulated finished req_ids: a request is "done sending"
once its DtoH copy event is complete. The subsequent disk write may
still be in flight; clients block on the per-file flock to wait for it.
"""
# Extract and submit async writes for newly finished requests
if self.has_connector_metadata():
connector_metadata = self._get_connector_metadata()
if isinstance(connector_metadata, ExampleHiddenStatesConnectorMetadata):
for pending in connector_metadata.pending_saves:
self._submit_async_write(pending)
# Poll for completed DtoH copies
self._accumulated_finished_req_ids.update(finished_req_ids)
done_sending: set[str] = set()
@@ -482,6 +523,11 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
self._req_copy_events.pop(req_id, None)
done_sending.add(req_id)
self._accumulated_finished_req_ids.discard(req_id)
# Clean up any leftover lock fds (e.g. aborted requests
# that never went through _submit_async_write).
lock_fd = self._lock_fds.pop(req_id, None)
if lock_fd is not None:
os.close(lock_fd)
return done_sending or None, None
@@ -490,7 +536,7 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
request: "Request",
block_ids: tuple[list[int], ...],
) -> tuple[bool, dict[str, Any] | None]:
return self.request_finished(request, block_ids[0])
return self.request_finished(request, block_ids[self._hs_group_idx])
@classmethod
def get_required_kvcache_layout(cls, vllm_config: "VllmConfig") -> str | None: