Skip to content

Nixl ep v2 - draft - #2409

Draft
lishapira wants to merge 11 commits into
ai-dynamo:mainfrom
lishapira:nixl_ep_v2
Draft

lishapira wants to merge 11 commits into
ai-dynamo:mainfrom
lishapira:nixl_ep_v2

Conversation

@lishapira

@lishapira lishapira commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

NIXL EP V2: Adaptive Expert-Parallel Communication

Overview

NIXL EP V2 is an expert-parallel communication implementation for Mixture-of-Experts (MoE) models built on top of NIXL's device API.

Compared with NIXL EP V1 (examples/device/ep), V2 adds topology-aware direct and hybrid kernels, fixed expert placement across topology changes, and CUDA graph reuse while the active world grows or shrinks.

NIXL EP V2 is located under runtime/ep and is imported through nixl.runtime.ep. The existing NIXL EP V1 implementation remains available through import nixl_ep.

Features

  • Dispatch and Combine support: High-throughput expert-parallel communication for MoE inference.
  • Topology-Aware:
    • One kernel for both topologies, with the data path chosen at kernel entry from a device-side topology descriptor: direct mode within one NVLink or Multi-Node NVLink (MNNVL) domain, hybrid mode with RDMA between separate NVLink domains and NVLink within them. The mode is selected automatically from the active topology.
    • Topology switch between Multi-Node NVLink and RDMA over separate NVLink domains as part of a rescale, with no buffer re-creation.
  • CUDA Graph Reuse: Captured graphs remain valid across topology and direct/hybrid mode changes.
  • Elastic Scaling: Ranks can be connected, disconnected, activated, or deactivated dynamically.
  • GEMM Agnosticism: Dispatch outputs expert-major compact, expert-major padded, or rank-major layouts, selected per call; combine accepts the same layout back.
  • Resource Optimization: Automatic SM count from a bandwidth model of the live world, a fixed launch grid whose idle blocks exit at entry, and one receive region shared by dispatch and combine.

Buffer Initialization

NIXL EP V2 uses an ElasticBuffer sized for the maximum expected topology:

import torch.distributed as dist
from nixl.runtime import ep

buffer = ep.ElasticBuffer(...)

# Dispatch and combine
buffer.dispatch(...)
buffer.combine(...)

# Change the active world.
# The communication mode is updated automatically.
buffer.set_active_ranks(active_rank_ids)

buffer.dispatch(...)
buffer.combine(...)

buffer.destroy()

More complex scaling operations can establish or remove peer connections separately:

buffer.connect_ranks(new_rank_ids, remote_mds, peer_infos)
buffer.stage_topology(active_ranks=new_active_rank_ids)
buffer.commit_topology()

# After departed ranks are no longer active:
buffer.disconnect_ranks(departed_rank_ids)
buffer.commit_topology()

Key APIs

  • ElasticBuffer(...): Allocate communication resources for the maximum topology and initialize the current cohort.
  • dispatch(...): Route token activations and expert selections to the destination ranks.
  • combine(...): Return and reduce expert outputs.
  • connect_ranks(...): Establish NIXL connections to new peers.
  • disconnect_ranks(...): Remove connections to departed peers.
  • stage_topology(...): Prepare a new active topology.
  • commit_topology(): Make the staged topology visible to subsequent kernels.
  • set_active_ranks(...): Stage and commit a new live world in one call. The direct or hybrid mode is derived automatically.
  • set_num_active_sms(...): Change the active kernel SM count without changing the live world.

Testing

The test suite under runtime/ep/tests validates fixed and elastic expert-parallel communication.

Plan files define a sequence of active worlds. Each phase validates dispatch and combine correctness, active membership, and the automatically selected communication mode.

Example plan:

[
  [0, 1, 2, 3],
  [0, 1, 2, 3, 4, 5, 6, 7],
  [4, 5, 6, 7]
]

This plan defines:

  • Phase 0: One NVLink domain is active; direct mode is selected.
  • Phase 1: Two domains are active; hybrid RDMA+NVLink mode is selected.
  • Phase 2: One domain remains active; the mode returns to direct.

Getting Started

Build NIXL with NIXL EP enabled:

meson setup build \
    -Ducx_path=<ucx-prefix> \
    -Dprefix=<nixl-prefix> \
    -Dbuildtype=release \
    -Dbuild_nixl_ep=true

meson compile -C build
meson install -C build

NIXL EP V2 requires PyTorch, the CUDA toolkit, rdma-core development headers, and UCX with its GPU device API and CUDA IPC support.

After installation:

from nixl.runtime import ep

Refer to runtime/ep/README.md for build requirements, environment variables, API details, and local test commands.

@copy-pr-bot

copy-pr-bot Bot commented Oct 13, 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

Copy link
Copy Markdown

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

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

🚀

@lishapira

Copy link
Copy Markdown
Contributor Author

/build

1 similar comment
@lishapira

Copy link
Copy Markdown
Contributor Author

/build

@svc-nixl

Copy link
Copy Markdown
Collaborator

🤖 CI Triage Agent — nixl-ci-build-wheel · commit 1b702b61

TL;DR: The nixl-ci-build-wheel EP-V2 CUDA targets fail to compile because src/api/device/gpu/impl/proxy/device_proxy_ring.cuh includes <device/proxy/proxy_protocol.h>, which only resolves via src/utils on the include path — and runtime/ep/meson.build's v2_inc_dirs omits utils_inc_dirs. Add utils_inc_dirs to v2_inc_dirs (and ship the proxy header in the wheel include set).

