Skip to content

UCT/TCP: Fix inverted priority of the default network interface - #12241

Open
dmitrygx wants to merge 5 commits into
openucx:masterfrom
dmitrygx:topic/ucx/tcp-prefer-default
Open

dmitrygx wants to merge 5 commits into
openucx:masterfrom
dmitrygx:topic/ucx/tcp-prefer-default

Conversation

@dmitrygx

@dmitrygx dmitrygx commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

What?

Give the TCP interface with the default route priority 1 and the other interfaces priority 0, as UCX_TCP_PREFER_DEFAULT describes ("Give higher priority to the default network interface on the host"). Until now it was the other way around.

Why?

UCP prefers the higher uct_iface_attr_t::priority when transport scores are equal: ucp_score_prio_cmp() with the sum of the local and remote priorities in ucp_wireup_select_transport() and in the AM lane tiebreak. With the default UCX_TCP_MAX_BW=2200MBs, TCP interfaces of 25 Gb/s and faster report the same bandwidth and the same packed latency, so such ties are common on hosts with several NICs. Because of the inverted priority, the AM and keepalive lanes went to an interface without the default route. For example, with the VRF slave devices from #12182 enabled on a cluster, the AM lane moved from eth0 to an RDMA rail.

The mapping has been inverted since the TCP default interface code was added, but it has an effect only since v1.10.0, when the default route detection in uct_tcp_netif_is_default() was fixed (a8345ce). Before that, every TCP interface had priority 1.

Peers with different UCX versions still compute the same local + remote priority sum, so lane selection stays symmetric between them.

How?

Use netif_caps.is_default as the priority in uct_tcp_iface_query(); uct_tcp_iface_netif_caps_init() already leaves it 0 when UCX_TCP_PREFER_DEFAULT=n. The new gtests check that the priority matches uct_tcp_netif_is_default(), and that it is 0 with UCX_TCP_PREFER_DEFAULT=n.

UCP prefers the higher priority when transport scores are equal, but TCP
set priority 1 on the interfaces without the default route and 0 on the
default one, the opposite of UCX_TCP_PREFER_DEFAULT. The inversion has
been effective since v1.10.0, when the default route detection was fixed.
@dmitrygx
dmitrygx requested a review from a team as a code owner November 4, 2026 11:51
@svc-nvidia-pr-review

Copy link
Copy Markdown

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

Comment thread src/uct/tcp/tcp_iface.c Resolved
@svc-nvidia-pr-review

Copy link
Copy Markdown

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

Comment thread test/gtest/uct/tcp/test_tcp.cc Outdated
UCS_TEST_MESSAGE << m_tcp_iface->if_name << ": is_default " << is_default;

// UCP prefers the higher priority when transport scores are equal
EXPECT_EQ(is_default ? 1 : 0, m_ent->iface_attr().priority);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

minor: uct_tcp_netif_is_default() always sets 0 or 1, so the ternary is redundant.

Suggested change
EXPECT_EQ(is_default ? 1 : 0, m_ent->iface_attr().priority);
EXPECT_EQ(is_default, m_ent->iface_attr().priority);


