Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 40 additions & 25 deletions cpp/monoprop/detail/partition/CpuTopology.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
#include <format>
#include <map>
#include <mutex>
#include <print>
#include <string>
#include <string_view>
#include <utility>
Expand All @@ -32,8 +31,6 @@

namespace {

/* ── Process-lifetime hwloc topology ──────────────────────────────────────── */

// hwloc_topology_t is safe for concurrent read-only access after hwloc_topology_load().
struct TopologyHolder {
hwloc_topology_t topo = nullptr;
Expand Down Expand Up @@ -70,8 +67,6 @@
return holder.topo;
}

/* ── Effective allowed cpuset for the calling thread ──────────────────────── */

// Queries the current thread's affinity to respect any launcher-imposed restriction (cgroup, MPI
// process binding) narrower than the topology's own allowed cpuset. Falls back to the topology
// allowed cpuset when the cpubind query is unsupported on this platform. Caller must free the bitmap.
Expand All @@ -88,9 +83,33 @@
return hwloc_bitmap_dup(hwloc_topology_get_allowed_cpuset(topo));
}

// One process has one placement, so the last verdict is the whole state. Locked rather than atomic:
// the five fields are read together and a torn mix of two placements would explain neither.
struct PlacementRecord {
std::mutex mu;
PlacementReport report;
};

auto placement_record() -> PlacementRecord & {
static PlacementRecord rec;
return rec;
}

} // anonymous namespace

/* ── topo_detail::placement_order ─────────────────────────────────────────── */
auto placement_report() -> PlacementReport {
auto &rec = placement_record();
const std::lock_guard lock(rec.mu);

Check warning on line 102 in cpp/monoprop/detail/partition/CpuTopology.cpp

View workflow job for this annotation

GitHub Actions / clang-tidy analysis

use 'std::scoped_lock' instead of 'std::lock_guard' [modernize-use-scoped-lock]
return rec.report;
}

auto format_unpinned_line(const PlacementReport &report) -> std::string {
return std::format("monoprop: partition pinning requested but not possible "
"({} cores visible, {} groups x {} partitions); threads run unpinned.\n",
report.cores_visible,
report.groups,
report.partitions);
}

namespace topo_detail {

Expand Down Expand Up @@ -155,8 +174,6 @@

} // namespace topo_detail

/* ── enumerate_physical_cores ──────────────────────────────────────────────── */

auto enumerate_physical_cores() -> std::vector<PhysicalCore> {
auto *const topo = get_topology();
if (!topo) {
Expand Down Expand Up @@ -226,8 +243,6 @@
return cores;
}

/* ── affinity_mask_words ───────────────────────────────────────────────────── */

auto affinity_mask_words(uint64_t *out, size_t nwords) -> bool {
if (out == nullptr || nwords == 0) {
return false;
Expand All @@ -253,8 +268,6 @@
return representable;
}

/* ── masks_are_pairwise_disjoint ───────────────────────────────────────────── */

auto masks_are_pairwise_disjoint(const uint64_t *masks, size_t n, size_t words) -> bool {
if (masks == nullptr || words == 0 || n < 2) {
return false;
Expand All @@ -281,8 +294,6 @@
return true;
}

/* ── summarize_masks ──────────────────────────────────────────────────────── */

// hwloc indexes a bitmap in unsigned long units, so a 64-bit word must be one of them.
static_assert(sizeof(unsigned long) == sizeof(uint64_t), "the mask word is not an hwloc bitmap unit");

Expand Down Expand Up @@ -347,7 +358,7 @@
auto place_line_is_new(std::string_view line) -> bool {
static std::mutex mu;
static std::string last;
const std::lock_guard lock(mu);

Check warning on line 361 in cpp/monoprop/detail/partition/CpuTopology.cpp

View workflow job for this annotation

GitHub Actions / clang-tidy analysis

use 'std::scoped_lock' instead of 'std::lock_guard' [modernize-use-scoped-lock]
if (line == last) {
return false;
}
Expand All @@ -355,8 +366,6 @@
return true;
}

/* ── partition_cpusets ─────────────────────────────────────────────────────── */

auto partition_cpusets(size_t n, size_t group_index, size_t group_count, NodeMask mask) -> std::vector<CpuSet> {
const auto cores = enumerate_physical_cores();

Expand All @@ -367,15 +376,23 @@
}
const auto order = topo_detail::placement_order(cores, n, group_index, group_count);

if (order.empty()) {
// `groups` is the count actually used, so a PerRank collapse reads back as the 1 group it became.
PlacementReport report{.pinned = !order.empty(),
.cores_visible = cores.size(),
.groups = group_count,
.partitions = n};
{
auto &rec = placement_record();
const std::lock_guard lock(rec.mu);

Check warning on line 386 in cpp/monoprop/detail/partition/CpuTopology.cpp

View workflow job for this annotation

GitHub Actions / clang-tidy analysis

use 'std::scoped_lock' instead of 'std::lock_guard' [modernize-use-scoped-lock]
report.decisions = rec.report.decisions + 1;
rec.report = report;
}

if (!report.pinned) {
// Kept: this is the only channel that survives with no Python in the process at all.
static std::once_flag warned;
std::call_once(warned, [&] {
std::print(stderr,
"monoprop: partition pinning requested but not possible "
"({} cores visible, {} groups x {} partitions); threads run unpinned.\n",
cores.size(),
group_count,
n);
std::fputs(format_unpinned_line(report).c_str(), stderr);
std::fflush(stderr);
});
}
Expand All @@ -387,8 +404,6 @@
return sets;
}

/* ── pin_this_thread ───────────────────────────────────────────────────────── */

auto pin_this_thread(const CpuSet &set) -> void {
if (set.pu < 0) {
return;
Expand Down
25 changes: 25 additions & 0 deletions cpp/monoprop/detail/partition/CpuTopology.h
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,30 @@ struct MaskSummary {
*/
[[nodiscard]] auto place_line_is_new(std::string_view line) -> bool;

/*! @brief What the last partition_cpusets() call decided, process-wide.
*
* Placement belongs to the process, not to a propagator, so one record answers "did the partition
* threads get pinned, and if not, why not" for whatever asked last. Refusal is silent by design --
* pinning is performance-only -- and silence measured 24.6x on propagate at 256 partitions over 128
* cores, so the outcome is recorded rather than only printed.
*/
struct PlacementReport {
bool pinned = false; //!< false β‡’ the request was refused and every partition thread runs unpinned
size_t cores_visible = 0; //!< physical cores in the calling thread's effective allowed mask
size_t groups = 0; //!< co-located ranks those cores were split between; 1 once a per-rank mask collapses it
size_t partitions = 0; //!< partitions this rank asked to place
uint64_t decisions = 0; //!< placements this process has decided, this one included; 0 β‡’ none yet
};

//! Snapshot of that record. Locked, so it is safe from any thread; `decisions` tells a fresh verdict from a stale one.
[[nodiscard]] auto placement_report() -> PlacementReport;

/*! @brief The one-line "threads run unpinned" message for @p report, newline-terminated.
* Returned rather than written so the stderr print and the binding's RuntimeWarning cannot drift apart,
* and so the text is testable without an oversubscribed host.
*/
[[nodiscard]] auto format_unpinned_line(const PlacementReport &report) -> std::string;

//! Whether the launcher has already handed this rank a private slice of the node, or the node's CPUs are shared.
enum class NodeMask { Shared, PerRank };

Expand All @@ -158,6 +182,7 @@ enum class NodeMask { Shared, PerRank };
* @p group_count x @p n cores are visible (@p n under PerRank).
*
* @note Under NodeMask::PerRank the group split is skipped: our share is already this rank's alone.
* @note Records the outcome in placement_report() whether or not it could place.
*/
auto partition_cpusets(size_t n, size_t group_index = 0, size_t group_count = 1, NodeMask mask = NodeMask::Shared)
-> std::vector<CpuSet>;
Expand Down
55 changes: 55 additions & 0 deletions cpp/tests/cpu_topology_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -457,3 +457,58 @@ BOOST_AUTO_TEST_CASE(cpu_topology_place_line_unknown_is_not_a_verdict) {
"COMMPLACE rank=0 node_rank=0 node_size=1 masks=unknown cpus=0 node_cpus=0 "
"cpu_list=none\n");
}

/* ── The placement record ─────────────────────────────────────────────────── */

// partition_cpusets() answers an oversubscribed request with an empty vector and a line on C++
// stderr, which pytest's fd capture swallows; until this record, nothing in-process could tell that
// refusal from a host where hwloc simply loaded no topology.
BOOST_AUTO_TEST_CASE(cpu_topology_placement_report_records_the_refusal) {
const auto cores = partition::enumerate_physical_cores();
const auto before = partition::placement_report();

const auto too_many = partition::partition_cpusets(/*n=*/1'000'000);
// The branch under test, asserted rather than assumed: nothing below means anything if it placed.
BOOST_REQUIRE(too_many.empty());

const auto after = partition::placement_report();
BOOST_CHECK(!after.pinned);
BOOST_CHECK_EQUAL(after.partitions, 1'000'000U);
BOOST_CHECK_EQUAL(after.groups, 1U);
BOOST_CHECK_EQUAL(after.cores_visible, cores.size());
BOOST_CHECK_EQUAL(after.decisions, before.decisions + 1);
}

// The other outcome, so a record that answered "unpinned" unconditionally fails here. Neither arm
// skips: a host with no topology is itself a verdict the record has to state.
BOOST_AUTO_TEST_CASE(cpu_topology_placement_report_records_a_placement) {
const auto cores = partition::enumerate_physical_cores();
const auto one = partition::partition_cpusets(/*n=*/1);
const auto report = partition::placement_report();

BOOST_CHECK_EQUAL(report.partitions, 1U);
BOOST_CHECK_EQUAL(report.cores_visible, cores.size());
BOOST_CHECK_EQUAL(report.pinned, !cores.empty());
BOOST_CHECK_EQUAL(one.size(), cores.empty() ? 0U : 1U);
}

// `groups` is the count the placement USED, so the per-rank collapse has to show as the 1 it became.
BOOST_AUTO_TEST_CASE(cpu_topology_placement_report_shows_the_collapsed_group_count) {
partition::partition_cpusets(/*n=*/1, /*group_index=*/3, /*group_count=*/8, partition::NodeMask::PerRank);
BOOST_CHECK_EQUAL(partition::placement_report().groups, 1U);

partition::partition_cpusets(/*n=*/1, /*group_index=*/3, /*group_count=*/8, partition::NodeMask::Shared);
BOOST_CHECK_EQUAL(partition::placement_report().groups, 8U);
}

// One text for the stderr line and for the binding's RuntimeWarning: a drift between them is two bugs.
BOOST_AUTO_TEST_CASE(cpu_topology_unpinned_line_names_every_field) {
const partition::PlacementReport report{.pinned = false,
.cores_visible = 128,
.groups = 1,
.partitions = 256,
.decisions = 4};
BOOST_CHECK_EQUAL(partition::format_unpinned_line(report),
"monoprop: partition pinning requested but not possible "
"(128 cores visible, 1 groups x 256 partitions); threads run unpinned.\n");
}
2 changes: 2 additions & 0 deletions src/monoprop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
antihermitian_generator_correction,
has_mpi,
is_antihermitian,
placement_report,
)
from ._version import version as __version__
from .circuit import (
Expand Down Expand Up @@ -68,6 +69,7 @@
"integrals_to_fermion",
"is_antihermitian",
"jordan_wigner_basis_change",
"placement_report",
"validate_parameter_mapping",
]

Expand Down
48 changes: 48 additions & 0 deletions src/monoprop/bindings/bindings.cpp.in
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "monoprop/Info.h"
#include "monoprop/MPFunctions.h"
#include "monoprop/detail/mpi/MPICompat.h"
#include "monoprop/detail/partition/CpuTopology.h"

using namespace monoprop;
using namespace nanobind::literals;
Expand Down Expand Up @@ -93,6 +94,33 @@ auto basis_enum_2_str(Basis basis) -> std::string {
throw std::invalid_argument("Unknown Basis enum value");
}
}

/* An unpinned placement costs 16-25x on propagate and says so only on C++ stderr, which pytest's
* file-descriptor capture swallows without `-s`. A RuntimeWarning reaches pytest.warns and the
* caller's warning filters, so a harness can fail closed on it.
*
* PyErr_WarnEx runs Python code and so needs the GIL, which the partition master threads never hold;
* this is called from a binding entry point on the constructing thread, which does. That same GIL
* serialises `warned_through`, which is what keeps the report of an EARLIER placement from being
* re-announced by a construction that placed nothing.
*/
auto warn_if_unpinned(int stack_level) -> bool {
static uint64_t warned_through = 0;
const auto report = monoprop::detail::partition::placement_report();
if (report.decisions == warned_through) {
return false;
}
warned_through = report.decisions;
if (report.pinned) {
return false;
}
auto text = monoprop::detail::partition::format_unpinned_line(report);
text.pop_back(); // the newline belongs to the stderr line; a warning message carries its own
if (PyErr_WarnEx(PyExc_RuntimeWarning, text.c_str(), stack_level) < 0) {
throw nb::python_error(); // the caller's filter turned this warning into an error
}
return true;
}
} // namespace monoprop::bindings::detail

NB_MODULE(_core, m) {
Expand Down Expand Up @@ -129,6 +157,26 @@ NB_MODULE(_core, m) {
&monoprop::antihermitian_generator_correction,
"indices"_a,
"Get the generator correction for a Majorana operator (represented by indices).");
m.def(
"placement_report",
[] {
const auto r = monoprop::detail::partition::placement_report();
nb::dict out;
out["pinned"] = r.pinned;
out["cores_visible"] = r.cores_visible;
out["groups"] = r.groups;
out["partitions"] = r.partitions;
out["decisions"] = r.decisions;
return out;
},
"What the last partition placement decided, process-wide: pinned, cores_visible, groups, "
"partitions, decisions. decisions == 0 means no placement has been attempted yet, and "
"pinned False means every partition thread runs on whatever mask the launcher left.");
m.def("warn_if_unpinned",
&monoprop::bindings::detail::warn_if_unpinned,
"stack_level"_a = 1,
"Raise a RuntimeWarning if the most recent placement ran unpinned, once per placement. "
"Returns whether it warned.");
// clang-format off
@_BINDINGS_BODY_@
// clang-format on
Expand Down
6 changes: 6 additions & 0 deletions src/monoprop/monomial_propagator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

import numpy as np

from monoprop._core import warn_if_unpinned
from monoprop._dispatch import dispatch

from .circuit import (
Expand Down Expand Up @@ -129,6 +130,11 @@ def _init_simulator(
comm=comm,
basis=basis,
)
# Unpinned partition threads cost 16-25x and say so only on C++ stderr, which pytest's
# fd capture swallows. Emitted here, on the thread that placed them, because PyErr_WarnEx
# needs the GIL and the partition masters hold none. stack_level=3 names the caller's
# constructor rather than this line.
warn_if_unpinned(stack_level=3)

@classmethod
def from_circuit(
Expand Down
Loading
Loading