Skip to content

PLUGINS/UCX: wait for in-flight transfers before release() returns - #2044

Open
vedularaghu wants to merge 2 commits into
ai-dynamo:mainfrom
vedularaghu:vedularaghu/ucx-drain-inflight-on-release
Open

vedularaghu wants to merge 2 commits into
ai-dynamo:mainfrom
vedularaghu:vedularaghu/ucx-drain-inflight-on-release

Conversation

@vedularaghu

@vedularaghu vedularaghu commented Aug 5, 2026

Copy link
Copy Markdown

What?

Make release() wait for UCX to be done with a transfer's memory before it returns, on both the simple-handle and the composite/threadpool path.

  • Commit 1 drains the worker in nixlUcxBackendReqH::release() until every in-flight request has reached a terminal state.
  • Commit 2 makes nixlUcxCompositeBackendReqH::release() wait for pendingReqs to drain before resetting the shared state.

Two commits because they touch different classes and each has its own negative control, but they are one invariant and neither half closes the window alone - see below. This supersedes #2045, which was folded in here so the argument only has to be evaluated once.

Correction to the original description

@brminich is right, and the first version of this PR was wrong on the mechanism. It claimed ucp_request_free() "does not synchronously complete internal requests" and implied the use-after-free came from freeing an in-flight request. In ucp_request_release_common():

if (ucs_likely(flags & UCP_REQUEST_FLAG_COMPLETED)) {
    ucp_request_put(req);
} else {
    req->flags = (flags | UCP_REQUEST_FLAG_RELEASED) & ~cb_flag;
}

An uncompleted request is only marked UCP_REQUEST_FLAG_RELEASED; it goes back to the pool from ucp_request_complete() when it finally completes. So ucp_request_free() on an in-flight request is safe and is the supported way to hand it back.

The first version also leaked the request when the drain deadline expired, on the theory that freeing it was the hazard. That was strictly worse than the code it replaced: a genuine req_mp leak for no benefit. It is gone - reqRelease() is unconditional, exactly as before this PR.

Why? (re-derived)

ucp_request_cancel() is a no-op for RMA: it only acts on requests carrying UCP_REQUEST_FLAG_RECV_TAG. So release() can return with the operation still outstanding. That much of the original TODO holds.

The hazard that creates is on the memory handle, not on the request. NIXL posts RMA with UCP_OP_ATTR_FIELD_MEMH and a memh from ucp_mem_map() (nixlUcxEp::read/write in ucx_utils.cpp). UCX stores that pointer in the request without taking a reference, because a user memh is not reference counted:

/* ucp_datatype_contig_iter_init(), src/ucp/dt/datatype_iter.inl */
if (param->op_attr_mask & UCP_OP_ATTR_FIELD_MEMH) {
    ...
    dt_iter->type.contig.memh = param->memh;   /* plain pointer, no ucp_memh_get() */
}

When the operation completes, ucp_datatype_iter_cleanup() calls ucp_datatype_iter_mem_dereg_single() -> ucp_memh_put(), which dereferences memh->context and memh->parent before doing anything else. If the caller has deregistered in the meantime, ucp_mem_unmap() -> ucp_memh_cleanup() has already ucs_free()d that memh, and the completion faults inside ucp_memh_put(). That matches the SIGSEGV we saw in production.

More generally, the operation is still reading and writing the caller's buffers and still holds the endpoint. So the invariant release() has to establish is not "the request object is reclaimed" but "UCX is done with this transfer's memory before the caller may unmap or free it".

Distinct from the rkey-unpack / endpoint-teardown race fixed in #1987 - that one races unpack against teardown, this one races RMA completion against the caller reclaiming memory.

Why both halves are needed

On the threadpool path the two changes interlock:

  • Composite release() sets the shared status to a failure, which makes the owning worker run nixlUcxChunkBackendReqH::complete() -> nixlUcxBackendReqH::release() on the chunk. Without commit 1 that release does not drain, so the chunk's requests can still be in flight when it returns.
  • Without commit 2 composite release() does not wait for that to happen at all - it returns before the worker has even looked at the chunk.

The controls below show this directly: reverting commit 1 alone breaks all four parameterisations, including the threadpool ones that still have commit 2.

Unit test

@brminich asked for a test showing the fix works. TestTransferRelease.InFlightXferIsDrainedBeforeReleaseReturns in test/gtest/test_transfer.cpp:

  • registers 64 x 1 MiB on each of two agents and posts a READ,
  • releases the handle while the read is still in flight (postXferReq() returned NIXL_IN_PROG),
  • checks the destination is fully written by the time releaseXferReq() returns,
  • then deregisters, which is the call that would free the memh from under UCX.

A read completes only once its data has landed locally, so this observes the drain directly rather than relying on a crash. The data path is pinned to UCX_TLS=tcp, because over shm/self UCX copies inline and nothing is ever in flight to drain. 64 descriptors puts the batch above the fixture's split_batch_size, so the ucx_threadpool* parameterisations take the composite path. No RDMA hardware needed; the four cases take 0.2-0.6 s each.

Negative control 1 - revert commit 1 (simple-path drain), keep commit 2

parameterisation bytes landed when releaseXferReq() returned
ucx 0 - 35 698 / 67 108 864 (varies by run)
ucx_no_pt 0 / 67 108 864
ucx_threadpool 0 / 67 108 864
ucx_threadpool_no_pt 0 / 67 108 864

All four fail, including the threadpool cases that still have the composite wait - the wait is meaningless if the chunk release it waits for does not drain.

5 of 14 runs also died inside UCX with its own fatal assertion, rather than just failing the data check:

