Skip to content

fix(partition): 🐛 place partitions inside the cgroup the batch system carved - #249

Merged
robertodr merged 10 commits into
mainfrom
pr/partition-cgroup-placement-v2
Aug 20, 2026
Merged

fix(partition): 🐛 place partitions inside the cgroup the batch system carved#249
robertodr merged 10 commits into
mainfrom
pr/partition-cgroup-placement-v2

Conversation

@diagonal-hamiltonian

@diagonal-hamiltonian diagonal-hamiltonian commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Summary

placement_order refuses whenever group_count * n > cores.size(). Under a per-rank cgroup that is
the ordinary case, not an edge case: cores.size() is 16 (the cgroup Slurm carved) while
group_count * n is 8 * 16 = 128 (the whole node). It returns empty, cpusets_ stays empty,
master_loop_ skips pin_this_thread, and every rank runs unpinned — with no diagnostic, because
pin_this_thread ignores bind errors by design.

A rank cannot settle this from its own mask: seeing 16 of 128 CPUs is equally "Slurm gave me my own
16" and "eight of us share these 16", and those want opposite placements. Only the peers' masks
separate them, so PartitionGroup now measures it instead of guessing.

Changes

  • classify_node_masks_ allgathers each co-located rank's affinity mask over the node communicator
    it already opens (512 B per rank, once per group construction), and masks_are_pairwise_disjoint
    decides. A mask too wide to represent is exchanged as zeros, and an all-zero row is never private:
    "shared" is the safe error.
  • partition_cpusets gains mask_is_private. Under it the group split is skipped outright — the
    launcher already separated the ranks, so subdividing our own cgroup again can only shrink the
    placement or empty it.
  • Placement that cannot be satisfied now says so, once per process, instead of silently leaving
    every thread unpinned. That silence is what this PR is about.
  • placement_order refuses group_count == 0, which would otherwise loop without advancing.

Gap worth stating: the rule is unit-tested as a free function over the gathered array, but the
exchange in classify_node_masks_ has no MPI test — only the end-to-end runs below cover it.

Measurement

Two prebuilt arms, one allocation per cell holding both, order flipped per (rep, cell), 10 reps,
median of paired per-rep ratios, two-sided sign test, Holm across the 24-test family.
main 216d7e2f / port 6ad4ffec. Term counts identical across arms in all 12 cells.

Placement, from /proc/self/task/*/status, reduced over ranks:

layout main port
A 1×128 128 pinned 128 pinned
B 8×16 0 pinned 16 pinned

8 of 24 tests clear Holm, P(X ≥ 8) = 1.5e-16:

cell N operation main port port/main agree
grid-pauli-B 2 build_graph[pauli] 10022.9 ms 9422.5 ms 0.94x 10/10
grid-pauli-B 2 gradient[pauli] 4955.7 ms 4705.3 ms 0.95x 10/10
grid-hubbard-B 1 propagate[hubbard] 20288.2 ms 19317.8 ms 0.95x 10/10
grid-hubbard-graph-B 1 build_graph[hubbard] 2975.4 ms 2850.7 ms 0.95x 10/10
grid-pauli-B 2 propagate[pauli] 7580.2 ms 7304.4 ms 0.96x 10/10
grid-pauli-B 1 gradient[pauli] 11877.6 ms 11598.0 ms 0.97x 10/10
grid-pauli-B 1 energy[pauli] 2911.1 ms 2842.8 ms 0.98x 10/10
grid-pauli-A 1 energy[pauli] 2411.1 ms 2437.3 ms 1.01x 10/10

All twelve layout-B tests point the port's way; seven of them clear Holm. The largest single
effect is not in that table: propagate[hubbard] at B/N=2 goes 16834.2 → 15083.6 ms (1.12x
faster
), which at 9/10 does not survive Holm at m = 24.

The last row is a 1% regression at a layout where the fix cannot act, and is reported as measured.
At layout A the process mask is unconfined, mask_is_private is always false, and at N=1 there is no
MPI at all — none of the new code runs. The other eleven layout-A tests scatter 0.98x–1.01x with no
direction. Read it as this grid's known sensitivity to binary layout, not as a mechanism.

Peak RSS is flat (one cell 0.98x, the rest 1.00x). Gates: ctest -L unit 216/216, -L serial
215/215, MPI suite 592 passed at every layout.

Not reproducible from this diff. Deucalion, 2× EPYC 7742 / 128 cores / SMT off / NPS4; a private
harness driving two prebuilt venvs with the hashes above, which is not proposed for this repo.

Checklist

  • Tests added or updated to cover the changes
  • Documentation updated (docstrings, docs/, CONTRIBUTING.md) if needed
  • CHANGELOG / release notes updated if applicable

AI/LLM disclosure

  • I did not use LLM tooling, or used it only privately for ideation
  • I used the following tool to help write this PR description: Claude Code
  • I used the following tool to generate or modify code: Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation cpp labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown

Docs preview: https://pr-249.monoprop-docs.pages.dev

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.70%. Comparing base (3061af0) to head (655ea3c).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #249   +/-   ##
=======================================
  Coverage   97.70%   97.70%           
=======================================
  Files          14       14           
  Lines         742      742           
  Branches       98       98           
=======================================
  Hits          725      725           
  Misses         12       12           
  Partials        5        5           
Flag Coverage Δ
cpp 97.70% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the pr/partition-cgroup-placement-v2 branch from 2812589 to 153be12 Compare August 19, 2026 21:16
Base automatically changed from pr/bench-and-test-harness to main August 20, 2026 07:53
diagonal-hamiltonian and others added 10 commits August 20, 2026 09:53
… carved

Placement must not divide an already-divided machine.

`enumerate_physical_cores` reports only the cores inside the calling
thread's affinity mask. When a launcher has already given each
co-located rank its own disjoint slice -- `srun --cpu-bind=cores`, or
any cgroup-confined launch -- that slice IS the rank's share. Passing
the node-wide ranks-per-node through as `group_count` then asks for
group_count x n cores out of a list that only ever held n,
`placement_order` correctly refuses, and every rank silently runs
UNPLACED. The two-level barrier loses its domains at the same time,
because `cpuset_domains` derives them from the placement.

Measured on 8 ranks x 16 partitions: 437 us/sync unplaced against
15.5 us/sync placed.

`PartitionGroup` therefore allgathers the masks over its node-local
communicator and `classify_node_mask` MEASURES disjointness; a
`NodeMask::PerRank` result collapses `group_count` to 1. Mask width
cannot substitute for that measurement: "8 ranks holding 16 cores each"
and "8 ranks sharing one 16-core mask" both leave a rank seeing 16 of
128, and they need opposite placement. Collapsing in the wrong
direction pins every co-located rank to the SAME cores, so `Shared` is
the default and the safe error.

This regressed once already, when topology discovery was rewritten onto
hwloc, because the guard lives in the placement policy rather than in
discovery -- any rework of that layer has to re-check it.
`cpu_topology_policy_per_rank_slice_starves_without_collapse` pins the
mechanism without needing live hardware, which is what makes it a
regression test rather than a machine-specific one.

Assisted-by: ClaudeCode:claude-opus-5
…ve hardware

The fix as committed added no hardware-free test case. It extends one live-topology case,
which early-returns when fewer than two physical cores are visible or pinning is off -- so on
a constrained runner the whole mechanism can go unexercised while the suite reports green.
That matters more than usual here: this regressed once already, when topology discovery moved
onto hwloc, because the guard lives in the placement policy rather than in discovery.

So the rule becomes a free function. masks_are_pairwise_disjoint() is pure bit arithmetic over
the array MPI_Allgather already leaves behind -- no communicator, no hwloc, no live hardware --
and classify_node_masks_ keeps only the exchange and the all_ok reduction. Six cases are ported
from the competing implementation on perf/multinode-comm-scaling, which spelled the same
mechanism as a NodeMask enum over a CpuMask POD; dropping that implementation in favour of this
one would otherwise have dropped its tests with it.

The empty-mask clause is folded into the predicate rather than left implicit. An all-zero mask
is trivially disjoint from everything, so a bare disjointness test answers "private" for a rank
that can see no CPU at all -- collapsing group_count in exactly the case where the caller knows
least. Not private is the safe error.

kMaskWords moves out of PartitionGroup and beside affinity_mask_words as kAffinityMaskWords:
the two must agree on the width and a constant living next to only one of them can drift
silently. process_is_cpu_confined() and its helper are deleted -- 40 lines of public-header API
with no caller, no test, and a Doxygen block warning against its own use.

Corrects the AGENTS.md paragraph, which described the OTHER branch's design: classify_node_mask,
NodeMask::PerRank and Shared appear nowhere in this tree, and neither did the test it cited.
Replaces the 437 -> 15.5 us/sync figure, which is a pair of range midpoints measured on the
pre-hwloc tree with a different branch as the arm, with the placement counts the /proc probe
takes on both arms (0 vs 16 threads pinned per rank), and records what the record actually
says: at 99.4M terms this does not make the workload faster, and pinning did not reduce
variance either.

Assisted-by: ClaudeCode:claude-opus-5
The rewrite in b3d788d left "every rank silently runs unpinned -- which also costs every rank
silently runs unpinned": half of the deleted clause and its own replacement, side by side.

The clause being deleted was the fix commit's "the two-level barrier loses its domains at the
same time, because `cpuset_domains` derives them from the placement". `cpuset_domains` is the
OTHER branch's vocabulary -- `git grep` finds it nowhere in this tree -- so the clause goes
rather than being repaired, and the consequence stated is the one this branch can actually
show: every rank runs unpinned.

Assisted-by: ClaudeCode:claude-opus-5
…ment count

kAffinityMaskWords and affinity_mask_words() "must agree", but they cannot be tied by a
static_assert: the function takes the width as a runtime `nwords` argument, as does
masks_are_pairwise_disjoint()'s `words`, so there is no shape to assert against. Hoisting the
constant out of PartitionGroup (b3d788d) is the whole fix -- there is now one definition, and
the drift it could have had was a second one.

What is assertable is the constraint the exchange imposes on the value: PartitionGroup passes
the width to MPI_Allgather as an element count, and MPI counts are `int`. State it where the
constant is declared, so a future widening past INT_MAX fails to compile rather than passing a
truncated count.

No behaviour change: a static_assert emits no code, and the objects prove it. Recompiled with
the job-1828023 build's own flags, CpuTopology.cpp.o, Evolution.cpp.o (which reaches this
header through MonomialPropagator.h -> PartitionGroup.h) and partition_equivalence_tests.cpp.o
are byte-identical with and without this hunk.

Assisted-by: ClaudeCode:claude-opus-5
…t cases

A case that early-returns on a constrained runner reports green while testing nothing, which is
the exact defect the ported cases exist to fix -- and three of them had it.

cpu_topology_shared_mask_keeps_co_located_ranks_disjoint required FOUR physical cores. A
standard GitHub runner has two, so on CI it returned at the first line, every time. Two cores
are enough: one per rank still asserts that two co-located ranks do not share one.

cpu_topology_per_rank_mask_still_places required two. One is enough -- the request is
group_count x n out of a list holding n at any n -- so the window shrinks from "fewer than two
cores" to "hwloc loaded no topology at all", which is the condition under which nothing in this
file means anything.

cpu_topology_affinity_mask_covers_enumerated_cores returned silently when affinity_mask_words()
said no. But enumerate_physical_cores() has just succeeded at that point, so hwloc demonstrably
works and the only licensed refusal is the truncation guard. It now asserts that some enumerated
PU really does sit past kAffinityMaskWords * 64; a mask function that had simply stopped working
used to read as "nothing to test".

Two premises are checked rather than assumed. confine_to_first() re-enumerates and requires the
narrowing to have reached hwloc -- if sched_setaffinity ever stopped moving what
effective_allowed_cpuset() sees, both live cases would go on asserting against the unconfined
machine and go on passing, which is an early return that does not look like one. And an empty
placement is now licensed only by monoprop_PARTITION_PINNING being off; "placed nothing" is the
bug this branch fixes and must never pass as a configuration.

What remains vacuous, and cannot be fixed here: hwloc loading no topology, and the kernel
refusing sched_setaffinity. Both are named at the return. The mechanism itself is covered
hardware-free by cpu_topology_policy_per_rank_slice_starves_without_collapse and
cpu_topology_masks_*, which have no early return at all.

Assisted-by: ClaudeCode:claude-opus-5
…nt retry

The MPI_Allreduce over the per-rank affinity_mask_words result cannot change
a verdict: refusal leaves the mask zeroed, and masks_are_pairwise_disjoint
already rejects an all-zero row, so every peer computes "not private" from
the allgathered buffer alone.

The private-mask collapse becomes unconditional rather than a retry after the
normal split has failed, which removes the second call to placement_order. A
rank whose mask is private owns all of it, so it now takes the head of its own
interleaved order instead of a group_index-offset slice of it even when the
split would have succeeded.
… is impossible

group_count == 0 never advanced the interleave arm's stride, so `mine` grew
without bound. It joins the existing oversubscription guard.

An empty placement is the failure this branch exists to fix and it was
returned silently. One warning per process now names the core count and the
request it could not meet.
…nal collapse

The truncation-guard assertion compared kAffinityMaskWords*64 against the
core representatives, which are the LOWEST allowed sibling of each core,
while affinity_mask_words refuses on the HIGHEST allowed PU. A core holding
PUs {100, 4196} refuses with a representative of 100. It now re-reads the
mask through a wider window and asserts against its own highest bit.

empty_placement_is_licensed() moves out of the __linux__ block so the two
live smoke cases can use it; both hard-failed under
monoprop_PARTITION_PINNING=0.

The new policy case covers a private mask whose split WOULD have fit, which
the fallback form never reached.
…t say

The AGENTS.md bullets restated the header and the PR body; both are dropped.
What survives elsewhere is the empty-mask rule, what the synthetic core
literals stand for physically, and why two cores rather than four are enough
on a CI runner.
The private-mask arm needs n cores, the shared arm group_count x n; the
clause named only the former.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@robertodr
robertodr force-pushed the pr/partition-cgroup-placement-v2 branch from fb2453a to 655ea3c Compare August 20, 2026 07:54
@sonarqubecloud

Copy link
Copy Markdown

@robertodr
robertodr merged commit 1a4177e into main Aug 20, 2026
24 checks passed
@robertodr
robertodr deleted the pr/partition-cgroup-placement-v2 branch August 20, 2026 08:21
robertodr pushed a commit that referenced this pull request Aug 20, 2026
…bool (#252)

🤖 _AI text below_ 🤖

## Summary

Follow-up to #249, which shipped the cgroup placement fix with a `bool
mask_is_private` parameter.
This renames that parameter to `enum class NodeMask { Shared, PerRank
}`. No behaviour change.

`partition_cpusets(n, gi, gc, true)` does not say at the call site which
regime `true` means, and
the safe default is spelled `false`. The enum names both, and a third
launcher regime would be a
new enumerator rather than a second bool.

## Changes

- `enum class NodeMask { Shared, PerRank }` in `CpuTopology.h`.
- `partition_cpusets`'s fourth parameter becomes `NodeMask mask =
NodeMask::Shared`.
- `PartitionGroup::node_mask_private_` becomes `NodeMask node_mask_`;
`classify_node_masks_`
converts `masks_are_pairwise_disjoint`'s `bool` at the call site. The
predicate keeps its `bool`
  return -- it answers a question about masks, not about policy.
- Test call sites lose their `/*mask_is_private=*/` argument comments,
which the enum value replaces.

Every assertion and every test-case name is unchanged, and `grep -rn
mask_is_private cpp/` is empty.

## Checklist

- [x] Tests added or updated to cover the changes
- [ ] Documentation updated (docstrings, `docs/`, `CONTRIBUTING.md`) if
needed
- [ ] `CHANGELOG` / release notes updated if applicable

## AI/LLM disclosure

- [ ] I did not use LLM tooling, or used it only privately for ideation
- [x] I used the following tool to help write this PR description:
Claude Code
- [x] I used the following tool to generate or modify code: Claude Code

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cpp documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants