1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""UCX-specific workarounds for NIXL transfers.
This module collects helpers that apply only when the NIXL backend is UCX
(InfiniBand / RoCE / OPA / EFA RDMA traffic). The headline piece is the
per-rank NIC pinning logic, which addresses two distinct problems.
The first is openucx/ucx#11259, where UCX's lane scoring does not honor
GPU<->NIC PCIe affinity for CUDA memory paths and ends up picking
cross-socket NICs on multi-NUMA hosts.
The second is why the ranking leads with load balancing, and it is the one
that has actually been measured to cost throughput here. UCX chooses a NIC
per process with no knowledge of what sibling ranks on the same host chose.
When a pod is under-provisioned - fewer usable rails than GPUs, or rails
allocated on the wrong socket - every rank independently makes the same
locally-correct choice and they all land on one adapter. Measured on such a
pod: four concurrent readers sharing a rail ran at 1.5 GB/s each, 6.1 GB/s
aggregate; spread one per rail they ran at 6.75 GB/s each, 27.0 GB/s
aggregate. Aggregate with four readers was *lower* than one reader alone, so
throughput was being destroyed rather than divided.
Note that this second case is not UCX misbehaving. Given a pod holding
exactly one rail on the GPUs' own NUMA node, converging every rank onto it is
the correct answer to the question UCX is asking. It is the wrong answer to
the question the host is posing, and only a component that can see all the
ranks at once can tell the difference - which is what the global assignment
in ``probe_nic_pin_for_device`` is for.
Public surface:
- ``apply_nic_pin_for_device(device_id)``: resolve ``MX_RDMA_NIC_PIN`` and
set ``UCX_NET_DEVICES`` for the worker. Side-effecting; designed to be
called once per worker before NIXL agent construction.
- ``probe_nic_pin_for_device(device_id, min_rate_gbps=None)``: pure
topology probe; returns the NIC string this rank should pin to, or
``None``. Exposed for diagnostics and testing.
Everything else is private and may change without notice.
"""
=
"""Read a single int from a sysfs file. Returns None on any failure."""
return
return None
"""Read a string from a sysfs file. Returns None on any failure."""
return
return None
"""Parse an InfiniBand port rate string ('400 Gb/s (4X NDR)') -> 400.0."""
return None
=
return None
return
return None
"""Return the PCIe BDF ('0000:0f:00.0') for a CUDA visible device.
Uses torch.cuda.get_device_properties; the CUDA runtime handles
CUDA_VISIBLE_DEVICES filtering, so device_id here is the visible
index that the worker drives.
"""
=
=
=
=
return None
return f
=
"""Every NVIDIA GPU on the host, whether or not this process can see it.
The assignment below spreads GPUs across rails by counting how many GPUs it
has already placed on each one, which only spreads anything if every rank
counts the same GPUs. ``torch.cuda.device_count()`` cannot supply that: under
a per-rank ``CUDA_VISIBLE_DEVICES`` mask - how NeMo-RL and prime-RL both
launch - each worker sees exactly one device, restarts the tally from zero,
and picks the same best rail as every one of its peers. That is the rail
convergence this module exists to prevent, arrived at by the code meant to
prevent it.
Enumerating PCI instead sidesteps it without any cross-rank coordination:
sysfs is not filtered by CUDA visibility, so each rank derives the same
host-wide map from the same snapshot and then reads off only its own row.
Returns [] when the listing is unreadable, which leaves the caller on
visible devices alone - degraded, and logged as such.
"""
=
return
: =
= f
=
=
continue
# Class 0x03xxxx is "display controller"; NVIDIA GPUs enumerate as
# 0x030000 (VGA) or 0x030200 (3D controller), and matching the prefix
# covers both without pinning the check to one of them.
return
"""Read the NUMA node for a given CUDA visible device's GPU.
Returns the numa_node int (which may be -1 on systems without NUMA),
or None if the BDF or sysfs file isn't readable.
"""
=
return None
return
"""Resolve a PCI BDF to its sysfs realpath and return the PCIe chain.
For a device at 0000:0f:00.0, the realpath of
/sys/bus/pci/devices/0000:0f:00.0 typically looks like:
/sys/devices/pci0000:00/0000:00:01.1/0000:01:00.0/0000:02:00.0/0000:0f:00.0
The returned list keeps the root-complex component (``pci0000:00``)
followed by the BDF-shaped components, in order from closest-to-root
to leaf. Common-prefix length between two such lists encodes PCIe
affinity, which is exactly the metric nvidia-smi topo -m uses to label
PIX / PXB / PHB / NODE / SYS connections.
Returns [] on any read failure.
"""
=
return
=
return
"""Length of the longest shared prefix between two PCIe path component lists.
Higher values mean closer in the PCIe tree:
- 2+ shared = same downstream bridge ancestry, with larger values closer
- 1 shared = same root complex, including flat-tree PHB connections
- 0 shared = different root complexes
A score of 0 does not by itself distinguish NODE from SYS: distinct root
complexes can still belong to the same NUMA node. The caller therefore
uses ``numa_node`` as a cross-socket tiebreak after PCIe depth.
"""
=
return
return
"""Return the PCI BDF for an InfiniBand NIC.
Reads the symlink /sys/class/infiniband/<nic>/device which points at
something like ../../../0000:10:00.0; returns the basename.
"""
=
return None
return
"""Return whether the NIC has a verbs device exposed in this container."""
= f
=
return False
return
"""Enumerate IB-class NICs eligible for compute RDMA traffic.
Probe surface is /sys/class/infiniband/, which is the kernel verbs
API. This covers InfiniBand, RoCE, OPA, and AWS EFA - any fabric
that exposes via ibv_*. Fabrics outside the verbs API (e.g. HPE
Cray Slingshot's CXI driver) are not visible here; on those
systems users should either leave MX_RDMA_NIC_PIN unset or set it
to an explicit NIC list. If /sys/class/infiniband is missing or
empty (most non-RDMA hosts) this returns []; the caller treats
that as "skip pin" and leaves UCX selection alone.
Filters out:
- bonded interfaces (e.g. mlx5_bond_0): UCX cannot resolve them
in containers and the AH lookup segfaults.
- NICs whose verbs device is not exposed in the container. Kubernetes
RDMA device plugins commonly expose one ``uverbs`` device while the
host's complete InfiniBand sysfs remains visible.
- NICs without a /ports/1 directory.
- NICs whose port-1 rate is below the effective threshold.
If min_rate_gbps is None (default), the threshold is set to
max(rate) over discovered NICs - this strips any side-fabric
NICs (management, storage) running slower than the compute
fabric without hardcoding a number. If min_rate_gbps is set,
it is used as an absolute lower bound (overrides the max-rate
autodetect; useful for clusters with mixed-tier compute
fabrics where you do want to keep multiple rates).
Returns a list of (nic_name, numa_node, rate_gbps, pci_path)
sorted alphabetically by NIC name. The PCIe path is the BDF chain
from /sys realpath; pair-wise common-prefix depth between a GPU's
path and a NIC's path encodes affinity (PIX > PXB > PHB > NODE > SYS)
and is the actual selection signal in probe_nic_pin_for_device().
NIC name ordering only affects the final lex tiebreak.
NUMA is read from /sys/class/infiniband/<nic>/device/numa_node and
is kept on the tuple for diagnostic logging only; -1 if the
kernel reports unknown.
"""
=
return
=
return
: =
continue
continue
= f
continue
=
=
continue
return
=
=
: =
continue
=
= -1
=
=
return
"""Probe topology and choose a UCX_NET_DEVICES value for a given GPU.
Selection signal is PCIe sysfs path distance: each device's
/sys/bus/pci/devices/<bdf> realpath exposes the full bus tree, and
the longest common PCIe component prefix between a GPU's path and a NIC's
path encodes affinity (PIX > PXB > PHB > NODE > SYS, the same metric
nvidia-smi topo -m reports). NIC names and GPU indices stop
mattering for correctness; they only affect the final lex tiebreak.
Strategy:
1. Enumerate compute-fabric IB-class NICs (verbs-API: IB / RoCE
/ OPA / EFA), with their PCIe paths. Rate filtering: by
default, keep only NICs at max(rate) across the discovered
set so side-fabric NICs (management, storage) at a lower
rate are stripped. min_rate_gbps overrides this with an
explicit absolute lower bound.
2. Discover the PCIe path of every GPU *on the host* - read from
PCI, not from CUDA, so a per-rank CUDA_VISIBLE_DEVICES mask
cannot shrink the set - so this rank computes the same global
GPU->NIC assignment that every other rank computes from the
same /sys snapshot. No coordination. See ``_host_gpu_bdfs``
for why the visible set is the wrong input.
3. Greedy assignment, best-affinity-first. Each GPU picks the NIC
with lowest (prior-assignments, -score, cross-socket,
lex-smallest name) - rail distinctness dominates, then PCIe
depth, then NUMA locality, then determinism. Reuse is allowed
when GPU count exceeds NIC count, with cycle counts kept
balanced.
4. Returns this rank's assignment as 'NICNAME:1', or None if no
compute device is reachable.
Distinctness ranking first is a measured decision. On a pod whose four
GPUs shared one same-socket rail, four concurrent readers ran at
1.5 GB/s each (6.1 GB/s aggregate); spread one-per-rail, with three of
the four crossing sockets, they ran at 6.75 GB/s each (27.0 GB/s
aggregate). Sharing a rail cost 4.45x. Crossing a socket cost nothing
measurable: the one reader on its PCIe-affine rail got 6.747 GB/s
against 6.745 for the three cross-socket ones.
Two things this ordering is *not*. It is not a fix for a shipped bug:
the previous key was ``(-score, count, name)``, and traced against that
pod it gives three distinct rails with one doubled, because almost
every GPU-NIC pair there scores 0 and the count term does break those
ties. It is a fix for an unshipped one: an intermediate revision of
this function inserted the cross-socket term *above* count, giving
``(-score, cross_socket, count, name)``, and that key hands the lone
same-socket rail to all four GPUs in turn - a same-socket rail beats
every free cross-socket one on every pass, and count never gets a say.
That is the 1.5 GB/s configuration, and it was written while diagnosing
the very collapse it would have caused.
Hence the cross-socket term is kept but ranked below distinctness, so
it is honoured only where free. Note the throughput figures above were
taken against a single publisher rail, so cross-socket cost is bounded
by that ceiling rather than shown to be zero in general - it is known
to be small relative to sharing, which is all this ordering needs.
"""
=
=
return None
# The rank's own GPU is identified by PCI address rather than by visible
# index, because the index means nothing outside this process: under a
# per-rank mask every worker drives "GPU 0" and they are four different
# cards. The address is what the host-wide map below is keyed on.
=
return None
=
= 0
=
# Union rather than replacement. PCI is the authority on what exists, but a
# GPU this process is actually driving belongs in the map even if the
# listing missed it, and on a host where the listing is unreadable this
# degrades to exactly the old visible-device behaviour.
=
=
# One GPU in the PCI listing too, so either the host genuinely has one
# or the container is device-isolated and there is nothing to spread
# across. Indistinguishable from in here, and only the second case is a
# problem, so say so rather than report a one-entry map as a success.
: =
: =
=
=
=
# Greedy assignment. Each GPU picks the least-assigned NIC, then the
# closest of those, then a same-socket one, then lex-smallest name for
# determinism so every rank computes the same map with no coordination.
#
# GPUs are visited best-affinity-first rather than in index order,
# because with distinctness ranked first the visit order decides who
# wins a contested rail. On the measured pod only GPU 3 had any PCIe
# affinity to the single same-socket rail, and in index order GPU 0
# took that rail on a zero-depth tie, pushing GPU 3 onto a cross-socket
# one. Both orders give four distinct rails and so both capture the
# 4.45x, but visiting the GPU that has a real affinity first also
# honours it. Ties fall back to index, keeping the result deterministic.
#
# Greedy is still not globally optimal - a Hungarian solve over the
# (gpu, nic) score matrix would be, on the same inputs - but the
# remaining gap is now a question of which GPU gets which distinct
# rail, not whether rails are shared, and sharing was the term worth
# 4.45x.
: =
: =
return
# Ties break on PCI address rather than visible index for the same reason
# the map is keyed on it: the index is not comparable across ranks, so
# ordering by it would let two ranks walk the same GPUs in different orders
# and reach different assignments from identical inputs.
=
=
=
: =
=
# NUMA locality ranks BELOW load balancing. PCIe common depth
# distinguishes shared root-complex and bridge ancestry but cannot
# separate same-NUMA from cross-socket devices under different root
# complexes. Without this term those devices tie at depth 0 and the
# tiebreak falls through to name order, possibly leaving the local
# rail unused. It stays for that reason.
# What it must not do is outrank distinctness: measured, sharing a
# rail costs 4.45x and crossing a socket costs ~0, and ranking it
# higher forces every GPU onto a lone same-socket rail in turn. An
# intermediate revision did exactly that; see the function docstring.
=
# Index 1 is -score; index 0 is the assignment count the sort now leads
# with. Reading the wrong slot here silently mislabels every diagnostic
# log line as PCIe common-depth 0.
=
= -
=
+= 1
, =
=
=
=
=
=
return f
"""Resolve MX_RDMA_NIC_PIN env var into a UCX_NET_DEVICES value.
Modes:
- unset / "off" / "0" / "false" / "no": returns None (no pinning).
- explicit comma-separated list: indexed by device_id, like the
original hardcoded shape. Useful for unusual topologies where
the auto-probe heuristic doesn't fit (e.g. fabrics outside the
kernel verbs API).
- any other truthy value (e.g. "auto", "1", "true", "yes", "on"):
runs probe_nic_pin_for_device(). Rate filtering defaults to
max-rate auto-detect (keep only NICs at the fastest rate
present, strips slower side-fabric NICs without hardcoding a
number). MX_RDMA_NIC_PIN_MIN_RATE_GBPS overrides with an
explicit absolute lower bound when needed.
"""
=
return None
=
=
return
return None
=
: | None = None
=
= None
return
"""Resolve MX_RDMA_NIC_PIN and apply it to UCX_NET_DEVICES.
Set permanently for the worker's lifetime (no restore in finally) so
any subsequently-created UCP contexts also use the pinned NIC. No-op
when MX_RDMA_NIC_PIN is unset / "off" / "0" / "false" / "no", which
is the default. Designed to be called once per worker before NIXL
agent construction.
See module docstring for the full semantics, including the explicit
NIC-list override and the rate-filter env var.
"""
=
=
=