From 67238ad2275ca767bcf024cc8fd1d2ee17e0ac0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Wed, 5 Aug 2026 12:33:01 +0000 Subject: [PATCH 1/7] =?UTF-8?q?refactor:=20=F0=9F=A7=B9=20use=20hwloc=20in?= =?UTF-8?q?stead=20of=20custom=20topology=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: GitHub Copilot: gpt-5.6-sol (plan), claude-haiku-4.5/claude-sonnet-4.6 (execute) --- .devcontainer/Dockerfile | 1 + .github/workflows/copilot-setup-steps.yml | 2 +- .github/workflows/docpages.yml | 2 +- .github/workflows/qa-analysis.yml | 6 +- .github/workflows/test.yml | 4 +- AGENTS.md | 1 + cpp/monoprop/CMakeLists.txt | 11 + cpp/monoprop/detail/partition/CpuTopology.cpp | 239 +++++++++++++----- cpp/monoprop/detail/partition/CpuTopology.h | 186 ++++++-------- cpp/tests/CMakeLists.txt | 1 + cpp/tests/cpu_topology_tests.cpp | 180 +++++++------ docs/content/docs/features/parallelism.mdx | 2 +- pyproject.toml | 2 +- tools/install-deps.sh | 64 ++++- 14 files changed, 440 insertions(+), 261 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ebe3345d..b89ec823 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -20,6 +20,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ openmpi-bin \ libboost-dev \ libboost-test-dev \ + libhwloc-dev \ libmsgpack-cxx-dev \ libopenmpi-dev \ && apt-get autoremove -y \ diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 74da2ab9..30925853 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -29,7 +29,7 @@ jobs: - name: Install dependencies from APT run: | sudo apt-get update - sudo apt-get install -y just libopenmpi-dev openmpi-bin libboost-dev libboost-test-dev libmsgpack-cxx-dev + sudo apt-get install -y just libopenmpi-dev openmpi-bin libboost-dev libboost-test-dev libmsgpack-cxx-dev libhwloc-dev - name: Install the latest version of uv uses: astral-sh/setup-uv@v9.0.0 diff --git a/.github/workflows/docpages.yml b/.github/workflows/docpages.yml index 307a7f60..99996567 100644 --- a/.github/workflows/docpages.yml +++ b/.github/workflows/docpages.yml @@ -52,7 +52,7 @@ jobs: - name: Install dependencies from APT run: | sudo apt-get update - sudo apt-get install -y just libopenmpi-dev openmpi-bin libboost-dev libboost-test-dev libmsgpack-cxx-dev + sudo apt-get install -y just libopenmpi-dev openmpi-bin libboost-dev libboost-test-dev libmsgpack-cxx-dev libhwloc-dev - name: Install the latest version of uv uses: astral-sh/setup-uv@v9.0.0 diff --git a/.github/workflows/qa-analysis.yml b/.github/workflows/qa-analysis.yml index c88e5c3e..2f581c96 100644 --- a/.github/workflows/qa-analysis.yml +++ b/.github/workflows/qa-analysis.yml @@ -70,7 +70,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y libopenmpi-dev openmpi-bin libboost-dev libboost-test-dev libmsgpack-cxx-dev + sudo apt-get install -y libopenmpi-dev openmpi-bin libboost-dev libboost-test-dev libmsgpack-cxx-dev libhwloc-dev - name: Install the latest version of uv uses: astral-sh/setup-uv@v9.0.0 @@ -157,7 +157,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y libopenmpi-dev openmpi-bin libboost-dev + sudo apt-get install -y libopenmpi-dev openmpi-bin libboost-dev libhwloc-dev - name: Install the latest version of uv uses: astral-sh/setup-uv@v9.0.0 @@ -205,7 +205,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y libopenmpi-dev openmpi-bin libboost-dev + sudo apt-get install -y libopenmpi-dev openmpi-bin libboost-dev libhwloc-dev - name: Install the latest version of uv uses: astral-sh/setup-uv@v9.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3a4b353..18f4cde2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,10 +66,10 @@ jobs: - name: Install dependencies run: | if [[ "${{ matrix.runner }}" == "macos-15" ]]; then - brew install boost open-mpi msgpack-cxx + brew install boost open-mpi msgpack-cxx hwloc else sudo apt-get update - packages="libopenmpi-dev openmpi-bin libboost-dev libboost-test-dev libmsgpack-cxx-dev" + packages="libopenmpi-dev openmpi-bin libboost-dev libboost-test-dev libmsgpack-cxx-dev libhwloc-dev" if [[ "${{ matrix.compiler }}" == "clang++-18" ]]; then packages="$packages clang-18" fi diff --git a/AGENTS.md b/AGENTS.md index 2d1d5e0d..f35b43b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,7 @@ mp = MajoranaPropagator(operator, initial_state, cutoff=4) - **uv**: Package management - **Boost**: Used for various utilities (unordered_map, unit tests) - **msgpack**: Serialization of the test-data fixtures only (`tests/data/*.msgpack`); consumed by the Python test loaders and the C++ test suite, not by the shipped library +- **hwloc**: CPU topology discovery and thread binding for partition placement (`CpuTopology.cpp`). Required system library (`libhwloc-dev` on Debian/Ubuntu, `hwloc` on Homebrew). Bundled into wheels automatically by auditwheel/delocate. - **MPI**: For distributed parallelization ## Common Tasks diff --git a/cpp/monoprop/CMakeLists.txt b/cpp/monoprop/CMakeLists.txt index 34eca4ab..b18c8b33 100644 --- a/cpp/monoprop/CMakeLists.txt +++ b/cpp/monoprop/CMakeLists.txt @@ -1,6 +1,13 @@ find_package(Boost 1.85 CONFIG REQUIRED) message(STATUS "Using Boost: ${Boost_DIR} (version ${Boost_VERSION})") +find_package(PkgConfig REQUIRED QUIET) +pkg_check_modules(HWLOC REQUIRED QUIET IMPORTED_TARGET GLOBAL "hwloc>=2.9") +message( + STATUS + "Using hwloc: ${HWLOC_LINK_LIBRARIES} (version ${HWLOC_VERSION})" +) + target_sources( monoprop-objs PRIVATE @@ -43,6 +50,8 @@ target_link_libraries( Boost::boost Threads::Threads $<$:MPI::MPI_CXX> + PRIVATE + PkgConfig::HWLOC ) set_target_properties( @@ -97,6 +106,8 @@ target_link_libraries( Boost::boost Threads::Threads $<$:MPI::MPI_CXX> + PRIVATE + PkgConfig::HWLOC ) add_subdirectory(algebra) diff --git a/cpp/monoprop/detail/partition/CpuTopology.cpp b/cpp/monoprop/detail/partition/CpuTopology.cpp index 5be85e90..2b70c96e 100644 --- a/cpp/monoprop/detail/partition/CpuTopology.cpp +++ b/cpp/monoprop/detail/partition/CpuTopology.cpp @@ -14,90 +14,96 @@ #include "monoprop/detail/partition/CpuTopology.h" -#if defined(__linux__) +#include -#include #include +#include +#include namespace monoprop::detail::partition { -auto enumerate_physical_cores() -> std::vector { - const std::set allowed = topo_detail::allowed_cpus(); - const bool filter = !allowed.empty(); // no mask readable ⇒ accept every CPU - const auto is_allowed = [&](int cpu) { return !filter || allowed.contains(cpu); }; +namespace { - std::vector cores; - std::set seen_cores; // sibling-group key (min sibling) already recorded - std::vector> l3_members; // cpu-set per distinct L3 domain, in discovery order - - // Scan a bounded id range rather than stopping at the first gap: online CPU ids are not contiguous - // (offlined or hot-plugged CPUs leave holes), and breaking on the first unreadable id truncates the - // core list to whatever preceded the hole, silently under-partitioning and crowding the low CPUs. - const int scan_limit = filter ? *allowed.rbegin() + 1 : CPU_SETSIZE; - for (int cpu = 0; cpu < scan_limit; ++cpu) { - const std::string base = "/sys/devices/system/cpu/cpu" + std::to_string(cpu); - const std::string sib = topo_detail::read_line(base + "/topology/thread_siblings_list"); - if (sib.empty()) { - continue; - } - const auto siblings = topo_detail::parse_cpulist(sib); - const int group_key = siblings.empty() ? cpu : *std::min_element(siblings.begin(), siblings.end()); - if (seen_cores.contains(group_key)) { - continue; - } - seen_cores.insert(group_key); +/* ── Process-lifetime hwloc topology ──────────────────────────────────────── */ - int rep = -1; - if (siblings.empty()) { - rep = is_allowed(cpu) ? cpu : -1; - } - else { - for (int s : siblings) { // parse_cpulist yields ascending order - if (is_allowed(s)) { - rep = s; - break; - } - } +// hwloc_topology_t is safe for concurrent read-only access after hwloc_topology_load(). +struct TopologyHolder { + hwloc_topology_t topo = nullptr; + + TopologyHolder() noexcept { + if (hwloc_topology_init(&topo) < 0) { + topo = nullptr; + return; } - if (rep < 0) { - continue; + /* Keep all L3 cache objects so shared-L3 domains can always be identified, even on + * topologies where the L3 appears private and would otherwise be suppressed by the + * default HWLOC_TYPE_FILTER_KEEP_STRUCTURE filter. */ + hwloc_topology_set_type_filter(topo, HWLOC_OBJ_L3CACHE, HWLOC_TYPE_FILTER_KEEP_ALL); + if (hwloc_topology_load(topo) < 0) { + hwloc_topology_destroy(topo); + topo = nullptr; } + } - const auto l3 = topo_detail::parse_cpulist(topo_detail::read_line(base + "/cache/index3/shared_cpu_list")); - int domain = -1; - for (size_t d = 0; d < l3_members.size(); ++d) { - if (std::find(l3_members[d].begin(), l3_members[d].end(), group_key) != l3_members[d].end()) { - domain = static_cast(d); - break; - } - } - if (domain < 0) { - domain = static_cast(l3_members.size()); - l3_members.push_back(l3.empty() ? std::vector{group_key} : l3); + ~TopologyHolder() { + if (topo) { + hwloc_topology_destroy(topo); } - cores.push_back(PhysicalCore{rep, domain}); } - return cores; + + TopologyHolder(const TopologyHolder &) = delete; + auto operator=(const TopologyHolder &) -> TopologyHolder & = delete; +}; + +// Returns the loaded topology, or nullptr when initialization failed. +// The static local is initialized once; subsequent calls return the cached handle. +auto get_topology() -> hwloc_topology_t { + static TopologyHolder holder; + return holder.topo; } -auto partition_cpusets(size_t n, size_t group_index, size_t group_count) -> std::vector { - if (!config::get().partition_pinning) { - return {}; +/* ── 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. +auto effective_allowed_cpuset(hwloc_topology_t topo) -> hwloc_cpuset_t { + hwloc_cpuset_t set = hwloc_bitmap_alloc(); + if (!set) { + return nullptr; } - const auto cores = enumerate_physical_cores(); + if (hwloc_get_cpubind(topo, set, HWLOC_CPUBIND_THREAD) == 0) { + return set; + } + /* cpubind query not supported (e.g. macOS without OS X binding): fall back. */ + hwloc_bitmap_free(set); + return hwloc_bitmap_dup(hwloc_topology_get_allowed_cpuset(topo)); +} + +} // anonymous namespace + +/* ── topo_detail::placement_order ─────────────────────────────────────────── */ + +namespace topo_detail { + +auto placement_order(const std::vector &cores, size_t n, size_t group_index, size_t group_count) + -> std::vector { if (cores.empty() || group_count * n > cores.size()) { return {}; } + int max_domain = 0; for (const auto &c : cores) { max_domain = std::max(max_domain, c.l3_domain); } - // Ordering: interleaved for a lone process, contiguous blocks for co-located ranks. + + /* Bucket representative PU indices by L3 domain id. */ std::vector> by_domain(static_cast(max_domain) + 1); for (const auto &c : cores) { by_domain[static_cast(c.l3_domain)].push_back(c.cpu); } - // Interleave `buckets` depth-first: bucket0[0], bucket1[0], …, bucket0[1], bucket1[1], … + + /* Interleave buckets depth-first: b0[0], b1[0], …, b0[1], b1[1], … */ const auto interleave = [](const std::vector> &buckets) { std::vector out; for (size_t depth = 0;; ++depth) { @@ -117,7 +123,7 @@ auto partition_cpusets(size_t n, size_t group_index, size_t group_count) -> std: std::vector order; size_t offset = 0; if (group_count <= by_domain.size()) { - // Domains dealt to this rank: group_index, +group_count, … (group_count == 1 ⇒ all of them). + /* Interleave arm: deal domains round-robin across ranks. */ std::vector> mine; for (size_t d = group_index; d < by_domain.size(); d += group_count) { mine.push_back(by_domain[d]); @@ -125,28 +131,127 @@ auto partition_cpusets(size_t n, size_t group_index, size_t group_count) -> std: order = interleave(mine); } else { - // More co-located ranks than L3 domains: flat domain-major order, one contiguous slice each. + /* More co-located ranks than L3 domains: flat domain-major order, one contiguous slice each. */ for (const auto &bucket : by_domain) { order.insert(order.end(), bucket.begin(), bucket.end()); } offset = group_index * n; } + if (offset + n > order.size()) { return {}; } + return std::vector(order.begin() + static_cast(offset), + order.begin() + static_cast(offset + n)); +} + +} // namespace topo_detail + +/* ── enumerate_physical_cores ──────────────────────────────────────────────── */ + +auto enumerate_physical_cores() -> std::vector { + const auto topo = get_topology(); + if (!topo) { + return {}; + } - std::vector sets(n); - for (size_t i = 0; i < n; ++i) { - CPU_ZERO(&sets[i]); - CPU_SET(order[offset + i], &sets[i]); + const hwloc_cpuset_t allowed = effective_allowed_cpuset(topo); + if (!allowed) { + return {}; + } + + const int core_depth = hwloc_get_type_depth(topo, HWLOC_OBJ_CORE); + if (core_depth == HWLOC_TYPE_DEPTH_UNKNOWN || core_depth == HWLOC_TYPE_DEPTH_MULTIPLE) { + hwloc_bitmap_free(allowed); + return {}; + } + + std::vector cores; + std::map l3_domain_map; // l3->logical_index → domain id + int next_domain_id = 0; + + 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); + if (!core || !core->cpuset) { + continue; + } + + /* Skip cores that have no PU in the calling thread's effective allowed mask. */ + if (!hwloc_bitmap_intersects(core->cpuset, allowed)) { + continue; + } + + /* Lowest allowed PU on this core is the representative OS index. */ + hwloc_cpuset_t core_allowed = hwloc_bitmap_alloc(); + if (!core_allowed) { + continue; + } + hwloc_bitmap_and(core_allowed, core->cpuset, allowed); + const int rep = hwloc_bitmap_first(core_allowed); + hwloc_bitmap_free(core_allowed); + if (rep < 0) { + continue; + } + + /* Find the L3 cache ancestor and assign a stable domain id. Cores sharing an L3 object + * (same logical_index) receive the same domain id. Cores without an L3 ancestor each + * 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); + if (l3) { + const auto [it, inserted] = l3_domain_map.emplace(l3->logical_index, next_domain_id); + if (inserted) { + ++next_domain_id; + } + domain = it->second; + } + else { + domain = next_domain_id++; + } + + cores.push_back(PhysicalCore{rep, domain}); + } + + hwloc_bitmap_free(allowed); + return cores; +} + +/* ── partition_cpusets ─────────────────────────────────────────────────────── */ + +auto partition_cpusets(size_t n, size_t group_index, size_t group_count) -> std::vector { + if (!config::get().partition_pinning) { + return {}; + } + const auto cores = enumerate_physical_cores(); + const auto order = topo_detail::placement_order(cores, n, group_index, group_count); + + std::vector sets(order.size()); + for (size_t i = 0; i < order.size(); ++i) { + sets[i] = CpuSet{order[i]}; } return sets; } +/* ── pin_this_thread ───────────────────────────────────────────────────────── */ + auto pin_this_thread(const CpuSet &set) -> void { - pthread_setaffinity_np(pthread_self(), sizeof(CpuSet), &set); + if (set.pu < 0) { + return; + } + const auto topo = get_topology(); + if (!topo) { + return; + } + hwloc_cpuset_t cpuset = hwloc_bitmap_alloc(); + if (!cpuset) { + return; + } + hwloc_bitmap_only(cpuset, static_cast(set.pu)); + /* Errors are intentionally ignored: pinning is performance-only, not a correctness requirement. */ + hwloc_set_cpubind(topo, cpuset, HWLOC_CPUBIND_THREAD | HWLOC_CPUBIND_STRICT); + hwloc_bitmap_free(cpuset); } } // namespace monoprop::detail::partition - -#endif // __linux__ diff --git a/cpp/monoprop/detail/partition/CpuTopology.h b/cpp/monoprop/detail/partition/CpuTopology.h index e5820194..1b60359e 100644 --- a/cpp/monoprop/detail/partition/CpuTopology.h +++ b/cpp/monoprop/detail/partition/CpuTopology.h @@ -12,6 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. +/*! + * @file CpuTopology.h + * @brief CPU-topology helpers for partition placement. + * + * Uses hwloc for cross-platform topology discovery and thread binding. + * 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. + */ + #pragma once #include @@ -19,122 +29,90 @@ #include "monoprop/detail/EnvConfig.h" -#if defined(__linux__) -#include -#include -#include -#include -#include -#elif defined(__APPLE__) -#include -#endif - -// CPU-topology helpers for partition placement (the one platform-specific file). Policy: one partition per -// physical core, spread across L3/ccx domains. The Linux fast path parses /sys and pins each master, -// intersected with the process's allowed-CPU mask; elsewhere partitions run unpinned (still correct, no -// locality win). - namespace monoprop::detail::partition { +/*! + * @brief One physical CPU core the process may use, tagged with its L3 cache domain. + * + * Produced by enumerate_physical_cores(). The representative PU is the lowest OS index in the + * calling thread's allowed affinity mask for the core, so a cgroup or launcher-imposed restriction + * is always respected. + */ struct PhysicalCore { - int cpu = 0; // representative hardware thread (an allowed SMT sibling of the core) - int l3_domain = 0; + int cpu = 0; //!< OS index of the representative PU (lowest allowed SMT sibling of the core). + int l3_domain = 0; //!< Sequential L3-cache domain id assigned by enumerate_physical_cores(). }; -#if defined(__linux__) - -using CpuSet = cpu_set_t; +/*! + * @brief Lightweight placement token: identifies the single PU a partition worker thread is pinned to. + */ +struct CpuSet { + int pu = -1; //!< OS PU index. -1 ⇒ invalid / not placed. +}; namespace topo_detail { - -// Parse a Linux cpulist ("0-3,16-19") into the set of CPU ids it names. -inline auto parse_cpulist(const std::string &text) -> std::vector { - std::vector out; - std::stringstream ss(text); - std::string tok; - while (std::getline(ss, tok, ',')) { - const auto dash = tok.find('-'); - if (dash == std::string::npos) { - if (!tok.empty()) { - out.push_back(std::stoi(tok)); - } - } - else { - const int lo = std::stoi(tok.substr(0, dash)); - const int hi = std::stoi(tok.substr(dash + 1)); - for (int c = lo; c <= hi; ++c) { - out.push_back(c); - } - } - } - return out; -} - -inline auto read_line(const std::string &path) -> std::string { - std::ifstream f(path); - std::string line; - if (f) { - std::getline(f, line); - } - return line; -} - -// The CPUs this process is allowed to run on (the cgroup / cpuset the launcher gave us). Empty ⇒ the -// query failed; callers then treat every CPU as allowed. -inline auto allowed_cpus() -> std::set { - std::set allowed; - cpu_set_t mask; - CPU_ZERO(&mask); - if (sched_getaffinity(0, sizeof(mask), &mask) == 0) { - for (int cpu = 0; cpu < CPU_SETSIZE; ++cpu) { - if (CPU_ISSET(cpu, &mask)) { - allowed.insert(cpu); - } - } - } - return allowed; -} - +/*! + * @brief Apply the L3-domain interleaving placement policy to a synthetic core list. + * + * Factored out of partition_cpusets() so the scheduling logic can be exercised without depending + * on hwloc or live hardware. Cores are bucketed by their l3_domain, then interleaved depth-first + * across the domains dealt to this rank. When there are more co-located ranks than L3 domains the + * algorithm falls back to flat domain-major order with one contiguous slice per rank. + * + * @param cores Physical cores available to allocate from, with L3 domain tags. + * @param n Number of partitions (PUs) requested for this rank. + * @param group_index This rank's 0-based index among the co-located ranks on the host. + * @param group_count Total number of co-located ranks on the host. + * @returns Ordered PU OS indices of length @p n for the slice assigned to this rank, + * or empty when the request cannot be filled (empty core list, oversubscription, + * or computed offset out of range). + */ +auto placement_order(const std::vector &cores, size_t n, size_t group_index, size_t group_count) + -> std::vector; } // namespace topo_detail -// Enumerate physical cores (one per smt sibling group) the process is allowed to use, tagged with their -// L3 domain. A core is included iff a sibling is in the allowed mask, with the smallest allowed sibling -// as representative, so a partial allocation never pins outside the mask. Empty if /sys cannot be read. +/*! + * @brief Enumerate physical cores (one per SMT sibling group) the process may use. + * + * Queries the calling thread's CPU affinity via hwloc to respect any cgroup or launcher-imposed + * restriction. For each core whose cpuset intersects that affinity mask, the lowest matching OS + * index is recorded as the representative PU and the core is labelled with the sequential id of + * its nearest L3 cache ancestor (or a unique singleton id when no L3 object is present). + * + * @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; -// Build `n` partition cpusets, one physical core each. `group_index`/`group_count` place one MPI rank's -// partitions among the ranks sharing this host, spread across L3 domains and disjoint from the other ranks' -// — two ranks must never share a core (one rank's busy-polling collectives would starve the other's -// barrier spins). Empty (⇒ unpinned) if the host lacks group_count*n cores. +/*! + * @brief Build placement tokens for one MPI rank's partitions. + * + * Calls enumerate_physical_cores() and applies topo_detail::placement_order() to select @p n + * distinct physical cores for this rank. Two co-located ranks must never share a core: one rank's + * busy-polling collectives would starve the other's barrier spins. + * + * @param n Number of partitions to place. + * @param group_index This rank's 0-based index among the co-located ranks on the host. + * @param group_count Total number of co-located ranks on the host. + * @returns Vector of @p n CpuSet tokens, or empty when @c monoprop_PARTITION_PINNING is disabled, + * hwloc is unavailable, or the host cannot provide @p group_count × @p n distinct cores. + */ auto partition_cpusets(size_t n, size_t group_index = 0, size_t group_count = 1) -> std::vector; -// A failing pthread call is ignored: only performance depends on it. +/*! + * @brief Bind the calling thread to the PU identified by @p set. + * + * Allocates a temporary hwloc bitmap, sets the single bit for @c set.pu, and calls + * @c hwloc_set_cpubind with @c HWLOC_CPUBIND_THREAD | @c HWLOC_CPUBIND_STRICT. The call is + * best-effort: hwloc errors are silently ignored because only performance, not correctness, + * depends on successful pinning. + * + * @param set Placement token as returned by partition_cpusets(). A token with @c pu == -1 + * is a no-op. + */ auto pin_this_thread(const CpuSet &set) -> void; - -#else // portable fallback: no topology, no pinning - -// A placeholder cpuset type so PartitionGroup's member/signatures are platform-independent. -struct CpuSet {}; - -// No /sys to parse. macOS reports its physical-core count so the partition-count policy stays accurate -// (threads still can't be pinned); other platforms return empty ⇒ hardware_concurrency()/2. -inline auto enumerate_physical_cores() -> std::vector { -#if defined(__APPLE__) - int n = 0; - size_t sz = sizeof(n); - if (sysctlbyname("hw.physicalcpu", &n, &sz, nullptr, 0) == 0 && n > 0) { - return std::vector(static_cast(n)); - } -#endif - return {}; -} - -inline auto partition_cpusets(size_t /*n*/, size_t /*group_index*/ = 0, size_t /*group_count*/ = 1) - -> std::vector { - return {}; -} -inline auto pin_this_thread(const CpuSet & /*set*/) -> void {} - -#endif // __linux__ - } // namespace monoprop::detail::partition diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 450a90a3..fe5c7c0b 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -46,6 +46,7 @@ target_link_libraries( monoprop-objs Boost::unit_test_framework msgpack-cxx + PkgConfig::HWLOC ) include(${CMAKE_CURRENT_LIST_DIR}/boost-test.cmake) diff --git a/cpp/tests/cpu_topology_tests.cpp b/cpp/tests/cpu_topology_tests.cpp index 55816317..7634852c 100644 --- a/cpp/tests/cpu_topology_tests.cpp +++ b/cpp/tests/cpu_topology_tests.cpp @@ -12,40 +12,61 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Coverage of CpuTopology.h (Linux /sys parsing + affinity pinning; count-only fallback elsewhere). +// Coverage of CpuTopology (hwloc-based topology discovery + thread affinity pinning). +// +// Tests are split into two layers: +// 1. Live smoke tests — exercise enumerate_physical_cores / partition_cpusets / pin_this_thread +// on the actual host topology; these validate end-to-end hwloc integration. +// 2. Policy unit tests — call topo_detail::placement_order with synthetic PhysicalCore vectors +// so the L3-domain interleaving and MPI-rank slicing logic can be checked deterministically +// without depending on live hardware or hwloc. #include #include #include #include -#include #include +#if defined(__linux__) +#include +#endif + #include "monoprop/detail/partition/CpuTopology.h" namespace partition = monoprop::detail::partition; +using partition::topo_detail::placement_order; + +/* RAII helper: save and restore the calling thread's CPU affinity around pin_this_thread() calls + * so that CTest is not left pinned to a single PU after the test completes. */ +struct AffinityGuard { +#if defined(__linux__) + cpu_set_t saved_{}; + AffinityGuard() { sched_getaffinity(0, sizeof(saved_), &saved_); } + ~AffinityGuard() { sched_setaffinity(0, sizeof(saved_), &saved_); } +#endif +}; + +/* ── Live smoke tests ─────────────────────────────────────────────────────── */ -// The enumerate/place/pin surface exists on every platform; exercise it regardless of OS. BOOST_AUTO_TEST_CASE(cpu_topology_enumerate_and_place) { const auto cores = partition::enumerate_physical_cores(); + AffinityGuard guard; // save affinity before any potential pin const auto one = partition::partition_cpusets(/*n=*/1); BOOST_CHECK(one.size() <= 1u); if (!one.empty()) { - // A placement only comes back where the engine can pin (Linux /sys), which implies cores were - // found. Pinning itself is best-effort and no-op-safe; drive it. + // A placement only comes back when topology discovery succeeded and pinning is enabled. BOOST_CHECK(!cores.empty()); partition::pin_this_thread(one.front()); + // guard restores affinity on scope exit } -#if defined(__linux__) - // With a readable /sys and pinning enabled, a non-empty core list must yield a placement. + // When topology discovery succeeds, a non-empty core list must produce a non-empty placement. if (!cores.empty()) { BOOST_CHECK_EQUAL(one.size(), 1u); } -#endif - // Asking for more physical cores than exist disables pinning (empty), never oversubscribes. + // Oversubscription must always return empty regardless of topology state. const auto too_many = partition::partition_cpusets(/*n=*/1'000'000); BOOST_CHECK(too_many.empty()); } @@ -53,88 +74,97 @@ BOOST_AUTO_TEST_CASE(cpu_topology_enumerate_and_place) { BOOST_AUTO_TEST_CASE(cpu_topology_place_co_located_ranks) { const auto cores = partition::enumerate_physical_cores(); if (cores.size() < 2) { - return; // need at least two cores to deal one to each of two co-located ranks + return; // need at least two cores for the disjoint-placement check } - // Two co-located ranks, one partition each. Covers both placement arms -- interleave across the - // dealt domains (group_count <= #L3), and the flat domain-major slice otherwise. + + // Two co-located ranks each requesting one partition. This exercises both placement arms: + // - interleave (group_count ≤ #L3 domains) + // - domain-major slice (group_count > #L3 domains) const auto rank0 = partition::partition_cpusets(/*n=*/1, /*group_index=*/0, /*group_count=*/2); const auto rank1 = partition::partition_cpusets(/*n=*/1, /*group_index=*/1, /*group_count=*/2); - // Off Linux there is no pinning, so both come back empty (unpinned, still disjoint by the scheduler). -#if defined(__linux__) - BOOST_CHECK_EQUAL(rank0.size(), 1u); - BOOST_CHECK_EQUAL(rank1.size(), 1u); -#else - BOOST_CHECK(rank0.empty()); - BOOST_CHECK(rank1.empty()); -#endif + BOOST_REQUIRE_EQUAL(rank0.size(), 1u); + BOOST_REQUIRE_EQUAL(rank1.size(), 1u); + // The two placements must be on distinct PUs; sharing would violate the MPI no-starvation + // invariant (one rank's busy-polling collectives cannot starve the other's barrier spins). + BOOST_CHECK(rank0.front().pu != rank1.front().pu); + // Oversubscription: 2 ranks × cores.size() partitions > total physical cores. const auto past_end = partition::partition_cpusets(/*n=*/cores.size(), /*group_index=*/1, /*group_count=*/2); BOOST_CHECK(past_end.empty()); } -#if defined(__linux__) - -using partition::topo_detail::parse_cpulist; -using partition::topo_detail::read_line; - -// Enumeration must span holes in the CPU id space (offline or hot-plugged CPUs leave unreadable ids). -// Oracle: the sibling groups re-derived straight from /sys over the whole allowed range. -BOOST_AUTO_TEST_CASE(cpu_topology_enumeration_spans_gaps_in_the_id_space) { - const auto allowed = partition::topo_detail::allowed_cpus(); - if (allowed.empty()) { - return; // affinity unreadable; enumeration accepts every CPU and there is nothing to compare - } +/* ── Policy unit tests (deterministic, no hwloc or live hardware) ─────────── */ + +BOOST_AUTO_TEST_CASE(cpu_topology_policy_interleave_across_l3) { + // 4 cores across 2 L3 domains; single rank receives all. + // by_domain[0] = {0, 4}, by_domain[1] = {2, 6} + // depth-first interleave: 0, 2, 4, 6 + const std::vector cores = {{0, 0}, {2, 1}, {4, 0}, {6, 1}}; + const auto order = placement_order(cores, 4, 0, 1); + BOOST_REQUIRE_EQUAL(order.size(), 4u); + BOOST_CHECK_EQUAL(order[0], 0); + BOOST_CHECK_EQUAL(order[1], 2); + BOOST_CHECK_EQUAL(order[2], 4); + BOOST_CHECK_EQUAL(order[3], 6); +} - std::set expected_groups; // one key (min sibling) per physical core with an allowed sibling - for (int cpu = 0; cpu <= *allowed.rbegin(); ++cpu) { - const std::string sib = - read_line("/sys/devices/system/cpu/cpu" + std::to_string(cpu) + "/topology/thread_siblings_list"); - if (sib.empty()) { - continue; - } - const auto siblings = parse_cpulist(sib); - if (siblings.empty()) { - continue; - } - if (std::any_of(siblings.begin(), siblings.end(), [&](int s) { return allowed.contains(s); })) { - expected_groups.insert(*std::min_element(siblings.begin(), siblings.end())); - } - } - if (expected_groups.empty()) { - return; // /sys unreadable on this host - } +BOOST_AUTO_TEST_CASE(cpu_topology_policy_disjoint_mpi_ranks) { + // 4 cores across 2 L3 domains; 2 co-located ranks each get 1 partition. + // rank0 is dealt domain 0, rank1 is dealt domain 1 ⇒ no shared PU. + const std::vector cores = {{0, 0}, {2, 1}, {4, 0}, {6, 1}}; + const auto r0 = placement_order(cores, 1, 0, 2); + const auto r1 = placement_order(cores, 1, 1, 2); + BOOST_REQUIRE_EQUAL(r0.size(), 1u); + BOOST_REQUIRE_EQUAL(r1.size(), 1u); + BOOST_CHECK(r0.front() != r1.front()); +} - const auto cores = partition::enumerate_physical_cores(); - BOOST_CHECK_EQUAL(cores.size(), expected_groups.size()); - for (const auto &core : cores) { - BOOST_TEST(allowed.contains(core.cpu)); +BOOST_AUTO_TEST_CASE(cpu_topology_policy_domain_major_more_ranks_than_l3) { + // 4 cores in 1 L3 domain; 2 ranks each get 2 partitions (flat domain-major arm). + // order = [0, 2, 4, 6]; rank0 offset=0 → {0,2}, rank1 offset=2 → {4,6}. + const std::vector cores = {{0, 0}, {2, 0}, {4, 0}, {6, 0}}; + const auto r0 = placement_order(cores, 2, 0, 2); + const auto r1 = placement_order(cores, 2, 1, 2); + BOOST_REQUIRE_EQUAL(r0.size(), 2u); + BOOST_REQUIRE_EQUAL(r1.size(), 2u); + + const std::set s0(r0.begin(), r0.end()); + const std::set s1(r1.begin(), r1.end()); + for (const auto cpu : s1) { + BOOST_CHECK(!s0.contains(cpu)); } } -BOOST_AUTO_TEST_CASE(cpu_topology_parse_cpulist_shapes) { - BOOST_TEST(parse_cpulist("5") == (std::vector{5}), boost::test_tools::per_element()); - BOOST_TEST(parse_cpulist("0-3") == (std::vector{0, 1, 2, 3}), boost::test_tools::per_element()); - BOOST_TEST(parse_cpulist("0-3,16-17") == (std::vector{0, 1, 2, 3, 16, 17}), boost::test_tools::per_element()); - BOOST_TEST(parse_cpulist("2,4,6") == (std::vector{2, 4, 6}), boost::test_tools::per_element()); +BOOST_AUTO_TEST_CASE(cpu_topology_policy_insufficient_cores_returns_empty) { + // 2 cores total; 2 ranks × 2 partitions = 4 > 2 ⇒ oversubscription. + const std::vector cores = {{0, 0}, {2, 0}}; + BOOST_CHECK(placement_order(cores, 2, 0, 2).empty()); + BOOST_CHECK(placement_order(cores, 2, 1, 2).empty()); - // Empty input and empty tokens contribute nothing (the !tok.empty() guard). - BOOST_CHECK(parse_cpulist("").empty()); - BOOST_TEST(parse_cpulist("1,,3") == (std::vector{1, 3}), boost::test_tools::per_element()); -} - -BOOST_AUTO_TEST_CASE(cpu_topology_read_line_present_and_absent) { - BOOST_CHECK(read_line("/nonexistent/monoprop/topology/does_not_exist").empty()); + // Single rank requesting more cores than exist. + BOOST_CHECK(placement_order(cores, 3, 0, 1).empty()); - // cpu0 always exists on Linux, and its thread_siblings_list is a non-empty cpulist. - const std::string line = read_line("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list"); - BOOST_CHECK(!line.empty()); - BOOST_CHECK(!parse_cpulist(line).empty()); + // Empty core list. + BOOST_CHECK(placement_order({}, 1, 0, 1).empty()); } -BOOST_AUTO_TEST_CASE(cpu_topology_allowed_cpus_nonempty_on_ci) { - // sched_getaffinity succeeds on Linux CI, so the process's allowed set is non-empty. - const auto allowed = partition::topo_detail::allowed_cpus(); - BOOST_CHECK(!allowed.empty()); +BOOST_AUTO_TEST_CASE(cpu_topology_policy_singleton_l3_domains) { + // 2 cores each in its own singleton domain (no shared L3). + // by_domain[0] = {0}, by_domain[1] = {4}; interleaved: 0, 4. + const std::vector cores = {{0, 0}, {4, 1}}; + const auto order = placement_order(cores, 2, 0, 1); + BOOST_REQUIRE_EQUAL(order.size(), 2u); + BOOST_CHECK_EQUAL(order[0], 0); + BOOST_CHECK_EQUAL(order[1], 4); } -#endif // __linux__ +BOOST_AUTO_TEST_CASE(cpu_topology_policy_uneven_domains) { + // 3 cores: 2 in domain 0, 1 in domain 1; single rank, 3 partitions. + // by_domain[0] = {0, 4}, by_domain[1] = {2}; interleaved: 0, 2, 4. + const std::vector cores = {{0, 0}, {2, 1}, {4, 0}}; + const auto order = placement_order(cores, 3, 0, 1); + BOOST_REQUIRE_EQUAL(order.size(), 3u); + BOOST_CHECK_EQUAL(order[0], 0); + BOOST_CHECK_EQUAL(order[1], 2); + BOOST_CHECK_EQUAL(order[2], 4); +} diff --git a/docs/content/docs/features/parallelism.mdx b/docs/content/docs/features/parallelism.mdx index 8ebacb7b..adb14a8c 100644 --- a/docs/content/docs/features/parallelism.mdx +++ b/docs/content/docs/features/parallelism.mdx @@ -29,7 +29,7 @@ whose partner lives in another partition are resolved through a per-gate exchang | --- | --- | --- | | `monoprop_NUM_THREADS` | one partition per physical core | Caps the number of partitions. Set it to run fewer partitions than cores. | | `monoprop_PARTITIONS` | `auto` | `auto` = one partition per core (capped by `monoprop_NUM_THREADS`); an integer `N` = exactly `N` partitions; `off` = one partition holding the whole operator. | -| `monoprop_PARTITION_PINNING` | `on` | `0`/`false`/`no` disables pinning each partition to a core. Has an effect only on Linux. | +| `monoprop_PARTITION_PINNING` | `on` | `0`/`false`/`no` disables pinning each partition to a core. Supported on platforms where hwloc can bind threads (Linux, macOS). | ```bash # Run 8 partitions instead of one-per-core: diff --git a/pyproject.toml b/pyproject.toml index 49b1c4b5..5731e903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -353,5 +353,5 @@ before-build = ["./tools/install-deps.sh --skip-boost-test --skip-msgpack"] environment = { SKBUILD_CMAKE_ARGS = "-Dmonoprop_ENABLE_ARCH_FLAGS=OFF;-Dmonoprop_ENABLE_CXX_UNIT_TESTS=OFF;-Dmonoprop_ENABLE_MPI=OFF" } [tool.cibuildwheel.macos] -before-build = "brew install boost" +before-build = "brew install boost hwloc" environment = { MACOSX_DEPLOYMENT_TARGET = "15.0", SKBUILD_CMAKE_ARGS = "-Dmonoprop_ENABLE_ARCH_FLAGS=OFF;-Dmonoprop_ENABLE_CXX_UNIT_TESTS=OFF;-Dmonoprop_ENABLE_MPI=OFF" } diff --git a/tools/install-deps.sh b/tools/install-deps.sh index bdc9be53..027c6d01 100755 --- a/tools/install-deps.sh +++ b/tools/install-deps.sh @@ -10,7 +10,7 @@ Usage: $0 [INSTALL_PREFIX] [OPTIONS] Install C++ dependencies for monoprop project. -This script can install Boost Unordered, Boost Test, and msgpack-cxx. +This script can install Boost Unordered, Boost Test, msgpack-cxx, and hwloc. Each component can be skipped with the corresponding option. The default installation prefix is /usr/local. @@ -21,6 +21,7 @@ Options: --skip-boost-unordered Skip installing Boost unordered --skip-boost-test Skip installing Boost Test library (only install unordered) --skip-msgpack Skip installing msgpack-cxx library + --skip-hwloc Skip installing hwloc library --help, -h Show this help message Examples: @@ -28,7 +29,7 @@ Examples: $0 \$HOME/Software # Install all deps to \$HOME/Software $0 --skip-boost-test # Skip Boost Test, install rest to default location $0 /opt --skip-msgpack # Install to /opt, skip msgpack - $0 --skip-boost-test --skip-msgpack # Minimal install + $0 --skip-boost-test --skip-msgpack # Minimal install (Boost unordered + hwloc) EOF } @@ -38,6 +39,7 @@ INSTALL_PREFIX="$DEFAULT_PREFIX" INSTALL_BOOST_UNORDERED=true INSTALL_BOOST_TEST=true INSTALL_MSGPACK=true +INSTALL_HWLOC=true # Parse arguments while [[ $# -gt 0 ]]; do @@ -54,6 +56,10 @@ while [[ $# -gt 0 ]]; do INSTALL_MSGPACK=false shift ;; + --skip-hwloc) + INSTALL_HWLOC=false + shift + ;; --help|-h) show_help exit 0 @@ -81,6 +87,7 @@ echo "Installing C++ dependencies to: $INSTALL_PREFIX" echo "Boost unordered: $([ "$INSTALL_BOOST_UNORDERED" = true ] && echo "YES" || echo "SKIP")" echo "Boost Test: $([ "$INSTALL_BOOST_TEST" = true ] && echo "YES" || echo "SKIP")" echo "msgpack-cxx: $([ "$INSTALL_MSGPACK" = true ] && echo "YES" || echo "SKIP")" +echo "hwloc: $([ "$INSTALL_HWLOC" = true ] && echo "YES" || echo "SKIP")" echo # Create install directory if it doesn't exist @@ -152,14 +159,58 @@ install_msgpack() { fi } -# check that we're running on Ubuntu -. /etc/os-release -echo "Detected OS: $PRETTY_NAME" -install_boost +install_hwloc() { + if [ "$INSTALL_HWLOC" != true ]; then + echo "Skipping hwloc installation" + return 0 + fi + + local hwloc_version="2.13.0" + local major_minor + major_minor="$(echo "$hwloc_version" | cut -d. -f1-2)" + local tarball="hwloc-${hwloc_version}.tar.gz" + local src_dir="hwloc-${hwloc_version}" + + echo "Installing hwloc $hwloc_version..." + curl -fsSL "https://download.open-mpi.org/release/hwloc/v${major_minor}/${tarball}" -o "$tarball" + tar xzf "$tarball" + cd "$src_dir" + + local nproc_count + nproc_count="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)" + + ./configure \ + --prefix="$INSTALL_PREFIX" \ + --enable-shared \ + --disable-static \ + --disable-doxygen \ + --disable-man-pages \ + --without-x + make -j"$nproc_count" + make install + + echo "Cleaning up $src_dir..." + cd - + rm -rf "$src_dir" "$tarball" +} + +# Detect the OS (non-fatal: macOS does not have /etc/os-release). +if [ -f /etc/os-release ]; then + . /etc/os-release + echo "Detected OS: $PRETTY_NAME" +elif command -v sw_vers &>/dev/null; then + echo "Detected OS: macOS $(sw_vers -productVersion)" +else + echo "Detected OS: unknown" +fi + +install_boost install_msgpack +install_hwloc + echo echo "Dependencies installation completed successfully!" echo "Install location: $INSTALL_PREFIX" @@ -171,3 +222,4 @@ echo "Installed components:" [ "$INSTALL_BOOST_UNORDERED" = true ] && echo " ✓ Boost unordered" || echo " ✗ Boost unordered (skipped)" [ "$INSTALL_BOOST_TEST" = true ] && echo " ✓ Boost Test" || echo " ✗ Boost Test (skipped)" [ "$INSTALL_MSGPACK" = true ] && echo " ✓ msgpack-cxx" || echo " ✗ msgpack-cxx (skipped)" +[ "$INSTALL_HWLOC" = true ] && echo " ✓ hwloc" || echo " ✗ hwloc (skipped)" From df691ce28ce4627176bbfc0f39844f85afa23d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 6 Aug 2026 14:01:36 +0000 Subject: [PATCH 2/7] build(cmake): RPATH handling, to fix the stubgen --- src/monoprop/bindings/CMakeLists.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/monoprop/bindings/CMakeLists.txt b/src/monoprop/bindings/CMakeLists.txt index e8f2a4c1..e18ede35 100644 --- a/src/monoprop/bindings/CMakeLists.txt +++ b/src/monoprop/bindings/CMakeLists.txt @@ -133,6 +133,27 @@ COMMAND_ERROR_IS_FATAL ANY )" ) +if(APPLE) + set(_rpath "@loader_path/${CMAKE_INSTALL_LIBDIR}") +else() + set(_rpath "\$ORIGIN/${CMAKE_INSTALL_LIBDIR}") +endif() + +set_target_properties( + _core + PROPERTIES + MACOSX_RPATH + ON + SKIP_BUILD_RPATH + OFF + BUILD_WITH_INSTALL_RPATH + OFF + INSTALL_RPATH + "${_rpath}" + INSTALL_RPATH_USE_LINK_PATH + ON +) + install(TARGETS _core LIBRARY DESTINATION ${PROJECT_NAME}) # generation of Python typing stubs From 4662b29333b87751a4b52e5109ef8b75706541de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Thu, 6 Aug 2026 14:02:31 +0000 Subject: [PATCH 3/7] ci(wheel): revert to before-all with before-build we re-ran the same installation script before every build. --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5731e903..925a95bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -349,9 +349,9 @@ test-groups = ["test"] test-extras = ["qiskit"] [tool.cibuildwheel.linux] -before-build = ["./tools/install-deps.sh --skip-boost-test --skip-msgpack"] +before-all = ["./tools/install-deps.sh --skip-boost-test --skip-msgpack"] environment = { SKBUILD_CMAKE_ARGS = "-Dmonoprop_ENABLE_ARCH_FLAGS=OFF;-Dmonoprop_ENABLE_CXX_UNIT_TESTS=OFF;-Dmonoprop_ENABLE_MPI=OFF" } [tool.cibuildwheel.macos] -before-build = "brew install boost hwloc" +before-all = "brew install boost hwloc" environment = { MACOSX_DEPLOYMENT_TARGET = "15.0", SKBUILD_CMAKE_ARGS = "-Dmonoprop_ENABLE_ARCH_FLAGS=OFF;-Dmonoprop_ENABLE_CXX_UNIT_TESTS=OFF;-Dmonoprop_ENABLE_MPI=OFF" } From c0fb9232342fc3970323ecbb1ded86dca8e31d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 7 Aug 2026 09:45:24 +0000 Subject: [PATCH 4/7] docs: document need for pkg-config to find hwloc --- AGENTS.md | 2 +- README.md | 2 ++ docs/content/docs/building.mdx | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f35b43b0..f4b5d178 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ mp = MajoranaPropagator(operator, initial_state, cutoff=4) - **uv**: Package management - **Boost**: Used for various utilities (unordered_map, unit tests) - **msgpack**: Serialization of the test-data fixtures only (`tests/data/*.msgpack`); consumed by the Python test loaders and the C++ test suite, not by the shipped library -- **hwloc**: CPU topology discovery and thread binding for partition placement (`CpuTopology.cpp`). Required system library (`libhwloc-dev` on Debian/Ubuntu, `hwloc` on Homebrew). Bundled into wheels automatically by auditwheel/delocate. +- **hwloc**: CPU topology discovery and thread binding for partition placement (`CpuTopology.cpp`). Required system library (`libhwloc-dev` on Debian/Ubuntu, `hwloc` on Homebrew). Requires `pkg-config` so CMake can locate `hwloc`. Bundled into wheels automatically by auditwheel/delocate. - **MPI**: For distributed parallelization ## Common Tasks diff --git a/README.md b/README.md index cb1633fd..0ff2cc1a 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ ctest --test-dir build/editable/Release Full instructions — prerequisites, MPI options, and running the example executable — are in the [building guide](https://docs.algorithmiq.fi/monoprop/docs/building). +In particular, from-source builds require `hwloc` and `pkg-config` so CMake can +locate `hwloc`. ## Running the tests diff --git a/docs/content/docs/building.mdx b/docs/content/docs/building.mdx index 98ae8395..aa0fe949 100644 --- a/docs/content/docs/building.mdx +++ b/docs/content/docs/building.mdx @@ -25,6 +25,7 @@ without MPI, so a from-source build is required for multi-rank runs. - CMake and Ninja - Python 3.11 or newer and the `uv` package manager (for the bindings) - an MPI implementation such as Open MPI (only for MPI builds) +- `hwloc` (version 2.9+) and `pkg-config` (required so CMake can locate `hwloc`) The repository ships a [DevContainer](https://containers.dev/) with all of the above pre-configured; opening the folder in VS Code and rebuilding the container is From e5deeb5e7d6d0704ad15d6cf9c5c68fdf522013f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Mon, 10 Aug 2026 20:56:18 +0200 Subject: [PATCH 5/7] chore(cmake): clean up target_link_libraries for monoprop --- cpp/monoprop/CMakeLists.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/cpp/monoprop/CMakeLists.txt b/cpp/monoprop/CMakeLists.txt index b18c8b33..06bb121c 100644 --- a/cpp/monoprop/CMakeLists.txt +++ b/cpp/monoprop/CMakeLists.txt @@ -106,8 +106,6 @@ target_link_libraries( Boost::boost Threads::Threads $<$:MPI::MPI_CXX> - PRIVATE - PkgConfig::HWLOC ) add_subdirectory(algebra) From af5af9ce3452a14bfaf2d0eb62757256edf618b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Mon, 10 Aug 2026 20:56:31 +0200 Subject: [PATCH 6/7] style(c++): re-order includes --- cpp/monoprop/detail/partition/CpuTopology.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpp/monoprop/detail/partition/CpuTopology.cpp b/cpp/monoprop/detail/partition/CpuTopology.cpp index 2b70c96e..dd019d8e 100644 --- a/cpp/monoprop/detail/partition/CpuTopology.cpp +++ b/cpp/monoprop/detail/partition/CpuTopology.cpp @@ -14,12 +14,12 @@ #include "monoprop/detail/partition/CpuTopology.h" -#include - #include #include #include +#include + namespace monoprop::detail::partition { namespace { From 36249456cfe3af5836a71755d21fa1d1a8c00be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Mon, 10 Aug 2026 21:14:19 +0200 Subject: [PATCH 7/7] chore(cmake): additional tweaking of target_link_libraries --- cpp/monoprop/CMakeLists.txt | 1 - cpp/tests/CMakeLists.txt | 1 - 2 files changed, 2 deletions(-) diff --git a/cpp/monoprop/CMakeLists.txt b/cpp/monoprop/CMakeLists.txt index 06bb121c..b34b5f47 100644 --- a/cpp/monoprop/CMakeLists.txt +++ b/cpp/monoprop/CMakeLists.txt @@ -50,7 +50,6 @@ target_link_libraries( Boost::boost Threads::Threads $<$:MPI::MPI_CXX> - PRIVATE PkgConfig::HWLOC ) diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index fe5c7c0b..450a90a3 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -46,7 +46,6 @@ target_link_libraries( monoprop-objs Boost::unit_test_framework msgpack-cxx - PkgConfig::HWLOC ) include(${CMAKE_CURRENT_LIST_DIR}/boost-test.cmake)