UCS_TEST_P(test_uct_tcp, prefer_default_disabled, "TCP_PREFER_DEFAULT=n")
{
EXPECT_EQ(0, m_ent->iface_attr().priority);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

this check is vacuous for non-default netdevs (e.g. tcp/lo), where priority is 0 either way. maybe query uct_tcp_netif_is_default() here too and skip if it is not the default netdev, so the test really covers the disabled path?

@svc-nvidia-pr-review

Copy link
Copy Markdown

Residual coverage gap worth noting: prefer_default only has real signal on hosts where at least one enumerated TCP netdev owns the default route; in a netns/container without a default route in /proc/net/route, both new tests pass trivially. Also, since priority is packed into the worker address and summed with the peer's value in select.c:680, a mixed old/new rolling upgrade will tie (1+0 vs 0+1) and pick arbitrarily — transient and selection-only, not a correctness or wire-format break, so it is not raised as a finding.

@dmitrygx

dmitrygx commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

prefer_default has signal also without a default-route netdev: without the fix every non-default netdev, lo included, gets priority 1 and the test fails. Only prefer_default_disabled needs the default netdev, and it now skips the others.

@svc-nvidia-pr-review

Copy link
Copy Markdown

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

@svc-nvidia-pr-review

Copy link
Copy Markdown

🤖 Review complete — no issues found.

@guy-ealey-morag guy-ealey-morag left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would also update the docs in src/uct/api/uct.h:1180 to say that higher value is preferred.

Comment thread src/uct/tcp/tcp_iface.c Resolved
Comment thread test/gtest/uct/tcp/test_tcp.cc Resolved
@svc-nvidia-pr-review

Copy link
Copy Markdown

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

@dmitrygx

dmitrygx commented Nov 4, 2026

Copy link
Copy Markdown
Member Author

update the docs in src/uct/api/uct.h:1180

Done

guy-ealey-morag
guy-ealey-morag previously approved these changes Nov 4, 2026
@svc-nvidia-pr-review

Copy link
Copy Markdown

🤖 Review complete — no issues found.

@dmitrygx
dmitrygx enabled auto-merge (squash) November 4, 2026 15:00
@svc-ucx

svc-ucx commented Nov 4, 2026

Copy link
Copy Markdown

🤖 CI Triage Agent — UCX PR (AddressSanitizer gpu on worker 1) · commit 0e432b18

TL;DR: The ASAN gtest shard aborted because ud_verbs/test_uct_stats.flush_wait_iface/2 <ud_verbs/mlx5_1:1> hit a 30 s UD peer timeout (no ACKs/CREP ever arrived on mlx5_1:1), and since test_uct_stats registers no UCT error handler, the timeout escalated to ucs_fatal and killed the whole test binary — this is unrelated to the PR (which touches only src/uct/tcp).

Full analysis

Summary: make -C test/gtest test aborted (core dumped) during ud_verbs/test_uct_stats.flush_wait_iface/2 <ud_verbs/mlx5_1:1> with ud_ep.c:295 Fatal: UD endpoint 0x6130000050c0 to :0: unhandled timeout error.

Root cause: Evidence from the log:

  • Test started at 15:45:43.01 and the fatal hit at 15:46:13.63 — a ~30 s silent gap that exactly matches the UD TIMEOUT default ({"TIMEOUT", "30s", ...} in src/uct/ib/ud/base/ud_iface.c:682). So the UD endpoint sent its window (the test's fill_tx_q(0) pushes ~1024 am_bcopy packets) and received no acknowledgment at all for 30 s on device mlx5_1:1, while the test spun on uct_iface_flush() + progress().
  • The peer string printed is empty (to :0:). ep->peer.name/pid are only populated from a received CREQ/CREP (UCT_UD_EP_PEER_NAME_ARG, src/uct/ib/ud/base/ud_ep.h:291), so the UD connection handshake never completed either — i.e. nothing was received from the peer iface on that port. That points to the mlx5_1:1 port on swx-rdmz-ucx-gpu-02 not passing UD traffic (fabric/port-level problem), not to a UCX logic bug.
  • Why it killed the build instead of failing one test: test_uct_stats is constructed as uct_p2p_test(0) with err_handler == NULL (test/gtest/uct/test_stats.cc:30, test/gtest/uct/uct_p2p_test.cc:50-83), so uct_iface_handle_ep_err() returns non-OK and uct_ud_ep_deferred_timeout_handler() calls ucs_fatal() (src/uct/ib/ud/base/ud_ep.c:289-296), aborting the process and the whole shard.
  • The PR's changes are confined to TCP iface priority (src/uct/tcp/tcp_iface.c:382, uct_tcp_iface_netif_caps_init() / PREFER_DEFAULT); recent commits on this branch ([REDACTED:Hex High Entropy String], fc5c6e28, 1bad9a54, all by Dmitrii) touch only src/uct/tcp. Nothing in the UD/IB path was modified, and the failing test parameterization is ud_verbs, so the failure is not attributable to this PR.

Implicated commit: none from this PR ([REDACTED:Hex High Entropy String] touches only src/uct/tcp); failure is environmental on worker swx-rdmz-ucx-gpu-02, port mlx5_1:1.

File: src/uct/ib/ud/base/ud_ep.c:293 (fatal site); test: test/gtest/uct/test_stats.cc:501 (flush_wait_iface), config default at src/uct/ib/ud/base/ud_iface.c:682.

Suggested fix:

  1. Re-run the ASAN gpu job — this is not caused by the PR. If it reproduces on the same node, have CI/infra check mlx5_1 port 1 on swx-rdmz-ucx-gpu-02 (ibstat/ibv_devinfo port state, link partner, and for RoCE the operational GID), since UD loopback/peer traffic on that port was completely dropped.
  2. Optional hardening (separate PR): make test_uct_stats tolerant of transport-level peer failure instead of letting a fabric hiccup ucs_fatal() the entire gtest shard — pass a non-NULL uct_error_handler_t to uct_p2p_test (as other UD tests do) and skip/fail just that test on UCS_ERR_ENDPOINT_TIMEOUT, and/or bound flush_wait_iface's do { ... } while (status != UCS_OK) loop with ucs::get_deadline().

Related: #11920 (UCT/GTEST: Hardening of UD stale private EP tests) — prior work hardening UD gtests against timeout-induced fatals; no existing issue found for flush_wait_iface specifically.

🛡️ This comment had 1 potential secret(s) redacted (Hex High Entropy String). See request_id b6d7c9a2-796d-4609-bd76-61b5440baa3b in the triage console for the audit trail.

@dmitrygx

dmitrygx commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 4 pipeline(s).

Comment thread src/uct/tcp/tcp_iface.c Resolved
@svc-nvidia-pr-review

Copy link
Copy Markdown

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

@svc-nvidia-pr-review

Copy link
Copy Markdown

🤖 Review complete — no issues found.

@svc-ucx

svc-ucx commented Nov 6, 2026

Copy link
Copy Markdown

🤖 CI Triage Agent — UCX PR (Tests gpu on worker 1) · commit 817b90df

TL;DR: The gpu on worker 1 job died with SIGSEGV (bash exit 139) because the CUDA MPS daemon on swx-rdmz-ucx-gpu-01 was in a broken state (cudaGetDeviceCount() failed: 807 — MPS server is not ready to accept new MPS client requests), and uct_hello_world then dereferenced an uninitialized pointer returned by mem_type_malloc() after cudaMallocManaged() failed. Fix the node's MPS daemon (infra) and harden mem_type_malloc() to initialize ptr = NULL so a CUDA allocation failure gives a clean error instead of a crash.

Full analysis

Summary: contrib/test_jenkins.sh → run_uct_hello with -m cuda-managed segfaulted (core dumped, PID 1649), causing ##[error]Bash exited with code '139'.

Root cause: Two layers, both unrelated to the PR under test (topic/ucx/tcp-prefer-default touches TCP config, not CUDA):

  1. Environmental trigger: every CUDA call on the agent failed from the start of the job — cuda_ctx.c:52 cuDeviceGetCount(&num_devices) failed: initialization error and cuda_alloc.c:32 cudaGetDeviceCount() failed: 807 (MPS server is not ready to accept new MPS client requests) (seen repeatedly in the earlier run_ucx_perftest_cuda_device task too, where all ucp_device_cuda_* stages produced no results). The CUDA MPS control daemon on swx-rdmz-ucx-gpu-01 is wedged/not accepting clients.
  2. Latent code bug that turns it into a crash instead of a clean failure: in examples/hello_world_util.h, mem_type_malloc() declares void *ptr; uninitialized and uses CUDA_FUNC() (lines 50–57), which only prints the CUDA error and does not set ptr. So when cudaMallocManaged(&ptr, ...) fails, ptr is returned as stack garbage. The caller's CHKERR_ACTION(str == NULL, ...) at examples/uct_hello_world.c:717 does not catch a non-NULL garbage pointer, so execution continues into generate_test_string()/do_am_short() and faults: Caught signal 11 (Segmentation fault: address not mapped to object at address 0x13).

Log evidence ordering is unambiguous: cudaMallocManaged(...) failed: MPS server is not ready → cudaMemcpy(...) failed: MPS server is not ready → Caught signal 11. The host-memory variant of the same test passed (----- UCT TEST SUCCESS ----), confirming the failure is CUDA-path-only. There is no hang — timestamps show continuous progress, the only gaps being the intentional sleep 15 between server and client launches.

Implicated commit: None in this PR. The uninitialized-ptr pattern predates the PR; the latest relevant touches are 2be620aa (rakhmets, "EXAMPLES: Fixed checking for memory type support") and [REDACTED:Hex High Entropy String] (Raul Akhmetshin, CUDA flags separation). Commit [REDACTED:Hex High Entropy String] is not causal.

File: examples/hello_world_util.h:69-92 (specifically void *ptr; at line 71 and CUDA_FUNC(cudaMallocManaged(...)) at line 82); crash surfaces at examples/uct_hello_world.c:716-725

Suggested fix:

  1. Unblock CI (primary): restart the CUDA MPS daemon on swx-rdmz-ucx-gpu-01 (echo quit | nvidia-cuda-mps-control, then restart, or stop MPS entirely for this agent) and re-run the PR. This failure is not attributable to UCT/TCP: Fix inverted priority of the default network interface #12241.
  2. Make the example fail cleanly instead of segfaulting: initialize void *ptr = NULL; in mem_type_malloc() and make the CUDA error path explicit, e.g. add a CUDA_FUNC_RET-style macro that sets ptr = NULL on _result != cudaSuccess:
    case UCS_MEMORY_TYPE_CUDA_MANAGED:
        if (cudaMallocManaged(&ptr, length, cudaMemAttachGlobal) != cudaSuccess) {
            ptr = NULL;
        }
        break;
    Same treatment for the cudaMalloc branch. With this, CHKERR_ACTION(str == NULL, ...) at uct_hello_world.c:717 triggers and the job reports "Failed to allocate memory" with a non-139 exit code, making future MPS/driver outages diagnosable instead of looking like a crash in the PR.
  3. Optional CI hardening: have run_uct_hello/run_ucx_perftest_cuda_device in contrib/test_jenkins.sh probe CUDA health (e.g. a cudaGetDeviceCount smoke check) before adding cuda/cuda-managed to mem_types_list, and skip the GPU memory types with a clear infra message rather than running tests guaranteed to fail.

Related: PR #12241 (the build under test, unrelated to the failure); no existing issue found for the MPS error signature — worth filing one for the uninitialized-pointer hardening in examples/hello_world_util.h.

🛡️ This comment had 1 potential secret(s) redacted (Hex High Entropy String). See request_id 7065fef4-aa69-4b58-8bcf-c4a7711f241a in the triage console for the audit trail.

@dmitrygx

dmitrygx commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 4 pipeline(s).

@svc-ucx

svc-ucx commented Nov 7, 2026

Copy link
Copy Markdown

🤖 CI Triage Agent — UCX PR (go gpu) · commit 817b90df

TL;DR: The go gpu job didn't time out from slowness — TestUcpWorkerEfd deadlocked for 9 minutes because syscall.EpollWait returned EINTR and the test's helper goroutine died via t.Fatalf (which calls runtime.Goexit) without ever sending to eventsChan, leaving the main test goroutine blocked on <-eventsChan. Fix is to retry EpollWait on EINTR and stop calling t.Fatalf from a non-test goroutine.

Full analysis

Summary: make -C build/bindings/go test failed with panic: test timed out after 10m0s in TestUcpWorkerEfd (bindings/go/tests/worker_test.go:74).

Root cause: Timeline from the log proves a hang, not slow progress:

  • 04:18:07.105 — last meaningful line: worker_test.go:61: Failed to call epoll_wait interrupted system call
  • 04:28:02.236 — panic: test timed out after 10m0s (a ~9m55s gap with zero output; the panic dump confirms goroutine 1 [chan receive, 9 minutes] and goroutine 24 parked at worker_test.go:74)

Two compounding bugs in the test, both pre-existing:

  1. syscall.EpollWait(epfdLocal, events[:], -1) at worker_test.go:59 is not EINTR-safe. The Go runtime delivers SIGURG for asynchronous goroutine preemption, which routinely interrupts a blocking epoll_wait; Go's syscall.EpollWait is a thin wrapper and does not retry on EINTR. That is exactly the error reported on line 61 ("interrupted system call").
  2. Line 61 uses t.Fatalf from inside a spawned goroutine, which is explicitly unsupported by testing. Fatalf → runtime.Goexit terminates only that goroutine, so the eventsChan <- nevents on line 64 never executes. The main test goroutine then blocks forever on nevents := <-eventsChan (line 74), and only the 10-minute global test alarm breaks the deadlock, taking down the whole job.

So the EINTR is the trigger and the misuse of t.Fatalf turns a would-be one-line test failure into a 10-minute job-killing hang.

This is unrelated to the PR under test: branch topic/ucx/tcp-prefer-default (#12241) changes TCP transport preference, but the hang is in epoll/signal handling of the Go test harness and occurs before any transport selection matters. All preceding tests (TestUcpContext, TestUcpEpTag, TestUcpEpAm, TestUcpListener, TestUcpMmap) passed. Treat this as flaky infrastructure, not a PR regression — re-run to unblock, and fix the test separately.

Implicated commit: Not a regression from a recent commit. The latent defect dates to 2bbf7cfe (Peter Rudenko, "BINDINGS/GO: Ucp endpoint functionality.", 2021-08-02), which introduced worker_test.go; last touched by bd1b1143 (Artemy Kovalyov, "BINDINGS/GO: Add go mod support", 2024-08-11).

File: bindings/go/tests/worker_test.go:59-64 (EINTR-unsafe EpollWait + t.Fatalf in goroutine); deadlock manifests at bindings/go/tests/worker_test.go:74

Suggested fix:

  1. Make the EpollWait call EINTR-safe by retrying:
var nevents int
for {
    nevents, err = syscall.EpollWait(epfdLocal, events[:], -1)
    if err != syscall.EINTR {
        break
    }
}
if err != nil {
    t.Errorf("Failed to call epoll_wait %v", err)
    close(eventsChan)   // unblock the main goroutine
    return
}
  1. Replace every t.Fatalf inside the goroutine (lines 39, 44, 51, 61) with t.Errorf plus close(eventsChan); return, so a failure propagates instead of deadlocking.
  2. Defensively bound the final receive so this can never consume the whole job's wall clock again:
select {
case nevents, ok := <-eventsChan:
    if !ok || nevents <= 0 {
        t.Fatalf("No events in epoll")
    }
case <-time.After(5 * time.Second):
    t.Fatal("Timed out waiting for epoll event")
}
  1. Optional hardening: consider using the golang.org/x/sys/unix wrappers or an EpollWait timeout rather than -1, since indefinite blocking syscalls in Go are inherently exposed to preemption signals.

Related: PR #12241 (the PR under test, not the cause). No existing issue found tracking this flake — worth opening one against bindings/go for the EINTR/t.Fatalf-in-goroutine pattern.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants