Skip to content

collectives: binomial-tree reduce and broadcast, tests at any size and root; optional bounded spin; two build fixes - #11

Merged
zoeyzyhu merged 8 commits into
zoeyzyhu:mainfrom
UCzhangxi:xiz/collectives-pr
Sep 30, 2026
Merged

zoeyzyhu merged 8 commits into
zoeyzyhu:mainfrom
UCzhangxi:xiz/collectives-pr

Conversation

@UCzhangxi

@UCzhangxi UCzhangxi commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Seven commits on main cf41b46. Each commit message carries its own evidence; commits 6 and 7 are by Sihe Chen.

1. Tests (2bd56fa): reduce, broadcast and all_reduce at any world size and root. tests/test_collectives.py: for every root, reduce SUM gives the root the sum and leaves the non-root inputs unchanged, broadcast reaches every rank, and all_reduce SUM agrees. Integer inputs keep the sums exact in any order. CI now also runs it at 3 and 4 ranks, since at 2 ranks a tree and a linear walk coincide.

  • It passes on main at 2, 3, 4 and 5 ranks.
  • It fails on an intermediate tree version that let reduce overwrite a non-root input: at 4 and 5 ranks, e.g. "rank 2 holds [70, 72, ...], expected [30, 31, ...]". That is the guard for commit 2.

