-
-
Notifications
You must be signed in to change notification settings - Fork 20.4k
[Core] Remove busy loop from idle buffer readers #28053
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
67623aa
3ce3a48
21c7b04
b296ad5
0b84082
6a36b18
8037303
00c4c3f
f55c68e
0bd12b3
74cc6d5
9c97af5
73c3398
184db2a
28a4e60
3e47bf4
7749b21
9ff2361
0f0ccf6
dbac53a
de3f4a6
b8b56f0
1affbef
40051c5
18febb3
6038f89
df8dcfe
5f14af2
2f3f98c
c45326b
e1565e0
54ff00c
f7e3486
1f04a71
26ee621
9be917a
cd06f1f
765660f
97eb604
364ce6c
8bf1da6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
|
|
@@ -17,6 +17,7 @@ | |
| from torch.distributed import ProcessGroup | ||
| from zmq import ( # type: ignore | ||
| IPV6, # type: ignore | ||
| PUB, | ||
| SUB, | ||
| SUBSCRIBE, | ||
| XPUB, | ||
|
|
@@ -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, | ||
| ) | ||
|
|
@@ -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") | ||
|
|
||
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are these lines needed?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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: | ||
|
|
@@ -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: | ||
|
|
@@ -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, | ||
| ) | ||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remote readers missing
|
||
| ) | ||
| else: | ||
| self.buffer = None # type: ignore | ||
|
|
@@ -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): | ||
|
|
@@ -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" | ||
|
|
@@ -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: | ||
|
|
@@ -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 | ||
|
|
@@ -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): | ||
|
|
@@ -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 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.