Environment (loaded binary → source)
- UCX 1.21.0 (in-process
ucp_get_version_string() on the loaded
libucp; the process maps the bundled libuct_cuda.so, not a system UCX).
- Loaded DSO SHA-256 (full values below), bundled via NIXL 1.3.2 wheel:
libucs … fa900fe7c8d685ee70407ab681c655c25404b3e4e01145602df2422ac6cd59e3
libucp … 2c200186a59932afc88a8059527df5aae7c524860781b62c9b50719af4296475
libuct … 0f13475685a1b7b1422e31eec7f89310baba98da5f3e300cd78c2d2f34310cdf
ucx/libuct_cuda … 8dbeb2dedd5b1efb0c60335fca259acc21cee2ae7e165d922201285e6ccaafc3
- Source referenced against tag
v1.21.0
(tag → commit b6a9d47fccce849c28111f05a7fa8f1c930ff17d),
src/uct/cuda/cuda_ipc/cuda_ipc_cache.c (file SHA-256 prefix e9594315…).
- Hardware: multi-node NVLink (MNNVL, fabric handles via IMEX);
CU_MEM_HANDLE_TYPE_FABRIC VMM allocations transferred cross-node over the
cuda_ipc transport. Transfer bytes did not appear on any NIC counter during a
cross-node copy (consistent with an on-fabric NVLink path, i.e. VMM cuda_ipc
dispatch — not directly confirmed at the handle-type/dispatch level).
Caveat on provenance: the above proves runtime binary identity + a matching
source tag; it does not prove the bundled .so is bit-identical to an
unmodified tag build. Anyone reproducing on a from-source v1.21.0 build is
welcome to confirm.
Symptom
An importer that has mapped a peer's CU_MEM_HANDLE_TYPE_FABRIC (VMM) memory
over the cuda_ipc transport keeps that mapping alive for its entire process
lifetime. Releasing every handle the transport/consumer layer exposes does
not free it; the exporter-side allocation therefore stays resident on the
exporter GPU for as long as any importer process is alive — even after the
importer has finished all transfers and torn down its endpoints/agent.
At fleet scale this is a real operational hazard: tearing down a subset of a
fabric memory domain leaves exporter-GPU memory (in our case ~200 GiB/GPU)
pinned and unreclaimable until the surviving importer processes on other nodes
exit.
Minimal reproduction (2 process, in-process, no framework)
Two small Python processes (exporter allocates a CU_MEM_HANDLE_TYPE_FABRIC
VMM buffer, registers it; importer maps it via one cuda_ipc READ, then walks a
graded teardown ladder while staying alive). Per-step exporter-GPU HBM sampled
with nvidia-smi, real rc per step. Scripts + raw logs:
lifecycle_repro*.py, lifecycle_{E,v5}.sh, lifecycle_*/controller.log.
Graded destruction ladder inside a LIVING importer (exporter killed first;
exporter-GPU HBM, serving baseline 237.7 GiB, +8–9 GiB test buffer):
| step (importer stays alive) |
exporter HBM |
| release transfer handle |
pinned (~245.9 GiB) |
| remove remote agent |
pinned |
| deregister own memory |
pinned |
| destroy entire agent object + gc |
pinned |
| importer process exit |
freed (→ 237.7 GiB) |
Also measured with UCX_CUDA_IPC_CACHE=n: still pinned until process exit
(env recognition positive-controlled — a bogus UCX_* var warns "unused",
this one does not, so the toggle is parsed). A plausible reading, consistent
with the source but not proven by this experiment alone, is that the
consumer-layer teardown never reaches the unmap path so the cache toggle has
nothing to act on.
Where it comes from (source reading, v1.21.0 cuda_ipc_cache.c)
- Imported mappings live in a module-global cache
uct_cuda_ipc_remote_cache (hash + recursive spinlock), keyed by the remote
{pid, pid_ns, cu_device} triple.
- The import path (
uct_cuda_ipc_map_memhandle → ..._open_memhandle_vmm:
cuMemImportFromShareableHandle + cuMemAddressReserve + cuMemMap +
cuMemSetAccess) inserts into that cache with refcount semantics.
- Hash entries are destroyed only in
UCS_STATIC_CLEANUP (library
unload / process exit). Endpoint/worker/context/md teardown never removes a
peer's entry. uct_cuda_ipc_cache_purge is static; the LRU capacity path
is an insert-pressure valve, not a peer-close path.
- There is no public API to evict one peer's cached mappings. So a consumer
that has finished with a peer (peer gone, or last endpoint closed) has no way
to release the imported mappings short of exiting.
Confidence: the symptom (release-only-at-process-exit) is directly measured
and reproducible; the "module-global cache, static-cleanup-only" mechanism is
from source reading of the matching tag and is consistent with the
measurements, but I have not instrumented the running binary at that line
(this libucs build has ucs_trace compiled out). Independent confirmation
on a from-source build would be welcome.
Proposed direction (for maintainer feedback — not a submitted patch)
A per-peer eviction entry point, callable by a transport/consumer that knows a
peer is done, e.g.:
ucs_status_t uct_cuda_ipc_remote_cache_evict(pid_t pid, ucs_sys_ns_t pid_ns,
int cu_device);
Design notes we've worked through (full doc: UCX_PURGE_API_DESIGN.md):
- In-place region eviction, cache object stays in the hash — avoids both a
use-after-free of the cache object and the unmap-relookup-by-key hazard (the
existing unmap path re-looks-up the cache by triple key and asserts the
region is present, so detach-and-replace of the whole cache object is unsafe).
- Drain-before-evict precondition (caller closes admission for the peer and
waits for in-flight region refcounts to reach 0), so eviction is a clean
two-phase scan-then-remove: phase 1 under the cache lock verifies all regions
are idle (else UCS_ERR_BUSY, no side effect); phase 2 removes from pgtable
and closes handles after dropping the lock (no CUDA call under any lock).
- Idempotent, per-peer isolation (only that key's pgtable touched), triple
key so container PID-ns / PID reuse is disambiguated.
Open questions for maintainers:
- Is a per-peer eviction API the right shape, or is there an existing
mechanism I've missed (beyond LRU capacity limits and static cleanup)?
- Ownership/lifetime: does UCX expect the transport md/ep teardown to drive
this, or a new explicit call from the consumer (NIXL, in our case)?
- The separate
uct_cuda_ipc_rem_mpool_cache (fabric mempool path) has the
same static-cleanup-only lifetime — should a fix cover both?
I'm happy to prepare a PR with CPU-race unit tests (TSAN/ASan) and a
compute-sanitizer'd 2-process test if the direction is acceptable. Attaching
the minimal repro, per-step HBM logs, source-anchored analysis, and the API
design doc.
Environment (loaded binary → source)
ucp_get_version_string()on the loadedlibucp; the process maps the bundledlibuct_cuda.so, not a system UCX).libucs …fa900fe7c8d685ee70407ab681c655c25404b3e4e01145602df2422ac6cd59e3libucp …2c200186a59932afc88a8059527df5aae7c524860781b62c9b50719af4296475libuct …0f13475685a1b7b1422e31eec7f89310baba98da5f3e300cd78c2d2f34310cdfucx/libuct_cuda …8dbeb2dedd5b1efb0c60335fca259acc21cee2ae7e165d922201285e6ccaafc3v1.21.0(
tag → commit b6a9d47fccce849c28111f05a7fa8f1c930ff17d),src/uct/cuda/cuda_ipc/cuda_ipc_cache.c(file SHA-256 prefixe9594315…).CU_MEM_HANDLE_TYPE_FABRICVMM allocations transferred cross-node over thecuda_ipc transport. Transfer bytes did not appear on any NIC counter during a
cross-node copy (consistent with an on-fabric NVLink path, i.e. VMM cuda_ipc
dispatch — not directly confirmed at the handle-type/dispatch level).
Symptom
An importer that has mapped a peer's
CU_MEM_HANDLE_TYPE_FABRIC(VMM) memoryover the cuda_ipc transport keeps that mapping alive for its entire process
lifetime. Releasing every handle the transport/consumer layer exposes does
not free it; the exporter-side allocation therefore stays resident on the
exporter GPU for as long as any importer process is alive — even after the
importer has finished all transfers and torn down its endpoints/agent.
At fleet scale this is a real operational hazard: tearing down a subset of a
fabric memory domain leaves exporter-GPU memory (in our case ~200 GiB/GPU)
pinned and unreclaimable until the surviving importer processes on other nodes
exit.
Minimal reproduction (2 process, in-process, no framework)
Two small Python processes (exporter allocates a
CU_MEM_HANDLE_TYPE_FABRICVMM buffer, registers it; importer maps it via one cuda_ipc READ, then walks a
graded teardown ladder while staying alive). Per-step exporter-GPU HBM sampled
with
nvidia-smi, real rc per step. Scripts + raw logs:lifecycle_repro*.py,lifecycle_{E,v5}.sh,lifecycle_*/controller.log.Graded destruction ladder inside a LIVING importer (exporter killed first;
exporter-GPU HBM, serving baseline 237.7 GiB, +8–9 GiB test buffer):
Also measured with
UCX_CUDA_IPC_CACHE=n: still pinned until process exit(env recognition positive-controlled — a bogus
UCX_*var warns "unused",this one does not, so the toggle is parsed). A plausible reading, consistent
with the source but not proven by this experiment alone, is that the
consumer-layer teardown never reaches the unmap path so the cache toggle has
nothing to act on.
Where it comes from (source reading, v1.21.0
cuda_ipc_cache.c)uct_cuda_ipc_remote_cache(hash + recursive spinlock), keyed by the remote{pid, pid_ns, cu_device}triple.uct_cuda_ipc_map_memhandle→..._open_memhandle_vmm:cuMemImportFromShareableHandle+cuMemAddressReserve+cuMemMap+cuMemSetAccess) inserts into that cache with refcount semantics.UCS_STATIC_CLEANUP(libraryunload / process exit). Endpoint/worker/context/md teardown never removes a
peer's entry.
uct_cuda_ipc_cache_purgeisstatic; the LRU capacity pathis an insert-pressure valve, not a peer-close path.
that has finished with a peer (peer gone, or last endpoint closed) has no way
to release the imported mappings short of exiting.
Proposed direction (for maintainer feedback — not a submitted patch)
A per-peer eviction entry point, callable by a transport/consumer that knows a
peer is done, e.g.:
Design notes we've worked through (full doc:
UCX_PURGE_API_DESIGN.md):use-after-free of the cache object and the unmap-relookup-by-key hazard (the
existing unmap path re-looks-up the cache by triple key and asserts the
region is present, so detach-and-replace of the whole cache object is unsafe).
waits for in-flight region refcounts to reach 0), so eviction is a clean
two-phase scan-then-remove: phase 1 under the cache lock verifies all regions
are idle (else
UCS_ERR_BUSY, no side effect); phase 2 removes from pgtableand closes handles after dropping the lock (no CUDA call under any lock).
key so container PID-ns / PID reuse is disambiguated.
Open questions for maintainers:
mechanism I've missed (beyond LRU capacity limits and static cleanup)?
this, or a new explicit call from the consumer (NIXL, in our case)?
uct_cuda_ipc_rem_mpool_cache(fabric mempool path) has thesame static-cleanup-only lifetime — should a fix cover both?
I'm happy to prepare a PR with CPU-race unit tests (TSAN/ASan) and a
compute-sanitizer'd 2-process test if the direction is acceptable. Attaching
the minimal repro, per-step HBM logs, source-anchored analysis, and the API
design doc.