2. Collectives (8510922): a binomial tree instead of a linear reduce-to-root. reduce_locked and broadcast_locked walked every peer from the root, so an allreduce cost 2(P-1) blocking messages serialized on one rank. At P = 8..128 on one node the linear allreduce doubles in cost with every doubling of P (7.6 us per rank; 992 us at 128 ranks), while a p2p ring over the same ranks stays flat. The tree sends 2 ceil(log2 P) messages per rank: 14 instead of 254 at P = 128. Virtual ranks keep any root working, and the mask walk handles sizes that are not a power of two. A reduce accumulates into a clone on non-root ranks, so their inputs are not mutated. In a 128-rank application step, the wall time went from 12.47 to 9.95 ms (1.25x) with bit-identical output. Allreduce of 4096 float64 on one node, spin off, microseconds per call (main cf41b46 vs this branch's library):

ranks  linear_us  tree_us  ratio
    8      359.4    190.9   1.88
   16      657.6    343.2   1.92
   32     1551.6    483.3   3.21
   64     3284.2    776.2   4.23
  128     7192.3   1075.0   6.69

3. Wait (0da2698): an optional bounded spin (budgeted per wait by commit 6) before parking on the worker's eventfd. COMMUX_SPIN_US, default 0 = off, as with COMMUX_COALESCE and COMMUX_GROUP. Measured at 128 ranks, per step: park takes 7.94 ms CPU and 12.76 ms wall; spinning takes 9.43 ms CPU and 9.48 ms wall. The message states the CPU budget and the one untested path (a multi-threaded consumer).

4. CMake (432b550): link test_commux with Threads::Threads. The test starts std::threads but did not link Threads. glibc >= 2.34 hides this; on glibc 2.17 the link fails ("libpthread.so.0: error adding symbols: DSO missing from command line"). With the fix it links, and test_commux passes at 2 ranks with COMMUX_MT=1 COMMUX_DEADLOCK=1.

5. CMake (c8005b6): build commux._C with torch's pybind11 ABI tag. A torch that bundles pybind11 2.x (2.6, for one) exposes torch._C._PYBIND11_BUILD_ABI. commux._C took the compiler's own tag, so it could not see c10d::Store (TypeError: create_backend(): incompatible function arguments). The existing torch probe now reads the tag and, when it is set, defines it for commux_pybind, as torch.utils.cpp_extension does. torch 2.10 (the CI pin) has no such attribute, so there nothing changes.

6. Spin budget (013f5a1): one spin deadline per wait, not per call. Review found that in commit 3, every unrelated completion restarted the spin window, so one wait could spin without bound, on the blocking path while holding the worker mutex. The wait now carries one deadline across re-entries. With COMMUX_SPIN_US unset or 0, the path is unchanged. A test in test_commux runs with COMMUX_SPIN_US=20000 while a peer sends 40 unrelated messages 3 ms apart before the one being waited on: the waiter's thread CPU is 124.5 ms with commit 3's source (it spins through the whole drip) and 22.3 ms with this commit (one window, then it parks).

7. Format (357bad7): clang-format of the new test code only.

Checks at 357bad7 (CPU, UCX, spin off unless stated): test_collectives passes at 2, 3, 4, 5 and 8 ranks; test_torch_dist.py passes at 2 ranks; test_commux passes at 2 ranks with COMMUX_MT=1 COMMUX_DEADLOCK=1, and its spin-budget check passes with COMMUX_SPIN_US=20000. clang-format 20.1.4 and every pre-commit hook pass.

UCzhangxi and others added 8 commits September 29, 2026 09:23
CI ran the collectives at 2 ranks only, where a tree and a linear walk take the
same path. test_collectives.py checks, for every root, that reduce (SUM) leaves
the sum on the root and every other rank's input unchanged, that broadcast
reaches every rank, and that all_reduce gives every rank the sum. CI now also
runs it at 3 and 4 ranks.
reduce_locked and broadcast_locked each walked every peer from the root, so an
allreduce cost 2*(P-1) blocking messages serialized on one rank. The source said
so ("recursive-doubling is a future optimization... payloads are tiny, so linear
is fine") -- true of the payload, not of the message count.

Measured on one 128-core node, all shared memory, allreduce of a 1-element f64
through torch.distributed (no application), linear version:

    ranks        2     4     8    16    32    64    128
    ucx us    20.0  54.4  77.0 144.3 252.7 513.8  992.3

Every doubling of P doubles the cost from P=8 up (1.87, 1.75, 2.03, 1.93x),
fitting 7.6 us/rank over P=8..128. A halo-sized p2p ring over the same ranks
stays flat (17.8 -> 82.7 us), so this is the collective, not the fabric.

The tree sends 2*ceil(log2(P)) messages per rank instead: 14 rather than 254 at
P=128. Virtual ranks (vrank = rank - root mod size) keep any root working, and
the mask walk handles non-power-of-two sizes.

Measured in an application, 128 ranks, 512 cells per rank, one block per rank
(snapy, a dry box, two allreduces per step), on a second cluster: the step went
from 12.47 to 9.95 ms wall (1.25x) and from 7.81 to 6.48 ms CPU on rank 0, and
from 12.31 to 9.94 ms in a repeat on other nodes. Output was bit-identical.

A reduce must leave a non-root rank's input alone: an interior node of the tree
accumulates into a clone, not into t, so after a bare reduce() every rank still
holds its own input (the minimal case of the in-place version is size 4, root 0,
rank 2). allreduce would not show it, because the broadcast overwrites t.

An out-of-range root still fails loudly, as the linear version did through
coll_send; with virtual ranks it would otherwise hang.

MIN and MAX are exact under reordering, so per-step MIN/MAX collectives are
bit-identical. SUM and PRODUCT reassociate, so a reduce-based diagnostic may
differ in the last ulp.
Every completion not already satisfied by ucp_worker_progress went straight to
ucp_worker_arm + poll(), so each cost a transport transition into interrupt mode
plus a scheduler wakeup. Measured on the matched dry box, 128 ranks, per step:

    park                    cpu  7.94   wall 12.76
    spin, never parking     cpu  9.43   wall  9.48

Idle collapsed 4.82 -> 0.05 ms and reappeared as CPU: wakeup latency, not waiting
on a peer. Never parking is not shippable -- a rank then burns a core through a
genuinely long wait -- so this spins a bounded window and otherwise parks exactly
as before.

OFF by default (COMMUX_SPIN_US=0), matching COMMUX_COALESCE and COMMUX_GROUP in
this file: the consumer opts in, because spinning is not a universal win. Runs
with few large waits (GPU, 3-D) would burn a core for nothing.

The window bounds one CALL, not one wait. Both call sites reach here only after
ucp_worker_progress returned 0, so a busy path never enters the loop; but an
unrelated completion mid-spin returns early and the caller, finding its own
request still pending, re-enters with a fresh window. Total spin across a single
wait is therefore unbounded, and a root fielding many peers is exactly that
traffic. Budget CPU as "up to one window per unrelated completion observed",
not as one window per wait.

Untested: the concurrent-consumer path. worker_mu_ is held across this spin at
the wait_request call site, and the multi-threaded consumer it guards against is
snapy's BlockWorkerPool, built only when a rank owns more than one block
(mesh.cpp:244). Every run gating this used one block per rank. The pre-existing
idle path already held that lock across a poll() of up to COMMUX_WAIT_POLL_MS,
so the change there is 50 us then park rather than park immediately.

Note COMMUX_WAIT_POLL_MS=0 still means 1 ms, not "do not park" -- the clamp is
v > 0 ? v : 1.
tests/test_commux.cpp starts std::threads (the COMMUX_MT test and the deadlock
watchdog), but the target linked only commux and torch. glibc 2.34 folded
libpthread into libc, which hides this. With glibc 2.17 the link fails:

    libpthread.so.0: error adding symbols: DSO missing from command line

With Threads::Threads it links there, and test_commux passes at 2 ranks with
COMMUX_MT=1 COMMUX_DEADLOCK=1.
A torch that bundles pybind11 2.x (2.6, for one) builds its extensions with
PYBIND11_BUILD_ABI="_cxxabi1011" and exposes it as torch._C._PYBIND11_BUILD_ABI.
commux._C took the compiler's own tag instead (gcc 11: _cxxabi1016), so its
pybind11 internals were not torch's and it could not see c10d::Store:

    TypeError: create_backend(): incompatible function arguments. The following
    argument types are supported:
        1. (store: c10d::Store, rank: int, size: int, timeout: ...) -> c10d::Backend

Read the tag in the existing torch probe and, when it is set, define it for
commux_pybind, as torch.utils.cpp_extension does. With it, test_torch_dist.py
passes at 2 ranks on torch 2.6. torch 2.10 bundles pybind11 3 and has no such
attribute, so there the probe returns an empty tag and nothing is defined.
Unrelated UCX completions return from worker_wait_timed so the caller can
reap, and the next call used to start a new window. One wait now keeps a
single deadline. COMMUX_SPIN_US unset or 0 still skips the spin and parks.
Google style, matching pre-commit. No behavior change.
The outer progress loops treated any ucp_worker_progress as a reason to
skip the park. A back-to-back stream never read the per-wait deadline, so
the CPU spin ran for the whole wait. Once that deadline is past, sleep
one poll interval even when ucp_worker_arm returns BUSY. No deadline
(COMMUX_SPIN_US unset or 0) still returns immediately on BUSY.
@UCzhangxi
UCzhangxi marked this pull request as ready for review September 30, 2026 04:29
@UCzhangxi
UCzhangxi requested review from zoeyzyhu and a balanced review from Copilot September 30, 2026 04:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Late completions can incur unnecessary poll latency, and the spin regression test is not enabled in CI.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Improves collective scalability, wait behavior, testing, and build compatibility.

Changes:

  • Replaces linear collectives with binomial-tree reduce/broadcast.
  • Adds bounded spinning and regression tests.
  • Fixes thread linking and PyBind11 ABI handling.
File Description
src/​process_group_ucx.cpp Implements tree collectives and bounded spinning.
tests/​test_collectives.py Tests collectives across roots and world sizes.
tests/​test_commux.cpp Adds spin-budget regression checks.
CMakeLists.txt Fixes threading and PyBind11 ABI configuration.
.github/​workflows/​ci.yml Runs multi-rank collective tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/process_group_ucx.cpp
Comment on lines +329 to +334
if (ucp_worker_progress(worker_)) {
// Advanced. A stream of unrelated completions stays on this branch and
// would never consult the one per-wait deadline below.
if (spin.expired()) worker_wait_timed(worker_, spin.deadline());
continue;
}
Comment thread tests/test_commux.cpp
Comment on lines +343 to +344
if (const char* spin_env = std::getenv("COMMUX_SPIN_US");
spin_env != nullptr && std::atoi(spin_env) > 0 && size == 2) {
@zoeyzyhu
zoeyzyhu merged commit 0994e4e into zoeyzyhu:main Sep 30, 2026
2 checks passed
UCzhangxi pushed a commit to UCzhangxi/commux that referenced this pull request Sep 30, 2026
ucp_worker_progress can complete the request this wait is blocked on.
Parking after that, or sleeping on UCS_ERR_BUSY while idle, waits out
the rest of COMMUX_WAIT_POLL_MS without watching the wakeup fd. Reap
first. The idle path and wait_request yield on BUSY and progress again.
The p2p throttle still sleeps one poll when unrelated completions are
queued and this request is still pending, so the zoeyzyhu#11 flood stays off
the core.
@UCzhangxi
UCzhangxi deleted the xiz/collectives-pr branch September 30, 2026 23:19
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.

4 participants