Skip to content

fix(multimodal): bound MmKwargsNixlSender.cleanup and always release buffers - #12759

Merged
rmccorm4 merged 6 commits into
ai-dynamo:mainfrom
yifjiang:fix/mm-nixl-cleanup-unbounded-wait
Aug 11, 2026
Merged

fix(multimodal): bound MmKwargsNixlSender.cleanup and always release buffers#12759
rmccorm4 merged 6 commits into
ai-dynamo:mainfrom
yifjiang:fix/mm-nixl-cleanup-unbounded-wait

Conversation

@yifjiang

@yifjiang yifjiang commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

MmKwargsNixlSender.cleanup() awaits transfer completion with no bound:

await asyncio.gather(*items)

ReadableOperation.wait_for_completion() resolves only once the backend actually reads the registered buffer. A request that is cancelled, rejected before the read, or routed to a worker that dies leaves it pending forever. The pending coroutine holds a reference to the operation, so _release() never runs, the NIXL registration is never dropped, and the frontend retains the payload of every un-read transfer for the life of the process.

Observed on a multimodal deployment as frontend RSS climbing under image traffic until the container hit its memory limit and was OOMKilled, repeatedly, over days. Workers were unaffected. Disabling the NIXL multimodal path stopped the growth: frontends have since held flat at ~2 GiB where they previously reached ~62 GiB.

cleanup() receives only the completion awaitables, so it has no handle to release with — a timeout alone would stop the hang while still pinning the memory. Both halves below are required.

Fix

  • _encode_item() returns the ReadableOperation instead of only readable_op.wait_for_completion(). The abstract hook documents this value as "an opaque handle the caller passes to cleanup()", so no public signature changes.

  • cleanup() bounds the wait with asyncio.wait_for (DYN_MM_NIXL_CLEANUP_TIMEOUT_S, default 60) and releases every operation in a finally block. ReadableOperation.__exit__ calls _release(), which skips descriptors that are already deregistered, so the explicit release stays safe alongside __del__.

Test

The package __init__ chain imports compiled extensions (dynamo._core, dynamo.llm) that I cannot build locally, so I loaded the module directly and drove cleanup() with a stand-in operation whose completion never resolves:

before after
cleanup() returns never — still hanging at 4.0 s 0.20 s
buffers released none all

Adds TestMmKwargsNixlSenderCleanup covering the never-read timeout path, normal completion, the empty-items no-op, and release when the await raises.

  • pytest components/src/dynamo/common/tests/multimodal/test_mm_kwargs_transfer.py -k Cleanup
  • Confirm the timeout path logs the warning and does not raise
  • Multimodal request over NIXL where the backend reads normally — unchanged behaviour
  • Cancel a multimodal request mid-transfer and confirm frontend RSS returns to baseline

Notes

  • The committed tests have not been run under the repo's own runner — conftest.py imports dynamo._core, which needs the Rust extension built. CI should be the judge.
  • mm_kwargs_transfer.py has not been modified since it was added in feat(multimodal): move MM routing into vLLM frontend processor #8065 (2026-04-24), so this behaviour is present in every release since.
  • The 60 s default is a judgement call; happy to change it or drop the env var.
  • Cancelling the gather also drops the coroutine references, so __del__ would eventually release even without the explicit __exit__. The explicit call makes it deterministic rather than GC-timing dependent.

Summary by CodeRabbit

  • Bug Fixes
    • Improved cleanup reliability for multimodal transfers.
    • Cleanup now stops waiting after 60 seconds if an operation does not complete.
    • Registered memory is released even when cleanup encounters timeouts or failures.
    • Added improved handling and logging for cleanup errors.

@copy-pr-bot

copy-pr-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

👋 Hi yifjiang! Thank you for contributing to ai-dynamo/dynamo.

Just a reminder: The NVIDIA Test Github Validation CI runs an essential subset of the testing framework to quickly catch errors.Your PR reviewers may elect to test the changes comprehensively before approving your changes.

🚀

@github-actions github-actions Bot added the external-contribution Pull request is from an external contributor label Aug 6, 2026
@yifjiang
yifjiang marked this pull request as ready for review August 7, 2026 00:44
@yifjiang
yifjiang requested review from a team as code owners August 7, 2026 00:44

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread components/src/dynamo/common/multimodal/mm_kwargs_transfer.py Outdated
Comment thread components/src/dynamo/common/multimodal/mm_kwargs_transfer.py Outdated
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

NIXL sender cleanup now retains operation objects, waits for completion with a configurable 60-second default timeout, logs failures, and releases registered memory regions in all cases. Tests cover timeout, normal completion, and empty input.

Changes

NIXL cleanup lifecycle

Layer / File(s) Summary
Timeout-bounded cleanup and release
components/src/dynamo/common/multimodal/mm_kwargs_transfer.py, components/src/dynamo/common/tests/multimodal/test_mm_kwargs_transfer.py
The cleanup path uses MM_NIXL_CLEANUP_TIMEOUT_S, retains readable NIXL operations, bounds completion waits, logs failures, and releases registered memory regions in a finally block. Tests cover hanging operations, normal completion, and empty input.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the problem, fix, tests, and limitations, but it omits the required Related Issues section and confirmation. Add the required Related Issues section and either link the relevant issue or confirm that this PR has no related issue.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the bounded cleanup and buffer-release changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
components/src/dynamo/common/multimodal/mm_kwargs_transfer.py (1)

251-254: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Align the cleanup-item type contract.

_encode_item() declares Awaitable[None] but returns an operation that provides wait_for_completion() and __exit__(). Define a cleanup-handle Protocol and use it in the sender contracts. Cleanup items are passed only to cleanup() and are not awaited directly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/src/dynamo/common/multimodal/mm_kwargs_transfer.py` around lines
251 - 254, Define a cleanup-handle Protocol exposing the operation methods used
by cleanup, including wait_for_completion() and __exit__(), then update
_encode_item() and the sender cleanup-item type contracts to use this Protocol
instead of Awaitable[None]. Preserve returning the operation itself from
_encode_item() and ensure cleanup items are handled only through cleanup().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/src/dynamo/common/multimodal/mm_kwargs_transfer.py`:
- Around line 305-314: Update the operation-release loop to collect exceptions
from each op.__exit__ call instead of only logging them at debug level. Continue
attempting release for every operation, then re-raise the retained release error
after the loop so cleanup failures propagate to callers.
- Around line 290-303: The cleanup await in the NIXL transfer completion flow
must wait for every operation before buffers are released. Update the
asyncio.gather call around op.wait_for_completion() to use
return_exceptions=True, then inspect gathered results and re-raise completion
errors only after all operations finish; preserve the existing timeout handling,
and add a regression test covering one failing operation alongside one blocking
operation.

In `@components/src/dynamo/common/tests/multimodal/test_mm_kwargs_transfer.py`:
- Around line 146-161: Add the pytest timeout marker to
test_cleanup_is_bounded_and_releases_when_never_read while retaining its local
asyncio.wait_for assertion and existing cleanup behavior.

---

Nitpick comments:
In `@components/src/dynamo/common/multimodal/mm_kwargs_transfer.py`:
- Around line 251-254: Define a cleanup-handle Protocol exposing the operation
methods used by cleanup, including wait_for_completion() and __exit__(), then
update _encode_item() and the sender cleanup-item type contracts to use this
Protocol instead of Awaitable[None]. Preserve returning the operation itself
from _encode_item() and ensure cleanup items are handled only through cleanup().
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f26923f2-58ce-4434-bdcc-19d76104a816

📥 Commits

Reviewing files that changed from the base of the PR and between 4a34a46 and a18e47a.

📒 Files selected for processing (2)
  • components/src/dynamo/common/multimodal/mm_kwargs_transfer.py
  • components/src/dynamo/common/tests/multimodal/test_mm_kwargs_transfer.py

Comment thread components/src/dynamo/common/multimodal/mm_kwargs_transfer.py Outdated
Comment thread components/src/dynamo/common/multimodal/mm_kwargs_transfer.py Outdated
@yifjiang

yifjiang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test a18e47a

@datadog-official

This comment has been minimized.

@yifjiang

yifjiang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test a49b80a

@yifjiang
yifjiang temporarily deployed to external_collaborator August 7, 2026 01:03 — with GitHub Actions Inactive
@yifjiang

