mirror of
https://github.com/vllm-project/vllm.git
synced 2026-10-28 09:45:26 +08:00
[Fast Start] Add daemon-to-daemon warmup cache seeding for Preload (#63088)
Signed-off-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: Xun Sun <UNIDY2002@outlook.com> Co-authored-by: liusy58 <mg21330037@smail.nju.edu.cn> Co-authored-by: Kimi Code <noreply@moonshot.cn>
This commit is contained in:
10 files changed
+989
-113
No files matched your search
@@ -70,6 +70,50 @@ loading and before binding its socket, then writes the cache:
|
||||
vllm preload --model /path/to/model --tensor-parallel-size 4
|
||||
```
|
||||
|
||||
### Seeding warmup caches between daemons (D2D)
|
||||
|
||||
A fresh daemon replica — a new node joining a fleet, a restarted preload
|
||||
set — would normally pay the autotune pass itself. With a seed source
|
||||
configured, each daemon rank fetches its rank-matched warmup caches from the
|
||||
source replica over an authenticated TCP control plane before tuning,
|
||||
validates them, and installs them into its own on-disk cache, so its warmup
|
||||
skips the profiling sweep. Any failure (unreachable, missing, incompatible)
|
||||
falls back to local tuning.
|
||||
|
||||
Source replica:
|
||||
|
||||
```bash
|
||||
vllm preload --model /path/to/model --tensor-parallel-size 4 \
|
||||
--seed-listen-host 0.0.0.0 --seed-listen-port 29600 \
|
||||
--seed-token "$TOKEN"
|
||||
```
|
||||
|
||||
Mirror replica (possibly on another node):
|
||||
|
||||
```bash
|
||||
vllm preload --model /path/to/model --tensor-parallel-size 4 \
|
||||
--seed-source-host <source-host> --seed-source-port 29600 \
|
||||
--seed-token "$TOKEN"
|
||||
```
|
||||
|
||||
Each rank binds and dials `base_port + group offset + its global rank`, so one
|
||||
`host:base_port` pair covers a whole replica, including the draft daemon group
|
||||
when speculative decoding is configured.
|
||||
|
||||
The exchange is keyed by the on-disk cache layout: a request names the cache
|
||||
by kind, configuration hash, DP rank and world rank, and the source resolves
|
||||
it to files under its cache root. Before installing, the target rejects a
|
||||
payload whose FlashInfer environment metadata (GPU, CUDA/cuDNN stack) differs
|
||||
from its own — such a table would never load anyway, and a hard conflict is
|
||||
never overwritten, which would poison the cache file into re-tuning on every
|
||||
start. Tables are shareable across vLLM versions as long as the configuration
|
||||
hash matches.
|
||||
|
||||
The channel is a generic seeding point: warmup caches are the first kind, and
|
||||
further daemon state (e.g. Triton warmup caches or weight manifests) plugs in
|
||||
as additional kinds — small JSON payloads travel inline, while large binary
|
||||
state rides the data plane.
|
||||
|
||||
## How it works
|
||||
|
||||
1. `vllm preload` spawns one daemon process per GPU. Each daemon loads its
|
||||
@@ -193,6 +237,10 @@ Socket paths are derived from the GPU UUID, so they are stable regardless of
|
||||
passes, and a table tuned on the draft model would key on the draft's
|
||||
config, which no engine looks up), so those deployments' first engine
|
||||
tunes and fills the cache instead.
|
||||
- **Warmup cache seeding**: only the v1 per-rank FlashInfer autotune cache is
|
||||
seeded; single-rank daemons use FlashInfer's managed v2 store and always
|
||||
tune in place. Seeding requires a trusted network — the TCP channel is
|
||||
authenticated by a shared token but unencrypted.
|
||||
- **Quantization**: every quantization method in the model must declare
|
||||
support for pre-processed weights (the daemon transfers weights *after*
|
||||
quantization post-processing). Unsupported methods raise
|
||||
@@ -214,6 +262,13 @@ processes owned by the same user:
|
||||
directory is additionally rejected if it is group/world accessible.
|
||||
- On Linux the daemon verifies the connecting peer's UID via `SO_PEERCRED`.
|
||||
|
||||
The optional daemon-to-daemon listener (`--seed-listen-*`) is a
|
||||
separate TCP channel: requests are JSON-only and size-capped, authenticate
|
||||
with the shared `--seed-token` (constant-time comparison), and
|
||||
an unauthenticated peer that stops sending is cut off by a whole-message
|
||||
deadline instead of blocking the accept loop. The channel is unencrypted —
|
||||
bind it to a trusted network.
|
||||
|
||||
See the [security documentation](../usage/security.md) for vLLM's general
|
||||
threat model.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ warm restarts (weights mapped from the daemon via CUDA IPC) must both serve
|
||||
identical outputs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -15,7 +16,7 @@ import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -36,6 +37,8 @@ class WeightCacheDaemon:
|
||||
model: str,
|
||||
tp_size: int,
|
||||
extra_args: list[str] | None = None,
|
||||
env_extra: dict[str, str] | None = None,
|
||||
log_file: str | None = None,
|
||||
):
|
||||
# Short base path: Unix socket paths are limited to ~107 characters.
|
||||
self.socket_dir = tempfile.mkdtemp(prefix="vllm_ipc_")
|
||||
@@ -58,21 +61,41 @@ class WeightCacheDaemon:
|
||||
str(self.health_port),
|
||||
*(extra_args or []),
|
||||
]
|
||||
self._env_extra = env_extra
|
||||
self._log_file = log_file
|
||||
self._log_chunks: list[str] = []
|
||||
self._proc: subprocess.Popen | None = None
|
||||
|
||||
def __enter__(self) -> "WeightCacheDaemon":
|
||||
env = {**os.environ, **self._env_extra} if self._env_extra is not None else None
|
||||
self._proc = subprocess.Popen(
|
||||
self._cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
|
||||
self._cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
threading.Thread(target=self._drain_stderr, daemon=True).start()
|
||||
threading.Thread(target=self._drain_output, daemon=True).start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info) -> None:
|
||||
self._stop()
|
||||
|
||||
def _drain_stderr(self) -> None:
|
||||
assert self._proc is not None and self._proc.stderr is not None
|
||||
self._proc.stderr.read()
|
||||
@property
|
||||
def logs(self) -> str:
|
||||
return "".join(self._log_chunks)
|
||||
|
||||
def _drain_output(self) -> None:
|
||||
assert self._proc is not None and self._proc.stdout is not None
|
||||
if self._log_file is None:
|
||||
for line in self._proc.stdout:
|
||||
self._log_chunks.append(line)
|
||||
return
|
||||
with open(self._log_file, "w") as out:
|
||||
for line in self._proc.stdout:
|
||||
self._log_chunks.append(line)
|
||||
out.write(line)
|
||||
out.flush()
|
||||
|
||||
def check_health(self) -> None:
|
||||
url = f"http://127.0.0.1:{self.health_port}/health"
|
||||
@@ -270,6 +293,85 @@ def test_ipc_cache_cold_start_and_warm_restart_tp2_ep(vllm_runner, tmp_path):
|
||||
run_cold_start_and_warm_restart(vllm_runner, DSV2_EP_CASE, tmp_path)
|
||||
|
||||
|
||||
@create_new_process_for_each_test()
|
||||
@pytest.mark.distributed(num_gpus=2)
|
||||
def test_d2d_warmup_cache_seeding(vllm_runner, tmp_path):
|
||||
"""A mirror daemon adopts the source's autotune cache over D2D, skips
|
||||
re-tuning, and serves outputs identical to the default-loader baseline."""
|
||||
if not current_platform.is_cuda_alike():
|
||||
pytest.skip("Weight cache IPC sharing requires CUDA or ROCm")
|
||||
if not current_platform.is_device_capability_family(100):
|
||||
pytest.skip("Kimi K3 IPC weight cache requires an SM100 MXFP4 backend")
|
||||
from vllm.utils.flashinfer import has_flashinfer
|
||||
|
||||
if not (has_flashinfer() and current_platform.has_device_capability(90)):
|
||||
pytest.skip("FlashInfer autotune requires FlashInfer and SM90+")
|
||||
|
||||
case = replace(
|
||||
K3_CASE,
|
||||
llm_kwargs={**K3_CASE.llm_kwargs, "tensor_parallel_size": 2},
|
||||
)
|
||||
baseline_outputs = generate(vllm_runner, case, None, fallback=True)
|
||||
assert all(text for _, texts in baseline_outputs for text in texts)
|
||||
|
||||
token = "d2d-test-token"
|
||||
base_port = get_open_port()
|
||||
log_dir = os.environ.get("D2D_E2E_LOG_DIR")
|
||||
log_file = (
|
||||
(lambda name: os.path.join(log_dir, name)) if log_dir else lambda name: None
|
||||
)
|
||||
# The v1 per-rank cache path needs a multi-rank tuning group (v2 is gated
|
||||
# to single-rank), so both daemons run at tp=2.
|
||||
with WeightCacheDaemon(
|
||||
case.model,
|
||||
tp_size=2,
|
||||
extra_args=[
|
||||
*case.daemon_args,
|
||||
"--seed-listen-host",
|
||||
"127.0.0.1",
|
||||
"--seed-listen-port",
|
||||
str(base_port),
|
||||
"--seed-token",
|
||||
token,
|
||||
],
|
||||
env_extra={"VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR": str(tmp_path / "source")},
|
||||
log_file=log_file("source.log"),
|
||||
) as source:
|
||||
source.wait_until_ready()
|
||||
# The source had an empty cache dir, so it must have tuned in place.
|
||||
assert "Running FlashInfer autotune" in source.logs
|
||||
|
||||
with WeightCacheDaemon(
|
||||
case.model,
|
||||
tp_size=2,
|
||||
extra_args=[
|
||||
*case.daemon_args,
|
||||
"--seed-source-host",
|
||||
"127.0.0.1",
|
||||
"--seed-source-port",
|
||||
str(base_port),
|
||||
"--seed-token",
|
||||
token,
|
||||
],
|
||||
env_extra={"VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR": str(tmp_path / "mirror")},
|
||||
log_file=log_file("mirror.log"),
|
||||
) as mirror:
|
||||
mirror.wait_until_ready()
|
||||
# The mirror adopted both ranks' tables and skipped the sweep.
|
||||
assert mirror.logs.count("Adopted remote flashinfer_autotune seed") == 2
|
||||
assert "Running FlashInfer autotune" not in mirror.logs
|
||||
mirror_tables = sorted(
|
||||
(tmp_path / "mirror").rglob("autotune_configs_dp0_rank*.json")
|
||||
)
|
||||
assert len(mirror_tables) == 2
|
||||
|
||||
mirror_outputs = generate(
|
||||
vllm_runner, case, mirror.socket_dir, fallback=False
|
||||
)
|
||||
|
||||
assert mirror_outputs == baseline_outputs
|
||||
|
||||
|
||||
def run_cold_start_and_warm_restart(vllm_runner, case: ModelCase, tmp_path, capfd=None):
|
||||
# Baseline: plain disk loading with the default loader.
|
||||
baseline_outputs = generate(
|
||||
@@ -802,3 +904,168 @@ def test_ipc_cache_key_tracks_kv_quantization(
|
||||
assert len(requested_keys) == 2
|
||||
for key in requested_keys:
|
||||
assert server.cache_key.mismatched_fields(key) == mismatched_fields
|
||||
|
||||
|
||||
def _warmup_cache_request(config_hash: str = "a" * 64, **overrides) -> dict:
|
||||
request = {
|
||||
"kind": "flashinfer_autotune",
|
||||
"config_hash": config_hash,
|
||||
"dp_rank": 0,
|
||||
"rank": 0,
|
||||
}
|
||||
request.update(overrides)
|
||||
return request
|
||||
|
||||
|
||||
def test_warmup_cache_request_validation_and_miss(tmp_path, monkeypatch):
|
||||
"""The source validates the wire key before touching the filesystem."""
|
||||
monkeypatch.setenv("VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR", str(tmp_path))
|
||||
from vllm.model_executor.model_loader.weight_cache.seed import SeedTransport
|
||||
from vllm.model_executor.model_loader.weight_cache.warmup_cache import (
|
||||
FlashInferWarmupCache,
|
||||
)
|
||||
|
||||
def handle(request):
|
||||
return SeedTransport([FlashInferWarmupCache()]).handle_request(request)
|
||||
|
||||
assert handle("not a dict")["status"] == "error"
|
||||
assert handle(_warmup_cache_request(kind="bogus"))["status"] == "error"
|
||||
# A request missing the kind entirely is rejected, not resolved.
|
||||
assert handle({"token": "t"})["status"] == "error"
|
||||
# A path traversal or malformed hash must never reach the filesystem.
|
||||
assert handle(_warmup_cache_request(config_hash="../x"))["status"] == "error"
|
||||
assert handle(_warmup_cache_request(config_hash="A" * 64))["status"] == "error"
|
||||
# A garbage rank just names a file that does not exist: a clean miss.
|
||||
assert handle(_warmup_cache_request(rank=-1))["status"] == "miss"
|
||||
# A well-formed request for a cache that does not exist is a clean miss.
|
||||
assert handle(_warmup_cache_request())["status"] == "miss"
|
||||
|
||||
|
||||
def test_warmup_cache_metadata_gate():
|
||||
"""Tables flashinfer would refuse to load are rejected before install."""
|
||||
pytest.importorskip("flashinfer")
|
||||
from flashinfer.autotuner.autotuner import _collect_metadata
|
||||
|
||||
from vllm.model_executor.model_loader.weight_cache.warmup_cache import (
|
||||
FlashInferWarmupCache,
|
||||
)
|
||||
|
||||
kind = FlashInferWarmupCache()
|
||||
filename = "autotune_configs_dp0_rank0.json"
|
||||
|
||||
matching = {filename: {"_metadata": _collect_metadata(), "records": {}}}
|
||||
assert kind.validate_payload(matching) == matching
|
||||
|
||||
# Pre-metadata flashinfer files are accepted, like load_configs does.
|
||||
legacy: dict[str, dict] = {filename: {"records": {}}}
|
||||
assert kind.validate_payload(legacy) == legacy
|
||||
|
||||
foreign = {"_metadata": {**_collect_metadata(), "gpu": "Not A Real GPU"}}
|
||||
with pytest.raises(ValueError, match="environment mismatch"):
|
||||
kind.validate_payload({filename: foreign})
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
kind.validate_payload({filename: [1, 2]})
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
kind.validate_payload({filename: {}, "extra.json": {}})
|
||||
|
||||
|
||||
def test_remote_warmup_cache_listener(tmp_path, monkeypatch):
|
||||
"""Peers need the token, and only get warmup caches for their rank."""
|
||||
import json
|
||||
import socket
|
||||
|
||||
monkeypatch.setenv("VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR", str(tmp_path))
|
||||
from vllm.model_executor.model_loader.weight_cache.protocol import (
|
||||
recv_json,
|
||||
send_json,
|
||||
)
|
||||
from vllm.model_executor.model_loader.weight_cache.seed import (
|
||||
SeedConfig,
|
||||
SeedTransport,
|
||||
)
|
||||
from vllm.model_executor.model_loader.weight_cache.warmup_cache import (
|
||||
FlashInferWarmupCache,
|
||||
)
|
||||
|
||||
config_hash = "b" * 64
|
||||
table = {"records": {"op": {"shape": 1}}}
|
||||
cache_dir = tmp_path / config_hash
|
||||
cache_dir.mkdir()
|
||||
(cache_dir / "autotune_configs_dp0_rank0.json").write_text(json.dumps(table))
|
||||
|
||||
transport = SeedTransport(
|
||||
[FlashInferWarmupCache()],
|
||||
SeedConfig(listen=("127.0.0.1", 0), token="test-secret"),
|
||||
)
|
||||
address = transport.start_listener()
|
||||
assert address is not None
|
||||
try:
|
||||
cases = [
|
||||
(_warmup_cache_request(config_hash, token="wrong"), "error"),
|
||||
(
|
||||
_warmup_cache_request(config_hash, kind="weights", token="test-secret"),
|
||||
"error",
|
||||
),
|
||||
(_warmup_cache_request(config_hash, token="test-secret"), "ok"),
|
||||
(_warmup_cache_request("f" * 64, token="test-secret"), "miss"),
|
||||
]
|
||||
for request, expected in cases:
|
||||
with socket.create_connection(address, timeout=2.0) as peer:
|
||||
send_json(peer, request)
|
||||
response = recv_json(peer)
|
||||
assert response["status"] == expected
|
||||
if expected == "ok":
|
||||
assert response["payload"] == {"autotune_configs_dp0_rank0.json": table}
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
|
||||
def test_transport_cuts_off_trickling_peer(monkeypatch):
|
||||
"""A peer that never finishes its message cannot block the accept loop."""
|
||||
import socket
|
||||
import time
|
||||
|
||||
import vllm.model_executor.model_loader.weight_cache.seed as seed_mod
|
||||
from vllm.model_executor.model_loader.weight_cache.protocol import (
|
||||
recv_json,
|
||||
send_json,
|
||||
)
|
||||
from vllm.model_executor.model_loader.weight_cache.seed import (
|
||||
SeedConfig,
|
||||
SeedTransport,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(seed_mod, "_REMOTE_REQUEST_TIMEOUT_S", 0.5)
|
||||
transport = SeedTransport([], SeedConfig(listen=("127.0.0.1", 0), token="t"))
|
||||
address = transport.start_listener()
|
||||
assert address is not None
|
||||
try:
|
||||
with socket.create_connection(address) as idle:
|
||||
idle.sendall(b"\x00") # a partial frame, then silence
|
||||
time.sleep(1.5) # past the whole-message deadline
|
||||
with socket.create_connection(address, timeout=2.0) as peer:
|
||||
send_json(peer, {"cmd": "anything", "token": "t"})
|
||||
response = recv_json(peer)
|
||||
# The trickler was cut off and this peer got served (unknown
|
||||
# command, but the accept loop answered).
|
||||
assert response["status"] == "error"
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
|
||||
def test_warmup_cache_install_writes_table(tmp_path, monkeypatch):
|
||||
"""Install writes the fetched table to the rank's on-disk cache path."""
|
||||
import json
|
||||
|
||||
from vllm.model_executor.model_loader.weight_cache.warmup_cache import (
|
||||
FlashInferWarmupCache,
|
||||
)
|
||||
from vllm.model_executor.warmup import flashinfer_autotune_cache as fi_cache
|
||||
|
||||
target = tmp_path / "autotune_configs_dp0_rank0.json"
|
||||
monkeypatch.setattr(
|
||||
fi_cache, "resolve_rank_flashinfer_autotune_file", lambda _: target
|
||||
)
|
||||
table = {"records": {"op": {"shape": 1}}}
|
||||
FlashInferWarmupCache().install(None, {"autotune_configs_dp0_rank0.json": table})
|
||||
assert json.loads(target.read_text()) == table
|
||||
@@ -176,7 +176,11 @@ def test_flashinfer_autotune_lifecycle(
|
||||
model = runner.get_model()
|
||||
cache_path = tmp_path / "autotune.json"
|
||||
rank_cache_path = cache_path.with_name(f"autotune_dp0_rank{rank}.json")
|
||||
rank_cache_path.write_text(json.dumps({"_metadata": {}, "op": 1}))
|
||||
# A matching cache lets every rank skip the tuning sweep, so the dummy
|
||||
# failure case must run on a cache miss to reach the sweep at all.
|
||||
cache_hit = failure != "dummy"
|
||||
if cache_hit:
|
||||
rank_cache_path.write_text(json.dumps({"_metadata": {}, "op": 1}))
|
||||
events = Mock()
|
||||
events.tuner.load_configs.return_value = True
|
||||
events.tuner._dirty = True
|
||||
@@ -226,9 +230,9 @@ def test_flashinfer_autotune_lifecycle(
|
||||
monkeypatch.setattr(fi_utils, "has_flashinfer_autotune_v2", lambda: has_v2)
|
||||
monkeypatch.setattr(fi_utils, "autotune", events.autotune)
|
||||
monkeypatch.setattr(warmup.envs, "VLLM_FLASHINFER_AUTOTUNE_SKIP_OPS", skip_ops)
|
||||
resolve_file = Mock(return_value=cache_path)
|
||||
resolve_file = Mock(return_value=rank_cache_path)
|
||||
resolve_root = Mock(return_value=str(tmp_path))
|
||||
monkeypatch.setattr(warmup, "resolve_flashinfer_autotune_file", resolve_file)
|
||||
monkeypatch.setattr(warmup, "resolve_rank_flashinfer_autotune_file", resolve_file)
|
||||
monkeypatch.setattr(warmup, "resolve_flashinfer_autotune_v2_root", resolve_root)
|
||||
|
||||
def gather(out, value, *, group):
|
||||
@@ -257,22 +261,21 @@ def test_flashinfer_autotune_lifecycle(
|
||||
else:
|
||||
resolve_root.assert_not_called()
|
||||
resolve_file.assert_called_once_with(runner)
|
||||
expected.extend(
|
||||
[
|
||||
call.tuner.load_configs(str(rank_cache_path)),
|
||||
call.autotune(tune_mode=True, **kwargs),
|
||||
]
|
||||
)
|
||||
if cache_hit:
|
||||
expected.append(call.tuner.load_configs(str(rank_cache_path)))
|
||||
expected.append(call.autotune(tune_mode=True, **kwargs))
|
||||
expected.extend(
|
||||
[call.set_group(world.cpu_group if world_size > 1 else None), call.enter()]
|
||||
)
|
||||
if hisparse:
|
||||
expected.append(call.hisparse(runner))
|
||||
for name, arg, kwargs in [
|
||||
("dummy", runner, {"skip_attn": hisparse}),
|
||||
warmup_calls: list[tuple[str, object, dict]] = [
|
||||
("replayssm", runner, {}),
|
||||
("kimi", model, {}),
|
||||
]:
|
||||
]
|
||||
if use_v2 or not cache_hit:
|
||||
warmup_calls.insert(0, ("dummy", runner, {"skip_attn": hisparse}))
|
||||
for name, arg, kwargs in warmup_calls:
|
||||
expected.append(getattr(call, name)(arg, **kwargs))
|
||||
if failure == name:
|
||||
break
|
||||
@@ -567,8 +570,8 @@ def autotune_run(monkeypatch, tmp_path):
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
warmup,
|
||||
"resolve_flashinfer_autotune_file",
|
||||
lambda runner: tmp_path / "autotune_configs.json",
|
||||
"resolve_rank_flashinfer_autotune_file",
|
||||
lambda runner: run.cache_path(run.rank),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
warmup, "_flashinfer_autotune_skip_ops", lambda runner: None
|
||||
|
||||
+114
-26
@@ -100,6 +100,37 @@ def _start_health_server(
|
||||
return server, thread
|
||||
|
||||
|
||||
def _validate_seed_args(
|
||||
args: argparse.Namespace, *, world_size: int, num_groups: int
|
||||
) -> None:
|
||||
"""Validate the seed CLI options before any daemon spawns."""
|
||||
for flag, host, port in (
|
||||
("--seed-listen", args.seed_listen_host, args.seed_listen_port),
|
||||
("--seed-source", args.seed_source_host, args.seed_source_port),
|
||||
):
|
||||
if (host is None) != (port is None):
|
||||
raise ValueError(f"{flag}-host and {flag}-port must be provided together")
|
||||
has_endpoint = any(
|
||||
value is not None
|
||||
for value in (
|
||||
args.seed_listen_host,
|
||||
args.seed_listen_port,
|
||||
args.seed_source_host,
|
||||
args.seed_source_port,
|
||||
)
|
||||
)
|
||||
if has_endpoint != bool(args.seed_token):
|
||||
raise ValueError("Preload daemons must set --seed-token to enable D2D transfer")
|
||||
for base_port in (args.seed_listen_port, args.seed_source_port):
|
||||
if base_port is None:
|
||||
continue
|
||||
max_port = base_port + num_groups * world_size - 1
|
||||
if not 1 <= base_port <= 65535 or max_port > 65535:
|
||||
raise ValueError(
|
||||
f"Daemon-to-daemon port range {base_port}-{max_port} exceeds 65535"
|
||||
)
|
||||
|
||||
|
||||
class PreloadSubcommand(CLISubcommand):
|
||||
"""The `preload` subcommand for the vLLM CLI."""
|
||||
|
||||
@@ -143,6 +174,39 @@ class PreloadSubcommand(CLISubcommand):
|
||||
default="0.0.0.0",
|
||||
help="Host for the parent /health endpoint (default: 0.0.0.0).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-listen-host",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Host to bind for authenticated daemon-to-daemon requests.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-listen-port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Base port for daemon-to-daemon requests; each daemon binds "
|
||||
"base_port + group offset + its global rank.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-source-host",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Host of a source daemon replica to seed warmup caches from.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-source-port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Base port of the source daemon replica; each daemon dials "
|
||||
"base_port + group offset + its global rank.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-token",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Shared secret for daemon-to-daemon requests. The JSON "
|
||||
"connection is unencrypted; use a trusted network.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def cmd(args: argparse.Namespace) -> None:
|
||||
@@ -153,6 +217,7 @@ class PreloadSubcommand(CLISubcommand):
|
||||
get_draft_daemon_config,
|
||||
plan_local_ranks,
|
||||
)
|
||||
from vllm.model_executor.model_loader.weight_cache.seed import SeedConfig
|
||||
from vllm.model_executor.model_loader.weight_cache.utils import (
|
||||
format_daemon_role,
|
||||
)
|
||||
@@ -179,6 +244,7 @@ class PreloadSubcommand(CLISubcommand):
|
||||
tp_size = parallel_config.tensor_parallel_size
|
||||
pp_size = parallel_config.pipeline_parallel_size
|
||||
dp_size = parallel_config.data_parallel_size
|
||||
world_size = dp_size * pp_size * tp_size
|
||||
|
||||
placements = plan_local_ranks(parallel_config)
|
||||
local_world_size = len(placements)
|
||||
@@ -248,32 +314,54 @@ class PreloadSubcommand(CLISubcommand):
|
||||
groups, placements
|
||||
)
|
||||
}
|
||||
procs = [
|
||||
ctx.Process(
|
||||
target=_run_daemon,
|
||||
args=(
|
||||
dp_rank * pp_size * tp_size + pp_rank * tp_size + tp_rank,
|
||||
local_rank,
|
||||
config,
|
||||
init_method,
|
||||
args.weight_cache_socket_dir,
|
||||
ready_queue,
|
||||
is_draft,
|
||||
dp_rank,
|
||||
pp_rank,
|
||||
),
|
||||
name=(
|
||||
f"vllm-weight-cache-{format_daemon_role(is_draft)}-"
|
||||
f"{(dp_rank * pp_size * tp_size + pp_rank * tp_size + tp_rank)}"
|
||||
),
|
||||
)
|
||||
for (is_draft, config, init_method), (
|
||||
local_rank,
|
||||
dp_rank,
|
||||
pp_rank,
|
||||
tp_rank,
|
||||
) in product(groups, placements)
|
||||
]
|
||||
_validate_seed_args(args, world_size=world_size, num_groups=len(groups))
|
||||
|
||||
def _seed_endpoint(
|
||||
host: str | None, base_port: int | None, offset: int
|
||||
) -> tuple[str, int] | None:
|
||||
# Validated above: host and port are provided together.
|
||||
if host is None or base_port is None:
|
||||
return None
|
||||
return host, base_port + offset
|
||||
|
||||
procs = []
|
||||
for group_index, (is_draft, config, init_method) in enumerate(groups):
|
||||
# Draft daemons live past the target group's port block so both
|
||||
# roles can be reached over a single seed host:base_port.
|
||||
port_offset = group_index * world_size
|
||||
for local_rank, dp_rank, pp_rank, tp_rank in placements:
|
||||
global_rank = dp_rank * pp_size * tp_size + pp_rank * tp_size + tp_rank
|
||||
offset = port_offset + global_rank
|
||||
seed_config = SeedConfig(
|
||||
listen=_seed_endpoint(
|
||||
args.seed_listen_host, args.seed_listen_port, offset
|
||||
),
|
||||
source=_seed_endpoint(
|
||||
args.seed_source_host, args.seed_source_port, offset
|
||||
),
|
||||
token=args.seed_token,
|
||||
)
|
||||
procs.append(
|
||||
ctx.Process(
|
||||
target=_run_daemon,
|
||||
args=(
|
||||
global_rank,
|
||||
local_rank,
|
||||
config,
|
||||
init_method,
|
||||
args.weight_cache_socket_dir,
|
||||
ready_queue,
|
||||
is_draft,
|
||||
dp_rank,
|
||||
pp_rank,
|
||||
seed_config,
|
||||
),
|
||||
name=(
|
||||
f"vllm-weight-cache-{format_daemon_role(is_draft)}-"
|
||||
f"{global_rank}"
|
||||
),
|
||||
)
|
||||
)
|
||||
health_state = _HealthState(procs)
|
||||
health_server = None
|
||||
health_thread = None
|
||||
|
||||
@@ -119,15 +119,19 @@ from vllm.model_executor.model_loader.weight_cache.protocol import (
|
||||
send_msg,
|
||||
verify_peer_is_owner,
|
||||
)
|
||||
from vllm.model_executor.model_loader.weight_cache.seed import (
|
||||
SeedConfig,
|
||||
SeedTransport,
|
||||
)
|
||||
from vllm.model_executor.model_loader.weight_cache.utils import (
|
||||
build_warmup_runner,
|
||||
export_model_attrs,
|
||||
format_daemon_role,
|
||||
is_draft_model_cacheable,
|
||||
)
|
||||
from vllm.model_executor.warmup.kernel_warmup import flashinfer_autotune
|
||||
from vllm.model_executor.model_loader.weight_cache.warmup_cache import (
|
||||
FlashInferWarmupCache,
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.utils.flashinfer import has_flashinfer
|
||||
from vllm.utils.mem_utils import format_gib
|
||||
from vllm.utils.torch_utils import set_default_torch_dtype
|
||||
from vllm.v1.worker.workspace import init_workspace_manager
|
||||
@@ -212,10 +216,13 @@ class WeightCacheDaemon:
|
||||
is_draft: bool = False,
|
||||
dp_rank: int = 0,
|
||||
pp_rank: int = 0,
|
||||
seed_config: SeedConfig | None = None,
|
||||
):
|
||||
parallel_config = vllm_config.parallel_config
|
||||
if parallel_config.enable_eplb:
|
||||
raise ValueError("The weight cache daemon does not support EPLB.")
|
||||
self.warmup_caches = [FlashInferWarmupCache()]
|
||||
self.d2d = SeedTransport(self.warmup_caches, seed_config)
|
||||
self.tp_size = parallel_config.tensor_parallel_size
|
||||
self.pp_size = parallel_config.pipeline_parallel_size
|
||||
self.dp_size = parallel_config.data_parallel_size
|
||||
@@ -277,39 +284,9 @@ class WeightCacheDaemon:
|
||||
)
|
||||
|
||||
def warmup(self) -> None:
|
||||
"""Run the FlashInfer autotune pass against the cached model."""
|
||||
vllm_config = self.vllm_config
|
||||
# Mirrors the gate in the engine's kernel warmup.
|
||||
if (
|
||||
vllm_config.kernel_config.enable_flashinfer_autotune is False
|
||||
or not has_flashinfer()
|
||||
or not current_platform.has_device_capability(90)
|
||||
or is_draft_model_cacheable(vllm_config.speculative_config)
|
||||
):
|
||||
return
|
||||
try:
|
||||
current_platform.update_block_size_for_backend(vllm_config)
|
||||
runner = build_warmup_runner(
|
||||
vllm_config,
|
||||
self.local_rank,
|
||||
is_draft=self.is_draft,
|
||||
model_config=self.model_config,
|
||||
)
|
||||
assert self.model is not None, "warmup ran before load_model"
|
||||
runner.load_model(model=self.model)
|
||||
# The V2 runner requires skip_attn when no KV cache exists
|
||||
flashinfer_autotune(runner, skip_attn=vllm_config.use_v2_model_runner)
|
||||
logger.info(
|
||||
"Weight cache %s daemon rank %d tuned FlashInfer; the tuned "
|
||||
"configs are in the on-disk autotune cache",
|
||||
self.role,
|
||||
self.global_rank,
|
||||
)
|
||||
finally:
|
||||
# Free the runner and the dummy-run activations; the daemon's
|
||||
# weights are untouched.
|
||||
gc.collect()
|
||||
torch.accelerator.empty_cache()
|
||||
"""Run each warmup cache's pass (adopting remote caches via D2D)."""
|
||||
for cache in self.warmup_caches:
|
||||
cache.warmup(self, self.d2d)
|
||||
|
||||
def load_model(self) -> None:
|
||||
torch.accelerator.set_device_index(self.local_rank)
|
||||
@@ -418,6 +395,7 @@ class WeightCacheDaemon:
|
||||
server.bind(socket_path)
|
||||
os.chmod(socket_path, 0o600)
|
||||
server.listen()
|
||||
self.d2d.start_listener()
|
||||
logger.info(
|
||||
"Weight cache %s daemon rank %d serving on %s",
|
||||
self.role,
|
||||
@@ -445,6 +423,7 @@ class WeightCacheDaemon:
|
||||
send_msg(conn, {"status": "error", "message": str(e)})
|
||||
finally:
|
||||
server.close()
|
||||
self.d2d.close()
|
||||
if os.path.exists(socket_path):
|
||||
os.unlink(socket_path)
|
||||
os.close(lock_fd)
|
||||
@@ -553,6 +532,7 @@ def _run_daemon(
|
||||
is_draft: bool = False,
|
||||
dp_rank: int = 0,
|
||||
pp_rank: int = 0,
|
||||
seed_config: SeedConfig | None = None,
|
||||
) -> None:
|
||||
# Spawned daemons start with logging unconfigured, like engine workers.
|
||||
if logging_config := getattr(vllm_config, "logging_config", None):
|
||||
@@ -566,6 +546,7 @@ def _run_daemon(
|
||||
is_draft,
|
||||
dp_rank,
|
||||
pp_rank,
|
||||
seed_config=seed_config,
|
||||
)
|
||||
daemon.load_model()
|
||||
daemon.warmup()
|
||||
|
||||
@@ -19,6 +19,7 @@ import socket
|
||||
import stat
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
@@ -46,6 +47,9 @@ _LEN_STRUCT = struct.Struct("!Q")
|
||||
# Sanity bound for a single message. IPC handles are tiny; only small
|
||||
# non-CUDA tensors are ever shipped by value.
|
||||
MAX_MSG_SIZE = 1 << 34
|
||||
# Bound for one JSON message on the remote warmup-cache control plane.
|
||||
# Warmup cache tables are small JSON documents.
|
||||
MAX_JSON_MSG_SIZE = 1 << 26
|
||||
|
||||
|
||||
def _current_uid() -> int:
|
||||
@@ -452,9 +456,45 @@ def recv_msg(sock: socket.socket) -> Any:
|
||||
return pickle.loads(_recv_exact(sock, length))
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, num_bytes: int) -> bytes:
|
||||
def send_json(sock: socket.socket, obj: Any) -> None:
|
||||
payload = json.dumps(obj).encode()
|
||||
if len(payload) > MAX_JSON_MSG_SIZE:
|
||||
raise ValueError("JSON message exceeds configured limit")
|
||||
sock.sendall(_LEN_STRUCT.pack(len(payload)))
|
||||
sock.sendall(payload)
|
||||
|
||||
|
||||
def recv_json(
|
||||
sock: socket.socket,
|
||||
max_size: int = MAX_JSON_MSG_SIZE,
|
||||
deadline: float | None = None,
|
||||
) -> Any:
|
||||
"""Receive one JSON message.
|
||||
|
||||
Args:
|
||||
sock: Connected socket.
|
||||
max_size: Largest payload accepted.
|
||||
deadline: ``time.monotonic()`` by which the whole message must have
|
||||
arrived; a per-read timeout alone lets a peer trickle bytes
|
||||
forever.
|
||||
|
||||
"""
|
||||
(length,) = _LEN_STRUCT.unpack(_recv_exact(sock, _LEN_STRUCT.size, deadline))
|
||||
if length > max_size:
|
||||
raise ValueError(f"Message size {length} exceeds limit {max_size}")
|
||||
return json.loads(_recv_exact(sock, length, deadline))
|
||||
|
||||
|
||||
def _recv_exact(
|
||||
sock: socket.socket, num_bytes: int, deadline: float | None = None
|
||||
) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < num_bytes:
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("Peer did not send its message in time")
|
||||
sock.settimeout(remaining)
|
||||
chunk = sock.recv(num_bytes - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("Socket closed while receiving message")
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Daemon-to-daemon seeding over an authenticated JSON channel.
|
||||
|
||||
A :class:`Seedable` is one kind of state a daemon can seed to or from a peer
|
||||
daemon: warmup caches today, weight shards later. The target names what it
|
||||
wants by kind and key fields; the source resolves the key to a payload; the
|
||||
target validates and installs it:
|
||||
|
||||
request: {"kind": "flashinfer_autotune", <key fields>..., "token": "..."}
|
||||
response: {"status": "ok", "payload": <kind-specific>}
|
||||
{"status": "miss"} | {"status": "error", "message": "..."}
|
||||
|
||||
Payloads are JSON values. Large binary state (weights) must not be inlined:
|
||||
the payload then describes the transfer (e.g. a storage manifest) and the
|
||||
kind's ``install`` drives the data plane itself.
|
||||
|
||||
The connection is unencrypted beyond the shared token; keep the listener on a
|
||||
trusted network.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import hmac
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, NamedTuple, Protocol
|
||||
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.model_loader.weight_cache.protocol import (
|
||||
recv_json,
|
||||
send_json,
|
||||
)
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
# Requests carry a kind and small key fields; payloads flow in responses.
|
||||
_MAX_REMOTE_REQUEST_SIZE = 64 << 10
|
||||
_REQUEST_TIMEOUT_S = 30.0
|
||||
# An unauthenticated peer gets a whole-message deadline, not a per-read
|
||||
# timeout: per-read timeouts let a peer trickle bytes forever and stall the
|
||||
# serial accept loop. Authenticated handlers (e.g. weight manifests) may be
|
||||
# slow, so the budget relaxes once the token checks out.
|
||||
_REMOTE_REQUEST_TIMEOUT_S = 10.0
|
||||
_AUTHENTICATED_TIMEOUT_S = 300.0
|
||||
|
||||
|
||||
class Seedable(Protocol):
|
||||
"""One kind of state a daemon can seed to or from a peer daemon."""
|
||||
|
||||
kind: str
|
||||
|
||||
def request_params(self, ctx: Any) -> dict[str, Any]:
|
||||
"""Target side: the key fields identifying what this rank needs."""
|
||||
...
|
||||
|
||||
def resolve(self, request: dict[str, Any]) -> Any | None:
|
||||
"""Source side: produce the payload named by ``request``.
|
||||
|
||||
Validate the wire key first — raise an error on any malformed field
|
||||
(``ValueError`` for bad values, ``KeyError`` for missing ones), before
|
||||
touching local state. The transport turns it into an error response.
|
||||
Return None on a miss.
|
||||
"""
|
||||
...
|
||||
|
||||
def validate_payload(self, payload: Any) -> Any:
|
||||
"""Target side: validate the fetched payload, or raise ValueError."""
|
||||
...
|
||||
|
||||
def install(self, ctx: Any, payload: Any) -> None:
|
||||
"""Target side: install a validated payload."""
|
||||
...
|
||||
|
||||
|
||||
class SeedConfig(NamedTuple):
|
||||
"""One daemon's D2D seeding configuration: its listener, its source
|
||||
replica, and the shared token authenticating both."""
|
||||
|
||||
listen: tuple[str, int] | None = None
|
||||
source: tuple[str, int] | None = None
|
||||
token: str | None = None
|
||||
|
||||
|
||||
class SeedTransport:
|
||||
"""The daemon's D2D seeding endpoint: serves and fetches seedables."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
seedables: Iterable[Seedable],
|
||||
seed_config: SeedConfig | None = None,
|
||||
):
|
||||
seed_config = seed_config or SeedConfig()
|
||||
listen, source, token = seed_config
|
||||
if (listen is not None or source is not None) and not token:
|
||||
raise ValueError("A daemon-to-daemon listener/source requires a token")
|
||||
self._seedables = list(seedables)
|
||||
self._seed_config = seed_config
|
||||
self._server: socket.socket | None = None
|
||||
|
||||
def adopt(self, seedable: Seedable, ctx: Any) -> None:
|
||||
"""Best-effort: fetch and install ``seedable`` from the source daemon.
|
||||
|
||||
Any failure degrades to the local path with a log line.
|
||||
"""
|
||||
response = self.request(
|
||||
{"kind": seedable.kind, **seedable.request_params(ctx)},
|
||||
)
|
||||
if response is None:
|
||||
return
|
||||
status = response.get("status")
|
||||
if status != "ok":
|
||||
log = logger.info if status == "miss" else logger.warning
|
||||
log(
|
||||
"No remote %s seed adopted (status %s); using the local path",
|
||||
seedable.kind,
|
||||
status,
|
||||
)
|
||||
return
|
||||
try:
|
||||
seedable.install(ctx, seedable.validate_payload(response.get("payload")))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Rejected the remote %s seed; using the local path: %s",
|
||||
seedable.kind,
|
||||
e,
|
||||
)
|
||||
return
|
||||
logger.info("Adopted remote %s seed", seedable.kind)
|
||||
|
||||
def handle_request(self, request: Any) -> dict[str, Any]:
|
||||
"""Source side: answer one authenticated seed request."""
|
||||
kind_name = request.get("kind") if isinstance(request, dict) else None
|
||||
seedable = next((s for s in self._seedables if s.kind == kind_name), None)
|
||||
if seedable is None:
|
||||
return {"status": "error", "message": "unsupported seed kind"}
|
||||
try:
|
||||
payload = seedable.resolve(request)
|
||||
except (OSError, KeyError, TypeError, ValueError) as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
if payload is None:
|
||||
return {"status": "miss"}
|
||||
return {"status": "ok", "payload": payload}
|
||||
|
||||
def request(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Send one authenticated request to the source daemon.
|
||||
|
||||
Returns the response, or None with a log line on any transport
|
||||
failure; the caller falls back to its local path then.
|
||||
"""
|
||||
source = self._seed_config.source
|
||||
if source is None:
|
||||
return None
|
||||
label = payload.get("kind", "payload")
|
||||
try:
|
||||
with socket.create_connection(source, timeout=_REQUEST_TIMEOUT_S) as sock:
|
||||
send_json(sock, {**payload, "token": self._seed_config.token})
|
||||
response = recv_json(sock)
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning(
|
||||
"Cannot fetch %s from %s over D2D; falling back locally: %s",
|
||||
label,
|
||||
source,
|
||||
e,
|
||||
)
|
||||
return None
|
||||
if not isinstance(response, dict):
|
||||
logger.warning(
|
||||
"Source %s sent a malformed response for %s; falling back locally",
|
||||
source,
|
||||
label,
|
||||
)
|
||||
return None
|
||||
return response
|
||||
|
||||
def start_listener(self) -> tuple[str, int] | None:
|
||||
"""Start serving this rank's seedables to peer daemons.
|
||||
|
||||
Returns the bound address, or None when no listener is configured.
|
||||
"""
|
||||
listen = self._seed_config.listen
|
||||
if listen is None:
|
||||
return None
|
||||
family = socket.AF_INET6 if ":" in listen[0] else socket.AF_INET
|
||||
self._server = socket.create_server(listen, family=family)
|
||||
threading.Thread(
|
||||
target=self._serve,
|
||||
name="vllm-weight-cache-d2d",
|
||||
daemon=True,
|
||||
).start()
|
||||
host, port = self._server.getsockname()[:2]
|
||||
return host, port
|
||||
|
||||
def close(self) -> None:
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
self._server = None
|
||||
|
||||
def _serve(self) -> None:
|
||||
assert self._server is not None
|
||||
while True:
|
||||
try:
|
||||
conn, _ = self._server.accept()
|
||||
except OSError:
|
||||
return
|
||||
with conn:
|
||||
try:
|
||||
request = recv_json(
|
||||
conn,
|
||||
max_size=_MAX_REMOTE_REQUEST_SIZE,
|
||||
deadline=time.monotonic() + _REMOTE_REQUEST_TIMEOUT_S,
|
||||
)
|
||||
presented = (
|
||||
request.get("token") if isinstance(request, dict) else None
|
||||
)
|
||||
if (
|
||||
not self._seed_config.token
|
||||
or not isinstance(presented, str)
|
||||
or not hmac.compare_digest(presented, self._seed_config.token)
|
||||
):
|
||||
send_json(
|
||||
conn,
|
||||
{"status": "error", "message": "Unauthorized request"},
|
||||
)
|
||||
continue
|
||||
# Authenticated handlers (e.g. weight manifests) may be slow.
|
||||
conn.settimeout(_AUTHENTICATED_TIMEOUT_S)
|
||||
send_json(conn, self.handle_request(request))
|
||||
except (OSError, ValueError) as e:
|
||||
with contextlib.suppress(OSError):
|
||||
send_json(conn, {"status": "error", "message": str(e)})
|
||||
@@ -0,0 +1,183 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Daemon warmup caches, seeded daemon-to-daemon over the shared transport.
|
||||
|
||||
Each warmup-cache object (currently only :class:`FlashInferWarmupCache`)
|
||||
drives its own warmup pass on the daemon; the seeding flow itself
|
||||
(:class:`seed.SeedTransport`) is shared with future kinds such as weight
|
||||
shards. A target daemon installs its rank-matched cache before tuning, so
|
||||
both the daemon's own warmup and the later engine start skip re-tuning. The
|
||||
disk layout is the index: a request names the cache by the same key the
|
||||
layout uses (kind, config hash, dp rank, world rank) and the source resolves
|
||||
it to files under the cache root. There is no separate artifact store or
|
||||
registry.
|
||||
|
||||
The FlashInfer payload is a ``{filename: parsed JSON}`` map rather than a
|
||||
single document because upcoming kinds (Triton tuned configs) consist of
|
||||
several JSON files per cache.
|
||||
|
||||
TODO: install valid files individually instead of falling back to local
|
||||
warmup when any file of a multi-file kind fails validation.
|
||||
"""
|
||||
|
||||
import gc
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
|
||||
import regex as re
|
||||
import torch
|
||||
|
||||
from vllm.logger import init_logger
|
||||
from vllm.model_executor.model_loader.weight_cache.seed import Seedable
|
||||
from vllm.model_executor.model_loader.weight_cache.utils import (
|
||||
build_warmup_runner,
|
||||
is_draft_model_cacheable,
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.utils.flashinfer import has_flashinfer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.model_executor.model_loader.weight_cache.daemon import (
|
||||
WeightCacheDaemon,
|
||||
)
|
||||
from vllm.model_executor.model_loader.weight_cache.seed import (
|
||||
SeedTransport,
|
||||
)
|
||||
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_CONFIG_HASH_RE = re.compile(r"[0-9a-f]{64}")
|
||||
|
||||
|
||||
class WarmupCache(Seedable, Protocol):
|
||||
"""One daemon warmup cache: its warmup pass and its cache's semantics.
|
||||
|
||||
The warmup layer; the D2D exchange lives in seed.py (SeedTransport).
|
||||
"""
|
||||
|
||||
def warmup(self, daemon: "WeightCacheDaemon", transport: "SeedTransport") -> None:
|
||||
"""Run this rank's warmup pass against the cached model."""
|
||||
...
|
||||
|
||||
|
||||
class FlashInferWarmupCache(WarmupCache):
|
||||
"""FlashInfer's per-rank autotune table (a single JSON file)."""
|
||||
|
||||
kind = "flashinfer_autotune"
|
||||
|
||||
def warmup(self, daemon: "WeightCacheDaemon", transport: "SeedTransport") -> None:
|
||||
vllm_config = daemon.vllm_config
|
||||
# Mirrors the gate in the engine's kernel warmup.
|
||||
if (
|
||||
vllm_config.kernel_config.enable_flashinfer_autotune is False
|
||||
or not has_flashinfer()
|
||||
or not current_platform.has_device_capability(90)
|
||||
or is_draft_model_cacheable(vllm_config.speculative_config)
|
||||
):
|
||||
return
|
||||
from vllm.model_executor.warmup.kernel_warmup import flashinfer_autotune
|
||||
|
||||
try:
|
||||
current_platform.update_block_size_for_backend(vllm_config)
|
||||
runner = build_warmup_runner(
|
||||
vllm_config,
|
||||
daemon.local_rank,
|
||||
is_draft=daemon.is_draft,
|
||||
model_config=daemon.model_config,
|
||||
)
|
||||
assert daemon.model is not None, "warmup ran before load_model"
|
||||
runner.load_model(model=daemon.model)
|
||||
transport.adopt(self, runner)
|
||||
# The V2 runner requires skip_attn when no KV cache exists
|
||||
flashinfer_autotune(runner, skip_attn=vllm_config.use_v2_model_runner)
|
||||
logger.info(
|
||||
"Weight cache %s daemon rank %d tuned FlashInfer; the tuned "
|
||||
"configs are in the on-disk autotune cache",
|
||||
daemon.role,
|
||||
daemon.global_rank,
|
||||
)
|
||||
finally:
|
||||
# Free the runner and the dummy-run activations; the daemon's
|
||||
# weights are untouched.
|
||||
gc.collect()
|
||||
torch.accelerator.empty_cache()
|
||||
|
||||
def request_params(self, runner: "GPUModelRunner") -> dict[str, Any]:
|
||||
from vllm.distributed.parallel_state import get_world_group
|
||||
from vllm.model_executor.warmup.flashinfer_autotune_cache import (
|
||||
flashinfer_autotune_cache_hash,
|
||||
)
|
||||
|
||||
return {
|
||||
"config_hash": flashinfer_autotune_cache_hash(runner),
|
||||
"dp_rank": runner.vllm_config.parallel_config.data_parallel_rank,
|
||||
"rank": get_world_group().rank_in_group,
|
||||
}
|
||||
|
||||
def resolve(self, request: dict[str, Any]) -> dict[str, Any] | None:
|
||||
from vllm.model_executor.warmup.flashinfer_autotune_cache import (
|
||||
flashinfer_autotune_filename,
|
||||
flashinfer_autotune_root,
|
||||
)
|
||||
|
||||
config_hash = request.get("config_hash")
|
||||
if not isinstance(config_hash, str) or not _CONFIG_HASH_RE.fullmatch(
|
||||
config_hash
|
||||
):
|
||||
raise ValueError("invalid config_hash")
|
||||
|
||||
# Garbage ranks simply name a file that does not exist (a miss); the
|
||||
# hex check above is what keeps the request inside the cache root.
|
||||
filename = flashinfer_autotune_filename(request["dp_rank"], request["rank"])
|
||||
path = flashinfer_autotune_root() / config_hash / filename
|
||||
if not path.is_file():
|
||||
return None
|
||||
return {filename: json.loads(path.read_bytes())}
|
||||
|
||||
def validate_payload(self, payload: Any) -> dict[str, Any]:
|
||||
if not isinstance(payload, dict) or len(payload) != 1:
|
||||
raise ValueError("expected exactly one autotune table")
|
||||
((filename, table),) = payload.items()
|
||||
if not isinstance(table, dict):
|
||||
raise ValueError("autotune table must be a JSON object")
|
||||
if not filename.startswith("autotune_configs_"):
|
||||
raise ValueError(f"unexpected file {filename!r}")
|
||||
# Reject tables flashinfer would refuse to load: a hard _metadata
|
||||
# conflict would also never be overwritten, poisoning this cache
|
||||
# path into re-tuning on every start.
|
||||
saved_meta = table.get("_metadata")
|
||||
if saved_meta is None:
|
||||
# Pre-metadata flashinfer file; load_configs accepts these.
|
||||
return payload
|
||||
try:
|
||||
from flashinfer.autotuner.autotuner import (
|
||||
_classify_metadata_mismatches,
|
||||
_collect_metadata,
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"flashinfer metadata classifier unavailable; "
|
||||
"accepting the remote autotune table unchecked"
|
||||
)
|
||||
return payload
|
||||
hard, soft = _classify_metadata_mismatches(saved_meta, _collect_metadata())
|
||||
mismatches = {**hard, **soft}
|
||||
if mismatches:
|
||||
details = ", ".join(
|
||||
f"{key}: remote={old} vs local={new}"
|
||||
for key, (old, new) in mismatches.items()
|
||||
)
|
||||
raise ValueError(f"autotune table environment mismatch: {details}")
|
||||
return payload
|
||||
|
||||
def install(self, runner: "GPUModelRunner", payload: dict[str, Any]) -> None:
|
||||
from vllm.model_executor.warmup.flashinfer_autotune_cache import (
|
||||
resolve_rank_flashinfer_autotune_file,
|
||||
write_flashinfer_autotune_cache,
|
||||
)
|
||||
|
||||
(data,) = payload.values()
|
||||
cache_path = resolve_rank_flashinfer_autotune_file(runner)
|
||||
write_flashinfer_autotune_cache(cache_path, json.dumps(data).encode())
|
||||
logger.info("Installed remote FlashInfer autotune table: %s", cache_path)
|
||||
@@ -38,26 +38,44 @@ def flashinfer_autotune_cache_hash(runner: "GPUModelRunner") -> str:
|
||||
return hashlib.sha256(config_hash.encode()).hexdigest()
|
||||
|
||||
|
||||
def resolve_flashinfer_autotune_file(runner: "GPUModelRunner") -> Path:
|
||||
def flashinfer_autotune_root() -> Path:
|
||||
"""Root directory of the FlashInfer autotune cache (no runner needed)."""
|
||||
override_dir = envs.VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR
|
||||
if override_dir:
|
||||
root = Path(override_dir).expanduser()
|
||||
else:
|
||||
from flashinfer.jit import env as flashinfer_jit_env
|
||||
return Path(override_dir).expanduser()
|
||||
from flashinfer.jit import env as flashinfer_jit_env
|
||||
|
||||
flashinfer_workspace = flashinfer_jit_env.FLASHINFER_WORKSPACE_DIR
|
||||
root = (
|
||||
Path(envs.VLLM_CACHE_ROOT)
|
||||
/ "flashinfer_autotune_cache"
|
||||
/ flashinfer_workspace.parent.name
|
||||
/ flashinfer_workspace.name
|
||||
)
|
||||
flashinfer_workspace = flashinfer_jit_env.FLASHINFER_WORKSPACE_DIR
|
||||
return (
|
||||
Path(envs.VLLM_CACHE_ROOT)
|
||||
/ "flashinfer_autotune_cache"
|
||||
/ flashinfer_workspace.parent.name
|
||||
/ flashinfer_workspace.name
|
||||
)
|
||||
|
||||
output_dir = root / flashinfer_autotune_cache_hash(runner)
|
||||
|
||||
def flashinfer_autotune_filename(dp_rank: int, rank: int) -> str:
|
||||
"""Per-rank cache filename, shared by the engine reader and D2D exchange."""
|
||||
return f"autotune_configs_dp{dp_rank}_rank{rank}.json"
|
||||
|
||||
|
||||
def resolve_flashinfer_autotune_file(runner: "GPUModelRunner") -> Path:
|
||||
output_dir = flashinfer_autotune_root() / flashinfer_autotune_cache_hash(runner)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return output_dir / "autotune_configs.json"
|
||||
|
||||
|
||||
def resolve_rank_flashinfer_autotune_file(runner: "GPUModelRunner") -> Path:
|
||||
"""The per-rank variant of :func:`resolve_flashinfer_autotune_file`."""
|
||||
from vllm.distributed.parallel_state import get_world_group
|
||||
|
||||
path = resolve_flashinfer_autotune_file(runner)
|
||||
dp_rank = runner.vllm_config.parallel_config.data_parallel_rank
|
||||
return path.with_name(
|
||||
flashinfer_autotune_filename(dp_rank, get_world_group().rank_in_group)
|
||||
)
|
||||
|
||||
|
||||
def use_flashinfer_autotune_v2(runner: "GPUModelRunner") -> bool:
|
||||
from vllm.distributed.parallel_state import get_node_count, get_world_group
|
||||
from vllm.utils.flashinfer import has_flashinfer_autotune_v2
|
||||
|
||||
@@ -22,8 +22,8 @@ from vllm.model_executor.warmup.b12x_warmup import b12x_warmup
|
||||
from vllm.model_executor.warmup.cutedsl_warmup import cutedsl_warmup
|
||||
from vllm.model_executor.warmup.deep_gemm_warmup import deep_gemm_warmup
|
||||
from vllm.model_executor.warmup.flashinfer_autotune_cache import (
|
||||
resolve_flashinfer_autotune_file,
|
||||
resolve_flashinfer_autotune_v2_root,
|
||||
resolve_rank_flashinfer_autotune_file,
|
||||
use_flashinfer_autotune_v2,
|
||||
)
|
||||
from vllm.model_executor.warmup.flashinfer_sparse_mla_warmup import (
|
||||
@@ -405,6 +405,27 @@ def _all_ranks_have_matching_cache(path: Path, group) -> bool:
|
||||
return fingerprint is not None and all(f == fingerprint for f in gathered)
|
||||
|
||||
|
||||
def _load_shared_autotune_cache(cache_path: Path, tuner, tune_group) -> bool:
|
||||
"""Load the per-rank autotune cache into the tuner, group-wide.
|
||||
|
||||
Returns True only when every rank of the tune group had a matching file
|
||||
and loaded it: the dummy-run sweep contains collectives, so skipping it
|
||||
is safe only if the decision is unanimous.
|
||||
"""
|
||||
if not _all_ranks_have_matching_cache(cache_path, tune_group):
|
||||
return False
|
||||
loaded = tuner.load_configs(str(cache_path))
|
||||
if tune_group.world_size > 1:
|
||||
loaded_by_rank: list[bool | None] = [None] * tune_group.world_size
|
||||
torch.distributed.all_gather_object(
|
||||
loaded_by_rank, loaded, group=tune_group.cpu_group
|
||||
)
|
||||
loaded = all(loaded_by_rank)
|
||||
if not loaded:
|
||||
tuner.clear_cache()
|
||||
return loaded
|
||||
|
||||
|
||||
def flashinfer_autotune(runner: "GPUModelRunner", *, skip_attn: bool = False) -> None:
|
||||
"""Autotune FlashInfer operations.
|
||||
FlashInfer have many implementations for the same operation,
|
||||
@@ -455,6 +476,7 @@ def flashinfer_autotune(runner: "GPUModelRunner", *, skip_attn: bool = False) ->
|
||||
autotune_kwargs["skip_ops"] = skip_ops
|
||||
|
||||
use_autotune_v2 = use_flashinfer_autotune_v2(runner)
|
||||
cache_hit = False
|
||||
if use_autotune_v2:
|
||||
from flashinfer import autotune_v2, autotune_v2_reload
|
||||
|
||||
@@ -469,12 +491,7 @@ def flashinfer_autotune(runner: "GPUModelRunner", *, skip_attn: bool = False) ->
|
||||
mode="tune", cache_root=cache_root, **autotune_kwargs
|
||||
)
|
||||
else:
|
||||
cache_path = resolve_flashinfer_autotune_file(runner)
|
||||
# The world group only spans DP ranks when vLLM folds DP into it.
|
||||
dp_rank = runner.vllm_config.parallel_config.data_parallel_rank
|
||||
cache_path = cache_path.with_name(
|
||||
f"{cache_path.stem}_dp{dp_rank}_rank{world.rank_in_group}{cache_path.suffix}"
|
||||
)
|
||||
cache_path = resolve_rank_flashinfer_autotune_file(runner)
|
||||
if is_leader:
|
||||
logger.info_once(
|
||||
"Using FlashInfer autotune cache file: %s. Pass "
|
||||
@@ -482,16 +499,7 @@ def flashinfer_autotune(runner: "GPUModelRunner", *, skip_attn: bool = False) ->
|
||||
cache_path,
|
||||
)
|
||||
|
||||
if _all_ranks_have_matching_cache(cache_path, tune_group):
|
||||
loaded = tuner.load_configs(str(cache_path))
|
||||
if tune_group.world_size > 1:
|
||||
loaded_by_rank: list[bool | None] = [None] * tune_group.world_size
|
||||
torch.distributed.all_gather_object(
|
||||
loaded_by_rank, loaded, group=tune_group.cpu_group
|
||||
)
|
||||
loaded = all(loaded_by_rank)
|
||||
if not loaded:
|
||||
tuner.clear_cache()
|
||||
cache_hit = _load_shared_autotune_cache(cache_path, tuner, tune_group)
|
||||
autotune_context = fi_utils.autotune(tune_mode=True, **autotune_kwargs)
|
||||
|
||||
group = tune_group.cpu_group if tune_group.world_size > 1 else None
|
||||
@@ -506,7 +514,8 @@ def flashinfer_autotune(runner: "GPUModelRunner", *, skip_attn: bool = False) ->
|
||||
# HiSparse hot-buffer attention is bounded by decode batch
|
||||
# size, not the prefill-sized batch used for the full model.
|
||||
autotune_hisparse_flashinfer_attention(runner)
|
||||
_run_flashinfer_autotune_dummy_runs(runner, skip_attn=skip_attn)
|
||||
if not cache_hit:
|
||||
_run_flashinfer_autotune_dummy_runs(runner, skip_attn=skip_attn)
|
||||
replayssm_autotune_warmup(runner)
|
||||
_autotune_kimi_k3_kda_qkvg(runner.get_model())
|
||||
finally:
|
||||
|
||||
Reference in new issue
Block a user