Skip to content

feat(partition): 🔊 make a refused partition placement queryable and audible - #294

Open
diagonal-hamiltonian wants to merge 2 commits into
mainfrom
fix/placement-observable
Open

feat(partition): 🔊 make a refused partition placement queryable and audible#294
diagonal-hamiltonian wants to merge 2 commits into
mainfrom
fix/placement-observable

Conversation

@diagonal-hamiltonian

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

Copy link
Copy Markdown
Collaborator

Summary of the slop below, basically ive been having some issues with the threading etc and oversubscription of the partitions. This attacks this. Maybe we should have some sort of logging for the partiontions and their placement to avoid this if possible?

AI text below

When monoprop_PARTITIONS exceeds the visible physical cores, placement_order() returns empty, partition_cpusets() refuses, and every thread runs unpinned. Measured on Deucalion x86 (128 physical cores), against an in-run pinned control on the identical workload:

partitions partitions/thread state propagate vs control
128 2.0000 pinned 31.32 s 1.00×
192 3.0000 unpinned 512.32 s 16.4×
256 4.0000 unpinned 768.97 s 24.6×

Both collapsing cells have an exactly integer partitions-per-thread ratio, so this is not about divisibility — the rule is partitions > visible cores, full stop. Cost grows with the oversubscription factor rather than being a fixed penalty, and the unpinned cells are also unstable across reps (2.46× spread, against 1.01× when pinned).

The problem is not that it degrades. It is that it degrades silently. partition_cpusets() already prints a warning, but it goes to C++ stderr, which pytest's file-descriptor capture swallows without -s — which is how the benchmarks and most harnesses run. And there was no way to ask, from Python, whether pinning actually happened, so nothing could assert on it.

This PR adds channels. It does not change pinning behaviour — no refusal, no clamping, no fallback placement. That is a separate decision.

What it adds

The engine records rather than only printing. PlacementReport { pinned, cores_visible, groups, partitions, decisions }, written by every partition_cpusets() call into a mutex-guarded process-wide record and read back via placement_report(). decisions is a monotonic counter, which is what lets a caller distinguish a fresh verdict from a stale one. The existing stderr write stays — it is the only channel that works with no Python in the process — and both channels now render one string via format_unpinned_line() so they cannot drift.

groups records the count the placement used — 1 once a NodeMask::PerRank mask collapses it, not the launcher's node_size — since that is what explains the outcome. A test pins both directions.

The binding exposes it. _core.placement_report() returns a dict; monoprop.placement_report is the public re-export so a harness has a surface to fail closed on. _core.warn_if_unpinned(stack_level=…) raises the RuntimeWarning.

The warning is raised from Python, in MonomialPropagator._init_simulator right after the dispatch call — not from the engine, not from the binding's __init__. PyErr_WarnEx needs the GIL and the partition masters never hold it, but the constructing thread does, so that alone would have allowed __init__. The blocker is this repo's own filterwarnings = ["error"]: under it the warning is an exception, and nanobind does not mark an instance ready when __init__ throws, so the destructor never runs — stranding a fully-constructed PartitionGroup with S live master threads. Raising after the object is bound to a Python name makes the error path plain refcounting.

The cost of that choice: a construction that goes straight through _core bypasses the warning. The report still records it.

⚠️ One judgement call for the reviewer

Because of filterwarnings = ["error"], in any environment where placement legitimately fails, every propagator construction under pytest now raises rather than warns. That is fail-closed and arguably the point — a silent 25× is worse — but it is a real behaviour change for anyone running the suite on an oddly-confined box, and it is worth an explicit yes or no rather than arriving as a side effect. All four measured layouts stay pinned, so nothing in the current harness trips it. Say the word and I will downgrade it to a plain warning with the filter exempted.

Verification

Built and run on dev-x86 compute nodes:

suite result
ctest -L unit 100% passed, 0 failed out of 245
ctest -L serial 100% passed, 0 failed out of 244
ctest -L mpi 1/1 passed
pytest tests 588 passed, 8 skipped
4-layout MPI sweep, 2 nodes 596 passed on every one of 24 rank-runs (2×1, 2×16, 4×8, 16×16, world 256)

The layout sweep was run specifically because of the filterwarnings interaction — it is the check that the new warning does not fire spuriously under real srun --cpu-bind=cores masks and turn the suite red. It doesn't, and test_oversubscription_warns_and_reports_unpinned passed on all 24 rank-runs.

The unpinned branch is confirmed reached, not assumed. A probe printed the record at four states: unset → confined to 1 CPU with partitions=2 (warning raised, pinned=False, cores_visible=1, decisions=1) → partitions=1, no placement decided (decisions unchanged, no re-announcement) → unconfined (pinned=True, cores_visible=128, decisions=2). The C++ stderr line appeared alongside, so both channels fire on the same event.

No test skips. The affinity helper falls back to cpu_count() + 1 partitions where sched_setaffinity is absent or refused, and both arms assert identically. The C++ case asserts the returned set is empty before reading the record, so a refusal that silently placed fails. cpu_topology_placement_report_records_a_placement asserts pinned == !cores.empty() — a host with no hwloc topology is a verdict the record must state, not a reason to return early.

🤖 Generated with Claude Code

…udible

partition_cpusets() returns an empty placement order when the partitions
requested exceed the visible physical cores, and every partition thread then
runs unpinned -- 16.4x on propagate at 192 partitions over 128 cores, 24.6x at
256. The only sign was a std::print to C++ stderr, which pytest's
file-descriptor capture swallows without -s, and nothing in-process could be
asked whether pinning had happened, so a harness could not fail closed on it.

Record the outcome rather than only printing it. PlacementReport -- pinned,
cores visible, groups, partitions, and a monotonic decision counter -- is
written by every partition_cpusets() call and read back through
_core.placement_report(), re-exported as monoprop.placement_report(). The
message text moves into format_unpinned_line() so the stderr line and the new
RuntimeWarning cannot drift apart; the stderr line itself stays, being the only
channel that works with no Python in the process.

The warning is raised from _init_simulator, not from the engine: PyErr_WarnEx
needs the GIL, which the partition masters never hold, and raising it from the
binding's __init__ would strand a constructed PartitionGroup with live threads
when a caller's filter turns the warning into an error. The decision counter is
what keeps a build that placed nothing from re-announcing an earlier refusal.

Pinning behaviour is unchanged: nothing refuses, clamps, or falls back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

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

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.71%. Comparing base (97f95f7) to head (ecc14e8).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #294   +/-   ##
=======================================
  Coverage   97.70%   97.71%           
=======================================
  Files          14       14           
  Lines         742      744    +2     
  Branches       98       98           
=======================================
+ Hits          725      727    +2     
  Misses         12       12           
  Partials        5        5           
Flag Coverage Δ
cpp 97.71% <100.00%> (+<0.01%) ⬆️

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

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

Decision on the filterwarnings interaction: keep it fatal. Recording it here so it is met as a choice rather than discovered as a side effect.

This repo sets filterwarnings = ["error"] in pyproject.toml, so the new RuntimeWarning means that on a box where placement legitimately fails, every propagator construction under pytest raises rather than warns. That is a real behaviour change and it was weighed rather than inherited:

  • The failure it replaces is a 16.4× slowdown at 192 partitions and 24.6× at 256, against an in-run pinned control at 31.32 s, with no message reaching the user at all. A hard failure at construction is strictly better than a silent 25×.
  • It is not hair-trigger. The condition is partitions > visible physical cores — a misconfiguration, not a tight machine. All four measured layouts (2×1, 2×16, 4×8, 16×16, world 256) stay pinned, and CI's own few-core runners pass the full matrix, so the ordinary path is unaffected.
  • Callers who genuinely want to oversubscribe can filter the warning; callers who did it by accident now find out immediately.

The alternative considered was exempting it from the error filter so it warns without raising. Rejected because a suite running -W error is exactly the audience that wants to know, and downgrading it would restore the silence for everyone else.

Happy to flip it if you'd rather — it is a one-line filter entry, no code change.

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

@robertodr is this a good idea? I want users to feel pain when they use threading incorrectly.

Removed commented sections for clarity and conciseness.

Signed-off-by: Aaron Miller <61472721+diagonal-hamiltonian@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant