Skip to content
Merged
13 changes: 1 addition & 12 deletions cpp/monoprop/detail/EnvConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,13 @@
// Single home for runtime environment configuration. Kept dependency-free by design, because it is
// pulled into hot-path headers.
//
// monoprop_NUM_THREADS positive int (1..1e6), else ignored β†’ num_threads
// monoprop_PARTITION_PINNING bool, default ON; 0/false disables per-core pinning β†’ partition_pinning
// monoprop_NUM_THREADS positive int (1..1e6), else ignored β†’ num_threads
// monoprop_PARTITIONS int N | "auto" | "off"; parsed where it is used (resolve_partition_count_)

namespace monoprop::config {

namespace detail {

inline auto parse_flag(const char *value, bool default_value) -> bool {
if (value == nullptr || value[0] == '\0') {
return default_value;
}
const char c = value[0];
return !(c == '0' || c == 'f' || c == 'F' || c == 'n' || c == 'N');
}

inline auto parse_positive_int(const char *text) -> std::optional<int> {
if (text == nullptr) {
return std::nullopt;
Expand All @@ -56,15 +47,13 @@ inline auto parse_positive_int(const char *text) -> std::optional<int> {

struct Settings {
std::optional<int> num_threads;
bool partition_pinning = true;
};

// Parse the environment once; the Settings are cached and shared across TUs.
inline auto get() -> const Settings & {
static const Settings settings = [] {
Settings s;
s.num_threads = detail::parse_positive_int(std::getenv("monoprop_NUM_THREADS"));
s.partition_pinning = detail::parse_flag(std::getenv("monoprop_PARTITION_PINNING"), true);
return s;
}();
return settings;
Expand Down
86 changes: 75 additions & 11 deletions cpp/monoprop/detail/partition/CpuTopology.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,14 @@
#include "monoprop/detail/partition/CpuTopology.h"

#include <algorithm>
#include <array>
#include <cstdio>
#include <format>
#include <map>
#include <mutex>
#include <print>
#include <string>
#include <utility>
#include <vector>

#include <hwloc.h>
Expand Down Expand Up @@ -144,16 +148,16 @@ auto placement_order(const std::vector<PhysicalCore> &cores, size_t n, size_t gr
if (offset + n > order.size()) {
return {};
}
return std::vector<int>(order.begin() + static_cast<std::ptrdiff_t>(offset),
order.begin() + static_cast<std::ptrdiff_t>(offset + n));
return {order.begin() + static_cast<std::ptrdiff_t>(offset),
order.begin() + static_cast<std::ptrdiff_t>(offset + n)};
}

} // namespace topo_detail

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

auto enumerate_physical_cores() -> std::vector<PhysicalCore> {
const auto topo = get_topology();
auto *const topo = get_topology();
if (!topo) {
return {};
}
Expand All @@ -175,7 +179,7 @@ auto enumerate_physical_cores() -> std::vector<PhysicalCore> {

const unsigned num_cores = hwloc_get_nbobjs_by_depth(topo, core_depth);
for (unsigned i = 0; i < num_cores; ++i) {
const hwloc_obj_t core = hwloc_get_obj_by_depth(topo, core_depth, i);
auto *const core = hwloc_get_obj_by_depth(topo, core_depth, i);
if (!core || !core->cpuset) {
continue;
}
Expand All @@ -202,7 +206,7 @@ auto enumerate_physical_cores() -> std::vector<PhysicalCore> {
* receive their own singleton domain so the placement algorithm can still spread across
* whatever structure the topology does have. */
int domain;
const hwloc_obj_t l3 = hwloc_get_ancestor_obj_by_type(topo, HWLOC_OBJ_L3CACHE, core);
auto *const l3 = hwloc_get_ancestor_obj_by_type(topo, HWLOC_OBJ_L3CACHE, core);
if (l3) {
const auto [it, inserted] = l3_domain_map.emplace(l3->logical_index, next_domain_id);
if (inserted) {
Expand All @@ -214,7 +218,7 @@ auto enumerate_physical_cores() -> std::vector<PhysicalCore> {
domain = next_domain_id++;
}

cores.push_back(PhysicalCore{rep, domain});
cores.push_back(PhysicalCore{.cpu = rep, .l3_domain = domain});
}

hwloc_bitmap_free(allowed);
Expand All @@ -228,7 +232,7 @@ auto affinity_mask_words(uint64_t *out, size_t nwords) -> bool {
return false;
}
std::fill_n(out, nwords, uint64_t{0});
const auto topo = get_topology();
auto *const topo = get_topology();
if (!topo) {
return false;
}
Expand Down Expand Up @@ -276,12 +280,72 @@ auto masks_are_pairwise_disjoint(const uint64_t *masks, size_t n, size_t words)
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");

auto summarize_masks(const uint64_t *masks, size_t n, size_t words, size_t self) -> std::optional<MaskSummary> {
if (masks == nullptr || n == 0 || words == 0 || self >= n) {
return std::nullopt;
}
auto *const row = hwloc_bitmap_alloc();
auto *const all = hwloc_bitmap_alloc();
if (row == nullptr || all == nullptr) {
hwloc_bitmap_free(row);
hwloc_bitmap_free(all);
return std::nullopt;
}
MaskSummary out;
bool ok = true;
for (size_t r = 0; r < n; ++r) {
hwloc_bitmap_zero(row);
for (size_t w = 0; w < words; ++w) {
hwloc_bitmap_set_ith_ulong(row, static_cast<unsigned>(w), masks[(r * words) + w]);
}
const int weight = hwloc_bitmap_weight(row);
if (weight <= 0) { // an all-zero row is a mask that did not fit the exchange window
ok = false;
break;
}
if (r == self) {
out.cpus = static_cast<size_t>(weight);
}
hwloc_bitmap_or(all, all, row);
}
if (ok) {
out.node_cpus = static_cast<size_t>(hwloc_bitmap_weight(all));
std::array<char, 512> text{};
const int need = hwloc_bitmap_list_snprintf(text.data(), text.size(), all);
out.cpu_list = text.data();
// Truncation is stated, never silent: a cut list read as complete is a smaller machine. Cut
// back to the last whole range first, so the marker never follows a half-written CPU id.
if (std::cmp_greater_equal(need, text.size())) {
const size_t last = out.cpu_list.rfind(',');
out.cpu_list.resize(last == std::string::npos ? 0 : last + 1);
out.cpu_list += "+";
}
}
hwloc_bitmap_free(row);
hwloc_bitmap_free(all);
return ok ? std::optional{out} : std::nullopt;
}

auto format_place_line(int mpi_rank, int node_rank, int node_size, const char *verdict, const MaskSummary &summary)
-> std::string {
return std::format("COMMPLACE rank={} node_rank={} node_size={} masks={} cpus={} node_cpus={} cpu_list={}\n",
mpi_rank,
node_rank,
node_size,
verdict,
summary.cpus,
summary.node_cpus,
summary.cpu_list);
}

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

auto partition_cpusets(size_t n, size_t group_index, size_t group_count, NodeMask mask) -> std::vector<CpuSet> {
if (!config::get().partition_pinning) {
return {};
}
const auto cores = enumerate_physical_cores();

// A private mask IS this rank's share: the launcher already separated co-located ranks.
Expand Down Expand Up @@ -317,7 +381,7 @@ auto pin_this_thread(const CpuSet &set) -> void {
if (set.pu < 0) {
return;
}
const auto topo = get_topology();
auto *const topo = get_topology();
if (!topo) {
return;
}
Expand Down
43 changes: 35 additions & 8 deletions cpp/monoprop/detail/partition/CpuTopology.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,18 @@
* Policy: one partition per physical core, spread across L3/CCX domains, each worker thread pinned
* to its representative PU. Falls back to unpinned execution when hwloc cannot load the topology or
* when binding is unsupported β€” pinning is a performance optimisation, not a correctness requirement.
* Pinning has no runtime knob: leaving placement to the launcher measured propagate[hubbard] 2.90x slower.
*/

#pragma once

#include <climits>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>

#include "monoprop/detail/EnvConfig.h"

namespace monoprop::detail::partition {

/*!
Expand Down Expand Up @@ -83,10 +84,6 @@ auto placement_order(const std::vector<PhysicalCore> &cores, size_t n, size_t gr
*
* @returns Vector of PhysicalCore in hwloc logical-core order, or empty when hwloc cannot load
* the topology or when no core passes the affinity filter.
*
* @note This function deliberately ignores @c monoprop_PARTITION_PINNING so that the auto
* partition-count heuristic (one partition per physical core) works even when pinning is
* disabled by the user.
*/
auto enumerate_physical_cores() -> std::vector<PhysicalCore>;

Expand All @@ -104,6 +101,36 @@ auto affinity_mask_words(uint64_t *out, size_t nwords) -> bool;
*/
[[nodiscard]] auto masks_are_pairwise_disjoint(const uint64_t *masks, size_t n, size_t words) -> bool;

//! What one host's exchanged affinity masks add up to. The union says what the JOB got, not what one rank got.
struct MaskSummary {
size_t cpus = 0; //!< CPUs in our own mask
size_t node_cpus = 0; //!< CPUs in the union over the host
std::string cpu_list = "none"; //!< that union as ascending ranges, "0-15,64-79"
};

/*! @brief Summarize the @p n masks of @p words words laid end to end in @p masks, row @p self being ours.
* @returns nullopt for a null/empty argument or any all-zero row: a mask that did not fit the exchange
* window cannot be summed with the others. Diagnostic only; nothing branches on the result.
*/
[[nodiscard]] auto summarize_masks(const uint64_t *masks, size_t n, size_t words, size_t self)
-> std::optional<MaskSummary>;

/* COMMPLACE: a rank seeing 16 of a host's 128 CPUs is equally "my own 16" and "eight of us share
* these 16", and only the co-located ranks' masks separate them, so the exchange PartitionGroup
* already runs to place also reports. Report-only, unconditional; nothing branches on the line. */

/*! @brief One newline-terminated COMMPLACE line naming what the launcher handed this rank.
* @param verdict how the co-located masks relate: "private" (pairwise disjoint), "shared" (two ranks
* can land on one CPU), "alone" (the only rank on its host, which is NOT "private"), or
* "unknown" (a mask did not fit the exchanged window, so no verdict is sound).
* Returned, not written, so the formatting is testable without a live rank.
*/
[[nodiscard]] auto format_place_line(int mpi_rank,
int node_rank,
int node_size,
const char *verdict,
const MaskSummary &summary) -> 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 @@ -119,8 +146,8 @@ enum class NodeMask { Shared, PerRank };
* @param group_count Total number of co-located ranks on the host.
* @param mask NodeMask::PerRank only when the co-located ranks' affinity masks have been measured
* pairwise DISJOINT (PartitionGroup::classify_node_masks_), so this mask is our share.
* @returns Vector of @p n CpuSet tokens, or empty when @c monoprop_PARTITION_PINNING is disabled,
* hwloc is unavailable, or fewer than @p group_count x @p n cores are visible (@p n under PerRank).
* @returns Vector of @p n CpuSet tokens, or empty when hwloc is unavailable or fewer than
* @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.
*/
Expand Down
29 changes: 29 additions & 0 deletions cpp/monoprop/detail/partition/PartitionGroup.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <exception>
#include <functional>
#include <memory>
Expand Down Expand Up @@ -168,15 +169,18 @@ class PartitionGroup {
MPI_Comm_size(node, &node_size_);
classify_node_masks_(node);
MPI_Comm_free(&node);
return;
}
#endif
report_placement_(nullptr, 0, "alone");
}

#ifdef monoprop_ENABLE_MPI
// A rank seeing 16 of 128 CPUs is equally "my own 16" and "eight of us share these 16": only the masks tell.
auto classify_node_masks_(MPI_Comm node) -> void {
node_mask_ = NodeMask::Shared;
if (node_size_ <= 1) {
report_placement_(nullptr, 0, "alone");
return; // nobody to collide with; the normal split already handles group_count == 1
}
constexpr size_t kMaskWords = monoprop::detail::partition::kAffinityMaskWords;
Expand All @@ -189,9 +193,34 @@ class PartitionGroup {
static_cast<size_t>(node_size_),
kMaskWords);
node_mask_ = disjoint ? NodeMask::PerRank : NodeMask::Shared;
report_placement_(all.data(), static_cast<size_t>(node_size_), disjoint ? "private" : "shared");
}
#endif

/* COMMPLACE only, over the array MPI_Allgather already filled: no extra collective, and no
* reduction either, since every rank reads the same rows and so reaches the same verdict. `masks`
* nullptr means no peers, so measure our own mask; the verdict is then "alone", which is NOT
* evidence that a multi-rank launcher did the right thing. Reached only from the primary ctor, so
* a clone does not re-emit -- the mask belongs to the process, not the object. */
auto report_placement_(const uint64_t *masks, size_t peers, const char *verdict) -> void {
constexpr size_t kWords = monoprop::detail::partition::kAffinityMaskWords;
std::array<uint64_t, kWords> own{};
if (masks == nullptr && affinity_mask_words(own.data(), kWords)) {
masks = own.data();
peers = 1;
}
// No summary is "unknown" rather than a plausible zero: some mask did not fit the window.
const auto sum = summarize_masks(masks, peers, kWords, static_cast<size_t>(node_rank_));
std::fputs(format_place_line(mpi::rank(parent_),
node_rank_,
node_size_,
sum ? verdict : "unknown",
sum.value_or(MaskSummary{}))
.c_str(),
stderr);
std::fflush(stderr);
}

auto make_transport_() -> void {
#ifdef monoprop_ENABLE_MPI
if (parent_.kind == mpi::Comm::Kind::Mpi && mpi::size(parent_) > 1) {
Expand Down
Loading
Loading