Skip to content

PLUGINS/UCX: Set ERR_HANDLER field only when a handler is provided - #2423

Merged
iyastreb merged 2 commits into
ai-dynamo:mainfrom
tomerdav:fix/ucx-ep-params-zero-init
Oct 14, 2026
Merged

iyastreb merged 2 commits into
ai-dynamo:mainfrom
tomerdav:fix/ucx-ep-params-zero-init

Conversation

@tomerdav

@tomerdav tomerdav commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What?

Set UCP_EP_PARAM_FIELD_ERR_HANDLER in nixlUcxEp only when the error handler is filled in.

Why?

Since #2365, under UCP_ERR_HANDLING_MODE_NONE the err handler field is still set in
field_mask but err_handler.cb/arg are left uninitialized. UCX then calls that
garbage pointer when a peer dies, so the surviving process segfaults.

How?

Add the field to field_mask together with the handler, matching the "mask only what
is set" convention from #2017.

Summary by CodeRabbit

  • Bug Fixes
    • Improved UCX endpoint configuration so error handling is enabled only when an error-handling mode is selected.

@github-actions

Copy link
Copy Markdown

👋 Hi tomerdav! 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 Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: ai-dynamo/nixl/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Enterprise
  • Run ID: a65a76e6-89b9-49f8-8c02-94fbd7a863a9
📥 Commits

Reviewing files that changed from the base of the PR and between ca5dbd8 and de69086.

📒 Files selected for processing: 1
  • src/plugins/ucx/ucx_utils.cpp

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

📝 Walkthrough

nixlUcxEp now excludes the UCX error-handler field from the initial endpoint mask. It adds the field only when error handling is enabled. Callback and argument assignments remain conditional on the same mode.

Changes: UCX endpoint error-handler parameters

Layer / File(s) Summary
Configure error-handler parameters
src/plugins/ucx/ucx_utils.cpp
nixlUcxEp adds UCP_EP_PARAM_FIELD_ERR_HANDLER only when the error-handling mode is not UCP_ERR_HANDLING_MODE_NONE.

Priority: ➖ Normal

Estimated code review effort: 1 (Trivial) | ~3 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 44642

Merge Risk: ⚪ Minimal · up to 44642

No actionable merge-blocking risk is established by the reviewed change.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 1 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
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 main UCX change: setting the ERR_HANDLER field only when a handler is provided.
Description check Passed The description includes the required What, Why, and How sections. It explains the uninitialized handler issue, the resulting segfault risk, and the implementation approach.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@tomerdav

Copy link
Copy Markdown
Contributor Author

/build

Comment thread src/plugins/ucx/ucx_utils.cpp Outdated
Signed-off-by: Tomer Davidor <tdavidor@nvidia.com>
@tomerdav tomerdav changed the title PLUGINS/UCX: Zero-initialize ep_params in nixlUcxEp PLUGINS/UCX: Set ERR_HANDLER field only when a handler is provided Oct 14, 2026
@tomerdav
tomerdav force-pushed the fix/ucx-ep-params-zero-init branch from ca5dbd8 to de69086 Compare October 14, 2026 12:32
@iyastreb

Copy link
Copy Markdown
Contributor

/build

@iyastreb
iyastreb enabled auto-merge (squash) October 14, 2026 12:49
@iyastreb
iyastreb merged commit c8178f5 into ai-dynamo:main Oct 14, 2026
29 of 30 checks passed
ovidiusm pushed a commit that referenced this pull request Oct 19, 2026
## What?

The UCX backend as the CPU proxy's transport, and the switch that turns
the proxy on:

- `nixlUcxEp::atomicAdd`: a post-mode remote `UCP_ATOMIC_OP_ADD`, for
the counters a receiver waits on to learn
  that data has landed (`ucx_utils.{h,cpp}`).
- `nixl::ucx::proxyEngine` (`ucx_proxy_engine.{h,cpp}`): the
`nixlUcxEngine` subclass that `create()` picks for
`device_proxy=true`. It owns a `nixl::proxyRuntime` (#2223) with a
`nixl::proxyTransport` (#2225) over the
engine's UCX workers, and hands out `PROXY` memory views (part 5)
instead of direct ones.
- Proxy threads may outnumber channels, up to one thread per (channel,
peer) ring, while each pass still makes
  one call per channel.
- The proxy transport and runtime are ready once constructed: no
`init()`/`shutdown()`, and
  `proxyRuntime::create()` returns a running runtime or throws.
- Tests: the backend-parameter table, and the existing single-write
device tests run in both modes. The
  real-UCX lifetime test is in the tests PR (#2283).

Direct mode is unchanged and remains the default: without
`device_proxy=true` the engine is main's.

## Why?

Parts 1 to 5 give a GPU a way to hand work to host threads, but nothing
posts that work to a network. This PR
makes UCX the transport, so a kernel written against the device API can
run over any UCX transport the host can
drive (for example where UCX's direct GPU API is unavailable), without
changes to the kernel.

## How?

**Transport** (`ucx_proxy_engine.cpp`)

- The engine's shared workers are partitioned one per (channel, peer)
ring, so each proxy thread progresses only
the UCX workers of the rings it owns, and a ring's operations are
ordered on one UCX worker.
- `submit` posts a PUT (`ucp_put_nbx`) or an atomic add. Before an
atomic, the transport fences the worker, so a
counter update is not visible before the PUTs issued ahead of it on that
ring.
- Request status, terminal release, per-ring progress and quiescence map
directly onto UCX requests and
`ucp_worker_progress`. A post-mode atomic completes inside
`ucp_atomic_op_nbx` and never yields a request, so
  only PUTs are tracked.
- `resolveDirectPtrs` fills a remote view's direct pointers with
`ucp_rkey_ptr` where the remote key allows
direct access, and leaves them null otherwise. It skips holes (the null
agent, or no metadata), which the
  memory-view manager marks unusable.

**Engine** (`nixl::ucx::proxyEngine`)

- Backend parameters: `device_proxy=true` enables the proxy;
`proxy_channel_count`, `proxy_thread_count`,
`proxy_max_peers`, `proxy_ring_depth` and `proxy_drain_timeout_ms` tune
it (parsed by `nixl::parseProxyConfig`).
Tuning without `device_proxy=true`, `num_threads` and the agent progress
thread are rejected. Without a loaded
device implementation (a build without CUDA), creating an engine with
`device_proxy=true` fails.
- `proxy_thread_count` defaults to the channel count and may go up to
one thread per ring: up to the channel
count each thread owns a block of whole channels, and beyond it each
channel's peers are strided over
thread_count / channel_count threads. Only counts that split the rings
evenly are accepted. Each thread gets
its span of channels and the peers it serves in them when the runtime is
built, so the proxy loop is the
  previous per-channel loop over a slice of peers.
- The constructor sizes the shared workers to one per ring (channels x
peers): a smaller `num_workers`, such as
the default of 1 that `getPluginParams()` seeds, is raised to that
count, and a larger one is rejected, since only
the proxy threads progress the workers. It runs with
`NIXL_THREAD_SYNC_RW`, since proxy threads call into UCX outside the
agent
lock, and sets `ucx_error_handling_mode=none`: under `peer`, UCX
disables every AMO protocol and the proxy's
atomics would complete as cancelled, so an explicit `peer` is rejected.
It then creates the runtime, which
starts its threads; the runtime is a member, so it stops its threads
before the base engine destroys the workers.
- `prepMemView` registers the descriptors with the runtime and wraps the
result as a `PROXY` view.
`releaseMemView` retires it: the runtime waits until every operation on
the view reaches a terminal status
and the owning UCX workers are quiescent, and only then frees it. A view
that was never published rolls back
  without a runtime drain.
- A failed peer's endpoint does not make release fatal, and a view the
runtime cannot retire logs a warning; a
  view that cannot be read is fatal.

**Notifications**: on main, the base engine's notification list relies
on the agent lock, which holds while
only agent calls progress its workers. In proxy mode every shared worker
is a ring worker, so a proxy thread
progresses worker 0 and runs the notification callback outside the agent
lock. `proxyEngine` overrides
`appendNotif` and `getNotifs` with its own mutex, so the regular engine
stays lock-free, and its `getNotifs` only
swaps the list: the proxy threads already progress every shared worker
on each pass, worker 0 with its
notifications and incoming connections included, so the application no
longer competes with them on the ring
workers.

**Build**: the UCX plugin always links `libnixl_device_proxy` and
`libdevice_utils` and builds the transport,
with no compile-time guard; neither library needs CUDA to build. CUDA is
still loaded at run time, through the
device operations library and UCX's own modules.

## Known limitations and follow-ups

To be fixed as follow-up work; none affects the tested configurations.

- **Connection fan-out.** Every UCX worker the engine creates connects
to every peer, as in direct mode, but in
proxy mode there are channels x peers workers and each peer is served by
only one of them, so each rank makes
about channels x peers x peers endpoints, and connection setup grows
with it. Keep `proxy_max_peers` at the
real peer count, as EP does. The fix is to connect only the worker that
serves each peer.
- **Software atomics on GPU memory.** If UCX cannot do a NIC atomic on
the counter's GPU memory (seen with a NIC
that is not PCIe-local to the GPU), it falls back to its software atomic
protocol, which performs the add with a
CPU instruction on the target and crashes the remote process. The
transport should detect that fallback at
  setup and fail with a clear error.
- **Unbounded quiesce.** `quiesce` waits for `ucp_worker_flush_nbx`,
which is bounded only by the transport's own
retry timeouts. When the peer above crashed (same host), release waited
indefinitely; the likely cause is a
shared-memory lane, which has no retry timeout, under
`ucx_error_handling_mode=none`, which has no keepalive.
The fix is an application-level deadline that reports the peer as
failed.
- **Proxy threads spin and are not bound to the GPU's NUMA node.**
Across ranks the threads must fit the
physical cores, and threads on the GPU's NUMA node are faster; the proxy
should bind its threads itself and
  warn on oversubscription.

## Testing

- L40S over RoCE, main `d1c6fb9e`: unit 191 pass; single-write device
tests 18/18 in direct and proxy mode;
UCX suites 90/90; the proxy cases at 4, 32 and 64 threads over 32
channels (local thread-count override, with
the dropped many-elements test added back locally) 12/12 each; #2283's
real-UCX lifetime test 3/3. Every
commit builds with `-Werror`, and so does the release build;
clang-format clean; PR size 387.
- NIXL EP's CI elastic tests with #2328 on top, on one 8x H100 node: all
six single-node plans, direct and
  proxy, NVLink and RDMA only, 88/88.
- `ProxyConfigTest`: parameter parsing, thread splits, and that the
threads' shares cover every ring once.
- Proxy unit, GPU and lifetime tests: #2283. NIXL EP, with performance:
#2328.

## Commits

- `bffebeca` PLUGIN/UCX: add a remote atomic add on the endpoint
- `2a3a1b5d` PLUGIN/UCX: add the device proxy transport
- `5b15101d` PLUGIN/UCX: own a device proxy runtime, enabled by backend
parameters
- `09a8eb00` test: check the device proxy backend parameters
- `71db9bd3` test: exercise UCX proxy mode in device PUT integration
tests
- `5b50ae05` ucx: synchronize base engine notification callbacks and
drains
- `511faf52` test: parse the proxy drain timeout
- `9a41e6c9` test: run the device PUT tests with the execution mode
fixed
- `9a396423` PLUGIN/UCX: keep the proxy declarations private and the
opcode switch exhaustive
- `0261134d` utils: roll back a failed proxy memory view with the
regular release
- `54c54d8b` device_api: pick the proxy ring by the element's remote
agent
- `2f49ebcf` PLUGIN/UCX: move the device proxy into its own engine
- `d4f14f64` utils: let proxy threads own individual rings
- `bba890d7` PLUGIN/UCX: run the device proxy with error mode none
- `f956bf6b` utils: construct the proxy transport and runtime ready to
use

Based on main; part 5 (#2326) has merged. The tests PR #2283 sits on top
of this one.

## Changes in this revision

For hbadihi's and rakhmets's reviews; every change is folded into the
commit it belongs to.

- The proxy engine is `nixl::ucx::proxyEngine` (`2f49ebcf`). Its
transport reads the memory handle through
`getMem()`, so the friend declaration is gone and `nixlUcxEp::write()`
takes the handle as const.
- `proxyRuntime::create()` builds the runtime and starts its workers,
and throws on any failure; `startWorkers()`
  and the runtime's stopped state are gone (`f956bf6b`).
- `proxyRuntime::releaseMemView()` returns nothing and warns on failure,
and the engine no longer treats a
  failed release as fatal (`f956bf6b`, `2f49ebcf`).
- The runtime, not the memory-view manager, maps each remote agent to a
peer and checks `proxy_max_peers`
  (`54c54d8b`).
- The transport frees a completed request with `ucp_request_free()`
directly (`2a3a1b5d`).
- Removed: `deviceChannelViews()`, `proxyChannel::deallocate()`, the
transport header's `proxy_config.h`
include, a few comments, and the `ManyElementsOnePeerPut` test commit,
which nixlbench covers (#2328).
- The `num_threads` error now points to `proxy_thread_count`.
- For ColinNV's comments on #2328, on this PR's code: the peer check is
spelled out, the peer list is reserved,
  and its copy size follows the element type (`54c54d8b`).
- Our own change: a `num_workers` below channels x peers, such as the
default of 1 from `getPluginParams()`, is
raised to that count instead of rejected, so applications no longer
erase it (#2328 drops its erases); a larger
one is still rejected, since nothing but the proxy threads would
progress the extra workers (`5b15101d`,
  `2f49ebcf`).

Previous revisions:

- Rebased on main at `d1c6fb9e`, which includes #2423 (no garbage error
handler under
  `ucx_error_handling_mode=none`) and #2412.
- For iyastreb's review: the proxy is a separate `nixlUcxProxyEngine`
that `create()` picks (`d72887b8`); the base
engine is main's code apart from that. The notification lock moved into
the proxy engine, whose `getNotifs`
no longer progresses the workers, which also removes `getNotifs` stalls
under host polling.
- For rakhmets's review: the proxy engine sets up the runtime in its
constructor and tears it down in its
destructor (`d72887b8`); the transport and runtime are ready once
constructed, and `proxyRuntime::create()`
  returns a `unique_ptr` (`0efb4f4c`).
- For Colin's review on #2328: `ucx_error_handling_mode` defaults to
`none` with the proxy and `peer` is rejected
  (`b240e1e6`), and `proxyTransport` is no longer copyable (`0efb4f4c`).
- Proxy threads up to one per (channel, peer) ring, even splits only
(`10fa8327`): each thread gets a span of
  channels and a peer slice, so the loop keeps one call per channel.
- The base engine's memory-view comment now says that a mode fixed at
compile time is checked against the
  handle's tag.
- Rebased on main at `d59f99c3` now that #2326 has merged; the PR shows
only its own 12 commits.
- Review fixes folded into their commits: no stray blank lines in the
engine commit (`d56d2688`), and
  `nixlUcxEp::atomicAdd` is const (`b0e23d91`).
- `f1e597b5` picks a proxy ring by the element's remote agent instead of
its index in the view, and
  `93eee926` puts to more elements of one peer than `proxy_max_peers`.

- Merged #2326's named status `mode` field (`57311ecc`); no code changes
here.
- Rebased on #2326's merge of main (`d52c5c36`, #2230's Device API
comments); no code changes.
- New commit `5b2a988b`, for CodeRabbit's review:
`discardUnpublishedMemView` is removed. When the engine fails to
allocate a
view's device wrapper, it rolls the proxy view back with the regular
`releaseMemView`, so the runtime API is
prepare and release only. The drain this adds runs only on that
allocation-failure path.
- New commit `53dcc156`, for CodeRabbit's review: the proxy helpers are
declared in the engine's existing
private section (with #2326's `dcf2f55a`, the engine's public interface
is unchanged from main), and the
transport's opcode switch has no `default`, so a new opcode triggers a
compiler warning.
- Rebased on #2326's compile-time mode argument (`dc0959c2`); new commit
`4878665d` adds the single-write case
  with the mode fixed. No other changes.
- Rebased on #2326's review fixes (`8251aa55`). Following them, the
transport and engine commits no longer
use `HAVE_NIXL_DEVICE_API`: the plugin always builds the transport and
links the proxy library, and the
build-time rejection of proxy parameters without CUDA is gone (engine
creation fails instead). The engine
  uses the `nixl::deviceMemView*` names.
- Stacked directly on #2326 instead of on the tests PR #2283. The two
test `meson.build` changes are made
against main, and `proxy_lifetime` and
`UnpublishedRollbackDoesNotDrainLiveTraffic` moved to #2283. No code
  changes.

- Rebased on main at `d790ad4f`; no code changes.
- Restacked after the reorder that makes #2283 the post-feature tests
PR: this PR now sits on #2283 (which sits
on #2326), and `RetirementSelectsTheOwningDevice` moved to #2283.
Includes #2326's review fixes; no code
  changes here.
- No longer carries #2325's commit.
- Rebased on #2283's test simplification; no code changes.

- Carries #2325's reworked single commit as a plain commit instead of
merging the old two-commit
  branch; the EP release reordering is no longer part of it.
- The engine keeps the device allocator it built the runtime with, and
passes it to
  `proxyRuntime::create` and to the device memview wrappers.
- `ProxyConfigTest` covers the new `proxy_drain_timeout_ms` parameter.
- Rebased on #2223's review fixes; no code changes.
- Rebased on main at `40b38570` after #2226 merged.
- `proxy_lifetime` now depends on `ucx_dep`, so it builds against the
configured UCX instead of
whatever UCX headers are on the default include path (an incomplete
system UCX broke the build).
- Follows #2226's review renames: the engine releases a view through
  `proxyRuntime::releaseMemView`, and `discardUnpublishedMemView` takes
  `proxy_view_handle_t`.
- The transport's submit debug log prints only the fields an operation
uses: an atomic
  add has no source, so it logs its destination address and value.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added CUDA proxy support for GPU PUT and atomic-add operations, with
routing between proxy and direct UCX execution.
  - Added proxy configuration and proxy-backed memory views.
- Added GPU utilities for coordinating work across warps, blocks, and
grids.

- **Bug Fixes**
- Improved remote metadata and memory-view lifetime handling: existing
views retain invalidated metadata, while new transfers using stale
metadata are rejected.
- Improved device endpoint disconnection by synchronizing CUDA and
releasing memory views before disconnecting peers.

- **Tests**
- Added coverage for proxy transfers, configuration, memory-view
lifetimes, completion handling, and remote metadata invalidation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Signed-off-by: Tomer Davidor <tdavidor@nvidia.com>

This branch was successfully deployed

1 active deployment
SWX_AWS — 44642837 Deployed Oct 14, 2026 by copy-pr-bot[bot] via Run AWS Tests #10061
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.

3 participants