Assertion `0' failed
  uct_tcp_ep_pending_purge_cb (tcp/tcp_ep.c:2172)
  uct_tcp_ep_pending_purge (tcp/tcp_ep.c:2186)
  uct_tcp_ep_t_cleanup (tcp/tcp_ep.c:379)
  ucp_worker_discard_uct_ep_destroy_progress (core/ucp_worker.c:2765)

That is UCX refusing to destroy a TCP endpoint that still has operations queued on it.

Negative control 2 - revert commit 2 (composite wait), keep commit 1

parameterisation bytes landed when releaseXferReq() returned
ucx, ucx_no_pt 67 108 864 / 67 108 864 (pass - covered by commit 1)
ucx_threadpool 3 464 592 - 27 532 784 / 67 108 864 (varies by run)
ucx_threadpool_no_pt 0 / 67 108 864

1 of 3 runs segfaulted instead, with the read landing in a buffer the caller had already freed:

__memcpy_avx512_unaligned_erms
ucp_memcpy_unpack (src/ucp/dt/dt.h:82)
ucp_datatype_iter_unpack (src/ucp/dt/datatype_iter.inl:447)
ucp_get_rep_handler (rma/rma_sw.c:284)
...
ucp_worker_progress (core/ucp_worker.c:3071)
nixlUcxBackendReqH::drainRequest (src/plugins/ucx/ucx_backend.cpp:197)
nixlUcxBackendReqH::release (src/plugins/ucx/ucx_backend.cpp:143)
nixlUcxChunkBackendReqH::complete (src/plugins/ucx/ucx_backend.cpp:534)
nixlUcxDedicatedThread::run (src/plugins/ucx/ucx_backend.cpp:722)

The chunk drain does run, but on the threadpool worker, after the composite release() has already returned and the caller has freed the destination.

To be precise about what these do and do not show: neither control reproduces the exact ucp_memh_put() SIGSEGV we saw in production - these are over TCP, not RDMA, and the fault lands at a different site. They are the same root cause (release() returning while the RMA is live) surfacing wherever the transport happens to touch the reclaimed resource first.

With both commits, 25 consecutive runs of the four cases: no failures, no crashes.

Test plan

  • Ubuntu 24.04 / gcc 13.3, meson debugoptimized, UCX 1.20, built clean from this branch
  • test/gtest/unit: 84/84
  • test/gtest *TestTransfer*: 57/57, including the four new cases
  • Both negative controls reproduce, on demand, on this branch
  • 25/25 clean runs with the fix in place
  • clang-format-diff-19 clean on every line added against main

What this does not fix

@iyastreb is right that this is not an abort, and I do not want to oversell it.

The drain closes the window whenever the transfer can still make progress - which is the ordinary releaseXferReq()-mid-transfer case the test reproduces. It does not help when the transfer can never complete: after the deadline release() returns anyway, the operation is still outstanding, and the caller's memory still is not safe to reclaim. All the timeout buys there is an error message instead of silence.

That residual case needs the real abort primitive you are discussing, and if you would rather this waited for that, we are happy to hold it. What we would like to avoid is release() continuing to return, in the ordinary case, while UCX still holds the caller's buffers and endpoint.

Separately, on the NIXL_IN_PROG/FAILED-endpoint point from the other thread: production UCX debug logs from a wedged pod back your diagnosis - 156 set_ep_failed status Endpoint timeout on lane[N] events over the wedge, 27 on the exact rank whose handle stalled, all while NIXL reported NIXL_IN_PROG. Written up in #2047, which we have reframed as a backstop rather than a fix for that.

@vedularaghu
vedularaghu requested review from a team, brminich, gleon99 and yosefe as code owners August 5, 2026 15:34
@copy-pr-bot

copy-pr-bot Bot commented Aug 5, 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 5, 2026

Copy link
Copy Markdown

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

Your PR reviewers will review your contribution then trigger the CI to test your changes.

🚀

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

nixlUcxBackendReqH::release() now cancels and drains in-flight UCX requests before cleanup. It progresses the worker, logs warnings, and leaks requests that exceed the configured drain timeout.

Changes

UCX request lifecycle

Layer / File(s) Summary
Drain cancelled UCX requests
src/plugins/ucx/ucx_backend.cpp
drainRequest() polls request completion, progresses the UCX worker, emits periodic warnings, and sleeps when progress stalls. release() releases drained requests and logs and leaks requests that remain active after the configured timeout.

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

Sequence Diagram(s)

sequenceDiagram
  participant Release as nixlUcxBackendReqH::release()
  participant Drain as drainRequest()
  participant Worker as UCX worker
  participant Request as UCX request
  Release->>Drain: Cancel and drain request
  Drain->>Worker: Progress worker
  Worker->>Request: Poll completion
  Request-->>Drain: Return completion state
  Drain-->>Release: Return drained or timed out
  Release->>Request: Release or intentionally leak
Loading

Possibly related PRs

Suggested reviewers: brminich, gleon99, yosefe

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the primary change: waiting for in-flight UCX transfers before release() returns.
Description check ✅ Passed The description includes What and Why sections, detailed design and test information, and sufficiently covers the optional How content.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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: 2

🤖 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 `@src/plugins/ucx/ucx_backend.cpp`:
- Around line 141-151: Replace the multi-line block comment in ucx_backend.cpp
with equivalent regular // comments at the same location, keeping the existing
rationale about draining the worker and the ucp_memh_put lifetime issue intact.
Update only the comment style around the UCX zcopy completion handling; do not
change the surrounding logic or symbols such as ucp_request_cancel,
ucp_request_free, or ucp_memh_put.
- Around line 152-155: Update the request-wait logic around
ucp_request_check_status in the UCX backend so UCS_ERR_UNSUPPORTED cannot cause
the loop to exit while the request remains in flight. Use a supported UCX
completion mechanism, or retain the request and associated memory handle until
asynchronous completion before calling ucp_request_free or deregistering memory.
🪄 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: ASSERTIVE

Plan: Enterprise

Run ID: 87731186-cfa3-459c-8060-ef56494d3026

📥 Commits

Reviewing files that changed from the base of the PR and between 029a854 and 3f304f1.

📒 Files selected for processing (1)
  • src/plugins/ucx/ucx_backend.cpp

Comment thread src/plugins/ucx/ucx_backend.cpp Outdated
Comment thread src/plugins/ucx/ucx_backend.cpp Outdated
@vedularaghu
vedularaghu force-pushed the vedularaghu/ucx-drain-inflight-on-release branch from 3f304f1 to 067f176 Compare August 5, 2026 15:41
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@vedularaghu
vedularaghu force-pushed the vedularaghu/ucx-drain-inflight-on-release branch from 067f176 to ec52236 Compare August 5, 2026 15:49
@pull-request-size pull-request-size Bot added size/M and removed size/S labels Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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: 2

🤖 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 `@src/plugins/ucx/ucx_backend.cpp`:
- Around line 141-155: Update the drain-timeout path in release() so a false
result from drainRequest(req) retains ownership of the transfer’s memory handle
until UCX reaches terminal completion, rather than only leaking req. Ensure
requests_.clear(), conn_.reset(), and any caller-triggered deregistration cannot
release the backing memory while ucp_memh_put() may still run; alternatively,
block release() until terminal completion.
- Line 193: Update drainRequest around worker_->progress() to inspect its
returned progress count and briefly sleep when it is zero, matching the backoff
behavior used in mem_list.cpp. Preserve the existing loop and timeout behavior
while avoiding busy-polling during periods with no UCX progress.
🪄 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: ASSERTIVE

Plan: Enterprise

Run ID: 83b7eca2-e90a-4981-9c77-e7b957d5e2db

📥 Commits

Reviewing files that changed from the base of the PR and between 029a854 and ec52236.

📒 Files selected for processing (1)
  • src/plugins/ucx/ucx_backend.cpp

Comment thread src/plugins/ucx/ucx_backend.cpp
Comment thread src/plugins/ucx/ucx_backend.cpp Outdated
## What?

Drain the worker in `nixlUcxBackendReqH::release()` until every in-flight
request has reached a terminal state, instead of returning as soon as
`ucp_request_cancel()` and `ucp_request_free()` have been called.

The composite/threadpool half of the same invariant is in the following commit;
neither closes the window on its own.

## Why?

`release()` currently cancels outstanding requests and frees them, with a TODO
noting "it may not be enough to cancel UCX request". It isn't, but not for the
reason the request-lifetime reading suggests.

`ucp_request_cancel()` is a no-op for RMA: it only acts on requests carrying
`UCP_REQUEST_FLAG_RECV_TAG`. So `release()` can return with the operation still
outstanding.

The hazard that creates is on the *memory handle*, not on the request. NIXL
posts RMA with `UCP_OP_ATTR_FIELD_MEMH` and a memh from `ucp_mem_map()`. UCX
stores that pointer in the request (`ucp_datatype_iter`, `type.contig.memh`)
without taking a reference - a user memh is not reference counted. When the
operation completes, `ucp_datatype_iter_cleanup()` calls
`ucp_datatype_iter_mem_dereg_single()` -> `ucp_memh_put()`, which dereferences
`memh->context` and `memh->parent`. If the caller has deregistered in the
meantime, `ucp_mem_unmap()` -> `ucp_memh_cleanup()` has already `ucs_free()`d
that memh, and the completion faults inside `ucp_memh_put()`.

Releasing the request object itself is safe either way:
`ucp_request_release_common()` returns an *uncompleted* request to the pool only
by marking it `UCP_REQUEST_FLAG_RELEASED`; UCX calls `ucp_request_put()` when
the request later completes. Draining protects the memory handle, not the
request, so `reqRelease()` is now called unconditionally.

The drain is bounded by `NIXL_UCX_REQUEST_DRAIN_TIMEOUT` (default 10s) so that
`release()` cannot block forever on a wedged transfer. If the deadline expires,
the request is still released and an error tells the caller its memory is not
safe to deregister - there is nothing better `release()` can do without a real
abort primitive. Progress is reported every `NIXL_UCX_WARNING_TIMEOUT`
(default 5s), matching the existing wait loop in `mem_list.cpp`.

This is distinct from the rkey-unpack / endpoint-teardown race fixed in ai-dynamo#1987 -
that one races unpack against teardown, this one races RMA completion against
`ucp_mem_unmap()`.

## How was it tested?

`TestTransferRelease.InFlightXferIsDrainedBeforeReleaseReturns` in
`test/gtest/test_transfer.cpp` posts a 64 MiB READ over a TCP-pinned UCX data
path, releases the handle while the read is still in flight, and checks the
destination is fully written by the time `releaseXferReq()` returns. A read
completes only once its data has landed locally, so this observes the drain
directly and needs no RDMA hardware.

With the drain removed and nothing else changed, the `ucx` parameterisation
sees 8 128 of 67 108 864 bytes landed when `releaseXferReq()` returns, and
`ucx_no_pt` sees 0. With the drain in place both are complete.

Also carried in production at Fireworks AI on a NIXL 0.10.0-based build for
disaggregated-prefill KV cache transfer, where the `ucp_memh_put()` SIGSEGV was
originally observed.
@vedularaghu
vedularaghu force-pushed the vedularaghu/ucx-drain-inflight-on-release branch from ec52236 to 15f357e Compare August 5, 2026 15:54
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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: 1

🤖 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 `@src/plugins/ucx/ucx_backend.cpp`:
- Around line 175-191: Validate the durations returned for
NIXL_UCX_REQUEST_DRAIN_TIMEOUT and NIXL_UCX_WARNING_TIMEOUT as strictly positive
before entering the drain loop in release(). Reject zero or negative values,
preventing immediate warning retries and leaked in-flight requests.
🪄 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: ASSERTIVE

Plan: Enterprise

Run ID: 89532ee6-4b4a-438c-8040-d601e41d94aa

📥 Commits

Reviewing files that changed from the base of the PR and between 029a854 and 15f357e.

📒 Files selected for processing (1)
  • src/plugins/ucx/ucx_backend.cpp

Comment thread src/plugins/ucx/ucx_backend.cpp
@vedularaghu
vedularaghu force-pushed the vedularaghu/ucx-drain-inflight-on-release branch from 15f357e to 06938ef Compare August 5, 2026 17:44
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

// TODO: Need process this properly.
// it may not be enough to cancel UCX request
worker_->reqCancel(req);
if (!drainRequest(req)) {

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.

Currently there is no proper "abort" functionality in NIXL, we are discussing it.
@mkhazraee
But I'm afraid that this approach does not really solve the problem, just hides it a bit by doing 10s extra polling, but then it still fails the same way..

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed - this isn't an abort, and I don't want to claim it is.

To split the two cases apart:

Transfer can still make progress. This is the ordinary releaseXferReq()-mid-transfer path, and today release() returns with the RMA still outstanding. The new gtest measures it: with the drain removed, only 8 128 of 67 108 864 bytes of a READ had landed when releaseXferReq() returned (0 bytes with no progress thread). The caller is free to deregisterMem() at that point, and ucp_mem_unmap() frees a memh the request still holds a raw pointer to. The drain does close that window, and it costs ~300 ms for a 64 MiB transfer.

Transfer can never make progress. You're right that the timeout doesn't fix anything here. After 10 s release() returns anyway, the operation is still outstanding, and the caller's memory still isn't safe to deregister - all the deadline buys is an error line instead of silence. Only a real abort helps, and that's yours to design.

So I'd frame this as: it fixes the case where waiting is sufficient, and it makes the case where it isn't sufficient visible instead of silent. If you'd rather not carry the 10 s knob at all and wait for proper abort support, we're happy to hold or close it - the part we'd like to avoid keeping is release() returning, in the normal case, while UCX still holds a pointer to a memh the caller is about to unmap.

Separately: the production evidence we gathered for #2047 supports your diagnosis on the other thread. On a wedged pod (NIXL 1.3.2, UCX debug logging) there were 156 set_ep_failed status Endpoint timeout on lane[N] events over the wedge, 27 of them on the exact rank whose handle stalled, while NIXL reported NIXL_IN_PROG throughout. A FAILED endpoint with a permanently outstanding request is exactly what you described, and it points at your checkConnection()-on-NIXL_IN_PROG POC as the primary fix rather than anything in these two PRs. Details in #2047.

@brminich brminich 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.

it does not seem to solve a real issue.
Pls submit a unit test, which would show this fix is working

Comment thread src/plugins/ucx/ucx_backend.cpp Outdated
worker_->reqCancel(req);
if (!drainRequest(req)) {
// Still in flight past the deadline, so the request object is not
// ours to reclaim: freeing it now lets a later completion write into

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.

no, ucp_request_free is not doing anything with uncompleted request. Request is returned to memory pool only when it is completed and ucp_request_free() is called

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right, and thanks for catching it - I had the request lifetime wrong. ucp_request_release_common() only marks an uncompleted request UCP_REQUEST_FLAG_RELEASED; ucp_request_put() happens from ucp_request_complete() when it finally completes, so the request is never handed back early and freeing it in flight is safe.

That also means the "leak the request on timeout" branch I had here was a straight regression - a real req_mp leak for no benefit - so I've removed it. reqRelease() is unconditional again, as before this PR.

Re-deriving the crash from scratch, the hazard is on the memh, not the request. We post RMA with UCP_OP_ATTR_FIELD_MEMH and a memh from ucp_mem_map(). ucp_datatype_contig_iter_init() stores it raw, without taking a reference, because a user memh isn't refcounted:

dt_iter->type.contig.memh = param->memh;

and on completion ucp_datatype_iter_cleanup() -> ucp_datatype_iter_mem_dereg_single() -> ucp_memh_put() dereferences memh->context and memh->parent. If the caller has deregistered in between, ucp_mem_unmap() -> ucp_memh_cleanup() has already ucs_free()d it, and that dereference is the SIGSEGV in ucp_memh_put() we saw.

So what release() has to guarantee is "UCX is done with the memh before the caller may unmap it", and ucp_request_cancel() can't provide it for RMA since it only acts on UCP_REQUEST_FLAG_RECV_TAG. The PR description has been rewritten around this; please re-read it rather than the original.

## What?

Wait for `pendingReqs` to drain in `nixlUcxCompositeBackendReqH::release()`
before resetting the shared state.

## Why?

Setting `sharedState_->status` to a failed value stops new chunks from starting,
but it does not complete chunk requests that have already been posted, and
resetting `sharedState_` only drops this handle's reference to it. The chunk
handles are cancelled and released later, on the threadpool worker that owns
them.

So `release()` can return while a chunk request is still in flight. The caller
is then free to deregister its memory, which is the same hazard as the
non-composite path: chunks are posted with `UCP_OP_ATTR_FIELD_MEMH`, and UCX
holds that `ucp_mem_h` in the request as a plain pointer without taking a
reference. When the operation completes, `ucp_datatype_iter_cleanup()` calls
`ucp_memh_put()` on it, which dereferences `memh->context` and `memh->parent`.
If `ucp_mem_unmap()` has already `ucs_free()`d that memh, the completion faults
inside `ucp_memh_put()`.

Only the threadpool worker that owns a chunk may progress it, so the wait polls
the pending counter instead of driving progress itself.

The wait is bounded by `NIXL_UCX_REQUEST_DRAIN_TIMEOUT` (default 10s) so that
`release()` cannot block forever on a wedged chunk; if the deadline expires the
handle is released anyway and an error tells the caller its memory is not safe
to deregister. Progress is reported every `NIXL_UCX_WARNING_TIMEOUT`
(default 5s), matching the existing wait loop in `mem_list.cpp`.

This is the composite half of the fix in the preceding commit and only closes
the window in combination with it: a cancelled chunk is drained by
`nixlUcxBackendReqH::release()`, and this wait is what keeps the composite
`release()` from returning before that has happened.

## How was it tested?

The `ucx_threadpool` and `ucx_threadpool_no_pt` parameterisations of
`TestTransferRelease.InFlightXferIsDrainedBeforeReleaseReturns` cover the
composite path. With this commit reverted and the preceding one kept, they fail
with half the destination written (progress thread) or none of it (no progress
thread), while the non-composite parameterisations still pass.
@vedularaghu
vedularaghu force-pushed the vedularaghu/ucx-drain-inflight-on-release branch from 06938ef to 68fd77e Compare August 7, 2026 02:58
@pull-request-size pull-request-size Bot added size/L and removed size/M labels Aug 7, 2026
@vedularaghu

Copy link
Copy Markdown
Author

@brminich @iyastreb - pushed a rewrite. Summary of what changed and what I conceded:

1. You were right about ucp_request_free(), and my description was wrong. I claimed it "does not synchronously complete internal requests" and implied that freeing an in-flight request caused the use-after-free. ucp_request_release_common() only sets UCP_REQUEST_FLAG_RELEASED on an uncompleted request; ucp_request_put() runs from ucp_request_complete() later. Freeing in flight is safe. I also had a "leak the request on timeout" branch built on that wrong premise - a real req_mp leak for no benefit - which is now gone. reqRelease() is unconditional again.

2. The bug is on the memory handle, and it is real. RMA is posted with UCP_OP_ATTR_FIELD_MEMH, and ucp_datatype_contig_iter_init() stores the user memh raw (dt_iter->type.contig.memh = param->memh) without a reference, since a user memh is not refcounted. On completion ucp_datatype_iter_cleanup() -> ucp_memh_put() dereferences memh->context and memh->parent; if the caller deregistered first, ucp_mem_unmap() -> ucp_memh_cleanup() has already ucs_free()d it. That is the ucp_memh_put() SIGSEGV. ucp_request_cancel() cannot prevent it because it only acts on UCP_REQUEST_FLAG_RECV_TAG.

3. Unit test, as asked. TestTransferRelease.InFlightXferIsDrainedBeforeReleaseReturns posts a 64 MiB READ over a TCP-pinned data path, releases the handle while it is in flight, and checks the destination is fully written when releaseXferReq() returns - a read completes only when its data has landed, so the drain is directly observable and no RDMA hardware is needed. Negative control with the drain removed:

parameterisation bytes landed when releaseXferReq() returned
ucx (progress thread) 8 128 / 67 108 864
ucx_no_pt 0 / 67 108 864

test/gtest/unit 84/84 and *TestTransfer* 57/57 on Ubuntu 24.04 / gcc 13.3 / UCX 1.20.

4. @iyastreb - agreed on the limitation, no argument from me. The drain works when the transfer can still make progress. When it can't, the deadline expires, release() returns anyway, and the memory still isn't safe to deregister; all it buys is an error line instead of silence. That case needs the abort primitive you're designing. If you'd rather this waited for that, say so and we'll hold or close it.

Also worth flagging on your other point: we went back to the UCX debug logs from the wedged production pod, and they support your NIXL_IN_PROG/FAILED-endpoint diagnosis directly - 156 set_ep_failed status Endpoint timeout on lane[N] events over the wedge, 27 on the exact rank whose handle stalled, one about four minutes before that rank's requests went permanently non-terminal, all while NIXL reported NIXL_IN_PROG. Written up in #2047, which we've reframed as a backstop for the residual "peer alive but not progressing" case rather than a fix for this one. Your checkConnection() POC is the fix we'd rather see land.

#2045 is the composite/threadpool half of this change and now stacks on this branch, since neither closes the window alone.

@vedularaghu
vedularaghu force-pushed the vedularaghu/ucx-drain-inflight-on-release branch from 68fd77e to 4d758dc Compare August 7, 2026 03:59
@vedularaghu vedularaghu changed the title PLUGINS/UCX: drain in-flight requests on handle release PLUGINS/UCX: wait for in-flight transfers before release() returns Aug 7, 2026
@vedularaghu

Copy link
Copy Markdown
Author

@brminich @iyastreb - folded #2045 into this PR and closed it, so there is one change and one argument to evaluate rather than two. The composite/threadpool path is the same bug and the two halves interlock (neither closes the window alone), which was not obvious with them split across PRs.

Kept as two commits so each code path stays separately reviewable, and because each has its own negative control.

While re-verifying the merged branch I got two things worth adding, both reproducible on demand with the fix reverted:

Reverting the simple-path drain kills all four parameterisations, including the threadpool ones that still have the composite wait - the wait is meaningless if the chunk release it waits for does not drain. 5 of 14 runs also died on UCX's own fatal assertion rather than merely failing the data check:

Assertion `0' failed
  uct_tcp_ep_pending_purge_cb (tcp/tcp_ep.c:2172)
  uct_tcp_ep_t_cleanup (tcp/tcp_ep.c:379)
  ucp_worker_discard_uct_ep_destroy_progress (core/ucp_worker.c:2765)

UCX refusing to destroy a TCP endpoint that still has operations queued on it.

Reverting only the composite wait segfaulted 1 run in 3, with the read landing in a buffer the caller had already freed:

__memcpy_avx512_unaligned_erms
ucp_datatype_iter_unpack (datatype_iter.inl:447)
ucp_get_rep_handler (rma/rma_sw.c:284)
...
nixlUcxBackendReqH::drainRequest (ucx_backend.cpp:197)
nixlUcxChunkBackendReqH::complete (ucx_backend.cpp:534)
nixlUcxDedicatedThread::run (ucx_backend.cpp:722)

The chunk drain does run - just on the threadpool worker, after the composite release() already returned and the caller freed the destination.

Being precise about what that does and does not show: neither reproduces the exact ucp_memh_put() SIGSEGV we saw in production. These are over TCP rather than RDMA and fault at a different site. They are the same root cause - release() returning while the RMA is live - surfacing wherever the transport touches the reclaimed resource first.

With both commits: 25 consecutive runs of the four cases, no failures and no crashes. test/gtest/unit 84/84 and *TestTransfer* 57/57 on a clean build of this branch (Ubuntu 24.04 / gcc 13.3 / UCX 1.20). Full numbers in the rewritten description.

The concession on ucp_request_free() and the agreement with @iyastreb that this is not an abort both still stand, unchanged.

@iyastreb

iyastreb commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Currently real abort if not supported by NIXL.
Adding timeouts here and there just hides the problem but does not solve it the right way.
Thanks for highlighting the problem, I appreciate that and we will discuss it with NIXL team.
But IMO we either need a proper solution: guaranteed in-flight request termination, or nothing. Adding timeouts complicate the logic but still does not provide guarantees

@lluki

lluki commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Hi All

We have a similar issue in the POSIX / io_uring backend where we can have outstanding requests in the ring. In my opinion the correct behavior is to panic (= terminate the process) when the handle is dropped and there are outstanding requests because of this issue you mentioned in the PR description:

It does not help when the transfer can never complete: after the deadline release() returns anyway, the operation is still outstanding, and the caller's memory still is not safe to reclaim.

I was circulating this document internally before. We need to agree on a NIXL wide approach and then all the baekcnd

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants