From 94361411efe2d4c1a61d171d8cce75aaf668161b Mon Sep 17 00:00:00 2001 From: Amin Aramoon Date: Wed, 29 Jul 2026 08:43:13 -0700 Subject: [PATCH 1/3] feat: discover NUMA node capacity and size host spaces from it Topology discovery now enumerates NUMA nodes with their total and free memory, parsed from /sys/devices/system/node/node/meminfo, and num_numa_nodes is derived from that list. system_topology_info gains get_numa_memory_capacity(), get_numa_free_memory(), and get_total_numa_memory_capacity() lookups. reservation_manager_configurator gains set_usage_limit_ratio_per_host(), which sizes each host memory space as a fraction of the capacity of the NUMA node backing it, mirroring set_usage_limit_ratio_per_gpu(). Absolute per-host and total-host capacities keep their existing behavior; build() throws when a fraction is requested and the NUMA capacity is unknown. Co-Authored-By: Claude Opus 5 (1M context) --- .../reservation_manager_configurator.hpp | 21 +- .../cucascade/memory/topology_discovery.hpp | 59 +++++ .../reservation_manager_configurator.cpp | 40 +++- src/memory/topology_discovery.cpp | 90 ++++++-- test/CMakeLists.txt | 1 + .../test_reservation_manager_configurator.cpp | 214 ++++++++++++++++++ test/memory/test_topology_discovery.cpp | 61 +++++ 7 files changed, 463 insertions(+), 23 deletions(-) create mode 100644 test/memory/test_reservation_manager_configurator.cpp diff --git a/include/cucascade/memory/reservation_manager_configurator.hpp b/include/cucascade/memory/reservation_manager_configurator.hpp index 2f8a1834..2546da66 100644 --- a/include/cucascade/memory/reservation_manager_configurator.hpp +++ b/include/cucascade/memory/reservation_manager_configurator.hpp @@ -120,6 +120,14 @@ class reservation_manager_configurator { /// @param bytes Memory capacity per NUMA node in bytes. builder_reference& set_per_host_capacity(std::size_t bytes); + /// @brief set the capacity of each host tier as a fraction of its NUMA node capacity + /// @param fraction Fraction of the NUMA node memory capacity to use, in (0.0, 1.0]. + /// @note Requires NUMA capacities in the topology passed to `build()`; `build()` throws + /// if the capacity of a NUMA node backing a host space is unknown. + /// @note The fraction applies per host space. With `use_host_per_gpu()` and several GPUs + /// on the same NUMA node, each space gets that fraction of the shared node. + builder_reference& set_usage_limit_ratio_per_host(double fraction); + /// \brief set reservation limit ratio per GPU /// @param fraction Fraction of GPU memory capacity to reserve. builder_reference& set_downgrade_fractions_per_host(double start, double end); @@ -171,6 +179,7 @@ class reservation_manager_configurator { struct host_info { int space_id{-1}; int numa_id{-1}; + std::size_t numa_capacity{0}; }; std::vector extract_gpu_ids(const system_topology_info& topology) const; @@ -193,6 +202,16 @@ class reservation_manager_configurator { } } + [[nodiscard]] bool holds_fraction() const + { + return std::holds_alternative(_fraction_or_size_value); + } + + [[nodiscard]] std::size_t get_size() const + { + return std::get(_fraction_or_size_value); + } + [[nodiscard]] std::size_t get_capacity(std::size_t total_size) const { if (std::holds_alternative(_fraction_or_size_value)) { @@ -214,7 +233,7 @@ class reservation_manager_configurator { std::pair downgrade_fractions_per_gpu_{0.85, 0.65}; mutable DeviceMemoryResourceFactoryFn _gpu_mr_fn = make_default_gpu_memory_resource; - std::size_t _host_capacity{static_cast(4UL << 30)}; // 4GB + fraction_or_size _host_capacity{static_cast(4UL << 30)}; // 4GB bool _is_capacity_per_space{true}; struct bind_host_to_gpu_id {}; struct bind_cpu_to_gpu_numa {}; diff --git a/include/cucascade/memory/topology_discovery.hpp b/include/cucascade/memory/topology_discovery.hpp index 55a3d375..a5e141cf 100644 --- a/include/cucascade/memory/topology_discovery.hpp +++ b/include/cucascade/memory/topology_discovery.hpp @@ -5,6 +5,7 @@ #pragma once +#include #include #include #include @@ -50,6 +51,19 @@ struct storage_device_info { std::string pci_bus_id; ///< PCI bus ID. }; +/** + * @brief NUMA node memory information. + * + * Capacities are reported in bytes and are read from + * `/sys/devices/system/node/node/meminfo`. They are 0 when the kernel does not + * expose the corresponding entry. + */ +struct numa_topology_info { + int id{-1}; ///< NUMA node ID. + std::size_t memory_capacity{0}; ///< Total memory of the node in bytes (0 if unknown). + std::size_t free_memory{0}; ///< Currently free memory of the node in bytes (0 if unknown). +}; + /** * @brief System topology information. */ @@ -61,6 +75,51 @@ struct system_topology_info { std::vector gpus; ///< GPU topology information. std::vector network_devices; ///< Network device information. std::vector storage_devices; ///< Storage device information. + std::vector numa_nodes; ///< NUMA node information, sorted by id. + + /** + * @brief Get the memory capacity of a NUMA node. + * + * @param numa_id NUMA node ID to look up. + * @return Capacity of the node in bytes; 0 if the node is unknown or its capacity + * could not be determined. + */ + [[nodiscard]] std::size_t get_numa_memory_capacity(int numa_id) const + { + for (auto const& node : numa_nodes) { + if (node.id == numa_id) { return node.memory_capacity; } + } + return 0; + } + + /** + * @brief Get the free memory of a NUMA node. + * + * @param numa_id NUMA node ID to look up. + * @return Free memory of the node in bytes; 0 if the node is unknown or its free + * memory could not be determined. + */ + [[nodiscard]] std::size_t get_numa_free_memory(int numa_id) const + { + for (auto const& node : numa_nodes) { + if (node.id == numa_id) { return node.free_memory; } + } + return 0; + } + + /** + * @brief Get the summed memory capacity of all discovered NUMA nodes. + * + * @return Total host memory capacity in bytes. + */ + [[nodiscard]] std::size_t get_total_numa_memory_capacity() const + { + std::size_t total = 0; + for (auto const& node : numa_nodes) { + total += node.memory_capacity; + } + return total; + } }; /** diff --git a/src/memory/reservation_manager_configurator.cpp b/src/memory/reservation_manager_configurator.cpp index 29a66c19..f7432627 100644 --- a/src/memory/reservation_manager_configurator.cpp +++ b/src/memory/reservation_manager_configurator.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -125,6 +126,14 @@ builder_reference& reservation_manager_configurator::set_per_host_capacity(std:: return *this; } +builder_reference& reservation_manager_configurator::set_usage_limit_ratio_per_host(double fraction) +{ + assert(fraction > 0.0 && fraction <= 1.0 && "Usage limit ratio must be in (0.0, 1.0]"); + _host_capacity = fraction; + _is_capacity_per_space = true; + return *this; +} + builder_reference& reservation_manager_configurator::set_downgrade_fractions_per_host(double start, double end) { @@ -225,12 +234,24 @@ std::vector reservation_manager_configurator::build( configs.emplace_back(config); }; - size_t per_host_capacity = - (host_infos.size() <= 1) - ? _host_capacity - : (_is_capacity_per_space ? _host_capacity : _host_capacity / host_infos.size()); + // Absolute capacities are either per-space or split evenly across spaces; a fraction is + // always relative to the capacity of the NUMA node backing the space. + auto host_capacity_of = [&](const host_info& info) -> std::size_t { + if (_host_capacity.holds_fraction()) { + if (info.numa_capacity == 0) { + throw std::runtime_error( + "Host capacity fraction requested but the memory capacity of NUMA node " + + std::to_string(info.numa_id) + " is unknown"); + } + return _host_capacity.get_capacity(info.numa_capacity); + } + auto const bytes = _host_capacity.get_size(); + return (_is_capacity_per_space || host_infos.size() <= 1) ? bytes : bytes / host_infos.size(); + }; for (auto& info : host_infos) { + size_t const per_host_capacity = host_capacity_of(info); + host_memory_space_config config; config.numa_id = info.space_id; config.memory_capacity = per_host_capacity; @@ -313,17 +334,20 @@ reservation_manager_configurator::extract_gpu_ids(const system_topology_info& to } std::vector -reservation_manager_configurator::extract_host_ids( - const std::vector& gpus, [[maybe_unused]] const system_topology_info& topology) const +reservation_manager_configurator::extract_host_ids(const std::vector& gpus, + const system_topology_info& topology) const { std::vector host_infos; std::set host_ids_set; for (const auto& gpu : gpus) { + auto const numa_capacity = topology.get_numa_memory_capacity(gpu.numa_id); if (std::holds_alternative(_host_creation_policy)) { - host_infos.emplace_back(host_info{.space_id = gpu.space_id, .numa_id = gpu.numa_id}); + host_infos.emplace_back(host_info{ + .space_id = gpu.space_id, .numa_id = gpu.numa_id, .numa_capacity = numa_capacity}); } else if (std::holds_alternative(_host_creation_policy)) { if (!host_ids_set.contains(gpu.numa_id)) { - host_infos.emplace_back(host_info{.space_id = gpu.numa_id, .numa_id = gpu.numa_id}); + host_infos.emplace_back(host_info{ + .space_id = gpu.numa_id, .numa_id = gpu.numa_id, .numa_capacity = numa_capacity}); host_ids_set.insert(gpu.numa_id); } } diff --git a/src/memory/topology_discovery.cpp b/src/memory/topology_discovery.cpp index a8f303f5..03ca6637 100644 --- a/src/memory/topology_discovery.cpp +++ b/src/memory/topology_discovery.cpp @@ -695,32 +695,93 @@ std::string get_hostname() } /** - * @brief Count NUMA nodes on the system. + * @brief Read the total and free memory of a NUMA node. * - * Counts subdirectories named "node*" under /sys/devices/system/node. Returns 0 if - * the directory does not exist or cannot be iterated. + * Parses /sys/devices/system/node/node/meminfo, whose lines have the form + * "Node 0 MemTotal: 263950224 kB". Values are reported in kB and converted + * to bytes. Missing entries are reported as 0. * - * @return Number of NUMA nodes; 0 if unavailable. + * @param numa_path Path to the NUMA node directory. + * @param info NUMA node info to populate. */ -int count_numa_nodes() +void read_numa_node_memory(fs::path const& numa_path, numa_topology_info& info) { - std::string numa_path = "/sys/devices/system/node"; - int count = 0; + std::ifstream meminfo(numa_path / "meminfo"); + if (!meminfo.is_open()) { return; } + + std::string line; + while (std::getline(meminfo, line)) { + // Each line is "Node : kB". + auto const colon = line.find(':'); + if (colon == std::string::npos) { continue; } + + std::size_t const key_end = colon; + std::size_t const key_start = line.find_last_of(' ', key_end) + 1; + std::string const key = line.substr(key_start, key_end - key_start); + if (key != "MemTotal" && key != "MemFree") { continue; } + + std::size_t value_kb = 0; + try { + value_kb = std::stoull(line.substr(colon + 1)); + } catch (...) { + continue; + } - if (!fs::exists(numa_path)) { return 0; } + if (key == "MemTotal") { + info.memory_capacity = value_kb * 1024; + } else { + info.free_memory = value_kb * 1024; + } + } +} + +/** + * @brief Discover NUMA nodes and their memory capacities. + * + * Scans subdirectories named "node" under /sys/devices/system/node and reads each + * node's memory capacity. Returns an empty vector if the directory does not exist or + * cannot be iterated. + * + * @return NUMA node information sorted by node id; empty if unavailable. + */ +std::vector discover_numa_nodes() +{ + std::string const numa_path = "/sys/devices/system/node"; + std::vector nodes; + + if (!fs::exists(numa_path)) { return nodes; } try { for (auto const& entry : fs::directory_iterator(numa_path)) { - std::string name = entry.path().filename().string(); - if (name.starts_with("node")) { // starts with "node" - count++; + std::string const name = entry.path().filename().string(); + if (!name.starts_with("node")) { continue; } + + std::string const id_str = name.substr(4); + if (id_str.empty() || !std::all_of(id_str.begin(), id_str.end(), [](unsigned char c) { + return std::isdigit(c); + })) { + continue; } + + numa_topology_info info; + try { + info.id = std::stoi(id_str); + } catch (...) { + continue; + } + read_numa_node_memory(entry.path(), info); + nodes.push_back(info); } } catch (...) { - return 0; + return {}; } - return count; + std::sort( + nodes.begin(), nodes.end(), [](numa_topology_info const& lhs, numa_topology_info const& rhs) { + return lhs.id < rhs.id; + }); + + return nodes; } nvmlReturn_t initialize_nvml_for_current_process() @@ -780,7 +841,8 @@ bool topology_discovery::discover(NetworkDeviceVerification net_verification) // Get system information topology.hostname = get_hostname(); - topology.num_numa_nodes = count_numa_nodes(); + topology.numa_nodes = discover_numa_nodes(); + topology.num_numa_nodes = static_cast(topology.numa_nodes.size()); topology.num_gpus = device_count; topology.num_network_devices = static_cast(network_devices_with_topology.size()); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e098d2eb..64885560 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -30,6 +30,7 @@ if(NOT CUCASCADE_TOPOLOGY_ONLY) # Memory tests memory/test_memory_reservation_manager.cpp memory/test_reservation_aware_resource_adaptor.cpp + memory/test_reservation_manager_configurator.cpp memory/test_small_pinned_host_memory_resource.cpp memory/test_gpu_kernels.cu # Data tests diff --git a/test/memory/test_reservation_manager_configurator.cpp b/test/memory/test_reservation_manager_configurator.cpp new file mode 100644 index 00000000..ad96f0ed --- /dev/null +++ b/test/memory/test_reservation_manager_configurator.cpp @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Tests for host (NUMA) capacity configuration in reservation_manager_configurator. + * + * Test Tags: + * [configurator] - reservation_manager_configurator tests + */ + +#include +#include +#include + +#include + +#include +#include +#include + +using namespace cucascade::memory; + +namespace { + +constexpr std::size_t synthetic_numa_capacity = 64ull << 30; // 64 GiB + +/// Collect the host space configs produced by the builder. +std::vector host_configs(std::vector const& configs) +{ + std::vector hosts; + for (auto const& config : configs) { + if (auto const* host = std::get_if(&config)) { + hosts.push_back(*host); + } + } + return hosts; +} + +/** + * @brief Build a topology with a single real GPU and a synthetic NUMA node. + * + * The GPU entry must come from real discovery because the configurator queries the + * device for its memory capacity; the NUMA capacity is overridden so the expected + * values are independent of the machine running the test. + */ +bool make_single_gpu_topology(system_topology_info& topology, std::size_t numa_capacity) +{ + topology_discovery discovery; + if (!discovery.discover()) { return false; } + + topology = discovery.get_topology(); + if (topology.gpus.empty()) { return false; } + + topology.gpus.resize(1); + topology.num_gpus = 1; + topology.numa_nodes = { + numa_topology_info{topology.gpus.front().numa_node, numa_capacity, numa_capacity / 2}}; + topology.num_numa_nodes = 1; + return true; +} + +} // namespace + +TEST_CASE("Configurator sets host capacity as a fraction of NUMA capacity", "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_usage_limit_ratio_per_host(0.25); + + auto const hosts = host_configs(builder.build(topology)); + + REQUIRE(hosts.size() == 1); + REQUIRE(hosts.front().memory_capacity == synthetic_numa_capacity / 4); +} + +TEST_CASE("Configurator sets host capacity in absolute bytes", "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + constexpr std::size_t requested = 3ull << 30; // 3 GiB + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_per_host_capacity(requested); + + auto const hosts = host_configs(builder.build(topology)); + + REQUIRE(hosts.size() == 1); + // Absolute capacities are used verbatim, independent of the NUMA node capacity. + REQUIRE(hosts.front().memory_capacity == requested); +} + +TEST_CASE("Configurator host capacity fraction overrides a previous absolute setting", + "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_total_host_capacity(1ull << 30); + builder.set_usage_limit_ratio_per_host(0.5); + + auto const hosts = host_configs(builder.build(topology)); + + REQUIRE(hosts.size() == 1); + REQUIRE(hosts.front().memory_capacity == synthetic_numa_capacity / 2); +} + +TEST_CASE("Configurator reservation limit follows the fraction-derived host capacity", + "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + constexpr std::size_t reservation_bytes = 4ull << 30; // 4 GiB + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_usage_limit_ratio_per_host(0.5); + builder.set_reservation_limit_per_host(reservation_bytes); + + auto const hosts = host_configs(builder.build(topology)); + + REQUIRE(hosts.size() == 1); + REQUIRE(hosts.front().memory_capacity == synthetic_numa_capacity / 2); + REQUIRE(hosts.front().reservation_limit() == reservation_bytes); +} + +TEST_CASE("Configurator throws when NUMA capacity is unknown and a fraction is requested", + "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + // Drop NUMA information: the fraction can no longer be resolved. + topology.numa_nodes.clear(); + topology.num_numa_nodes = 0; + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_usage_limit_ratio_per_host(0.5); + + REQUIRE_THROWS_AS(builder.build(topology), std::runtime_error); +} + +TEST_CASE("Configurator resolves host capacity from discovered NUMA capacity", "[configurator]") +{ + topology_discovery discovery; + REQUIRE(discovery.discover()); + auto const& topology = discovery.get_topology(); + + if (topology.gpus.empty() || topology.numa_nodes.empty()) { + SUCCEED("Skipped: requires at least one GPU and NUMA node"); + return; + } + + auto const numa_id = topology.gpus.front().numa_node; + auto const numa_capacity = topology.get_numa_memory_capacity(numa_id); + if (numa_capacity == 0) { + SUCCEED("Skipped: NUMA capacity is not exposed on this host"); + return; + } + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_usage_limit_ratio_per_host(0.1); + + auto const hosts = host_configs(builder.build(topology)); + + REQUIRE(hosts.size() == 1); + REQUIRE(hosts.front().memory_capacity > 0); + REQUIRE(hosts.front().memory_capacity == static_cast( + static_cast(numa_capacity) * 0.1)); +} diff --git a/test/memory/test_topology_discovery.cpp b/test/memory/test_topology_discovery.cpp index 5aa69680..4334a374 100644 --- a/test/memory/test_topology_discovery.cpp +++ b/test/memory/test_topology_discovery.cpp @@ -206,6 +206,67 @@ TEST_CASE("Topology Discovery resolves GPU NUMA node on NUMA-aware hosts", "[hw_ } } +TEST_CASE("Topology Discovery reports NUMA node capacities", "[hw_topology]") +{ + topology_discovery discovery; + REQUIRE(discovery.discover()); + + auto const& topology = discovery.get_topology(); + + // num_numa_nodes is derived from the discovered node list. + REQUIRE(topology.numa_nodes.size() == static_cast(topology.num_numa_nodes)); + + if (topology.numa_nodes.empty()) { + SUCCEED("Skipped: host does not expose NUMA topology"); + return; + } + + size_t summed_capacity = 0; + int previous_id = -1; + for (auto const& node : topology.numa_nodes) { + INFO("NUMA node " << node.id); + REQUIRE(node.id >= 0); + // Nodes are reported sorted by id and each id appears once. + REQUIRE(node.id > previous_id); + previous_id = node.id; + + // /sys/devices/system/node/node/meminfo always reports MemTotal and MemFree. + REQUIRE(node.memory_capacity > 0); + REQUIRE(node.free_memory <= node.memory_capacity); + + REQUIRE(topology.get_numa_memory_capacity(node.id) == node.memory_capacity); + REQUIRE(topology.get_numa_free_memory(node.id) == node.free_memory); + summed_capacity += node.memory_capacity; + } + + REQUIRE(topology.get_total_numa_memory_capacity() == summed_capacity); + + // Unknown node ids resolve to 0 rather than throwing. + REQUIRE(topology.get_numa_memory_capacity(-1) == 0); + REQUIRE(topology.get_numa_memory_capacity(topology.numa_nodes.back().id + 1) == 0); + REQUIRE(topology.get_numa_free_memory(-1) == 0); +} + +// Every GPU's NUMA node must be one of the discovered NUMA nodes with a known capacity, +// so host memory spaces can be sized from it. +TEST_CASE("Topology Discovery maps GPUs to NUMA nodes with known capacity", "[hw_topology]") +{ + topology_discovery discovery; + REQUIRE(discovery.discover()); + + auto const& topology = discovery.get_topology(); + + if (topology.num_gpus == 0 || topology.numa_nodes.empty()) { + SUCCEED("Skipped: requires at least one GPU and NUMA node"); + return; + } + + for (auto const& gpu : topology.gpus) { + INFO("GPU " << gpu.id << " on NUMA node " << gpu.numa_node); + REQUIRE(topology.get_numa_memory_capacity(gpu.numa_node) > 0); + } +} + TEST_CASE("Topology Discovery rejects out-of-range CUDA_VISIBLE_DEVICES", "[hw_topology]") { ScopedEnvVar env("CUDA_VISIBLE_DEVICES", "99999999"); From 9a2646397807a87aa3d86f7fc114e396c330eaa5 Mon Sep 17 00:00:00 2001 From: Amin Aramoon Date: Wed, 29 Jul 2026 08:51:13 -0700 Subject: [PATCH 2/3] test: cover host spaces whose GPU reports no NUMA affinity The machine used to develop this has a single NUMA node and both GPUs report numa_node 0, so the numa_node == -1 path was never exercised. Add cases where the GPU has no NUMA affinity: absolute per-host and total-host capacities are still honored verbatim, and a requested fraction throws because it has nothing to resolve against. Co-Authored-By: Claude Opus 5 (1M context) --- .../test_reservation_manager_configurator.cpp | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/test/memory/test_reservation_manager_configurator.cpp b/test/memory/test_reservation_manager_configurator.cpp index ad96f0ed..7ec47bf9 100644 --- a/test/memory/test_reservation_manager_configurator.cpp +++ b/test/memory/test_reservation_manager_configurator.cpp @@ -161,6 +161,84 @@ TEST_CASE("Configurator reservation limit follows the fraction-derived host capa REQUIRE(hosts.front().reservation_limit() == reservation_bytes); } +// Hosts whose GPU reports no NUMA affinity (numa_node == -1) have no discoverable capacity. +// Absolute capacities must still be honored verbatim on such hosts. +TEST_CASE("Configurator uses absolute host capacity when the NUMA node is unknown", + "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + // Emulate a GPU without NUMA affinity: no node backs the host space. + topology.gpus.front().numa_node = -1; + topology.numa_nodes.clear(); + topology.num_numa_nodes = 0; + + constexpr std::size_t requested = 2ull << 30; // 2 GiB + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_per_host_capacity(requested); + + auto const hosts = host_configs(builder.build(topology)); + + REQUIRE(hosts.size() == 1); + REQUIRE(hosts.front().numa_id == -1); + REQUIRE(hosts.front().memory_capacity == requested); +} + +// The total-capacity split must also survive an unknown NUMA node. +TEST_CASE("Configurator uses total host capacity when the NUMA node is unknown", "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + topology.gpus.front().numa_node = -1; + topology.numa_nodes.clear(); + topology.num_numa_nodes = 0; + + constexpr std::size_t requested = 8ull << 30; // 8 GiB + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_total_host_capacity(requested); + + auto const hosts = host_configs(builder.build(topology)); + + REQUIRE(hosts.size() == 1); + REQUIRE(hosts.front().memory_capacity == requested); +} + +TEST_CASE( + "Configurator throws when a NUMA node has no discoverable capacity and a fraction is " + "requested", + "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + // GPU without NUMA affinity: the fraction has nothing to resolve against. + topology.gpus.front().numa_node = -1; + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_host_per_numa(); + builder.set_usage_limit_ratio_per_host(0.5); + + REQUIRE_THROWS_AS(builder.build(topology), std::runtime_error); +} + TEST_CASE("Configurator throws when NUMA capacity is unknown and a fraction is requested", "[configurator]") { @@ -209,6 +287,6 @@ TEST_CASE("Configurator resolves host capacity from discovered NUMA capacity", " REQUIRE(hosts.size() == 1); REQUIRE(hosts.front().memory_capacity > 0); - REQUIRE(hosts.front().memory_capacity == static_cast( - static_cast(numa_capacity) * 0.1)); + REQUIRE(hosts.front().memory_capacity == + static_cast(static_cast(numa_capacity) * 0.1)); } From c6bed56dc6146cab77c72a45ab69d93565f51b56 Mon Sep 17 00:00:00 2001 From: Amin Aramoon Date: Thu, 30 Jul 2026 16:23:03 -0700 Subject: [PATCH 3/3] refactor: name host configuration after the NUMA region it sizes A host memory space covers a single NUMA region, not the whole host, so the per-space builder settings are renamed to say so: set_per_host_capacity -> set_per_numa_region_capacity set_usage_limit_ratio_per_host -> set_usage_limit_ratio_per_numa_region set_reservation_limit_per_host -> set_reservation_limit_per_numa_region set_reservation_fraction_per_host -> set_reservation_fraction_per_numa_region set_downgrade_fractions_per_host -> set_downgrade_fractions_per_numa_region set_total_host_capacity keeps its name: it is the one host-wide setting, divided evenly across the host spaces. The host creation policies are renamed for what they actually select -- the id a host space is identified by, not how many spaces exist: use_host_per_gpu -> use_gpu_id_as_host_id use_host_per_numa -> use_numa_id_as_host_id (default) Under use_gpu_id_as_host_id the space is still bound to that GPU's NUMA node for allocation; only the space id differs. get_numa_memory_capacity() and get_numa_free_memory() now return std::optional so an unknown node id no longer resolves to a silent 0, and find_numa_node() exposes the node itself. NUMA discovery reads node/cpulist and flags a node that has memory but no CPUs as is_device_memory. DGX Station and Grace-Hopper expose GPU HBM as such a node, and CXL expanders do the same; sizing a host space from one would hand out device memory as host memory. Those nodes are excluded from get_total_numa_memory_capacity(), and requesting a capacity fraction against one now throws. Co-Authored-By: Claude Opus 5 --- docs/topology-and-configuration.md | 64 +++++++--- .../reservation_manager_configurator.hpp | 84 ++++++------ .../cucascade/memory/topology_discovery.hpp | 51 ++++++-- .../reservation_manager_configurator.cpp | 112 +++++++++------- src/memory/topology_discovery.cpp | 26 ++++ .../test_memory_reservation_manager.cpp | 12 +- .../test_reservation_manager_configurator.cpp | 120 ++++++++++++++---- test/memory/test_topology_discovery.cpp | 29 +++-- test/utils/mock_test_utils.hpp | 4 +- 9 files changed, 339 insertions(+), 163 deletions(-) diff --git a/docs/topology-and-configuration.md b/docs/topology-and-configuration.md index f2c19830..7ddc7d88 100644 --- a/docs/topology-and-configuration.md +++ b/docs/topology-and-configuration.md @@ -49,12 +49,35 @@ if (discovery.discover()) { topo.hostname; // System hostname topo.num_gpus; // Total GPU count topo.num_numa_nodes; // Total NUMA node count + topo.numa_nodes; // Per-NUMA-node capacity and memory kind topo.gpus; // Per-GPU topology info topo.network_devices; // NICs with NUMA affinity topo.storage_devices; // NVMe/SATA drives with NUMA affinity + + // Lookups return nullopt for an unknown node rather than a silent 0. + topo.get_numa_memory_capacity(0); // std::optional + topo.get_numa_free_memory(0); // std::optional + topo.get_total_numa_memory_capacity(); // host memory only + topo.find_numa_node(0); // numa_topology_info const* } ``` +**Per-NUMA-node information** (`numa_topology_info`), read from +`/sys/devices/system/node/node/`: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `int` | NUMA node ID | +| `memory_capacity` | `std::size_t` | `MemTotal` in bytes (0 if unknown) | +| `free_memory` | `std::size_t` | `MemFree` in bytes (0 if unknown) | +| `has_cpus` | `bool` | Whether `cpulist` names any CPU | +| `is_device_memory` | `bool` | Node has memory but no CPUs -- device memory, not host memory | + +A node with memory and no CPUs is not host memory. DGX Station and Grace-Hopper expose GPU +HBM as its own CPU-less node, and CXL memory expanders do the same. Such nodes are excluded +from `get_total_numa_memory_capacity()`, and `set_usage_limit_ratio_per_numa_region()` +throws rather than sizing a host space from device memory. + **Per-GPU information** (`gpu_topology_info`): | Field | Type | Description | @@ -151,8 +174,8 @@ configurator .set_number_of_gpus(2) .set_gpu_usage_limit(4ULL << 30) // 4 GB per GPU .set_reservation_fraction_per_gpu(0.85) // 85% reservable - .set_per_host_capacity(16ULL << 30) // 16 GB per host space - .use_host_per_numa(); // One host space per NUMA node + .set_per_numa_region_capacity(16ULL << 30) // 16 GB per host space + .use_numa_id_as_host_id(); // One host space per NUMA node // With topology topology_discovery discovery; @@ -186,19 +209,22 @@ Note: `set_gpu_usage_limit()` and `set_usage_limit_ratio_per_gpu()` are mutually | Method | Description | Default | |--------|-------------|---------| -| `use_host_per_gpu()` | One host space per GPU (testing) | N/A | -| `use_host_per_numa()` | One host space per NUMA node (production) | `use_host_per_numa()` | +| `use_gpu_id_as_host_id()` | One host space per GPU (testing) | N/A | +| `use_numa_id_as_host_id()` | One host space per NUMA node (production) | `use_numa_id_as_host_id()` | | `set_total_host_capacity(bytes)` | Total host memory across all spaces | 4 GB | -| `set_per_host_capacity(bytes)` | Memory per host space | 4 GB | -| `set_reservation_fraction_per_host(0.85)` | Fraction reservable | 0.85 | -| `set_reservation_limit_per_host(bytes)` | Absolute reservation limit | N/A | -| `set_downgrade_fractions_per_host(0.85, 0.65)` | (trigger, stop) fractions | (0.85, 0.65) | +| `set_per_numa_region_capacity(bytes)` | Memory per host space | 4 GB | +| `set_usage_limit_ratio_per_numa_region(0.25)` | Memory per host space as a fraction of its NUMA region's capacity | N/A | +| `set_reservation_fraction_per_numa_region(0.85)` | Fraction reservable | 0.85 | +| `set_reservation_limit_per_numa_region(bytes)` | Absolute reservation limit | N/A | +| `set_downgrade_fractions_per_numa_region(0.85, 0.65)` | (trigger, stop) fractions | (0.85, 0.65) | | `set_host_pool_features(chunk, block, count)` | Block allocator settings | (1MB, 128, 4) | | `set_host_memory_resource_factory(fn)` | Custom host allocator factory | NUMA-pinned | Host creation policies: -- **`use_host_per_gpu()`** -- creates one host space per GPU, regardless of NUMA topology. Useful for testing. -- **`use_host_per_numa()`** -- creates one host space per NUMA node, shared by GPUs on that node. Optimal for production. +- **`use_gpu_id_as_host_id()`** -- creates one host space per GPU and identifies it by the GPU id. The space is still bound to that GPU's NUMA node for allocation; only the space id differs. Useful for testing. +- **`use_numa_id_as_host_id()`** -- creates one host space per NUMA node, identified by the NUMA node id and shared by GPUs on that node. Default, and optimal for production. + +`set_total_host_capacity()` is the only host-wide setting: it is divided evenly across the host spaces. Every other capacity setting applies per space, i.e. per NUMA region. ### Disk Configuration @@ -233,8 +259,8 @@ reservation_manager_configurator configurator; configurator .set_gpu_usage_limit(4ULL << 30) // 4 GB GPU .set_reservation_fraction_per_gpu(0.8) // 80% reservable - .set_per_host_capacity(8ULL << 30) // 8 GB host - .use_host_per_gpu(); + .set_per_numa_region_capacity(8ULL << 30) // 8 GB host + .use_gpu_id_as_host_id(); auto configs = configurator.build(); memory_reservation_manager manager(std::move(configs)); @@ -252,8 +278,8 @@ configurator .set_usage_limit_ratio_per_gpu(0.8) // 80% of each GPU .set_reservation_fraction_per_gpu(0.85) .set_downgrade_fractions_per_gpu(0.85, 0.65) - .set_per_host_capacity(32ULL << 30) // 32 GB per NUMA node - .use_host_per_numa() + .set_per_numa_region_capacity(32ULL << 30) // 32 GB per NUMA node + .use_numa_id_as_host_id() .set_host_pool_features( 2ULL << 20, // 2 MB blocks 64, // 64 blocks per pool @@ -302,10 +328,10 @@ configurator // Host: NUMA-aware, 64 GB per node, 2 MB blocks configurator - .use_host_per_numa() - .set_per_host_capacity(64ULL << 30) - .set_reservation_fraction_per_host(0.85) - .set_downgrade_fractions_per_host(0.85, 0.65) + .use_numa_id_as_host_id() + .set_per_numa_region_capacity(64ULL << 30) + .set_reservation_fraction_per_numa_region(0.85) + .set_downgrade_fractions_per_numa_region(0.85, 0.65) .set_host_pool_features(2ULL << 20, 128, 8); // Disk: 1 TB NVMe @@ -334,7 +360,7 @@ memory_reservation_manager manager(std::move(configs)); | Host block size | 1 MB (`1 << 20`) | | Host pool size | 128 blocks per pool | | Host initial pools | 4 | -| Host creation policy | `use_host_per_numa()` | +| Host creation policy | `use_numa_id_as_host_id()` | | GPU allocator | `rmm::cuda_async_memory_resource` | | Host allocator | `numa_region_pinned_host_memory_resource` | | Stream pool size | 16 streams | diff --git a/include/cucascade/memory/reservation_manager_configurator.hpp b/include/cucascade/memory/reservation_manager_configurator.hpp index 2546da66..82cab707 100644 --- a/include/cucascade/memory/reservation_manager_configurator.hpp +++ b/include/cucascade/memory/reservation_manager_configurator.hpp @@ -51,8 +51,8 @@ namespace memory { * builder.set_number_of_gpus(2) * .set_gpu_usage_limit(2UL << 30) * .set_reservation_limit_ratio_per_gpu(0.8) - * .set_numa_ids({0, 1}) - * .set_capacity_per_numa_node(8UL << 30) + * .use_numa_id_as_host_id() + * .set_per_numa_region_capacity(8UL << 30) * .set_gpu_memory_resource_factory(custom_gpu_factory) * .set_cpu_memory_resource_factory(custom_cpu_factory); * auto configs = builder.build(system_topology); @@ -102,43 +102,50 @@ class reservation_manager_configurator { /// @param mr_fn Function to create GPU memory resource. builder_reference& set_gpu_memory_resource_factory(DeviceMemoryResourceFactoryFn mr_fn); - // --- cpu / host settings --- + // --- cpu / numa region settings --- + // + // A host memory space covers a single NUMA region, not the whole host, so the per-space + // settings are named after the region they size. Only `set_total_host_capacity()` is + // host-wide: it is divided across the spaces. - /// @brief set host ids - /// @param host_ids Vector of host ids. - /// @note this is meant to be used for testing purpose only, host ids will be mapped to numa ids - builder_reference& use_host_per_gpu(); + /// @brief identify each host space by the id of the GPU it backs + /// @note One host space is created per GPU. The space is still bound to that GPU's NUMA + /// node for allocation; only the space id differs. Meant for testing. + builder_reference& use_gpu_id_as_host_id(); - /// @brief automatically bind cpu tiers to gpus based on topology - builder_reference& use_host_per_numa(); + /// @brief identify each host space by its NUMA node id (default) + /// @note One host space is created per NUMA node, shared by every GPU on that node. + builder_reference& use_numa_id_as_host_id(); - /// set capacity per host tier - /// @param bytes Memory capacity per NUMA node in bytes. + /// @brief set the memory capacity shared by all host spaces + /// @param bytes Total host memory capacity in bytes, split evenly across the host spaces. builder_reference& set_total_host_capacity(std::size_t bytes); - /// set capacity per host tier - /// @param bytes Memory capacity per NUMA node in bytes. - builder_reference& set_per_host_capacity(std::size_t bytes); + /// @brief set the capacity of each NUMA region space + /// @param bytes Memory capacity per NUMA region in bytes. + builder_reference& set_per_numa_region_capacity(std::size_t bytes); - /// @brief set the capacity of each host tier as a fraction of its NUMA node capacity - /// @param fraction Fraction of the NUMA node memory capacity to use, in (0.0, 1.0]. + /// @brief set the capacity of each NUMA region space as a fraction of that region's capacity + /// @param fraction Fraction of the NUMA region memory capacity to use, in (0.0, 1.0]. /// @note Requires NUMA capacities in the topology passed to `build()`; `build()` throws - /// if the capacity of a NUMA node backing a host space is unknown. - /// @note The fraction applies per host space. With `use_host_per_gpu()` and several GPUs - /// on the same NUMA node, each space gets that fraction of the shared node. - builder_reference& set_usage_limit_ratio_per_host(double fraction); + /// if the capacity of a NUMA region backing a space is unknown, or if that region is + /// device memory rather than host memory. + /// @note The fraction applies per space. With `use_gpu_id_as_host_id()` and several GPUs + /// on the same NUMA node, each space gets that fraction of the shared region. + builder_reference& set_usage_limit_ratio_per_numa_region(double fraction); - /// \brief set reservation limit ratio per GPU - /// @param fraction Fraction of GPU memory capacity to reserve. - builder_reference& set_downgrade_fractions_per_host(double start, double end); + /// \brief set the downgrade trigger and stop fractions per NUMA region + /// @param start Fraction of region capacity at which downgrading starts. + /// @param end Fraction of region capacity at which downgrading stops. + builder_reference& set_downgrade_fractions_per_numa_region(double start, double end); - /// \brief set ratio of space capacity used for reservation in cpus - /// @param fraction Fraction of NUMA node memory capacity to reserve. - builder_reference& set_reservation_limit_per_host(size_t bytes); + /// \brief set absolute reservation limit per NUMA region + /// @param bytes Reservable bytes per NUMA region. + builder_reference& set_reservation_limit_per_numa_region(size_t bytes); - /// \brief set ratio of space capacity used for reservation in cpus - /// @param fraction Fraction of NUMA node memory capacity to reserve. - builder_reference& set_reservation_fraction_per_host(double fraction); + /// \brief set ratio of space capacity used for reservation per NUMA region + /// @param fraction Fraction of NUMA region memory capacity to reserve. + builder_reference& set_reservation_fraction_per_numa_region(double fraction); /// \brief set the function that takes in the numa node id and create cpu memory resource /// @param mr_fn Function to create CPU memory resource. @@ -176,16 +183,17 @@ class reservation_manager_configurator { int numa_id{-1}; }; - struct host_info { + struct numa_region_info { int space_id{-1}; int numa_id{-1}; - std::size_t numa_capacity{0}; + std::optional numa_capacity{}; ///< Nullopt when the region capacity is unknown. + bool is_device_memory{false}; ///< True when the region is GPU/device memory. }; std::vector extract_gpu_ids(const system_topology_info& topology) const; - std::vector extract_host_ids(const std::vector& gpus, - const system_topology_info& topology) const; + std::vector extract_numa_region_ids(const std::vector& gpus, + const system_topology_info& topology) const; struct fraction_or_size { fraction_or_size(double fraction) : _fraction_or_size_value(fraction) {} @@ -233,15 +241,15 @@ class reservation_manager_configurator { std::pair downgrade_fractions_per_gpu_{0.85, 0.65}; mutable DeviceMemoryResourceFactoryFn _gpu_mr_fn = make_default_gpu_memory_resource; - fraction_or_size _host_capacity{static_cast(4UL << 30)}; // 4GB + fraction_or_size _numa_region_capacity{static_cast(4UL << 30)}; // 4GB bool _is_capacity_per_space{true}; - struct bind_host_to_gpu_id {}; - struct bind_cpu_to_gpu_numa {}; - std::variant _host_creation_policy{}; + struct bind_host_id_to_gpu_id {}; + struct bind_host_id_to_numa_id {}; + std::variant _host_id_policy{}; std::optional chunk_size; std::optional block_size; std::optional initial_block_count; - std::pair downgrade_fractions_per_host_{0.85, 0.65}; + std::pair downgrade_fractions_per_numa_region_{0.85, 0.65}; fraction_or_size _cpu_reservation{0.85}; // 75% limit per NUMA node by default mutable DeviceMemoryResourceFactoryFn _cpu_mr_fn{}; std::optional _host_memory_portability; diff --git a/include/cucascade/memory/topology_discovery.hpp b/include/cucascade/memory/topology_discovery.hpp index a5e141cf..a6057dbc 100644 --- a/include/cucascade/memory/topology_discovery.hpp +++ b/include/cucascade/memory/topology_discovery.hpp @@ -57,11 +57,18 @@ struct storage_device_info { * Capacities are reported in bytes and are read from * `/sys/devices/system/node/node/meminfo`. They are 0 when the kernel does not * expose the corresponding entry. + * + * @note Not every NUMA node backs host memory. Systems such as DGX Station and + * Grace-Hopper expose device memory (GPU HBM) as its own CPU-less NUMA node, and + * CXL memory expanders do the same. Those nodes are flagged with + * `is_device_memory` and must not be used to size a host memory space. */ struct numa_topology_info { int id{-1}; ///< NUMA node ID. std::size_t memory_capacity{0}; ///< Total memory of the node in bytes (0 if unknown). std::size_t free_memory{0}; ///< Currently free memory of the node in bytes (0 if unknown). + bool has_cpus{false}; ///< Whether any CPU is assigned to this node. + bool is_device_memory{false}; ///< Whether this node is device memory rather than host memory. }; /** @@ -78,37 +85,52 @@ struct system_topology_info { std::vector numa_nodes; ///< NUMA node information, sorted by id. /** - * @brief Get the memory capacity of a NUMA node. + * @brief Find a NUMA node by ID. * * @param numa_id NUMA node ID to look up. - * @return Capacity of the node in bytes; 0 if the node is unknown or its capacity - * could not be determined. + * @return Pointer to the node, or `nullptr` if no such node was discovered. */ - [[nodiscard]] std::size_t get_numa_memory_capacity(int numa_id) const + [[nodiscard]] numa_topology_info const* find_numa_node(int numa_id) const { for (auto const& node : numa_nodes) { - if (node.id == numa_id) { return node.memory_capacity; } + if (node.id == numa_id) { return &node; } } - return 0; + return nullptr; + } + + /** + * @brief Get the memory capacity of a NUMA node. + * + * @param numa_id NUMA node ID to look up. + * @return Capacity of the node in bytes, or `std::nullopt` if the node was not + * discovered or the kernel did not report its capacity. The two cases are + * distinguishable via `find_numa_node()`. + */ + [[nodiscard]] std::optional get_numa_memory_capacity(int numa_id) const + { + auto const* node = find_numa_node(numa_id); + if (node == nullptr || node->memory_capacity == 0) { return std::nullopt; } + return node->memory_capacity; } /** * @brief Get the free memory of a NUMA node. * * @param numa_id NUMA node ID to look up. - * @return Free memory of the node in bytes; 0 if the node is unknown or its free - * memory could not be determined. + * @return Free memory of the node in bytes, or `std::nullopt` if the node was not + * discovered or the kernel did not report its free memory. */ - [[nodiscard]] std::size_t get_numa_free_memory(int numa_id) const + [[nodiscard]] std::optional get_numa_free_memory(int numa_id) const { - for (auto const& node : numa_nodes) { - if (node.id == numa_id) { return node.free_memory; } - } - return 0; + auto const* node = find_numa_node(numa_id); + if (node == nullptr || node->free_memory == 0) { return std::nullopt; } + return node->free_memory; } /** - * @brief Get the summed memory capacity of all discovered NUMA nodes. + * @brief Get the summed memory capacity of all host-backing NUMA nodes. + * + * Nodes flagged as device memory are excluded, so the result is usable host memory. * * @return Total host memory capacity in bytes. */ @@ -116,6 +138,7 @@ struct system_topology_info { { std::size_t total = 0; for (auto const& node : numa_nodes) { + if (node.is_device_memory) { continue; } total += node.memory_capacity; } return total; diff --git a/src/memory/reservation_manager_configurator.cpp b/src/memory/reservation_manager_configurator.cpp index f7432627..957e7194 100644 --- a/src/memory/reservation_manager_configurator.cpp +++ b/src/memory/reservation_manager_configurator.cpp @@ -94,56 +94,55 @@ builder_reference& reservation_manager_configurator::track_reservation_per_strea return *this; } -// --- cpu / host settings --- +// --- cpu / numa region settings --- -builder_reference& reservation_manager_configurator::use_host_per_gpu() +builder_reference& reservation_manager_configurator::use_gpu_id_as_host_id() { - _host_creation_policy = bind_host_to_gpu_id{}; + _host_id_policy = bind_host_id_to_gpu_id{}; return *this; } -builder_reference& reservation_manager_configurator::use_host_per_numa() +builder_reference& reservation_manager_configurator::use_numa_id_as_host_id() { - _host_creation_policy = bind_cpu_to_gpu_numa{}; + _host_id_policy = bind_host_id_to_numa_id{}; return *this; } -/// set capacity per host tier -/// @param bytes Memory capacity per NUMA node in bytes. builder_reference& reservation_manager_configurator::set_total_host_capacity(std::size_t bytes) { - assert(bytes > 0 && "Total host capacity must be positive"); - _host_capacity = bytes; + assert(bytes > 0 && "Total NUMA region capacity must be positive"); + _numa_region_capacity = bytes; _is_capacity_per_space = false; return *this; } -builder_reference& reservation_manager_configurator::set_per_host_capacity(std::size_t bytes) +builder_reference& reservation_manager_configurator::set_per_numa_region_capacity(std::size_t bytes) { - assert(bytes > 0 && "Capacity per NUMA node must be positive"); - _host_capacity = bytes; + assert(bytes > 0 && "Capacity per NUMA region must be positive"); + _numa_region_capacity = bytes; _is_capacity_per_space = true; return *this; } -builder_reference& reservation_manager_configurator::set_usage_limit_ratio_per_host(double fraction) +builder_reference& reservation_manager_configurator::set_usage_limit_ratio_per_numa_region( + double fraction) { assert(fraction > 0.0 && fraction <= 1.0 && "Usage limit ratio must be in (0.0, 1.0]"); - _host_capacity = fraction; + _numa_region_capacity = fraction; _is_capacity_per_space = true; return *this; } -builder_reference& reservation_manager_configurator::set_downgrade_fractions_per_host(double start, - double end) +builder_reference& reservation_manager_configurator::set_downgrade_fractions_per_numa_region( + double start, double end) { assert(start > 0.0 && start <= 1.0 && "Start fraction must be in (0.0, 1.0]"); assert(end > 0.0 && end <= 1.0 && "End fraction must be in (0.0, 1.0]"); - downgrade_fractions_per_host_ = {start, end}; + downgrade_fractions_per_numa_region_ = {start, end}; return *this; } -builder_reference& reservation_manager_configurator::set_reservation_fraction_per_host( +builder_reference& reservation_manager_configurator::set_reservation_fraction_per_numa_region( double fraction) { assert(fraction > 0.0 && fraction <= 1.0 && "Reservation limit ratio must be in (0.0, 1.0]"); @@ -151,7 +150,8 @@ builder_reference& reservation_manager_configurator::set_reservation_fraction_pe return *this; } -builder_reference& reservation_manager_configurator::set_reservation_limit_per_host(size_t bytes) +builder_reference& reservation_manager_configurator::set_reservation_limit_per_numa_region( + size_t bytes) { _cpu_reservation = bytes; return *this; @@ -212,7 +212,7 @@ std::vector reservation_manager_configurator::build( const system_topology_info& topology) const { auto gpus_info = extract_gpu_ids(topology); - auto host_infos = extract_host_ids(gpus_info, topology); + auto numa_region_infos = extract_numa_region_ids(gpus_info, topology); bool const make_host_portable = _host_memory_portability.value_or(gpus_info.size() > 1); std::vector configs; @@ -235,26 +235,34 @@ std::vector reservation_manager_configurator::build( }; // Absolute capacities are either per-space or split evenly across spaces; a fraction is - // always relative to the capacity of the NUMA node backing the space. - auto host_capacity_of = [&](const host_info& info) -> std::size_t { - if (_host_capacity.holds_fraction()) { - if (info.numa_capacity == 0) { + // always relative to the capacity of the NUMA region backing the space. + auto numa_region_capacity_of = [&](const numa_region_info& info) -> std::size_t { + if (_numa_region_capacity.holds_fraction()) { + if (info.is_device_memory) { + throw std::runtime_error("NUMA region capacity fraction requested but NUMA node " + + std::to_string(info.numa_id) + + " is device memory, not host memory; size this space with " + "set_per_numa_region_capacity() instead"); + } + if (!info.numa_capacity.has_value()) { throw std::runtime_error( - "Host capacity fraction requested but the memory capacity of NUMA node " + + "NUMA region capacity fraction requested but the memory capacity of NUMA node " + std::to_string(info.numa_id) + " is unknown"); } - return _host_capacity.get_capacity(info.numa_capacity); + return _numa_region_capacity.get_capacity(*info.numa_capacity); } - auto const bytes = _host_capacity.get_size(); - return (_is_capacity_per_space || host_infos.size() <= 1) ? bytes : bytes / host_infos.size(); + auto const bytes = _numa_region_capacity.get_size(); + return (_is_capacity_per_space || numa_region_infos.size() <= 1) + ? bytes + : bytes / numa_region_infos.size(); }; - for (auto& info : host_infos) { - size_t const per_host_capacity = host_capacity_of(info); + for (auto& info : numa_region_infos) { + size_t const per_numa_region_capacity = numa_region_capacity_of(info); host_memory_space_config config; config.numa_id = info.space_id; - config.memory_capacity = per_host_capacity; + config.memory_capacity = per_numa_region_capacity; config.make_portable = make_host_portable; DeviceMemoryResourceFactoryFn host_mr_fn = [current_mr_fn = _cpu_mr_fn, numa_id = info.numa_id, make_host_portable]( @@ -263,9 +271,9 @@ std::vector reservation_manager_configurator::build( return make_default_host_memory_resource(numa_id, capacity, make_host_portable); }; config.mr_factory_fn = std::move(host_mr_fn); - config.reservation_limit_fraction = _cpu_reservation.get_fraction(per_host_capacity); - config.downgrade_trigger_fraction = downgrade_fractions_per_host_.first; - config.downgrade_stop_fraction = downgrade_fractions_per_host_.second; + config.reservation_limit_fraction = _cpu_reservation.get_fraction(per_numa_region_capacity); + config.downgrade_trigger_fraction = downgrade_fractions_per_numa_region_.first; + config.downgrade_stop_fraction = downgrade_fractions_per_numa_region_.second; config.block_size = chunk_size.value_or(memory::default_block_size); config.pool_size = block_size.value_or(memory::default_pool_size); config.initial_number_pools = @@ -333,26 +341,32 @@ reservation_manager_configurator::extract_gpu_ids(const system_topology_info& to return gpu_infos; } -std::vector -reservation_manager_configurator::extract_host_ids(const std::vector& gpus, - const system_topology_info& topology) const +std::vector +reservation_manager_configurator::extract_numa_region_ids( + const std::vector& gpus, const system_topology_info& topology) const { - std::vector host_infos; - std::set host_ids_set; + std::vector numa_region_infos; + std::set numa_region_ids; for (const auto& gpu : gpus) { - auto const numa_capacity = topology.get_numa_memory_capacity(gpu.numa_id); - if (std::holds_alternative(_host_creation_policy)) { - host_infos.emplace_back(host_info{ - .space_id = gpu.space_id, .numa_id = gpu.numa_id, .numa_capacity = numa_capacity}); - } else if (std::holds_alternative(_host_creation_policy)) { - if (!host_ids_set.contains(gpu.numa_id)) { - host_infos.emplace_back(host_info{ - .space_id = gpu.numa_id, .numa_id = gpu.numa_id, .numa_capacity = numa_capacity}); - host_ids_set.insert(gpu.numa_id); + auto const* node = topology.find_numa_node(gpu.numa_id); + auto const numa_capacity = topology.get_numa_memory_capacity(gpu.numa_id); + bool const is_device_memory = node != nullptr && node->is_device_memory; + if (std::holds_alternative(_host_id_policy)) { + numa_region_infos.emplace_back(numa_region_info{.space_id = gpu.space_id, + .numa_id = gpu.numa_id, + .numa_capacity = numa_capacity, + .is_device_memory = is_device_memory}); + } else if (std::holds_alternative(_host_id_policy)) { + if (!numa_region_ids.contains(gpu.numa_id)) { + numa_region_infos.emplace_back(numa_region_info{.space_id = gpu.numa_id, + .numa_id = gpu.numa_id, + .numa_capacity = numa_capacity, + .is_device_memory = is_device_memory}); + numa_region_ids.insert(gpu.numa_id); } } } - return host_infos; + return numa_region_infos; } } // namespace memory diff --git a/src/memory/topology_discovery.cpp b/src/memory/topology_discovery.cpp index 03ca6637..c0445703 100644 --- a/src/memory/topology_discovery.cpp +++ b/src/memory/topology_discovery.cpp @@ -735,6 +735,26 @@ void read_numa_node_memory(fs::path const& numa_path, numa_topology_info& info) } } +/** + * @brief Read whether a NUMA node has any CPU assigned to it. + * + * Reads /sys/devices/system/node/node/cpulist, which is empty for CPU-less nodes. + * + * @param numa_path Path to the NUMA node directory. + * @return True if at least one CPU is assigned to the node; false if none or unreadable. + */ +bool numa_node_has_cpus(fs::path const& numa_path) +{ + std::ifstream cpulist(numa_path / "cpulist"); + if (!cpulist.is_open()) { return false; } + + std::string line; + while (std::getline(cpulist, line)) { + if (line.find_first_not_of(" \t\r\n") != std::string::npos) { return true; } + } + return false; +} + /** * @brief Discover NUMA nodes and their memory capacities. * @@ -742,6 +762,10 @@ void read_numa_node_memory(fs::path const& numa_path, numa_topology_info& info) * node's memory capacity. Returns an empty vector if the directory does not exist or * cannot be iterated. * + * A node that has memory but no CPU is not host memory: on DGX Station and Grace-Hopper + * the GPU's own HBM is exposed as such a node, as is CXL-attached memory. Those nodes are + * flagged with `is_device_memory` so that host memory spaces are never sized from them. + * * @return NUMA node information sorted by node id; empty if unavailable. */ std::vector discover_numa_nodes() @@ -770,6 +794,8 @@ std::vector discover_numa_nodes() continue; } read_numa_node_memory(entry.path(), info); + info.has_cpus = numa_node_has_cpus(entry.path()); + info.is_device_memory = !info.has_cpus && info.memory_capacity > 0; nodes.push_back(info); } } catch (...) { diff --git a/test/memory/test_memory_reservation_manager.cpp b/test/memory/test_memory_reservation_manager.cpp index 83ef96ae..94c0991f 100644 --- a/test/memory/test_memory_reservation_manager.cpp +++ b/test/memory/test_memory_reservation_manager.cpp @@ -61,9 +61,9 @@ std::unique_ptr createSingleDeviceMemoryManager() builder.set_gpu_usage_limit(expected_gpu_capacity); // 2 GB builder.set_gpu_memory_resource_factory(cucascade::test::make_shared_current_device_resource); builder.set_reservation_fraction_per_gpu(limit_ratio); - builder.set_per_host_capacity(expected_host_capacity); // 4 GB - builder.use_host_per_gpu(); - builder.set_reservation_fraction_per_host(limit_ratio); + builder.set_per_numa_region_capacity(expected_host_capacity); // 4 GB + builder.use_gpu_id_as_host_id(); + builder.set_reservation_fraction_per_numa_region(limit_ratio); auto space_configs = builder.build(); return std::make_unique(std::move(space_configs)); @@ -75,10 +75,10 @@ std::unique_ptr createDualGpuMemoryManager() builder.set_gpu_usage_limit(expected_gpu_capacity); // 2 GB builder.set_gpu_memory_resource_factory(cucascade::test::make_shared_current_device_resource); builder.set_reservation_fraction_per_gpu(limit_ratio); - builder.set_per_host_capacity(expected_host_capacity); // 4 GB + builder.set_per_numa_region_capacity(expected_host_capacity); // 4 GB builder.set_number_of_gpus(2); - builder.use_host_per_gpu(); - builder.set_reservation_fraction_per_host(limit_ratio); + builder.use_gpu_id_as_host_id(); + builder.set_reservation_fraction_per_numa_region(limit_ratio); auto space_configs = builder.build(); return std::make_unique(std::move(space_configs)); diff --git a/test/memory/test_reservation_manager_configurator.cpp b/test/memory/test_reservation_manager_configurator.cpp index 7ec47bf9..fcb5001a 100644 --- a/test/memory/test_reservation_manager_configurator.cpp +++ b/test/memory/test_reservation_manager_configurator.cpp @@ -66,9 +66,12 @@ bool make_single_gpu_topology(system_topology_info& topology, std::size_t numa_c if (topology.gpus.empty()) { return false; } topology.gpus.resize(1); - topology.num_gpus = 1; - topology.numa_nodes = { - numa_topology_info{topology.gpus.front().numa_node, numa_capacity, numa_capacity / 2}}; + topology.num_gpus = 1; + topology.numa_nodes = {numa_topology_info{.id = topology.gpus.front().numa_node, + .memory_capacity = numa_capacity, + .free_memory = numa_capacity / 2, + .has_cpus = true, + .is_device_memory = false}}; topology.num_numa_nodes = 1; return true; } @@ -85,8 +88,8 @@ TEST_CASE("Configurator sets host capacity as a fraction of NUMA capacity", "[co reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); - builder.set_usage_limit_ratio_per_host(0.25); + builder.use_numa_id_as_host_id(); + builder.set_usage_limit_ratio_per_numa_region(0.25); auto const hosts = host_configs(builder.build(topology)); @@ -106,8 +109,8 @@ TEST_CASE("Configurator sets host capacity in absolute bytes", "[configurator]") reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); - builder.set_per_host_capacity(requested); + builder.use_numa_id_as_host_id(); + builder.set_per_numa_region_capacity(requested); auto const hosts = host_configs(builder.build(topology)); @@ -127,9 +130,9 @@ TEST_CASE("Configurator host capacity fraction overrides a previous absolute set reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); + builder.use_numa_id_as_host_id(); builder.set_total_host_capacity(1ull << 30); - builder.set_usage_limit_ratio_per_host(0.5); + builder.set_usage_limit_ratio_per_numa_region(0.5); auto const hosts = host_configs(builder.build(topology)); @@ -150,9 +153,9 @@ TEST_CASE("Configurator reservation limit follows the fraction-derived host capa reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); - builder.set_usage_limit_ratio_per_host(0.5); - builder.set_reservation_limit_per_host(reservation_bytes); + builder.use_numa_id_as_host_id(); + builder.set_usage_limit_ratio_per_numa_region(0.5); + builder.set_reservation_limit_per_numa_region(reservation_bytes); auto const hosts = host_configs(builder.build(topology)); @@ -181,8 +184,8 @@ TEST_CASE("Configurator uses absolute host capacity when the NUMA node is unknow reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); - builder.set_per_host_capacity(requested); + builder.use_numa_id_as_host_id(); + builder.set_per_numa_region_capacity(requested); auto const hosts = host_configs(builder.build(topology)); @@ -208,7 +211,7 @@ TEST_CASE("Configurator uses total host capacity when the NUMA node is unknown", reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); + builder.use_numa_id_as_host_id(); builder.set_total_host_capacity(requested); auto const hosts = host_configs(builder.build(topology)); @@ -233,8 +236,8 @@ TEST_CASE( reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); - builder.set_usage_limit_ratio_per_host(0.5); + builder.use_numa_id_as_host_id(); + builder.set_usage_limit_ratio_per_numa_region(0.5); REQUIRE_THROWS_AS(builder.build(topology), std::runtime_error); } @@ -254,8 +257,8 @@ TEST_CASE("Configurator throws when NUMA capacity is unknown and a fraction is r reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); - builder.set_usage_limit_ratio_per_host(0.5); + builder.use_numa_id_as_host_id(); + builder.set_usage_limit_ratio_per_numa_region(0.5); REQUIRE_THROWS_AS(builder.build(topology), std::runtime_error); } @@ -273,20 +276,89 @@ TEST_CASE("Configurator resolves host capacity from discovered NUMA capacity", " auto const numa_id = topology.gpus.front().numa_node; auto const numa_capacity = topology.get_numa_memory_capacity(numa_id); - if (numa_capacity == 0) { - SUCCEED("Skipped: NUMA capacity is not exposed on this host"); + auto const* numa_node = topology.find_numa_node(numa_id); + if (!numa_capacity.has_value() || (numa_node != nullptr && numa_node->is_device_memory)) { + SUCCEED("Skipped: no host NUMA capacity exposed for the GPU's node"); return; } reservation_manager_configurator builder; builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); - builder.use_host_per_numa(); - builder.set_usage_limit_ratio_per_host(0.1); + builder.use_numa_id_as_host_id(); + builder.set_usage_limit_ratio_per_numa_region(0.1); auto const hosts = host_configs(builder.build(topology)); REQUIRE(hosts.size() == 1); REQUIRE(hosts.front().memory_capacity > 0); REQUIRE(hosts.front().memory_capacity == - static_cast(static_cast(numa_capacity) * 0.1)); + static_cast(static_cast(*numa_capacity) * 0.1)); +} + +// GPU HBM surfaces as a CPU-less NUMA node on DGX Station and Grace-Hopper. Sizing a host +// space from it would hand out device memory as if it were host memory. +TEST_CASE("Configurator throws when the backing NUMA region is device memory", "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + topology.numa_nodes.front().has_cpus = false; + topology.numa_nodes.front().is_device_memory = true; + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_numa_id_as_host_id(); + builder.set_usage_limit_ratio_per_numa_region(0.5); + + REQUIRE_THROWS_AS(builder.build(topology), std::runtime_error); +} + +// An absolute capacity is caller-supplied, so a device-memory node is not consulted at all. +TEST_CASE("Configurator honors an absolute capacity on a device-memory NUMA region", + "[configurator]") +{ + system_topology_info topology; + if (!make_single_gpu_topology(topology, synthetic_numa_capacity)) { + SUCCEED("Skipped: requires at least one GPU"); + return; + } + + topology.numa_nodes.front().has_cpus = false; + topology.numa_nodes.front().is_device_memory = true; + + constexpr std::size_t requested = 2ull << 30; // 2 GiB + + reservation_manager_configurator builder; + builder.set_gpu_ids({static_cast(topology.gpus.front().id)}); + builder.use_numa_id_as_host_id(); + builder.set_per_numa_region_capacity(requested); + + auto const hosts = host_configs(builder.build(topology)); + + REQUIRE(hosts.size() == 1); + REQUIRE(hosts.front().memory_capacity == requested); +} + +// Device-memory nodes are not host memory and must not inflate the host total. +TEST_CASE("Total NUMA capacity excludes device-memory nodes", "[configurator]") +{ + system_topology_info topology; + topology.numa_nodes = {numa_topology_info{.id = 0, + .memory_capacity = 1024, + .free_memory = 512, + .has_cpus = true, + .is_device_memory = false}, + numa_topology_info{.id = 1, + .memory_capacity = 4096, + .free_memory = 4096, + .has_cpus = false, + .is_device_memory = true}}; + + REQUIRE(topology.get_total_numa_memory_capacity() == 1024); + REQUIRE(topology.get_numa_memory_capacity(1).value() == 4096); + REQUIRE_FALSE(topology.get_numa_memory_capacity(7).has_value()); + REQUIRE(topology.find_numa_node(7) == nullptr); } diff --git a/test/memory/test_topology_discovery.cpp b/test/memory/test_topology_discovery.cpp index 4334a374..fcc79b4c 100644 --- a/test/memory/test_topology_discovery.cpp +++ b/test/memory/test_topology_discovery.cpp @@ -221,8 +221,8 @@ TEST_CASE("Topology Discovery reports NUMA node capacities", "[hw_topology]") return; } - size_t summed_capacity = 0; - int previous_id = -1; + size_t summed_host_capacity = 0; + int previous_id = -1; for (auto const& node : topology.numa_nodes) { INFO("NUMA node " << node.id); REQUIRE(node.id >= 0); @@ -234,17 +234,22 @@ TEST_CASE("Topology Discovery reports NUMA node capacities", "[hw_topology]") REQUIRE(node.memory_capacity > 0); REQUIRE(node.free_memory <= node.memory_capacity); - REQUIRE(topology.get_numa_memory_capacity(node.id) == node.memory_capacity); - REQUIRE(topology.get_numa_free_memory(node.id) == node.free_memory); - summed_capacity += node.memory_capacity; + // A node with memory but no CPUs is device memory (GPU HBM, CXL), not host memory. + REQUIRE(node.is_device_memory == !node.has_cpus); + + REQUIRE(topology.find_numa_node(node.id) != nullptr); + REQUIRE(topology.get_numa_memory_capacity(node.id).value() == node.memory_capacity); + REQUIRE(topology.get_numa_free_memory(node.id).value_or(0) == node.free_memory); + if (!node.is_device_memory) { summed_host_capacity += node.memory_capacity; } } - REQUIRE(topology.get_total_numa_memory_capacity() == summed_capacity); + REQUIRE(topology.get_total_numa_memory_capacity() == summed_host_capacity); - // Unknown node ids resolve to 0 rather than throwing. - REQUIRE(topology.get_numa_memory_capacity(-1) == 0); - REQUIRE(topology.get_numa_memory_capacity(topology.numa_nodes.back().id + 1) == 0); - REQUIRE(topology.get_numa_free_memory(-1) == 0); + // Unknown node ids are reported as such rather than silently resolving to 0. + REQUIRE_FALSE(topology.get_numa_memory_capacity(-1).has_value()); + REQUIRE_FALSE(topology.get_numa_memory_capacity(topology.numa_nodes.back().id + 1).has_value()); + REQUIRE_FALSE(topology.get_numa_free_memory(-1).has_value()); + REQUIRE(topology.find_numa_node(-1) == nullptr); } // Every GPU's NUMA node must be one of the discovered NUMA nodes with a known capacity, @@ -263,7 +268,9 @@ TEST_CASE("Topology Discovery maps GPUs to NUMA nodes with known capacity", "[hw for (auto const& gpu : topology.gpus) { INFO("GPU " << gpu.id << " on NUMA node " << gpu.numa_node); - REQUIRE(topology.get_numa_memory_capacity(gpu.numa_node) > 0); + auto const capacity = topology.get_numa_memory_capacity(gpu.numa_node); + REQUIRE(capacity.has_value()); + REQUIRE(*capacity > 0); } } diff --git a/test/utils/mock_test_utils.hpp b/test/utils/mock_test_utils.hpp index 916a12da..1af19cd3 100644 --- a/test/utils/mock_test_utils.hpp +++ b/test/utils/mock_test_utils.hpp @@ -137,8 +137,8 @@ inline std::vector create_conversion_test_configs() .set_gpu_usage_limit(2048ull * 1024 * 1024) // Use shared pooled allocator initialized once for all tests. .set_gpu_memory_resource_factory(make_shared_current_device_resource) - .use_host_per_gpu() - .set_per_host_capacity(4096ull * 1024 * 1024); + .use_gpu_id_as_host_id() + .set_per_numa_region_capacity(4096ull * 1024 * 1024); return builder.build(); }