Skip to content

UCT/IB: Add refcounted, TTL-based address handle cache - #11845

Open
tvegas1 wants to merge 1 commit into
openucx:masterfrom
tvegas1:ah_cache_refcnt
Open

UCT/IB: Add refcounted, TTL-based address handle cache#11845
tvegas1 wants to merge 1 commit into
openucx:masterfrom
tvegas1:ah_cache_refcnt

Conversation

@tvegas1

@tvegas1 tvegas1 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

What?

  • AH cache: add refcnt to each entry; evict (re-query) on TTL after refcnt drops to 0.
  • Only ud_v and srd hold a long-term AH reference. The transports (rc_x, ud_x, dc) release any reference right away as they mainly need an AV for connect.
  • ud_v and srd functions is_connected()/ conn_match: compare peer identity (dlid/gid), not AH pointer value.

Important: TTL=0 means full cache bypass, no refcnt. It avoids for instance a lingering UD endpoint pinning a stale AH.

Why?

The AH cache had no lifecycle, entries were never re-resolved or reclaimed, and is_connected() relied on that by comparing AH pointers directly. Adding refcnt/TTL-based eviction makes pointer comparison unsafe for TTL=0, so identity-based comparison is needed too.

Cache use-case: quickly reconnecting endpoints speedup.

Replace the AH cache's simple create-or-lookup semantics with
refcounted entries that carry an idle timestamp. uct_ib_device_ah_get()
shares an entry while it's referenced and re-queries it once idle past
the new UCX_IB_AH_CACHE_TTL (default inf; 0 disables the cache and
hands out private, unshared AHs). Callers release with ah_put(), or
duplicate a reference with ah_hold() for independently-released
holders.

Port PR openucx#11825's identity-based is_connected()/conn_match for UD verbs
(compare dlid/gid instead of AH pointers) onto the refcounted cache,
and give EFA/SRD the same treatment since it also holds an AH for the
life of the endpoint. Other AH consumers (UD mlx5, RC mlx5 DEVX, DC
pure-grant, mlx5 compact-AV probe) switch to a transient get+put around
uct_ib_mlx5_get_av().
@svc-nvidia-pr-review

Copy link
Copy Markdown

🤖 Starting review — findings will be posted here when done.

uct_ud_verbs_ep_release_ah(self);
}

static void uct_ud_verbs_ep_destroy(uct_ep_h tl_ep)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

blocker — uct_ud_ep_disconnect() does not free the endpoint synchronously; it only sets close_time / UCT_UD_EP_FLAG_DISCONNECTED and defers the real ep_free() (which runs the uct_ud_verbs_ep_t cleanup) to the timer via uct_ud_ep_free_by_timeout() after linger_timeout. But the new destroy releases the AH immediately:

static void uct_ud_verbs_ep_destroy(uct_ep_h tl_ep)
{
    uct_ud_ep_disconnect(tl_ep);
    uct_ud_verbs_ep_release_ah(ucs_derived_of(tl_ep, uct_ud_verbs_ep_t));
}

During the linger window the endpoint stays alive and can still retransmit its TX window. uct_ud_verbs_post_send() reads ep->ah_entry->ah, but uct_ud_verbs_ep_release_ah() has already set ep->ah_entry = NULL, so a resend dereferences NULL. It also drops the AH cache reference while the ep may still be sending, so with a short/zero TTL the underlying ibv_ah can be destroyed and reused by the device. The AH release must happen when the ep is actually freed (in the cleanup func), not right after uct_ud_ep_disconnect(). The cleanup func already calls uct_ud_verbs_ep_release_ah(), so this extra release in destroy looks unsafe.

Suggested question to the author: can we drop the AH release from uct_ud_verbs_ep_destroy and rely solely on the cleanup func, since the ep can still resend during the disconnect linger period?

kh_foreach_value(&dev->ah_hash, ah, ibv_destroy_ah(ah));
uct_ib_ah_entry_t *entry;

kh_foreach_value(&dev->ah_hash, entry, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The new cleanup both asserts refcount == 0 and warns + destroys when it is non-zero:

ucs_assertv(entry->refcount == 0, ...);
if (entry->refcount != 0) {
    ucs_warn(...);
}
ibv_destroy_ah(entry->ah);
ucs_free(entry);

If any live holder still references the entry at device cleanup (which the blocker above can produce), this frees the entry and destroys the AH out from under it. The assert catches it in debug builds, but release builds warn and then continue into a use-after-free for the holder. Worth confirming the lifetime guarantees so this path is truly unreachable, otherwise the leak-vs-UAF tradeoff needs a decision.

@svc-nvidia-pr-review

Copy link
Copy Markdown

Coverage note: The added gtests (test_uct_ib_ah_cache, test_ud_verbs_ah_cache, test_ud_verbs_is_connected) cover refcount sharing, TTL reuse/re-query, TTL=0, hold, and ep-churn refcount balance. They do not cover the disconnect-linger resend path that the blocker concerns — a test that destroys a ud_verbs ep with an outstanding/unacked TX window (forcing a resend after destroy) would exercise it.

@svc-ucx

svc-ucx commented Aug 27, 2026

Copy link
Copy Markdown

🤖 CI Triage AgentUCX PR (Tests new on worker 0) · commit 8247b977

TL;DR: The UD-verbs async progress thread segfaults dereferencing ep->ah_entry (NULL) at ud_verbs.c:119, because the new AH-cache commit releases the endpoint's AH reference in uct_ud_verbs_ep_destroy() while the UD endpoint object deliberately stays alive after uct_ep_destroy() and keeps sending ACKs. Fix: don't NULL the AH in ep_destroy (leave it to the class cleanup func), or guard/short-circuit sends when ah_entry == NULL.

Full analysis

Summary: contrib/test_jenkins.sh exited 139 — ucx_info -epw -u t and examples/ucp_hello_world both crashed with SIGSEGV (signal 11, "address not mapped to object at address (nil)") in uct_ud_verbs_post_send() on the UCS async progress thread.

Root cause: Both backtraces are identical and deterministic:

uct_ud_verbs_post_send()   src/uct/ib/ud/verbs/ud_verbs.c:119
uct_ud_verbs_ep_tx_skb()   ud_verbs.c:149
uct_ud_verbs_ep_send_ctl() ud_verbs.c:187
uct_ud_iface_send_ctl()    ud_iface.h:543
uct_ud_ep_send_ack()       ud_ep.c:1591
uct_ud_ep_do_pending()     ud_ep.c:1695
ucs_arbiter_dispatch_nonempty() ... uct_ud_verbs_iface_async_handler()

Line 119 is wr->wr.ud.ah = ep->ah_entry->ah;. The faulting address is (nil), i.e. ep->ah_entry == NULL (a stale/freed entry would give a non-zero garbage address).

ah_entry is a brand-new field added by the HEAD commit (ud_verbs.h:28). The commit also added:

static void uct_ud_verbs_ep_destroy(uct_ep_h tl_ep)
{
    uct_ud_ep_disconnect(tl_ep);
    uct_ud_verbs_ep_release_ah(ucs_derived_of(tl_ep, uct_ud_verbs_ep_t)); /* sets ah_entry = NULL */
}

(ud_verbs.c:82-86, releasing at ud_verbs.c:63-74)

This is unsafe for UD: uct_ud_ep_disconnect() explicitly does not free the endpoint — see its own comment at src/uct/ib/ud/base/ud_ep.c:1886-1892 "the EP will be destroyed by interface destroy or timeout in uct_ud_ep_timer". The object stays in iface->eps and in iface->tx.pending_q, and dest_ep_id remains set so uct_ud_ep_is_connected() (guard at ud_ep.c:1544) still returns true. Consequently the async thread later dispatches a pending UCT_UD_EP_OP_CTL_ACK on the "destroyed" ep and dereferences the now-NULL ah_entry. Previously the AH lived in the device-wide AH cache and was never dropped per-endpoint, so this path was safe.

Secondary defect: uct_ud_verbs_ep_release_ah() is called from uct_ud_verbs_ep_destroy() outside uct_ud_enter()/uct_ud_leave(), so even with correct lifetime it races with the async progress thread.

Implicated commit: 8247b977 — "UCT/IB: Add refcounted, TTL-based address handle cache", Thomas Vegas (this is the PR HEAD, [REDACTED:Hex High Entropy String])

File: src/uct/ib/ud/verbs/ud_verbs.c:82-86 (premature release) and src/uct/ib/ud/verbs/ud_verbs.c:119 (unguarded deref)

Suggested fix:

  1. Remove the uct_ud_verbs_ep_release_ah() call from uct_ud_verbs_ep_destroy(). The AH reference must be dropped only in the class cleanup func (UCS_CLASS_CLEANUP_FUNC(uct_ud_verbs_ep_t), ud_verbs.c:76-80), which runs when the EP is actually freed by the iface destroy / deferred-timeout path (uct_ud_ep_deferred_timeout_handler, ud_ep.c:284-288). uct_ud_verbs_ep_destroy can then simply be uct_ud_ep_disconnect(tl_ep).
  2. If an early release is genuinely required, it must (a) hold uct_ud_enter(&iface->super) around release_ah, and (b) be paired with a defensive check so no send can occur without an AH — e.g. in uct_ud_verbs_ep_send_ctl()/post_send():
    if (ucs_unlikely(ep->ah_entry == NULL)) {
        return iface->tx.send_sn;  /* no AH: drop ctl packet */
    }
    plus an ucs_assertv(ep->ah_entry != NULL, "ep=%p", ep) in uct_ud_verbs_post_send() so debug builds catch this instead of segfaulting.
  3. Also audit uct_ud_verbs_ep_resolve_peer_address() (ud_verbs.c:661-664): it calls release_ah() before uct_ib_iface_ah_get(), leaving a window where ah_entry == NULL; if ah_get() fails the EP is left permanently AH-less. Get the new entry into a temporary and only release the old one after success.

Related: none found (no existing issue/PR matches this signature)

🛡️ This comment had 1 potential secret(s) redacted (Hex High Entropy String). See request_id 9ff409da-aec1-451c-8ca5-9270fe69bbad in the triage console for the audit trail.

@tvegas1 tvegas1 added the WIP-DNM Work in progress / Do not review label Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

WIP-DNM Work in progress / Do not review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants