distributed/shm_broadcast.py: MessageQueue
Code version: DeepInfer @ 9bd9cf2 · 2026-10-21
Purpose#
shm_broadcast.py sends Python objects from one process to several processes on the same host, through a ring buffer in shared memory, with ZeroMQ sockets for set-up and for large objects.
| File | deepinfer/distributed/shm_broadcast.py, 461 lines of Python. "Derived from vLLM; modified by DeepSeek." (deepinfer/distributed/shm_broadcast.py:1-2). deepinfer/distributed/__init__.py is empty. |
|---|---|
| Main classes | ShmRingBuffer (deepinfer/distributed/shm_broadcast.py:33), Handle (deepinfer/distributed/shm_broadcast.py:152-159), MessageQueue (deepinfer/distributed/shm_broadcast.py:162). |
| Runs in | The CPU side of every rank of a CP group with more than one rank: the writer in the engine process (CP rank 0), a reader in each CP follower process. No device code. |
| Called by | ParallelProcessGroup.create_process_groups (set-up) and cp_broadcast_object (every message), both in parallel_state.py. |
| Calls | multiprocessing.shared_memory, pickle, zmq, torch.distributed.broadcast_object_list (one time, for the handle). |
| Not in this file | Tensor data. Device tensors go through NCCL or HCCL broadcasts in communication_op.py. The pinned memory pool for images is a different module: shared_memory.py: the pinned memory pool for images. |
Where it sits#
Read panel 2 from left to right. The dashed arrow is the writer that comes back to the same chunk 10 messages later. Panel 3 is the path of every call from the CP driver to its followers.
Who calls it#
- Set-up.
create_process_groupsmakes one queue per CP group, with 1 MiB chunks and 10 chunks, only ifcp_size > 1(deepinfer/model_executor/parallel_utils/parallel_state.py:122-124). - Every message.
cp_broadcast_objectcallsenqueueon the writer anddequeueon a reader (deepinfer/model_executor/parallel_utils/parallel_state.py:17-23). Three kinds of callers use it:- method calls of the worker:
run_workerson the driver, the slave loop on the followers (deepinfer/worker/base.py:296-300,deepinfer/worker/base.py:129-133); - the non-tensor part of
broadcast_tensor_dict(deepinfer/model_executor/parallel_utils/communication_op.py:72-75); - the non-tensor fields of
InputMetadata.broadcast(deepinfer/model_executor/input_metadata/input_metadata.py:153-156).
- method calls of the worker:
- Shut-down.
cleanup_shm_broadcastcloses and unlinks the buffer (deepinfer/model_executor/parallel_utils/parallel_state.py:161-169). - Test.
tests/distributed/test_shm_broadcast.pysends 10,000 random arrays (about 400 MB) from rank 2 of 4 processes, with 40 KiB chunks, so that many arrays use the overflow path (tests/distributed/test_shm_broadcast.py:108-136).
What it calls#
multiprocessing.shared_memory.SharedMemoryto make or open the buffer (deepinfer/distributed/shm_broadcast.py:100-121).zmqXPUB and SUB sockets on127.0.0.1(deepinfer/distributed/shm_broadcast.py:193-201,deepinfer/distributed/shm_broadcast.py:260-264).dist.broadcast_object_liston the Gloo CP group, to send the handle to the readers (deepinfer/distributed/shm_broadcast.py:454-458).deepinfer.utils.get_open_portandget_ip(deepinfer/utils.py:40-49).
Key classes and functions#
| Name | Line | What it does |
|---|---|---|
ShmRingBuffer.__init__ | deepinfer/distributed/shm_broadcast.py:34-121 | Makes (writer) or opens (reader) the shared memory; the docstring describes the layout and the flag protocol. |
handle, __reduce__ | deepinfer/distributed/shm_broadcast.py:123-127 | Pickles as (n_reader, max_chunk_bytes, max_chunks, name), so another process opens the same memory. |
__del__ | deepinfer/distributed/shm_broadcast.py:129-135 | Closes; the creator also unlinks. |
get_data, get_metadata | deepinfer/distributed/shm_broadcast.py:137-149 | Memory views of one chunk and of its flag row. |
Handle | deepinfer/distributed/shm_broadcast.py:152-159 | Dataclass that a reader needs: IP, local reader ranks, buffer handle, ports. |
MessageQueue.__init__ | deepinfer/distributed/shm_broadcast.py:163-239 | Writer side: the ring buffer, an XPUB socket for local readers, another for remote readers. |
create_from_handle | deepinfer/distributed/shm_broadcast.py:244-284 | Reader side: opens the buffer and connects a SUB socket. |
wait_until_ready | deepinfer/distributed/shm_broadcast.py:286-306 | Handshake: the writer waits for all subscriptions, then sends b"READY". |
_spin_wait | deepinfer/distributed/shm_broadcast.py:308-318 | One wait turn: yield the CPU, log now and then, raise on time-out. |
acquire_write, acquire_read | deepinfer/distributed/shm_broadcast.py:320-387 | Context managers that wait for a usable chunk and update its flags. |
enqueue, dequeue | deepinfer/distributed/shm_broadcast.py:389-422 | Pickle and write; read and unpickle. Large objects go over the socket. |
broadcast_object | deepinfer/distributed/shm_broadcast.py:424-429 | Writer enqueues, reader dequeues. DeepInfer uses its own copy of this logic in cp_broadcast_object; only the test calls this method. |
create_from_process_group | deepinfer/distributed/shm_broadcast.py:431-461 | Collective set-up over a process group: writer makes, readers open, all wait until ready. |
Walk through the main methods#
create_from_process_group: set-up over a group#
@staticmethod
def create_from_process_group(
pg: ProcessGroup,
max_chunk_bytes: int,
max_chunks: int,
writer_rank: int = 0,
) -> "MessageQueue":
group_rank = dist.get_rank(pg)
group_world_size = dist.get_world_size(pg)
global_ranks = dist.get_process_group_ranks(pg)
# assume the group does not cross nodes, so every reader is a local reader
n_reader = group_world_size - 1
local_reader_ranks = [i for i in range(group_world_size) if i != writer_rank]
buffer_io: MessageQueue
if group_rank == writer_rank:
buffer_io = MessageQueue(
n_reader=n_reader,
n_local_reader=n_reader,
local_reader_ranks=local_reader_ranks,
max_chunk_bytes=max_chunk_bytes,
max_chunks=max_chunks,
)
dist.broadcast_object_list([buffer_io.export_handle()], src=global_ranks[writer_rank], group=pg)
else:
recv = [None]
dist.broadcast_object_list(recv, src=global_ranks[writer_rank], group=pg)
handle = recv[0] # type: ignore
buffer_io = MessageQueue.create_from_handle(handle, group_rank)
buffer_io.wait_until_ready()
return buffer_io- Line 442: the comment states the assumption. Every reader is a local (shared-memory) reader. So the group must be on one host.
- Lines 446-454: the writer (group rank 0 by default) makes the queue and broadcasts its
Handlewithbroadcast_object_list. This is a Gloo collective on the CPU CP group. - Lines 455-459: the readers get the handle and open the queue.
- Line 460: all ranks call
wait_until_ready.
MessageQueue.__init__ and create_from_handle: the two sides#
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)
# XPUB is very similar to PUB,
# except that it can receive subscription messages
# to confirm the number of subscribers
self.local_socket = context.socket(XPUB)
# set the verbose option so that we can receive every subscription
# message. otherwise, we will only receive the first subscription
# see http://api.zeromq.org/3-3:zmq-setsockopt for more details
self.local_socket.setsockopt(XPUB_VERBOSE, True)
local_subscribe_port = get_open_port()
socket_addr = f"tcp://127.0.0.1:{local_subscribe_port}"
logger.debug("Binding to %s", socket_addr)
self.local_socket.bind(socket_addr)
self.current_idx = 0
else:
self.buffer = None # type: ignore
local_subscribe_port = None
self.local_socket = None
self.current_idx = -1The writer makes a ShmRingBuffer with one flag per local reader, and binds an XPUB socket on a free local port. XPUB_VERBOSE makes the socket pass every subscription message to the writer, so the writer can count the readers (the comment on lines 194-196). A reader opens the same buffer from the handle and connects a SUB socket that accepts all messages (deepinfer/distributed/shm_broadcast.py:252-264). The reader's position in local_reader_ranks is its flag number (deepinfer/distributed/shm_broadcast.py:256).
When a reader opens the buffer, the code patches multiprocessing.resource_tracker.register to do nothing. The comment explains: "Python incorrectly tracks shared memory even if it is not created by the process" (deepinfer/distributed/shm_broadcast.py:110-115).
acquire_write: wait for a free chunk#
@contextmanager
def acquire_write(self, timeout: float | None = None) -> Generator[memoryview, None, None]:
assert self._is_writer, "Only writers can acquire write"
start_time = time.monotonic()
n_warning = 1
while True:
with self.buffer.get_metadata(self.current_idx) as metadata_buffer:
read_count = sum(metadata_buffer[1:])
written_flag = metadata_buffer[0]
if written_flag and read_count != self.buffer.n_reader:
# this block is written and not read by all readers
# for writers, `self.current_idx` is the next block to write
# if this block is not ready to write,
# we need to wait until it is read by all readers
n_warning = self._spin_wait(start_time, n_warning, timeout)
continue
# found a block that is either
# (1) not written
# (2) read by all readers
# mark the block as not written
metadata_buffer[0] = 0
# let caller write to the buffer
with self.buffer.get_data(self.current_idx) as buf:
yield buf
# caller has written to the buffer
# NOTE: order is important here
# first set the read flags to 0
# then set the written flag to 1
# otherwise, the readers may think they already read the block
for i in range(1, self.buffer.n_reader + 1):
# set read flag to 0, meaning it is not read yet
metadata_buffer[i] = 0
# mark the block as written
metadata_buffer[0] = 1
self.current_idx = (self.current_idx + 1) % self.buffer.max_chunks
break- Lines 326-335: look at the flag row of chunk
current_idx. If it is written and not read by all readers, wait one turn and look again. - Line 341: set
written = 0. The chunk is now "not written" for all readers. - Lines 343-344: give the chunk to the caller, which writes the data.
- Lines 351-355: set all reader flags to 0, then set
written = 1. The comment on lines 347-350 says the order is important: with the other order, a reader could see "written and already read" for a short time. - Line 356: the next write uses the next chunk, modulo 10.
acquire_read: wait for a new chunk#
@contextmanager
def acquire_read(self, timeout: float | None = None) -> Generator[memoryview, None, None]:
assert self._is_local_reader, "Only readers can acquire read"
start_time = time.monotonic()
n_warning = 1
while True:
with self.buffer.get_metadata(self.current_idx) as metadata_buffer:
read_flag = metadata_buffer[self.local_reader_rank + 1]
written_flag = metadata_buffer[0]
if not written_flag or read_flag:
# this block is either
# (1) not written
# (2) already read by this reader
# for readers, `self.current_idx` is the next block to read
# if this block is not ready,
# we need to wait until it is written
n_warning = self._spin_wait(start_time, n_warning, timeout)
continue
# found a block that is not read by this reader
# let caller read from the buffer
with self.buffer.get_data(self.current_idx) as buf:
yield buf
# caller has read from the buffer
# set the read flag
metadata_buffer[self.local_reader_rank + 1] = 1
self.current_idx = (self.current_idx + 1) % self.buffer.max_chunks
breakEach reader keeps its own current_idx. It waits until its chunk is written and its own flag is 0. After the caller has read the data, it sets its own flag to 1 and moves to the next chunk. Readers never touch the written flag.
enqueue and dequeue: the object path#
def enqueue(self, obj: Any, timeout: float | None = None) -> None:
"""Write to message queue with optional timeout (in seconds)"""
assert self._is_writer, "Only writers can enqueue"
serialized_obj = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
if self.n_local_reader > 0:
# first byte of the chunk is the overflow flag; the payload is stored
# inline if it fits, otherwise it is sent through the local socket
overflow = len(serialized_obj) >= self.buffer.max_chunk_bytes
with self.acquire_write(timeout) as buf:
buf[0] = overflow
if not overflow:
buf[1 : len(serialized_obj) + 1] = serialized_obj
if overflow:
self.local_socket.send(serialized_obj)
if self.n_remote_reader > 0:
self.remote_socket.send(serialized_obj)
def dequeue(self, timeout: float | None = None) -> Any:
"""Read from message queue with optional timeout (in seconds)"""
if self._is_local_reader:
with self.acquire_read(timeout) as buf:
overflow = buf[0] == 1
if not overflow:
# no need to know the size of serialized object
# pickle format contains the size information internally
# see https://docs.python.org/3/library/pickle.html
obj = pickle.loads(buf[1:])
if overflow:
obj = pickle.loads(self.local_socket.recv())
elif self._is_remote_reader:
obj = pickle.loads(self.remote_socket.recv())
else:
raise RuntimeError("Only readers can dequeue")
return obj- Line 392: pickle the object with the highest protocol.
- Line 396: the object "overflows" if its pickle has
max_chunk_bytesbytes or more (1 MiB in the CP queue). - Lines 397-400: byte 0 of the chunk is the overflow flag. A small object goes into bytes 1 and up.
- Lines 401-402: a large object goes over the local XPUB socket. The chunk still goes through the ring, as a ticket.
- Lines 410-417: the reader reads byte 0. For a small object,
pickle.loads(buf[1:])reads the object; pickle knows its own length (comment, lines 412-414). For a large object, the reader reads one message from its SUB socket.
wait_until_ready and _spin_wait#
def wait_until_ready(self) -> None:
"""This is a collective operation. All processes (including the
readers and the writer) should call this function.
"""
if self._is_writer:
# wait for subscription messages from all readers, then send a
# message to confirm the publish channel is working
for n_reader, socket in (
(self.n_local_reader, self.local_socket),
(self.n_remote_reader, self.remote_socket),
):
for _ in range(n_reader):
socket.recv()
if n_reader > 0:
socket.send(b"READY")
elif self._is_local_reader:
# wait for the writer to send a message
assert self.local_socket.recv() == b"READY"
elif self._is_remote_reader:
# wait for the writer to send a message
assert self.remote_socket.recv() == b"READY"
@staticmethod
def _spin_wait(start_time: float, n_warning: int, timeout: float | None = None) -> int:
"""自旋等待一轮:让出 CPU、按间隔打 debug 日志、超时抛 TimeoutError。返回更新后的 n_warning。"""
os.sched_yield()
elapsed = time.monotonic() - start_time
if elapsed > envs.DEEPINFER_RINGBUFFER_WARNING_INTERVAL * n_warning:
logger.debug("No available shared memory block after %.1f seconds", elapsed)
n_warning += 1
if timeout is not None and elapsed > timeout:
raise TimeoutError
return n_warningTranslated comments (Chinese → English)
- 310 Spin-wait for one turn: give up the CPU, write a debug log at intervals, raise TimeoutError on time-out. Returns the updated n_warning.
The handshake (lines 290-306) makes sure that every SUB socket is connected before the first large object goes out. A ZeroMQ publisher drops messages for subscribers that are not connected yet (general ZeroMQ behaviour). The wait loop (lines 308-318) calls os.sched_yield() on each turn. It does not sleep.
Deep dive#
Deep diveData flow: what goes through the queue in one step
With CP above 1, each prefill step sends these objects from the driver, in this order (the first three come from run_workers, _prepare_input_driver and prepare_sampler_inputs):
| # | Object | Typical size | Source |
|---|---|---|---|
| 1 | ("execute_model_step", {"is_prompt": True, "is_placeholder": False}) | tens of bytes | deepinfer/worker/base.py:300 |
| 2 | list of (key, value): the input metadata, with a TensorMetadata(dtype, size) for each tensor | a few KiB | deepinfer/model_executor/parallel_utils/communication_op.py:64-72 |
| 3 | the sampler metadata dict, the same way | small | deepinfer/worker/model_runner.py:252-259 |
The tensor data does not go through the queue. After each metadata list, cp_broadcast_multi_tensor sends the tensors with one device broadcast per tensor (deepinfer/model_executor/parallel_utils/communication_op.py:36-43). So the 1 MiB chunk is large enough for normal steps, and the overflow path is rare. (Sizes in the table are estimates, not measured.)
Types. Any picklable object can go. The input metadata dict contains a class object ("type": type(input_metadata)), which pickle stores by name (deepinfer/worker/model_runner.py:204).
Deep diveProcesses, threads and CPU use
- Writer: the engine process, on the thread that runs
step().enqueuespins only if the reader is 10 messages behind. - Readers: each CP follower process, in
start_slave_worker_loop. Between steps, the follower sits inacquire_read. That loop callsos.sched_yield()and checks the flag again, with no sleep (deepinfer/distributed/shm_broadcast.py:308-318,deepinfer/distributed/shm_broadcast.py:364-377). So an idle follower keeps one CPU core busy. - ZeroMQ context:
Context.instance()is one context per process (deepinfer/distributed/shm_broadcast.py:182). - The slow-reader log. After 60 s of waiting (
DEEPINFER_RINGBUFFER_WARNING_INTERVAL), the waiter writes a debug log "No available shared memory block after ... seconds", then again after 120 s, and so on (deepinfer/environ.py:184,deepinfer/distributed/shm_broadcast.py:312-315). At the default log level, a debug log may not show.
Deep diveWorked example: chunk numbers over 12 messages
CP group of 4: writer W, readers R0, R1, R2, 10 chunks. W sends messages m0 to m11.
- m0 to m9 go into chunks 0 to 9. Each chunk becomes
1 0 0 0. - R0 and R2 are fast. They read m0 to m9; the rows become
1 1 0 1. R1 is slow and has read only m0 to m3: chunks 0-3 are1 1 1 1. - m10 goes to chunk 0 (
10 % 10). Chunk 0 is1 1 1 1, so W can write at once. - m11 goes to chunk 1, also free.
- m14 would go to chunk 4. Chunk 4 is
1 1 0 1, so W waits until R1 reads m4.
So the writer can be at most 10 messages ahead of the slowest reader. In DeepInfer, each step sends 3 messages (the method call, the input metadata and the sampler metadata), so the driver CPU can be about 3 steps ahead before it waits.
Deep diveError paths
| Situation | Result | Where |
|---|---|---|
enqueue on a reader, acquire_read on the writer | AssertionError | deepinfer/distributed/shm_broadcast.py:322, deepinfer/distributed/shm_broadcast.py:361, deepinfer/distributed/shm_broadcast.py:391 |
dequeue on the writer | RuntimeError("Only readers can dequeue") | deepinfer/distributed/shm_broadcast.py:420-421 |
| Time-out passed and reached | TimeoutError. DeepInfer passes no time-out, so it waits for ever. | deepinfer/distributed/shm_broadcast.py:316-317 |
| A reader on another host | The buffer does not exist there; FileNotFoundError is suppressed, and the object has no shared_memory | deepinfer/distributed/shm_broadcast.py:117-121 |
| Buffer size differs from the handle | AssertionError | deepinfer/distributed/shm_broadcast.py:116 |
Handshake message is not b"READY" | AssertionError | deepinfer/distributed/shm_broadcast.py:303, deepinfer/distributed/shm_broadcast.py:306 |
Buffer already unlinked at __del__ | suppressed | deepinfer/distributed/shm_broadcast.py:133-135 |
Settings. The only environment variable in this file is DEEPINFER_RINGBUFFER_WARNING_INTERVAL (default 60, deepinfer/environ.py:184). The chunk size and count come from the caller: 1 MiB and 10 for the CP queue (deepinfer/model_executor/parallel_utils/parallel_state.py:124). The defaults of the class are 10 MiB and 10 (deepinfer/distributed/shm_broadcast.py:168-169).
Deep diveRemote readers: in the code, not used by DeepInfer
The class can also serve readers on other hosts. For them, the writer binds a second XPUB socket on tcp://*:<port>, and enqueue sends every object over it (deepinfer/distributed/shm_broadcast.py:211-220, deepinfer/distributed/shm_broadcast.py:403-404). The writer's IP comes from get_ip(), which opens a UDP socket toward 8.8.8.8 to learn the local address; no packet is needed (deepinfer/utils.py:40-43). IPv6 addresses set the IPV6 socket option.
create_from_process_group always sets n_local_reader = n_reader (deepinfer/distributed/shm_broadcast.py:443-453), so in DeepInfer the remote path stays unused.
Related concepts#
- Context parallelism (mirror CP): why the CP driver sends method calls and metadata to the followers.
- parallel_utils/parallel_state.py: the process groups: where the queue is made, and
cp_broadcast_object. - worker/model_runner.py: the ModelRunner class: the driver and follower paths of
_prepare_input. - shared_memory.py: the pinned memory pool for images: the other shared-memory module, for image tensors.
- Tour: One step on many GPUs: the queue in one step on many GPUs.
Check yourself#
- 1Which process writes to the CP queue, and which processes read from it?easy
Show answer
CP rank 0 (the engine process with the driver worker) writes. Each CP follower reads. The writer is
writer_rank = 0of the CPU CP group (deepinfer/distributed/shm_broadcast.py:436,deepinfer/model_executor/parallel_utils/parallel_state.py:124). - 2Does the queue carry the tensor data of the input metadata?easy
Show answer
No. It carries the keys, the small values and a
TensorMetadata(dtype, size)for each tensor. The tensors go with device broadcasts (deepinfer/model_executor/parallel_utils/communication_op.py:64-81). - 3A pickled object has exactly 1,048,576 bytes (1 MiB). Which path does it take in the CP queue?medium
Show answer
The overflow path.
overflow = len(serialized_obj) >= max_chunk_bytes(deepinfer/distributed/shm_broadcast.py:396), and 1 MiB is not smaller than 1 MiB. Byte 0 of the chunk becomes 1, and the bytes go over the local ZeroMQ socket. (The chunk also needs byte 0 for the flag, so an inline object can have at mostmax_chunk_bytes − 1bytes.) - 4Why doeshard
acquire_writereset the reader flags before it setswritten = 1, and not after?Show answer
With the other order, the row would go from
0 1 1 1to1 1 1 1and1 0 1 1before it reaches1 0 0 0. These intermediate rows say "written and already read" for a message that nobody has read. The docstring calls this intermediate case 3 "not correct" (deepinfer/distributed/shm_broadcast.py:80-85,deepinfer/distributed/shm_broadcast.py:347-355). The reset-first order keeps the row in a valid state at every moment. (Our reading ofacquire_read: a reader that sees its own flag still at 1 does not skip the chunk. It waits one more turn, because it moves to the next chunk only after it reads, lines 365-377.) - 5A follower is idle for an hour between two requests. What does its CPU do, and what appears in the logs?hard
Show answer
It spins in
acquire_read: each turn reads the flag row and callsos.sched_yield(), with no sleep, so one core stays busy (deepinfer/distributed/shm_broadcast.py:364-377). Every 60 s it writes a debug line "No available shared memory block after ... seconds" (deepinfer/distributed/shm_broadcast.py:312-315). Note: withLLM.generate, the driver keeps callingstep()while there is work. When it stops, the follower waits.