Full analysis

Summary: Two of three "Build Wheel" stages (x86_64 and aarch64, node IDs 506/475) failed at Dockerfile.manylinux STEP 18/27 during ninja, on nvcc compiles of runtime/ep/csrc/kernels/backend/nixl_device.cu and runtime/ep/csrc/indexing/main.cu.

Root cause: Missing include directory, not a missing file. The error is:

../src/api/device/gpu/impl/proxy/device_proxy_ring.cuh:25:10: fatal error:
     device/proxy/proxy_protocol.h: No such file or directory
   25 | #include <device/proxy/proxy_protocol.h>

reached via nixl_device.cuh → device_dispatch.cuh:64 → impl/proxy/device_proxy.cuh:20.

The header exists at src/utils/device/proxy/proxy_protocol.h, so the angle-bracket path device/proxy/proxy_protocol.h is only resolvable with src/utils on the search path, i.e. the global utils_inc_dirs (meson.build:606). The failing nvcc command lines in the log contain -I../src/api/device -I../src/core -I../src/infra -I../src/api/cpp … but no -I../src/utils — matching v2_inc_dirs in runtime/ep/meson.build:116-123, which lists nixl_inc_dirs, nixl_device_inc_dirs, csrc, nixl_ep_v2/include, cccl, torch_inc_dirs and omits utils_inc_dirs. Every other consumer of these device headers (e.g. src/plugins/ucx/meson.build:38-39, src/utils/device/proxy/meson.build:26) passes both nixl_inc_dirs and utils_inc_dirs, which is why only the EP-V2 targets break.

