Skip to content

cuda_ipc: imported remote mappings are never released until importer process exit (no per-peer eviction) — pins exporter VMM/fabric memory domain-wide #11753

Description

@404mario

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:

  1. 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)?
  2. 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)?
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions