Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
67623aa
wip
joerunde Oct 31, 2025
3ce3a48
:zap: functional SpinCondition
joerunde Nov 4, 2025
21c7b04
:art: cleanup
joerunde Nov 4, 2025
b296ad5
:art: fmt
joerunde Nov 4, 2025
0b84082
:art: cleanup
joerunde Nov 4, 2025
6a36b18
Merge branch 'main' into new-poll-fix
joerunde Nov 4, 2025
8037303
Merge branch 'main' into new-poll-fix
joerunde Nov 13, 2025
00c4c3f
:poop: WIP unit tests
joerunde Nov 13, 2025
f55c68e
test: flesh out shm_broadcast tests
tjohnson31415 Nov 17, 2025
0bd12b3
fix timeout handling and little refactor
tjohnson31415 Nov 17, 2025
74cc6d5
Merge branch 'main' into new-poll-fix
tjohnson31415 Nov 18, 2025
9c97af5
Merge branch 'main' into new-poll-fix
tjohnson31415 Nov 20, 2025
73c3398
test: add busy shutdown test
tjohnson31415 Dec 1, 2025
184db2a
Merge branch 'main' into new-poll-fix
joerunde Dec 4, 2025
28a4e60
Merge branch 'main' into new-poll-fix
tjohnson31415 Dec 18, 2025
3e47bf4
Merge branch 'main' into new-poll-fix
joerunde Jan 21, 2026
7749b21
:bug: fix uninitialized spin condition
joerunde Jan 21, 2026
9ff2361
:rewind: revert changes from #32965
joerunde Jan 23, 2026
0f0ccf6
:recycle: refactor timeout stuff
joerunde Feb 18, 2026
dbac53a
refactor: make ReadTimeout class clearer
tjohnson31415 Feb 18, 2026
de3f4a6
rename: ReadTimeoutWithWarnings
tjohnson31415 Feb 18, 2026
b8b56f0
:test_tube: add negative test for warning logs
joerunde Feb 18, 2026
1affbef
:bug: fix test hangs
joerunde Feb 19, 2026
40051c5
test: distributed_run fail fast
tjohnson31415 Feb 19, 2026
18febb3
refactor: move monitor_parent_death to be a worker method
tjohnson31415 Feb 19, 2026
6038f89
refactor: cleanup new monitor function
tjohnson31415 Feb 19, 2026
df8dcfe
Merge branch 'main' into new-poll-fix
joerunde Feb 20, 2026
5f14af2
review: changes from review
tjohnson31415 Feb 24, 2026
2f3f98c
fix: handle inherited socket connections when forking
tjohnson31415 Feb 24, 2026
c45326b
log: add some debug logs to ensure_worker_termination
tjohnson31415 Feb 25, 2026
e1565e0
fix: shutdown all queues in MultiprocExecutor
tjohnson31415 Feb 25, 2026
54ff00c
Merge branch 'main' into new-poll-fix
tjohnson31415 Feb 25, 2026
f7e3486
log: add logging to SpinCondition wait
tjohnson31415 Mar 2, 2026
1f04a71
Merge branch 'main' into new-poll-fix
tjohnson31415 Mar 2, 2026
26ee621
Merge branch 'main' into new-poll-fix
tjohnson31415 Mar 2, 2026
9be917a
Merge branch 'main' into new-poll-fix
tjohnson31415 Mar 3, 2026
cd06f1f
Merge branch 'main' into new-poll-fix
tjohnson31415 Mar 3, 2026
765660f
cleanup removed env var
njhill Mar 3, 2026
97eb604
minor code simplification
njhill Mar 3, 2026
364ce6c
Merge branch 'main' into new-poll-fix
tjohnson31415 Mar 3, 2026
8bf1da6
fix: time in log message now rounds to 0
tjohnson31415 Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 148 additions & 42 deletions vllm/distributed/device_communicators/shm_broadcast.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import functools
import math
import pickle
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from multiprocessing import shared_memory
from pickle import PickleBuffer
from threading import Event
from typing import TYPE_CHECKING, Any
from unittest.mock import patch

Expand All @@ -17,6 +17,7 @@
from torch.distributed import ProcessGroup
from zmq import ( # type: ignore
IPV6, # type: ignore
PUB,
SUB,
SUBSCRIBE,
XPUB,
Expand All @@ -30,6 +31,7 @@
from vllm.utils.network_utils import (
get_ip,
get_open_port,
get_open_zmq_inproc_path,
get_open_zmq_ipc_path,
is_valid_ipv6_address,
)
Expand All @@ -49,40 +51,115 @@ def to_bytes_big(value: int, size: int) -> bytes:
logger = init_logger(__name__)


class SpinTimer:
def record_activity(self):
pass

def spin(self):
sched_yield()


class SpinSleepTimer(SpinTimer):
class SpinCondition:
"""
In setups which have long inactivity periods it is desirable to reduce
system power consumption when vllm does nothing. This would lead to more
CPU thermal headroom when a request eventually comes, especially when
multiple GPUs are connected as each GPU would otherwise pin one thread at
100% CPU usage.

The simplest solution is to reduce polling frequency when there is no
activity for a certain period of time.
This class implements an interface similar to a threading.Condition. It
allows a writer to notify readers to wake up and read from the shared memory
buffer. This notification is done over a zmq socket.

For optimal performance under load we don't want the readers to need to poll
the zmq socket for every read. So the `wait` method here will return
immediately when reads are frequent, and will only enter "idle mode" and
await a notification on the zmq socket after a period of inactivity. This
allows the readers to spin quickly, hence "SpinCondition".

To support clean shutdown, a separate thread in the reader's process must be
able to wake the reader so that it can exit. A separate cancel() method is
implemented with an in-process socket to allow this interruption.
"""

def __init__(self, busy_loop_s: float = 3.0, wait_sleep_s: float = 0.1):
self.last_activity = time.monotonic()
self.busy_loop_s = busy_loop_s
self.wait_sleep_s = wait_sleep_s

def record_activity(self):
self.last_activity = time.monotonic()

def spin(self):
curr_time = time.monotonic()
if curr_time >= self.last_activity + self.busy_loop_s:
time.sleep(self.wait_sleep_s)
def __init__(
self,
is_reader: bool,
context: zmq.Context,
notify_address: str,
busy_loop_s: float = 1,
):
self.is_reader = is_reader

if is_reader:
# Time of last shm buffer read
self.last_read = time.monotonic()

# Time to keep busy-looping on the shm buffer before going idle
self.busy_loop_s = busy_loop_s

# Readers subscribe to write notifications
self.local_notify_socket: zmq.Socket = context.socket(SUB)
# Set zmq.CONFLATE to only keep the last message that the socket
# receives. This prevents us from piling up notification messages
# under high load when we aren't polling the socket.
self.local_notify_socket.setsockopt(zmq.CONFLATE, 1)
# Subscribe to all messages on the socket
self.local_notify_socket.setsockopt_string(SUBSCRIBE, "")
self.local_notify_socket.connect(notify_address)

# Readers require a process-local socket to poll for cancellation
cancel_path = get_open_zmq_inproc_path()
self.write_cancel_socket: zmq.Socket = context.socket(zmq.PAIR)
self.write_cancel_socket.bind(cancel_path)
self.read_cancel_socket: zmq.Socket = context.socket(zmq.PAIR)
self.read_cancel_socket.connect(cancel_path)

# Poller allows waiting on either `.notify()` or `.cancel()`
self.poller = zmq.Poller()
self.poller.register(self.read_cancel_socket, zmq.POLLIN)
self.poller.register(self.local_notify_socket, zmq.POLLIN)
else:
# Writer side publishes write notifications
self.local_notify_socket: zmq.Socket = context.socket(PUB) # type: ignore
# Set high water mark to 1- we don't need to send a massive amount of
# pings during busy operation. PUB sockets will silently drop subsequent
# messages after the high water mark is reached.
self.local_notify_socket.setsockopt(zmq.SNDHWM, 1)
self.local_notify_socket.bind(notify_address)

self.last_read = 0
self.busy_loop_s = 0
self.read_cancel_socket = None
self.write_cancel_socket = None
self.poller = None

def record_read(self):
self.last_read = time.monotonic()

def cancel(self):
# Sends cancellation ping that will cause the reader to wake up.
# This is done from a monitor thread in the same process as the reader.
if self.is_reader:
logger.debug("Canceling waiting reads on SHM Buffer")
self.write_cancel_socket.send(b"\x00")
Comment thread
joerunde marked this conversation as resolved.

def wait(self, timeout_ms: float | None = None) -> None:
"""Wait for data on the shared memory buffer.

Yields the scheduler then returns immediately if it has been less than
self.busy_loop_s since the last read.

Otherwise, enters idle mode and awaits a socket ping for at most
`timeout_ms` milliseconds, or indefinitely if timeout_s is None.
"""
assert self.is_reader, "Only readers can wait"

current_time = time.monotonic()
if current_time <= self.last_read + self.busy_loop_s:
sched_yield()
else:
events = dict(self.poller.poll(timeout=timeout_ms))

if self.read_cancel_socket in events:
# return immediately on cancel
return

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these lines needed?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, no, not as is written. I could add a debug logging statement here though to indicate that a cancel was received (I had such a log statmenet during my testing) 😅


if self.local_notify_socket in events:
# Since zmq.CONFLATE is set, there will only be one notification
# to read from the socket
self.local_notify_socket.recv(flags=zmq.NOBLOCK, copy=False)

def notify(self):
"""Notifies all readers to wake up"""
assert not self.is_reader, "Only writers can notify"
self.local_notify_socket.send(b"\x00")


class ShmRingBuffer:
Expand Down Expand Up @@ -226,6 +303,7 @@ class Handle:

buffer_handle: tuple[int, int, int, str] | None = None
local_subscribe_addr: str | None = None
local_notify_addr: str | None = None
remote_subscribe_addr: str | None = None
remote_addr_ipv6: bool = False

Expand All @@ -249,7 +327,7 @@ def __init__(
self.n_local_reader = n_local_reader
n_remote_reader = n_reader - n_local_reader
self.n_remote_reader = n_remote_reader

self.shutting_down = False
context = Context()

if n_local_reader > 0:
Expand All @@ -271,11 +349,19 @@ def __init__(
self.local_socket.bind(local_subscribe_addr)

self.current_idx = 0

# Create the notification side of the SpinCondition
local_notify_addr = get_open_zmq_ipc_path()
self._spin_condition = SpinCondition(
is_reader=False, context=context, notify_address=local_notify_addr
)
else:
self.buffer = None # type: ignore
local_subscribe_addr = None
self.local_socket = None
self.current_idx = -1
local_notify_addr = None
self._spin_condition = None # type: ignore

remote_addr_ipv6 = False
if n_remote_reader > 0:
Expand All @@ -302,12 +388,12 @@ def __init__(
self.local_reader_rank = -1
# rank does not matter for remote readers
self._is_remote_reader = False
self._read_spin_timer = SpinTimer()

self.handle = Handle(
local_reader_ranks=local_reader_ranks,
buffer_handle=self.buffer.handle() if self.buffer is not None else None,
local_subscribe_addr=local_subscribe_addr,
local_notify_addr=local_notify_addr,
remote_subscribe_addr=remote_subscribe_addr,
remote_addr_ipv6=remote_addr_ipv6,
)
Expand Down Expand Up @@ -340,9 +426,9 @@ def create_from_handle(handle: Handle, rank) -> "MessageQueue":
self.local_socket.connect(socket_addr)

self.remote_socket = None

self._read_spin_timer = (
SpinSleepTimer() if envs.VLLM_SLEEP_WHEN_IDLE else SpinTimer()
assert isinstance(handle.local_notify_addr, str)
self._spin_condition = SpinCondition(
is_reader=True, context=context, notify_address=handle.local_notify_addr

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remote readers missing _spin_condition attribute initialization

High Severity

In create_from_handle(), when creating a remote reader (the else branch for ranks not in local_reader_ranks), the _spin_condition attribute is never set. However, the shutdown() method accesses self._spin_condition unconditionally. This causes an AttributeError when shutdown() is called on a remote reader MessageQueue in multi-node deployments.

Additional Locations (1)

Fix in Cursor Fix in Web

)
else:
self.buffer = None # type: ignore
Expand All @@ -361,6 +447,7 @@ def create_from_handle(handle: Handle, rank) -> "MessageQueue":
logger.debug("Connecting to %s", socket_addr)
self.remote_socket.connect(socket_addr)

self.shutting_down = False
return self

def wait_until_ready(self):
Expand Down Expand Up @@ -396,6 +483,13 @@ def wait_until_ready(self):
recv = self.remote_socket.recv()
assert recv == b"READY"

def shutdown(self):
"""If this is an idle reader, wakes it up so it can clean up and shut
down"""
self.shutting_down = True
if self._spin_condition is not None:
self._spin_condition.cancel()

@contextmanager
def acquire_write(self, timeout: float | None = None):
assert self._is_writer, "Only writers can acquire write"
Expand Down Expand Up @@ -458,11 +552,14 @@ def acquire_write(self, timeout: float | None = None):
def acquire_read(
self,
timeout: float | None = None,
cancel: Event | None = None,
indefinite: bool = False,
):
assert self._is_local_reader, "Only readers can acquire read"
start_time = time.monotonic()
if not indefinite and timeout is not None:
deadline = start_time + timeout
else:
deadline = math.inf
n_warning = 1
while True:
with self.buffer.get_metadata(self.current_idx) as metadata_buffer:
Expand All @@ -477,10 +574,18 @@ def acquire_read(
# if this block is not ready,
# we need to wait until it is written

# Release the processor to other threads
self._read_spin_timer.spin()
if not indefinite:
self._spin_condition.wait(
timeout_ms=min(
VLLM_RINGBUFFER_WARNING_INTERVAL,
deadline - time.monotonic(),
)
* 1000
)
else:
self._spin_condition.wait()

if cancel is not None and cancel.is_set():
if self.shutting_down:
raise RuntimeError("cancelled")

# if we time out, raise an exception
Expand Down Expand Up @@ -512,7 +617,7 @@ def acquire_read(
metadata_buffer[self.local_reader_rank + 1] = 1
self.current_idx = (self.current_idx + 1) % self.buffer.max_chunks

self._read_spin_timer.record_activity()
self._spin_condition.record_read()
break

def enqueue(self, obj, timeout: float | None = None):
Expand Down Expand Up @@ -555,18 +660,19 @@ def oob_callback(buf: PickleBuffer) -> bool:
buf[offset:buf_offset] = to_bytes_big(buf_len, 4)
buf[buf_offset : (offset := buf_offset + buf_len)] = buffer

self._spin_condition.notify()

if self.n_remote_reader > 0:
self.remote_socket.send_multipart(all_buffers, copy=False)

def dequeue(
self,
timeout: float | None = None,
cancel: Event | None = None,
indefinite: bool = False,
):
"""Read from message queue with optional timeout (in seconds)"""
if self._is_local_reader:
with self.acquire_read(timeout, cancel, indefinite) as buf:
with self.acquire_read(timeout, indefinite) as buf:
overflow = buf[0] == 1
if not overflow:
offset = 3
Expand Down
4 changes: 0 additions & 4 deletions vllm/envs.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@
] = "allgather_reducescatter"
VLLM_MAX_TOKENS_PER_EXPERT_FP4_MOE: int = 163840
VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS: int = 1
VLLM_SLEEP_WHEN_IDLE: bool = False
VLLM_MQ_MAX_CHUNK_BYTES_MB: int = 16
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS: int = 300
VLLM_KV_CACHE_LAYOUT: Literal["NHD", "HND"] | None = None
Expand Down Expand Up @@ -1239,9 +1238,6 @@ def get_vllm_port() -> int | None:
"VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS": lambda: int(
os.getenv("VLLM_TOOL_PARSE_REGEX_TIMEOUT_SECONDS", "1")
),
# Reduce CPU usage when vLLM is idle. Enabling this will incur small
# latency penalty when a request eventually comes.
"VLLM_SLEEP_WHEN_IDLE": lambda: bool(int(os.getenv("VLLM_SLEEP_WHEN_IDLE", "0"))),
# Control the max chunk bytes (in MB) for the rpc message queue.
# Object larger than this threshold will be broadcast to worker
# processes via zmq.
Expand Down
Loading