Files
vllm-omni/vllm_omni/edge/placement.py
T
tzhouamandClaude Fable 5.1 4e3e8a1ebc [edge-infer] WIP snapshot: uncommitted M0/W1/W4/W5 edge work as of 2026-09-18
Verbatim copy of the edge/omni-edge working tree (tracked diffs + untracked
edge/local, tests/edge/local, docs/edge, pipeline files). Taken so that
vLLM-Omni v0.29.0rc1 can be merged on a branch without touching the
user's working tree. logs_edge/ and benchmark .npy outputs excluded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 09:47:29 +08:00

347 lines
14 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Where each parameter lives when the checkpoint is bigger than the VRAM.
:mod:`vllm_omni.edge.weight_path` picks a *format* from the device. This picks
a *placement*, which is a different question and only arises when the two
numbers do not fit: Qwen3.8-27B-FP8 is 30.89 GB of weights against a 24 GiB
card. vLLM will do this for you -- ``cpu_offload_gb`` maps host memory into the
GPU's address space and the kernels read it over PCIe -- but it offloads
"non-selectively until the memory limit is reached", and that is the expensive
way to do it.
**The cost of getting it wrong, measured.** On this laptop (RTX 5090 Laptop
24 GiB, PCIe 4.0 x8, 13.9 GB/s pinned H2D, 472 GB/s VRAM) Qwen3.8-27B-FP8 runs
at **1.05 tok/s** with the smallest bulk offload that fits (10 GB; 9 GB fails
to leave room for the cache). Decode reads every weight once per token, so a
byte on the host side of a 13.9 GB/s link costs 34x what the same byte costs in
472 GB/s VRAM. At 10 GB offloaded that is 719 ms of a ~950 ms step: **the
offload is 75% of the decode time and the other 18.8 GB of weights are 4%.**
**Which bytes those are is a choice.** Not every parameter is read once per
token, and the ones that are not are free to evict. Qwen3.8-27B-FP8's
``outside.safetensors`` and ``mtp.safetensors`` hold 6.5 GB, of which:
lm_head 2.543 GB HOT -- one 248320 x 5120 GEMM per token
embed_tokens 2.543 GB COLD -- a gather; one row, ~10 KB per token
visual.* 0.922 GB COLD -- unless the prompt has an image
mtp.* 0.478 GB COLD -- unless speculative decoding is on
``embed_tokens`` and ``lm_head`` are the same shape and the same dtype, and one
of them is the single best parameter in the model to offload while the other is
among the worst. A size-ordered, name-blind policy cannot tell them apart;
``cpu_offload_params`` can, because it matches name segments.
So the plan here is: close the deficit out of the cold set first, and only
spend the hot set when the cold set runs out. On this checkpoint the cold set
is **3.94 GB**, which is most of the 8.4 GB deficit, and the arithmetic says
the remainder costs ~324 ms/token instead of 719.
**Two limits, both found the hard way (2026-09-15).**
*The cold set is not actually offloadable in vLLM today.* The plan below sorts
parameters into hot and cold and emits ``cpu_offload_params`` for the cold ones.
That flag exists and matches name segments as documented -- but the offloader is
only ever **offered decoder layers**: ``wrap_modules`` is called from inside
``make_layers`` (``vllm/model_executor/models/utils.py:812``) and nowhere else.
``embed_tokens``, ``lm_head``, the vision tower and the MTP head are built
outside it and can never be reached, whatever the flag says. Measured: asking
for ``{embed_tokens, visual, mtp}`` on a 21.92 GB checkpoint with a 4.31 GB cold
set moved **~0.5 GiB**, and the run failed identically to one with no offload at
all. The classification here is still right about which bytes are cheap to move;
the *mechanism* is missing, and closing it means an upstream change. Until then
treat ``cold_offload`` plans as advisory.
*Fewer bytes is not always faster.* This module prices weights by traffic, which
assumes decode is bandwidth-bound. That held for Qwen3.8-27B-FP8 to within 4.3%
(see the test). It does **not** hold for the NVFP4 build of the same model:
21.95 GB of NVFP4 measured **0.67 tok/s** against 30.87 GB of FP8 at
**1.05 tok/s** -- slower while 30% smaller, and 4.7x off what the traffic model
predicts. Enabling torch.compile and CUDA graphs moved it to 0.72, so it is not
an eager-mode artifact: that path is compute-bound, not bandwidth-bound, and a
model of bytes cannot see it. Never recommend a format change on this module's
estimate alone -- measure it.
Estimates here are arithmetic, not measurements: bytes over a bandwidth. They
are labelled ``est_`` and the measured points they were checked against are in
the table above.
"""
from __future__ import annotations
import json
import struct
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
GiB = 2**30
# Name segments that are read once per decode step, and segments that are not.
# Matching is on dot-separated segments, the same rule vLLM's
# ``cpu_offload_params`` uses, so what is planned here is what it will do.
COLD_SEGMENTS: tuple[str, ...] = (
"embed_tokens", # a gather: one row per token, not the whole table
"visual", # vision tower: only on a prompt that carries an image
"vision_tower",
"mtp", # multi-token-prediction head: only under speculation
"audio_tower",
)
"""Parameters a text decode step does not read. Offloading these is close to
free -- ``embed_tokens`` still costs one row's worth of PCIe per token, which
at 5120 x 2 bytes is 10 KiB against a 13.9 GB/s link, i.e. under a microsecond."""
HOT_ALWAYS: tuple[str, ...] = ("lm_head",)
"""Named so the planner can never classify them cold by accident: ``lm_head``
is the same shape and dtype as ``embed_tokens`` and the opposite decision."""
@dataclass(frozen=True)
class ParamGroup:
name: str
bytes: int
cold: bool
@dataclass(frozen=True)
class Placement:
strategy: str
"""``resident`` | ``cold_offload`` | ``hot_offload`` | ``infeasible``."""
device_bytes: int
host_bytes: int
host_read_per_token: int
"""Bytes crossing the link every decode step. This, not ``host_bytes``, is
what costs time -- offloading a parameter nothing reads costs nothing."""
deficit_bytes: int
"""How far the checkpoint overran the device budget before any offload."""
est_link_s_per_token: float
est_vram_s_per_token: float
engine_kwargs: dict[str, Any]
rationale: str
notes: list[str] = field(default_factory=list)
@property
def est_decode_tok_s(self) -> float:
"""Weight-traffic bound only: no attention, no kernel launch, no
sampling. An upper bound, and on the measured point it was 1.24x the
observed rate."""
total = self.est_link_s_per_token + self.est_vram_s_per_token
return 1.0 / total if total > 0 else float("inf")
def summary(self) -> str:
return (
f"{self.strategy}: {self.device_bytes / 1e9:.1f} GB on device, "
f"{self.host_bytes / 1e9:.1f} GB on host of which "
f"{self.host_read_per_token / 1e9:.1f} GB is read per token; "
f"est {self.est_decode_tok_s:.1f} tok/s on weight traffic alone"
)
def is_cold(name: str) -> bool:
"""Segment match, so ``model.visual.blocks.0`` is cold and ``visualise``
is not. ``lm_head`` is never cold whatever else it matches."""
segments = set(name.split("."))
if segments & set(HOT_ALWAYS):
return False
return bool(segments & set(COLD_SEGMENTS))
def _safetensors_header(path: Path) -> dict[str, Any]:
with path.open("rb") as f:
n = struct.unpack("<Q", f.read(8))[0]
return json.loads(f.read(n))
def group_key(name: str, top_segments: int = 3) -> str:
"""The unit the planner is allowed to move: a prefix, but never coarser
than one decoder layer.
A plain 3-segment prefix puts all 64 of Qwen3.8's decoder layers in one
24.4 GB group, and a planner that can only take groups whole then has a
choice between offloading no layers and offloading every layer. Keeping the
layer index makes each layer its own 0.38 GB group, which is also the
granularity vLLM's prefetch offloader works at.
"""
parts = name.split(".")
for i, part in enumerate(parts):
if part.isdigit() and i > 0 and parts[i - 1].endswith("layers"):
return ".".join(parts[: i + 1])
return ".".join(parts[:top_segments])
def checkpoint_param_groups(model_dir: str | Path, top_segments: int = 3) -> list[ParamGroup]:
"""Bytes per parameter group, read from the safetensors headers.
Reads headers only -- a few kilobytes per shard -- so this is cheap enough
to run at deploy-planning time on a 31 GB checkpoint. Walks every
``*.safetensors``, which is how Qwen3.8's per-layer shards are laid out.
"""
root = Path(model_dir)
shards = sorted(root.glob("*.safetensors"))
if not shards:
raise FileNotFoundError(f"no *.safetensors under {root}")
totals: dict[str, int] = {}
cold: dict[str, bool] = {}
for shard in shards:
for key, meta in _safetensors_header(shard).items():
if key == "__metadata__":
continue
offs = meta.get("data_offsets")
if not offs:
continue
prefix = group_key(key, top_segments)
totals[prefix] = totals.get(prefix, 0) + (offs[1] - offs[0])
# A group is cold only if every tensor in it is.
cold[prefix] = cold.get(prefix, True) and is_cold(key)
return sorted(
(ParamGroup(k, v, cold[k]) for k, v in totals.items()),
key=lambda g: -g.bytes,
)
def _engine_kwargs(
offloaded: list[ParamGroup], hot: list[ParamGroup], freed: int
) -> dict[str, Any]:
"""Translate a plan into flags vLLM actually has.
The cold set is expressible exactly: ``cpu_offload_params`` matches name
segments, and every cold group is named by one. The hot set is not -- it is
"these 12 specific layers", and the only knob for that is the prefetch
offloader's ``offload_group_size`` / ``offload_num_in_group``, which takes
the last N of every group of M. So the hot half is emitted as the closest
(M, N) whose count matches, and the caller is told it is an approximation.
"""
segments = sorted(
{
seg
for g in offloaded
if g.cold
for seg in g.name.split(".")
if seg in COLD_SEGMENTS
}
)
kwargs: dict[str, Any] = {
"cpu_offload_gb": round(freed / GiB + 0.5, 1),
"cpu_offload_params": segments,
}
if hot:
n_layers = sum(1 for g in hot if group_key(g.name) == g.name and
any(p.isdigit() for p in g.name.split(".")))
n_layers = n_layers or len(hot)
# Offload 1 layer in every ceil(total/n) -- the coarsest pattern that
# reaches the count. The caller re-plans if the fit is not exact.
kwargs["offload_group_size"] = max(2, round(64 / max(n_layers, 1)))
kwargs["offload_num_in_group"] = 1
return kwargs
def plan_placement(
groups: list[ParamGroup],
*,
device_bytes_available: int,
link_bytes_per_s: float,
vram_bytes_per_s: float,
embedding_row_bytes: int = 0,
) -> Placement:
"""Decide what goes to the host, cheapest bytes first.
``device_bytes_available`` is what is left for *weights* after the KV
cache, the recurrent state, activations and the driver's own reservation --
:func:`vllm_omni.edge.kv_budget.kv_budget_for` sizes the first two.
"""
weights = sum(g.bytes for g in groups)
deficit = weights - device_bytes_available
if deficit <= 0:
return Placement(
strategy="resident",
device_bytes=weights, host_bytes=0, host_read_per_token=0,
deficit_bytes=0,
est_link_s_per_token=0.0,
est_vram_s_per_token=weights / vram_bytes_per_s,
engine_kwargs={},
rationale=(
"The checkpoint fits. Every byte is read at VRAM speed and "
"nothing crosses the link, which is the only placement that "
"does not pay 34x for some of its weights."
),
)
# Cheapest first: parameters a decode step never reads.
offloaded: list[ParamGroup] = []
freed = 0
for g in sorted((g for g in groups if g.cold), key=lambda g: -g.bytes):
if freed >= deficit:
break
offloaded.append(g)
freed += g.bytes
cold_only = freed >= deficit
hot_offloaded: list[ParamGroup] = []
if not cold_only:
# Still short. Spend hot parameters, largest first -- they all cost the
# same per byte, so the only thing that matters is hitting the target
# in as few groups as possible.
for g in sorted((g for g in groups if not g.cold), key=lambda g: -g.bytes):
if freed >= deficit:
break
hot_offloaded.append(g)
offloaded.append(g)
freed += g.bytes
if freed < deficit:
return Placement(
strategy="infeasible",
device_bytes=device_bytes_available, host_bytes=freed,
host_read_per_token=sum(g.bytes for g in hot_offloaded),
deficit_bytes=deficit - freed,
est_link_s_per_token=float("inf"), est_vram_s_per_token=0.0,
engine_kwargs={},
rationale=(
f"{(deficit - freed) / 1e9:.1f} GB short even with every "
"parameter on the host. A smaller checkpoint is the only "
"remaining lever."
),
)
hot_bytes = sum(g.bytes for g in hot_offloaded)
# A cold embedding still pays one row per token; everything else cold is 0.
read_per_token = hot_bytes + (
embedding_row_bytes
if any("embed_tokens" in g.name for g in offloaded)
else 0
)
device_bytes = weights - freed
notes = [
f"cold offload: {', '.join(g.name for g in offloaded if g.cold)}"
or "cold offload: none",
]
if hot_offloaded:
notes.append(
"hot offload (read every token): "
+ ", ".join(g.name for g in hot_offloaded)
)
return Placement(
strategy="cold_offload" if cold_only else "hot_offload",
device_bytes=device_bytes,
host_bytes=freed,
host_read_per_token=read_per_token,
deficit_bytes=deficit,
est_link_s_per_token=read_per_token / link_bytes_per_s,
est_vram_s_per_token=device_bytes / vram_bytes_per_s,
engine_kwargs=_engine_kwargs(offloaded, hot_offloaded, freed),
rationale=(
f"{deficit / 1e9:.1f} GB over budget. "
+ (
"Closed entirely out of parameters a decode step does not "
"read, so the link is idle between steps."
if cold_only
else f"The cold set gave {(freed - hot_bytes) / 1e9:.1f} GB; "
f"the remaining {hot_bytes / 1e9:.1f} GB is read every token "
f"and is what the decode rate is bound by."
)
),
notes=notes,
)