yifjiang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Tagging reviewers with a suggested split, since this touches two areas:

  • @krishung5 — you added mm_kwargs_transfer.py in feat(multimodal): move MM routing into vLLM frontend processor #8065, and it is still the file's only commit. The part worth your eye is that _encode_item() now returns the ReadableOperation rather than readable_op.wait_for_completion(). The abstract hook documents cleanup_item as "an opaque handle the caller passes to cleanup()", so I read that as intended latitude — but you would know if it was meant more narrowly.

  • @furionw — you reviewed feat(multimodal): move MM routing into vLLM frontend processor #8065 and are closest to this area currently; mainly a sanity check that the cleanup contract still matches how the frontend processor drives prepare()/cleanup().

  • @whoisj — not a multimodal question: the fix's correctness rests on two nixl_connect properties I verified by reading the source but would rather have confirmed by an owner. (1) Calling ReadableOperation.__exit__() explicitly is safe alongside the later __del__, since both route to _release(). (2) _release() is idempotent because it guards on d.is_registered. If either is untrue, the finally block needs rethinking.

Context on urgency: this is a live leak. On a multimodal deployment the frontend grew to ~62 GiB (97% of its limit) and OOMKilled repeatedly; disabling the NIXL multimodal path holds it flat at ~2 GiB with zero restarts. Anyone running DYNAMO_MM_TRANSFER=nixl today is exposed, and the code has been unchanged since April.

CI is green (56 passed, 0 failures), including dynamo-runtime / test on both arches, which exercises the added regression tests.

@yifjiang

yifjiang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 9bb7745

@yifjiang
yifjiang temporarily deployed to external_collaborator August 7, 2026 18:54 — with GitHub Actions Inactive
@yifjiang

yifjiang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all three findings were valid and are addressed in 9bb7745.

1. Premature release (@coderabbitai, Major) — real bug, fixed. gather propagated the first completion error while its siblings were still running, so the release could deregister a buffer whose read was still in flight. Now uses return_exceptions=True, so every operation is awaited (or cancelled by the timeout) before anything is released, and the results are inspected afterwards.

Confirmed against the previous revision with a slow operation alongside a raising one:

before after
slow op finished before release False True
released while read in flight True False

Added the regression test you asked for (one failing op + one blocking op).

2. Swallowed release errors (@devin-ai-integration) — fixed. You're right that this contradicted .ai/python-guidelines.md. The broad except stays, because releasing is best-effort across all items and aborting mid-loop would leak exactly the buffers this code frees — but it now logs at warning, collects the first failure, and re-raises it after every item has been attempted. The raise sits outside the finally so it can never mask an already-propagating exception (e.g. cancellation). The justification is in a docstring on _release_all. Added a test asserting a failing release does not skip the remaining buffers.

3. Late-read race after timeout (@devin-ai-integration) — confirmed, and now documented rather than changed. Your reading is correct: once the timeout elapses the descriptor is deregistered, so a backend that reads at, say, 61 s gets a NIXL transfer error rather than data. That is the deliberate trade-off — the alternative is the unbounded growth this PR exists to stop — and it is now stated explicitly in the cleanup() docstring.

On the 60 s default: it is meant to be comfortably above worst-case backend queueing, and it is tunable via DYN_MM_NIXL_CLEANUP_TIMEOUT_S precisely because the right value is deployment-specific. I don't have a measured distribution of backend read latency under load, so I'd rather not claim 60 s is provably safe — if anyone has that data and it argues for a higher default, I'll take the correction.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@yifjiang Thanks for the detailed follow-up.

The return_exceptions=True change prevents release while another read is still active. The regression test covers the reported failure mode. The _release_all behavior also preserves best-effort release without hiding release failures.

The documented timeout trade-off is clear. The configurable timeout is appropriate because backend queueing latency is deployment-specific.

🐇 ✅


✏️ Learnings added
Learnt from: yifjiang
URL: https://github.com/ai-dynamo/dynamo/pull/12759

Timestamp: 2026-08-07T18:55:22.435Z
Learning: In `components/src/dynamo/common/multimodal/mm_kwargs_transfer.py`, `MmKwargsNixlSender.cleanup()` uses a bounded NIXL cleanup timeout to prevent unbounded registered-buffer growth. When the timeout expires, it can deregister a descriptor before a late backend read; the resulting NIXL transfer failure is an intentional trade-off and the timeout is configurable through `DYN_MM_NIXL_CLEANUP_TIMEOUT_S`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@yifjiang

yifjiang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

End-to-end verification: the leak is reproduced, and this fix stops it

Until now this PR only had unit-level evidence (cleanup() returns instead of hanging). That does not actually demonstrate the leak is fixed, so I built a reproduction and measured it.

Reproduction. Drives MmKwargsNixlSender directly with no backend reader — the trigger the leak requires. This is why it had never reproduced under ordinary load: a normal backend always reads, and ~40k mixed requests (text, cancellations, images, multi-image, concurrency 8 and 64) never triggered it. Real NIXL, real registration, GB200 arm64, 20 MB payloads.

Results (RSS growth after the loop, gc settled):

iterations unpatched patched
20 440 MB 59 MB
40 841 MB 59 MB
cleanup tasks completed 0/40 40/40

Unpatched growth scales linearly with request count — unbounded. Patched growth is constant at 59 MB across both loads, i.e. working set rather than a leak. Not one unpatched cleanup task ever completed; every patched one did.

Both fixes are required — worth knowing before this lands

Running the same test without MALLOC_MMAP_THRESHOLD_ set produced a misleading near-null result:

unpatched patched
default allocator 439 MB 419 MB (looks ineffective)
MALLOC_MMAP_THRESHOLD_=131072 440 MB 59 MB

The two changes address different halves and neither is sufficient alone:

  • this fix frees the objects — without it they are pinned forever by the un-resolving coroutine
  • pinning glibc's mmap threshold returns the freed memory to the OS — without it, multi-MB blocks ratchet the dynamic threshold and RSS never drops even though the objects are gone

That explains something that was previously unclear: pinning the allocator threshold alone was deployed first and was only ever a partial fix — it roughly doubled frontend lifetime but the process still OOMKilled, because the buffers were never actually released. Anyone applying only one of the two should expect exactly that.

I nearly reported this fix as ineffective on the strength of the first run. Worth stating plainly so a reviewer reproducing it doesn't reach the same wrong conclusion.

@yifjiang

yifjiang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test d19f7a0

@yifjiang
yifjiang temporarily deployed to external_collaborator August 7, 2026 21:06 — with GitHub Actions Inactive
@yifjiang

yifjiang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

All review findings are now addressed in d19f7a0.

finding resolution
🟠 Wait for all operations before releasing buffers return_exceptions=True so every sibling is awaited (or cancelled by the timeout) before anything is released; completion errors inspected afterwards and now re-raised, as asked. Regression test added with one raising op alongside one blocking op.
🟡 Do not hide release failures Release errors are logged at warning, retained, and re-raised after every item has been attempted — aborting mid-loop would leak the very buffers this frees. The raise sits outside the finally so it cannot mask an already-propagating exception.
🟡 Add @pytest.mark.timeout(10) Added to all five cleanup tests. The local asyncio.wait_for assertions stay; the marker additionally catches an event-loop stall. pytest-timeout==2.4.0 is already a dependency and timeout is registered in pyproject.toml.

One deliberate narrowing on the re-raise: only Exception instances are surfaced, not bare BaseException. return_exceptions=True also captures CancelledError from a cancelled sibling, and converting that into a raised fault here would be wrong. Precedence is release-error first, then completion-error, since a failed release means a buffer is still registered.

Re-verified end to end after these changes

The behaviour changed, so the leak measurement was re-run on real NIXL (GB200 arm64) rather than assumed:

growth cleanups completed
unpatched, 40 iters 841 MB 0/40
patched (pre-review), 40 iters 59 MB 40/40
patched (post-review), 40 iters 60 MB 40/40

No regression: growth stays constant with load rather than scaling with it.

Also re-checked: black/isort/flake8 clean, and mypy shows the same two pre-existing memoryview findings as the unmodified file — no new ones.

@yifjiang
yifjiang force-pushed the fix/mm-nixl-cleanup-unbounded-wait branch from d19f7a0 to a2eb20a Compare August 8, 2026 00:18
@yifjiang
yifjiang temporarily deployed to external_collaborator August 8, 2026 00:18 — with GitHub Actions Inactive
@dynamo-ops

Copy link
Copy Markdown
Contributor

/ok to test a2eb20a

1 similar comment
@yifjiang

yifjiang commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test a2eb20a

@krishung5 krishung5 left a comment

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.

Thanks for the fix! Left some comments.

# 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.

Comment thread components/src/dynamo/common/multimodal/mm_kwargs_transfer.py Outdated
@yifjiang

Copy link
Copy Markdown
Contributor Author

/ok to test 82bcb2b

@yifjiang
yifjiang temporarily deployed to external_collaborator August 10, 2026 23:17 — with GitHub Actions Inactive
@dynamo-ops

Copy link
Copy Markdown
Contributor

/ok to test 82bcb2b

…buffers

MmKwargsNixlSender.cleanup() awaited transfer completion with no timeout:

    await asyncio.gather(*items)

ReadableOperation.wait_for_completion() only resolves once the backend
actually reads the registered buffer. A request that is cancelled,
rejected before the read, or routed to a worker that dies leaves it
pending forever. Because the pending coroutine holds a reference to the
operation, _release() never runs and the NIXL registration is never
dropped, so the frontend retains the full payload of every un-read
transfer for the lifetime of the process.

On a production multimodal deployment this presented as frontend RSS
climbing steadily under image traffic until the container hit its memory
limit and was OOMKilled, repeatedly, while workers were unaffected.

Two changes, both needed:

1. _encode_item() now returns the ReadableOperation instead of only its
   completion awaitable. cleanup_item is documented as an opaque handle
   passed straight back to cleanup(), so this does not alter any public
   signature.

2. cleanup() bounds the wait (DYN_MM_NIXL_CLEANUP_TIMEOUT_S, default 60)
   and releases the operations in a finally block.

The release is the part that fixes the leak. A timeout alone would stop
the hang while still pinning the memory, since cleanup() previously had
no handle on which to call _release(). _release() skips descriptors that
are already deregistered, so the explicit __exit__ remains safe
alongside __del__.

Verified by loading the module directly (the package __init__ chain
requires compiled extensions):

  unfixed: cleanup never returns; 0 buffers released
  fixed:   returns in 0.20s; all buffers released

Adds regression tests covering the never-read timeout path, normal
completion, the empty-items no-op, and release when the await raises.

Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
mypy: _encode_item still declared tuple[TensorTransferSpec,
Awaitable[None]] while it now returns the ReadableOperation.

Annotates the second element as Any, matching the base hook's opaque
cleanup_item contract and avoiding a dependency on dynamo.nixl_connect,
which is imported lazily so the module stays importable where NIXL is
unavailable. Drops the now-unused Awaitable import.

Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
… errors

Addresses review findings on this PR.

1. Premature release (real bug). asyncio.gather propagates the first
   completion error while its siblings are still running, so the release
   that follows could deregister a buffer whose backend read was still in
   flight. Adds return_exceptions=True so every operation is awaited (or
   cancelled by the timeout) before anything is released, and inspects
   the results afterwards, logging failures at warning level.

   Confirmed against the previous revision with a slow operation
   alongside a raising one:
     before  slow op finished=False  released mid-read=True
     after   slow op finished=True   released mid-read=False

