[Bugfix] Don't reuse engine core payload buffer while zmq is sending it (#50053)
Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com>
Nick Hill committed
Jul 28, 2026 at 09:09 UTC
6453fc0b8cb50668dceaf5839033b0a2a8fa7f7f
4 files changed
+179
-58
tests/v1/test_serial_utils.py
+136
@@ -423,3 +423,139 @@ def test_multiple_senders_single_receiver_ipc():
423
assert torch.allclose(decoded.prompt_embeds, original_tensor), (
424
f"Value mismatch for sender {sender_idx} msg {msg_idx}"
425
)
426
+
427
+
428
+def _logprobs_outputs(num_reqs: int, num_prompt_tokens: int):
429
+ """An EngineCoreOutputs carrying prompt logprobs, as the engine core sends
430
+ it: many requests, each with per-token tensors small enough that pyzmq
431
+ copies their frames, while the accumulated payload frame is large enough
432
+ that pyzmq sends it zero-copy."""
433
+ from vllm.v1.engine import EngineCoreOutput, EngineCoreOutputs
434
+ from vllm.v1.outputs import LogprobsTensors
435
+
436
+ outputs = []
437
+ for req in range(num_reqs):
438
+ num_tokens = num_prompt_tokens + req % 4
439
+ outputs.append(
440
+ EngineCoreOutput(
441
+ request_id=f"req-{req:08d}",
442
+ new_token_ids=[req],
443
+ new_prompt_logprobs_tensors=LogprobsTensors(
444
+ logprob_token_ids=torch.arange(
445
+ num_tokens * 2, dtype=torch.int64
446
+ ).view(num_tokens, 2),
447
+ logprobs=torch.zeros(num_tokens, 2, dtype=torch.float32),
448
+ selected_token_ranks=torch.zeros(num_tokens, dtype=torch.int32),
449
+ ),
450
+ )
451
+ )
452
+ return EngineCoreOutputs(outputs=outputs)
453
+
454
+
455
+def test_payload_buffer_reuse_does_not_corrupt_in_flight_messages():
456
+ """The engine core recycles the msgpack payload buffer across messages
457
+ (`MsgpackEncoder.encode_into`). It may only do so once zmq has finished
458
+ sending that buffer, otherwise a newer payload is delivered alongside the
459
+ older message's zero-copy tensor frames.
460
+
461
+ `Socket.send_multipart(track=True)` cannot be used to detect this: it
462
+ returns a tracker for the last frame only, and pyzmq copies frames below
463
+ `zmq.COPY_THRESHOLD` and reports them as already-sent.
464
+ """
465
+ import zmq
466
+
467
+ from vllm.v1.engine import EngineCoreOutputs
468
+ from vllm.v1.engine.core import EngineCoreProc
469
+
470
+ num_msgs = 100
471
+ encoder = MsgpackEncoder()
472
+ decoder = MsgpackDecoder(EngineCoreOutputs)
473
+ # Enough requests that the payload frame is zero-copied rather than copied
474
+ # by pyzmq, which is what makes early reuse observable.
475
+ messages = [_logprobs_outputs(300, 24 + i % 8) for i in range(num_msgs)]
476
+ assert len(encoder.encode(messages[0])[0]) >= zmq.COPY_THRESHOLD
477
+
478
+ reuse_buffers: list[bytearray] = []
479
+ pending: list[tuple[zmq.MessageTracker, bytearray]] = []
480
+ with zmq.Context() as ctx:
481
+ push = ctx.socket(zmq.PUSH)
482
+ push.bind("inproc://test-payload-reuse")
483
+ pull = ctx.socket(zmq.PULL)
484
+ pull.connect("inproc://test-payload-reuse")
485
+
486
+ for outputs in messages:
487
+ while pending and pending[0][0].done:
488
+ reuse_buffers.append(pending.pop(0)[1])
489
+ buffer = reuse_buffers.pop() if reuse_buffers else bytearray()
490
+ buffers = encoder.encode_into(outputs, buffer)
491
+ tracker = EngineCoreProc._send_msg_tracking_payload(push, buffers)
492
+ if tracker.done:
493
+ reuse_buffers.append(buffer)
494
+ else:
495
+ pending.append((tracker, buffer))
496
+
497
+ for i, sent in enumerate(messages):
498
+ received = decoder.decode(pull.recv_multipart(copy=False))
499
+ assert len(received.outputs) == len(sent.outputs), f"message {i}"
500
+ for expected, actual in zip(sent.outputs, received.outputs):
501
+ sent_ids = expected.new_prompt_logprobs_tensors.logprob_token_ids
502
+ got_ids = actual.new_prompt_logprobs_tensors.logprob_token_ids
503
+ assert actual.request_id == expected.request_id, f"message {i}"
504
+ assert torch.equal(got_ids, sent_ids), (
505
+ f"message {i} request {actual.request_id}: corrupted "
506
+ f"prompt logprobs, {got_ids.shape} vs {sent_ids.shape}"
507
+ )
508
+ push.close(linger=0)
509
+ pull.close(linger=0)
510
+
511
+
512
+def test_zero_copy_frames_survive_without_caller_side_references():
513
+ """Callers don't need to retain the encoded object until zmq has sent it:
514
+ for a zero-copy frame, zmq holds its own reference to the backing buffer.
515
+
516
+ The engine core clients rely on this when sending requests that carry
517
+ tensors (e.g. prompt embeds) without tracking the messages.
518
+
519
+ What makes that safe is that `tensor_data()` hands zmq a memoryview which
520
+ transitively references the source tensor, so refcounting - not timing -
521
+ keeps the memory from being freed and reused underneath zmq.
522
+ """
523
+ import gc
524
+
525
+ import zmq
526
+
527
+ from vllm.v1.utils import tensor_data
528
+
529
+ num_elems = 100_000 # comfortably over zmq.COPY_THRESHOLD
530
+ expected = torch.arange(num_elems, dtype=torch.int64)
531
+ encoder = MsgpackEncoder()
532
+ decoder = MsgpackDecoder(RequestWithTensor)
533
+
534
+ # The buffer handed to zmq must keep the tensor's storage alive by itself.
535
+ holder = tensor_data(expected).obj
536
+ while getattr(holder, "base", None) is not None:
537
+ holder = holder.base
538
+ assert isinstance(holder, torch.Tensor)
539
+ assert holder.data_ptr() == expected.data_ptr()
540
+
541
+ with zmq.Context() as ctx:
542
+ push = ctx.socket(zmq.PUSH)
543
+ push.bind("inproc://test-zero-copy-lifetime")
544
+ pull = ctx.socket(zmq.PULL)
545
+ pull.connect("inproc://test-zero-copy-lifetime")
546
+
547
+ request = RequestWithTensor(prompt_embeds=expected.clone(), data="req")
548
+ buffers = encoder.encode(request)
549
+ assert max(len(buf) for buf in buffers) >= zmq.COPY_THRESHOLD
550
+ push.send_multipart(buffers, copy=False)
551
+
552
+ # Drop every reference the sender holds, then churn the allocator.
553
+ del request, buffers
554
+ gc.collect()
555
+ torch.arange(num_elems * 4, dtype=torch.int64)
556
+
557
+ decoded = decoder.decode(pull.recv_multipart(copy=False))
558
+ assert decoded.prompt_embeds is not None
559
+ assert torch.equal(decoded.prompt_embeds, expected)
560
+ push.close(linger=0)
561
+ pull.close(linger=0)
vllm/envs.py
+1
-1
@@ -1547,7 +1547,7 @@ environment_variables: dict[str, Callable[[], Any]] = {
1547
# tensors above will instead be sent via a separate message.
1548
# While the sending side still actually copies the tensor
1549
# in all cases, on the receiving side, tensors above this
1550
- # limit will actually be zero-copy decoded.
1550
+ # limit will actually be zero-copy decoded. The unit is bytes.
1551
"VLLM_MSGPACK_ZERO_COPY_THRESHOLD": lambda: int(
1552
os.getenv("VLLM_MSGPACK_ZERO_COPY_THRESHOLD", "256")
1553
),
vllm/v1/engine/core.py
+30
-11
@@ -7,7 +7,7 @@ import signal
7
import threading
8
import time
9
from collections import defaultdict, deque
10
-from collections.abc import Callable, Generator
10
+from collections.abc import Callable, Generator, Sequence
11
from concurrent.futures import Future
12
from contextlib import ExitStack, contextmanager
13
from enum import IntEnum
@@ -87,7 +87,7 @@ from vllm.v1.kv_cache_interface import KVCacheConfig, get_kv_cache_spec_kind
87
from vllm.v1.metrics.stats import SchedulerIterationDetails, SchedulerStats
88
from vllm.v1.outputs import ModelRunnerOutput
89
from vllm.v1.request import Request, RequestStatus
90
-from vllm.v1.serial_utils import MsgpackDecoder, MsgpackEncoder
90
+from vllm.v1.serial_utils import MsgpackDecoder, MsgpackEncoder, bytestr
91
from vllm.v1.structured_output import StructuredOutputManager
92
from vllm.v1.utils import compute_iteration_details
93
from vllm.version import __version__ as VLLM_VERSION
@@ -1748,10 +1748,11 @@ class EngineCoreProc(EngineCore):
1748
encoder = MsgpackEncoder()
1749
# Send buffers to reuse.
1750
reuse_buffers: list[bytearray] = []
1751
- # Keep references to outputs and buffers until zmq is finished
1752
- # with them (outputs may contain tensors/np arrays whose
1753
- # backing buffers were extracted for zero-copy send).
1754
- pending = deque[tuple[zmq.MessageTracker, Any, bytearray]]()
1751
+ # Payload buffers that can't be reused yet because zmq may still be
1752
+ # sending them.
1753
+ # Buffers of the zero-copy tensor/ndarray frames don't need tracking
1754
+ # here: zmq itself holds a reference to each until it's done with it.
1755
+ pending = deque[tuple[zmq.MessageTracker, bytearray]]()
1756
1757
# We must set linger to ensure the ENGINE_CORE_DEAD
1758
# message is sent prior to closing the socket.
@@ -1792,20 +1793,38 @@ class EngineCoreProc(EngineCore):
1793
1794
# Reclaim buffers that zmq is finished with.
1795
while pending and pending[-1][0].done:
1795
- reuse_buffers.append(pending.pop()[2])
1796
+ reclaimed = pending.pop()[1]
1797
+ if len(reuse_buffers) < max_reuse_bufs:
1798
+ reuse_buffers.append(reclaimed)
1799
1800
buffer = reuse_buffers.pop() if reuse_buffers else bytearray()
1801
buffers = encoder.encode_into(outputs, buffer)
1799
- tracker = sockets[client_index].send_multipart(
1800
- buffers, copy=False, track=True
1802
+ tracker = self._send_msg_tracking_payload(
1803
+ sockets[client_index], buffers
1804
)
1805
if not tracker.done:
1803
- ref = outputs if len(buffers) > 1 else None
1804
- pending.appendleft((tracker, ref, buffer))
1806
+ pending.appendleft((tracker, buffer))
1807
elif len(reuse_buffers) < max_reuse_bufs:
1808
# Limit the number of buffers to reuse.
1809
reuse_buffers.append(buffer)
1810
1811
+ @staticmethod
1812
+ def _send_msg_tracking_payload(
1813
+ socket: zmq.Socket, buffers: Sequence[bytestr]
1814
+ ) -> zmq.MessageTracker:
1815
+ """Send `buffers` as a zero-copy multipart message, returning a tracker
1816
+ for the *first* frame.
1817
+
1818
+ Used instead of `Socket.send_multipart()` because we reuse the buffer
1819
+ passed to `MsgpackEncoder.encode_into()`: `send_multipart()` returns a
1820
+ tracker for the last frame only.
1821
+ """
1822
+ more_flag = zmq.SNDMORE if len(buffers) > 1 else 0
1823
+ tracker = socket.send(buffers[0], more_flag, copy=False, track=True)
1824
+ if more_flag:
1825
+ socket.send_multipart(buffers[1:], copy=False)
1826
+ return tracker
1827
+
1828
def _handle_request_preproc_error(self, request: EngineCoreRequest) -> None:
1829
"""Log and return a request-scoped error response for exceptions raised
1830
from the add request preprocessing in the input socket processing thread.
vllm/v1/engine/core_client.py
+12
-46
@@ -7,7 +7,7 @@ import sys
7
import uuid
8
import weakref
9
from abc import ABC, abstractmethod
10
-from collections import Counter, defaultdict, deque
10
+from collections import Counter, defaultdict
11
from collections.abc import Awaitable, Callable, Sequence
12
from concurrent.futures import Future
13
from dataclasses import dataclass
@@ -671,11 +671,6 @@ class MPClient(EngineCoreClient):
671
self.core_engine: EngineIdentity = self.core_engines[0]
672
self.utility_results: dict[int, AnyFuture] = {}
673
674
- # Request objects which may contain pytorch-allocated tensors
675
- # that we need to keep references to until zmq is done with the
676
- # underlying data.
677
- self.pending_messages = deque[tuple[zmq.MessageTracker, Any]]()
678
-
674
# Start monitoring engine core processes for unexpected failures
675
self.start_engine_core_monitor()
676
@@ -707,14 +702,6 @@ class MPClient(EngineCoreClient):
702
if self.resources.engine_dead:
703
raise EngineDeadError()
704
710
- def add_pending_message(self, tracker: zmq.MessageTracker, msg: Any):
711
- if not tracker.done:
712
- self.pending_messages.appendleft((tracker, msg))
713
-
714
- def free_pending_messages(self):
715
- while self.pending_messages and self.pending_messages[-1][0].done:
716
- self.pending_messages.pop()
717
-
705
def dp_engines_running(self) -> bool:
706
return self.engines_running
707
@@ -896,17 +883,12 @@ class SyncMPClient(MPClient):
883
884
def _send_input(self, request_type: EngineCoreRequestType, request: Any):
885
self.ensure_alive()
899
- self.free_pending_messages()
886
# (Identity, RequestType, SerializedRequest)
887
msg = (self.core_engine, request_type.value, *self.encoder.encode(request))
902
-
903
- if len(msg) <= 3:
904
- # No auxiliary buffers => no tensor backing buffers in request.
905
- self.input_socket.send_multipart(msg, copy=False)
906
- return
907
-
908
- tracker = self.input_socket.send_multipart(msg, copy=False, track=True)
909
- self.add_pending_message(tracker, request)
888
+ # Any zero-copy tensor/ndarray frames are kept alive by zmq itself
889
+ # until it's finished sending them (there is a ref chain from the underlying
890
+ # memoryview back to the original owning tensor/ndarray).
891
+ self.input_socket.send_multipart(msg, copy=False)
892
893
def call_utility(self, method: str, *args) -> Any:
894
call_id = uuid.uuid1().int >> 64
@@ -1129,32 +1111,16 @@ class AsyncMPClient(MPClient):
1111
engine = self.core_engine
1112
1113
message = (request_type.value, *self.encoder.encode(request))
1132
- return self._send_input_message(message, engine, request)
1114
+ return self._send_input_message(message, engine)
1115
1116
def _send_input_message(
1135
- self, message: tuple[bytestr, ...], engine: EngineIdentity, objects: Any
1117
+ self, message: tuple[bytestr, ...], engine: EngineIdentity
1118
) -> Awaitable[Any]:
1137
- """
1138
- objects is a reference to retain until zmq is finished with the
1139
- buffers, in case they were extracted from tensors in the request.
1140
- """
1119
self.ensure_alive()
1142
- self.free_pending_messages()
1143
-
1144
- msg = (engine,) + message
1145
- if not objects or len(msg) <= 3:
1146
- # No auxiliary buffers => no tensor backing buffers in request.
1147
- return self.input_socket.send_multipart(msg, copy=False)
1148
-
1149
- future: asyncio.Future[zmq.MessageTracker]
1150
- future = self.input_socket.send_multipart(msg, copy=False, track=True)
1151
-
1152
- def add_pending(f: asyncio.Future[zmq.MessageTracker]):
1153
- with contextlib.suppress(BaseException):
1154
- self.add_pending_message(f.result(), objects)
1155
-
1156
- future.add_done_callback(add_pending)
1157
- return future
1120
+ # Any zero-copy tensor/ndarray frames are kept alive by zmq itself
1121
+ # until it's finished sending them (there is a ref chain from the underlying
1122
+ # memoryview back to the original owning tensor/ndarray).
1123
+ return self.input_socket.send_multipart((engine,) + message, copy=False)
1124
1125
async def call_utility_async(self, method: str, *args) -> Any:
1126
return await self._call_utility_async(method, *args, engine=self.core_engine)
@@ -1169,7 +1135,7 @@ class AsyncMPClient(MPClient):
1135
EngineCoreRequestType.UTILITY.value,
1136
*self.encoder.encode((self.client_index, call_id, method, args)),
1137
)
1172
- await self._send_input_message(message, engine, args)
1138
+ await self._send_input_message(message, engine)
1139
self._ensure_output_queue_task()
1140
return await future
1141