Bolins/shm tensor arena - #51207
Conversation
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban. 🚀 |
|
Documentation preview: https://vllm--51207.org.readthedocs.build/en/51207/ |
vLLM these days probably prefer a CLI flag instead. |
| try: | ||
| if not torch.cuda.is_available(): | ||
| return | ||
| import ctypes | ||
|
|
||
| buf = self.shared_memory.buf | ||
| assert buf is not None | ||
| ptr = ctypes.addressof(ctypes.c_char.from_buffer(buf)) | ||
| ret = torch.cuda.cudart().cudaHostRegister(ptr, self.total_bytes, 0) | ||
| self._pinned = int(ret) == 0 | ||
| 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) |
There was a problem hiding this comment.
Let's avoid using torch.cuda through current_platform and torch.acceleractor API instead?
There was a problem hiding this comment.
Done — switched to current_platform (is_cuda_alike() gate + current_platform.cudart() / .Event()), which resolves across CUDA/ROCm; the inherently-CUDA/ROCm cudaHostRegister stays behind the is_cuda_alike() guard, matching the existing pin_mmap_region pattern
| VLLM_SHM_TENSOR_ARENA = os.getenv("VLLM_SHM_TENSOR_ARENA", "1") != "0" | ||
| VLLM_SHM_TENSOR_ARENA_SLOTS = int(os.getenv("VLLM_SHM_TENSOR_ARENA_SLOTS", "8")) | ||
| VLLM_SHM_TENSOR_ARENA_SLOT_MB = int(os.getenv("VLLM_SHM_TENSOR_ARENA_SLOT_MB", "256")) | ||
| VLLM_SHM_TENSOR_ARENA_MIN_MB = int(os.getenv("VLLM_SHM_TENSOR_ARENA_MIN_MB", "8")) |
There was a problem hiding this comment.
Yea, we're avoiding increasing the num of env variables in vLLM. 😅
There was a problem hiding this comment.
Done — the slot count/size and divert threshold are now internal module constants (_ARENA_SLOTS/_ARENA_SLOT_BYTES/_ARENA_MIN_BYTES), so no new env vars are added; the only user-facing knob is the single --enable-shm-tensor-arena / --no-enable-shm-tensor-arena CLI flag (ParallelConfig.enable_shm_tensor_arena, default on)
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: effa08b4f2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| self.shared_memory = shared_memory.SharedMemory( | ||
| create=True, size=self.total_bytes | ||
| ) |
There was a problem hiding this comment.
Check shared-memory capacity before creating the arena
When the default-on arena is created, it reserves 8 × 256 MiB without calling the existing check_shm_free_space guard. On containers whose /dev/shm is smaller than 2 GiB, SharedMemory/ftruncate can initially succeed, but copying a large tensor into pages beyond the tmpfs capacity can terminate the process with SIGBUS; the ring buffer avoids this failure by checking capacity before allocation. Check the arena's total_bytes before creating it, including the space already reserved by the ring buffer.
Useful? React with 👍 / 👎.
| slot_mv = self._slot(idx, nbytes) | ||
| t8 = torch.frombuffer(slot_mv, dtype=torch.uint8, count=nbytes) | ||
| t = t8.view(dtype).view(shape) | ||
| self._pending_release.append(idx) |
There was a problem hiding this comment.
Keep slots reserved while returned tensors remain live
For any caller that retains a large CPU tensor after requesting the next message—for example, an RPC method that caches its input—the next dequeue() moves this slot toward release regardless of whether the returned tensor is still referenced. Once every reader does so, the writer can reuse the slot and silently mutate the previously returned tensor; the CUDA event only protects an assumed H2D copy, not subsequent CPU access or object lifetime. The prior out-of-band path kept its frame alive through the tensor's storage, so slot release likewise needs to be tied to the returned view's lifetime or an explicit consumption acknowledgement.
Useful? React with 👍 / 👎.
| isinstance(obj, torch.Tensor) | ||
| and obj.device.type == "cpu" | ||
| and obj.layout == torch.strided | ||
| and obj.is_contiguous() | ||
| and obj.numel() * obj.element_size() | ||
| >= VLLM_SHM_TENSOR_ARENA_MIN_MB * 1024 * 1024 |
There was a problem hiding this comment.
Preserve autograd metadata on arena tensors
When a contiguous CPU tensor at least MIN_MB has requires_grad=True, this override now selects the arena even though _reduce_tensor deliberately excludes such tensors and delegates to PyTorch's normal reducer. write_tensor() detaches the source and _rebuild_arena_tensor() constructs a fresh tensor with requires_grad=False, so the round trip silently drops its autograd state. Add the same not obj.requires_grad restriction here or explicitly reconstruct the metadata.
Useful? React with 👍 / 👎.
| _TENSOR_ARENAS[self.tensor_arena.shared_memory.name] = ( | ||
| self.tensor_arena | ||
| ) |
There was a problem hiding this comment.
Clean up registered reader arenas
In a long-lived process that creates and destroys multiple reader queues, this module-level dictionary holds every attached arena strongly forever. MessageQueue.shutdown() neither removes the entry nor calls cudaHostUnregister, so each queue can retain a 2 GiB mapping and its CUDA host registration until process exit, eventually exhausting virtual address or pinned-memory limits during executor recreation. Add an explicit arena cleanup path that unregisters pinned memory, removes the matching registry entry, and closes the mapping.
Useful? React with 👍 / 👎.
|
Thanks @BolinSNLHM! I had thought about doing something like this before. But the numbers don't look super convincing to me, not sure it would justify the extra complexity / configuratin burden. It would be good to have some more comprehensive benchmarks testing realistic workloads with large images and varying TP sizes, and importantly comparing against current main which already has the oob tensor pickling optimization. |
It does have a big E2E impact (like ~1.5x) for maximizing the QPS under a ultra-low P99 latency bound scenario in the MLPerf Inference VLM (Qwen3-VL) benchmark with a PD disagg topology. Unfortunately, MLPerf v6.1 results are still under embargo so we can't post it here.
As with all advanced perf optimization techniques, users are free to choose to not use them.
This is a fair request but which "oob tensor pickling optimization" exactly are you refer to? (So that @BolinSNLHM would choose the intended baseline correctly) |
|
Thanks @wangshangsam
Could just use the latest main branch? The optimization I'm referring to in particular is #48442, which was merged 2 weeks ago. |
…Queue Large multimodal pixel_values tensors (100-200MB) were pickle-serialized into the broadcast payload on the engine (THPStorage_writeFileRaw) and deserialized on every local TP reader (THPStorage_readFileRaw), blocking the EngineCore step loop ~1s per large image with all GPUs idle. Add ShmTensorArena: a slotted shared-memory region (per-slot reader flags, same protocol as ShmRingBuffer). A Pickler.reducer_override diverts large contiguous CPU tensors into a free slot (single memcpy) and pickles only a (slot, nbytes, dtype, shape) stub; readers rebuild the tensor as a zero-copy torch.frombuffer view of the mapped slot. Slots are released lazily at the reader's next dequeue (worker loop is sequential, so the previous step's HtoD has completed). The writer never blocks: no free slot or oversize tensor falls back to the default in-band pickling. Env knobs: VLLM_SHM_TENSOR_ARENA (default on), _SLOTS (8), _SLOT_MB (256), _MIN_MB (8). Local readers only; disabled when remote readers exist. Unit test: 199MB bf16 roundtrip to 2 forked readers = 66.7ms enqueue (vs ~1275ms pickled), checksums exact, slot reuse + fallback verified. Signed-off-by: Bolin Sun <bolins@nvidia.com>
The zero-copy tensor views the tmpfs mapping; without pinning, the HtoD pays first-touch page faults + pageable staging (~600-800ms observed for a 192MB image). Lazy one-time cudaHostRegister at first get_tensor makes every later HtoD a true DMA. Falls back gracefully when the process has no CUDA context. Signed-off-by: Bolin Sun <bolins@nvidia.com>
Documents the multimodal engine->worker transport bottleneck (in-band pickle of large pixel_values tensors in MessageQueue.enqueue, per-rank deserialize, engine-loop head-of-line blocking) and the ShmTensorArena zero-copy design that removes it, including the pinning rationale, configuration knobs, validation results, and known limitations. Signed-off-by: Bolin Sun <bolins@nvidia.com>
Slots were released at the reader's next dequeue on the premise that the consuming HtoD had completed by then. That holds only for a pageable source (cudaMemcpyAsync stages synchronously before returning). But readers cudaHostRegister-pin the arena, making the HtoD a true async DMA, and --async-scheduling removes the sampler's covering device sync -- so the DMA can still be reading a slot when the writer reclaims and overwrites it. Masked in practice by the 8-slot round-robin depth, but a latent data race on the deployed path. flush_releases now records a CUDA event on the compute stream (ordered after the previous step's HtoD) and frees a slot only once event.query() reports the DMA complete; a slot not yet done waits one more dequeue. Unpinned / no-CUDA readers release immediately as before (the pageable copy already staged synchronously). The writer never blocks: undrained slots stay busy and write_tensor falls back to pickle, preserving the deadlock-free invariant. Signed-off-by: Bolin Sun <bolins@nvidia.com>
… channel Upstream `_reduce_tensor` already routes CPU tensors out-of-band, removing the in-band serialize copy the arena was originally motivated against. Reposition the doc accordingly: the arena's remaining value is collapsing the N per-reader transport copies to one (scales with TP degree) plus the pinned-vs-pageable reader H2D. Label the existing benchmarks as measured against the pre-oob in-band baseline, and flag the arena-vs-oob A/B on current main as the outstanding validation item. Signed-off-by: Bolin Sun <bolins@nvidia.com>
…hm-tensor-arena) instead of an env var (review: prefer a CLI flag) Signed-off-by: Bolin Sun <bolins@nvidia.com>
…orch.cuda for the arena's pinning and release-gating (review: avoid torch.cuda) Signed-off-by: Bolin Sun <bolins@nvidia.com>
…nv vars (review: avoid adding env variables) Signed-off-by: Bolin Sun <bolins@nvidia.com>
…ants Signed-off-by: Bolin Sun <bolins@nvidia.com>
…(the CLI flag was silently ignored) get_current_vllm_config() raises outside a set_current_vllm_config() context, so the previous ambient lookup always fell back to the default (enabled) and --no-enable-shm-tensor-arena had no effect. Benchmarking caught this: the disabled control arm still pinned the arena. Pass the value from ParallelConfig at the executor instead. Signed-off-by: Bolin Sun <bolins@nvidia.com>
7ec4f02 to
186b409
Compare
Default-on would change behavior for MessageQueue constructions that don't thread the config flag (e.g. the ray executor) and alters the tensor path exercised by the existing test_tensor_broadcast. Off by default keeps stock behavior everywhere unless --enable-shm-tensor-arena is passed, and addresses the configuration-burden review concern. Signed-off-by: Bolin Sun <bolins@nvidia.com>
…or tests Covers: byte-exact zero-copy round-trips (readers alias one mapping; bf16/fp8 via the uint8 view path), slot lifecycle (no reuse until every reader releases; exhaustion, oversize and non-contiguous inputs fall back), _ArenaPickler composition with _reduce_tensor (large diverted to a slot, everything declined takes the out-of-band path unchanged), CUDA-event-gated release on the pinned path (GPU-only), and an end-to-end MessageQueue broadcast with a forked reader verifying the received tensor aliases an arena slot. Signed-off-by: Bolin Sun <bolins@nvidia.com>
|
@njhill The benchmarks you asked for are in -- baseline is current Headline: on Qwen3-VL-235B-A22B NVFP4, 8×B300 — E2E p99 vs
And TTFT p99 moves the same way (-21.5% at TP=8/2048² image size), and TP=1 with sub-8MB images are confirmed neutral So the honest summary: neutral outside the large-image + TP>=2 regime, and 17-25% p99 latency reduction inside it - a targeted optimization for high-resolution multimodal serving under TP, not a universally applicable optimization. On the complexity/config concern: the latest version makes the arena default off and strictly opt-in: no env vars, a single |
| 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) |
There was a problem hiding this comment.
Maybe also need to call cudaHostUnregister to clear up registered memory.
There was a problem hiding this comment.
Done -- the arena now pairs the registration with a cudaHostUnregister in __del__, before the mapping is closed. Added a unit test asserting the register/unregister pairing.
| _TENSOR_ARENAS[self.tensor_arena.shared_memory.name] = ( | ||
| self.tensor_arena | ||
| ) |
There was a problem hiding this comment.
If one worker only has one tensor_arena, we do not need to maintain a list like _TENSOR_ARENAS.
There was a problem hiding this comment.
Good point; now I replaced the dict with a single module-level _TENSOR_ARENA
Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Bolin Sun <bolins@nvidia.com>
Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Bolin Sun <bolins@nvidia.com>
Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Bolin Sun <bolins@nvidia.com>
Purpose
MessageQueue.enqueue(shm_broadcast.py) already routes CPU tensors out-of-band via_reduce_tensor(protocol-5PickleBuffer, #48442), which removed the dominant cost ofthe old in-band path — copying tensor bytes into the pickle stream. Two costs remain for
a large multimodal
pixel_valuestensor on a TP=N worker:copies for TP=N), and
first-touch faults).
This PR adds an opt-in slotted shared-memory tensor arena that layers on top of
_reduce_tensor: the writer does one memcpy into a free slot, every reader takes azero-copy view of the same slot (no per-reader transport copy), and the mapping is
cudaHostRegister-pinned so the H2D is a true DMA. Large contiguous CPU tensors arediverted by
_ArenaPickler.reducer_override; anything it declines (too small,non-contiguous, or arena exhausted) falls through to
_reduce_tensorunchanged.Net for a ~200 MB image at TP=8: 1 memcpy + 8 pinned DMAs instead of 8 transport
copies + 8 pageable stagings. The gain scales with TP degree and tensor size, and is
zero at TP=1 (no multiproc broadcast) — both now confirmed empirically below.
Design doc:
docs/design/shm_tensor_arena.md.Safety / scope: controlled by the
--enable-shm-tensor-arena/--no-enable-shm-tensor-arenaCLI flag (ParallelConfig.enable_shm_tensor_arena,default on — happy to flip to default-off if preferred); activates only when all queue
readers are node-local; the writer never blocks (no free/oversized slot → fall back
to
_reduce_tensor, deadlock structurally impossible); slot reuse is gated on anH2D-completion CUDA event, so the writer can't overwrite a slot whose async DMA is
still in flight — correct under
--async-scheduling.Test Plan
A/B against current
main: the baseline arm is the unmodifiedvllm/vllm-openai:nightlyimage (nightly-65b7662d3f…, includes #48442); the arena armis the same image plus this PR's four files. A third arm runs this PR's code with
--no-enable-shm-tensor-arenaas a disable-control (must be behaviorally identical tobase; also bounds run-to-run noise).
Two setups, so the TP axis and the model-realism axis are both covered:
at TP≤4), image sizes 512²–3072², 150 prompts @ 2 req/s.
TP ∈ {4, 8}, image sizes 1024²/2048²/3072², 120 prompts @ 1 req/s.
Common method:
vllm bench serve --dataset-name random-mm(openai-chat backend), oneimage per request at a fixed size per cell, poisson arrivals at a non-saturated
rate, 20 warmups (excludes the one-time arena pinning), seed 42, 2 repeats per cell.
Built-in negative controls: TP=1 (arena structurally inactive) and 512² images (~3 MB
pixel_values, below the 8 MB divert threshold).Repro commands
Server (one per arm × TP; arena arms = same image + this PR's files):
Load (per image size HW, per repeat):
Still outstanding: port the standalone correctness harness (21 checks: byte-exact
round-trip incl. bf16, zero-copy sharing, slot lifecycle with per-reader release,
event-gated reuse, exhaustion/oversize/non-contiguous fallbacks) into
tests/distributed/test_shm_broadcast.pyalongside the #48442 tests.Test Result
Qwen3-VL-235B (NVFP4) on 8×B300 — E2E p99, base → arena (mean of 2 repeats):
pixel_valuessize)TTFT p99 moves the same direction (TP=8: −21.5% at 2048², −11.9% at 3072²), and at these
payload sizes TTFT p50 improves too (TP=8/3072²: 1185 → 948 ms). Request throughput is
identical in every cell (rate-limited by design).
TP-scaling sweep (Qwen2.5-VL-3B — separate setup from the table above): TP=1 and
512² images are neutral (negative controls behave as designed — the arena never
activates); gains appear only at TP≥2 with ≥8 MB tensors and grow with TP and size. On
this small model the relative effect is larger than the 235B numbers above — up to −34%
E2E p99 at 3072²/TP=4 (3020 → 1992 ms) — because the same transport cost is a much
bigger fraction of a 3B's per-request time, and at 2 req/s its large-image cells carry
queueing that the stall removal also drains. The 235B table above is the more
conservative, production-representative measurement. The disable-control arm on this
setup bounds noise at roughly ±5% per cell.
Statistical honesty: with n=2 repeats, individual p99 cells are noisy (the 235B
control shows single-cell swings up to ~±17%). The claim rests on the pattern, not any
one cell: the arena is ahead in all 12 TTFT/E2E comparisons across both models,
monotonically in TP degree and tensor size — the shape the mechanism predicts
(per-reader transport + pageable staging replaced by one memcpy + pinned DMA), and the
E2E-tail bias matches head-of-line blocking on co-scheduled decodes.
Summary: neutral outside the large-image + TP≥2 regime; ~18–25% p99 reduction
inside it on a production-scale VLM.
Process note: the disable-control caught a bug in an earlier revision of this PR —
--no-enable-shm-tensor-arenawas silently ignored (the gate readget_current_vllm_config()outside its context and fell back to the default). Fixed bythreading the flag explicitly through the executor; the control was re-validated inert
(0 arena activations) before the numbers above.
Historical: original measurement vs the pre-#48442 in-band baseline
Same-seed interactive multimodal workload (uncapped images, TP=4 ×2 workers on one
8-GPU node, ~1.2k aligned requests): TTFT p50 93→89, p90 401→241, p99 1321→862, max
2139→1375 ms; requests >1.5 s: 9→0. These numbers predate #48442 and therefore overstate
the delta vs current main; kept only as provenance for the original problem analysis.
Essential Elements Checklist
tests/distributed/still outstandingmainon two models × TP 1–8, with disable-controldocs/design/shm_tensor_arena.md