2. Release errors were logged at debug and swallowed, against
   .ai/python-guidelines.md ("if you must catch broadly ... always
   re-raise after logging"). The broad except stays, because releasing
   is best-effort across all items and aborting early would leak exactly
   the buffers this code frees -- but it now logs at warning, collects
   the first failure, and re-raises it once every item has been
   attempted. The raise sits outside the finally so it can never mask an
   exception that was already propagating.

3. Documents the deliberate trade-off raised in review: after the
   timeout the buffer is deregistered, so a backend that reads later
   sees a NIXL transfer error rather than data. The alternative is
   unbounded growth; the timeout is generous and tunable via
   DYN_MM_NIXL_CLEANUP_TIMEOUT_S.

Adds the regression test asked for (one failing operation alongside one
blocking operation) plus one asserting a failing release does not skip
the remaining buffers.

Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Remaining review items.

Completion errors were logged but swallowed. They are now re-raised,
after every sibling has been awaited and every buffer released, so a
failed transfer is visible rather than silent. A release failure takes
precedence over a completion failure, because it means a buffer is still
registered -- the condition this method exists to prevent.

Deliberately narrowed to Exception: a cancelled sibling captured by
return_exceptions=True is not a fault worth converting into one here.

Adds @pytest.mark.timeout(10) to the cleanup tests. The local
asyncio.wait_for assertions stay; the marker additionally stops the test
if the event loop itself stalls. pytest-timeout is already a dependency
and the marker is registered in pyproject.toml.

Updates the failing-op regression test to expect the re-raise while
still asserting no sibling was released mid-read.

Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
Reverts the re-raise added earlier. Review is right that it breaks the
calling contract, and both points check out in the source:

  - MmKwargsShmSender.cleanup() logs and continues, so raising here made
    the two senders diverge on failure
  - the only caller (vllm_processor._generator_inner) awaits this from a
    bare finally with no guard, so a raise surfaces after the stream has
    completed and, on a client cancel, replaces the CancelledError

The release in the finally is what fixes the leak, and it is unchanged.
Re-measured after this revert: 40 iterations of un-read 20 MB transfers
grow 59 MB with the fix versus 841 MB without, 40/40 cleanups complete
-- identical to the version that re-raised.

The broad except is now justified inline, since it is a deliberate
exception to the "re-raise after logging" guideline.

Also records the measured cost of the synchronous native release, which
review asked about: p50 0.005 ms, p95 0.016 ms per buffer for 8-20 MB on
GB200, so ~0.02 ms of event-loop time for a 4-image request. That is
below the cost of an executor hand-off, so it stays inline rather than
moving to run_in_executor.

Signed-off-by: Yifan Jiang <19356972+yifjiang@users.noreply.github.com>
@yifjiang
yifjiang force-pushed the fix/mm-nixl-cleanup-unbounded-wait branch from 82bcb2b to 06f2e15 Compare August 10, 2026 23:17
@yifjiang
yifjiang temporarily deployed to external_collaborator August 10, 2026 23:17 — with GitHub Actions Inactive
@dynamo-ops

Copy link
Copy Markdown
Contributor

/ok to test 06f2e15

1 similar comment
@yifjiang

Copy link
Copy Markdown
Contributor Author

/ok to test 06f2e15

@yifjiang

Copy link
Copy Markdown
Contributor Author

Thanks @krishung5 — both points check out, and I've taken both. Pushed in 06f2e15.

Re-raise removed (:374)

You're right, and I verified both claims against the source before changing it:

  • MmKwargsShmSender.cleanup() (620-633) catches Exception, logs a warning and continues — best-effort. Raising in the NIXL sender did make the two diverge.
  • _generator_inner (vllm_processor.py:685-687) awaits it from a bare finally with no guard, so a raise surfaces after the stream has completed and, on a client cancel, replaces the in-flight CancelledError.

That second one is the decisive argument — swapping a cancellation for a cleanup error is strictly worse than a logged warning. cleanup() is best-effort again; the finally release is untouched.

For the record on why it was there: it was added in response to earlier review asking for the .ai/python-guidelines.md "always re-raise after logging" rule. You have context on the caller that the rule doesn't, so I've kept the broad except and justified it inline instead — noting the sibling's behaviour, the bare-finally caller, and that aborting the loop would leak the buffers this frees. Hopefully that stops it being re-flagged.

Confirmed the leak fix doesn't depend on the raise, as you predicted — re-measured on real NIXL (GB200 arm64), 40 iterations of un-read 20 MB transfers:

growth cleanups completed
unpatched 841 MB 0/40
with re-raise 59 MB 40/40
best-effort (this revision) 59 MB 40/40

Release cost measured (:295)

Good question — I measured it rather than guessing. 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

~5 µs per release, so ~0.02 ms of event-loop time for a 4-image request, and 0.2 ms for 20 buffers. That is below the cost of a run_in_executor hand-off, so I've left it inline and recorded the numbers in the docstring so the question doesn't have to be re-asked. Happy to switch to an executor if you'd still prefer it defensively, but on these numbers it looks like it would cost more than it saves.

@krishung5 krishung5 left a comment

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.

LGTM, thanks!

@rmccorm4
rmccorm4 enabled auto-merge (squash) August 11, 2026 19:00
@rmccorm4
rmccorm4 merged commit ee08cf3 into ai-dynamo:main Aug 11, 2026
104 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external-contribution Pull request is from an external contributor fix multimodal size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants