Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
113 changes: 104 additions & 9 deletions components/src/dynamo/common/multimodal/mm_kwargs_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import uuid
from abc import ABC, abstractmethod
from queue import Queue
from typing import Any, Awaitable
from typing import Any

import torch
from pydantic import BaseModel
Expand All @@ -39,6 +39,11 @@

logger = logging.getLogger(__name__)

# Upper bound on how long cleanup() waits for the backend to read a transferred
# payload before releasing the NIXL-registered buffer anyway. Unbounded waiting
# pins the buffer for the process lifetime when the read never happens.
MM_NIXL_CLEANUP_TIMEOUT_S = float(os.environ.get("DYN_MM_NIXL_CLEANUP_TIMEOUT_S", "60"))


# ---------------------------------------------------------------------------
# Wire protocol
Expand Down Expand Up @@ -228,8 +233,14 @@ def _is_available(self) -> bool:

async def _encode_item(
self, idx: int, pickled: bytes
) -> tuple[TensorTransferSpec, Awaitable[None]]:
"""Register pickled bytes with NIXL and return the spec + completion."""
) -> tuple[TensorTransferSpec, Any]:
"""Register pickled bytes with NIXL and return the spec + operation.

The second element is the ``ReadableOperation`` itself, typed as ``Any``
to match the base hook's opaque ``cleanup_item`` contract and to avoid
depending on ``dynamo.nixl_connect``, which is imported lazily so the
module stays importable where NIXL is unavailable.
"""
with _nvtx.annotate("mm_nixl:register_descriptor", color="magenta"):
pickled_tensor = torch.frombuffer(bytearray(pickled), dtype=torch.uint8)
descriptor = self._nixl_connect.Descriptor(pickled_tensor)
Expand All @@ -243,7 +254,10 @@ async def _encode_item(
dtype_str="uint8",
serialized_request=readable_op.metadata().model_dump(),
)
return spec, readable_op.wait_for_completion()
# Hand back the operation itself, not just its completion awaitable:
# cleanup() must be able to release the registered memory region even
# when the remote side never reads it.
return spec, readable_op

def _assemble_extra_args(
self,
Expand All @@ -262,15 +276,96 @@ def _assemble_extra_args(
)
return {"mm_kwargs_nixl": metadata.model_dump()}

def _release_all(self, items: list[Any]) -> None:
"""Release every operation. Best-effort by design.

A broad ``except`` that logs instead of re-raising is deliberate, and
is the kind of exception the style guide asks to be justified inline:

* the sibling ``MmKwargsShmSender.cleanup()`` is best-effort too, so
raising here would make the two senders diverge on failure;
* the only caller awaits this from a bare ``finally``
(``vllm_processor._generator_inner``) with no guard, so a raise would
surface as a late exception after the stream has completed and, on a
client cancel, would *replace* the in-flight ``CancelledError``;
* aborting the loop early would leak the very buffers this frees.

Releasing every item is what fixes the leak; reporting failures at
warning level is enough for monitoring to notice them.

Each release is a blocking native call (``deregister_memory``). Measured
cost on GB200 for 8-20 MB buffers: p50 0.005 ms, p95 0.016 ms, i.e.
~0.02 ms of event-loop time for a 4-image request -- well below the cost
of handing off to an executor, so it stays inline.
"""
for op in items:
try:
# ReadableOperation.__exit__ -> _release() -> deregister.
# _release() skips descriptors that are already deregistered,
# so this stays safe alongside __del__.
op.__exit__(None, None, None)

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.

_release_all loops N synchronous native releases here: op.__exit__()_release()Descriptor.deregister_with_connector()connection._nixl.deregister_memory(), which is a blocking native call. This runs inside async cleanup() on the single frontend, once per request. If deregister_memory isn't cheap, this stalls the event loop proportional to the buffer count. Could we confirm the perf impact, or maybe offloading via run_in_executor?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Measured it rather than guessing — you were right to ask, and the answer is that it is cheap.

Timed op.__exit__() -> _release() -> deregister_memory() per buffer, real NIXL on GB200:

buffer size p50 p95 max
20 MB 0.005 ms 0.016 ms 0.039 ms
8 MB 0.005 ms 0.013 ms 0.038 ms

So ~5 us per release: ~0.02 ms of event-loop time for a 4-image request, 0.2 ms for 20 buffers. That is below the cost of a run_in_executor hand-off, so I have left it inline and recorded the numbers in the _release_all docstring so the question does not have to be re-derived later.

Leaving this thread open rather than resolving it — if you would still prefer the executor defensively (e.g. you expect pathological buffer counts per request that I have not exercised), say so and I will switch it.

except Exception:
logger.warning(
"[NIXL-Sender] failed to release transfer", exc_info=True
)

async def cleanup(self, items: list[Any]) -> None:
"""Await NIXL completion futures so memory regions can be deregistered."""
"""Await NIXL completion, then release the registered memory regions.

The wait is bounded. ``ReadableOperation.wait_for_completion()`` only
resolves once the backend actually reads the buffer, so a request that
is cancelled, rejected, or routed to a worker that dies leaves it
pending forever. Because the pending coroutine holds a reference to the
operation, the NIXL-registered buffer is never deregistered and the
frontend's RSS grows by the size of every un-read payload for the
lifetime of the process.

Releasing in ``finally`` is the part that actually fixes the leak: the
timeout alone would stop the hang while still pinning the memory.

``return_exceptions=True`` matters for correctness, not just tidiness:
without it ``gather`` propagates the first failure while its siblings
are still running, and the release below would then deregister buffers
whose reads are still in flight.

Trade-off worth stating: once the timeout elapses the buffer is
deregistered, so a backend that reads *later* will see a NIXL transfer
error rather than data. That is deliberate -- the alternative is
unbounded growth -- and is why the timeout is generous and tunable via
``DYN_MM_NIXL_CLEANUP_TIMEOUT_S``.
"""
if not items:
return
try:
await asyncio.gather(*items)
logger.debug("[NIXL-Sender] all transfers completed")
except Exception:
logger.warning("[NIXL-Sender] transfer completion failed", exc_info=True)
results = await asyncio.wait_for(
asyncio.gather(
*(op.wait_for_completion() for op in items),
return_exceptions=True,
),
timeout=MM_NIXL_CLEANUP_TIMEOUT_S,
)
except asyncio.TimeoutError:
logger.warning(
"[NIXL-Sender] %d transfer(s) not read within %.1fs; "
"releasing buffers anyway",
len(items),
MM_NIXL_CLEANUP_TIMEOUT_S,
)
else:
failures = [r for r in results if isinstance(r, BaseException)]
if failures:
logger.warning(
"[NIXL-Sender] %d of %d transfer(s) failed to complete; "
"first error: %r",
len(failures),
len(items),
failures[0],
)
else:
logger.debug("[NIXL-Sender] all transfers completed")
finally:
# The release is the actual leak fix, so it must always run.
self._release_all(items)


# ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@

"""Unit tests for MM kwargs transfer (NIXL sender/receiver + SHM sender/receiver)."""

import asyncio
import pickle
from unittest.mock import MagicMock

import pytest

from dynamo.common.multimodal import mm_kwargs_transfer
from dynamo.common.multimodal.mm_kwargs_transfer import (
MmKwargsNixlSender,
MmKwargsShmReceiver,
Expand Down Expand Up @@ -124,6 +126,126 @@ async def test_prepare_skips_none_data_in_multi_feature(self):
assert feats[2].mm_hash == "hash_2"


class TestMmKwargsNixlSenderCleanup:
"""cleanup() must be bounded and must always release registered buffers."""

class _FakeOp:
"""Stands in for ReadableOperation: completion never resolves."""

def __init__(self, never_completes: bool = True):
self.released = False
self._never_completes = never_completes

async def wait_for_completion(self) -> None:
if self._never_completes:
await asyncio.Event().wait() # pends forever

def __exit__(self, exc_type, exc_value, traceback) -> None:
self.released = True

@pytest.mark.asyncio
@pytest.mark.timeout(10)
async def test_cleanup_is_bounded_and_releases_when_never_read(self, monkeypatch):
"""A backend that never reads must not pin the buffer forever.

Before this was bounded, cleanup() awaited the completion future
indefinitely; the pending coroutine held the operation alive and its
NIXL registration was never dropped, so the frontend leaked the full
payload for every un-read request.
"""
monkeypatch.setattr(mm_kwargs_transfer, "MM_NIXL_CLEANUP_TIMEOUT_S", 0.05)
sender = MmKwargsNixlSender.__new__(MmKwargsNixlSender)
ops = [self._FakeOp(), self._FakeOp()]

await asyncio.wait_for(sender.cleanup(ops), timeout=5)

assert all(op.released for op in ops), "buffers must be released on timeout"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@pytest.mark.asyncio
@pytest.mark.timeout(10)
async def test_cleanup_releases_on_normal_completion(self, monkeypatch):
"""The happy path still releases."""
monkeypatch.setattr(mm_kwargs_transfer, "MM_NIXL_CLEANUP_TIMEOUT_S", 5.0)
sender = MmKwargsNixlSender.__new__(MmKwargsNixlSender)
ops = [self._FakeOp(never_completes=False)]

await sender.cleanup(ops)

assert ops[0].released

@pytest.mark.asyncio
@pytest.mark.timeout(10)
async def test_one_failing_op_does_not_release_a_still_pending_op_early(
self, monkeypatch
):
"""A failing completion must not cut the wait short for its siblings.

Without ``return_exceptions=True``, ``gather`` propagates the first
error while the other completion coroutines are still running, and the
release below would then deregister a buffer whose backend read is
still in flight.
"""
monkeypatch.setattr(mm_kwargs_transfer, "MM_NIXL_CLEANUP_TIMEOUT_S", 5.0)
sender = MmKwargsNixlSender.__new__(MmKwargsNixlSender)

released_while_pending = []

class _RaisingOp(TestMmKwargsNixlSenderCleanup._FakeOp):
async def wait_for_completion(self) -> None:
raise RuntimeError("transfer failed")

class _SlowOp(TestMmKwargsNixlSenderCleanup._FakeOp):
def __init__(self):
super().__init__(never_completes=False)
self.done = False

async def wait_for_completion(self) -> None:
await asyncio.sleep(0.2)
self.done = True

def __exit__(self, exc_type, exc_value, traceback) -> None:
if not self.done:
released_while_pending.append(self)
super().__exit__(exc_type, exc_value, traceback)

slow = _SlowOp()
# cleanup() is best-effort and does not raise: the caller awaits it
# from a bare finally, where a raise would replace an in-flight
# CancelledError.
await sender.cleanup([_RaisingOp(), slow])

assert slow.done, "cleanup returned before the pending transfer finished"
assert not released_while_pending, "released a buffer whose read was in flight"
assert slow.released, "sibling buffer was not released"

@pytest.mark.asyncio
@pytest.mark.timeout(10)
async def test_release_failure_does_not_stop_remaining_releases(self):
"""A failing release must not stop the remaining buffers being freed."""
sender = MmKwargsNixlSender.__new__(MmKwargsNixlSender)

class _BadRelease(TestMmKwargsNixlSenderCleanup._FakeOp):
def __init__(self):
super().__init__(never_completes=False)

def __exit__(self, exc_type, exc_value, traceback) -> None:
raise RuntimeError("deregister failed")

good = TestMmKwargsNixlSenderCleanup._FakeOp(never_completes=False)
# Best-effort: the failure is logged, not raised, and the loop continues.
await sender.cleanup([_BadRelease(), good])

assert (
good.released
), "a later buffer was skipped after an earlier release failed"

@pytest.mark.asyncio
@pytest.mark.timeout(10)
async def test_cleanup_with_no_items_is_a_noop(self):
sender = MmKwargsNixlSender.__new__(MmKwargsNixlSender)
await sender.cleanup([])


class TestMmKwargsShmTransfer:
"""Tests for the SHM sender/receiver round-trip."""

Expand Down
Loading