From 1aacd2d75a190a10db15f295bc3e95460bedd27b Mon Sep 17 00:00:00 2001 From: GagaLP Date: Tue, 14 Jul 2026 14:48:29 +0200 Subject: [PATCH] Enable preliminary thread affinity/pinning support on Windows --- CHANGELOG.md | 8 + CMakeLists.txt | 1 + include/affinity.h | 4 + include/platform_specific/affinity_win32.h | 105 ++++++++ src/platform_specific/affinity.win.cc | 264 ++++++++++++++++++++- src/platform_specific/affinity_win32.cc | 111 +++++++++ test/affinity_tests.cc | 105 +++++++- 7 files changed, 593 insertions(+), 5 deletions(-) create mode 100644 include/platform_specific/affinity_win32.h create mode 100644 src/platform_specific/affinity_win32.cc diff --git a/CHANGELOG.md b/CHANGELOG.md index 950892e64..19fc4e0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [WIP] + +This release is WIP + +### Added + +- Add preliminary support for thread affinity on Windows by emulating the POSIX interface (#338) + ## [0.7.0] - 2025-08-18 This release includes changes that may require adjustments when upgrading: diff --git a/CMakeLists.txt b/CMakeLists.txt index 5f6a4d3cc..a4c0afd99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -285,6 +285,7 @@ set(CELERITY_DETAIL_HAS_NAMED_THREADS OFF) if(WIN32) set(SOURCES ${SOURCES} src/platform_specific/affinity.win.cc) + set(SOURCES ${SOURCES} src/platform_specific/affinity_win32.cc) set(SOURCES ${SOURCES} src/platform_specific/named_threads.win.cc) set(CELERITY_DETAIL_HAS_NAMED_THREADS ON) elseif(UNIX) diff --git a/include/affinity.h b/include/affinity.h index 14f337c2e..43786297b 100644 --- a/include/affinity.h +++ b/include/affinity.h @@ -6,6 +6,10 @@ #include "named_threads.h" +#ifdef _WIN32 +#include "platform_specific/affinity_win32.h" +#endif + // The goal of this thread pinning mechanism, when enabled, is to ensure that threads which benefit from fast communication // are pinned to cores that are close to each other in terms of cache hierarchy. // It currently accomplishes this by pinning threads to cores in a round-robin fashion according to their order in the `named_threads::thread_type` enum. diff --git a/include/platform_specific/affinity_win32.h b/include/platform_specific/affinity_win32.h new file mode 100644 index 000000000..48044f9ec --- /dev/null +++ b/include/platform_specific/affinity_win32.h @@ -0,0 +1,105 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX + +#include +#include +#include +#include +#include + +#include "log.h" + +using pthread_t = DWORD; + +struct cpu_set_t { + static constexpr unsigned CPU_SETSIZE = 1024; + static constexpr unsigned WORDS = (CPU_SETSIZE + 63) / 64; + + uint64_t bits[WORDS] = {}; +}; + +inline constexpr unsigned CPU_SETSIZE = cpu_set_t::CPU_SETSIZE; + +void CPU_ZERO(cpu_set_t* set); +void CPU_SET(unsigned cpu, cpu_set_t* set); +void CPU_CLR(unsigned cpu, cpu_set_t* set); +int CPU_ISSET(unsigned cpu, const cpu_set_t* set); + +int CPU_COUNT(const cpu_set_t* set); +int CPU_EQUAL(const cpu_set_t* a, const cpu_set_t* b); + +int sched_getaffinity(int pid, size_t cpusetsize, cpu_set_t* mask); +int sched_setaffinity(int pid, size_t cpusetsize, const cpu_set_t* mask); + +pthread_t pthread_self(); + +int pthread_setaffinity_np(pthread_t thread, size_t cpusetsize, const cpu_set_t* mask); +int pthread_getaffinity_np(pthread_t thread, size_t cpusetsize, cpu_set_t* mask); + +namespace win32_pthread_detail { + +static constexpr unsigned PROCS_PER_GROUP = 64; + +struct cpu_topology_entry { + WORD group; + WORD count; +}; + +using cpu_topology = std::vector; + +struct windows_topology_policy { + static WORD get_group_count() { return GetActiveProcessorGroupCount(); } + + static unsigned get_proc_count(WORD g) { return GetActiveProcessorCount(g); } +}; + +using topology_policy = windows_topology_policy; +template +cpu_topology get_cpu_topology() { + cpu_topology topo; + + const WORD groups = Policy::get_group_count(); + + for(WORD g = 0; g < groups; ++g) { + const unsigned count = Policy::get_proc_count(g); + topo.push_back({g, (WORD)count}); + } + + return topo; +} + +template +bool cpuset_to_group_affinity(const cpu_set_t* set, GROUP_AFFINITY& out) { + const WORD groups = Policy::get_group_count(); + + bool found = false; + + for(WORD g = 0; g < groups; ++g) { + const unsigned procs = Policy::get_proc_count(g); + + KAFFINITY mask = 0; + + for(unsigned p = 0; p < procs && p < PROCS_PER_GROUP; ++p) { + const unsigned global_cpu = g * PROCS_PER_GROUP + p; + + if(CPU_ISSET(global_cpu, set)) { mask |= (KAFFINITY(1) << p); } + } + + if(mask == 0) continue; + + if(found) { + CELERITY_WARN("Affinity mask spans multiple processor groups (not supported on Windows)."); + return false; + } + + found = true; + out.Group = g; + out.Mask = mask; + } + + return found; +} + +} // namespace win32_pthread_detail diff --git a/src/platform_specific/affinity.win.cc b/src/platform_specific/affinity.win.cc index 2593ceb17..b074160bf 100644 --- a/src/platform_specific/affinity.win.cc +++ b/src/platform_specific/affinity.win.cc @@ -1,15 +1,271 @@ #include "affinity.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "platform_specific/affinity_win32.h" + #include "log.h" +#include "named_threads.h" -#include +namespace { + +using namespace celerity::detail::thread_pinning; +using namespace celerity::detail::named_threads; + +std::vector get_available_sequential_cores(const cpu_set_t& available_cores, const uint32_t count, const uint32_t starting_from_core) { + std::vector cores; + uint32_t current_core = starting_from_core; + for(uint32_t i = 0; i < count; ++i) { + // find the next sequential core we may use + while(CPU_ISSET(current_core, &available_cores) == 0 && current_core < CPU_SETSIZE) { + current_core++; + } + if(current_core >= CPU_SETSIZE) { return {}; } + cores.push_back(current_core++); + } + return cores; +} + +struct pinned_thread_state { + cpu_set_t previous_cpuset = {}; + cpu_set_t pinned_to_cpuset = {}; +}; + +struct thread_pinner_state { + std::mutex mutex; + bool initialized = false; + runtime_configuration config; + cpu_set_t available_cores = {}; + std::unordered_map thread_pinning_plan; + std::unordered_map pinned_threads; +}; +thread_pinner_state g_state; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// This helper removes a thread from the pinned_threads map when it ends +struct thread_remover { + thread_remover() = default; + thread_remover(const thread_remover&) = delete; + thread_remover& operator=(const thread_remover&) = delete; + thread_remover(thread_remover&&) = delete; + thread_remover& operator=(thread_remover&&) = delete; + ~thread_remover() { + std::lock_guard lock(g_state.mutex); + if(g_state.initialized) { g_state.pinned_threads.erase(pthread_self()); } + } +}; +thread_local std::optional t_remover; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +// Retrieves the threads that should be pinned according to the given configuration, in order +std::vector get_threads_to_pin(const runtime_configuration& cfg) { + std::vector threads_to_pin = {thread_type::application, thread_type::scheduler, thread_type::executor}; + if(cfg.use_backend_device_submission_threads) { + for(uint32_t i = 0; i < cfg.num_devices; ++i) { + threads_to_pin.push_back(task_type_device_submitter(i)); + } + } + return threads_to_pin; +} + +// Gets the list of thread types to be pinned for a given configuration as a string, for debug output +std::string get_threads_to_pin_string(const runtime_configuration& cfg) { + const auto to_pin = get_threads_to_pin(cfg); + std::vector to_pin_names; + std::transform(to_pin.begin(), to_pin.end(), std::back_inserter(to_pin_names), thread_type_to_string); + return fmt::format("[{}]", fmt::join(to_pin_names, ", ")); +} + +// Initializes the thread pinning machinery +// This captures the current thread's affinity mask and sets the thread pinning machinery up +// Calls to pin_this_thread prior to this call will have no effect +bool initialize(const runtime_configuration& cfg) { + std::lock_guard lock(g_state.mutex); + if(g_state.initialized) { + CELERITY_ERROR("Thread pinning already initialized. Ignoring this initialization attempt."); + return false; + } + assert(g_state.thread_pinning_plan.empty() && "Thread pinning plan not initially empty."); + assert(g_state.pinned_threads.empty() && "Pinned threads not initially empty."); + + g_state.config = cfg; + + const auto ret = sched_getaffinity(0, sizeof(cpu_set_t), &g_state.available_cores); + if(ret != 0) { + CELERITY_WARN("Error retrieving initial process affinity mask. Unable to check whether enough logical cores are available to this process.{}", + cfg.enabled ? " Will disable thread pinning." : ""); + g_state.config.enabled = false; + return true; + } + { // log tracing information about available cores, specifically useful to understand MPI implementation behaviour + std::string available_cores_str = {}; + for(uint32_t i = 0; i < std::min(static_cast(CPU_SETSIZE), std::thread::hardware_concurrency()); ++i) { + available_cores_str += CPU_ISSET(i, &g_state.available_cores) ? "1" : "0"; + } + CELERITY_TRACE("Affinity: Initialized, available cores: {}", available_cores_str); + } + + // pinned threads per process: application, scheduler, executor, 1 device submitter per device if enabled + uint32_t pinned_threads_per_process = 3; + if(g_state.config.use_backend_device_submission_threads) { pinned_threads_per_process += g_state.config.num_devices; } + // total number of threads to be pinned across processes (legacy mode) - we assume that each process has been assigned the same number of device + const uint32_t total_threads = pinned_threads_per_process * g_state.config.num_legacy_processes; + + if(g_state.config.enabled) { + // select the core set to use + std::vector selected_core_ids = {}; + if(!cfg.hardcoded_core_ids.empty()) { + // attempt to use the provided hardcoded IDs if they match the number of threads to be pinned + if(static_cast(cfg.hardcoded_core_ids.size()) != total_threads) { + CELERITY_WARN("Hardcoded core ID count ({}) does not match the number of threads to be pinned ({}), downgrading to auto-pinning.", + cfg.hardcoded_core_ids.size(), total_threads); + CELERITY_WARN("Expected core list for these threads: {}", get_threads_to_pin_string(cfg)); + } else { + // also check if the provided core IDs are actually available + if(!std::ranges::all_of(cfg.hardcoded_core_ids, [&](const uint32_t core_id) { return CPU_ISSET(core_id, &g_state.available_cores) != 0; })) { + CELERITY_WARN("Not all hardcoded core IDs are available, downgrading to auto-pinning."); + } else { + // if everything checks out, use the provided core IDs + selected_core_ids = cfg.hardcoded_core_ids; + } + } + } + if(selected_core_ids.empty()) { + // otherwise, sequential core assignments for now; it is most important that each of the threads is "close" + // to the ones next to it in this sequence, so that communication between them is fast + selected_core_ids = get_available_sequential_cores(g_state.available_cores, total_threads, cfg.standard_core_start_id); + if(selected_core_ids.empty()) { + CELERITY_WARN("Insufficient logical cores available for thread pinning (required {} starting from {}, {} available), disabling pinning." + " Performance may be negatively impacted.", // + total_threads, cfg.standard_core_start_id, CPU_COUNT(&g_state.available_cores)); + } + } + // build our pinning plan based on the selected core list + if(selected_core_ids.empty()) { + g_state.config.enabled = false; + } else { + uint32_t current_core_id = cfg.legacy_process_index * pinned_threads_per_process; + const auto threads_to_pin = get_threads_to_pin(cfg); + for(const auto t_type : threads_to_pin) { + g_state.thread_pinning_plan.emplace(t_type, selected_core_ids[current_core_id++]); + } + } + } else { + // when pinning is disabled, still validate that we have enough threads, warn otherwise + const auto cores_available = CPU_COUNT(&g_state.available_cores); + if(static_cast(cores_available) < total_threads) { + CELERITY_WARN("Celerity has detected that only {} logical cores are available to this process. It is recommended to assign at least {} logical " + "cores. Performance may be negatively impacted.", + cores_available, total_threads); + } else { + CELERITY_DEBUG("Thread pinning is disabled."); + } + } + + g_state.initialized = true; + return true; +} + +// Tears down the thread pinning machinery +// This restores the affinity mask of all threads that have been pinned, are still running, and had no other changes to their affinity +void teardown() { + std::lock_guard lock(g_state.mutex); + assert(g_state.initialized && "Thread pinning not initialized."); + + if(g_state.config.enabled) { + for(const auto& [thread, pinned_state] : g_state.pinned_threads) { + // first check if no one else made changes to the affinity of this thread + cpu_set_t current_cpuset; + if(pthread_getaffinity_np(thread, sizeof(cpu_set_t), ¤t_cpuset) != 0) { + CELERITY_WARN("Error retrieving thread affinity of thread {} to check for unexpected changes.", thread); + continue; + } + if(CPU_EQUAL(¤t_cpuset, &pinned_state.pinned_to_cpuset) == 0) { + CELERITY_WARN("Thread affinity of thread {} was changed unexpectedly, skipping restoration.", thread); + continue; + } + // now we can safely restore the affinity + const auto ret = pthread_setaffinity_np(thread, sizeof(cpu_set_t), &pinned_state.previous_cpuset); + if(ret != 0) { CELERITY_WARN("Error resetting thread affinity."); } + } + } + + g_state.pinned_threads.clear(); + g_state.thread_pinning_plan.clear(); + g_state.config.enabled = false; + g_state.initialized = false; +} + +} // namespace namespace celerity::detail::thread_pinning { -thread_pinner::thread_pinner(const runtime_configuration& cfg) { - if(cfg.enabled) { CELERITY_WARN("Thread pinning is currently not supported on Windows."); } +thread_pinner::thread_pinner(const runtime_configuration& cfg) : m_successfully_initialized(initialize(cfg)) {} +thread_pinner::~thread_pinner() { + if(m_successfully_initialized) { teardown(); } +} + +void pin_this_thread(const thread_type t_type) { + std::lock_guard lock(g_state.mutex); + // if thread pinning is not initialized or disabled, do nothing + if(!g_state.initialized || !g_state.config.enabled) return; + + if(!g_state.thread_pinning_plan.contains(t_type)) { + // this is fine, not all threads need to be pinned in each plan, but all should call this function for consistency and potential + // future (e.g. NUMA-aware) pinning strategies + CELERITY_TRACE("Affinity: thread '{}' is not pinned.", thread_type_to_string(t_type)); + return; + } + + assert(g_state.pinned_threads.find(pthread_self()) == g_state.pinned_threads.end() && "Trying to pin a thread which was already pinned."); + + // retrieve current thread affinity for later restoration + const auto this_thread = pthread_self(); + cpu_set_t previous_cpuset; + if(pthread_getaffinity_np(this_thread, sizeof(cpu_set_t), &previous_cpuset) != 0) { + CELERITY_WARN("Error retrieving thread affinity for thread '{}', will not pin it.", thread_type_to_string(t_type)); + return; + } + + // if the application thread was already pinned (i.e. its affinity mask is different from the process mask), we should not pin it again + if(t_type == thread_type::application && !CPU_EQUAL(&previous_cpuset, &g_state.available_cores)) { + CELERITY_WARN("Affinity mask for the application thread was modified, will not pin it."); + return; + } + + // set new affinity to designated core + const auto core = g_state.thread_pinning_plan.at(t_type); + cpu_set_t new_cpuset; + CPU_ZERO(&new_cpuset); + CPU_SET(core, &new_cpuset); + const auto ret = pthread_setaffinity_np(this_thread, sizeof(cpu_set_t), &new_cpuset); + if(ret != 0) { + CELERITY_WARN("Could not set affinity fot thread '{}'.", thread_type_to_string(t_type)); + return; + } + + // allow the affinity of this thread to be restored on teardown + { + const pinned_thread_state ps{ + .previous_cpuset = previous_cpuset, + .pinned_to_cpuset = new_cpuset, + }; + g_state.pinned_threads.emplace(this_thread, ps); + if(!t_remover.has_value()) t_remover.emplace(); + } + + CELERITY_DEBUG("Affinity: pinned thread '{}' to core {} (local process #{} thread id {:x})", // + thread_type_to_string(t_type), core, g_state.config.legacy_process_index, this_thread); } -thread_pinner::~thread_pinner() {} } // namespace celerity::detail::thread_pinning diff --git a/src/platform_specific/affinity_win32.cc b/src/platform_specific/affinity_win32.cc new file mode 100644 index 000000000..9752cde77 --- /dev/null +++ b/src/platform_specific/affinity_win32.cc @@ -0,0 +1,111 @@ +#ifdef _WIN32 +#include "platform_specific/affinity_win32.h" + +#include +#include + +#include "log.h" + +void CPU_ZERO(cpu_set_t* set) { std::memset(set->bits, 0, sizeof(set->bits)); } + +void CPU_SET(unsigned cpu, cpu_set_t* set) { + if(cpu < cpu_set_t::CPU_SETSIZE) set->bits[cpu / 64] |= (uint64_t(1) << (cpu % 64)); +} + +void CPU_CLR(unsigned cpu, cpu_set_t* set) { + if(cpu < cpu_set_t::CPU_SETSIZE) set->bits[cpu / 64] &= ~(uint64_t(1) << (cpu % 64)); +} + +int CPU_ISSET(unsigned cpu, const cpu_set_t* set) { + if(cpu >= cpu_set_t::CPU_SETSIZE) return 0; + return (set->bits[cpu / 64] & (uint64_t(1) << (cpu % 64))) != 0; +} + +int CPU_COUNT(const cpu_set_t* set) { + int n = 0; + for(unsigned i = 0; i < cpu_set_t::WORDS; ++i) + n += std::popcount(set->bits[i]); + return n; +} + +int CPU_EQUAL(const cpu_set_t* a, const cpu_set_t* b) { return std::memcmp(a->bits, b->bits, sizeof(a->bits)) == 0; } + + +namespace win32_pthread_detail {} // namespace win32_pthread_detail + +int sched_getaffinity(int, size_t, cpu_set_t* mask) { + GROUP_AFFINITY ga{}; + + if(!GetThreadGroupAffinity(GetCurrentThread(), &ga)) return -1; + + CPU_ZERO(mask); + + const unsigned base = ga.Group * win32_pthread_detail::PROCS_PER_GROUP; + + for(unsigned p = 0; p < win32_pthread_detail::PROCS_PER_GROUP; ++p) { + if(ga.Mask & (KAFFINITY(1) << p)) CPU_SET(base + p, mask); + } + + return 0; +} + +int sched_setaffinity(int, size_t, const cpu_set_t* mask) { + GROUP_AFFINITY ga{}; + + if(!win32_pthread_detail::cpuset_to_group_affinity(mask, ga)) { + CELERITY_WARN("sched_setaffinity failed (multi-group mask rejected)"); + return -1; + } + + return SetThreadGroupAffinity(GetCurrentThread(), &ga, nullptr) ? 0 : -1; +} + +pthread_t pthread_self() { return GetCurrentThreadId(); } + +int pthread_setaffinity_np(pthread_t thread, size_t, const cpu_set_t* mask) { + const bool self = (thread == GetCurrentThreadId()); + + HANDLE h = self ? GetCurrentThread() : OpenThread(THREAD_SET_INFORMATION | THREAD_QUERY_INFORMATION, FALSE, thread); + + if(!h) return -1; + + GROUP_AFFINITY ga{}; + + if(!win32_pthread_detail::cpuset_to_group_affinity(mask, ga)) { + if(!self) CloseHandle(h); + return -1; + } + + int ok = SetThreadGroupAffinity(h, &ga, nullptr) ? 0 : -1; + + if(!self) CloseHandle(h); + return ok; +} + +int pthread_getaffinity_np(pthread_t thread, size_t, cpu_set_t* mask) { + const bool self = (thread == GetCurrentThreadId()); + + HANDLE h = self ? GetCurrentThread() : OpenThread(THREAD_QUERY_INFORMATION, FALSE, thread); + + if(!h) return -1; + + GROUP_AFFINITY ga{}; + + if(!GetThreadGroupAffinity(h, &ga)) { + if(!self) CloseHandle(h); + return -1; + } + + CPU_ZERO(mask); + + const unsigned base = ga.Group * win32_pthread_detail::PROCS_PER_GROUP; + + for(unsigned p = 0; p < win32_pthread_detail::PROCS_PER_GROUP; ++p) { + if(ga.Mask & (KAFFINITY(1) << p)) CPU_SET(base + p, mask); + } + + if(!self) CloseHandle(h); + return 0; +} + +#endif diff --git a/test/affinity_tests.cc b/test/affinity_tests.cc index 5a357fb10..f64919bed 100644 --- a/test/affinity_tests.cc +++ b/test/affinity_tests.cc @@ -63,7 +63,7 @@ class raii_test_runtime { }; #ifdef _WIN32 -#define SKIP_UNSUPPORTED() SKIP("Affinity is not supported on Windows"); +#define SKIP_UNSUPPORTED() // SKIP("Affinity is not supported on Windows"); #else #define SKIP_UNSUPPORTED() #endif @@ -355,3 +355,106 @@ TEST_CASE("application threads are not pinned if their affinity mask is modified CHECK(get_current_cores() == process_mask); CHECK(test_utils::log_contains_substring(detail::log_level::warn, "Affinity mask for the application thread was modified, will not pin it.")); } + + +#ifdef _WIN32 +struct test_topology_policy { + struct group { + unsigned count; + }; + + static inline const std::vector* groups = nullptr; + + static WORD get_group_count() { return static_cast(groups ? groups->size() : 1); } + + static unsigned get_proc_count(WORD g) { return groups ? (*groups)[g].count : 0; } +}; + +struct scoped_topology { + std::vector m_groups; + + scoped_topology(std::initializer_list entries) { + for(const auto& e : entries) { + m_groups.push_back({e.count}); + } + test_topology_policy::groups = &m_groups; + } + ~scoped_topology() { test_topology_policy::groups = nullptr; } + scoped_topology(const scoped_topology&) = delete; + scoped_topology(scoped_topology&&) = delete; + scoped_topology& operator=(const scoped_topology&) = delete; + scoped_topology& operator=(scoped_topology&&) = delete; +}; + + +TEST_CASE("single group cpuset produces correct mask") { + test_utils::allow_max_log_level(detail::log_level::warn); + + scoped_topology topo({{0, 64}}); + + cpu_set_t set{}; + CPU_ZERO(&set); + CPU_SET(0, &set); + CPU_SET(1, &set); + + GROUP_AFFINITY ga{}; + REQUIRE(win32_pthread_detail::cpuset_to_group_affinity(&set, ga)); + + CHECK(ga.Group == 0); + CHECK(ga.Mask == 0b11); +} + +TEST_CASE("multi-group cpuset triggers warning") { + test_utils::allow_max_log_level(detail::log_level::warn); + + scoped_topology topo({{0, 64}, {1, 64}}); + + cpu_set_t set{}; + CPU_ZERO(&set); + CPU_SET(0, &set); // group 0 + CPU_SET(64, &set); // group 1 + + GROUP_AFFINITY ga{}; + CHECK_FALSE(win32_pthread_detail::cpuset_to_group_affinity(&set, ga)); + CHECK(test_utils::log_contains_substring(detail::log_level::warn, "Affinity mask spans multiple processor groups")); +} + +TEST_CASE("single cpu produces single-bit mask") { + scoped_topology topo({{0, 64}, {1, 64}}); + + cpu_set_t set{}; + CPU_ZERO(&set); + CPU_SET(0, &set); + + GROUP_AFFINITY ga{}; + REQUIRE(win32_pthread_detail::cpuset_to_group_affinity(&set, ga)); + + CHECK(ga.Group == 0); + CHECK(ga.Mask == 1); +} + +TEST_CASE("empty cpuset is rejected") { + scoped_topology topo({{0, 64}}); + + cpu_set_t set{}; + CPU_ZERO(&set); + + GROUP_AFFINITY ga{}; + CHECK_FALSE(win32_pthread_detail::cpuset_to_group_affinity(&set, ga)); +} + +TEST_CASE("cpu in second group maps to correct group and bit") { + scoped_topology topo({{0, 64}, {1, 64}}); + + cpu_set_t set{}; + CPU_ZERO(&set); + CPU_SET(64, &set); // first CPU of group 1 + + GROUP_AFFINITY ga{}; + REQUIRE(win32_pthread_detail::cpuset_to_group_affinity(&set, ga)); + + CHECK(ga.Group == 1); + CHECK(ga.Mask == 1); // bit 0 within the group +} + +#endif // _WIN32