[Core] shm tensor arena for efficient cpu->gpu worker broadcast (#51207)

Signed-off-by: Bolin Sun <bolins@nvidia.com>
Signed-off-by: <bolins@nvidia.com>
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Nick Hill <nickhill123@gmail.com>
This commit is contained in:
Bolin Sun
2026-10-09 16:13:24 -07:00
committed by GitHub
co-authored by Claude Nick Hill
parent fa94ad4143
commit 04b02fc8a5
8 changed files with 1290 additions and 8 deletions
+302
View File
@@ -0,0 +1,302 @@
# Zero-Copy Shared-Memory Tensor Arena for `MessageQueue`
This document describes an optimization to vLLM's engine→worker IPC path for
multimodal serving under tensor parallelism, implemented in
`vllm/distributed/device_communicators/shm_tensor_arena.py` and wired into
`shm_broadcast.py`'s `MessageQueue`. It **builds on the existing out-of-band
tensor channel** (`_reduce_tensor` / `_rebuild_tensor`, still in
`shm_broadcast.py`), which already keeps CPU-tensor bytes out of the pickle
stream.
> **Status: experimental, opt-in.** This lands as an increment over the
> existing out-of-band channel, not a replacement for it. vLLM also has an
> existing multimodal-tensor shm caching mechanism; reconciling the two is
> intentionally left as follow-up work rather than blocking this PR.
## TL;DR
`MessageQueue.enqueue` already avoids copying CPU tensor bytes into the pickle
stream: `_reduce_tensor` emits each tensor as a protocol-5 out-of-band
`PickleBuffer`, and large payloads are published to the local readers over a
ZMQ socket. That removed the dominant cost of the older *in-band* path — the
per-tensor `pickle` serialize (see [History](#history-the-in-band-pickle-era)).
Two costs remain for a large multimodal `pixel_values` tensor on a TP=N worker:
1. **N transport copies.** On the overflow (large-payload) path the out-of-band
buffer is published to each of the N node-local readers via `send_multipart`,
so the tensor bytes are moved once **per reader** by the transport — ~N×200 MB
of host data motion for a payload the model needs exactly once.
2. **A pageable H2D.** Each reader zero-copies a view over its received ZMQ
frame, but that frame is ordinary pageable memory, so the reader's subsequent
`cudaMemcpyAsync` to the GPU pays pageable-copy staging plus first-touch
faults.
This change adds a slotted shared-memory **arena**: the writer does **one**
memcpy of the tensor into a free slot; every reader takes a **zero-copy view**
of the same slot (no per-reader transport copy); and the mapping is
`cudaHostRegister`-pinned so the H2D is a true DMA. Net for a 200 MB image on
TP=4: **1 copy + a pinned DMA** instead of **4 transport copies + a pageable
H2D**. Small tensors, non-contiguous tensors, and arena exhaustion all fall
through to the existing `_reduce_tensor` path unchanged.
## Scope of the win (and what still needs measuring)
The arena is an **incremental** improvement layered on the out-of-band channel,
not a from-scratch fix — the out-of-band channel already eliminated the single
largest cost (the serialize copy). The benefit that remains is:
- **N→1 copies**, which grows with TP degree (no gain at TP=1), and
- the **pinned vs. pageable H2D**, which shows up as reader-side H2D latency.
> **The benchmarks in [§4](#4-validation) were measured against the *in-band
> pickle* baseline that predates `_reduce_tensor`**, where the dominant cost was
> the serialize copy. They are retained as motivation and as an upper bound. The
> incremental improvement of the arena *over the current out-of-band path on
> `main`* is smaller and has **not yet been re-measured**; that A/B (arena vs.
> oob, same seeded workload) is the outstanding validation item before this is
> merge-ready.
## 1. Where it bites
Any multimodal model served with `--tensor-parallel-size > 1` through the
multiproc executor. The `rpc_broadcast_mq` `MessageQueue`
(`v1/executor/multiproc_executor.py`) carries scheduler output — including the
raw preprocessed `pixel_values` — from the EngineCore to every TP worker each
step. Images at native resolution reach tens to hundreds of MB (a ~4k×4k image
is ~200 MB as bf16 patches). The out-of-band channel keeps those bytes out of
the pickle stream, but on the overflow path they are still published **once per
node-local reader**, and each reader's H2D is from **pageable** memory. The
per-reader copy cost scales with TP degree; the pinning cost is a fixed per-image
tax on the reader H2D. Latency-sensitive (interactive) serving makes the residual
visible in TTFT and, via head-of-line blocking, in the end-to-end latency of
*other* requests.
### History: the in-band-pickle era
Before `_reduce_tensor`, `MessageQueue.enqueue` pickled the whole payload —
including tensor bytes — because `torch.Tensor.__reduce_ex__` does not emit
protocol-5 out-of-band buffers. A ~200 MB tensor was byte-copied into the pickle
stream on the engine (`THPStorage_writeFileRaw`, ~6.5 ms/MB → ~1.3 s), *inside*
the EngineCore step loop, so all GPUs on the worker went idle for **0.9–1.3 s per
large image** (the vision-encoder compute for the same image is 50–90 ms →
transport dominated compute ~15:1); each reader then paid a full `pickle.loads`
byte-copy. Per 200 MB image on TP=4 that was ~1.8 GB of host data motion.
The `_reduce_tensor` out-of-band channel removed the serialize copy (and the
per-reader `pickle.loads` copy on the zero-copy ZMQ-frame path). Note that
merely raising `VLLM_MQ_MAX_CHUNK_BYTES_MB` (ring vs. socket path) did **not**
help in the in-band era: the serialize ran before the ring-vs-socket branch, so
it was common to both — a negative result that pinned the real cost on the
byte-copies, not the transport choice.
## 2. The arena
The arena itself (`ShmTensorArena`, `_ArenaPickler`, `_rebuild_arena_tensor`)
lives in `shm_tensor_arena.py`; `shm_broadcast.py`'s `MessageQueue` creates
and wires it in but otherwise treats it as an internal implementation detail.
### 2.1 `ShmTensorArena`
A second shared-memory region created alongside the existing `ShmRingBuffer`:
- **N slots × slot_bytes** (default 8 × 256 MB, internal constants -- see
§3), plus per-slot metadata `[written_flag, reader0_done … readerN_done]`.
- Concurrency uses the **same lock-free single-writer/N-reader protocol as
`ShmRingBuffer`** (memory fences, per-reader done flags), so the model is one
the codebase already trusts.
- Created by the queue **writer** in `MessageQueue.__init__` when all readers are
node-local; readers attach via a new `tensor_arena_handle` field on the queue
`Handle`. Queues with remote readers get no arena and keep today's behavior.
- Pages are allocated lazily by the kernel on first write, so arenas on queues
that never carry big tensors (e.g. worker→engine response queues) cost
approximately nothing beyond the (virtual) reservation.
### 2.2 Writer path — arena diversion, composing with `_reduce_tensor`
`enqueue` builds one `dispatch_table` that routes CPU tensors through
`_reduce_tensor` (out-of-band `PickleBuffer`). When a node-local arena exists,
the pickler is an `_ArenaPickler` whose `reducer_override` *additionally* diverts
**large contiguous** CPU tensors into the arena:
```python
class _ArenaPickler(pickle.Pickler):
def reducer_override(self, obj):
rebuild_fn = _ARENA_REBUILD_FNS.get(type(obj)) # {Tensor: ..., Parameter: ...}
if (rebuild_fn is not None and obj.device.type == "cpu"
and obj.layout is torch.strided and obj.is_contiguous()
and not obj.requires_grad
and obj.numel() * obj.element_size() >= MIN_BYTES):
idx = self.arena.write_tensor(obj) # ONE memcpy into a free slot
if idx is not None:
return (rebuild_fn,
(self.arena.shared_memory.name, idx, nbytes, dtype_str, shape))
return NotImplemented # fall through to dispatch_table → _reduce_tensor
```
Note the exact-type dict lookup (not `isinstance`), matching how
`dispatch_table` itself dispatches: an unrecognized `Tensor` subclass must
decline here too, or it would come back as a plain `Tensor`, silently
losing its subclass identity. `torch.nn.Parameter` is the one subclass this
*does* divert, via its own rebuild function (§2.3).
`reducer_override` is consulted before an object's normal reduction, and
returning `NotImplemented` falls through to the `dispatch_table` — so a diverted
tensor becomes a ~100-byte rebuild stub, and **everything the arena declines**
(too small, non-contiguous, or arena full) is handled by `_reduce_tensor`
exactly as on `main`. The single arena copy —
`torch.frombuffer(slot).copy_(t.view(torch.uint8))` — is a multithreaded memcpy
at memory bandwidth (~50–70 ms for 200 MB), and, unlike the oob path, the *same*
slot is then read zero-copy by all N readers rather than transported to each.
**The writer never blocks.** If no slot is free or the tensor exceeds the slot
size, `write_tensor` returns `None`; the pickler falls through to `_reduce_tensor`
— the worst case is exactly the current out-of-band behavior, and deadlock is
structurally impossible.
### 2.3 Reader path — zero copies
The stub unpickles through a module-level rebuild function:
```python
def _rebuild_arena_tensor(arena_name, slot_idx, nbytes, dtype_str, shape):
arena = _TENSOR_ARENAS[arena_name] # this process's registry of attached arenas
# get_tensor: a view over the mapped slot — zero bytes copied
return arena.get_tensor(slot_idx, nbytes, getattr(torch, dtype_str), shape)
```
`_rebuild_arena_parameter` wraps the same tensor in
`torch.nn.Parameter(t, requires_grad=False)`, which shares its storage.
`_ARENA_REBUILD_FNS = {torch.Tensor: _rebuild_arena_tensor, torch.nn.
Parameter: _rebuild_arena_parameter}` is the full set of types the arena
diverts; anything else (any other `Tensor` subclass) declines in
`reducer_override` and takes the out-of-band path.
The registry is keyed by arena shm name (`_TENSOR_ARENAS:
weakref.WeakValueDictionary[str, ShmTensorArena]`) rather than a single
per-process slot, since nothing guarantees a process only ever attaches to
one arena-bearing queue. It holds only a *weak* reference — a strong one
would keep the arena (and its pinned mapping) alive for the process
lifetime — and needs no explicit removal on `MessageQueue.shutdown()`
(unsafe anyway, since shutdown can run on a different thread than an
in-flight `dequeue()`); an entry just disappears once nothing else
references that arena.
The rebuilt tensor *is* the shared memory — no transport copy and no deserialize
on any rank.
**Slot lifecycle.** The rebuilt tensor is the *source* of an async H2D while
the worker executes that step, so the reader must not release the slot at
unpickle time. Release is tied to **the lifetime of the slot's tensor
storage**, not a fixed "next dequeue" schedule — needed for callers like
`prompt_embeds` that retain the tensor across many `dequeue` calls during
chunked prefill, where a fixed schedule would let the writer reclaim (and
corrupt) the slot while the worker was still reading it. `get_tensor` builds
the tensor via `torch.from_numpy` over a numpy array wrapping the slot; the
tensor's storage holds the only reference to that array, so a
`weakref.finalize` on it fires only once every tensor, view/slice and wrapper
(e.g. `Parameter`) sharing the storage has been freed. Once queued, the
pinned fast path (§2.4) adds a second gate: the H2D is a true async DMA that
can outlive `execute_model`, so "the storage was freed" alone isn't
sufficient.
`flush_releases` records a CUDA event on the compute stream after each
step's H2D and only marks a slot done once that event completes
(non-blocking `event.query()`; not-yet-done just waits one more dequeue).
Unpinned, `cudaMemcpyAsync` from pageable memory already stages
synchronously, so the slot releases immediately. Either way, the writer
requires every reader's done flag before reusing a slot.
> **Note.** Assumes the multimodal H2D is issued on the worker's
> current/default compute stream (true today: mm inputs are copied eagerly, outside the decode CUDA
> graph). If a future vLLM issues that copy on a dedicated side stream, the event
> must be recorded at the copy site rather than at `flush_releases`.
### 2.4 Pinning — the zero-copy trap
A zero-copy view over shared memory the reader process has never touched makes
the subsequent `cudaMemcpyAsync` pay **first-touch page faults on ~50k pages plus
pageable-copy staging** — the same tax the current oob path pays on its pageable
ZMQ frame. Fix: each reader lazily `cudaHostRegister`s the whole arena mapping
once (~1 s, first use), after which every H2D from the arena is a **pinned-memory
DMA** (~10 ms for 192 MB). Processes without a CUDA context skip registration
silently. (Because the arena mapping is stable and reused, this one-time pin
amortizes across all future images — an option the transient per-message ZMQ
frame does not have.)
## 3. Configuration
A single CLI flag toggles the arena; the slot count/size and divert threshold
are internal constants (no env vars).
| Flag (`ParallelConfig` field) | Default | Meaning |
| --- | --- | --- |
| `--enable-shm-tensor-arena` / `--no-enable-shm-tensor-arena` (`enable_shm_tensor_arena`) | **off** | Opt in to route large CPU tensors through the arena (reserves slots in `/dev/shm`). Off = the out-of-band `_reduce_tensor` path only, identical to stock behavior. |
Internal constants in `shm_tensor_arena.py`: **8 slots × 256 MB**; tensors larger
than a slot, or smaller than the **8 MB** divert threshold, take the out-of-band
`_reduce_tensor` path.
## 4. Validation
> **Baseline caveat.** The A/B numbers below were collected against the *in-band
> pickle* path that predates `_reduce_tensor`. They quantify the arena vs. that
> older baseline (an upper bound), **not** the arena vs. the current out-of-band
> path. The current-`main` oob baseline would land between the "stock" and
> "arena + pinning" rows; measuring exactly where is the open item.
1. **Unit tests** (`tests/distributed/test_shm_broadcast.py`): byte-exact
zero-copy round-trips (incl. bf16/fp8), slot lifecycle (no reuse until every
reader releases; exhaustion / oversize / non-contiguous fall back to the
out-of-band path), event-gated release on the pinned path, `_ArenaPickler` ⊕
`_reduce_tensor` composition, and an end-to-end `MessageQueue` broadcast
through forked processes. (Historical microbenchmark: enqueue of a 199 MB
payload took **66.7 ms** vs ~1275 ms for the old in-band serialize.)
2. **Same-seed A/B** (baseline = in-band pickle, pre-`_reduce_tensor`), interactive
multimodal workload, uncapped input images, TP=4 ×2 workers on one 8-GPU node,
low qps so individual images decompose cleanly (~1.2k aligned requests):
| TTFT (ms) | p50 | p90 | p99 | max | >1 s | >1.5 s |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| in-band pickle (old baseline) | 93 | 401 | 1321 | 2139 | 30 | 9 |
| + bigger MQ chunk (config lever) | 91 | 368 | 1287 | 2027 | 22 | 5 |
| **arena + pinning** | **89** | **241** | **862** | **1375** | **7** | **0** |
p50 unchanged → `reducer_override` adds no measurable overhead when no large
tensor is present.
3. **Per-image stall** (CUDA-level all-GPU idle gap around the pixel H2D), vs the
in-band baseline: 192 MB image 1318 ms → 681 ms (arena only) → **351 ms class**
(arena + pinning); vision-encoder compute for the same image is ~80 ms.
4. **Outstanding — arena vs. out-of-band on `main`.** Re-run the same seeded A/B
with the baseline set to current `main` (i.e. `_reduce_tensor` on, arena off
via `--no-enable-shm-tensor-arena`) vs. arena on, to quantify the incremental N→1
copy + pinning gain at TP=4 (and ideally TP=2/8 to show the copy-count scaling).
## 5. Limitations and future work
- **Value is TP- and size-dependent.** The arena helps only when a queue has
≥2 node-local readers *and* carries tensors ≥ `MIN_MB`; at TP=1, or for small
payloads, it adds nothing and everything takes the out-of-band path.
- **Shared-memory reservation.** The arena reserves `slots × slot_bytes` of
`/dev/shm` (default 2 GB, lazily paged). Like `ShmRingBuffer`, creation is
guarded by a free-space check (`check_shm_free_space`), so an undersized
`/dev/shm` fails fast with a clear error instead of a `SIGBUS` the first
time a tensor is copied into pages beyond tmpfs capacity.
- **Relationship to `_reduce_tensor`.** This is strictly additive: the arena is
an opt-in fast path for the large-multimodal-tensor case; declining it (or
disabling via env) reverts to the merged out-of-band behavior.
- **Relationship to existing mm tensor shm caching.** vLLM already has a
separate shared-memory caching path for multimodal tensors; this arena is
not yet unified with it (see the experimental note in §1). Reconciling the
two is follow-up work.
- **Slot release granularity**: releases are tied to the slot's tensor
storage being freed and, on the pinned path, further gated on a per-slot
CUDA event recorded after the consuming H2D (§2.3), which closes both the
multi-step-retention and the async-DMA reuse windows. Bursts deeper than
the slot count safely fall back to the out-of-band path when the arena is
exhausted.
- **Fallback observability**: arena exhaustion (no free slot) is a
rate-limited log line today; a counter metric would be better. An
oversize tensor (bigger than a slot) falls back silently, with no log at
all -- worth adding if that path turns out to matter in practice.
- **Scope**: the arena activates only when every queue reader is node-local.
Remote readers (multi-node PP/TP) keep the existing socket path.
+519 -3
View File
@@ -1,6 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import dataclasses
import gc
import io
import pickle
@@ -17,7 +18,7 @@ import pytest
import torch
import torch.distributed as dist
from vllm.distributed.device_communicators import shm_broadcast
from vllm.distributed.device_communicators import shm_broadcast, shm_tensor_arena
from vllm.distributed.device_communicators.shm_broadcast import (
MessageQueue,
ShmRingBuffer,
@@ -25,6 +26,10 @@ from vllm.distributed.device_communicators.shm_broadcast import (
_reduce_tensor,
check_shm_free_space,
)
from vllm.distributed.device_communicators.shm_tensor_arena import (
ShmTensorArena,
_ArenaPickler,
)
from vllm.distributed.utils import StatelessProcessGroup
from vllm.utils.network_utils import get_open_port
from vllm.utils.system_utils import update_environment_variables
@@ -38,15 +43,18 @@ def get_arrays(n: int, seed: int = 0) -> list[np.ndarray]:
return [np.random.randint(1, 100, i) for i in sizes]
def distributed_run(fn, world_size, timeout=60):
def distributed_run(fn, world_size, timeout=60, mp_context=None):
"""Run a function in multiple processes with proper error handling.
Args:
fn: Function to run in each process
world_size: Number of processes to spawn
timeout: Maximum time in seconds to wait for processes (default: 60)
mp_context: Optional multiprocess context (e.g. ``mp.get_context("spawn")``)
for workers that must not inherit the parent's thread/CUDA state.
"""
ctx = mp_context or mp
number_of_processes = world_size
processes = []
for i in range(number_of_processes):
@@ -57,7 +65,7 @@ def distributed_run(fn, world_size, timeout=60):
env["LOCAL_WORLD_SIZE"] = str(number_of_processes)
env["MASTER_ADDR"] = "localhost"
env["MASTER_PORT"] = "12345"
p = mp.Process(target=fn, args=(env,))
p = ctx.Process(target=fn, args=(env,))
processes.append(p)
p.start()
@@ -765,6 +773,20 @@ def test_shm_ring_buffer_creation_checks_free_space():
ShmRingBuffer(n_reader=1, max_chunk_bytes=24 * 1024 * 1024, max_chunks=10)
def test_shm_tensor_arena_creation_checks_free_space():
"""Like ShmRingBuffer, arena creation must fail fast with a clear error
on an undersized /dev/shm instead of discovering the shortfall via a
SIGBUS the first time a tensor is copied into pages past tmpfs capacity."""
with (
mock.patch.object(
shm_broadcast.shutil, "disk_usage", return_value=_fake_disk_usage(1 << 20)
),
mock.patch.object(shm_broadcast.os.path, "isdir", return_value=True),
pytest.raises(RuntimeError, match="Insufficient space"),
):
ShmTensorArena(n_reader=1, slot_bytes=256 << 20, n_slots=8)
def test_remote_subscribe_addr_unique_concurrent_writers(
monkeypatch: pytest.MonkeyPatch,
):
@@ -828,3 +850,497 @@ def test_remote_subscribe_addr_unique_concurrent_writers(
for q in queues:
q.remote_socket.close(linger=0)
# --------------- ShmTensorArena (zero-copy tensor arena) tests ---------------
def _make_arena(n_reader: int = 2, slot_bytes: int = 4 << 20, n_slots: int = 3):
"""Writer arena plus attached per-reader arenas (same process)."""
writer = ShmTensorArena(n_reader, slot_bytes, n_slots)
readers = [ShmTensorArena(*writer.handle(), reader_rank=i) for i in range(n_reader)]
return writer, readers
def _get_view(reader: ShmTensorArena, idx: int, ref: torch.Tensor) -> torch.Tensor:
return reader.get_tensor(
idx, ref.numel() * ref.element_size(), ref.dtype, tuple(ref.shape)
)
def _drain(reader: ShmTensorArena) -> None:
"""Flush a reader's releases to completion. On the pinned path the first
flush defers the release behind an H2D-completion event; the second flush
retires it once the event has fired."""
reader.flush_releases()
if reader._deferred_releases:
torch.accelerator.synchronize()
reader.flush_releases()
assert not reader._pending_release
assert not reader._deferred_releases
def test_arena_zero_copy_roundtrip():
writer, (r0, r1) = _make_arena(n_reader=2)
src = torch.randn(400, 512)
idx = writer.write_tensor(src)
assert idx is not None
a = _get_view(r0, idx, src)
b = _get_view(r1, idx, src)
assert torch.equal(a, src)
assert torch.equal(b, src)
assert a.dtype == src.dtype and a.shape == src.shape
# Readers alias one shared mapping: a write through one reader's view is
# visible through the other's (this is what "zero-copy" means here).
a[0, 0] = 12345.0
assert b[0, 0].item() == 12345.0
del a, b
@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float8_e4m3fn])
def test_arena_dtype_roundtrip(dtype):
"""Dtypes numpy doesn't recognize traverse the arena's uint8 view path."""
writer, (reader,) = _make_arena(n_reader=1)
src = torch.randn(256, 256).to(dtype)
idx = writer.write_tensor(src)
assert idx is not None
got = _get_view(reader, idx, src)
assert got.dtype == dtype
assert torch.equal(got.view(torch.uint8), src.view(torch.uint8))
del got
def test_arena_slot_lifecycle():
"""A slot must not be reusable until EVERY reader has released it —
the writer overwriting a slot a reader still consumes would corrupt data.
Release is tied to the returned tensor's storage lifetime (`get_tensor`):
a reader that still holds its view keeps the slot reserved even across
repeated `flush_releases()` calls, not just until the "next" one."""
writer, readers = _make_arena(n_reader=2, n_slots=3)
t = torch.ones(1000)
first = writer.write_tensor(t)
views = [_get_view(r, first, t) for r in readers]
# Fill the remaining slots; the arena is now exhausted.
assert all(writer.write_tensor(t) is not None for _ in range(2))
assert writer.write_tensor(t) is None # exhausted -> caller falls back
# Flushing while both readers still hold their view releases nothing:
# the slot is only queued once the returned tensor's storage is freed.
readers[0].flush_releases()
readers[1].flush_releases()
assert writer.write_tensor(t) is None
# Dropping one reader's view queues its release; still not enough.
views[0] = None
_drain(readers[0])
assert writer.write_tensor(t) is None
# Once every reader has dropped (and released) its view, it's reused.
views[1] = None
_drain(readers[1])
assert writer.write_tensor(t) == first
def test_arena_slot_not_released_while_tensor_retained_across_multiple_flushes():
"""A caller retaining the tensor across many `flush_releases()` calls
(e.g. `prompt_embeds` across chunked-prefill steps) must keep its slot
reserved the whole time, not just until the reader's next flush."""
writer, (reader,) = _make_arena(n_reader=1, n_slots=2)
t = torch.ones(1000)
idx = writer.write_tensor(t)
retained = _get_view(reader, idx, t)
# Several flush cycles pass while the caller still holds `retained`.
for _ in range(5):
reader.flush_releases()
assert reader._pending_release == []
# The slot is still reserved: only the arena's other slot is available...
assert writer.write_tensor(t) is not None
assert writer.write_tensor(t) is None # ...and now the arena is exhausted.
# ...and the bytes the caller is reading are unchanged throughout.
assert torch.equal(retained, t)
# Only once the caller drops its reference is the slot released.
del retained
_drain(reader)
assert writer.write_tensor(t) == idx
def test_arena_slot_not_released_while_view_retained():
"""A view/slice (or wrapper) sharing the returned tensor's storage keeps
the slot reserved even after the original tensor object is dropped."""
writer, (reader,) = _make_arena(n_reader=1, n_slots=1)
t = torch.arange(1000, dtype=torch.float32)
idx = writer.write_tensor(t)
view = _get_view(reader, idx, t)[10:20].unsqueeze(0)
param = torch.nn.Parameter(view, requires_grad=False)
del view
reader.flush_releases()
assert reader._pending_release == []
assert writer.write_tensor(t) is None
assert torch.equal(param[0], t[10:20])
del param
assert reader._pending_release == [idx]
_drain(reader)
assert writer.write_tensor(t) == idx
def test_arena_release_survives_intervening_flush_from_another_tensor():
"""`flush_releases()` must clear `_pending_release` in place, never
rebind it: a finalizer registered before a rebind would append into the
now-orphaned old list and permanently leak its slot. Exercises the
interleaving a long-lived `prompt_embeds` tensor sees once a
shorter-lived tensor through the same reader triggers a flush first."""
writer, (reader,) = _make_arena(n_reader=1, n_slots=2)
t = torch.ones(1000)
# `a` is long-lived: its finalizer is registered first, while
# _pending_release is still the arena's original (empty) list object.
idx_a = writer.write_tensor(t)
a = _get_view(reader, idx_a, t)
# `b` is short-lived and its finalizer is registered against that SAME
# list object (nothing has flushed yet).
idx_b = writer.write_tensor(t)
b = _get_view(reader, idx_b, t)
del b
assert reader._pending_release == [idx_b]
# This flush must not orphan `a`'s still-pending finalizer. `_drain`,
# not a bare `flush_releases()` call: on the pinned fast path (real
# CUDA present) the release is gated behind an event and may need a
# second flush once it fires -- see `flush_releases`.
_drain(reader)
assert writer.write_tensor(t) == idx_b # b's slot correctly reused.
# `a` is dropped well after that intervening flush.
del a
assert reader._pending_release == [idx_a]
_drain(reader)
assert writer.write_tensor(t) == idx_a # a's slot must NOT be leaked.
def test_arena_oversize_falls_back():
writer, _ = _make_arena(n_reader=1, slot_bytes=1 << 20, n_slots=2)
big = torch.empty((1 << 20) + 4096, dtype=torch.uint8)
assert writer.write_tensor(big) is None
def _dumps_arena(obj, arena: ShmTensorArena) -> tuple[bytes, list, list[int]]:
"""Pickle `obj` the same way `MessageQueue.enqueue` does when an arena is
attached: arena diversion first (reducer_override), then the tensor
dispatch table, with out-of-band buffers >= 1MiB. Also returns the slot
indices the arena actually wrote to, in write order."""
buffers = []
def callback(buf: pickle.PickleBuffer) -> bool:
raw = buf.raw()
if raw.nbytes < 1024 * 1024:
return True
buffers.append(raw)
return False
written: list[int] = []
orig_write_tensor = arena.write_tensor
def _spy_write_tensor(t):
idx = orig_write_tensor(t)
if idx is not None:
written.append(idx)
return idx
bio = io.BytesIO()
pickler = _ArenaPickler(bio, arena, buffer_callback=callback)
pickler.dispatch_table = {torch.Tensor: _reduce_tensor}
with mock.patch.object(arena, "write_tensor", side_effect=_spy_write_tensor):
pickler.dump(obj)
return bio.getvalue(), buffers, written
def test_arena_pickler_composes(monkeypatch):
"""Large contiguous tensors are diverted into the arena; everything the
arena declines falls through to `_reduce_tensor` unchanged."""
writer, (reader,) = _make_arena(n_reader=1)
monkeypatch.setattr(shm_tensor_arena, "_ARENA_MIN_BYTES", 1 << 20)
monkeypatch.setitem(
shm_tensor_arena._TENSOR_ARENAS, reader.shared_memory.name, reader
)
big = torch.randn(1024, 1024) # 4MiB -> diverted into the arena
small = torch.randn(16, 16) # 1KiB -> falls through to _reduce_tensor
data, buffers, written = _dumps_arena({"big": big, "small": small}, writer)
# The diverted tensor's bytes are in the arena, not the pickle stream.
assert len(data) + sum(b.nbytes for b in buffers) < big.numel() * 4
out = pickle.loads(data, buffers=buffers)
assert torch.equal(out["big"], big)
assert torch.equal(out["small"], small)
# "big" is a zero-copy view of the reader's slot; "small" is not.
(idx,) = written
nbytes = big.numel() * big.element_size()
slot_ptr = torch.frombuffer(reader._slot(idx, nbytes), dtype=torch.uint8).data_ptr()
assert out["big"].data_ptr() == slot_ptr
assert out["small"].data_ptr() != slot_ptr
# Not yet queued for release: `out["big"]` is still alive (see get_tensor).
assert reader._pending_release == []
del out
assert reader._pending_release == [idx]
_drain(reader)
def test_arena_pickler_noncontig_falls_through(monkeypatch):
writer, (reader,) = _make_arena(n_reader=1)
monkeypatch.setattr(shm_tensor_arena, "_ARENA_MIN_BYTES", 1 << 20)
monkeypatch.setitem(
shm_tensor_arena._TENSOR_ARENAS, reader.shared_memory.name, reader
)
nc = torch.randn(2048, 1024)[:, ::2] # non-contiguous, above threshold
assert not nc.is_contiguous()
data, buffers, written = _dumps_arena(nc, writer)
out = pickle.loads(data, buffers=buffers)
assert torch.equal(out, nc)
# The arena never saw it: no slot consumed on the reader.
assert written == []
assert reader._pending_release == []
del out
@pytest.mark.parametrize("case", ["requires_grad", "conj"])
def test_arena_pickler_excludes_requires_grad_and_conj(monkeypatch, case: str):
"""Tensors `_reduce_tensor` excludes (requires_grad, or an exotic
conjugate-bit view that can't alias as uint8) must fall through the
arena the same way, even when every other criterion qualifies."""
writer, (reader,) = _make_arena(n_reader=1)
monkeypatch.setattr(shm_tensor_arena, "_ARENA_MIN_BYTES", 1 << 20)
monkeypatch.setitem(
shm_tensor_arena._TENSOR_ARENAS, reader.shared_memory.name, reader
)
if case == "requires_grad":
tensor = torch.randn(1024, 1024, requires_grad=True) # 4MiB, above threshold
else:
tensor = torch.randn(600, 600, dtype=torch.complex64).conj() # ~2.9MiB
assert tensor.is_contiguous()
assert tensor.numel() * tensor.element_size() >= shm_tensor_arena._ARENA_MIN_BYTES
data, buffers, written = _dumps_arena(tensor, writer)
# Declined by the arena -- no slot consumed -- and handled by
# _reduce_tensor's identical exclusion instead.
assert written == []
out = pickle.loads(data, buffers=buffers)
assert torch.equal(out, tensor)
assert out.requires_grad == tensor.requires_grad
def test_arena_pickler_excludes_tensor_subclasses(monkeypatch):
"""`torch.nn.Parameter` is diverted via its own `_rebuild_arena_parameter`
hook, which wraps an intermediate tensor it keeps no Python reference to.
The slot must stay reserved while the Parameter is alive -- the
`_pending_release` assertions below check this even though
`type(out) is torch.nn.Parameter` alone would not."""
writer, (reader,) = _make_arena(n_reader=1)
monkeypatch.setattr(shm_tensor_arena, "_ARENA_MIN_BYTES", 1 << 20)
monkeypatch.setitem(
shm_tensor_arena._TENSOR_ARENAS, reader.shared_memory.name, reader
)
# requires_grad=False to isolate the subclass check from the (already
# separately tested) requires_grad exclusion.
param = torch.nn.Parameter(torch.randn(1024, 1024), requires_grad=False)
assert type(param) is not torch.Tensor
assert param.is_contiguous()
assert param.numel() * param.element_size() >= shm_tensor_arena._ARENA_MIN_BYTES
data, buffers, written = _dumps_arena(param, writer)
# The Parameter itself is diverted directly (_ARENA_REBUILD_FNS routes
# it to _rebuild_arena_parameter) -- Parameter.__reduce_ex__ never runs,
# so there's no separate "inner" tensor diversion to speak of.
assert written != []
out = pickle.loads(data, buffers=buffers)
# The outer Parameter's type identity survives.
assert type(out) is torch.nn.Parameter
assert torch.equal(out, param)
assert out.requires_grad == param.requires_grad
# Not yet queued for release: `out` still shares the slot's storage.
assert reader._pending_release == []
del out
assert reader._pending_release == written
_drain(reader)
def test_arena_unregisters_pinned_memory_on_del(monkeypatch):
"""A reader that cudaHostRegister-ed its mapping must cudaHostUnregister
the same pointer before the mapping is closed; an unpinned arena must not
unregister memory it never registered."""
writer, (reader,) = _make_arena(n_reader=1)
cudart = mock.MagicMock()
cudart.cudaHostRegister.return_value = 0
cudart.cudaHostUnregister.return_value = 0
monkeypatch.setattr(
shm_tensor_arena,
"current_platform",
SimpleNamespace(is_cuda_alike=lambda: True, cudart=lambda: cudart),
)
reader._ensure_pinned()
assert reader._pinned
ptr = cudart.cudaHostRegister.call_args.args[0]
del reader
cudart.cudaHostUnregister.assert_called_once_with(ptr)
del writer # never pinned -> no unregister
cudart.cudaHostUnregister.assert_called_once()
@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs CUDA")
def test_arena_event_gated_release():
"""On the pinned path a slot release is gated on an H2D-completion CUDA
event: the writer must not see the reader's done flag until the async DMA
sourced from the slot has been retired. Release is first queued once the
caller drops the CPU-side view -- the realistic pattern: the view is only
needed as the H2D source, while the GPU tensor is what's retained for the
rest of the step."""
writer, (reader,) = _make_arena(n_reader=1)
reader._ensure_pinned()
if not reader._pinned:
pytest.skip("cudaHostRegister unavailable in this environment")
src = torch.randn(512, 1024)
idx = writer.write_tensor(src)
view = _get_view(reader, idx, src)
dev = view.to("cuda", non_blocking=True)
# Not yet queued: this test still holds `view`.
assert reader._pending_release == []
del view
assert reader._pending_release == [idx]
reader.flush_releases()
# Deferred behind the event, not applied eagerly.
assert len(reader._deferred_releases) == 1
with reader._meta(idx) as meta:
assert meta[1] == 0
torch.accelerator.synchronize()
reader.flush_releases()
assert reader._deferred_releases == []
with reader._meta(idx) as meta:
assert meta[1] == 1
assert torch.equal(dev.cpu(), src)
del dev
def test_tensor_arena_registry_is_weak_and_survives_shutdown():
"""`_TENSOR_ARENAS` must not keep an arena alive past its owning
MessageQueue, but also must not be cleared by `shutdown()` -- which can
run on a different thread than an in-flight `dequeue()` still
reconstructing a tensor from that arena. A weak-value registry gets
both: the entry survives `shutdown()` and vanishes only once
unreferenced."""
writer_mq = MessageQueue(n_reader=1, n_local_reader=1, enable_shm_tensor_arena=True)
handle = writer_mq.export_handle()
reader_mq = MessageQueue.create_from_handle(handle, rank=0)
name = reader_mq.tensor_arena.shared_memory.name
assert shm_tensor_arena._TENSOR_ARENAS[name] is reader_mq.tensor_arena
# shutdown() must NOT remove the entry: that would race with a
# concurrent dequeue() still reconstructing a tensor from this arena.
reader_mq.shutdown()
assert shm_tensor_arena._TENSOR_ARENAS[name] is reader_mq.tensor_arena
# Once nothing else references the arena, the registry entry vanishes
# on its own.
reader_mq.tensor_arena = None
assert name not in shm_tensor_arena._TENSOR_ARENAS
writer_mq.shutdown()
def test_create_from_handle_tolerates_arena_attach_failure():
"""If a reader's `ShmTensorArena.__init__` can't attach to the writer's
segment (`FileNotFoundError`, e.g. deserialized on a different node),
`create_from_handle` must not crash -- same deferred-failure convention
as the sibling `ShmRingBuffer` path."""
writer_mq = MessageQueue(n_reader=1, n_local_reader=1, enable_shm_tensor_arena=True)
handle = writer_mq.export_handle()
n_reader, slot_bytes, n_slots, _real_name = handle.tensor_arena_handle
bogus_handle = dataclasses.replace(
handle,
tensor_arena_handle=(
n_reader,
slot_bytes,
n_slots,
"vllm-arena-does-not-exist",
),
)
reader_mq = MessageQueue.create_from_handle(bogus_handle, rank=0)
# Attach failed silently: the arena object exists but never got its
# shared_memory attribute, and was never registered.
assert reader_mq.tensor_arena is not None
assert not hasattr(reader_mq.tensor_arena, "shared_memory")
assert "vllm-arena-does-not-exist" not in shm_tensor_arena._TENSOR_ARENAS
reader_mq.shutdown()
writer_mq.shutdown()
@worker_fn_wrapper
def worker_fn_arena_broadcast():
rank = dist.get_rank()
writer_rank = 0
if rank == writer_rank:
message_queue = MessageQueue(
1,
1,
local_reader_ranks=[1],
max_chunk_bytes=8 * 1024 * 1024,
enable_shm_tensor_arena=True,
)
handles = [message_queue.export_handle()]
dist.broadcast_object_list(handles, src=writer_rank)
else:
handles = [None]
dist.broadcast_object_list(handles, src=writer_rank)
message_queue = MessageQueue.create_from_handle(handles[0], rank)
message_queue.wait_until_ready()
torch.manual_seed(42)
payload = {
# 16MiB: above the arena divert threshold (8MiB) -> arena slot.
"huge": torch.randn(2048, 2048),
# 2MiB: declined by the arena -> out-of-band _reduce_tensor path.
"mid": torch.randn(1024, 512),
}
if rank == writer_rank:
assert message_queue.tensor_arena is not None
with mock.patch(
"vllm.distributed.device_communicators.shm_broadcast._reduce_tensor",
wraps=_reduce_tensor,
) as wrapped_reduce:
message_queue.enqueue(payload)
# "huge" was diverted into the arena before the dispatch table was
# consulted; only "mid" went through _reduce_tensor.
assert wrapped_reduce.call_count == 1
else:
received = message_queue.dequeue(timeout=30)
assert torch.equal(received["huge"], payload["huge"])
assert torch.equal(received["mid"], payload["mid"])
# The huge tensor is a zero-copy view into the arena's shared memory
# (not a copy received over the transport). Its slot is not yet
# queued for release while `received` still holds the reference.
arena = message_queue.tensor_arena
assert arena is not None
assert arena._pending_release == []
huge_ptr = received["huge"].data_ptr()
arena_base = torch.frombuffer(
arena.shared_memory.buf, dtype=torch.uint8
).data_ptr()
assert arena_base <= huge_ptr < arena_base + arena.total_bytes
# Dropping the last reference to the slot's storage queues it for
# release (see get_tensor).
del received
assert len(arena._pending_release) == 1
dist.barrier()
print(f"arena broadcast passed the test! Rank {rank}")
def test_arena_broadcast():
# Spawn (not fork): by the time this test runs, the pytest process may be
# multi-threaded / CUDA-initialized (earlier GPU tests), and a forked
# child running the arena's slot memcpy can deadlock on inherited lock
# state. Spawned workers start clean; the larger timeout absorbs their
# interpreter + import startup.
distributed_run(
worker_fn_arena_broadcast,
2,
timeout=180,
mp_context=mp.get_context("spawn"),
)
@@ -56,6 +56,7 @@ CHECK_IMPORTS = {
"vllm/distributed/device_communicators/all_reduce_utils.py",
"vllm/distributed/device_communicators/shm_broadcast.py",
"vllm/distributed/device_communicators/shm_object_storage.py",
"vllm/distributed/device_communicators/shm_tensor_arena.py",
"vllm/distributed/weight_transfer/ipc_engine.py",
"vllm/distributed/weight_transfer/clients.py",
"vllm/model_executor/model_loader/weight_cache/protocol.py",
+10
View File
@@ -235,6 +235,15 @@ class ParallelConfig:
disable_custom_all_reduce: bool = False
"""Disable the custom all-reduce kernel and fall back to NCCL."""
enable_shm_tensor_arena: bool = False
"""Route large CPU tensors (e.g. multimodal ``pixel_values``) in the
engine→worker broadcast through a zero-copy shared-memory arena instead of
sending a copy to every reader. **Experimental**: may be reconciled with
vLLM's existing multimodal shm tensor caching in a future release. Opt-in;
most beneficial for multimodal serving with large images and tensor
parallelism. Active only when all queue readers are node-local; reserves
slots in ``/dev/shm``."""
enable_elastic_ep: bool = False
"""Enable elastic expert parallelism with stateless NCCL groups for DP/EP."""
elastic_ep_max_dp_size: int = Field(default=None, ge=1) # type: ignore[assignment]
@@ -901,6 +910,7 @@ class ParallelConfig:
"nnodes",
"max_parallel_loading_workers",
"disable_custom_all_reduce",
"enable_shm_tensor_arena",
"ray_workers_use_nsight",
"ray_runtime_env",
"placement_group",
@@ -61,6 +61,10 @@ SPINLOOP_TIMEOUT_SECONDS = 0.1
if TYPE_CHECKING:
from _typeshed import SizedBuffer
from vllm.distributed.device_communicators.shm_tensor_arena import (
ShmTensorArena,
)
VLLM_RINGBUFFER_WARNING_INTERVAL = envs.VLLM_RINGBUFFER_WARNING_INTERVAL
# Cap on how long an idle reader parks before re-reading the authoritative SHM
# written-flag. Bounds lost-notify recovery latency to ~5s while the periodic
@@ -471,6 +475,7 @@ class Handle:
local_notify_addr: str | None = None
remote_subscribe_addr: str | None = None
remote_addr_ipv6: bool = False
tensor_arena_handle: tuple[int, int, int, str] | None = None
def _close_zmq(context: zmq.Context, sockets: list[zmq.Socket]) -> None:
@@ -490,6 +495,7 @@ class MessageQueue:
max_chunk_bytes: int = 1024 * 1024 * 24,
max_chunks: int = 10,
connect_ip: str | None = None,
enable_shm_tensor_arena: bool = False,
):
if local_reader_ranks is None:
local_reader_ranks = list(range(n_local_reader))
@@ -501,12 +507,22 @@ class MessageQueue:
self.shutting_down = False
context = Context()
self.tensor_arena: ShmTensorArena | None = None
if n_local_reader > 0:
# for local readers, we will:
# 1. create a shared memory ring buffer to communicate small data
# 2. create a publish-subscribe socket to communicate large data
self.buffer = ShmRingBuffer(n_local_reader, max_chunk_bytes, max_chunks)
# Zero-copy arena for large CPU tensors (see ShmTensorArena).
# Local readers only: remote readers receive the pickled bytes
# over a socket and cannot map the arena, so the substitution
# would break them.
if enable_shm_tensor_arena and n_remote_reader == 0:
from vllm.distributed.device_communicators import shm_tensor_arena
self.tensor_arena = shm_tensor_arena.ShmTensorArena(n_local_reader)
# XPUB is very similar to PUB,
# except that it can receive subscription messages
# to confirm the number of subscribers
@@ -567,6 +583,9 @@ class MessageQueue:
local_notify_addr=local_notify_addr,
remote_subscribe_addr=remote_subscribe_addr,
remote_addr_ipv6=remote_addr_ipv6,
tensor_arena_handle=(
self.tensor_arena.handle() if self.tensor_arena is not None else None
),
)
self._close_zmq_on_collect(context)
@@ -599,6 +618,7 @@ class MessageQueue:
context = Context()
self.tensor_arena = None
if rank in handle.local_reader_ranks:
assert handle.buffer_handle is not None
self.buffer = ShmRingBuffer(*handle.buffer_handle)
@@ -607,6 +627,23 @@ class MessageQueue:
self._is_local_reader = True
self._is_remote_reader = False
arena_handle = handle.tensor_arena_handle
if arena_handle is not None:
from vllm.distributed.device_communicators import shm_tensor_arena
self.tensor_arena = shm_tensor_arena.ShmTensorArena(
*arena_handle, reader_rank=self.local_reader_rank
)
# `shared_memory` is unset (not just None) if attaching to
# the writer's segment raised FileNotFoundError -- see
# ShmTensorArena.__init__'s reader branch. Registering a
# broken arena is a no-op deferred failure (same convention
# ShmRingBuffer uses above), not a crash here.
if hasattr(self.tensor_arena, "shared_memory"):
shm_tensor_arena._TENSOR_ARENAS[
self.tensor_arena.shared_memory.name
] = self.tensor_arena
self.local_socket = context.socket(SUB)
self.local_socket.setsockopt_string(SUBSCRIBE, "")
socket_addr = handle.local_subscribe_addr
@@ -880,12 +917,21 @@ class MessageQueue:
# dispatch table would otherwise shadow them.
dispatch_table = dict(copyreg.dispatch_table)
dispatch_table[torch.Tensor] = _reduce_tensor
arena = self.tensor_arena
with io.BytesIO() as bio:
pickler = pickle.Pickler(
bio,
protocol=pickle.HIGHEST_PROTOCOL,
buffer_callback=oob_callback,
)
pickler: pickle.Pickler
if arena is not None:
from vllm.distributed.device_communicators.shm_tensor_arena import (
_ArenaPickler,
)
pickler = _ArenaPickler(bio, arena, buffer_callback=oob_callback)
else:
pickler = pickle.Pickler(
bio,
protocol=pickle.HIGHEST_PROTOCOL,
buffer_callback=oob_callback,
)
pickler.dispatch_table = dispatch_table
pickler.dump(obj)
all_buffers[0] = bio.getvalue()
@@ -922,6 +968,9 @@ class MessageQueue:
):
"""Read from message queue with optional timeout (in seconds)"""
if self._is_local_reader:
# See ShmTensorArena.flush_releases for what this retires and why.
if self.tensor_arena is not None:
self.tensor_arena.flush_releases()
with self.acquire_read(timeout, indefinite) as buf:
overflow = buf[0] == 1
if not overflow:
@@ -0,0 +1,397 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Zero-copy shared-memory arena for large CPU tensors (multimodal fast path).
Problem: `MessageQueue.enqueue` (see `shm_broadcast.py`) pickles the whole
payload, and torch's default tensor pickling copies bytes into the pickle
stream on the writer and back out on every local reader. For a large
`pixel_values` tensor (up to hundreds of MB) this blocks the EngineCore step
loop for ~1s with all GPUs idle.
Fast path: `_ArenaPickler.reducer_override` intercepts qualifying CPU
tensors during pickling, memcpys them ONCE into a free arena slot, and
pickles a tiny `(arena_name, slot, nbytes, dtype, shape)` stub. Readers
rebuild a zero-copy view of the mapped slot (`torch.frombuffer`).
Slot lifecycle: single writer, n_reader readers, per-slot metadata
[written_flag, reader0_done, ..., readerN_done] (same protocol as
ShmRingBuffer). A slot is not released when rebuilt: the rebuilt tensor is
the SOURCE of an async H2D copy, and callers like chunked-prefill
`prompt_embeds` retain it across many steps. Release is queued only once the
tensor's storage is freed, i.e. once no tensor or view of the slot remains
(see `get_tensor`), and is further gated on a CUDA event on the pinned fast
path, since the H2D can outlive the step that issued it (see
`flush_releases`).
The writer NEVER blocks: no free slot (or an oversized tensor) falls back to
the default pickle path, so worst case matches today's behavior.
Physical memory: slots are paged in lazily, so an arena on a queue that
never carries big tensors costs ~0. Creation is guarded by
`check_shm_free_space` (like `ShmRingBuffer`) so an undersized `/dev/shm`
fails fast instead of a `SIGBUS` mid-copy.
"""
import pickle
import weakref
from contextlib import suppress
from multiprocessing import shared_memory
from unittest.mock import patch
import numpy as np
import torch
from vllm.distributed.device_communicators.shm_broadcast import (
check_shm_free_space,
memory_fence,
)
from vllm.logger import init_logger
from vllm.platforms import current_platform
logger = init_logger(__name__)
# Arena sizing — internal constants (kept off the env/CLI surface).
_ARENA_SLOTS = 8
_ARENA_SLOT_BYTES = 256 * 1024 * 1024
# Minimum contiguous CPU-tensor size to divert into the arena; smaller tensors
# take the out-of-band pickle-buffer path.
_ARENA_MIN_BYTES = 8 * 1024 * 1024
# Reader-side registry: arena shm name -> attached ShmTensorArena, keyed by
# name rather than a single slot since a process isn't guaranteed to attach
# to only one. Populated by `MessageQueue.create_from_handle`; consumed by
# the `_rebuild_arena_*` unpickle hooks. WEAK-value: a strong reference here
# would keep the arena (and its pinned mapping) alive for the process
# lifetime, and would need `MessageQueue.shutdown` to remove it explicitly --
# unsafe since shutdown() can run on a different thread than an in-flight
# `dequeue`. Instead an entry just disappears once nothing else references
# that arena.
_TENSOR_ARENAS: weakref.WeakValueDictionary[str, "ShmTensorArena"] = (
weakref.WeakValueDictionary()
)
class ShmTensorArena:
"""Slotted shared-memory arena: one writer, n_reader zero-copy readers.
Memory layout: [slot0 | slot1 | ... | slotN-1 | meta0 | meta1 | ... ]
where each meta is (1 + n_reader) bytes: [written_flag, reader_done...].
Slot states mirror ShmRingBuffer:
written=0 -> free (never written / being written)
written=1, some reader_done=0 -> in use, cannot reuse
written=1, all reader_done=1 -> consumed, can reuse
"""
def __init__(
self,
n_reader: int,
slot_bytes: int = _ARENA_SLOT_BYTES,
n_slots: int = _ARENA_SLOTS,
name: str | None = None,
reader_rank: int = -1,
):
self.n_reader = n_reader
self.slot_bytes = slot_bytes
self.n_slots = n_slots
self.reader_rank = reader_rank # -1 for the writer
self.metadata_size = 1 + n_reader
self.metadata_offset = slot_bytes * n_slots
self.total_bytes = (slot_bytes + self.metadata_size) * n_slots
self._next_slot = 0
self._fallbacks = 0
self._pin_attempted = False
self._pinned = False
self._pinned_ptr = 0
# slots queued for release once the storage of the tensor `get_tensor`
# handed out is freed; flushed at the next dequeue.
self._pending_release: list[int] = []
# slots whose release is gated on an H2D-completion CUDA event: each
# entry is (event, [slot_idx, ...]). Only populated on the pinned fast
# path, where the source-side DMA is asynchronous (see flush_releases).
self._deferred_releases: list[tuple[torch.cuda.Event, list[int]]] = []
if name is None:
self.is_creator = True
# Same guard as ShmRingBuffer: SharedMemory/ftruncate can succeed
# on an undersized /dev/shm, so check up front instead of SIGBUS
# on first write past tmpfs capacity.
check_shm_free_space(self.total_bytes, allocation_name="ShmTensorArena")
self.shared_memory = shared_memory.SharedMemory(
create=True, size=self.total_bytes
)
assert self.shared_memory.buf is not None
with self.shared_memory.buf[self.metadata_offset :] as meta:
torch.frombuffer(meta, dtype=torch.uint8).fill_(0)
else:
self.is_creator = False
# same resource_tracker workaround as ShmRingBuffer
with patch(
"multiprocessing.resource_tracker.register",
lambda *args, **kwargs: None,
):
try:
self.shared_memory = shared_memory.SharedMemory(name=name)
assert self.shared_memory.size >= self.total_bytes
except FileNotFoundError:
# deserialized on a different node; arena unused there
pass
def handle(self):
return (self.n_reader, self.slot_bytes, self.n_slots, self.shared_memory.name)
def _meta(self, idx: int) -> memoryview:
start = self.metadata_offset + idx * self.metadata_size
assert self.shared_memory.buf is not None
return self.shared_memory.buf[start : start + self.metadata_size]
def _slot(self, idx: int, nbytes: int) -> memoryview:
start = idx * self.slot_bytes
assert self.shared_memory.buf is not None
return self.shared_memory.buf[start : start + nbytes]
# ---- writer side ----
def write_tensor(self, t: torch.Tensor) -> int | None:
"""Copy tensor bytes into a free slot; return slot idx or None
(caller must then fall back to the default pickle path)."""
nbytes = t.numel() * t.element_size()
if nbytes > self.slot_bytes:
return None
try:
# Computed before claiming a slot: exotic tensors (e.g. conjugate
# bit set) raise RuntimeError here, same as `_reduce_tensor`'s
# identical view -- fail before claiming, not after.
src = t.detach().reshape(-1).view(torch.uint8)
except RuntimeError:
return None
memory_fence()
for probe in range(self.n_slots):
idx = (self._next_slot + probe) % self.n_slots
with self._meta(idx) as meta:
free = meta[0] == 0 or sum(meta[1:]) == self.n_reader
if not free:
continue
meta[0] = 0 # claim
slot_mv = self._slot(idx, nbytes)
try:
dst = torch.frombuffer(slot_mv, dtype=torch.uint8, count=nbytes)
dst.copy_(src)
finally:
slot_mv.release()
with self._meta(idx) as meta:
for i in range(1, self.n_reader + 1):
meta[i] = 0
memory_fence()
meta[0] = 1
memory_fence()
self._next_slot = (idx + 1) % self.n_slots
return idx
self._fallbacks += 1
if self._fallbacks == 1 or self._fallbacks % 100 == 0:
logger.info(
"ShmTensorArena: no free slot (%d bytes, %d fallbacks so far); "
"falling back to the out-of-band pickle path.",
nbytes,
self._fallbacks,
)
return None
# ---- reader side ----
def _ensure_pinned(self):
"""Pin the whole arena mapping via cudaHostRegister in THIS process
(lazy, once). Without it the HtoD of a zero-copy tensor pays
first-touch page faults on the tmpfs mapping plus pageable staging
(~hundreds of ms for 200MB); registration allocates+pins the pages
once, making every later HtoD a true DMA. Failure (no CUDA in this
process, etc.) is fine — the copy still works, just slower."""
if self._pin_attempted:
return
self._pin_attempted = True
try:
if not current_platform.is_cuda_alike():
return
import ctypes
buf = self.shared_memory.buf
assert buf is not None
ptr = ctypes.addressof(ctypes.c_char.from_buffer(buf))
ret = current_platform.cudart().cudaHostRegister(ptr, self.total_bytes, 0)
self._pinned = int(ret) == 0
if self._pinned:
self._pinned_ptr = ptr
logger.info(
"ShmTensorArena: cudaHostRegister(%d MB) -> %s",
self.total_bytes >> 20,
"pinned" if self._pinned else f"error {int(ret)}",
)
except Exception as e:
logger.info("ShmTensorArena: host-register skipped: %s", e)
def _unpin(self):
"""Undo the pin from _ensure_pinned via cudaHostUnregister; must run
before the mapping is closed. Failures are ignored — at interpreter
shutdown the CUDA context may already be gone, and the registration
dies with the process anyway."""
if not self._pinned:
return
self._pinned = False
with suppress(Exception):
current_platform.cudart().cudaHostUnregister(self._pinned_ptr)
def get_tensor(
self, idx: int, nbytes: int, dtype: torch.dtype, shape: tuple[int, ...]
) -> torch.Tensor:
"""Zero-copy view of a slot as a tensor. The slot is queued for
release once the returned tensor's storage is freed, i.e. once the
caller has dropped it along with any views/slices or wrappers (such
as `torch.nn.Parameter`) sharing that storage."""
self._ensure_pinned()
# The tensor's storage holds the only reference to `holder` and drops
# it when freed, firing the finalizer. (`weakref.finalize` can't
# target the tensor itself, which views and wrappers don't keep alive,
# nor the memoryview, which doesn't support weakrefs.)
holder = np.frombuffer(self._slot(idx, nbytes), dtype=np.uint8)
# Binds to the current list OBJECT: `flush_releases` must mutate it in
# place, never rebind `self._pending_release`.
weakref.finalize(holder, self._pending_release.append, idx)
return torch.from_numpy(holder).view(dtype).view(shape)
def _mark_released(self, idxs: list[int]):
"""Set THIS reader's done flag on the given slots; a slot becomes
reusable once every reader has done so."""
for idx in idxs:
with self._meta(idx) as meta:
meta[1 + self.reader_rank] = 1
memory_fence()
def _record_release_event(self):
"""Record a CUDA event on the current (compute) stream, or return None
if this process has no usable CUDA context. Called from flush_releases,
which runs after the previous step's H2D was enqueued on that same
stream, so the event is ordered strictly after that H2D."""
try:
event = current_platform.Event()
event.record()
return event
except Exception:
return None
def flush_releases(self):
"""Retire this reader's arena slots that are ready for writer reuse.
Called on every `MessageQueue.dequeue`. `_pending_release` holds
slots whose tensor storage was just freed (see `get_tensor`). On
the pinned fast path that tensor was the source of an async
`non_blocking=True` H2D that can outlive the collection, so instead
of marking released immediately we record a CUDA event (ordered
after that H2D on the same stream) and move the slot to
`_deferred_releases` until `event.query()` says it's done -- a
not-yet-complete slot just waits one more `dequeue`. Unpinned (or no
CUDA context), the H2D already staged synchronously, so the slot is
safe to release right away.
A slot only becomes free for the writer (`write_tensor`) once every
reader has marked it released (`_mark_released`).
"""
if self._deferred_releases:
still_pending = []
for deferred_event, idxs in self._deferred_releases:
if deferred_event.query():
self._mark_released(idxs)
else:
still_pending.append((deferred_event, idxs))
self._deferred_releases = still_pending
if not self._pending_release:
return
# Drain in place with atomic pops, never rebind (see get_tensor): a
# finalizer may append from another thread concurrently.
idxs = []
while self._pending_release:
idxs.append(self._pending_release.pop())
event = self._record_release_event() if self._pinned else None
if event is None:
self._mark_released(idxs)
else:
self._deferred_releases.append((event, idxs))
def __del__(self):
if hasattr(self, "shared_memory"):
self._unpin()
try:
self.shared_memory.close()
if self.is_creator:
self.shared_memory.unlink()
except BufferError:
# zero-copy tensor views may still hold exported pointers at
# interpreter shutdown; the mapping dies with the process.
pass
def _rebuild_arena_tensor(arena_name, slot_idx, nbytes, dtype_str, shape):
"""Unpickle hook: rebuild a tensor as a zero-copy view of an arena slot."""
arena = _TENSOR_ARENAS[arena_name]
return arena.get_tensor(slot_idx, nbytes, getattr(torch, dtype_str), shape)
def _rebuild_arena_parameter(arena_name, slot_idx, nbytes, dtype_str, shape):
"""Unpickle hook for `torch.nn.Parameter` (shares the slot's storage)."""
t = _rebuild_arena_tensor(arena_name, slot_idx, nbytes, dtype_str, shape)
return torch.nn.Parameter(t, requires_grad=False)
# Types `_ArenaPickler.reducer_override` diverts, mapped to the rebuild hook
# that reconstructs them. Any other type -- including other `torch.Tensor`
# subclasses -- falls through to the normal pickle path, since an arbitrary
# subclass can't be faithfully rebuilt from just the slot's bytes.
_ARENA_REBUILD_FNS = {
torch.Tensor: _rebuild_arena_tensor,
torch.nn.Parameter: _rebuild_arena_parameter,
}
class _ArenaPickler(pickle.Pickler):
"""Pickler that diverts large contiguous CPU tensors into the arena.
`reducer_override` is consulted before an object's normal reduction. For
a qualifying tensor (exact type in `_ARENA_REBUILD_FNS`, CPU, strided,
contiguous, not `requires_grad`, above the divert-size threshold -- the
same criteria `_reduce_tensor` uses, plus size) it does one memcpy into a
free slot and returns a tiny rebuild stub. Anything that doesn't qualify,
or finds the arena full, returns `NotImplemented` and falls through to
the normal out-of-band pickle path, exactly as if no arena were attached.
"""
def __init__(self, file, arena: ShmTensorArena, buffer_callback=None):
super().__init__(
file,
protocol=pickle.HIGHEST_PROTOCOL,
buffer_callback=buffer_callback,
)
self.arena = arena
def reducer_override(self, obj):
rebuild_fn = _ARENA_REBUILD_FNS.get(type(obj))
if (
rebuild_fn is not None
and obj.device.type == "cpu"
and obj.layout == torch.strided
and obj.is_contiguous()
and not obj.requires_grad
and obj.numel() * obj.element_size() >= _ARENA_MIN_BYTES
):
idx = self.arena.write_tensor(obj)
if idx is not None:
return (
rebuild_fn,
(
self.arena.shared_memory.name,
idx,
obj.numel() * obj.element_size(),
str(obj.dtype).removeprefix("torch."),
tuple(obj.shape),
),
)
return NotImplemented
+6
View File
@@ -622,6 +622,7 @@ class EngineArgs:
allow_deprecated_quantization: bool = ModelConfig.allow_deprecated_quantization
enforce_eager: bool = ModelConfig.enforce_eager
disable_custom_all_reduce: bool = ParallelConfig.disable_custom_all_reduce
enable_shm_tensor_arena: bool = ParallelConfig.enable_shm_tensor_arena
language_model_only: bool = MultiModalConfig.language_model_only
limit_mm_per_prompt: dict[str, int | dict[str, int]] = get_field(
MultiModalConfig, "limit_per_prompt"
@@ -1274,6 +1275,10 @@ class EngineArgs:
"--all2all-backend", **parallel_kwargs["all2all_backend"]
)
parallel_group.add_argument("--enable-dbo", **parallel_kwargs["enable_dbo"])
parallel_group.add_argument(
"--enable-shm-tensor-arena",
**parallel_kwargs["enable_shm_tensor_arena"],
)
parallel_group.add_argument(
"--ubatch-size",
**parallel_kwargs["ubatch_size"],
@@ -2534,6 +2539,7 @@ class EngineArgs:
enable_elastic_ep=self.enable_elastic_ep,
elastic_ep_max_dp_size=self.elastic_ep_max_dp_size,
enable_dbo=self.enable_dbo,
enable_shm_tensor_arena=self.enable_shm_tensor_arena,
ubatch_size=self.ubatch_size,
dbo_decode_token_threshold=self.dbo_decode_token_threshold,
dp_sync_interval=self.dp_sync_interval,
+1
View File
@@ -166,6 +166,7 @@ class MultiprocExecutor(Executor):
self.local_world_size,
max_chunk_bytes=max_chunk_bytes,
connect_ip=mq_connect_ip,
enable_shm_tensor_arena=(self.parallel_config.enable_shm_tensor_arena),
)
scheduler_output_handle = self.rpc_broadcast_mq.export_handle()
# Create workers