The proxy device headers came in with the CPU-Proxy series (PR #2326 / #2327), while the EP-V2 build targets were added separately by b69639b1/1b702b61 — the two branches never had their include paths reconciled.

Secondary, unrelated flake: the third wheel stage (node 462, aarch64) failed earlier in meson setup with fatal: unable to access 'https://github.com/taskflow/taskflow.git/': Empty reply from server → ERROR: Subproject taskflow is buildable: NO (meson.build:233). That is a transient network failure, not the code defect.

Implicated commit: [REDACTED:Hex High Entropy String] — Lior Shapira, "nixl_ep_v2: Fix build warnings and separate V2 tests" (targets introduced in b69639b, same author); the header dependency originates from Tomer Davidor's CPU-proxy work (e12e9e72, 03ae3ea4).

File: runtime/ep/meson.build:116 (include list), consuming src/api/device/gpu/impl/proxy/device_proxy_ring.cuh:25

Suggested fix:

  1. Add utils_inc_dirs to v2_inc_dirs:
v2_inc_dirs = [
    nixl_inc_dirs,
    nixl_device_inc_dirs,
    utils_inc_dirs,          # <-- src/utils, for <device/proxy/proxy_protocol.h>
    include_directories('csrc'),
    include_directories('nixl_ep_v2/include'),
    include_directories(v2_cccl_include_root),
    torch_inc_dirs,
]
  1. Apply the same addition to examples/device/ep/meson.build:71-77 (nixl_ep_inc_dirs) — it has the identical omission and will break as soon as its .cu sources pull in nixl_device.cuh.
  2. Because the wheel installs only src/api/device/gpu (runtime/ep/meson.build:239-243), the installed EP-V2 headers will still be unusable at JIT time without proxy_protocol.h. Add an install_subdir/install_data for src/utils/device/proxy/proxy_protocol.h into v2_include_install_dir under device/proxy/, mirroring src/utils/device/proxy/meson.build:30-33.
  3. Re-run the aarch64 stage that hit the taskflow clone failure; if it recurs, consider vendoring/mirroring the taskflow tarball instead of a live git clone in meson.build:233.

Related: PR #2326 (CPU Proxy part 5, introduces the device proxy headers), PR #2327, PR #2328; current PR #2409.

🛡️ This comment had 1 potential secret(s) redacted (Hex High Entropy String). See request_id 11102f69-b4d6-4422-97ee-9f2ad7eae191 in the triage console for the audit trail.

@svc-nixl

Copy link
Copy Markdown
Collaborator

🤖 CI Triage Agent — nixl-ci-non-gpu · commit 1b702b61

TL;DR: The MDManagerEtcdFixture.InvalidateLocalRemovesRemote gtest failed because the etcd metadata backend arms its agent watcher after the remote MD is already loaded and visible, and arms it with no start revision — so an invalidateLocalMD (etcd rmdir) that lands in that window produces a DELETE event nobody is watching, and the destination's remote MD is never invalidated. Fix the race by arming the watcher before/with the fetch revision rather than after loadRemoteMD.

Full analysis

Summary: Stage 574 ("Test CPP", debug/aarch64 variant) failed on 1 of 341 gtest cases: MDManagerEtcdFixture.InvalidateLocalRemovesRemote — waitForRemoteMD(...) returned NIXL_SUCCESS (0) where NIXL_ERR_NOT_FOUND (-4) was expected (../test/gtest/md_manager.cpp:314).

Root cause: A latent ordering race in the ETCD metadata backend, not a timeout.

  • The test flow is: src.sendLocalMD() → dst.fetchRemoteMD(src) → wait for checkRemoteMD == NIXL_SUCCESS → src.invalidateLocalMD() → expect dst to observe NIXL_ERR_NOT_FOUND.
  • In nixlEtcdMetadataBackend::fetchRemote, the worker lambda calls ctx_.loadRemoteMD(blob, …) first and only then client_->setupAgentWatcher(remote_name) (src/core/nixl_etcd_metadata_backend.cpp:401 then :413). checkRemoteMD on dst starts succeeding the instant loadRemoteMD returns, so the test's first ASSERT can pass while the DELETE watcher is still unarmed.
  • setupAgentWatcher constructs etcd::Watcher(*etcd_, agent_prefix, process_response) with no start revision (:296-298), so it only sees events after it is armed. The rmdir issued by invalidateLocal (:421 → removeMetadataFromEtcd, :146) therefore produces a DELETE that is lost permanently, processInvalidatedAgents/ctx.invalidateRemoteMD never runs, and checkRemoteMD stays NIXL_SUCCESS forever.
  • The log corroborates "lost event", not "slow": the case burned the full 3 s poll window (3629 ms total) with no state change, and the etcd server in this run was visibly loaded (etcdserver: ... took too long (288.793648ms), MetadataExchangeTestFixture.EtcdSendLocal* taking 41 s and 51 s), which widens the unarmed-watcher window. The same test passed in the five sibling build variants of this build, so this is flakiness in the test/backend, not a regression from the PR under test (the commit is on nixl_ep_v2/PR Nixl ep v2 - draft #2409 and touches nothing in the metadata path).

Implicated commit: 2ff0105f — "Metadata manager pr5 (#1930)", aschwartz12 (the only commit to touch src/core/nixl_etcd_metadata_backend.cpp; the ordering and un-revisioned watcher originate there). Test last touched by 21b6327b, Daniel Pressler, "test: lengthen the md_manager remote-MD wait under sanitizers (#2349)".

File: src/core/nixl_etcd_metadata_backend.cpp:413 (watcher armed after loadRemoteMD) and src/core/nixl_etcd_metadata_backend.cpp:296-298 (watcher created without a start revision); failing assertion at test/gtest/md_manager.cpp:314.

Suggested fix: Close the window instead of widening the poll:

  1. In fetchRemote, call setupAgentWatcher(remote_name) before fetchOrWaitForMetadataFromEtcd/loadRemoteMD, so the agent prefix is being watched before the MD becomes visible to checkRemoteMD.
  2. Arm the watcher from a known revision: capture response.index() from the initial get in fetchMetadataFromEtcd/fetchOrWaitForMetadataFromEtcd and pass it to the etcd::Watcher overload that takes a start index (the same pattern already used in waitForMetadataFromEtcd, :203/:229). That makes a DELETE racing the arming replayable rather than lost.
  3. Optionally, as defence in depth, have processInvalidatedAgents or checkRemoteMD's miss path re-get the agent prefix so a dropped event self-heals instead of leaving a stale remote entry for the process lifetime.

Do not simply raise kRemoteMdTimeout again (as #2349 did) — the lost event never arrives, so a longer wait cannot help.

Related: #2349 (prior timeout bump for this same fixture's flakiness), #1930 (introduced the etcd metadata backend), #2130 (related etcd metadata-cleanup test gating)

@svc-nixl

Copy link
Copy Markdown
Collaborator

🤖 CI Triage Agent — nixl-ci-dl-gpu-ep · commit 1b702b61

TL;DR: The nixl-ep Docker build failed at ninja because the two new NIXL‑EP V2 CUDA targets (runtime/ep/meson.build) compile src/api/device/gpu/nixl_device.cuh, which now pulls in <device/proxy/proxy_protocol.h> from src/utils, but v2_inc_dirs doesn't include utils_inc_dirs — add it (and ship that header in the wheel include set).

Full analysis

Summary: Stage "Compiling NIXL EP Docker Image for DL" (node 161) failed: nvcc fatal error — device/proxy/proxy_protocol.h: No such file or directory while compiling runtime/ep/csrc/kernels/backend/nixl_device.cu and runtime/ep/csrc/indexing/main.cu, which stopped ninja and then the wheel copy (cp: cannot stat 'nixl_build/src/bindings/python/nixl-meta/nixl-*.whl'), failing the RUN ... .gitlab/build.sh step.

Root cause: Missing include directory, not a missing file. Both failing targets include src/api/device/gpu/nixl_device.cuh → impl/device_dispatch.cuh:64 → impl/proxy/device_proxy.cuh:20 → impl/proxy/device_proxy_ring.cuh:25, which includes the proxy wire contract as <device/proxy/proxy_protocol.h>. That header lives at src/utils/device/proxy/proxy_protocol.h and only resolves when src/utils is on the include path (utils_inc_dirs = include_directories('src/utils'), root meson.build:606); src/utils/device/proxy/meson.build:31-33 documents exactly this ("Device code includes the wire contract as <device/proxy/proxy_protocol.h>"). The V2 include set v2_inc_dirs (runtime/ep/meson.build:116-123) lists nixl_inc_dirs, nixl_device_inc_dirs, csrc, nixl_ep_v2/include, CCCL and torch — but not utils_inc_dirs. The failing nvcc command lines in the log confirm no -I../src/utils. -I/opt/nixl/include is on the line but only holds the freshly built UCX headers, not NIXL's not-yet-installed ones.

Implicated commit: 1b702b61 / b69639b1 — Lior Shapira, "nixl_ep_v2: Add build and wheel packaging" / "Fix build warnings and separate V2 tests" (added the V2 targets with an incomplete include set); the transitive proxy include came from PR #2326 (CPU Proxy part 5).

File: runtime/ep/meson.build:116 (v2_inc_dirs); failing include at src/api/device/gpu/impl/proxy/device_proxy_ring.cuh:25; header at src/utils/device/proxy/proxy_protocol.h

Suggested fix: Add utils_inc_dirs to v2_inc_dirs so both the extension module and the nixl_ep_v2_indexing_probe target see the proxy contract:

v2_inc_dirs = [
    nixl_inc_dirs,
    nixl_device_inc_dirs,
    utils_inc_dirs,            # <device/proxy/proxy_protocol.h>
    include_directories('csrc'),
    include_directories('nixl_ep_v2/include'),
    include_directories(v2_cccl_include_root),
    torch_inc_dirs,
]

Also extend the wheel header packaging at runtime/ep/meson.build:239-247 with install_data(join_paths(meson.project_source_root(), 'src', 'utils', 'device', 'proxy', 'proxy_protocol.h'), install_dir: join_paths(v2_include_install_dir, 'device', 'proxy')), otherwise JIT-compiled kernels against the installed wheel will hit the identical error at runtime. Worth auditing examples/device/ep/meson.build:71-77 (nixl_ep_inc_dirs) for the same gap — it survived this build only because its .cu doesn't yet reach the proxy include chain.

Related: #2326 (CPU Proxy part 5, introduced the device-side proxy include), #2225/#2226/#2223 (CPU proxy parts 1–3, added proxy_protocol.h), #2327/#2328 (open follow-ups in the same series)

@svc-nixl

Copy link
Copy Markdown
Collaborator

🤖 CI Triage Agent — nixl-ci-build-container-pr · commit 1b702b61

TL;DR: The NIXL‑EP V2 CUDA targets fail to compile because runtime/ep/meson.build's include list omits utils_inc_dirs (src/utils), so the GPU device headers' #include <device/proxy/proxy_protocol.h> can't be resolved; add utils_inc_dirs to v2_inc_dirs.

Full analysis

Summary: Both BUILD_NIXL_EP=true "Build image" branches (x86_64 and aarch64) of nixl-ci-build-container-pr #1098 failed at Dockerfile STEP 68 (meson setup … && ninja) with fatal error: device/proxy/proxy_protocol.h: No such file or directory while compiling runtime/ep/csrc/kernels/backend/nixl_device.cu and runtime/ep/csrc/indexing/main.cu.

Root cause: Not infrastructure — a deterministic include-path gap, reproduced identically on both architectures.

The include chain in the log is:
runtime/ep/csrc/kernels/backend/nixl_device.cu:26 → src/api/device/gpu/nixl_device.cuh:29 → src/api/device/gpu/impl/device_dispatch.cuh:64 → src/api/device/gpu/impl/proxy/device_proxy.cuh:20 → src/api/device/gpu/impl/proxy/device_proxy_ring.cuh:25, which does #include <device/proxy/proxy_protocol.h>.

That header lives at src/utils/device/proxy/proxy_protocol.h, so the angle-bracket include only resolves when src/utils (utils_inc_dirs, defined at meson.build:606) is on the compile line. The other consumer of these headers does exactly that — test/gtest/meson.build:164 passes [nixl_inc_dirs, utils_inc_dirs, device_api_inc, …].

runtime/ep/meson.build:116-123 builds v2_inc_dirs from only nixl_inc_dirs, nixl_device_inc_dirs, csrc, nixl_ep_v2/include, the CCCL root and the Torch dirs — no utils_inc_dirs. The failing nvcc command lines in the log confirm this: they carry -I../src/api/device, -I../src/core, -I../src/infra, -I../src/api/cpp but no -I../src/utils. The non‑EP image variants (stages 125 and 233) passed because runtime/ep is only entered when -Dbuild_nixl_ep=true (meson.build:609-611).

This is a latent mismatch that surfaced when the proxy headers were pulled into device_dispatch.cuh: src/utils/device/proxy/meson.build:31-32 even comments "Device code includes the wire contract as <device/proxy/proxy_protocol.h>" and installs it to $prefix/include/device/proxy — correct for installed consumers, but the in‑tree EP build never gets that path.

Implicated commit: [REDACTED:Hex High Entropy String] — Lior Shapira, "nixl_ep_v2: Fix build warnings and separate V2 tests" (the commit under test; it is the most recent change to runtime/ep/meson.build, following b69639b1 which introduced the V2 build). The headers it trips over came in via e12e9e72 / 03ae3ea4 (Tomer Davidor, CPU Proxy parts 1 and 3).

File: runtime/ep/meson.build:116 (v2_inc_dirs definition); failing include at src/api/device/gpu/impl/proxy/device_proxy_ring.cuh:25

Suggested fix: Add utils_inc_dirs to v2_inc_dirs so both the extension module and the indexing probe can resolve the proxy wire contract:

v2_inc_dirs = [
    nixl_inc_dirs,
    nixl_device_inc_dirs,
    utils_inc_dirs,          # <device/proxy/proxy_protocol.h> lives under src/utils
    include_directories('csrc'),
    include_directories('nixl_ep_v2/include'),
    include_directories(v2_cccl_include_root),
    torch_inc_dirs,
]

Two follow-ups worth doing in the same change:

  1. The wheel's self-contained include tree (runtime/ep/meson.build:234-247) installs src/api/device/gpu and nixl_types.h but not proxy_protocol.h. Since the installed GPU headers include it, add an install_data/install_headers of src/utils/device/proxy/proxy_protocol.h into v2_include_install_dir + '/device/proxy', or JIT compilation against the shipped headers will fail at runtime with the same error.
  2. examples/device/ep/meson.build:71-77 has the same omission; it only escapes today because its kernels don't reach nixl_device.cuh. Adding utils_inc_dirs there too prevents a repeat.

Related: #2225, #2226, #2223 (CPU Proxy parts 1–3, which introduced proxy_protocol.h and the <device/proxy/...> include convention); PR #2409 is the failing change.

🛡️ This comment had 1 potential secret(s) redacted (Hex High Entropy String). See request_id dea94ffc-5799-4f7f-a267-6d2af898e83c in the triage console for the audit trail.

@lishapira

Copy link
Copy Markdown
Contributor Author

/build

@lishapira

Copy link
Copy Markdown
Contributor Author

/build

@svc-nixl

Copy link
Copy Markdown
Collaborator

🤖 CI Triage Agent — nixl-ci-non-gpu · commit 21134483

TL;DR: The Test CPP stage of one of six parallel build variants failed on a single gtest, MDManagerEtcdFixture.InvalidateLocalRemovesRemote, which waits only 3 s for an etcd metadata invalidation to propagate; the embedded etcd server in that container was demonstrably slow during the run, so checkRemoteMD still returned NIXL_SUCCESS when the poll deadline expired. Fix is to make the wait tolerant of slow etcd (raise/scale kRemoteMdTimeout for the etcd fixture, not just under sanitizers).

Full analysis

Summary: Test CPP (stage ID 709) failed because 1 of 341 gtest cases failed — MDManagerEtcdFixture.InvalidateLocalRemovesRemote — while the same stage passed in the other five parallel variants.

Root cause: Timing-sensitive assertion against a slow etcd store, not a functional regression.

Evidence from the stage log:

  • The failure is an expectation mismatch, not a crash:
    ../test/gtest/md_manager.cpp:314: Failure
    Expected equality of these values:
      waitForRemoteMD(dst.agent.get(), src.name, {DRAM_SEG}, NIXL_ERR_NOT_FOUND)
        Which is: 0            <-- NIXL_SUCCESS, remote MD still visible
      NIXL_ERR_NOT_FOUND
        Which is: -4
    [warn] watcher does't exit normally
    
    i.e. after src.agent->invalidateLocalMD(nullptr), the dst agent had not yet observed the deletion when the poll deadline hit. The watcher does't exit normally warning is consistent with the etcd watch being torn down before the delete event was delivered.
  • waitForRemoteMD has a hard 3-second budget on this build: kRemoteMdTimeout = kSanitizerBuild ? 15s : 3s (test/gtest/md_manager.cpp:89-90). This is a non-sanitizer variant, so it only got 3 s.
  • The etcd backend was unusually slow in this container during this exact run. The etcd server itself logged latency warnings in the same stage:
    etcdserver: read-only range request ".../agent_0/metadata" ... took too long (170.529045ms)
    etcdserver: request ... put:<key:"/nixl/cpp_ci/10501/target/" ...> took too long (113.758326ms)
    
    and the neighbouring etcd tests ran one to two orders of magnitude slower than their TCPStore equivalents:
    • MetadataExchangeTestFixture.EtcdSendLocalAndFetchRemote — 41 054 ms
    • EtcdSendLocalPartialAndFetchRemote — 51 055 ms
    • EtcdSendLocalPartialAndFetchRemoteWithErrors — 21 022 ms
      versus MDManagerTcpStoreFixture.SendAndFetchByName — 1 545 ms and MDManagerTcpStoreFixture.InvalidateLocalRemovesRemote — 939 ms (passed).
  • The identical TCPStore-backed test (MDManagerTcpStoreFixture.InvalidateLocalRemovesRemote) passed immediately after, which isolates the problem to the etcd path's propagation latency rather than to invalidation logic.
  • Contributing mechanism: the fixture builds agents with NIXL_THREAD_SYNC_STRICT and no listen thread (md_manager.cpp:254-257), so invalidation propagation is driven opportunistically by the comm thread / API calls rather than a dedicated progress thread — which widens the window when the store is slow.

This is a flaky test, not a defect introduced by commit [REDACTED:Hex High Entropy String]. All other work in the build (Build, Test Python, Test Nixlbench, Test Rust, and five sibling Test CPP runs) succeeded.

Implicated commit: No functional regression implicated. The relevant test-timing code was last touched by 21b6327b — "test: lengthen the md_manager remote-MD wait under sanitizers (#2349)", Daniel Pressler — which raised this wait only for sanitizer builds and left non-sanitizer builds at 3 s.

File: test/gtest/md_manager.cpp:89-90 (the 3 s kRemoteMdTimeout), asserted at test/gtest/md_manager.cpp:314

Suggested fix:

  1. Decouple the etcd timeout from the sanitizer flag. The etcd round-trip is slow regardless of instrumentation, so give MDManagerEtcdFixture its own, larger budget, e.g.:
    constexpr std::chrono::milliseconds kRemoteMdTimeout =
        kSanitizerBuild ? std::chrono::seconds(15) : std::chrono::seconds(3);
    // etcd propagation is store-bound, not CPU-bound — don't gate it on sanitizers
    constexpr std::chrono::milliseconds kEtcdRemoteMdTimeout = std::chrono::seconds(30);
    and pass kEtcdRemoteMdTimeout at the waitForRemoteMD call sites in MDManagerEtcdFixture (lines 302, 311, 314). Note SendAndFetchByName on the same fixture took 1 545 ms and InvalidateLocalRemovesRemote 4 609 ms before failing, so headroom of a few seconds is not enough given the 20–50 s latencies observed here.
  2. Make the diagnosis cheaper next time: on timeout, log the last checkRemoteMD status plus elapsed time so a slow-store failure is distinguishable from a genuine invalidation bug.
  3. Optionally re-run build #3618 to confirm the test passes — the other five variants of the same commit already did.

Related: PR #2409 (nixl_ep_v2 — the branch under test); PR #2349 (prior timeout lengthening for this same wait, sanitizer-only); PR #2424 ("CI: print gtest failures when the sanitizer suite fails") is adjacent tooling work on surfacing these failures.

🛡️ This comment had 1 potential secret(s) redacted (Hex High Entropy String). See request_id af5f1492-bc46-480b-90f5-272ef5cab0a3 in the triage console for the audit trail.

@svc-nixl

Copy link
Copy Markdown
Collaborator

🤖 CI Triage Agent — nixl-ci-gpu-vr · commit 21134483

TL;DR: The "Run CPP tests" stage aborted because ./bin/nixl_test initiator never sets cfg.listenPort, so its metadata listener always binds the hard-coded default port 8888 — which was already in use on the shared test host — and the unhandled std::runtime_error killed the process (exit 134). Fix: give the initiator a CI-allocated port or listenPort = 0 (OS-assigned), as the Python examples already do.

Full analysis

Summary: Stage 307 "Run CPP tests" failed at the final NIXL client-server test: nixl_test initiator threw std::runtime_error: Failed to bind metadata listener socket and srun reported task 0: Exited with exit code 134 (SIGABRT) on vr-nvl72-ts3-l11-062-c01.

Root cause: Not a hang or a timeout — the whole C++ stage ran continuously and this is a hard abort at the very last command. The log shows:

+ nixl_test_port=27903
+ parallel ... ::: './bin/nixl_test target' 'sleep 3 ; ./bin/nixl_test initiator' ::: '127.0.0.1 27903'
E1018 13:45:48 metadata_stream.cpp:97] Socket Bind failed while setting up listener for MD: Address already in use [98]
terminate called after throwing an instance of 'std::runtime_error'
  what():  Failed to bind metadata listener socket

The CI script only allocates one port (27903, via get_next_tcp_port) and that port is consumed by the target (runTarget sets cfg.listenPort = port). runInitiator in test/nixl/nixl_test.cpp enables useListenThread = true but never assigns cfg.listenPort, so it falls back to nixlAgentConfig::kDefaultListenPort = default_comm_port = 8888 (src/api/cpp/nixl_types.h:133). Port 8888 lies entirely outside the per-executor range that .ci/scripts/common.sh carves out (10500–32500, sliced by nixl_concurrent_id), so the collision-avoidance machinery does not protect it — any other concurrent NIXL CI job or leftover agent on that shared host holds 8888 and the bind fails. SO_REUSEADDR (set at metadata_stream.cpp:87) does not permit two live listeners on the same port, confirming a real occupant. setupListener then propagates the exception out of the nixlAgent constructor with no handler, so the process aborts → 134.

Implicated commit: unknown — latent defect, not introduced by [REDACTED:Hex High Entropy String]. The fixed-port default predates the port-pooling work; the relevant enabling change is 0797ede5 ("Support OS-assigned ports for metadata listener", #1439, Guy Ealey Morag), which added the port == 0 path this test never adopted.

File: test/nixl/nixl_test.cpp:239-243 (runInitiator, missing cfg.listenPort); error surfaces at src/utils/stream/metadata_stream.cpp:97 and :104-108; default at src/api/cpp/nixl_params.h:32 / src/api/cpp/nixl_types.h:133.

Suggested fix: Stop the initiator from using the hard-coded 8888:

  1. Preferred — in runInitiator, set cfg.listenPort = 0 so the OS assigns a free port (the initiator only fetches/sends MD to the target; it needs no well-known port). This is exactly the pattern .gitlab/test_cpp.sh's Python peers already use (--port=0 plus "MD listener is listening on port" scraping).
  2. Alternatively, allocate a second port in .gitlab/test_cpp.sh:126 (nixl_initiator_port=$(get_next_tcp_port)), pass it as an extra argv, and plumb it into cfg.listenPort for the initiator so it stays inside the per-executor range.
  3. Hardening, independently useful: catch the bind failure in the nixl_test main and exit with a clear message instead of terminate(), so this shows up as a diagnosable test failure rather than SIGABRT.

Related: 0797ede5 (#1439 — OS-assigned listener ports), 2058f615 (#1371 — "Do not fail silently on metadata listener socket setup", which made this failure fatal), f125c7bc (#1685 — "CI: cap port pool below kernel ephemeral range").

🛡️ This comment had 1 potential secret(s) redacted (Hex High Entropy String). See request_id 15bcd22a-0a91-404a-aa0a-45a9fb67d90c in the triage console for the audit trail.

Import files byte-for-byte from DeepEP branch review/nixl_ep_v2 at
573bc1b703a849fe0dcd0a711cae4fd039b737a6, which already renames the
package and namespace deep_ep to nixl_ep_v2 and the remaining DeepEP
mentions.

Copied:
- csrc/python_api.cpp
- csrc/elastic/**
- csrc/indexing/main.cu
- csrc/jit/**
- csrc/kernels/backend/{nixl.cpp,nixl.hpp,nixl_device.cu,nixl_device.cuh}
- csrc/kernels/elastic/**
- csrc/utils/{event,format,hash,lazy_driver,lazy_init,system}.hpp
- nixl_ep_v2/**
- tests/elastic/**: the tests, the profilers and the compile probe
- tests/utils/test_gate.py

Not copied:
- CMakeLists.txt and the CMakeLists.txt files under csrc/
- csrc/utils/shared_memory.hpp
- third-party/fmt
- README.md, LICENSE, and figures/
- build.sh, develop.sh, format.sh, and install.sh
- setup.py, pyproject.toml, and requirements-lint.txt
- DeepEP repository metadata and configuration files

This commit intentionally contains no NIXL-specific adaptations or
formatting changes.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
Rename the 28 user-configurable EP_* environment variables to
NIXL_EP_* across their Python and C++ reads, diagnostics, and docs.

Keep compile-time EP_* macros and the EP_NIXL Store key unchanged. In
particular, only the environment reads corresponding to
EP_ADAPTIVE_PROFILE and EP_NUM_TOPK_IDX_BITS are renamed; their JIT
-D flags and kernel macros retain the original spelling.

Keep the four NIXL-specific knobs under NIXL_EP_NIXL_*: the first NIXL
is the NIXL_EP component namespace, while the second identifies the
NIXL transport. This avoids colliding with v1's differently configured
NIXL_EP_NUM_CHANNELS.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
Load the torch-versioned NIXL-EP V2 extension with the same optional
RTLD_DEEPBIND handling as v1, and expose it as the package-local _C
module before importing submodules that depend on it.

Make all internal extension imports relative so the package loads once
under its final nixl.runtime.ep name. During package initialization,
select the extension matching the installed torch version, initialize
the JIT, and reject a top-k index-width setting that disagrees with the
compiled host extension.

DeepEP formats generated JIT commands and source strings with its
bundled fmt library. NIXL builds with C++20 and does not carry that
third-party dependency, so expose std::format through the existing
nixl_ep_v2::fmt namespace and keep the current fmt::format call sites
unchanged.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
find_pkgs.py:
- Use the headers shipped inside the NIXL EP package by default.
- Keep NIXL_EP_NIXL_ROOT_DIR and NIXL_EP_UCX_ROOT_DIR as explicit
  overrides.
- Stop discovering arbitrary system NIXL and UCX installations, which
  could provide headers that do not match the wheel's libraries.
- Check only the NIXL device header needed by the JIT, and warn when the
  system rdma-core header required for inter-node puts is missing.

compiler.hpp:
- Add the packaged NIXL and UCX header contents to the kernel cache key,
  so a wheel upgrade cannot reuse cubins built against older headers.
- Add the libcu++ atomic-storage workaround required by nvcc 12.9.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
meson.build:
- Build runtime/ep when NIXL-EP is enabled.

runtime/ep/meson.build:
- Use DeepEP V2's setup.py as the reference for source files, CUDA
  flags, C++20, CCCL, and the NIXL and UCX dependencies.
- Use V1's examples/device/ep/meson.build as the reference for PyTorch
  detection, Torch-versioned extension naming, rpaths, debug-build
  handling, the nvcc 12.9 workaround, and Python installation.
- Check the required CUDA, UCX device API, CUDA IPC, PyTorch, pybind11,
  CCCL, and C++20 support, and skip V2 cleanly when unavailable.
- Build the V2 extension and a non-installed probe that compiles all
  JIT kernel headers during the normal build.
- Install the Python package, extension, and matching NIXL and UCX
  headers into the CUDA backend wheel.

contrib/build-wheel.sh:
- Keep the complete wheel built for the first requested Torch version.
- For each additional Torch version, build a temporary wheel and copy
  its Torch-specific V2 extension into the complete wheel.
- Keep V1 and V2 extensions in their separate package directories.
- Skip the V2 merge safely when Meson did not build V2 because a
  required dependency was unavailable.

pyproject.toml:
- Add nvidia-ml-py, which ElasticBuffer uses for GPU topology
  discovery.

runtime/ep/README.md:
- Document the V2 overview, features, initialization, key APIs,
  requirements, build instructions, JIT behavior, environment
  variables, and local tests.
- Mark the original nixl_ep implementation as temporarily available
  and planned for removal.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
LICENSE and runtime/ep/LICENSE-DeepEP:
- Attribute runtime/ep to deepseek-ai/DeepEP commit
  01dc3aaac82068020353dce2c302e38153c0bfaa.
- Include DeepEP's MIT license alongside NIXL's Apache 2.0 license.

runtime/ep imported sources:
- Add DeepSeek and NVIDIA copyright notices to every imported Python,
  C++, CUDA, and header file.
- Mark the original DeepEP material as MIT licensed and NVIDIA's
  modifications as Apache 2.0 using the combined
  MIT AND Apache-2.0 SPDX identifier.

CONTRIBUTING.md and the contribution guide:
- State that contributions modifying the DeepEP-derived V1 and V2
  implementations must be licensed under Apache 2.0.

.github/workflows/clang-format.yml:
- Exclude runtime/ep from NIXL's clang-format check, matching the
  existing V1 exclusion.
- Preserve the imported formatting so upstream comparisons and future
  re-syncs remain focused on functional changes.

This commit changes licensing, attribution, and formatting policy only;
it does not change runtime or build behavior.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
- Add a lazy nixl.runtime package that routes submodule imports to the selected CUDA backend, and include it in the meta wheel.
- Update the V2 tests, profilers and their helpers to import the installed nixl.runtime.ep package instead of the source package nixl_ep_v2.
- Run the V2 correctness, elastic-membership, and topology-mode tests in the existing NIXL-EP test job.

nixl/runtime/__init__.py:
- Route nixl.runtime submodule lookup to the runtime directory of the
  selected nixl_cu12 or nixl_cu13 backend.
- Keep V2 lazy: importing nixl does not load NIXL-EP V2; it is loaded
  only when the user imports nixl.runtime.ep.

Meta-package Meson files:
- Add nixl/runtime/meson.build to copy the runtime __init__.py into the
  meta-wheel build directory.
- Enter the runtime subdirectory from nixl/meson.build.
- Add the copied runtime initializer to the meta-wheel build inputs.

pyproject.toml.in:
- Add nixl.runtime to the setuptools package list so the routing
  initializer is included in the published meta wheel.

V2 tests:
- Replace the nixl_ep_v2 source import with the public nixl.runtime.ep
  import in every Python script under runtime/ep/tests, including the
  helpers that other tests import (test_deterministic_sort.py,
  test_adaptive_modes.py, test_adaptive_elastic.py).
- Update utility imports and API references to use the installed
  package path.

.gitlab/test_ep.sh:
- Run the V2 correctness, elastic-membership, and topology-mode tests
  through the installed backend package.
- Add the tests directory to PYTHONPATH so their sibling helper imports
  continue to work.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
runtime/ep/nixl_ep_v2:
- Format the imported runtime with NIXL's black and isort settings.
- Mark intentional delayed imports and public re-exports for flake8.
- Add type annotations and casts for optional topology values, bootstrap
  state, reference tensors, profiling results, and CUDA graph state so
  mypy can verify the code without changing runtime behavior.
- Split a tuple of list appends and remove an unused exception binding to
  satisfy lint checks while preserving behavior.

runtime/ep/tests:
- Format the V2 tests and profilers with NIXL's normal Python style.
- Annotate lazy nixl.runtime.ep imports, graph lifecycle state, timing
  records, closure references, and a reused variable name that static
  analysis cannot infer.
- In test_adaptive_processes.py, turn the barrier lambda into a nested
  function and rename the ta..tj timers to t_a..t_j, which codespell
  would otherwise flag.
- In test_nvfp4_dispatch.py, turn the zeros lambda into a nested
  function and type u8's pass-through result as Any; in
  test_zero_copy.py, annotate the graph state dicts; in sweep_sms.py,
  drop an unused os import.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
Suppress the two DeepEP-derived host warnings on the V2 targets so the default werror build succeeds. Keep V2 tests out of the existing V1 EP CI script; V2 validation runs separately.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
test_adaptive_elastic.py:
- Document how the test checks the mode. The host derives it for every
  committed world: direct when exactly one NVLink domain is live, hybrid
  otherwise. After every commit, the test checks the mode the buffer
  committed (get_topology()).
- Accept --plan as inline JSON or a JSON file path: a list of phases of
  dense rank ids.

tests/elastic/plans/two_domains.json:
- Add a 2 x 4 plan that scales between one and two domains, so the
  mode switches from direct to hybrid and back.

test_adaptive_processes.py:
- Document the relaunch of node 0 at phase 5 in the example plan.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
test_adaptive_elastic.py:
- By default, every live rank times one dispatch + combine pair per
  phase with CUDA events, as V1's elastic.py does. It prints the pair
  bandwidth and the avg/min/max time. Bytes are counted as in
  test_ep.py: scale-up is the tokens a rank receives and returns, and
  scale-out is the tokens that cross NVLink domains.
- Add --kineto: Kineto per-kernel timings (dispatch, copy epilogue,
  combine, reduce epilogue) in every phase. The existing PERF line
  gains scale-out and scale-up bandwidth fields after its original
  ones. Its pair wall time reuses the default pair timing instead of
  timing the pair a second time.
- Remove --perf-phases. It ran the same Kineto measurement on chosen
  phases only; --kineto replaces it.
- Add --skip-perf-test to skip the default pair timing. On its own it
  runs only the correctness checks, as before. With --kineto it reports
  only the per-kernel timings.
- Add --save-output [PATH]: the rank 0 process gathers every rank's
  records over the test's Store cohort and writes the run
  configuration, the per-rank phase records and the TRANS values as
  one JSON file. The default path is adaptive_elastic_results.json.
  stdout is unchanged.
- The correctness checks and the remaining flags are unchanged.

README.md:
- Describe test_adaptive_elastic.py (elastic, correctness and
  performance) and list its command first.
- Add test_ep.py and test_adaptive_modes.py to the list of other tests.

Signed-off-by: Lior Shapira <lishapira@nvidia.com>
@lishapira

Copy link
Copy Markdown
Contributor Author

/build

This branch has not been deployed

No deployments
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.

2 participants