From 9378a5e17f2e8406a0044c2d7157b03e6b0415e8 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 16:29:03 +0000 Subject: [PATCH 01/31] fix(rocjitsu): support DBT on multi-GPU hosts Resolve automatic DBT host selection against the effective ROCm-visible topology and publish the selected KFD GPU ID through the atomic runtime config handoff.\n\nAdd selector, handoff, and selection edge-case coverage.\n\nRefs #9212 --- emulation/rocjitsu/docs/rocjitsu_dbt_guest.md | 22 +- .../src/rocjitsu/config/dbt_guest_config.cpp | 46 +++- .../src/rocjitsu/config/dbt_guest_config.h | 20 +- .../src/rocjitsu/kmd/linux/interposer.cpp | 39 +-- emulation/rocjitsu/tests/config_test.cpp | 121 +++++++++ .../rocjitsu/tests/launch_preload_test.cpp | 130 ++++++++++ .../rocjitsu/tools/rocjitsu/CMakeLists.txt | 7 +- emulation/rocjitsu/tools/rocjitsu/main.cpp | 239 ++++++++---------- .../tools/rocjitsu/rocm_visibility.cpp | 199 +++++++++++++++ .../rocjitsu/tools/rocjitsu/rocm_visibility.h | 60 +++++ 10 files changed, 726 insertions(+), 157 deletions(-) create mode 100644 emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp create mode 100644 emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h diff --git a/emulation/rocjitsu/docs/rocjitsu_dbt_guest.md b/emulation/rocjitsu/docs/rocjitsu_dbt_guest.md index ae82c7940d8e..43716bf08cf9 100644 --- a/emulation/rocjitsu/docs/rocjitsu_dbt_guest.md +++ b/emulation/rocjitsu/docs/rocjitsu_dbt_guest.md @@ -26,6 +26,16 @@ running on A0 host) is rejected unless both revisions are set, because the required A0 workarounds cannot otherwise be selected. For every other target the machine ID already identifies the silicon, so these fields stay `unspecified`. +For either execution backend, `host_gpu_id: 0` selects the first `host_isa` +match in the effective ROCm-visible device order for that backend's topology. +The launcher applies +`ROCR_VISIBLE_DEVICES` first, including numeric reordering and UUID prefixes, +then `HIP_VISIBLE_DEVICES` or its `CUDA_VISIBLE_DEVICES` fallback. It atomically +publishes the config path and resolved KFD GPU ID in the per-invocation handoff, +so the KFD interposer and HSA hook consume one choice without a private +environment variable. Set a nonzero `host_gpu_id` to pin a particular GPU; the +launcher rejects the pin if standard ROCm visibility settings hide that device. + ## High Level Flow User: @@ -61,9 +71,15 @@ Underneath: keeps applications that choose the first GPU on the guest path while leaving the host agent alive for execution. - If `ROCR_VISIBLE_DEVICES` is set, the launcher expands it so the selected - host ordinal and appended guest ordinal both remain visible to ROCR before - the HSA hook applies this public shadowing. + If `ROCR_VISIBLE_DEVICES` is set, the launcher preserves its selected order + and expands it with the appended guest ordinal so both sides remain visible + internally before the HSA hook applies public shadowing. Because CLR applies + `HIP_VISIBLE_DEVICES` or `CUDA_VISIBLE_DEVICES` after HSA agent iteration, + the launcher normalizes an existing client selector to equivalent numeric + ordinals after ROCR filtering. This keeps host UUID selection meaningful + after the host's public identity is replaced by the guest. OpenCL's + `GPU_DEVICE_ORDINAL` remains owned by the client runtime and is not used to + choose the DBT host. 2. Guest-facing discovery stays guest-shaped. diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp index cd2f03a4983c..940bf8558aec 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp @@ -10,6 +10,7 @@ #include "flatbuffers/idl.h" #include "simulation_config_generated.h" +#include #include #include #include @@ -150,6 +151,39 @@ DbtGuestConfig load_dbt_guest_config_from_file(const std::string &path) { false); } +void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view value, + std::string_view source) { + if (!config.enabled || config.host.gpu_id != 0) + return; + + uint32_t gpu_id = 0; + const char *begin = value.data(); + const char *end = begin + value.size(); + auto [ptr, error] = std::from_chars(begin, end, gpu_id); + if (error != std::errc{} || ptr != end || gpu_id == 0) + throw std::runtime_error(std::string(source) + " must contain a nonzero KFD gpu_id"); + config.host.gpu_id = gpu_id; +} + +std::optional parse_dbt_runtime_config_handoff(std::string_view contents) { + const size_t first_newline = contents.find('\n'); + std::string_view config_path = contents.substr(0, first_newline); + if (!config_path.empty() && config_path.back() == '\r') + config_path.remove_suffix(1); + if (config_path.empty()) + return std::nullopt; + + std::optional resolved_gpu_id; + if (first_newline != std::string_view::npos) { + std::string_view second_line = contents.substr(first_newline + 1); + if (!second_line.empty()) { + const size_t second_newline = second_line.find_first_of("\r\n"); + resolved_gpu_id = second_line.substr(0, second_newline); + } + } + return DbtRuntimeConfigHandoff{std::string(config_path), std::move(resolved_gpu_id)}; +} + std::optional load_dbt_guest_config_from_runtime_config() { // Try the handoff tiers in priority order, opening the first that exists: // 1. $ROCJITSU_INVOCATION_DIR/config_path — the launcher exports this dir before @@ -176,11 +210,15 @@ std::optional load_dbt_guest_config_from_runtime_config() { if (!file.is_open()) return std::nullopt; - std::string path; - std::getline(file, path); - if (path.empty()) + const std::string contents((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + std::optional handoff = parse_dbt_runtime_config_handoff(contents); + if (!handoff) return std::nullopt; - return load_dbt_guest_config_from_file(path); + DbtGuestConfig config = load_dbt_guest_config_from_file(handoff->config_path); + if (handoff->resolved_gpu_id) + apply_resolved_dbt_host_gpu_id(config, *handoff->resolved_gpu_id, "runtime config handoff"); + return config; } } // namespace config diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h index 7d2c8e15f7d7..a7a2febd757c 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h @@ -12,6 +12,7 @@ #include #include #include +#include namespace rocjitsu::fb { struct DbtGuestConfig; @@ -39,7 +40,7 @@ enum class DbtSiliconRevision { /// @brief Host target selected for DBT translation and execution. struct DbtHostConfig { std::string isa; ///< Host ISA used for DBT output and ROCR execution. - uint32_t gpu_id = 0; ///< Host KFD topology gpu_id; 0 matches topology to isa. + uint32_t gpu_id = 0; ///< Host KFD topology gpu_id; 0 selects the first ISA match. DbtExecutionBackend backend = DbtExecutionBackend::Hardware; ///< Hardware or simulator execution. std::string simulator_config_path; ///< Optional external simulator host config. }; @@ -64,6 +65,12 @@ struct DbtGuestConfig { DbtSiliconRevision host_revision = DbtSiliconRevision::Unspecified; }; +/// @brief Parsed per-invocation config handoff shared by child runtime layers. +struct DbtRuntimeConfigHandoff { + std::string config_path; + std::optional resolved_gpu_id; +}; + /// @brief Resolve the simulator host config selected by a DBT guest config. /// @details An empty host_config_path selects dbt_config_path itself. Relative /// external paths are resolved beside the DBT guest config. A non-empty path @@ -96,6 +103,17 @@ DbtGuestConfig dbt_guest_from_fb(const fb::DbtGuestConfig *guest); /// @throws std::runtime_error on file I/O, parse errors, or invalid config. DbtGuestConfig load_dbt_guest_config_from_file(const std::string &path); +/// @brief Apply the launcher's resolved GPU to an automatic DBT host config. +/// @details Child processes read this value from the second line of the +/// per-invocation config handoff. Explicit nonzero config values remain authoritative. +/// @throws std::runtime_error when @p value is not a nonzero KFD gpu_id. +void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view value, + std::string_view source); + +/// @brief Parse the config path and optional resolved GPU ID from a runtime handoff. +/// @returns std::nullopt when the first line does not contain a config path. +std::optional parse_dbt_runtime_config_handoff(std::string_view contents); + /// @brief Load only dbt_guest from the rocjitsu child-process runtime config file. /// /// @details HSA tools run inside ROCR initialization and must not depend on the diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp index fd464c98396b..ed0ecc00325e 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp @@ -160,12 +160,12 @@ int kfd_ioctl_ret(int r) { return r; } -/// @brief Read the child-process rocjitsu config path from @p cfg_file. +/// @brief Read the child-process rocjitsu config handoff from @p cfg_file. /// -/// @details The launcher writes the config path to a runtime file (per-PID -/// invocation directory) that the interposer reads back for both local -/// simulation and DBT guest mode. -std::optional child_config_path(const std::string &cfg_file) { +/// @details The first line is the config path. DBT launches include the resolved +/// host KFD gpu_id on the second line. +std::optional +child_config_handoff(const std::string &cfg_file) { char cfg_buf[4096]{}; auto &real = rocjitsu::libc_passthrough(); int cfg_fd = real.openat(AT_FDCWD, cfg_file.c_str(), O_RDONLY, 0); @@ -177,9 +177,8 @@ std::optional child_config_path(const std::string &cfg_file) { if (n <= 0) return std::nullopt; - while (n > 0 && (cfg_buf[n - 1] == '\n' || cfg_buf[n - 1] == '\r')) - cfg_buf[--n] = '\0'; - return std::string(cfg_buf); + return rocjitsu::config::parse_dbt_runtime_config_handoff( + std::string_view(cfg_buf, static_cast(n))); } void *raw_mmap_syscall(void *addr, size_t length, int prot, int flags, int fd, off_t offset) { @@ -1161,31 +1160,34 @@ class InterposerContext { cfg_candidates.push_back(invocation_runtime_dir() + "/config_path"); cfg_candidates.push_back(rocjitsu::rpc_invocation_config_file_path(getpid())); cfg_candidates.push_back(rocjitsu::rpc_default_config_file_path()); - std::optional cfg_path; + std::optional handoff; std::string tried_last; for (const auto &candidate : cfg_candidates) { if (candidate == tried_last) continue; // Skip a duplicate tier (e.g. env unset collapses 1 and 2). tried_last = candidate; - cfg_path = child_config_path(candidate); - if (cfg_path) + handoff = child_config_handoff(candidate); + if (handoff) break; } - if (!cfg_path) { + if (!handoff) { util::Logger::debug_print("rocjitsu: no child config path"); in_construction = false; return nullptr; } try { - auto dbt_guest = rocjitsu::config::load_dbt_guest_config_from_file(*cfg_path); + auto dbt_guest = rocjitsu::config::load_dbt_guest_config_from_file(handoff->config_path); + if (handoff->resolved_gpu_id) + rocjitsu::config::apply_resolved_dbt_host_gpu_id(dbt_guest, *handoff->resolved_gpu_id, + "runtime config handoff"); if (dbt_guest.enabled) { LinuxKfd *execution_driver = nullptr; const bool simulator_backend = dbt_guest.host.backend == rocjitsu::config::DbtExecutionBackend::Simulator; if (simulator_backend) { const std::string host_config_path = rocjitsu::config::resolve_dbt_host_config_path( - *cfg_path, dbt_guest.host.simulator_config_path); + handoff->config_path, dbt_guest.host.simulator_config_path); if (!create_local_vm(host_config_path)) { in_construction = false; return nullptr; @@ -1212,16 +1214,17 @@ class InterposerContext { in_construction = false; return driver; } + + if (!create_local_vm(handoff->config_path)) { + in_construction = false; + return nullptr; + } } catch (const std::exception &e) { util::Logger::debug_print("rocjitsu: failed to load child config: ", e.what()); destroy_local_vm(); in_construction = false; return nullptr; } - if (!create_local_vm(*cfg_path)) { - in_construction = false; - return nullptr; - } // Publish the fully-constructed, not-yet-running driver BEFORE starting the // engine thread: the release-store of active_driver_ pairs with acquire diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 05130f51f04b..c76c2d3c00c6 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -11,6 +11,7 @@ #include "rocjitsu/isa/arch/amdgpu/cdna3/isa.h" #include "rocjitsu/isa/arch/amdgpu/shared/accvgpr_layout.h" #include "rocjitsu/kmd/linux/amdgpu_properties.h" +#include "rocjitsu/kmd/linux/rpc.h" #include "rocjitsu/vm/rj_vm.h" #include "rocjitsu/vm/soc.h" #include "rocjitsu/vm/virtual_machine.h" @@ -26,10 +27,12 @@ RJ_DIAGNOSTIC_POP #include #include +#include #include #include #include #include +#include #include #include #include @@ -49,6 +52,26 @@ std::filesystem::path write_temp_config(std::string_view name, std::string_view return path; } +class ScopedEnvironmentVariable { +public: + ScopedEnvironmentVariable(const char *name, const std::string &value) : name_(name) { + if (const char *original = std::getenv(name)) + original_ = original; + setenv(name_.c_str(), value.c_str(), 1); + } + + ~ScopedEnvironmentVariable() { + if (original_) + setenv(name_.c_str(), original_->c_str(), 1); + else + unsetenv(name_.c_str()); + } + +private: + std::string name_; + std::optional original_; +}; + TEST(ConfigLoaderTest, LoadCdna4Config) { std::string json = CONFIG_DIR_PATH + "/gfx950_cdna4.json"; auto loaded = config::load_config(json, rocjitsu::kEmbeddedSchema); @@ -562,6 +585,104 @@ TEST(ConfigLoaderTest, LoadsExplicitHardwareDbtBackendConfig) { EXPECT_EQ(dbt.host.backend, config::DbtExecutionBackend::Hardware); } +TEST(ConfigLoaderTest, AppliesResolvedDbtHostGpuId) { + config::DbtGuestConfig automatic; + automatic.enabled = true; + automatic.host.backend = config::DbtExecutionBackend::Hardware; + config::DbtGuestConfig explicit_id = automatic; + explicit_id.host.gpu_id = 8716; + config::DbtGuestConfig simulator = automatic; + simulator.host.backend = config::DbtExecutionBackend::Simulator; + + config::apply_resolved_dbt_host_gpu_id(automatic, "28851", "test handoff"); + config::apply_resolved_dbt_host_gpu_id(explicit_id, "28851", "test handoff"); + config::apply_resolved_dbt_host_gpu_id(simulator, "28851", "test handoff"); + + EXPECT_EQ(automatic.host.gpu_id, 28851u); + EXPECT_EQ(explicit_id.host.gpu_id, 8716u); + EXPECT_EQ(simulator.host.gpu_id, 28851u); +} + +TEST(ConfigLoaderTest, RejectsInvalidResolvedDbtHostGpuId) { + const std::array invalid_values = {"", "0", "not-a-number", "28851 trailing", + "4294967296"}; + for (std::string_view value : invalid_values) { + config::DbtGuestConfig dbt; + dbt.enabled = true; + EXPECT_THROW(config::apply_resolved_dbt_host_gpu_id(dbt, value, "test handoff"), + std::runtime_error) + << value; + } +} + +TEST(ConfigLoaderTest, ExplicitDbtHostGpuIdOverridesResolvedHandoff) { + config::DbtGuestConfig dbt; + dbt.enabled = true; + dbt.host.gpu_id = 8716; + + EXPECT_NO_THROW( + config::apply_resolved_dbt_host_gpu_id(dbt, "invalid-but-ignored", "test handoff")); + EXPECT_EQ(dbt.host.gpu_id, 8716u); +} + +TEST(ConfigLoaderTest, ParsesRuntimeConfigHandoff) { + const auto dbt = config::parse_dbt_runtime_config_handoff("/tmp/config.json\r\n28851\r\n"); + ASSERT_TRUE(dbt); + EXPECT_EQ(dbt->config_path, "/tmp/config.json"); + ASSERT_TRUE(dbt->resolved_gpu_id); + EXPECT_EQ(*dbt->resolved_gpu_id, "28851"); + + const auto non_dbt = config::parse_dbt_runtime_config_handoff("/tmp/config.json"); + ASSERT_TRUE(non_dbt); + EXPECT_EQ(non_dbt->config_path, "/tmp/config.json"); + EXPECT_FALSE(non_dbt->resolved_gpu_id); + + const auto newline_terminated = config::parse_dbt_runtime_config_handoff("/tmp/config.json\n"); + ASSERT_TRUE(newline_terminated); + EXPECT_FALSE(newline_terminated->resolved_gpu_id); + EXPECT_FALSE(config::parse_dbt_runtime_config_handoff("\n28851\n")); +} + +TEST(ConfigLoaderTest, RejectsEmptyResolvedGpuIdLineForEnabledDbt) { + const auto handoff = config::parse_dbt_runtime_config_handoff("/tmp/config.json\n\n"); + ASSERT_TRUE(handoff); + ASSERT_TRUE(handoff->resolved_gpu_id); + + config::DbtGuestConfig dbt; + dbt.enabled = true; + EXPECT_THROW( + config::apply_resolved_dbt_host_gpu_id(dbt, *handoff->resolved_gpu_id, "test handoff"), + std::runtime_error); +} + +TEST(ConfigLoaderTest, LoadsDbtRuntimeConfigHandoffFromInvocationDirectory) { + const std::filesystem::path runtime = + std::filesystem::temp_directory_path() / "rocjitsu_runtime_config_handoff_test"; + std::filesystem::create_directories(runtime); + const std::filesystem::path config_path = + write_temp_config("rocjitsu_runtime_dbt_config_test.json", R"({ + "dbt_guest": { + "enabled": true, + "guest_isa": "gfx950", + "host_isa": "gfx942" + } + })"); + { + std::ofstream handoff(runtime / "config_path"); + handoff << config_path.string() << "\n28851\n"; + } + ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.string()); + + const std::optional loaded = + config::load_dbt_guest_config_from_runtime_config(); + + ASSERT_TRUE(loaded); + EXPECT_TRUE(loaded->enabled); + EXPECT_EQ(loaded->host.gpu_id, 28851u); + std::filesystem::remove(config_path); + std::filesystem::remove_all(runtime); +} + TEST(ConfigLoaderTest, RejectsEmptyDbtExecutionBackend) { const std::filesystem::path path = write_temp_config("rocjitsu_empty_dbt_backend_config_test.json", R"({ diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index c4e630812dfe..b45b52e59d71 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT #include "launch_preload.h" +#include "rocm_visibility.h" #include #include @@ -54,6 +55,12 @@ void expect_ld_preload_eq(const rocjitsu::cli::LaunchEnvironment &environment, EXPECT_EQ(expected, ld_preload); } +std::vector test_gpus() { + return {{0, 100, 90402, 0x1111111111111111ULL}, + {1, 101, 90402, 0x2222222222222222ULL}, + {2, 102, 120001, 0x3333333333333333ULL}}; +} + #if defined(RJ_EXPECT_SHARED_ASAN_RUNTIME) || defined(RJ_EXPECT_SHARED_TSAN_RUNTIME) std::string canonical_existing_path(const std::filesystem::path &path) { std::error_code ec; @@ -97,6 +104,129 @@ TEST(LaunchPreloadTest, EnvpCacheRebuildsAfterSet) { EXPECT_NE(rebuilt.end(), std::find(rebuilt.begin(), rebuilt.end(), after)); } +TEST(RocmVisibilityTest, UnsetAndEmptyRocrSelectorsDiffer) { + const auto gpus = test_gpus(); + EXPECT_EQ(3u, rocjitsu::cli::filter_rocr_visible_gpus(gpus, std::nullopt).size()); + EXPECT_TRUE(rocjitsu::cli::filter_rocr_visible_gpus(gpus, std::string_view{}).empty()); +} + +TEST(RocmVisibilityTest, NumericAndUuidSelectorsReorderDevices) { + const auto gpus = test_gpus(); + const auto numeric = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "1,0"); + const auto uuid = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "gpu-2222"); + + ASSERT_EQ(2u, numeric.size()); + EXPECT_EQ(101u, numeric[0].gpu_id); + EXPECT_EQ(100u, numeric[1].gpu_id); + ASSERT_EQ(1u, uuid.size()); + EXPECT_EQ(101u, uuid[0].gpu_id); +} + +TEST(RocmVisibilityTest, AmbiguousRocrUuidTerminatesAfterValidPrefix) { + const std::vector gpus{{0, 100, 90402, 0x1111111111111111ULL}, + {1, 101, 90402, 0x2222222211111111ULL}, + {2, 102, 90402, 0x2222222244444444ULL}}; + const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "0,GPU-2222,1"); + + ASSERT_EQ(1u, selected.size()); + EXPECT_EQ(100u, selected[0].gpu_id); +} + +TEST(RocmVisibilityTest, ClientUuidUsesCaseSensitiveFirstMatch) { + const std::vector gpus{{0, 100, 90402, 0x1111111111111111ULL}, + {1, 101, 90402, 0x1111111111111122ULL}}; + const auto first = rocjitsu::cli::filter_client_visible_gpus(gpus, "GPU-1111"); + const auto lowercase = rocjitsu::cli::filter_client_visible_gpus(gpus, "gpu-1111"); + + ASSERT_EQ(1u, first.size()); + EXPECT_EQ(100u, first[0].gpu_id); + EXPECT_TRUE(lowercase.empty()); +} + +TEST(RocmVisibilityTest, InvalidRocrTokenPreservesValidPrefix) { + const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "1,invalid,0"); + ASSERT_EQ(1u, selected.size()); + EXPECT_EQ(101u, selected[0].gpu_id); +} + +TEST(RocmVisibilityTest, NegativeRocrOrdinalPreservesValidPrefix) { + const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,-1,1"); + ASSERT_EQ(1u, selected.size()); + EXPECT_EQ(100u, selected[0].gpu_id); +} + +TEST(RocmVisibilityTest, HipSelectorUsesPostRocrOrdinals) { + const auto selected = + rocjitsu::cli::effective_visible_gpus(test_gpus(), "1,0", "1", std::nullopt); + ASSERT_EQ(1u, selected.size()); + EXPECT_EQ(100u, selected[0].gpu_id); +} + +TEST(RocmVisibilityTest, HipSelectorTakesPrecedenceOverCudaFallback) { + const auto hip = rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, "1", "2"); + const auto cuda = + rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, std::string_view{}, "2"); + + ASSERT_EQ(1u, hip.size()); + EXPECT_EQ(101u, hip[0].gpu_id); + ASSERT_EQ(1u, cuda.size()); + EXPECT_EQ(102u, cuda[0].gpu_id); +} + +TEST(RocmVisibilityTest, NormalizesClientUuidToPostRocrOrdinal) { + const auto normalized = rocjitsu::cli::normalized_client_visible_devices( + test_gpus(), "1,0", "GPU-1111111111111111", std::nullopt); + ASSERT_TRUE(normalized); + EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); + EXPECT_EQ("1", normalized->value); +} + +TEST(RocmVisibilityTest, NormalizesClientSelectorAgainstExpandedRocrOrder) { + const auto normalized = + rocjitsu::cli::normalized_client_visible_devices(test_gpus(), "1,0,3", "1", std::nullopt); + ASSERT_TRUE(normalized); + EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); + EXPECT_EQ("1", normalized->value); +} + +TEST(RocmVisibilityTest, ExpandsRocrSelectionWithGuestOrdinal) { + const auto expanded = rocjitsu::cli::expanded_rocr_visible_devices(test_gpus(), "GPU-2222"); + ASSERT_TRUE(expanded); + EXPECT_EQ("1,3", *expanded); +} + +TEST(RocmVisibilityTest, DoesNotExpandEmptyRocrSelection) { + EXPECT_FALSE(rocjitsu::cli::expanded_rocr_visible_devices(test_gpus(), "5")); +} + +TEST(RocmVisibilityTest, SelectsFirstVisibleIsaMatch) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(test_gpus(), 0, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, selection.status); + EXPECT_EQ(100u, selection.gpu_id); +} + +TEST(RocmVisibilityTest, RejectsHiddenExplicitGpu) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu({test_gpus()[0]}, 101, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuHidden, selection.status); + EXPECT_EQ(101u, selection.gpu_id); +} + +TEST(RocmVisibilityTest, RejectsExplicitGpuWithDifferentIsa) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(test_gpus(), 102, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuIsaMismatch, selection.status); + EXPECT_EQ(102u, selection.gpu_id); +} + +TEST(RocmVisibilityTest, ReportsMissingIsaMatch) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(test_gpus(), 0, 999999); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::NoIsaMatch, selection.status); + EXPECT_EQ(0u, selection.gpu_id); +} + TEST(LaunchPreloadTest, NoAsanPrependsInterposerBeforeExistingPreload) { #if defined(RJ_EXPECT_SHARED_ASAN_RUNTIME) || defined(RJ_EXPECT_SHARED_TSAN_RUNTIME) GTEST_SKIP() << "shared sanitizer builds exercise sanitizer ordering cases"; diff --git a/emulation/rocjitsu/tools/rocjitsu/CMakeLists.txt b/emulation/rocjitsu/tools/rocjitsu/CMakeLists.txt index 076c1807958c..c9753f37487c 100644 --- a/emulation/rocjitsu/tools/rocjitsu/CMakeLists.txt +++ b/emulation/rocjitsu/tools/rocjitsu/CMakeLists.txt @@ -3,7 +3,12 @@ include(rj_configure_target) -add_library(rocjitsu_cli_launch_preload STATIC launch_preload.cpp) +add_library( + rocjitsu_cli_launch_preload + STATIC + launch_preload.cpp + rocm_visibility.cpp +) target_include_directories( rocjitsu_cli_launch_preload PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} diff --git a/emulation/rocjitsu/tools/rocjitsu/main.cpp b/emulation/rocjitsu/tools/rocjitsu/main.cpp index 33c3603494db..d0a0db548cd3 100644 --- a/emulation/rocjitsu/tools/rocjitsu/main.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/main.cpp @@ -19,6 +19,7 @@ #include "embedded_schema.h" #include "launch_preload.h" +#include "rocm_visibility.h" #include #include @@ -171,14 +172,28 @@ std::string find_interposer_lib() { return find_runtime_lib("librocjitsu.so"); } std::string find_hooks_lib() { return find_runtime_lib("librocjitsu_hooks.so"); } -bool write_config_file(const std::string &config_path, pid_t pid) { +bool write_config_file(const std::string &config_path, + const rocjitsu::config::DbtGuestConfig &dbt_guest, pid_t pid) { auto cfg_file = rpc_invocation_config_file_path(pid); std::filesystem::create_directories(std::filesystem::path(cfg_file).parent_path()); - std::ofstream ofs(cfg_file); + const std::string temp_file = cfg_file + ".tmp"; + std::ofstream ofs(temp_file); if (!ofs) return false; ofs << config_path << '\n'; - return ofs.good(); + if (dbt_guest.enabled) + ofs << dbt_guest.host.gpu_id << '\n'; + ofs.close(); + if (!ofs.good()) { + std::filesystem::remove(temp_file); + return false; + } + + std::error_code rename_error; + std::filesystem::rename(temp_file, cfg_file, rename_error); + if (rename_error) + std::filesystem::remove(temp_file); + return !rename_error; } void cleanup_runtime_files(pid_t pid) { @@ -231,12 +246,7 @@ void reap_stale_runtime_dirs() { } } -struct KfdGpuOrdinal { - uint32_t ordinal = 0; - uint32_t node_id = 0; - uint32_t gpu_id = 0; - uint32_t gfx_target_version = 0; -}; +using KfdGpuOrdinal = rocjitsu::cli::VisibleGpu; std::optional parse_u32(std::string_view text) { uint32_t value = 0; @@ -248,14 +258,6 @@ std::optional parse_u32(std::string_view text) { return value; } -std::string_view trim(std::string_view text) { - while (!text.empty() && std::isspace(static_cast(text.front()))) - text.remove_prefix(1); - while (!text.empty() && std::isspace(static_cast(text.back()))) - text.remove_suffix(1); - return text; -} - std::optional read_u32_file(const std::filesystem::path &path) { std::ifstream in(path); uint32_t value = 0; @@ -275,6 +277,17 @@ std::optional read_u32_property(const std::filesystem::path &path, std return std::nullopt; } +std::optional read_u64_property(const std::filesystem::path &path, std::string_view key) { + std::ifstream in(path); + std::string name; + uint64_t value = 0; + while (in >> name >> value) { + if (name == key) + return value; + } + return std::nullopt; +} + std::vector real_kfd_gpu_ordinals() { std::filesystem::path nodes_dir = "/sys/devices/virtual/kfd/kfd/topology/nodes"; if (!std::filesystem::exists(nodes_dir)) @@ -284,6 +297,7 @@ std::vector real_kfd_gpu_ordinals() { uint32_t node_id = 0; uint32_t gpu_id = 0; uint32_t gfx_target_version = 0; + uint64_t unique_id = 0; }; std::vector nodes; @@ -299,9 +313,10 @@ std::vector real_kfd_gpu_ordinals() { auto gpu_id = read_u32_file(entry.path() / "gpu_id"); if (gpu_id && *gpu_id != 0) { - uint32_t gfx_target_version = - read_u32_property(entry.path() / "properties", "gfx_target_version").value_or(0); - nodes.push_back({*node_id, *gpu_id, gfx_target_version}); + const std::filesystem::path properties = entry.path() / "properties"; + uint32_t gfx_target_version = read_u32_property(properties, "gfx_target_version").value_or(0); + uint64_t unique_id = read_u64_property(properties, "unique_id").value_or(0); + nodes.push_back({*node_id, *gpu_id, gfx_target_version, unique_id}); } } @@ -311,126 +326,74 @@ std::vector real_kfd_gpu_ordinals() { std::vector gpus; gpus.reserve(nodes.size()); for (uint32_t ordinal = 0; ordinal < nodes.size(); ++ordinal) - gpus.push_back({ordinal, nodes[ordinal].node_id, nodes[ordinal].gpu_id, - nodes[ordinal].gfx_target_version}); + gpus.push_back({ordinal, nodes[ordinal].gpu_id, nodes[ordinal].gfx_target_version, + nodes[ordinal].unique_id}); return gpus; } -/// @brief Reject implicit host selection when more than one GPU has the host ISA. -/// -/// @details The launcher, GuestKfd, and HSA tools hook discover the host through -/// separate views of KFD and ROCR. Selecting the first ISA match independently -/// is only well-defined when that match is unique. On a multi-GPU host, require -/// the config to name the shared KFD gpu_id so every layer routes to the same -/// physical GPU. -bool has_unambiguous_host_gpu(const rocjitsu::config::DbtGuestConfig &dbt_guest) { - if (dbt_guest.host.gpu_id != 0) - return true; +std::optional environment_value(const char *name) { + const char *value = std::getenv(name); + return value == nullptr ? std::nullopt : std::optional(value); +} - std::optional target_version = - rocjitsu::kmd::gfx_target_version_from_name(dbt_guest.host.isa); - if (!target_version) - return true; +/// @brief Resolve automatic host selection once for every DBT runtime layer. +bool resolve_host_gpu(rocjitsu::config::DbtGuestConfig *dbt_guest, + const std::vector &topology) { + const std::vector visible_gpus = rocjitsu::cli::effective_visible_gpus( + topology, environment_value("ROCR_VISIBLE_DEVICES"), environment_value("HIP_VISIBLE_DEVICES"), + environment_value("CUDA_VISIBLE_DEVICES")); - std::vector matching_gpu_ids; - for (const KfdGpuOrdinal &gpu : real_kfd_gpu_ordinals()) { - if (gpu.gfx_target_version == *target_version) - matching_gpu_ids.push_back(gpu.gpu_id); + std::optional target_version = + rocjitsu::kmd::gfx_target_version_from_name(dbt_guest->host.isa); + if (!target_version) { + std::cerr << std::format("rocjitsu: unrecognized dbt_guest.host_isa '{}'\n", + dbt_guest->host.isa); + return false; } - if (matching_gpu_ids.size() <= 1) - return true; - - std::cerr << std::format( - "rocjitsu: dbt_guest.host_isa '{}' matches {} host GPUs; set host_gpu_id to one of:", - dbt_guest.host.isa, matching_gpu_ids.size()); - for (uint32_t gpu_id : matching_gpu_ids) - std::cerr << ' ' << gpu_id; - std::cerr << '\n'; - return false; -} -bool append_unique(std::vector *tokens, std::string token) { - if (token.empty()) + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(visible_gpus, dbt_guest->host.gpu_id, *target_version); + if (selection.status == rocjitsu::cli::HostSelectionStatus::Selected) { + dbt_guest->host.gpu_id = selection.gpu_id; + return true; + } + if (selection.status == rocjitsu::cli::HostSelectionStatus::ExplicitGpuHidden) { + std::cerr << std::format( + "rocjitsu: dbt_guest.host_gpu_id {} is hidden by ROCm device visibility settings\n", + dbt_guest->host.gpu_id); return false; - if (std::find(tokens->begin(), tokens->end(), token) != tokens->end()) + } + if (selection.status == rocjitsu::cli::HostSelectionStatus::ExplicitGpuIsaMismatch) { + std::cerr << std::format("rocjitsu: dbt_guest.host_gpu_id {} does not match host_isa '{}'\n", + dbt_guest->host.gpu_id, dbt_guest->host.isa); return false; - tokens->push_back(std::move(token)); - return true; -} - -std::string join_comma(const std::vector &tokens) { - std::string result; - for (size_t i = 0; i < tokens.size(); ++i) { - if (i != 0) - result += ','; - result += tokens[i]; } - return result; -} - -std::optional -expanded_rocr_visible_devices(const rocjitsu::config::DbtGuestConfig &dbt_guest) { - const char *visible = std::getenv("ROCR_VISIBLE_DEVICES"); - if (visible == nullptr || *visible == '\0') - return std::nullopt; - std::vector gpus = real_kfd_gpu_ordinals(); - if (gpus.empty()) - return std::nullopt; - - uint32_t host_ordinal = 0; - if (dbt_guest.host.gpu_id != 0) { - auto match = std::find_if(gpus.begin(), gpus.end(), [&](const KfdGpuOrdinal &gpu) { - return gpu.gpu_id == dbt_guest.host.gpu_id; - }); - if (match == gpus.end()) - return std::nullopt; - host_ordinal = match->ordinal; - } else { - std::optional target_version = - rocjitsu::kmd::gfx_target_version_from_name(dbt_guest.host.isa); - if (!target_version) - return std::nullopt; + std::cerr << std::format("rocjitsu: no host GPU matches dbt_guest.host_isa '{}'\n", + dbt_guest->host.isa); + return false; +} - auto match = std::find_if(gpus.begin(), gpus.end(), [&](const KfdGpuOrdinal &gpu) { - return gpu.gfx_target_version == *target_version; - }); - if (match == gpus.end()) - return std::nullopt; - host_ordinal = match->ordinal; - } +std::vector +dbt_execution_gpu_ordinals(const std::string &dbt_config_path, + const rocjitsu::config::DbtGuestConfig &dbt_guest) { + if (dbt_guest.host.backend == rocjitsu::config::DbtExecutionBackend::Hardware) + return real_kfd_gpu_ordinals(); - const uint32_t guest_ordinal = static_cast(gpus.size()); - std::vector expanded; - std::string_view rest = visible; - bool changed = false; - - while (true) { - size_t comma = rest.find(','); - std::string_view raw = comma == std::string_view::npos ? rest : rest.substr(0, comma); - std::string_view token = trim(raw); - if (!token.empty()) { - std::optional ordinal = parse_u32(token); - if (ordinal && (*ordinal == host_ordinal || *ordinal == guest_ordinal)) { - // ROCR filters topology before HSA tools callbacks. Include both the - // hidden host and appended guest internally so our HSA iteration hook - // can present one public replacement agent. - changed = append_unique(&expanded, std::to_string(host_ordinal)) || changed; - changed = append_unique(&expanded, std::to_string(guest_ordinal)) || changed; - } else { - changed = append_unique(&expanded, std::string(token)) || changed; - } - } + const std::string host_config_path = rocjitsu::config::resolve_dbt_host_config_path( + dbt_config_path, dbt_guest.host.simulator_config_path); + rocjitsu::config::LoadedConfig loaded = + rocjitsu::config::load_config(host_config_path, rocjitsu::kEmbeddedSchema); + std::vector devices = loaded.devices; + if (devices.empty() && loaded.device.present) + devices.push_back(loaded.device); - if (comma == std::string_view::npos) - break; - rest.remove_prefix(comma + 1); + std::vector gpus; + for (uint32_t ordinal = 0; ordinal < devices.size(); ++ordinal) { + const auto &device = devices[ordinal]; + gpus.push_back({ordinal, device.gpu_id, device.gfx_target_version, device.unique_id}); } - - std::string rewritten = join_comma(expanded); - if (!rewritten.empty() && rewritten != visible) - return rewritten; - return std::nullopt; + return gpus; } void print_usage() { @@ -536,12 +499,19 @@ int main(int argc, char *argv[]) { } std::string hooks_path; + std::vector dbt_execution_gpus; if (dbt_guest_mode) { if (dbt_guest_config.guest_isa.empty() || dbt_guest_config.host.isa.empty()) { std::cerr << "rocjitsu: dbt_guest requires guest_isa and host_isa\n"; return 1; } - if (!has_unambiguous_host_gpu(dbt_guest_config)) + try { + dbt_execution_gpus = dbt_execution_gpu_ordinals(abs_config, dbt_guest_config); + } catch (const std::exception &e) { + std::cerr << std::format("rocjitsu: failed to load DBT execution topology: {}\n", e.what()); + return 1; + } + if (!resolve_host_gpu(&dbt_guest_config, dbt_execution_gpus)) return 1; hooks_path = find_hooks_lib(); if (hooks_path.empty()) { @@ -607,7 +577,7 @@ int main(int argc, char *argv[]) { return 1; } } else { - if (!write_config_file(abs_config, my_pid)) { + if (!write_config_file(abs_config, dbt_guest_config, my_pid)) { std::cerr << "rocjitsu: failed to write config file\n"; cleanup_runtime_files(my_pid); return 1; @@ -617,9 +587,18 @@ int main(int argc, char *argv[]) { rocjitsu::cli::LaunchEnvironment launch_environment; rocjitsu::cli::prepend_launch_preloads(launch_environment, lib_path); if (dbt_guest_mode) { - if (std::optional rocr_visible_devices = - expanded_rocr_visible_devices(dbt_guest_config)) - launch_environment.set("ROCR_VISIBLE_DEVICES", *rocr_visible_devices); + std::optional child_rocr_visible = environment_value("ROCR_VISIBLE_DEVICES"); + std::optional expanded_rocr_visible = + rocjitsu::cli::expanded_rocr_visible_devices(dbt_execution_gpus, child_rocr_visible); + if (expanded_rocr_visible) { + launch_environment.set("ROCR_VISIBLE_DEVICES", *expanded_rocr_visible); + child_rocr_visible = *expanded_rocr_visible; + } + if (std::optional client_visible = + rocjitsu::cli::normalized_client_visible_devices( + dbt_execution_gpus, child_rocr_visible, environment_value("HIP_VISIBLE_DEVICES"), + environment_value("CUDA_VISIBLE_DEVICES"))) + launch_environment.set(client_visible->name, client_visible->value); // The HSA hook still uses the legacy tools callback path. Disable only the // rocprofiler-register table-delivery path so it cannot validate an // unshadowed table before rocjitsu installs guest-agent wrappers. diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp new file mode 100644 index 000000000000..37dc4a8dfd18 --- /dev/null +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp @@ -0,0 +1,199 @@ +// Copyright (c) 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: MIT + +#include "rocm_visibility.h" + +#include +#include +#include +#include +#include + +namespace rocjitsu::cli { +namespace { + +std::string_view trim(std::string_view text) { + while (!text.empty() && std::isspace(static_cast(text.front()))) + text.remove_prefix(1); + while (!text.empty() && std::isspace(static_cast(text.back()))) + text.remove_suffix(1); + return text; +} + +std::string gpu_uuid(uint64_t unique_id) { return std::format("GPU-{:016X}", unique_id); } + +std::string join_comma(const std::vector &tokens) { + std::string result; + for (size_t i = 0; i < tokens.size(); ++i) { + if (i != 0) + result += ','; + result += tokens[i]; + } + return result; +} + +std::optional> +client_selector(std::optional hip_visible, + std::optional cuda_visible) { + if (hip_visible && !hip_visible->empty()) + return std::pair{"HIP_VISIBLE_DEVICES", *hip_visible}; + if (cuda_visible && !cuda_visible->empty()) + return std::pair{"CUDA_VISIBLE_DEVICES", *cuda_visible}; + return std::nullopt; +} + +} // namespace + +std::vector filter_rocr_visible_gpus(const std::vector &gpus, + std::optional selector) { + if (!selector) + return gpus; + + std::vector filtered; + std::string_view rest = *selector; + while (!rest.empty() && filtered.size() < gpus.size()) { + const size_t comma = rest.find(','); + std::string token(trim(comma == std::string_view::npos ? rest : rest.substr(0, comma))); + std::transform(token.begin(), token.end(), token.begin(), + [](unsigned char c) { return static_cast(std::toupper(c)); }); + + std::optional index; + if (token.starts_with("GPU-") && token != "GPU-XX") { + for (size_t candidate = 0; candidate < gpus.size(); ++candidate) { + if (gpus[candidate].unique_id == 0 || + !gpu_uuid(gpus[candidate].unique_id).starts_with(token)) + continue; + if (index) + return filtered; + index = candidate; + } + } else { + char *end = nullptr; + const long parsed = std::strtol(token.c_str(), &end, 0); + if (end != token.c_str() && *end == '\0' && parsed >= 0) + index = static_cast(parsed); + } + + if (!index || *index >= gpus.size() || + std::any_of(filtered.begin(), filtered.end(), + [&](const VisibleGpu &gpu) { return gpu.ordinal == gpus[*index].ordinal; })) + return filtered; + filtered.push_back(gpus[*index]); + + if (comma == std::string_view::npos) + break; + rest.remove_prefix(comma + 1); + } + return filtered; +} + +std::vector filter_client_visible_gpus(const std::vector &gpus, + std::optional selector) { + if (!selector) + return gpus; + + std::vector filtered; + std::string_view rest = *selector; + while (!rest.empty() && filtered.size() < gpus.size()) { + const size_t comma = rest.find(','); + std::string token(comma == std::string_view::npos ? rest : rest.substr(0, comma)); + + if (token.find("GPU-") != std::string::npos) { + for (size_t candidate = 0; candidate < gpus.size(); ++candidate) { + if (gpus[candidate].unique_id != 0 && + gpu_uuid(gpus[candidate].unique_id).find(token) != std::string::npos) { + token = std::to_string(candidate); + break; + } + } + } + + uint32_t index = 0; + const char *begin = token.data(); + const char *end = begin + token.size(); + auto [ptr, error] = std::from_chars(begin, end, index); + if (error != std::errc{} || ptr != end || index >= gpus.size()) + return filtered; + if (std::none_of(filtered.begin(), filtered.end(), + [&](const VisibleGpu &gpu) { return gpu.gpu_id == gpus[index].gpu_id; })) + filtered.push_back(gpus[index]); + + if (comma == std::string_view::npos) + break; + rest.remove_prefix(comma + 1); + } + return filtered; +} + +std::vector effective_visible_gpus(const std::vector &topology, + std::optional rocr_visible, + std::optional hip_visible, + std::optional cuda_visible) { + std::vector gpus = filter_rocr_visible_gpus(topology, rocr_visible); + const auto client = client_selector(hip_visible, cuda_visible); + return client ? filter_client_visible_gpus(gpus, client->second) : gpus; +} + +std::optional normalized_client_visible_devices( + const std::vector &topology, std::optional rocr_visible, + std::optional hip_visible, std::optional cuda_visible) { + const auto client = client_selector(hip_visible, cuda_visible); + if (!client) + return std::nullopt; + + const std::vector rocr_gpus = filter_rocr_visible_gpus(topology, rocr_visible); + const std::vector selected = filter_client_visible_gpus(rocr_gpus, client->second); + std::vector ordinals; + for (const VisibleGpu &gpu : selected) { + auto match = std::find_if(rocr_gpus.begin(), rocr_gpus.end(), [&](const VisibleGpu &candidate) { + return candidate.gpu_id == gpu.gpu_id; + }); + if (match != rocr_gpus.end()) + ordinals.push_back(std::to_string(std::distance(rocr_gpus.begin(), match))); + } + return VisibilityOverride{std::string(client->first), join_comma(ordinals)}; +} + +std::optional +expanded_rocr_visible_devices(const std::vector &topology, + std::optional rocr_visible) { + if (!rocr_visible || topology.empty()) + return std::nullopt; + + const std::vector selected = filter_rocr_visible_gpus(topology, rocr_visible); + if (selected.empty()) + return std::nullopt; + + std::vector ordinals; + for (const VisibleGpu &gpu : selected) + ordinals.push_back(std::to_string(gpu.ordinal)); + ordinals.push_back(std::to_string(topology.size())); + + std::string rewritten = join_comma(ordinals); + return rewritten == *rocr_visible ? std::nullopt + : std::optional(std::move(rewritten)); +} + +HostSelection select_host_gpu(const std::vector &visible_gpus, + uint32_t configured_gpu_id, uint32_t gfx_target_version) { + if (configured_gpu_id != 0) { + const auto match = + std::find_if(visible_gpus.begin(), visible_gpus.end(), + [&](const VisibleGpu &gpu) { return gpu.gpu_id == configured_gpu_id; }); + if (match == visible_gpus.end()) + return {HostSelectionStatus::ExplicitGpuHidden, configured_gpu_id}; + return {match->gfx_target_version == gfx_target_version + ? HostSelectionStatus::Selected + : HostSelectionStatus::ExplicitGpuIsaMismatch, + configured_gpu_id}; + } + + const auto match = + std::find_if(visible_gpus.begin(), visible_gpus.end(), [&](const VisibleGpu &gpu) { + return gpu.gfx_target_version == gfx_target_version; + }); + return match == visible_gpus.end() ? HostSelection{HostSelectionStatus::NoIsaMatch, 0} + : HostSelection{HostSelectionStatus::Selected, match->gpu_id}; +} + +} // namespace rocjitsu::cli diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h new file mode 100644 index 000000000000..35c26228e428 --- /dev/null +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include + +namespace rocjitsu::cli { + +struct VisibleGpu { + uint32_t ordinal = 0; + uint32_t gpu_id = 0; + uint32_t gfx_target_version = 0; + uint64_t unique_id = 0; +}; + +enum class HostSelectionStatus { + Selected, + ExplicitGpuHidden, + ExplicitGpuIsaMismatch, + NoIsaMatch, +}; + +struct HostSelection { + HostSelectionStatus status = HostSelectionStatus::NoIsaMatch; + uint32_t gpu_id = 0; +}; + +struct VisibilityOverride { + std::string name; + std::string value; +}; + +std::vector filter_rocr_visible_gpus(const std::vector &gpus, + std::optional selector); + +std::vector filter_client_visible_gpus(const std::vector &gpus, + std::optional selector); + +std::vector effective_visible_gpus(const std::vector &topology, + std::optional rocr_visible, + std::optional hip_visible, + std::optional cuda_visible); + +std::optional normalized_client_visible_devices( + const std::vector &topology, std::optional rocr_visible, + std::optional hip_visible, std::optional cuda_visible); + +std::optional +expanded_rocr_visible_devices(const std::vector &topology, + std::optional rocr_visible); + +HostSelection select_host_gpu(const std::vector &visible_gpus, + uint32_t configured_gpu_id, uint32_t gfx_target_version); + +} // namespace rocjitsu::cli From 573e63db21f852772e4ea9eaa333ac7f9ca70761 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:12:17 +0000 Subject: [PATCH 02/31] test(rocjitsu): isolate runtime handoff directory --- emulation/rocjitsu/tests/config_test.cpp | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 616d9ad965b1..58d63478e5b9 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -635,9 +635,7 @@ TEST(ConfigLoaderTest, RejectsEmptyResolvedGpuIdLineForEnabledDbt) { } TEST(ConfigLoaderTest, LoadsDbtRuntimeConfigHandoffFromInvocationDirectory) { - const std::filesystem::path runtime = - std::filesystem::temp_directory_path() / "rocjitsu_runtime_config_handoff_test"; - std::filesystem::create_directories(runtime); + const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-handoff-"); const auto config_file = write_temp_config(R"({ "dbt_guest": { "enabled": true, @@ -646,10 +644,10 @@ TEST(ConfigLoaderTest, LoadsDbtRuntimeConfigHandoffFromInvocationDirectory) { } })"); { - std::ofstream handoff(runtime / "config_path"); + std::ofstream handoff(std::filesystem::path(runtime.path()) / "config_path"); handoff << config_file.path() << "\n28851\n"; } - ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.string()); + ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); const std::optional loaded = config::load_dbt_guest_config_from_runtime_config(); @@ -657,7 +655,6 @@ TEST(ConfigLoaderTest, LoadsDbtRuntimeConfigHandoffFromInvocationDirectory) { ASSERT_TRUE(loaded); EXPECT_TRUE(loaded->enabled); EXPECT_EQ(loaded->host.gpu_id, 28851u); - std::filesystem::remove_all(runtime); } TEST(ConfigLoaderTest, RejectsEmptyDbtExecutionBackend) { From 3c5853aa1cc880a332f7ae7e7740cd65cf880d76 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:13:31 +0000 Subject: [PATCH 03/31] test(rocjitsu): cover explicit DBT host selection --- emulation/rocjitsu/tests/launch_preload_test.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index d0b4cc94beb8..e70f8434e915 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -207,6 +207,13 @@ TEST(RocmVisibilityTest, SelectsFirstVisibleIsaMatch) { EXPECT_EQ(100u, selection.gpu_id); } +TEST(RocmVisibilityTest, SelectsExplicitGpuWithMatchingIsa) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(test_gpus(), 100, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, selection.status); + EXPECT_EQ(100u, selection.gpu_id); +} + TEST(RocmVisibilityTest, RejectsHiddenExplicitGpu) { const rocjitsu::cli::HostSelection selection = rocjitsu::cli::select_host_gpu({test_gpus()[0]}, 101, 90402); From 2429986655b8f6afaa9a2f5abde8ed1cc88d47fd Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:14:49 +0000 Subject: [PATCH 04/31] test(rocjitsu): cover visibility selector ordering --- emulation/rocjitsu/tests/launch_preload_test.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index e70f8434e915..3beee9be2a35 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -150,6 +150,22 @@ TEST(RocmVisibilityTest, InvalidRocrTokenPreservesValidPrefix) { EXPECT_EQ(101u, selected[0].gpu_id); } +TEST(RocmVisibilityTest, DuplicateAndReorderedSelectorsMatchRuntimeBehavior) { + const auto rocr_duplicate = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,0,1"); + ASSERT_EQ(1u, rocr_duplicate.size()); + EXPECT_EQ(100u, rocr_duplicate[0].gpu_id); + + const auto client_duplicate = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "0,0,1"); + ASSERT_EQ(2u, client_duplicate.size()); + EXPECT_EQ(100u, client_duplicate[0].gpu_id); + EXPECT_EQ(101u, client_duplicate[1].gpu_id); + + const auto client_reordered = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "2,0"); + ASSERT_EQ(2u, client_reordered.size()); + EXPECT_EQ(102u, client_reordered[0].gpu_id); + EXPECT_EQ(100u, client_reordered[1].gpu_id); +} + TEST(RocmVisibilityTest, NegativeRocrOrdinalPreservesValidPrefix) { const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,-1,1"); ASSERT_EQ(1u, selected.size()); From a388c8c3aeb5dd282c635f28da3e3e561a72edb0 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:16:53 +0000 Subject: [PATCH 05/31] refactor(rocjitsu): share runtime handoff writer --- .../src/rocjitsu/config/dbt_guest_config.cpp | 24 +++++++++++++++++ .../src/rocjitsu/config/dbt_guest_config.h | 7 +++++ emulation/rocjitsu/tests/config_test.cpp | 19 ++++++++++++++ emulation/rocjitsu/tools/rocjitsu/main.cpp | 26 +------------------ 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp index 940bf8558aec..ff5c4b3b0fc7 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp @@ -165,6 +165,30 @@ void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view val config.host.gpu_id = gpu_id; } +bool write_dbt_runtime_config_handoff(const std::string &config_path, const DbtGuestConfig &config, + pid_t pid) { + const std::string handoff_file = rpc_invocation_config_file_path(pid); + std::filesystem::create_directories(std::filesystem::path(handoff_file).parent_path()); + const std::string temp_file = handoff_file + ".tmp"; + std::ofstream output(temp_file); + if (!output) + return false; + output << config_path << '\n'; + if (config.enabled) + output << config.host.gpu_id << '\n'; + output.close(); + if (!output.good()) { + std::filesystem::remove(temp_file); + return false; + } + + std::error_code rename_error; + std::filesystem::rename(temp_file, handoff_file, rename_error); + if (rename_error) + std::filesystem::remove(temp_file); + return !rename_error; +} + std::optional parse_dbt_runtime_config_handoff(std::string_view contents) { const size_t first_newline = contents.find('\n'); std::string_view config_path = contents.substr(0, first_newline); diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h index a7a2febd757c..aaecce074074 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h @@ -14,6 +14,8 @@ #include #include +#include + namespace rocjitsu::fb { struct DbtGuestConfig; } // namespace rocjitsu::fb @@ -110,6 +112,11 @@ DbtGuestConfig load_dbt_guest_config_from_file(const std::string &path); void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view value, std::string_view source); +/// @brief Atomically write the per-invocation runtime config handoff. +/// @returns false when the handoff cannot be written or published. +bool write_dbt_runtime_config_handoff(const std::string &config_path, const DbtGuestConfig &config, + pid_t pid); + /// @brief Parse the config path and optional resolved GPU ID from a runtime handoff. /// @returns std::nullopt when the first line does not contain a config path. std::optional parse_dbt_runtime_config_handoff(std::string_view contents); diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 58d63478e5b9..8097855f2af8 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -622,6 +622,25 @@ TEST(ConfigLoaderTest, ParsesRuntimeConfigHandoff) { EXPECT_FALSE(config::parse_dbt_runtime_config_handoff("\n28851\n")); } +TEST(ConfigLoaderTest, RoundTripsRuntimeConfigHandoff) { + const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-round-trip-"); + ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", runtime.path()); + config::DbtGuestConfig dbt; + dbt.enabled = true; + dbt.host.gpu_id = 28851; + + ASSERT_TRUE(config::write_dbt_runtime_config_handoff("/tmp/config.json", dbt, getpid())); + std::ifstream handoff(rocjitsu::rpc_invocation_config_file_path(getpid())); + const std::string contents((std::istreambuf_iterator(handoff)), + std::istreambuf_iterator()); + const auto parsed = config::parse_dbt_runtime_config_handoff(contents); + + ASSERT_TRUE(parsed); + EXPECT_EQ(parsed->config_path, "/tmp/config.json"); + ASSERT_TRUE(parsed->resolved_gpu_id); + EXPECT_EQ(*parsed->resolved_gpu_id, "28851"); +} + TEST(ConfigLoaderTest, RejectsEmptyResolvedGpuIdLineForEnabledDbt) { const auto handoff = config::parse_dbt_runtime_config_handoff("/tmp/config.json\n\n"); ASSERT_TRUE(handoff); diff --git a/emulation/rocjitsu/tools/rocjitsu/main.cpp b/emulation/rocjitsu/tools/rocjitsu/main.cpp index d0a0db548cd3..814a9f9aceb0 100644 --- a/emulation/rocjitsu/tools/rocjitsu/main.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/main.cpp @@ -172,30 +172,6 @@ std::string find_interposer_lib() { return find_runtime_lib("librocjitsu.so"); } std::string find_hooks_lib() { return find_runtime_lib("librocjitsu_hooks.so"); } -bool write_config_file(const std::string &config_path, - const rocjitsu::config::DbtGuestConfig &dbt_guest, pid_t pid) { - auto cfg_file = rpc_invocation_config_file_path(pid); - std::filesystem::create_directories(std::filesystem::path(cfg_file).parent_path()); - const std::string temp_file = cfg_file + ".tmp"; - std::ofstream ofs(temp_file); - if (!ofs) - return false; - ofs << config_path << '\n'; - if (dbt_guest.enabled) - ofs << dbt_guest.host.gpu_id << '\n'; - ofs.close(); - if (!ofs.good()) { - std::filesystem::remove(temp_file); - return false; - } - - std::error_code rename_error; - std::filesystem::rename(temp_file, cfg_file, rename_error); - if (rename_error) - std::filesystem::remove(temp_file); - return !rename_error; -} - void cleanup_runtime_files(pid_t pid) { std::error_code error; std::filesystem::remove_all(rpc_invocation_runtime_dir(pid), error); @@ -577,7 +553,7 @@ int main(int argc, char *argv[]) { return 1; } } else { - if (!write_config_file(abs_config, dbt_guest_config, my_pid)) { + if (!rocjitsu::config::write_dbt_runtime_config_handoff(abs_config, dbt_guest_config, my_pid)) { std::cerr << "rocjitsu: failed to write config file\n"; cleanup_runtime_files(my_pid); return 1; From 6e108c2db203b670e9b350284846805b39fe6c05 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:18:46 +0000 Subject: [PATCH 06/31] fix(rocjitsu): place DBT host at device zero --- emulation/rocjitsu/tests/launch_preload_test.cpp | 11 +++++++++++ emulation/rocjitsu/tools/rocjitsu/main.cpp | 2 +- .../rocjitsu/tools/rocjitsu/rocm_visibility.cpp | 12 ++++++++++-- emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h | 3 ++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index 3beee9be2a35..027fc75c5cb9 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -206,6 +206,17 @@ TEST(RocmVisibilityTest, NormalizesClientSelectorAgainstExpandedRocrOrder) { EXPECT_EQ("1", normalized->value); } +TEST(RocmVisibilityTest, NormalizesSelectedDbtHostToClientDeviceZero) { + const std::vector heterogeneous_gpus{ + {0, 100, 110000, 0x1111111111111111ULL}, {1, 101, 90402, 0x2222222222222222ULL}}; + const auto normalized = rocjitsu::cli::normalized_client_visible_devices( + heterogeneous_gpus, std::nullopt, "0,1", std::nullopt, 101); + + ASSERT_TRUE(normalized); + EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); + EXPECT_EQ("1,0", normalized->value); +} + TEST(RocmVisibilityTest, ExpandsRocrSelectionWithGuestOrdinal) { const auto expanded = rocjitsu::cli::expanded_rocr_visible_devices(test_gpus(), "GPU-2222"); ASSERT_TRUE(expanded); diff --git a/emulation/rocjitsu/tools/rocjitsu/main.cpp b/emulation/rocjitsu/tools/rocjitsu/main.cpp index 814a9f9aceb0..7e30489fc67a 100644 --- a/emulation/rocjitsu/tools/rocjitsu/main.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/main.cpp @@ -573,7 +573,7 @@ int main(int argc, char *argv[]) { if (std::optional client_visible = rocjitsu::cli::normalized_client_visible_devices( dbt_execution_gpus, child_rocr_visible, environment_value("HIP_VISIBLE_DEVICES"), - environment_value("CUDA_VISIBLE_DEVICES"))) + environment_value("CUDA_VISIBLE_DEVICES"), dbt_guest_config.host.gpu_id)) launch_environment.set(client_visible->name, client_visible->value); // The HSA hook still uses the legacy tools callback path. Disable only the // rocprofiler-register table-delivery path so it cannot validate an diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp index 37dc4a8dfd18..ea37731bd426 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp @@ -136,13 +136,21 @@ std::vector effective_visible_gpus(const std::vector &to std::optional normalized_client_visible_devices( const std::vector &topology, std::optional rocr_visible, - std::optional hip_visible, std::optional cuda_visible) { + std::optional hip_visible, std::optional cuda_visible, + std::optional first_gpu_id) { const auto client = client_selector(hip_visible, cuda_visible); if (!client) return std::nullopt; const std::vector rocr_gpus = filter_rocr_visible_gpus(topology, rocr_visible); - const std::vector selected = filter_client_visible_gpus(rocr_gpus, client->second); + std::vector selected = filter_client_visible_gpus(rocr_gpus, client->second); + if (first_gpu_id) { + const auto first = std::find_if(selected.begin(), selected.end(), [&](const VisibleGpu &gpu) { + return gpu.gpu_id == *first_gpu_id; + }); + if (first != selected.end()) + std::rotate(selected.begin(), first, first + 1); + } std::vector ordinals; for (const VisibleGpu &gpu : selected) { auto match = std::find_if(rocr_gpus.begin(), rocr_gpus.end(), [&](const VisibleGpu &candidate) { diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h index 35c26228e428..df931cbe02e1 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h @@ -48,7 +48,8 @@ std::vector effective_visible_gpus(const std::vector &to std::optional normalized_client_visible_devices( const std::vector &topology, std::optional rocr_visible, - std::optional hip_visible, std::optional cuda_visible); + std::optional hip_visible, std::optional cuda_visible, + std::optional first_gpu_id = std::nullopt); std::optional expanded_rocr_visible_devices(const std::vector &topology, From 6f1255419c87f46768a889c80c9cc56f55f2e9f3 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:19:56 +0000 Subject: [PATCH 07/31] fix(rocjitsu): require automatic DBT handoff ID --- .../src/rocjitsu/config/dbt_guest_config.cpp | 6 ++- emulation/rocjitsu/tests/config_test.cpp | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp index ff5c4b3b0fc7..7287d13a3838 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp @@ -240,8 +240,12 @@ std::optional load_dbt_guest_config_from_runtime_config() { if (!handoff) return std::nullopt; DbtGuestConfig config = load_dbt_guest_config_from_file(handoff->config_path); - if (handoff->resolved_gpu_id) + if (handoff->resolved_gpu_id) { apply_resolved_dbt_host_gpu_id(config, *handoff->resolved_gpu_id, "runtime config handoff"); + } else if (config.enabled && config.host.gpu_id == 0) { + throw std::runtime_error("runtime config handoff must contain a resolved KFD gpu_id for " + "automatic DBT host selection"); + } return config; } diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 8097855f2af8..165464aa4e7d 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -676,6 +676,44 @@ TEST(ConfigLoaderTest, LoadsDbtRuntimeConfigHandoffFromInvocationDirectory) { EXPECT_EQ(loaded->host.gpu_id, 28851u); } +TEST(ConfigLoaderTest, RejectsPathOnlyHandoffForAutomaticDbtHost) { + const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-automatic-"); + const auto config_file = write_temp_config(R"({ + "dbt_guest": { + "enabled": true, + "guest_isa": "gfx950", + "host_isa": "gfx942" + } + })"); + { + std::ofstream handoff(std::filesystem::path(runtime.path()) / "config_path"); + handoff << config_file.path() << '\n'; + } + ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); + + EXPECT_THROW(config::load_dbt_guest_config_from_runtime_config(), std::runtime_error); +} + +TEST(ConfigLoaderTest, AllowsPathOnlyHandoffWithoutAutomaticDbtHost) { + const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-path-only-"); + ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); + + for (const std::string_view dbt_guest : { + R"("dbt_guest": {"enabled": true, "host_gpu_id": 28851})", + R"("dbt_guest": {"enabled": false})", + }) { + const auto config_file = write_temp_config("{" + std::string(dbt_guest) + "}"); + { + std::ofstream handoff(std::filesystem::path(runtime.path()) / "config_path"); + handoff << config_file.path() << '\n'; + } + + const auto loaded = config::load_dbt_guest_config_from_runtime_config(); + ASSERT_TRUE(loaded); + EXPECT_EQ(loaded->host.gpu_id, dbt_guest.find("28851") == std::string_view::npos ? 0u : 28851u); + } +} + TEST(ConfigLoaderTest, RejectsEmptyDbtExecutionBackend) { const auto file = write_temp_config(R"({ "dbt_guest": { From 9e1e2abf0bc804bcfaac774890173c261d0ca723 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:30:06 +0000 Subject: [PATCH 08/31] fix(rocjitsu): reject malformed GPU selectors --- emulation/rocjitsu/tests/launch_preload_test.cpp | 14 ++++++++++++++ .../rocjitsu/tools/rocjitsu/rocm_visibility.cpp | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index 027fc75c5cb9..0e6f443b399f 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -150,6 +150,20 @@ TEST(RocmVisibilityTest, InvalidRocrTokenPreservesValidPrefix) { EXPECT_EQ(101u, selected[0].gpu_id); } +TEST(RocmVisibilityTest, MalformedSelectorsTerminateSelection) { + EXPECT_TRUE(rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "GPU-").empty()); + EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "01").empty()); + EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "00").empty()); + + const auto rocr_prefix = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,GPU-,1"); + ASSERT_EQ(1u, rocr_prefix.size()); + EXPECT_EQ(100u, rocr_prefix[0].gpu_id); + + const auto client_prefix = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "1,00,2"); + ASSERT_EQ(1u, client_prefix.size()); + EXPECT_EQ(101u, client_prefix[0].gpu_id); +} + TEST(RocmVisibilityTest, DuplicateAndReorderedSelectorsMatchRuntimeBehavior) { const auto rocr_duplicate = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,0,1"); ASSERT_EQ(1u, rocr_duplicate.size()); diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp index ea37731bd426..5f8ed8acb984 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp @@ -58,7 +58,7 @@ std::vector filter_rocr_visible_gpus(const std::vector & [](unsigned char c) { return static_cast(std::toupper(c)); }); std::optional index; - if (token.starts_with("GPU-") && token != "GPU-XX") { + if (token.size() >= 5 && token.size() <= 20 && token.starts_with("GPU-") && token != "GPU-XX") { for (size_t candidate = 0; candidate < gpus.size(); ++candidate) { if (gpus[candidate].unique_id == 0 || !gpu_uuid(gpus[candidate].unique_id).starts_with(token)) @@ -112,7 +112,8 @@ std::vector filter_client_visible_gpus(const std::vector const char *begin = token.data(); const char *end = begin + token.size(); auto [ptr, error] = std::from_chars(begin, end, index); - if (error != std::errc{} || ptr != end || index >= gpus.size()) + if (error != std::errc{} || ptr != end || token != std::to_string(index) || + index >= gpus.size()) return filtered; if (std::none_of(filtered.begin(), filtered.end(), [&](const VisibleGpu &gpu) { return gpu.gpu_id == gpus[index].gpu_id; })) From 84c9c67389888215eeccbb8ce1dc9105a1f0d52b Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:37:37 +0000 Subject: [PATCH 09/31] fix(rocjitsu): read complete runtime handoff --- .../src/rocjitsu/kmd/linux/interposer.cpp | 32 ++++++++++--- emulation/rocjitsu/tests/CMakeLists.txt | 16 +++++++ emulation/rocjitsu/tests/guest_kfd_test.cpp | 45 +++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp index ed0ecc00325e..ee539d65613a 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp @@ -166,19 +166,41 @@ int kfd_ioctl_ret(int r) { /// host KFD gpu_id on the second line. std::optional child_config_handoff(const std::string &cfg_file) { - char cfg_buf[4096]{}; + constexpr size_t kMaxHandoffSize = 64 * 1024; + constexpr size_t kReadChunkSize = 4096; auto &real = rocjitsu::libc_passthrough(); int cfg_fd = real.openat(AT_FDCWD, cfg_file.c_str(), O_RDONLY, 0); if (cfg_fd < 0) return std::nullopt; - auto n = real.read(cfg_fd, cfg_buf, sizeof(cfg_buf) - 1); + std::string contents; + contents.reserve(kReadChunkSize); + while (contents.size() < kMaxHandoffSize) { + char buffer[kReadChunkSize]; + const size_t remaining = kMaxHandoffSize - contents.size(); + ssize_t bytes_read = 0; + do { + bytes_read = real.read(cfg_fd, buffer, std::min(sizeof(buffer), remaining)); + } while (bytes_read < 0 && errno == EINTR); + if (bytes_read < 0) { + real.close(cfg_fd); + return std::nullopt; + } + if (bytes_read == 0) + break; + contents.append(buffer, static_cast(bytes_read)); + } + + char extra = 0; + ssize_t extra_bytes = 0; + do { + extra_bytes = real.read(cfg_fd, &extra, 1); + } while (extra_bytes < 0 && errno == EINTR); real.close(cfg_fd); - if (n <= 0) + if (contents.empty() || extra_bytes != 0) return std::nullopt; - return rocjitsu::config::parse_dbt_runtime_config_handoff( - std::string_view(cfg_buf, static_cast(n))); + return rocjitsu::config::parse_dbt_runtime_config_handoff(contents); } void *raw_mmap_syscall(void *addr, size_t length, int prot, int flags, int fd, off_t offset) { diff --git a/emulation/rocjitsu/tests/CMakeLists.txt b/emulation/rocjitsu/tests/CMakeLists.txt index d38fd288b26b..91a35b124763 100644 --- a/emulation/rocjitsu/tests/CMakeLists.txt +++ b/emulation/rocjitsu/tests/CMakeLists.txt @@ -876,6 +876,22 @@ rj_add_test( "bin/guest_kfd_test" "--gtest_filter=GuestKfdFailureTest.SimulatorOversizedLdsFailsCleanly" ) +rj_add_test( + NAME "GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes" + COMMAND + ${RJ_CLI} --config ${RJ_CDNA3_KMD_CONFIG} -- + $ + --gtest_filter=GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + TIMEOUT 60 + INSTALL_COMMAND + "\${RJ_INSTALLED_BINDIR}/rocjitsu" + "--config" + "\${RJ_INSTALLED_CONFIG_DIR}/${RJ_CDNA3_KMD_CONFIG_NAME}" + "--" + "bin/guest_kfd_test" + "--gtest_filter=GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes" +) if(RJ_HAS_GFX1201_GPU) add_test( NAME "GuestKfdMultiprocessTest.ForkedChildrenDoNotRemoveParentOverlay" diff --git a/emulation/rocjitsu/tests/guest_kfd_test.cpp b/emulation/rocjitsu/tests/guest_kfd_test.cpp index 317f74c1baac..d3094cc2c1db 100644 --- a/emulation/rocjitsu/tests/guest_kfd_test.cpp +++ b/emulation/rocjitsu/tests/guest_kfd_test.cpp @@ -157,6 +157,39 @@ bool install_inline_dbt_config(std::string simulator_json, const char *host_isa, } } +bool relocate_active_config_to_long_path() { + const auto dir = config_handoff_dir(); + const auto json = read_active_config_json(); + if (!dir || !json) + return false; + + try { + std::filesystem::path config_dir = *dir; + constexpr size_t kTargetParentLength = 4082; + while (config_dir.string().size() + 201 <= kTargetParentLength) + config_dir /= std::string(200, 'a'); + const size_t final_component_length = kTargetParentLength - config_dir.string().size() - 1; + if (final_component_length > 0) + config_dir /= std::string(final_component_length, 'a'); + std::filesystem::create_directories(config_dir); + const std::filesystem::path config_path = config_dir / "config.json"; + if (config_path.string().size() != 4094) + return false; + + std::ofstream config(config_path); + config << *json; + config.close(); + if (!config) + return false; + + std::ofstream handoff(*dir / "config_path", std::ios::trunc); + handoff << config_path.string() << "\n50148\n"; + return handoff.good(); + } catch (const std::filesystem::filesystem_error &) { + return false; + } +} + bool read_gpu_id(const std::string &path, uint32_t *gpu_id) { FILE *file = fopen(path.c_str(), "r"); if (!file) @@ -430,6 +463,18 @@ TEST(GuestKfdFailureTest, SimulatorOversizedLdsFailsCleanly) { close(fd); } +TEST(GuestKfdConfigTest, ReadsRuntimeHandoffLargerThan4095Bytes) { + const auto simulator_json = read_active_config_json(); + ASSERT_TRUE(simulator_json.has_value()); + ASSERT_TRUE(install_inline_dbt_config(*simulator_json, "gfx942", 64)); + ASSERT_TRUE(relocate_active_config_to_long_path()); + + const int fd = open(kKfdPath, O_RDWR | O_CLOEXEC); + ASSERT_GE(fd, 0) << std::strerror(errno); + EXPECT_TRUE(guest_gpu_is_visible()); + close(fd); +} + // Overwriting the interposer's hidden real /dev/kfd fd number via dup2 must not // leave a stale KFD classification behind. In guest/DBT mode open("/dev/kfd") // returns an app-facing dup while the real fd stays internal; if some dup2/dup3 From bd213ff32027ab782dd639bd959cf49b5f64ddce Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:40:11 +0000 Subject: [PATCH 10/31] fix(rocjitsu): exclude zero KFD GPU IDs --- emulation/rocjitsu/tests/launch_preload_test.cpp | 10 ++++++++++ emulation/rocjitsu/tools/rocjitsu/main.cpp | 4 ++-- .../rocjitsu/tools/rocjitsu/rocm_visibility.cpp | 13 +++++++++++++ emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h | 2 ++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index 0e6f443b399f..fc5385b1db98 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -111,6 +111,16 @@ TEST(RocmVisibilityTest, UnsetAndEmptyRocrSelectorsDiffer) { EXPECT_TRUE(rocjitsu::cli::filter_rocr_visible_gpus(gpus, std::string_view{}).empty()); } +TEST(RocmVisibilityTest, KfdEnumerationSkipsZeroGpuIdsAndCompactsOrdinals) { + const std::vector candidates{{0, 0, 90402, 0}, + {1, 101, 90402, 0x2222222222222222ULL}}; + const auto gpus = rocjitsu::cli::enumerate_kfd_gpus(candidates); + + ASSERT_EQ(1u, gpus.size()); + EXPECT_EQ(0u, gpus[0].ordinal); + EXPECT_EQ(101u, gpus[0].gpu_id); +} + TEST(RocmVisibilityTest, NumericAndUuidSelectorsReorderDevices) { const auto gpus = test_gpus(); const auto numeric = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "1,0"); diff --git a/emulation/rocjitsu/tools/rocjitsu/main.cpp b/emulation/rocjitsu/tools/rocjitsu/main.cpp index 7e30489fc67a..231df8584572 100644 --- a/emulation/rocjitsu/tools/rocjitsu/main.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/main.cpp @@ -304,7 +304,7 @@ std::vector real_kfd_gpu_ordinals() { for (uint32_t ordinal = 0; ordinal < nodes.size(); ++ordinal) gpus.push_back({ordinal, nodes[ordinal].gpu_id, nodes[ordinal].gfx_target_version, nodes[ordinal].unique_id}); - return gpus; + return rocjitsu::cli::enumerate_kfd_gpus(gpus); } std::optional environment_value(const char *name) { @@ -369,7 +369,7 @@ dbt_execution_gpu_ordinals(const std::string &dbt_config_path, const auto &device = devices[ordinal]; gpus.push_back({ordinal, device.gpu_id, device.gfx_target_version, device.unique_id}); } - return gpus; + return rocjitsu::cli::enumerate_kfd_gpus(gpus); } void print_usage() { diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp index 5f8ed8acb984..e29c225a889a 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp @@ -44,6 +44,19 @@ client_selector(std::optional hip_visible, } // namespace +std::vector enumerate_kfd_gpus(const std::vector &candidates) { + std::vector gpus; + gpus.reserve(candidates.size()); + for (const VisibleGpu &candidate : candidates) { + if (candidate.gpu_id == 0) + continue; + VisibleGpu gpu = candidate; + gpu.ordinal = static_cast(gpus.size()); + gpus.push_back(gpu); + } + return gpus; +} + std::vector filter_rocr_visible_gpus(const std::vector &gpus, std::optional selector) { if (!selector) diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h index df931cbe02e1..d2fd28e52e16 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h @@ -35,6 +35,8 @@ struct VisibilityOverride { std::string value; }; +std::vector enumerate_kfd_gpus(const std::vector &candidates); + std::vector filter_rocr_visible_gpus(const std::vector &gpus, std::optional selector); From 0e38713b059348e2abc84bd3ed5d632e6864c43f Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:45:07 +0000 Subject: [PATCH 11/31] fix(rocjitsu): handle handoff directory errors --- .../src/rocjitsu/config/dbt_guest_config.cpp | 6 +++++- emulation/rocjitsu/tests/config_test.cpp | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp index 7287d13a3838..6cc39997aa2f 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp @@ -168,7 +168,11 @@ void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view val bool write_dbt_runtime_config_handoff(const std::string &config_path, const DbtGuestConfig &config, pid_t pid) { const std::string handoff_file = rpc_invocation_config_file_path(pid); - std::filesystem::create_directories(std::filesystem::path(handoff_file).parent_path()); + std::error_code directory_error; + std::filesystem::create_directories(std::filesystem::path(handoff_file).parent_path(), + directory_error); + if (directory_error) + return false; const std::string temp_file = handoff_file + ".tmp"; std::ofstream output(temp_file); if (!output) diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 165464aa4e7d..9e86fa8537c5 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -641,6 +641,18 @@ TEST(ConfigLoaderTest, RoundTripsRuntimeConfigHandoff) { EXPECT_EQ(*parsed->resolved_gpu_id, "28851"); } +TEST(ConfigLoaderTest, RuntimeConfigHandoffReportsDirectoryCreationFailure) { + const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-write-failure-"); + const std::filesystem::path blocked_root = std::filesystem::path(runtime.path()) / "blocked"; + std::ofstream(blocked_root) << "not a directory"; + ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", blocked_root.string()); + config::DbtGuestConfig dbt; + dbt.enabled = true; + dbt.host.gpu_id = 28851; + + EXPECT_FALSE(config::write_dbt_runtime_config_handoff("/tmp/config.json", dbt, getpid())); +} + TEST(ConfigLoaderTest, RejectsEmptyResolvedGpuIdLineForEnabledDbt) { const auto handoff = config::parse_dbt_runtime_config_handoff("/tmp/config.json\n\n"); ASSERT_TRUE(handoff); From ebb4f8ca40357b14910c3c37c7e273ccac122c20 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:52:01 +0000 Subject: [PATCH 12/31] test(rocjitsu): cover host visibility invariant --- emulation/rocjitsu/tests/launch_preload_test.cpp | 16 ++++++++++++++++ .../rocjitsu/tools/rocjitsu/rocm_visibility.h | 1 + 2 files changed, 17 insertions(+) diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index fc5385b1db98..01b4506144d8 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -241,6 +241,22 @@ TEST(RocmVisibilityTest, NormalizesSelectedDbtHostToClientDeviceZero) { EXPECT_EQ("1,0", normalized->value); } +TEST(RocmVisibilityTest, SelectsDbtHostFromClientVisibleGpusBeforeNormalization) { + const auto visible = + rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, "1", std::nullopt); + const auto automatic = rocjitsu::cli::select_host_gpu(visible, 0, 90402); + ASSERT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, automatic.status); + EXPECT_EQ(101u, automatic.gpu_id); + + const auto normalized = rocjitsu::cli::normalized_client_visible_devices( + test_gpus(), std::nullopt, "1", std::nullopt, automatic.gpu_id); + ASSERT_TRUE(normalized); + EXPECT_EQ("1", normalized->value); + + const auto hidden_explicit = rocjitsu::cli::select_host_gpu(visible, 100, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuHidden, hidden_explicit.status); +} + TEST(RocmVisibilityTest, ExpandsRocrSelectionWithGuestOrdinal) { const auto expanded = rocjitsu::cli::expanded_rocr_visible_devices(test_gpus(), "GPU-2222"); ASSERT_TRUE(expanded); diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h index d2fd28e52e16..71f5b7dd5ea3 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h @@ -48,6 +48,7 @@ std::vector effective_visible_gpus(const std::vector &to std::optional hip_visible, std::optional cuda_visible); +/// @param first_gpu_id GPU selected from effective_visible_gpus() using the same selectors. std::optional normalized_client_visible_devices( const std::vector &topology, std::optional rocr_visible, std::optional hip_visible, std::optional cuda_visible, From b122ace7d95b12feeb2cf33771b398820a198273 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 22:59:03 +0000 Subject: [PATCH 13/31] docs(rocjitsu): document visibility helpers --- .../rocjitsu/tools/rocjitsu/rocm_visibility.h | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h index 71f5b7dd5ea3..22c6bb71ae30 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h @@ -1,6 +1,9 @@ // Copyright (c) 2026 Advanced Micro Devices, Inc. // SPDX-License-Identifier: MIT +/// @file rocm_visibility.h +/// @brief ROCm GPU visibility filtering, normalization, and DBT host selection. + #pragma once #include @@ -11,53 +14,64 @@ namespace rocjitsu::cli { +/// @brief GPU identity used while applying ROCm visibility selectors. struct VisibleGpu { - uint32_t ordinal = 0; - uint32_t gpu_id = 0; - uint32_t gfx_target_version = 0; - uint64_t unique_id = 0; + uint32_t ordinal = 0; ///< Ordinal in the current KFD enumeration. + uint32_t gpu_id = 0; ///< Nonzero KFD topology GPU ID. + uint32_t gfx_target_version = 0; ///< Numeric GFX target version. + uint64_t unique_id = 0; ///< KFD unique ID used in GPU UUID selectors. }; +/// @brief Result category for DBT host GPU selection. enum class HostSelectionStatus { - Selected, - ExplicitGpuHidden, - ExplicitGpuIsaMismatch, - NoIsaMatch, + Selected, ///< A visible GPU satisfies the request. + ExplicitGpuHidden, ///< The configured GPU is not client-visible. + ExplicitGpuIsaMismatch, ///< The configured GPU does not match the host ISA. + NoIsaMatch, ///< No client-visible GPU matches the host ISA. }; +/// @brief Selected GPU ID and status returned by DBT host selection. struct HostSelection { HostSelectionStatus status = HostSelectionStatus::NoIsaMatch; uint32_t gpu_id = 0; }; +/// @brief Environment variable replacement produced by visibility normalization. struct VisibilityOverride { std::string name; std::string value; }; +/// @brief Remove non-GPU KFD nodes and assign compact GPU ordinals. std::vector enumerate_kfd_gpus(const std::vector &candidates); +/// @brief Apply ROCR_VISIBLE_DEVICES filtering and ordering semantics. std::vector filter_rocr_visible_gpus(const std::vector &gpus, std::optional selector); +/// @brief Apply HIP_VISIBLE_DEVICES or CUDA_VISIBLE_DEVICES filtering semantics. std::vector filter_client_visible_gpus(const std::vector &gpus, std::optional selector); +/// @brief Return GPUs visible after applying ROCR and client selectors in runtime order. std::vector effective_visible_gpus(const std::vector &topology, std::optional rocr_visible, std::optional hip_visible, std::optional cuda_visible); +/// @brief Normalize the active client selector to post-ROCR numeric ordinals. /// @param first_gpu_id GPU selected from effective_visible_gpus() using the same selectors. std::optional normalized_client_visible_devices( const std::vector &topology, std::optional rocr_visible, std::optional hip_visible, std::optional cuda_visible, std::optional first_gpu_id = std::nullopt); +/// @brief Append the synthetic DBT guest ordinal to a nonempty ROCR selection. std::optional expanded_rocr_visible_devices(const std::vector &topology, std::optional rocr_visible); +/// @brief Select an explicit or automatic DBT host from client-visible GPUs. HostSelection select_host_gpu(const std::vector &visible_gpus, uint32_t configured_gpu_id, uint32_t gfx_target_version); From 8668dc4c3cb585b10003d3efd2b6d6f4e1c7e04d Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 24 Jul 2026 23:03:20 +0000 Subject: [PATCH 14/31] test(rocjitsu): detect truncated handoff GPU ID --- emulation/rocjitsu/tests/guest_kfd_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emulation/rocjitsu/tests/guest_kfd_test.cpp b/emulation/rocjitsu/tests/guest_kfd_test.cpp index d3094cc2c1db..a83d8da2043f 100644 --- a/emulation/rocjitsu/tests/guest_kfd_test.cpp +++ b/emulation/rocjitsu/tests/guest_kfd_test.cpp @@ -165,7 +165,7 @@ bool relocate_active_config_to_long_path() { try { std::filesystem::path config_dir = *dir; - constexpr size_t kTargetParentLength = 4082; + constexpr size_t kTargetParentLength = 4081; while (config_dir.string().size() + 201 <= kTargetParentLength) config_dir /= std::string(200, 'a'); const size_t final_component_length = kTargetParentLength - config_dir.string().size() - 1; @@ -173,7 +173,7 @@ bool relocate_active_config_to_long_path() { config_dir /= std::string(final_component_length, 'a'); std::filesystem::create_directories(config_dir); const std::filesystem::path config_path = config_dir / "config.json"; - if (config_path.string().size() != 4094) + if (config_path.string().size() != 4093) return false; std::ofstream config(config_path); From cc259a1b815751a092a3d0241584a849acfc3207 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Sat, 25 Jul 2026 00:01:22 +0000 Subject: [PATCH 15/31] test(rocjitsu): provide resolved hook host topology --- .../rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp | 4 ++++ emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp | 10 +++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp index 834adf71b895..4daa5e63d4fb 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp @@ -1406,6 +1406,10 @@ RjHsaLayer &layer() { /// not expose that value directly, so the hook reads the redirected topology /// tree and later compares agents by `HSA_AMD_AGENT_INFO_DRIVER_NODE_ID`. [[nodiscard]] std::optional node_id_for_kfd_gpu_id(uint32_t gpu_id) { + // Unit tests provide fake HSA node IDs and a matching synthetic topology tree. + if (const char *root = std::getenv("ROCJITSU_HSA_HOOK_TOPOLOGY_NODES_ROOT"); root && *root) + return node_id_for_kfd_gpu_id_in_root(root, gpu_id); + constexpr std::array kTopologyNodeRoots = { "/sys/devices/virtual/kfd/kfd/topology/nodes", "/sys/class/kfd/kfd/topology/nodes"}; for (const char *root : kTopologyNodeRoots) { diff --git a/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp b/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp index 0ff04ea745d4..9428ef588679 100644 --- a/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp +++ b/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp @@ -118,6 +118,7 @@ hsa_agent_t g_last_load_agent{}; hsa_code_object_reader_t g_last_load_reader{}; constexpr hsa_executable_t kFakeExecutable{123}; constexpr hsa_executable_symbol_t kFakeKernelSymbol{500}; +constexpr uint32_t kResolvedHostGpuId = 8716; const char *isa_name(hsa_isa_t isa) { if (isa.handle == kGuestIsa.handle) @@ -626,8 +627,15 @@ struct FakeApiTable { void write_runtime_config_path(const std::string &runtime_dir) { setenv("ROCJITSU_RUNTIME_DIR", runtime_dir.c_str(), 1); + const std::filesystem::path topology_root = + std::filesystem::path(runtime_dir) / "topology" / "nodes"; + const std::filesystem::path host_node = topology_root / std::to_string(kHostNodeId); + std::filesystem::create_directories(host_node); + std::ofstream(host_node / "gpu_id") << kResolvedHostGpuId << '\n'; + setenv("ROCJITSU_HSA_HOOK_TOPOLOGY_NODES_ROOT", topology_root.c_str(), 1); + std::ofstream config_path(rocjitsu::rpc_default_config_file_path()); - config_path << RJ_HOOK_UNIT_CONFIG_PATH << '\n'; + config_path << RJ_HOOK_UNIT_CONFIG_PATH << '\n' << kResolvedHostGpuId << '\n'; } class InstalledHook { From e3d68beba40246c2edc2d754b3bb5d9564616999 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 27 Jul 2026 15:45:46 +0000 Subject: [PATCH 16/31] test(rocjitsu): distinguish explicit DBT host pin --- emulation/rocjitsu/tests/launch_preload_test.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index 01b4506144d8..2f05b2fdb48a 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -276,9 +276,9 @@ TEST(RocmVisibilityTest, SelectsFirstVisibleIsaMatch) { TEST(RocmVisibilityTest, SelectsExplicitGpuWithMatchingIsa) { const rocjitsu::cli::HostSelection selection = - rocjitsu::cli::select_host_gpu(test_gpus(), 100, 90402); + rocjitsu::cli::select_host_gpu(test_gpus(), 101, 90402); EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, selection.status); - EXPECT_EQ(100u, selection.gpu_id); + EXPECT_EQ(101u, selection.gpu_id); } TEST(RocmVisibilityTest, RejectsHiddenExplicitGpu) { From 4d974f71fea0d4513307fb4ea7da799b4403f0fc Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 27 Jul 2026 15:53:45 +0000 Subject: [PATCH 17/31] fix(rocjitsu): order clientless DBT host first --- emulation/rocjitsu/tests/launch_preload_test.cpp | 16 ++++++++++++++++ .../rocjitsu/tools/rocjitsu/rocm_visibility.cpp | 8 +++++--- .../rocjitsu/tools/rocjitsu/rocm_visibility.h | 4 +++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index 2f05b2fdb48a..fe7f6b02a78f 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -241,6 +241,22 @@ TEST(RocmVisibilityTest, NormalizesSelectedDbtHostToClientDeviceZero) { EXPECT_EQ("1,0", normalized->value); } +TEST(RocmVisibilityTest, SynthesizesClientOrderForSelectedDbtHost) { + const std::vector heterogeneous_gpus{ + {0, 100, 110000, 0x1111111111111111ULL}, {1, 101, 90402, 0x2222222222222222ULL}}; + const auto normalized = rocjitsu::cli::normalized_client_visible_devices( + heterogeneous_gpus, std::nullopt, std::nullopt, std::nullopt, 101); + + ASSERT_TRUE(normalized); + EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); + EXPECT_EQ("1,0", normalized->value); +} + +TEST(RocmVisibilityTest, DoesNotSynthesizeClientOrderWithoutSelectedHost) { + EXPECT_FALSE(rocjitsu::cli::normalized_client_visible_devices( + test_gpus(), std::nullopt, std::nullopt, std::nullopt, std::nullopt)); +} + TEST(RocmVisibilityTest, SelectsDbtHostFromClientVisibleGpusBeforeNormalization) { const auto visible = rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, "1", std::nullopt); diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp index e29c225a889a..47c82eefb788 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp @@ -153,11 +153,12 @@ std::optional normalized_client_visible_devices( std::optional hip_visible, std::optional cuda_visible, std::optional first_gpu_id) { const auto client = client_selector(hip_visible, cuda_visible); - if (!client) + if (!client && !first_gpu_id) return std::nullopt; const std::vector rocr_gpus = filter_rocr_visible_gpus(topology, rocr_visible); - std::vector selected = filter_client_visible_gpus(rocr_gpus, client->second); + std::vector selected = + client ? filter_client_visible_gpus(rocr_gpus, client->second) : rocr_gpus; if (first_gpu_id) { const auto first = std::find_if(selected.begin(), selected.end(), [&](const VisibleGpu &gpu) { return gpu.gpu_id == *first_gpu_id; @@ -173,7 +174,8 @@ std::optional normalized_client_visible_devices( if (match != rocr_gpus.end()) ordinals.push_back(std::to_string(std::distance(rocr_gpus.begin(), match))); } - return VisibilityOverride{std::string(client->first), join_comma(ordinals)}; + const std::string name = client ? std::string(client->first) : "HIP_VISIBLE_DEVICES"; + return VisibilityOverride{name, join_comma(ordinals)}; } std::optional diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h index 22c6bb71ae30..56e561735ea5 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.h @@ -59,7 +59,9 @@ std::vector effective_visible_gpus(const std::vector &to std::optional hip_visible, std::optional cuda_visible); -/// @brief Normalize the active client selector to post-ROCR numeric ordinals. +/// @brief Normalize or synthesize a client selector using post-ROCR numeric ordinals. +/// @details Without an existing client selector, HIP_VISIBLE_DEVICES is synthesized only when +/// first_gpu_id is present because HIP is the primary ROCm client visibility control. /// @param first_gpu_id GPU selected from effective_visible_gpus() using the same selectors. std::optional normalized_client_visible_devices( const std::vector &topology, std::optional rocr_visible, From fe591a5cdef9224cf55270805944df11054b3eff Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 27 Jul 2026 16:04:22 +0000 Subject: [PATCH 18/31] fix(rocjitsu): validate interposer DBT handoff --- .../src/rocjitsu/config/dbt_guest_config.cpp | 23 ++++++++----- .../src/rocjitsu/config/dbt_guest_config.h | 6 +++- .../src/rocjitsu/kmd/linux/guest_kfd.cpp | 34 +++++++++++++++++++ .../src/rocjitsu/kmd/linux/interposer.cpp | 5 +-- emulation/rocjitsu/tests/CMakeLists.txt | 16 +++++++++ emulation/rocjitsu/tests/config_test.cpp | 10 ++++++ emulation/rocjitsu/tests/guest_kfd_test.cpp | 18 +++++++++- 7 files changed, 98 insertions(+), 14 deletions(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp index 6cc39997aa2f..c9db6de0868f 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp @@ -167,6 +167,9 @@ void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view val bool write_dbt_runtime_config_handoff(const std::string &config_path, const DbtGuestConfig &config, pid_t pid) { + if (config.enabled && config.host.gpu_id == 0) + return false; + const std::string handoff_file = rpc_invocation_config_file_path(pid); std::error_code directory_error; std::filesystem::create_directories(std::filesystem::path(handoff_file).parent_path(), @@ -212,6 +215,17 @@ std::optional parse_dbt_runtime_config_handoff(std::str return DbtRuntimeConfigHandoff{std::string(config_path), std::move(resolved_gpu_id)}; } +DbtGuestConfig load_dbt_guest_config_from_handoff(const DbtRuntimeConfigHandoff &handoff) { + DbtGuestConfig config = load_dbt_guest_config_from_file(handoff.config_path); + if (handoff.resolved_gpu_id) { + apply_resolved_dbt_host_gpu_id(config, *handoff.resolved_gpu_id, "runtime config handoff"); + } else if (config.enabled && config.host.gpu_id == 0) { + throw std::runtime_error("runtime config handoff must contain a resolved KFD gpu_id for " + "automatic DBT host selection"); + } + return config; +} + std::optional load_dbt_guest_config_from_runtime_config() { // Try the handoff tiers in priority order, opening the first that exists: // 1. $ROCJITSU_INVOCATION_DIR/config_path — the launcher exports this dir before @@ -243,14 +257,7 @@ std::optional load_dbt_guest_config_from_runtime_config() { std::optional handoff = parse_dbt_runtime_config_handoff(contents); if (!handoff) return std::nullopt; - DbtGuestConfig config = load_dbt_guest_config_from_file(handoff->config_path); - if (handoff->resolved_gpu_id) { - apply_resolved_dbt_host_gpu_id(config, *handoff->resolved_gpu_id, "runtime config handoff"); - } else if (config.enabled && config.host.gpu_id == 0) { - throw std::runtime_error("runtime config handoff must contain a resolved KFD gpu_id for " - "automatic DBT host selection"); - } - return config; + return load_dbt_guest_config_from_handoff(*handoff); } } // namespace config diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h index aaecce074074..ceb2ab8bb87e 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h @@ -113,7 +113,7 @@ void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view val std::string_view source); /// @brief Atomically write the per-invocation runtime config handoff. -/// @returns false when the handoff cannot be written or published. +/// @returns false when enabled DBT lacks a resolved host GPU or the handoff cannot be published. bool write_dbt_runtime_config_handoff(const std::string &config_path, const DbtGuestConfig &config, pid_t pid); @@ -121,6 +121,10 @@ bool write_dbt_runtime_config_handoff(const std::string &config_path, const DbtG /// @returns std::nullopt when the first line does not contain a config path. std::optional parse_dbt_runtime_config_handoff(std::string_view contents); +/// @brief Load and validate the DBT guest config referenced by a runtime handoff. +/// @throws std::runtime_error when automatic DBT host selection lacks a resolved GPU ID. +DbtGuestConfig load_dbt_guest_config_from_handoff(const DbtRuntimeConfigHandoff &handoff); + /// @brief Load only dbt_guest from the rocjitsu child-process runtime config file. /// /// @details HSA tools run inside ROCR initialization and must not depend on the diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp index 360020503d71..7aac4cf90815 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp @@ -206,6 +206,31 @@ std::optional first_real_gpu_id_matching_isa(std::string_view host_isa return std::nullopt; } +bool gpu_id_matches_isa_in_topology(const fs::path &topology_root, uint32_t gpu_id, + std::string_view host_isa) { + std::optional target_version = kmd::gfx_target_version_from_name(host_isa); + if (!target_version) + return false; + + fs::path nodes_dir = topology_root / "nodes"; + const uint32_t max_node = max_numeric_dir(nodes_dir); + for (uint32_t node_id = 1; node_id <= max_node; ++node_id) { + fs::path node_dir = nodes_dir / std::to_string(node_id); + if (read_u32_file(node_dir / "gpu_id") != gpu_id) + continue; + return read_u32_property(node_dir / "properties", "gfx_target_version", 0) == *target_version; + } + return false; +} + +bool real_gpu_id_matches_isa(uint32_t gpu_id, std::string_view host_isa) { + for (std::string_view topology_path : kRealTopologyPaths) { + if (gpu_id_matches_isa_in_topology(fs::path(topology_path), gpu_id, host_isa)) + return true; + } + return false; +} + std::string io_link_to_cpu(uint32_t node_from) { std::ostringstream link; link << "type 2\n" @@ -632,6 +657,15 @@ bool GuestKfd::ensure_ready_locked() { return false; } host_gpu_id_ = *host_gpu_id; + } else { + const bool host_matches = + execution_driver_ ? gpu_id_matches_isa_in_topology(execution_driver_->topology_path(), + host_gpu_id_, config_.host.isa) + : real_gpu_id_matches_isa(host_gpu_id_, config_.host.isa); + if (!host_matches) { + errno = ENODEV; + return false; + } } const std::string host_topology = execution_driver_ ? execution_driver_->topology_path() : ""; if (!overlay_->generate(guest_, host_topology)) { diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp index ee539d65613a..f70d33c18dd5 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp @@ -1199,10 +1199,7 @@ class InterposerContext { } try { - auto dbt_guest = rocjitsu::config::load_dbt_guest_config_from_file(handoff->config_path); - if (handoff->resolved_gpu_id) - rocjitsu::config::apply_resolved_dbt_host_gpu_id(dbt_guest, *handoff->resolved_gpu_id, - "runtime config handoff"); + auto dbt_guest = rocjitsu::config::load_dbt_guest_config_from_handoff(*handoff); if (dbt_guest.enabled) { LinuxKfd *execution_driver = nullptr; const bool simulator_backend = diff --git a/emulation/rocjitsu/tests/CMakeLists.txt b/emulation/rocjitsu/tests/CMakeLists.txt index 91a35b124763..fafccfc05b21 100644 --- a/emulation/rocjitsu/tests/CMakeLists.txt +++ b/emulation/rocjitsu/tests/CMakeLists.txt @@ -876,6 +876,22 @@ rj_add_test( "bin/guest_kfd_test" "--gtest_filter=GuestKfdFailureTest.SimulatorOversizedLdsFailsCleanly" ) +rj_add_test( + NAME "GuestKfdFailureTest.PathOnlyAutomaticDbtHandoffFailsClosed" + COMMAND + ${RJ_CLI} --config ${RJ_CDNA3_KMD_CONFIG} -- + $ + --gtest_filter=GuestKfdFailureTest.PathOnlyAutomaticDbtHandoffFailsClosed + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + TIMEOUT 60 + INSTALL_COMMAND + "\${RJ_INSTALLED_BINDIR}/rocjitsu" + "--config" + "\${RJ_INSTALLED_CONFIG_DIR}/${RJ_CDNA3_KMD_CONFIG_NAME}" + "--" + "bin/guest_kfd_test" + "--gtest_filter=GuestKfdFailureTest.PathOnlyAutomaticDbtHandoffFailsClosed" +) rj_add_test( NAME "GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes" COMMAND diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 9e86fa8537c5..4c27c30621ca 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -641,6 +641,16 @@ TEST(ConfigLoaderTest, RoundTripsRuntimeConfigHandoff) { EXPECT_EQ(*parsed->resolved_gpu_id, "28851"); } +TEST(ConfigLoaderTest, RejectsUnresolvedAutomaticDbtHandoffWrite) { + const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-unresolved-"); + ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", runtime.path()); + config::DbtGuestConfig dbt; + dbt.enabled = true; + + EXPECT_FALSE(config::write_dbt_runtime_config_handoff("/tmp/config.json", dbt, getpid())); + EXPECT_FALSE(std::filesystem::exists(rocjitsu::rpc_invocation_config_file_path(getpid()))); +} + TEST(ConfigLoaderTest, RuntimeConfigHandoffReportsDirectoryCreationFailure) { const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-write-failure-"); const std::filesystem::path blocked_root = std::filesystem::path(runtime.path()) / "blocked"; diff --git a/emulation/rocjitsu/tests/guest_kfd_test.cpp b/emulation/rocjitsu/tests/guest_kfd_test.cpp index a83d8da2043f..1adf878ccba0 100644 --- a/emulation/rocjitsu/tests/guest_kfd_test.cpp +++ b/emulation/rocjitsu/tests/guest_kfd_test.cpp @@ -97,7 +97,8 @@ std::optional read_active_config_json() { } bool install_inline_dbt_config(std::string simulator_json, const char *host_isa, - uint32_t lds_size_kb, std::string_view external_host_config = {}) { + uint32_t lds_size_kb, std::string_view external_host_config = {}, + bool include_resolved_gpu_id = true) { const auto dir = config_handoff_dir(); if (!dir) return false; @@ -151,6 +152,8 @@ bool install_inline_dbt_config(std::string simulator_json, const char *host_isa, if (!active_config) return false; active_config << config_path.string() << '\n'; + if (include_resolved_gpu_id) + active_config << "50148\n"; return active_config.good(); } catch (const std::filesystem::filesystem_error &) { return false; @@ -463,6 +466,19 @@ TEST(GuestKfdFailureTest, SimulatorOversizedLdsFailsCleanly) { close(fd); } +TEST(GuestKfdFailureTest, PathOnlyAutomaticDbtHandoffFailsClosed) { + const auto simulator_json = read_active_config_json(); + ASSERT_TRUE(simulator_json.has_value()); + ASSERT_TRUE(install_inline_dbt_config(*simulator_json, "gfx942", 64, {}, false)); + + errno = 0; + const int fd = open(kKfdPath, O_RDWR | O_CLOEXEC); + EXPECT_EQ(fd, -1); + EXPECT_EQ(errno, ENODEV); + if (fd >= 0) + close(fd); +} + TEST(GuestKfdConfigTest, ReadsRuntimeHandoffLargerThan4095Bytes) { const auto simulator_json = read_active_config_json(); ASSERT_TRUE(simulator_json.has_value()); From 436245e9893f36b544b4a572ea78b95a40be1f56 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 27 Jul 2026 16:15:22 +0000 Subject: [PATCH 19/31] test(rocjitsu): isolate hook topology injection --- .../src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp | 26 +++++++++-- .../tests/dbt/hsa_hooks_unit_test.cpp | 43 +++++++++++++++---- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp index 4daa5e63d4fb..12c125c43de7 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp @@ -1399,6 +1399,22 @@ RjHsaLayer &layer() { return result; } +std::mutex g_topology_nodes_root_mutex; +std::optional g_topology_nodes_root_for_test; + +/// @brief Set or clear the synthetic KFD topology root used by unit tests. +void set_topology_nodes_root_for_test(const char *root) { + std::lock_guard lock(g_topology_nodes_root_mutex); + g_topology_nodes_root_for_test = + root != nullptr && *root != '\0' ? std::optional(root) : std::nullopt; +} + +/// @brief Return a snapshot of the synthetic KFD topology root used by unit tests. +[[nodiscard]] std::optional topology_nodes_root_for_test() { + std::lock_guard lock(g_topology_nodes_root_mutex); + return g_topology_nodes_root_for_test; +} + /// @brief Translate a configured KFD gpu_id to ROCR's HSA driver node id. /// /// @details The config uses the KFD topology `gpu_id` because it is stable @@ -1406,9 +1422,8 @@ RjHsaLayer &layer() { /// not expose that value directly, so the hook reads the redirected topology /// tree and later compares agents by `HSA_AMD_AGENT_INFO_DRIVER_NODE_ID`. [[nodiscard]] std::optional node_id_for_kfd_gpu_id(uint32_t gpu_id) { - // Unit tests provide fake HSA node IDs and a matching synthetic topology tree. - if (const char *root = std::getenv("ROCJITSU_HSA_HOOK_TOPOLOGY_NODES_ROOT"); root && *root) - return node_id_for_kfd_gpu_id_in_root(root, gpu_id); + if (std::optional root = topology_nodes_root_for_test()) + return node_id_for_kfd_gpu_id_in_root(root->c_str(), gpu_id); constexpr std::array kTopologyNodeRoots = { "/sys/devices/virtual/kfd/kfd/topology/nodes", "/sys/class/kfd/kfd/topology/nodes"}; @@ -4021,6 +4036,11 @@ hsa_status_t HSA_API rj_executable_load_agent_code_object( #define RJ_HOOK_EXPORT #endif +/// @brief Set or clear the synthetic KFD topology root used by hook unit tests. +extern "C" RJ_HOOK_EXPORT void rj_hsa_dbt_set_topology_nodes_root_for_test(const char *root) { + set_topology_nodes_root_for_test(root); +} + /// @brief ROCR HSA tools entry point. /// /// @details Saves the incoming `CoreApiTable` function pointers and installs diff --git a/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp b/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp index 9428ef588679..94e783be5ea5 100644 --- a/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp +++ b/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp @@ -37,6 +37,7 @@ RJ_DIAGNOSTIC_POP extern "C" bool OnLoad(HsaApiTable *table, uint64_t runtime_version, uint64_t failed_tool_count, const char *const *failed_tool_names); extern "C" void OnUnload(); +extern "C" void rj_hsa_dbt_set_topology_nodes_root_for_test(const char *root); namespace { @@ -624,15 +625,18 @@ struct FakeApiTable { } }; -void write_runtime_config_path(const std::string &runtime_dir) { +void write_runtime_config_path(const std::string &runtime_dir, bool include_resolved_host = true) { setenv("ROCJITSU_RUNTIME_DIR", runtime_dir.c_str(), 1); const std::filesystem::path topology_root = std::filesystem::path(runtime_dir) / "topology" / "nodes"; - const std::filesystem::path host_node = topology_root / std::to_string(kHostNodeId); - std::filesystem::create_directories(host_node); - std::ofstream(host_node / "gpu_id") << kResolvedHostGpuId << '\n'; - setenv("ROCJITSU_HSA_HOOK_TOPOLOGY_NODES_ROOT", topology_root.c_str(), 1); + std::filesystem::create_directories(topology_root); + if (include_resolved_host) { + const std::filesystem::path host_node = topology_root / std::to_string(kHostNodeId); + std::filesystem::create_directories(host_node); + std::ofstream(host_node / "gpu_id") << kResolvedHostGpuId << '\n'; + } + rj_hsa_dbt_set_topology_nodes_root_for_test(topology_root.c_str()); std::ofstream config_path(rocjitsu::rpc_default_config_file_path()); config_path << RJ_HOOK_UNIT_CONFIG_PATH << '\n' << kResolvedHostGpuId << '\n'; @@ -640,12 +644,16 @@ void write_runtime_config_path(const std::string &runtime_dir) { class InstalledHook { public: - explicit InstalledHook(FakeApiTable &api) : runtime_dir_("rocjitsu-hsa-hooks-unit-") { + explicit InstalledHook(FakeApiTable &api, bool include_resolved_host = true) + : runtime_dir_("rocjitsu-hsa-hooks-unit-") { OnUnload(); - write_runtime_config_path(runtime_dir_.path()); + write_runtime_config_path(runtime_dir_.path(), include_resolved_host); installed_ = OnLoad(&api.table, 0, 0, nullptr); } - ~InstalledHook() { OnUnload(); } + ~InstalledHook() { + OnUnload(); + rj_hsa_dbt_set_topology_nodes_root_for_test(nullptr); + } [[nodiscard]] bool installed() const { return installed_; } @@ -1000,6 +1008,25 @@ TEST(HsaHooksUnitTest, IterateAgentsDropsGuestOwnSlotWhenHostAppearsFirst) { EXPECT_EQ(seen, std::vector{kGuestAgent.handle}); } +TEST(HsaHooksUnitTest, MissingResolvedHostKeepsPhysicalAgentOrder) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + InstalledHook hook(api, false); + ASSERT_TRUE(hook.installed()); + + std::vector seen; + hsa_status_t status = api.core.hsa_iterate_agents_fn( + [](hsa_agent_t agent, void *data) -> hsa_status_t { + static_cast *>(data)->push_back(agent.handle); + return HSA_STATUS_SUCCESS; + }, + &seen); + + EXPECT_EQ(status, HSA_STATUS_SUCCESS); + EXPECT_EQ(seen, (std::vector{kGuestAgent.handle, kHostAgent.handle})); +} + TEST(HsaHooksUnitTest, BatchCopyMapsScalarSourceAndDestinationAgents) { hsa_amd_memory_copy_op_t op{}; op.type = HSA_AMD_MEMORY_COPY_OP_LINEAR; From 1dc462a5ffbe559f4f5826796dcacd4e87710718 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Fri, 31 Jul 2026 18:33:56 +0000 Subject: [PATCH 20/31] refactor(rocjitsu): isolate hook topology test seam Compile the hook sources once into rocjitsu_hooks_objects and link them into two shared libraries: the installed rocjitsu_hooks, which exports only OnLoad and OnUnload, and the test-only rocjitsu_hooks_testing, which adds rj_hsa_dbt_set_topology_nodes_root_for_test. Both shared libraries must name RJ_HOOKS_LINK_LIBS themselves: CMake propagates an object library's usage requirements transitively but not its object files, so attaching the ISA and VM dependencies only to rocjitsu_hooks_objects leaves them out of the linked libraries. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/rocjitsu/hooks/CMakeLists.txt | 117 +++++++++++++----- .../src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp | 48 +++---- .../rocjitsu/hooks/rj_hsa_dbt_test_seams.cpp | 23 ++++ .../rocjitsu/hooks/rj_hsa_dbt_test_seams.h | 34 +++++ emulation/rocjitsu/tests/CMakeLists.txt | 4 +- 5 files changed, 170 insertions(+), 56 deletions(-) create mode 100644 emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.cpp create mode 100644 emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.h diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/CMakeLists.txt b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/CMakeLists.txt index 47b9e0a22403..45abce12e860 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/CMakeLists.txt +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/CMakeLists.txt @@ -1,18 +1,55 @@ # Copyright (c) 2026 Advanced Micro Devices, Inc. # SPDX-License-Identifier: MIT +# The hook sources are compiled once into an object library and linked into two +# shared libraries: the production rocjitsu_hooks, which exports only the HSA +# tool entry points, and the test-only rocjitsu_hooks_testing, which adds the +# exported C test seams (see rj_hsa_dbt_test_seams.cpp). +# +# Both shared libraries must name these dependencies themselves: CMake does not +# propagate an object library's object files transitively through another object +# library, so linking them only to rocjitsu_hooks_objects would leave the ISA and +# VM objects out of the shared libraries. +set(RJ_HOOKS_LINK_LIBS + rocjitsu_analysis + rocjitsu_code + rocjitsu_dbt_config + rocjitsu_isa + rocjitsu_isa_registry + rocjitsu_isa_cdna1 + rocjitsu_isa_cdna2 + rocjitsu_isa_cdna3 + rocjitsu_isa_cdna4 + rocjitsu_isa_rdna1 + rocjitsu_isa_rdna2 + rocjitsu_isa_rdna3 + rocjitsu_isa_rdna3_5 + rocjitsu_isa_rdna4 + rocjitsu_isa_gfx1250 + # \NPI new ISA family: link its rocjitsu_isa_ object library here. + rocjitsu_vm_amdgpu + util + flatbuffers + simdojo + Threads::Threads + ${CMAKE_DL_LIBS} +) + add_library( - rocjitsu_hooks - SHARED + rocjitsu_hooks_objects + OBJECT rj_hsa_dbt_hooks.cpp sidecar_registry.cpp virtual_lds.cpp ) -set_target_properties(rocjitsu_hooks PROPERTIES OUTPUT_NAME rocjitsu_hooks) +set_target_properties( + rocjitsu_hooks_objects + PROPERTIES POSITION_INDEPENDENT_CODE ON +) target_include_directories( - rocjitsu_hooks + rocjitsu_hooks_objects PRIVATE ${ROCJITSU_INCLUDE_DIR} ${ROCJITSU_SRC_DIR} @@ -20,40 +57,54 @@ target_include_directories( ${RJ_VERSION_INCLUDE_DIR} ) -target_include_directories(rocjitsu_hooks SYSTEM PRIVATE ${HSA_INCLUDE_DIR}) - -target_link_libraries( - rocjitsu_hooks - PRIVATE - rocjitsu_analysis - rocjitsu_code - rocjitsu_dbt_config - rocjitsu_isa - rocjitsu_isa_registry - rocjitsu_isa_cdna1 - rocjitsu_isa_cdna2 - rocjitsu_isa_cdna3 - rocjitsu_isa_cdna4 - rocjitsu_isa_rdna1 - rocjitsu_isa_rdna2 - rocjitsu_isa_rdna3 - rocjitsu_isa_rdna3_5 - rocjitsu_isa_rdna4 - rocjitsu_isa_gfx1250 - # \NPI new ISA family: link its rocjitsu_isa_ object library here. - rocjitsu_vm_amdgpu - util - flatbuffers - simdojo - Threads::Threads - ${CMAKE_DL_LIBS} +target_include_directories( + rocjitsu_hooks_objects + SYSTEM + PRIVATE ${HSA_INCLUDE_DIR} ) +target_link_libraries(rocjitsu_hooks_objects PRIVATE ${RJ_HOOKS_LINK_LIBS}) + if(MSVC) - target_compile_options(rocjitsu_hooks PRIVATE /W4 /WX) + target_compile_options(rocjitsu_hooks_objects PRIVATE /W4 /WX) elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") target_compile_options( - rocjitsu_hooks + rocjitsu_hooks_objects PRIVATE -Wall -Wextra -Wpedantic -Werror ) endif() + +# Production hook: exports the HSA tool entry points only. +add_library(rocjitsu_hooks SHARED) +set_target_properties(rocjitsu_hooks PROPERTIES OUTPUT_NAME rocjitsu_hooks) +target_link_libraries( + rocjitsu_hooks + PRIVATE rocjitsu_hooks_objects ${RJ_HOOKS_LINK_LIBS} +) + +if(BUILD_TESTING) + # Test-only hook: the same objects plus the exported test seams. Never + # installed; only hook unit tests link it. + add_library(rocjitsu_hooks_testing SHARED rj_hsa_dbt_test_seams.cpp) + set_target_properties( + rocjitsu_hooks_testing + PROPERTIES OUTPUT_NAME rocjitsu_hooks_testing + ) + target_include_directories( + rocjitsu_hooks_testing + PRIVATE ${ROCJITSU_SRC_DIR} + ) + target_link_libraries( + rocjitsu_hooks_testing + PRIVATE rocjitsu_hooks_objects ${RJ_HOOKS_LINK_LIBS} + ) + + if(MSVC) + target_compile_options(rocjitsu_hooks_testing PRIVATE /W4 /WX) + elseif(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options( + rocjitsu_hooks_testing + PRIVATE -Wall -Wextra -Wpedantic -Werror + ) + endif() +endif() diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp index 12c125c43de7..81c70c496552 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp @@ -26,6 +26,7 @@ #include "rocjitsu/code/patch/kernarg_extension.h" #include "rocjitsu/code/patch/sidecar_metadata.h" #include "rocjitsu/config/dbt_guest_config.h" +#include "rocjitsu/hooks/rj_hsa_dbt_test_seams.h" #include "rocjitsu/hooks/sidecar_registry.h" #include "rocjitsu/hooks/virtual_lds.h" #include "rocjitsu/isa/isa_traits.h" @@ -72,6 +73,30 @@ RJ_DIAGNOSTIC_IGNORE_PEDANTIC #include "hsa/AMDHSAKernelDescriptor.h" RJ_DIAGNOSTIC_POP +// Test seams live outside the anonymous namespace so the test-only translation +// unit can reach them, but they stay at hidden visibility: only +// librocjitsu_hooks_testing.so exports a C entry point for them. +namespace rocjitsu::hooks { +namespace { + +std::mutex g_topology_nodes_root_mutex; +std::optional g_topology_nodes_root_for_test; + +} // namespace + +void set_topology_nodes_root_for_test(const char *root) { + std::lock_guard lock(g_topology_nodes_root_mutex); + g_topology_nodes_root_for_test = + root != nullptr && *root != '\0' ? std::optional(root) : std::nullopt; +} + +std::optional topology_nodes_root_for_test() { + std::lock_guard lock(g_topology_nodes_root_mutex); + return g_topology_nodes_root_for_test; +} + +} // namespace rocjitsu::hooks + namespace { using rocjitsu::AmdGpuCodeObject; @@ -1399,22 +1424,6 @@ RjHsaLayer &layer() { return result; } -std::mutex g_topology_nodes_root_mutex; -std::optional g_topology_nodes_root_for_test; - -/// @brief Set or clear the synthetic KFD topology root used by unit tests. -void set_topology_nodes_root_for_test(const char *root) { - std::lock_guard lock(g_topology_nodes_root_mutex); - g_topology_nodes_root_for_test = - root != nullptr && *root != '\0' ? std::optional(root) : std::nullopt; -} - -/// @brief Return a snapshot of the synthetic KFD topology root used by unit tests. -[[nodiscard]] std::optional topology_nodes_root_for_test() { - std::lock_guard lock(g_topology_nodes_root_mutex); - return g_topology_nodes_root_for_test; -} - /// @brief Translate a configured KFD gpu_id to ROCR's HSA driver node id. /// /// @details The config uses the KFD topology `gpu_id` because it is stable @@ -1422,7 +1431,7 @@ void set_topology_nodes_root_for_test(const char *root) { /// not expose that value directly, so the hook reads the redirected topology /// tree and later compares agents by `HSA_AMD_AGENT_INFO_DRIVER_NODE_ID`. [[nodiscard]] std::optional node_id_for_kfd_gpu_id(uint32_t gpu_id) { - if (std::optional root = topology_nodes_root_for_test()) + if (std::optional root = rocjitsu::hooks::topology_nodes_root_for_test()) return node_id_for_kfd_gpu_id_in_root(root->c_str(), gpu_id); constexpr std::array kTopologyNodeRoots = { @@ -4036,11 +4045,6 @@ hsa_status_t HSA_API rj_executable_load_agent_code_object( #define RJ_HOOK_EXPORT #endif -/// @brief Set or clear the synthetic KFD topology root used by hook unit tests. -extern "C" RJ_HOOK_EXPORT void rj_hsa_dbt_set_topology_nodes_root_for_test(const char *root) { - set_topology_nodes_root_for_test(root); -} - /// @brief ROCR HSA tools entry point. /// /// @details Saves the incoming `CoreApiTable` function pointers and installs diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.cpp new file mode 100644 index 000000000000..01b5f18909b2 --- /dev/null +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.cpp @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: MIT + +/// @file rj_hsa_dbt_test_seams.cpp +/// @brief Exported C entry points for the DBT HSA hook's test seams. +/// +/// @details Linked only into `librocjitsu_hooks_testing.so`, the variant of the +/// hook that unit tests load. The production `librocjitsu_hooks.so` is built +/// from the same objects without this translation unit, so it exports only +/// `OnLoad` and `OnUnload`. + +#include "rocjitsu/hooks/rj_hsa_dbt_test_seams.h" + +#if defined(__GNUC__) || defined(__clang__) +#define RJ_HOOK_TEST_EXPORT __attribute__((visibility("default"))) +#else +#define RJ_HOOK_TEST_EXPORT +#endif + +/// @brief Set or clear the synthetic KFD topology root used by hook unit tests. +extern "C" RJ_HOOK_TEST_EXPORT void rj_hsa_dbt_set_topology_nodes_root_for_test(const char *root) { + rocjitsu::hooks::set_topology_nodes_root_for_test(root); +} diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.h b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.h new file mode 100644 index 000000000000..d480aab2ef43 --- /dev/null +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.h @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: MIT + +/// @file rj_hsa_dbt_test_seams.h +/// @brief Internal test seams of the DBT HSA hook. +/// +/// @details The hook reads KFD topology from a fixed pair of sysfs roots, which +/// unit tests cannot populate. These declarations let tests redirect that +/// lookup at a synthetic tree. They are defined in rj_hsa_dbt_hooks.cpp with +/// hidden visibility, so the production `librocjitsu_hooks.so` exports only its +/// HSA tool entry points (`OnLoad` and `OnUnload`). The C entry point that +/// drives the seam lives in rj_hsa_dbt_test_seams.cpp, which is linked into the +/// test-only `librocjitsu_hooks_testing.so` and nothing else. + +#pragma once + +#include +#include + +namespace rocjitsu::hooks { + +/// @brief Set or clear the synthetic KFD topology root used by hook unit tests. +/// +/// @param root Directory holding `/gpu_id` files, or nullptr/empty to +/// restore the real sysfs lookup. +void set_topology_nodes_root_for_test(const char *root); + +/// @brief Return a snapshot of the synthetic KFD topology root, if one is set. +/// +/// @details Always empty in production: nothing outside the test-only library +/// can reach set_topology_nodes_root_for_test(). +[[nodiscard]] std::optional topology_nodes_root_for_test(); + +} // namespace rocjitsu::hooks diff --git a/emulation/rocjitsu/tests/CMakeLists.txt b/emulation/rocjitsu/tests/CMakeLists.txt index fafccfc05b21..b0f8214fc0f6 100644 --- a/emulation/rocjitsu/tests/CMakeLists.txt +++ b/emulation/rocjitsu/tests/CMakeLists.txt @@ -1016,7 +1016,9 @@ target_include_directories( target_link_libraries( hsa_hooks_unit_test PRIVATE - rocjitsu_hooks + # Test-only hook variant: same objects as rocjitsu_hooks plus the + # exported rj_hsa_dbt_set_topology_nodes_root_for_test seam. + rocjitsu_hooks_testing ${RJ_OBJECT_LIBS} util flatbuffers From f1270ba19308012c281131dcee8e1cfe9f7a3136 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 17:32:58 +0000 Subject: [PATCH 21/31] fix(rocjitsu): avoid size_t underflow in long-path test helper relocate_active_config_to_long_path() grew the parent directory greedily in 201-byte steps, then subtracted a separator from whatever remained. When the handoff directory's length was congruent to the 4081-byte target modulo 201 (61 bytes, for one), the loop landed exactly on the target and the remainder underflowed to SIZE_MAX. std::string then threw std::length_error, which the filesystem_error handler does not catch, so the helper terminated the test process instead of failing it. One byte short of the target left no room for a separator plus a component and fell through to a bare false. Plan the padding from the byte budget instead: split it across the fewest components that stay within the per-component limit and spread the characters evenly, so every intermediate length stays in range. Return an AssertionResult naming the size invariant that failed for the two budgets no component can spend, and for the I/O and filesystem failures that previously also returned a bare false. Co-Authored-By: Claude Opus 5 (1M context) --- emulation/rocjitsu/tests/guest_kfd_test.cpp | 78 ++++++++++++++++----- 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/emulation/rocjitsu/tests/guest_kfd_test.cpp b/emulation/rocjitsu/tests/guest_kfd_test.cpp index 1adf878ccba0..5198ab9f1625 100644 --- a/emulation/rocjitsu/tests/guest_kfd_test.cpp +++ b/emulation/rocjitsu/tests/guest_kfd_test.cpp @@ -160,36 +160,80 @@ bool install_inline_dbt_config(std::string simulator_json, const char *host_isa, } } -bool relocate_active_config_to_long_path() { +// Move the active config to a path longer than PATH_MAX so the handoff reader is +// exercised against a path a fixed 4096-byte buffer cannot hold. +// +// The padding is planned up front rather than grown greedily. Each appended +// component costs one separator plus at least one character, so a greedy loop can +// land on a remainder it cannot spend: exactly on the target (where subtracting the +// separator underflows size_t and asks std::string for a SIZE_MAX-long component) +// or one byte short of it (where no legal component fits). Sizing the components +// from the byte budget keeps every intermediate length in range, and the remaining +// unspendable case -- a starting directory already at or past the budget -- is +// reported rather than silently returned as false, since it depends on the +// caller's runtime directory rather than on the behavior under test. +testing::AssertionResult relocate_active_config_to_long_path() { const auto dir = config_handoff_dir(); + if (!dir) + return testing::AssertionFailure() << "no config handoff directory to relocate into"; const auto json = read_active_config_json(); - if (!dir || !json) - return false; + if (!json) + return testing::AssertionFailure() << "no active config JSON to relocate"; try { - std::filesystem::path config_dir = *dir; - constexpr size_t kTargetParentLength = 4081; - while (config_dir.string().size() + 201 <= kTargetParentLength) - config_dir /= std::string(200, 'a'); - const size_t final_component_length = kTargetParentLength - config_dir.string().size() - 1; - if (final_component_length > 0) - config_dir /= std::string(final_component_length, 'a'); + constexpr std::string_view kConfigFileName = "config.json"; + constexpr size_t kMaxComponentLength = 200; // stays well under NAME_MAX + constexpr size_t kTargetPathLength = 4093; + constexpr size_t kTargetParentLength = kTargetPathLength - kConfigFileName.size() - 1; + + // Byte accounting below charges one separator per appended component, so drop + // any trailing separator the handoff directory already carries. A bare root + // supplies that separator itself, so it contributes no bytes of its own. + std::string parent = dir->string(); + while (parent.size() > 1 && parent.back() == '/') + parent.pop_back(); + const size_t parent_length = parent == "/" ? 0 : parent.size(); + + if (parent_length > kTargetParentLength) + return testing::AssertionFailure() + << "handoff directory " << *dir << " is " << parent.size() + << " bytes, already past the " << kTargetParentLength << "-byte parent to build"; + const size_t pad_budget = kTargetParentLength - parent_length; + if (pad_budget == 1) + return testing::AssertionFailure() + << "handoff directory " << *dir << " is " << parent.size() << " bytes, one short of " + << kTargetParentLength << ": no component fits in a separator plus one byte"; + + std::filesystem::path config_dir(parent); + if (pad_budget > 0) { + // Fewest components that keep each within kMaxComponentLength, with the + // characters left after the separators spread as evenly as possible. + const size_t components = (pad_budget + kMaxComponentLength) / (kMaxComponentLength + 1); + const size_t chars = pad_budget - components; + for (size_t i = 0; i < components; ++i) + config_dir /= std::string(chars / components + (i < chars % components ? 1 : 0), 'a'); + } + std::filesystem::create_directories(config_dir); - const std::filesystem::path config_path = config_dir / "config.json"; - if (config_path.string().size() != 4093) - return false; + const std::filesystem::path config_path = config_dir / kConfigFileName; + if (config_path.string().size() != kTargetPathLength) + return testing::AssertionFailure() + << "built a " << config_path.string().size() << "-byte config path from a " + << parent.size() << "-byte handoff directory, expected " << kTargetPathLength; std::ofstream config(config_path); config << *json; config.close(); if (!config) - return false; + return testing::AssertionFailure() << "failed to write config to the long path"; std::ofstream handoff(*dir / "config_path", std::ios::trunc); handoff << config_path.string() << "\n50148\n"; - return handoff.good(); - } catch (const std::filesystem::filesystem_error &) { - return false; + if (!handoff.good()) + return testing::AssertionFailure() << "failed to rewrite the config_path handoff"; + return testing::AssertionSuccess(); + } catch (const std::filesystem::filesystem_error &e) { + return testing::AssertionFailure() << "filesystem error building the long path: " << e.what(); } } From ddab036d33337d6c2497c84547811eddeaa21c16 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 17:38:55 +0000 Subject: [PATCH 22/31] fix(rocjitsu): fail agent iteration when the DBT host is unresolved GuestKfd rejects a host gpu_id it cannot match with ENODEV, but the HSA hook did not. When node_id_for_kfd_gpu_id() found no topology node for the configured host, AgentMapper recorded no mapping and rj_iterate_agents() fell through to the original iterator, publishing both physical agents. An application using the default device then ran untranslated on the real host, bypassing the guest entirely. Public enumeration is the replacement boundary, so fail closed there. Track whether the config demanded a mapping so the passthrough stays available when there is nothing to map, and return HSA_STATUS_ERROR when a required mapping was never resolved. MissingResolvedHostKeepsPhysicalAgentOrder pinned the old passthrough; it now expects the failure and is renamed accordingly. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp | 31 ++++++++++++++++++- .../tests/dbt/hsa_hooks_unit_test.cpp | 11 +++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp index eacafa78d70b..34cda9d82a3a 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp @@ -1490,6 +1490,18 @@ class AgentMapper { return has_mapping_ ? guest_ : hsa_agent_t{}; } + /// @brief Return true when the config demands a mapping that discovery could + /// not establish. + /// + /// @details Distinguishes "DBT is configured but its host is unreachable" from + /// "there is nothing to map", so callers can fail closed only in the former. + /// Discovery caches its outcome, so this stays true once it has failed. + [[nodiscard]] bool mapping_failed() { + ensure_discovered(); + std::lock_guard lock(mutex_); + return mapping_required_ && !has_mapping_; + } + /// @brief Replace the selected host agent with the visible guest agent. hsa_agent_t guest_for_host(hsa_agent_t agent) { ensure_discovered(); @@ -1504,6 +1516,7 @@ class AgentMapper { std::lock_guard lock(mutex_); discovered_ = false; has_mapping_ = false; + mapping_required_ = false; guest_ = {}; host_ = {}; ++generation_; @@ -1630,6 +1643,7 @@ class AgentMapper { hsa_agent_t discovered_host{}; bool has_mapping = false; bool attempted_agent_search = false; + const bool mapping_required = config && config->guest_target.has_value(); if (config && config->guest_target) { auto *iterate_agents = layer().iterate_agents(); @@ -1666,6 +1680,7 @@ class AgentMapper { guest_ = discovered_guest; host_ = discovered_host; has_mapping_ = has_mapping; + mapping_required_ = mapping_required; discovered_ = true; published = true; } @@ -1698,6 +1713,7 @@ class AgentMapper { bool discovering_ = false; bool discovered_ = false; bool has_mapping_ = false; + bool mapping_required_ = false; uint64_t generation_ = 0; hsa_agent_t guest_{}; hsa_agent_t host_{}; @@ -3062,8 +3078,21 @@ hsa_status_t HSA_API rj_iterate_agents(hsa_status_t (*callback)(hsa_agent_t agen hsa_agent_t guest = AgentMapper::instance().guest_agent(); hsa_agent_t host = AgentMapper::instance().host_for_guest(); - if (guest.handle == 0 || host.handle == 0) + if (guest.handle == 0 || host.handle == 0) { + // Fail closed when DBT is configured but its host never resolved -- the + // topology node for the configured gpu_id was not found, or ROCR never + // exposed both agents. Public enumeration is the replacement boundary, so + // falling through to the original iterator here would publish the physical + // host as an application-visible device and let anything using the default + // device run untranslated on it, which is exactly what the guest exists to + // prevent. GuestKfd rejects the same unresolved host with ENODEV. + if (AgentMapper::instance().mapping_failed()) { + std::fprintf(stderr, "[rocjitsu-hooks] refusing to enumerate agents: DBT is enabled but no " + "guest/host agent mapping was resolved\n"); + return HSA_STATUS_ERROR; + } return original(callback, data); + } struct ShadowIteration { hsa_status_t (*callback)(hsa_agent_t, void *) = nullptr; diff --git a/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp b/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp index 94e783be5ea5..ba71e739513d 100644 --- a/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp +++ b/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp @@ -1008,7 +1008,12 @@ TEST(HsaHooksUnitTest, IterateAgentsDropsGuestOwnSlotWhenHostAppearsFirst) { EXPECT_EQ(seen, std::vector{kGuestAgent.handle}); } -TEST(HsaHooksUnitTest, MissingResolvedHostKeepsPhysicalAgentOrder) { +// The configured host gpu_id has no topology node here, so the hook cannot tell +// which physical agent the guest should execute on. Enumeration must fail rather +// than fall through to the raw agent list: publishing the physical host would let +// an application using the default device run untranslated on it. GuestKfd already +// rejects the same unresolved host with ENODEV. +TEST(HsaHooksUnitTest, MissingResolvedHostFailsAgentIteration) { reset_pool_blocker(false); reset_agent_blocker(false); FakeApiTable api; @@ -1023,8 +1028,8 @@ TEST(HsaHooksUnitTest, MissingResolvedHostKeepsPhysicalAgentOrder) { }, &seen); - EXPECT_EQ(status, HSA_STATUS_SUCCESS); - EXPECT_EQ(seen, (std::vector{kGuestAgent.handle, kHostAgent.handle})); + EXPECT_EQ(status, HSA_STATUS_ERROR); + EXPECT_TRUE(seen.empty()); } TEST(HsaHooksUnitTest, BatchCopyMapsScalarSourceAndDestinationAgents) { From 572398898b42208232838dc11a75cb17551661a9 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 21:28:31 +0000 Subject: [PATCH 23/31] test(rocjitsu): move visibility tests beside their translation unit rocm_visibility.{h,cpp} arrived with all 24 of its tests wedged into launch_preload_test.cpp, between that file's own cases. The file was advertising eight preload tests and carrying thirty-two. tests/ mirrors the source structure and this directory already follows one-file-per-unit, so give the visibility helpers their own test file. The only coupling was that rocm_visibility.cpp links into the existing rocjitsu_cli_launch_preload target, which is a build detail rather than a reason to share a test file. Pure move: no test was added, removed, or changed. Co-Authored-By: Claude Opus 5 (1M context) --- emulation/rocjitsu/tests/CMakeLists.txt | 12 + .../rocjitsu/tests/launch_preload_test.cpp | 220 ----------------- .../rocjitsu/tests/rocm_visibility_test.cpp | 232 ++++++++++++++++++ 3 files changed, 244 insertions(+), 220 deletions(-) create mode 100644 emulation/rocjitsu/tests/rocm_visibility_test.cpp diff --git a/emulation/rocjitsu/tests/CMakeLists.txt b/emulation/rocjitsu/tests/CMakeLists.txt index 6734c2db2976..db65b6beb148 100644 --- a/emulation/rocjitsu/tests/CMakeLists.txt +++ b/emulation/rocjitsu/tests/CMakeLists.txt @@ -705,6 +705,18 @@ endif() rj_configure_target(launch_preload_test TEST) rj_add_gtest_tests(launch_preload_test) +# --- CLI ROCm visibility tests --- +# rocm_visibility.cpp ships in the launch-preload library for build convenience, +# but its tests get their own executable so they mirror the source structure. + +add_executable(rocm_visibility_test rocm_visibility_test.cpp) +target_link_libraries( + rocm_visibility_test + PRIVATE rocjitsu_cli_launch_preload GTest::gtest_main +) +rj_configure_target(rocm_visibility_test TEST) +rj_add_gtest_tests(rocm_visibility_test) + # --- DBI probe fixture test --- # Loads a real amdclang++-compiled rj_nop_probe device ELF and exercises the # probe symbol resolver against it. Needs the compiled fixture (amdclang++ + diff --git a/emulation/rocjitsu/tests/launch_preload_test.cpp b/emulation/rocjitsu/tests/launch_preload_test.cpp index fe7f6b02a78f..e87b4cb42b8f 100644 --- a/emulation/rocjitsu/tests/launch_preload_test.cpp +++ b/emulation/rocjitsu/tests/launch_preload_test.cpp @@ -2,7 +2,6 @@ // SPDX-License-Identifier: MIT #include "launch_preload.h" -#include "rocm_visibility.h" #include "scoped_temp.h" #include @@ -56,12 +55,6 @@ void expect_ld_preload_eq(const rocjitsu::cli::LaunchEnvironment &environment, EXPECT_EQ(expected, ld_preload); } -std::vector test_gpus() { - return {{0, 100, 90402, 0x1111111111111111ULL}, - {1, 101, 90402, 0x2222222222222222ULL}, - {2, 102, 120001, 0x3333333333333333ULL}}; -} - #if defined(RJ_EXPECT_SHARED_ASAN_RUNTIME) || defined(RJ_EXPECT_SHARED_TSAN_RUNTIME) std::string canonical_existing_path(const std::filesystem::path &path) { std::error_code ec; @@ -105,219 +98,6 @@ TEST(LaunchPreloadTest, EnvpCacheRebuildsAfterSet) { EXPECT_NE(rebuilt.end(), std::find(rebuilt.begin(), rebuilt.end(), after)); } -TEST(RocmVisibilityTest, UnsetAndEmptyRocrSelectorsDiffer) { - const auto gpus = test_gpus(); - EXPECT_EQ(3u, rocjitsu::cli::filter_rocr_visible_gpus(gpus, std::nullopt).size()); - EXPECT_TRUE(rocjitsu::cli::filter_rocr_visible_gpus(gpus, std::string_view{}).empty()); -} - -TEST(RocmVisibilityTest, KfdEnumerationSkipsZeroGpuIdsAndCompactsOrdinals) { - const std::vector candidates{{0, 0, 90402, 0}, - {1, 101, 90402, 0x2222222222222222ULL}}; - const auto gpus = rocjitsu::cli::enumerate_kfd_gpus(candidates); - - ASSERT_EQ(1u, gpus.size()); - EXPECT_EQ(0u, gpus[0].ordinal); - EXPECT_EQ(101u, gpus[0].gpu_id); -} - -TEST(RocmVisibilityTest, NumericAndUuidSelectorsReorderDevices) { - const auto gpus = test_gpus(); - const auto numeric = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "1,0"); - const auto uuid = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "gpu-2222"); - - ASSERT_EQ(2u, numeric.size()); - EXPECT_EQ(101u, numeric[0].gpu_id); - EXPECT_EQ(100u, numeric[1].gpu_id); - ASSERT_EQ(1u, uuid.size()); - EXPECT_EQ(101u, uuid[0].gpu_id); -} - -TEST(RocmVisibilityTest, AmbiguousRocrUuidTerminatesAfterValidPrefix) { - const std::vector gpus{{0, 100, 90402, 0x1111111111111111ULL}, - {1, 101, 90402, 0x2222222211111111ULL}, - {2, 102, 90402, 0x2222222244444444ULL}}; - const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "0,GPU-2222,1"); - - ASSERT_EQ(1u, selected.size()); - EXPECT_EQ(100u, selected[0].gpu_id); -} - -TEST(RocmVisibilityTest, ClientUuidUsesCaseSensitiveFirstMatch) { - const std::vector gpus{{0, 100, 90402, 0x1111111111111111ULL}, - {1, 101, 90402, 0x1111111111111122ULL}}; - const auto first = rocjitsu::cli::filter_client_visible_gpus(gpus, "GPU-1111"); - const auto lowercase = rocjitsu::cli::filter_client_visible_gpus(gpus, "gpu-1111"); - - ASSERT_EQ(1u, first.size()); - EXPECT_EQ(100u, first[0].gpu_id); - EXPECT_TRUE(lowercase.empty()); -} - -TEST(RocmVisibilityTest, InvalidRocrTokenPreservesValidPrefix) { - const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "1,invalid,0"); - ASSERT_EQ(1u, selected.size()); - EXPECT_EQ(101u, selected[0].gpu_id); -} - -TEST(RocmVisibilityTest, MalformedSelectorsTerminateSelection) { - EXPECT_TRUE(rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "GPU-").empty()); - EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "01").empty()); - EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "00").empty()); - - const auto rocr_prefix = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,GPU-,1"); - ASSERT_EQ(1u, rocr_prefix.size()); - EXPECT_EQ(100u, rocr_prefix[0].gpu_id); - - const auto client_prefix = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "1,00,2"); - ASSERT_EQ(1u, client_prefix.size()); - EXPECT_EQ(101u, client_prefix[0].gpu_id); -} - -TEST(RocmVisibilityTest, DuplicateAndReorderedSelectorsMatchRuntimeBehavior) { - const auto rocr_duplicate = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,0,1"); - ASSERT_EQ(1u, rocr_duplicate.size()); - EXPECT_EQ(100u, rocr_duplicate[0].gpu_id); - - const auto client_duplicate = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "0,0,1"); - ASSERT_EQ(2u, client_duplicate.size()); - EXPECT_EQ(100u, client_duplicate[0].gpu_id); - EXPECT_EQ(101u, client_duplicate[1].gpu_id); - - const auto client_reordered = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "2,0"); - ASSERT_EQ(2u, client_reordered.size()); - EXPECT_EQ(102u, client_reordered[0].gpu_id); - EXPECT_EQ(100u, client_reordered[1].gpu_id); -} - -TEST(RocmVisibilityTest, NegativeRocrOrdinalPreservesValidPrefix) { - const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,-1,1"); - ASSERT_EQ(1u, selected.size()); - EXPECT_EQ(100u, selected[0].gpu_id); -} - -TEST(RocmVisibilityTest, HipSelectorUsesPostRocrOrdinals) { - const auto selected = - rocjitsu::cli::effective_visible_gpus(test_gpus(), "1,0", "1", std::nullopt); - ASSERT_EQ(1u, selected.size()); - EXPECT_EQ(100u, selected[0].gpu_id); -} - -TEST(RocmVisibilityTest, HipSelectorTakesPrecedenceOverCudaFallback) { - const auto hip = rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, "1", "2"); - const auto cuda = - rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, std::string_view{}, "2"); - - ASSERT_EQ(1u, hip.size()); - EXPECT_EQ(101u, hip[0].gpu_id); - ASSERT_EQ(1u, cuda.size()); - EXPECT_EQ(102u, cuda[0].gpu_id); -} - -TEST(RocmVisibilityTest, NormalizesClientUuidToPostRocrOrdinal) { - const auto normalized = rocjitsu::cli::normalized_client_visible_devices( - test_gpus(), "1,0", "GPU-1111111111111111", std::nullopt); - ASSERT_TRUE(normalized); - EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); - EXPECT_EQ("1", normalized->value); -} - -TEST(RocmVisibilityTest, NormalizesClientSelectorAgainstExpandedRocrOrder) { - const auto normalized = - rocjitsu::cli::normalized_client_visible_devices(test_gpus(), "1,0,3", "1", std::nullopt); - ASSERT_TRUE(normalized); - EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); - EXPECT_EQ("1", normalized->value); -} - -TEST(RocmVisibilityTest, NormalizesSelectedDbtHostToClientDeviceZero) { - const std::vector heterogeneous_gpus{ - {0, 100, 110000, 0x1111111111111111ULL}, {1, 101, 90402, 0x2222222222222222ULL}}; - const auto normalized = rocjitsu::cli::normalized_client_visible_devices( - heterogeneous_gpus, std::nullopt, "0,1", std::nullopt, 101); - - ASSERT_TRUE(normalized); - EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); - EXPECT_EQ("1,0", normalized->value); -} - -TEST(RocmVisibilityTest, SynthesizesClientOrderForSelectedDbtHost) { - const std::vector heterogeneous_gpus{ - {0, 100, 110000, 0x1111111111111111ULL}, {1, 101, 90402, 0x2222222222222222ULL}}; - const auto normalized = rocjitsu::cli::normalized_client_visible_devices( - heterogeneous_gpus, std::nullopt, std::nullopt, std::nullopt, 101); - - ASSERT_TRUE(normalized); - EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); - EXPECT_EQ("1,0", normalized->value); -} - -TEST(RocmVisibilityTest, DoesNotSynthesizeClientOrderWithoutSelectedHost) { - EXPECT_FALSE(rocjitsu::cli::normalized_client_visible_devices( - test_gpus(), std::nullopt, std::nullopt, std::nullopt, std::nullopt)); -} - -TEST(RocmVisibilityTest, SelectsDbtHostFromClientVisibleGpusBeforeNormalization) { - const auto visible = - rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, "1", std::nullopt); - const auto automatic = rocjitsu::cli::select_host_gpu(visible, 0, 90402); - ASSERT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, automatic.status); - EXPECT_EQ(101u, automatic.gpu_id); - - const auto normalized = rocjitsu::cli::normalized_client_visible_devices( - test_gpus(), std::nullopt, "1", std::nullopt, automatic.gpu_id); - ASSERT_TRUE(normalized); - EXPECT_EQ("1", normalized->value); - - const auto hidden_explicit = rocjitsu::cli::select_host_gpu(visible, 100, 90402); - EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuHidden, hidden_explicit.status); -} - -TEST(RocmVisibilityTest, ExpandsRocrSelectionWithGuestOrdinal) { - const auto expanded = rocjitsu::cli::expanded_rocr_visible_devices(test_gpus(), "GPU-2222"); - ASSERT_TRUE(expanded); - EXPECT_EQ("1,3", *expanded); -} - -TEST(RocmVisibilityTest, DoesNotExpandEmptyRocrSelection) { - EXPECT_FALSE(rocjitsu::cli::expanded_rocr_visible_devices(test_gpus(), "5")); -} - -TEST(RocmVisibilityTest, SelectsFirstVisibleIsaMatch) { - const rocjitsu::cli::HostSelection selection = - rocjitsu::cli::select_host_gpu(test_gpus(), 0, 90402); - EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, selection.status); - EXPECT_EQ(100u, selection.gpu_id); -} - -TEST(RocmVisibilityTest, SelectsExplicitGpuWithMatchingIsa) { - const rocjitsu::cli::HostSelection selection = - rocjitsu::cli::select_host_gpu(test_gpus(), 101, 90402); - EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, selection.status); - EXPECT_EQ(101u, selection.gpu_id); -} - -TEST(RocmVisibilityTest, RejectsHiddenExplicitGpu) { - const rocjitsu::cli::HostSelection selection = - rocjitsu::cli::select_host_gpu({test_gpus()[0]}, 101, 90402); - EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuHidden, selection.status); - EXPECT_EQ(101u, selection.gpu_id); -} - -TEST(RocmVisibilityTest, RejectsExplicitGpuWithDifferentIsa) { - const rocjitsu::cli::HostSelection selection = - rocjitsu::cli::select_host_gpu(test_gpus(), 102, 90402); - EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuIsaMismatch, selection.status); - EXPECT_EQ(102u, selection.gpu_id); -} - -TEST(RocmVisibilityTest, ReportsMissingIsaMatch) { - const rocjitsu::cli::HostSelection selection = - rocjitsu::cli::select_host_gpu(test_gpus(), 0, 999999); - EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::NoIsaMatch, selection.status); - EXPECT_EQ(0u, selection.gpu_id); -} - TEST(LaunchPreloadTest, NoAsanPrependsInterposerBeforeExistingPreload) { #if defined(RJ_EXPECT_SHARED_ASAN_RUNTIME) || defined(RJ_EXPECT_SHARED_TSAN_RUNTIME) GTEST_SKIP() << "shared sanitizer builds exercise sanitizer ordering cases"; diff --git a/emulation/rocjitsu/tests/rocm_visibility_test.cpp b/emulation/rocjitsu/tests/rocm_visibility_test.cpp new file mode 100644 index 000000000000..1e7477fb56f0 --- /dev/null +++ b/emulation/rocjitsu/tests/rocm_visibility_test.cpp @@ -0,0 +1,232 @@ +// Copyright (c) 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: MIT + +#include "rocm_visibility.h" + +#include +#include +#include +#include + +namespace { + +std::vector test_gpus() { + return {{0, 100, 90402, 0x1111111111111111ULL}, + {1, 101, 90402, 0x2222222222222222ULL}, + {2, 102, 120001, 0x3333333333333333ULL}}; +} + +} // namespace + +TEST(RocmVisibilityTest, UnsetAndEmptyRocrSelectorsDiffer) { + const auto gpus = test_gpus(); + EXPECT_EQ(3u, rocjitsu::cli::filter_rocr_visible_gpus(gpus, std::nullopt).size()); + EXPECT_TRUE(rocjitsu::cli::filter_rocr_visible_gpus(gpus, std::string_view{}).empty()); +} + +TEST(RocmVisibilityTest, KfdEnumerationSkipsZeroGpuIdsAndCompactsOrdinals) { + const std::vector candidates{{0, 0, 90402, 0}, + {1, 101, 90402, 0x2222222222222222ULL}}; + const auto gpus = rocjitsu::cli::enumerate_kfd_gpus(candidates); + + ASSERT_EQ(1u, gpus.size()); + EXPECT_EQ(0u, gpus[0].ordinal); + EXPECT_EQ(101u, gpus[0].gpu_id); +} + +TEST(RocmVisibilityTest, NumericAndUuidSelectorsReorderDevices) { + const auto gpus = test_gpus(); + const auto numeric = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "1,0"); + const auto uuid = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "gpu-2222"); + + ASSERT_EQ(2u, numeric.size()); + EXPECT_EQ(101u, numeric[0].gpu_id); + EXPECT_EQ(100u, numeric[1].gpu_id); + ASSERT_EQ(1u, uuid.size()); + EXPECT_EQ(101u, uuid[0].gpu_id); +} + +TEST(RocmVisibilityTest, AmbiguousRocrUuidTerminatesAfterValidPrefix) { + const std::vector gpus{{0, 100, 90402, 0x1111111111111111ULL}, + {1, 101, 90402, 0x2222222211111111ULL}, + {2, 102, 90402, 0x2222222244444444ULL}}; + const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(gpus, "0,GPU-2222,1"); + + ASSERT_EQ(1u, selected.size()); + EXPECT_EQ(100u, selected[0].gpu_id); +} + +TEST(RocmVisibilityTest, ClientUuidUsesCaseSensitiveFirstMatch) { + const std::vector gpus{{0, 100, 90402, 0x1111111111111111ULL}, + {1, 101, 90402, 0x1111111111111122ULL}}; + const auto first = rocjitsu::cli::filter_client_visible_gpus(gpus, "GPU-1111"); + const auto lowercase = rocjitsu::cli::filter_client_visible_gpus(gpus, "gpu-1111"); + + ASSERT_EQ(1u, first.size()); + EXPECT_EQ(100u, first[0].gpu_id); + EXPECT_TRUE(lowercase.empty()); +} + +TEST(RocmVisibilityTest, InvalidRocrTokenPreservesValidPrefix) { + const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "1,invalid,0"); + ASSERT_EQ(1u, selected.size()); + EXPECT_EQ(101u, selected[0].gpu_id); +} + +TEST(RocmVisibilityTest, MalformedSelectorsTerminateSelection) { + EXPECT_TRUE(rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "GPU-").empty()); + EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "01").empty()); + EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "00").empty()); + + const auto rocr_prefix = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,GPU-,1"); + ASSERT_EQ(1u, rocr_prefix.size()); + EXPECT_EQ(100u, rocr_prefix[0].gpu_id); + + const auto client_prefix = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "1,00,2"); + ASSERT_EQ(1u, client_prefix.size()); + EXPECT_EQ(101u, client_prefix[0].gpu_id); +} + +TEST(RocmVisibilityTest, DuplicateAndReorderedSelectorsMatchRuntimeBehavior) { + const auto rocr_duplicate = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,0,1"); + ASSERT_EQ(1u, rocr_duplicate.size()); + EXPECT_EQ(100u, rocr_duplicate[0].gpu_id); + + const auto client_duplicate = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "0,0,1"); + ASSERT_EQ(2u, client_duplicate.size()); + EXPECT_EQ(100u, client_duplicate[0].gpu_id); + EXPECT_EQ(101u, client_duplicate[1].gpu_id); + + const auto client_reordered = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "2,0"); + ASSERT_EQ(2u, client_reordered.size()); + EXPECT_EQ(102u, client_reordered[0].gpu_id); + EXPECT_EQ(100u, client_reordered[1].gpu_id); +} + +TEST(RocmVisibilityTest, NegativeRocrOrdinalPreservesValidPrefix) { + const auto selected = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,-1,1"); + ASSERT_EQ(1u, selected.size()); + EXPECT_EQ(100u, selected[0].gpu_id); +} + +TEST(RocmVisibilityTest, HipSelectorUsesPostRocrOrdinals) { + const auto selected = + rocjitsu::cli::effective_visible_gpus(test_gpus(), "1,0", "1", std::nullopt); + ASSERT_EQ(1u, selected.size()); + EXPECT_EQ(100u, selected[0].gpu_id); +} + +TEST(RocmVisibilityTest, HipSelectorTakesPrecedenceOverCudaFallback) { + const auto hip = rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, "1", "2"); + const auto cuda = + rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, std::string_view{}, "2"); + + ASSERT_EQ(1u, hip.size()); + EXPECT_EQ(101u, hip[0].gpu_id); + ASSERT_EQ(1u, cuda.size()); + EXPECT_EQ(102u, cuda[0].gpu_id); +} + +TEST(RocmVisibilityTest, NormalizesClientUuidToPostRocrOrdinal) { + const auto normalized = rocjitsu::cli::normalized_client_visible_devices( + test_gpus(), "1,0", "GPU-1111111111111111", std::nullopt); + ASSERT_TRUE(normalized); + EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); + EXPECT_EQ("1", normalized->value); +} + +TEST(RocmVisibilityTest, NormalizesClientSelectorAgainstExpandedRocrOrder) { + const auto normalized = + rocjitsu::cli::normalized_client_visible_devices(test_gpus(), "1,0,3", "1", std::nullopt); + ASSERT_TRUE(normalized); + EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); + EXPECT_EQ("1", normalized->value); +} + +TEST(RocmVisibilityTest, NormalizesSelectedDbtHostToClientDeviceZero) { + const std::vector heterogeneous_gpus{ + {0, 100, 110000, 0x1111111111111111ULL}, {1, 101, 90402, 0x2222222222222222ULL}}; + const auto normalized = rocjitsu::cli::normalized_client_visible_devices( + heterogeneous_gpus, std::nullopt, "0,1", std::nullopt, 101); + + ASSERT_TRUE(normalized); + EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); + EXPECT_EQ("1,0", normalized->value); +} + +TEST(RocmVisibilityTest, SynthesizesClientOrderForSelectedDbtHost) { + const std::vector heterogeneous_gpus{ + {0, 100, 110000, 0x1111111111111111ULL}, {1, 101, 90402, 0x2222222222222222ULL}}; + const auto normalized = rocjitsu::cli::normalized_client_visible_devices( + heterogeneous_gpus, std::nullopt, std::nullopt, std::nullopt, 101); + + ASSERT_TRUE(normalized); + EXPECT_EQ("HIP_VISIBLE_DEVICES", normalized->name); + EXPECT_EQ("1,0", normalized->value); +} + +TEST(RocmVisibilityTest, DoesNotSynthesizeClientOrderWithoutSelectedHost) { + EXPECT_FALSE(rocjitsu::cli::normalized_client_visible_devices( + test_gpus(), std::nullopt, std::nullopt, std::nullopt, std::nullopt)); +} + +TEST(RocmVisibilityTest, SelectsDbtHostFromClientVisibleGpusBeforeNormalization) { + const auto visible = + rocjitsu::cli::effective_visible_gpus(test_gpus(), std::nullopt, "1", std::nullopt); + const auto automatic = rocjitsu::cli::select_host_gpu(visible, 0, 90402); + ASSERT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, automatic.status); + EXPECT_EQ(101u, automatic.gpu_id); + + const auto normalized = rocjitsu::cli::normalized_client_visible_devices( + test_gpus(), std::nullopt, "1", std::nullopt, automatic.gpu_id); + ASSERT_TRUE(normalized); + EXPECT_EQ("1", normalized->value); + + const auto hidden_explicit = rocjitsu::cli::select_host_gpu(visible, 100, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuHidden, hidden_explicit.status); +} + +TEST(RocmVisibilityTest, ExpandsRocrSelectionWithGuestOrdinal) { + const auto expanded = rocjitsu::cli::expanded_rocr_visible_devices(test_gpus(), "GPU-2222"); + ASSERT_TRUE(expanded); + EXPECT_EQ("1,3", *expanded); +} + +TEST(RocmVisibilityTest, DoesNotExpandEmptyRocrSelection) { + EXPECT_FALSE(rocjitsu::cli::expanded_rocr_visible_devices(test_gpus(), "5")); +} + +TEST(RocmVisibilityTest, SelectsFirstVisibleIsaMatch) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(test_gpus(), 0, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, selection.status); + EXPECT_EQ(100u, selection.gpu_id); +} + +TEST(RocmVisibilityTest, SelectsExplicitGpuWithMatchingIsa) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(test_gpus(), 101, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::Selected, selection.status); + EXPECT_EQ(101u, selection.gpu_id); +} + +TEST(RocmVisibilityTest, RejectsHiddenExplicitGpu) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu({test_gpus()[0]}, 101, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuHidden, selection.status); + EXPECT_EQ(101u, selection.gpu_id); +} + +TEST(RocmVisibilityTest, RejectsExplicitGpuWithDifferentIsa) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(test_gpus(), 102, 90402); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::ExplicitGpuIsaMismatch, selection.status); + EXPECT_EQ(102u, selection.gpu_id); +} + +TEST(RocmVisibilityTest, ReportsMissingIsaMatch) { + const rocjitsu::cli::HostSelection selection = + rocjitsu::cli::select_host_gpu(test_gpus(), 0, 999999); + EXPECT_EQ(rocjitsu::cli::HostSelectionStatus::NoIsaMatch, selection.status); + EXPECT_EQ(0u, selection.gpu_id); +} From b9f3bb116a1b660008a4795347ac81255a0d59af Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 21:28:46 +0000 Subject: [PATCH 24/31] fix(rocjitsu): require a valid GPU- prefix in client device selectors filter_client_visible_gpus() gated its UUID branch on a bare substring search, so any token merely containing "GPU-" was resolved against the first agent whose UUID also contained it. A bare "GPU-" selected gpu_id 100, and "1,GPU-,2" produced 101,100,102 -- an invalid isolation setting silently selecting devices instead of terminating selection. Apply the same spelling guard the ROCR path already used, factored into is_gpu_uuid_selector(): "GPU-" plus 1..16 hex digits of the 64-bit id, rejecting the "GPU-XX" sentinel ROCR reports for agents without UUID support. The comparison becomes a prefix match rather than a substring search. This makes rocjitsu deliberately stricter than the runtime it models -- CLR gates on the same bare find() with no length or prefix check (projects/clr/rocclr/device/rocm/rocdevice.cpp:431-444), so CLR resolves "GPU-" to agent 0 too. The divergence is safe only because CLR never sees a token rocjitsu rejected: this filter runs in DBT guest mode, where main.cpp unconditionally rewrites the client selector to normalized_client_visible_devices()'s canonical numeric list before execvp. Both the guard and the rewrite carry comments saying so. Everything below the guard stays CLR-shaped, preserving the intentional ROCR-vs-CLR split that DuplicateAndReorderedSelectorsMatchRuntimeBehavior documents: first match wins rather than terminating on ambiguity, and the comparison stays case-sensitive. Co-Authored-By: Claude Opus 5 (1M context) --- .../rocjitsu/tests/rocm_visibility_test.cpp | 13 ++++++ .../tools/rocjitsu/rocm_visibility.cpp | 40 +++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/emulation/rocjitsu/tests/rocm_visibility_test.cpp b/emulation/rocjitsu/tests/rocm_visibility_test.cpp index 1e7477fb56f0..ded842fbd128 100644 --- a/emulation/rocjitsu/tests/rocm_visibility_test.cpp +++ b/emulation/rocjitsu/tests/rocm_visibility_test.cpp @@ -78,6 +78,14 @@ TEST(RocmVisibilityTest, MalformedSelectorsTerminateSelection) { EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "01").empty()); EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "00").empty()); + // The client UUID guard rejects malformed spellings rather than substring-matching them onto the + // first agent: too short to carry a body, the ROCR no-UUID sentinel, and one hex digit past the + // 20-char maximum. Each falls through to the numeric parse, which terminates selection. + EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "GPU-").empty()); + EXPECT_TRUE(rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "GPU-XX").empty()); + EXPECT_TRUE( + rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "GPU-11111111111111111").empty()); + const auto rocr_prefix = rocjitsu::cli::filter_rocr_visible_gpus(test_gpus(), "0,GPU-,1"); ASSERT_EQ(1u, rocr_prefix.size()); EXPECT_EQ(100u, rocr_prefix[0].gpu_id); @@ -85,6 +93,11 @@ TEST(RocmVisibilityTest, MalformedSelectorsTerminateSelection) { const auto client_prefix = rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "1,00,2"); ASSERT_EQ(1u, client_prefix.size()); EXPECT_EQ(101u, client_prefix[0].gpu_id); + + const auto client_uuid_prefix = + rocjitsu::cli::filter_client_visible_gpus(test_gpus(), "1,GPU-,2"); + ASSERT_EQ(1u, client_uuid_prefix.size()); + EXPECT_EQ(101u, client_uuid_prefix[0].gpu_id); } TEST(RocmVisibilityTest, DuplicateAndReorderedSelectorsMatchRuntimeBehavior) { diff --git a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp index 47c82eefb788..1c03b08a9098 100644 --- a/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp +++ b/emulation/rocjitsu/tools/rocjitsu/rocm_visibility.cpp @@ -6,7 +6,7 @@ #include #include #include -#include +#include #include namespace rocjitsu::cli { @@ -22,6 +22,23 @@ std::string_view trim(std::string_view text) { std::string gpu_uuid(uint64_t unique_id) { return std::format("GPU-{:016X}", unique_id); } +constexpr std::string_view kGpuUuidPrefix = "GPU-"; +constexpr std::string_view kNoUuidSentinel = "GPU-XX"; +constexpr size_t kMinGpuUuidSelectorLength = kGpuUuidPrefix.size() + 1; +constexpr size_t kMaxGpuUuidSelectorLength = kGpuUuidPrefix.size() + 2 * sizeof(uint64_t); + +/// @brief Whether @p token is spelled as a GPU UUID prefix rather than as a device ordinal. +/// @details The accepted window is ROCR's own: "GPU-" followed by at least one and at most 16 hex +/// digits of the 64-bit KFD unique ID, which is the "GPU-{:016X}" spelling gpu_uuid() produces and +/// the 5..20 token-length check in +/// projects/rocr-runtime/runtime/hsa-runtime/core/runtime/amd_filter_device.cpp:119. "GPU-XX" is +/// the sentinel ROCR reports for agents without UUID support, so it never names a device. Both +/// selector paths share this predicate on purpose; only the matching below each call site differs. +bool is_gpu_uuid_selector(std::string_view token) { + return token.size() >= kMinGpuUuidSelectorLength && token.size() <= kMaxGpuUuidSelectorLength && + token.starts_with(kGpuUuidPrefix) && token != kNoUuidSentinel; +} + std::string join_comma(const std::vector &tokens) { std::string result; for (size_t i = 0; i < tokens.size(); ++i) { @@ -71,7 +88,7 @@ std::vector filter_rocr_visible_gpus(const std::vector & [](unsigned char c) { return static_cast(std::toupper(c)); }); std::optional index; - if (token.size() >= 5 && token.size() <= 20 && token.starts_with("GPU-") && token != "GPU-XX") { + if (is_gpu_uuid_selector(token)) { for (size_t candidate = 0; candidate < gpus.size(); ++candidate) { if (gpus[candidate].unique_id == 0 || !gpu_uuid(gpus[candidate].unique_id).starts_with(token)) @@ -111,10 +128,25 @@ std::vector filter_client_visible_gpus(const std::vector const size_t comma = rest.find(','); std::string token(comma == std::string_view::npos ? rest : rest.substr(0, comma)); - if (token.find("GPU-") != std::string::npos) { + // The spelling guard is the only thing this path borrows from filter_rocr_visible_gpus, and + // its window makes rocjitsu STRICTER than the runtime this path models: CLR gates on a bare + // find("GPU-") with no length or sentinel check + // (projects/clr/rocclr/device/rocm/rocdevice.cpp:431-444), so CLR resolves the token "GPU-" to + // agent 0. Diverging is safe only because CLR never sees a token rocjitsu rejected: + // filter_client_visible_gpus runs only in DBT guest mode, where main.cpp unconditionally + // rewrites the client selector to normalized_client_visible_devices()'s canonical numeric + // list before execvp. Do not restore CLR parity here without also removing that rewrite. + // + // Everything below the guard stays CLR-shaped, preserving the deliberate ROCR-vs-CLR split + // that DuplicateAndReorderedSelectorsMatchRuntimeBehavior documents: the break makes the + // first match win rather than terminating on ambiguity, the compare is case-sensitive + // because this path never uppercases, and a token that fails the guard or matches no agent + // needs no early return -- it falls through to the numeric parse below, which rejects it and + // terminates selection. + if (is_gpu_uuid_selector(token)) { for (size_t candidate = 0; candidate < gpus.size(); ++candidate) { if (gpus[candidate].unique_id != 0 && - gpu_uuid(gpus[candidate].unique_id).find(token) != std::string::npos) { + gpu_uuid(gpus[candidate].unique_id).starts_with(token)) { token = std::to_string(candidate); break; } From 0eb0d7e2a00303a34cdfafc43e4e9f208114a3cd Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 21:29:05 +0000 Subject: [PATCH 25/31] fix(rocjitsu): make the DBT agent-mapping guard recoverable and GPU-scoped Refusing to enumerate agents when the DBT host never resolved was right in direction but wrong in three ways. It latched. ensure_discovered() published discovered_ = true even when discovery failed, and clear() only runs at OnUnload, so a single transient miss -- sysfs EMFILE/EACCES, a topology tree not yet populated, ROCR not yet enumerating -- poisoned agent enumeration for the life of the process. Classify the failure instead: TopologyLookup separates "read the whole tree, gpu_id is absent" from "could not read the tree", and DiscoveryOutcome turns that into a retryable or final verdict, retried a bounded number of times. Only snapshot() opts in; map(), is_guest(), guest_agent() and host_for_guest() stay cached-only and O(1), because they run once per copy and once per dispatch. The settled-state check now precedes the condition-variable wait so those callers cannot park behind another thread's retry. It was too broad. Returning HSA_STATUS_ERROR suppressed CPU agents too, breaking host fine-grained pools, hsa_amd_memory_lock and host-side copies that were never a translation hazard, and handing HIP an opaque init failure rather than an empty device list. Filter GPU agents on the refusal path and let CPU agents through; anything that cannot be positively classified counts as a GPU and is suppressed. It was too narrow. The fail-closed property rested entirely on no agent handle escaping enumeration. With no mapping, is_guest() returns false, so rj_executable_load_agent_code_object took its pass-through branch and would load untranslated guest code on the raw host. Guard the five GPU-only execution entry points with mapping_unusable(); deliberately leave the query and copy hooks unguarded, since they legitimately take CPU agents. Also refuse rather than guess when a config reaches the hook with no resolved host gpu_id. The host predicate was only constrained by driver node id when host_gpu_id != 0; at 0 it degraded to "any agent with the host ISA" -- the first-ISA-match selection this series exists to remove. That was unreachable only because load_dbt_guest_config_from_handoff() throws two files away, which is coincidental correctness in the fail-open direction. Resolve the host before the guest so a same-ISA config cannot let the guest predicate claim the physical host, and report that collision distinctly from "no agents found". Co-Authored-By: Claude Opus 5 (1M context) --- .../src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp | 442 ++++++++++++++--- .../rocjitsu/hooks/rj_hsa_dbt_test_seams.h | 13 +- .../tests/dbt/hsa_hooks_unit_test.cpp | 467 +++++++++++++++++- 3 files changed, 842 insertions(+), 80 deletions(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp index 34cda9d82a3a..b9e9d82d5d1c 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_hooks.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -82,6 +83,15 @@ namespace { std::mutex g_topology_nodes_root_mutex; std::optional g_topology_nodes_root_for_test; +/// @brief Return a snapshot of the synthetic KFD topology root, if one is set. +/// +/// @details Always empty in production: nothing outside the test-only library +/// can reach set_topology_nodes_root_for_test(). +[[nodiscard]] std::optional topology_nodes_root_for_test() { + std::lock_guard lock(g_topology_nodes_root_mutex); + return g_topology_nodes_root_for_test; +} + } // namespace void set_topology_nodes_root_for_test(const char *root) { @@ -90,11 +100,6 @@ void set_topology_nodes_root_for_test(const char *root) { root != nullptr && *root != '\0' ? std::optional(root) : std::nullopt; } -std::optional topology_nodes_root_for_test() { - std::lock_guard lock(g_topology_nodes_root_mutex); - return g_topology_nodes_root_for_test; -} - } // namespace rocjitsu::hooks namespace { @@ -1366,6 +1371,17 @@ RjHsaLayer &layer() { return state; } +/// @brief Return true when the installed config selects DBT guest mode. +/// +/// @details Derived from the layer on every call rather than cached alongside +/// discovery results: a cached copy goes stale whenever discovery is abandoned +/// (an `OnUnload()` racing an in-flight search never republishes it), and a +/// stale `false` would make the enumeration guard fail open. +[[nodiscard]] bool dbt_guest_configured() { + std::optional config = layer().config(); + return config && config->guest_target.has_value(); +} + /// @brief Parse a uint32_t from a NUL-terminated sysfs text value. [[nodiscard]] bool parse_u32_text(const char *text, uint32_t *out) { if (!text || !out) @@ -1401,14 +1417,32 @@ RjHsaLayer &layer() { return value; } +/// @brief Why a KFD topology lookup did or did not yield a node id. +/// +/// @details Absence and unreadability must stay distinguishable: a configured +/// gpu_id missing from a topology read end to end is a permanent configuration +/// verdict, while a topology that could not be read says nothing about the +/// gpu_id and may succeed on a later attempt. +enum class TopologyLookup : uint8_t { + kFound, ///< A node advertising the requested gpu_id was read. + kAbsent, ///< Every node in a readable topology was checked; none matched. + kUnreadable, ///< The topology could not be read, so absence is not proven. +}; + +/// @brief Result of translating a KFD gpu_id to a topology node id. +struct TopologyNodeResult { + TopologyLookup status = TopologyLookup::kUnreadable; + uint32_t node_id = 0; +}; + /// @brief Search one KFD topology root for the node id owning @p gpu_id. -[[nodiscard]] std::optional node_id_for_kfd_gpu_id_in_root(const char *root, - uint32_t gpu_id) { +[[nodiscard]] TopologyNodeResult node_id_for_kfd_gpu_id_in_root(const char *root, uint32_t gpu_id) { DIR *dir = opendir(root); if (!dir) - return std::nullopt; + return TopologyNodeResult{TopologyLookup::kUnreadable, 0}; - std::optional result; + bool unreadable_node = false; + TopologyNodeResult result{TopologyLookup::kAbsent, 0}; while (dirent *entry = readdir(dir)) { if (entry->d_name[0] == '.') continue; @@ -1419,12 +1453,21 @@ RjHsaLayer &layer() { std::string gpu_id_path = std::string(root) + "/" + entry->d_name + "/gpu_id"; std::optional node_gpu_id = read_u32_file(gpu_id_path); - if (node_gpu_id && *node_gpu_id == gpu_id) { - result = node_id; + if (!node_gpu_id) { + // Every KFD node exposes gpu_id, so a failed read is I/O failure rather + // than a node that does not advertise one -- and the skipped node may be + // the one being searched for, so this scan can no longer prove absence. + unreadable_node = true; + continue; + } + if (*node_gpu_id == gpu_id) { + result = TopologyNodeResult{TopologyLookup::kFound, node_id}; break; } } closedir(dir); + if (result.status == TopologyLookup::kAbsent && unreadable_node) + result.status = TopologyLookup::kUnreadable; return result; } @@ -1434,17 +1477,24 @@ RjHsaLayer &layer() { /// across ROCR agent handle creation and is the id KFD ioctls use. HSA does /// not expose that value directly, so the hook reads the redirected topology /// tree and later compares agents by `HSA_AMD_AGENT_INFO_DRIVER_NODE_ID`. -[[nodiscard]] std::optional node_id_for_kfd_gpu_id(uint32_t gpu_id) { +[[nodiscard]] TopologyNodeResult node_id_for_kfd_gpu_id(uint32_t gpu_id) { if (std::optional root = rocjitsu::hooks::topology_nodes_root_for_test()) return node_id_for_kfd_gpu_id_in_root(root->c_str(), gpu_id); constexpr std::array kTopologyNodeRoots = { "/sys/devices/virtual/kfd/kfd/topology/nodes", "/sys/class/kfd/kfd/topology/nodes"}; + // The two roots are the same tree on a healthy system and one of them is + // normally absent, so absence is only proven when at least one root was + // scanned end to end. + TopologyNodeResult verdict{TopologyLookup::kUnreadable, 0}; for (const char *root : kTopologyNodeRoots) { - if (std::optional node_id = node_id_for_kfd_gpu_id_in_root(root, gpu_id)) - return node_id; + TopologyNodeResult result = node_id_for_kfd_gpu_id_in_root(root, gpu_id); + if (result.status == TopologyLookup::kFound) + return result; + if (result.status == TopologyLookup::kAbsent) + verdict.status = TopologyLookup::kAbsent; } - return std::nullopt; + return verdict; } /// @brief Maps the configured guest HSA agent to the selected host execution agent. @@ -1452,8 +1502,21 @@ RjHsaLayer &layer() { /// @details Discovery is lazy because HSA tools are installed before ROCR has /// necessarily enumerated agents. The guest agent remains visible to callers, /// but calls that would execute or allocate through it are redirected to host_. +/// +/// Discovery classifies its own failure. A transient miss (sysfs I/O, ROCR not +/// yet enumerating) is retried a bounded number of times and only from callers +/// that opt in with RetryPolicy::kRetryTransient -- in practice just the +/// enumeration entry point, whose failure is fatal to the process. A +/// deterministic miss latches immediately. Hot-path accessors never re-enter +/// discovery: they forward handles unchanged when no mapping exists. class AgentMapper { public: + /// @brief Whether a caller may pay for a fresh discovery attempt. + enum class RetryPolicy : uint8_t { + kCachedOnly, ///< Never re-run discovery once an attempt was published. + kRetryTransient, ///< Re-run discovery while the last attempt was retryable. + }; + /// @brief Return the process-wide agent mapper. static AgentMapper &instance() { static AgentMapper mapper; @@ -1490,16 +1553,58 @@ class AgentMapper { return has_mapping_ ? guest_ : hsa_agent_t{}; } - /// @brief Return true when the config demands a mapping that discovery could - /// not establish. + /// @brief Guest and host handles plus the failure verdict, from one locked read. + struct MappingSnapshot { + hsa_agent_t guest{}; + hsa_agent_t host{}; + /// @brief True when DBT is configured but discovery could not resolve both agents. + bool failed = false; + }; + + /// @brief Return the guest and host handles plus the failure verdict, with the + /// handles and verdict read under one lock. + /// + /// @details Callers needing more than one field must use this rather than + /// composing `guest_agent()`/`host_for_guest()`: a `clear()` landing between + /// two separate accessors yields a torn view that pairs a stale handle with a + /// fresh verdict, which is exactly how the enumeration guard would fail open. + /// + /// The DBT-configured flag is necessarily sampled outside `mutex_` (layer() has its + /// own lock and nesting it here would invert the order ensure_discovered() uses), so + /// `failed` can pair a pre-unload config read with a post-unload mapping. That + /// direction is deliberate: every caller refuses on `failed`, so the race can only + /// over-refuse, and only while the wrappers are already being torn down. + /// + /// `failed` is not sticky across a retryable failure when @p policy is + /// RetryPolicy::kRetryTransient. An unreadable KFD topology, or a ROCR agent + /// iteration that errored or has not published any agent yet, is re-attempted + /// on the next such call, up to kMaxTransientAttempts. It does latch once + /// discovery reaches a verdict: the configured gpu_id was absent from a + /// topology read end to end, the agent list did not hold the configured pair, + /// or the retry budget ran out. Callers that only need a consistent read of + /// an already-published attempt keep the default and stay O(1). + [[nodiscard]] MappingSnapshot snapshot(RetryPolicy policy = RetryPolicy::kCachedOnly) { + ensure_discovered(policy); + // Read the config outside mutex_: layer() has its own lock, and taking it + // underneath this one would invert the order used by ensure_discovered(). + const bool configured = dbt_guest_configured(); + std::lock_guard lock(mutex_); + if (!has_mapping_) + return MappingSnapshot{{}, {}, configured}; + return MappingSnapshot{guest_, host_, false}; + } + + /// @brief Return true only for the first refusal report since the last verdict. /// - /// @details Distinguishes "DBT is configured but its host is unreachable" from - /// "there is nothing to map", so callers can fail closed only in the former. - /// Discovery caches its outcome, so this stays true once it has failed. - [[nodiscard]] bool mapping_failed() { - ensure_discovered(); + /// @details Enumeration is retried by HIP/ROCR on every device query, so the + /// refusal diagnostic must be emitted once per discovery attempt rather than + /// once per call, or it buries the message naming the unresolved gpu_id. A + /// retryable attempt re-arms the flag, so the worst case is one line per + /// attempt (kMaxTransientAttempts + 1) instead of one per process: a transient + /// refusal must not consume the report that names the permanent failure. + [[nodiscard]] bool claim_failure_report() { std::lock_guard lock(mutex_); - return mapping_required_ && !has_mapping_; + return !std::exchange(reported_failure_, true); } /// @brief Replace the selected host agent with the visible guest agent. @@ -1511,18 +1616,36 @@ class AgentMapper { return agent; } - /// @brief Drop cached agent mapping so unload/reload can rediscover it. + /// @brief Drop the cached agent mapping, verdict, and retry budget so + /// unload/reload can rediscover from scratch. void clear() { std::lock_guard lock(mutex_); discovered_ = false; has_mapping_ = false; - mapping_required_ = false; + reported_failure_ = false; + outcome_ = DiscoveryOutcome::kRetryable; + transient_attempts_ = 0; guest_ = {}; host_ = {}; ++generation_; } private: + /// @brief How a completed discovery attempt should be cached. + enum class DiscoveryOutcome : uint8_t { + kResolved, ///< Both agents found; nothing left to retry. + kRetryable, ///< I/O or not-yet-ready failure; a later attempt may succeed. + kFinal, ///< Deterministic negative; retrying cannot change it. + }; + + /// @brief Cap on repeated discovery attempts after a transient failure. + /// + /// @details Bounds the worst case for an application that calls + /// hsa_iterate_agents in a loop: at most this many sysfs topology scans plus + /// ROCR agent iterations for the whole process, after which the mapper latches + /// and every entry point is O(1) again. + static constexpr uint32_t kMaxTransientAttempts = 8; + /// @brief Agent discovery callback state. struct AgentSearchData { AgentMapper *self = nullptr; @@ -1531,6 +1654,14 @@ class AgentMapper { std::optional host_node_id; hsa_agent_t guest_agent{}; hsa_agent_t host_agent{}; + /// @brief Agents ROCR offered this attempt; zero means it published none yet. + uint32_t visited = 0; + /// @brief Set when one agent satisfied both the host and the guest predicate. + /// + /// @details Only meaningful when the guest slot stays empty: a later, + /// distinct agent may still fill it, which is the intended outcome for a + /// same-ISA config that does have a separate synthetic guest node. + bool role_collision = false; }; /// @brief ISA discovery callback state for matching configured targets. @@ -1605,34 +1736,77 @@ class AgentMapper { } /// @brief Return true when @p agent is the configured host execution agent. + /// + /// @details The driver node id is what makes host selection unique on a multi-GPU + /// box, so ensure_discovered() never starts a search without one; matching on the + /// host ISA alone would pick whichever GPU ROCR happens to enumerate first. bool agent_matches_host(hsa_agent_t agent, const AgentSearchData &search) { - if (!agent_has_target(agent, search.host)) - return false; - if (search.host_node_id) - return agent_has_driver_node(agent, *search.host_node_id); - return true; + assert(search.host_node_id.has_value()); + return agent_has_target(agent, search.host) && + agent_has_driver_node(agent, *search.host_node_id); } /// @brief HSA agent iteration callback that records guest and host agents. + /// + /// @details The host is tested first and wins any agent that satisfies both + /// roles. Only the host predicate carries the node-id constraint, so when the + /// two predicates overlap -- `guest_isa == host_isa`, which the config layer + /// deliberately still accepts for the gfx1250 B0-on-A0 revision profile -- a + /// guest-first test would let the guest greedily claim the physical host and + /// leave the synthetic guest unmapped, and which side that happened on would + /// depend on ROCR's enumeration order. static hsa_status_t agent_callback(hsa_agent_t agent, void *data) { auto *search = static_cast(data); - if (search->guest_agent.handle == 0 && search->self->agent_has_target(agent, search->guest)) - search->guest_agent = agent; - if (search->host_agent.handle == 0 && search->self->agent_matches_host(agent, *search)) + ++search->visited; + const bool claimed_host = + search->host_agent.handle == 0 && search->self->agent_matches_host(agent, *search); + if (claimed_host) search->host_agent = agent; + if (search->guest_agent.handle == 0 && search->self->agent_has_target(agent, search->guest)) { + // Leave the guest slot open for a distinct agent rather than aliasing the + // host into it: a mapping whose two sides are the same agent translates + // nothing. + if (claimed_host) + search->role_collision = true; + else + search->guest_agent = agent; + } if (search->guest_agent.handle != 0 && search->host_agent.handle != 0) return HSA_STATUS_INFO_BREAK; return HSA_STATUS_SUCCESS; } + /// @brief Return true when a published attempt may be repeated for @p policy. + /// + /// @details Hot-path callers never re-enter discovery: rj_amd_memory_async_copy + /// and rj_amd_profiling_get_dispatch_time reach map() once per copy and once + /// per dispatch, and re-running a sysfs topology walk plus a full ROCR agent + /// iteration there would trade a correctness bug for a throughput bug. They + /// also do not need it -- they forward handles unchanged when no mapping + /// exists and pick up a later success for free. + [[nodiscard]] bool may_retry_locked(RetryPolicy policy) const { + return policy == RetryPolicy::kRetryTransient && outcome_ == DiscoveryOutcome::kRetryable && + transient_attempts_ < kMaxTransientAttempts; + } + + /// @brief Return true when the already-published attempt answers @p policy. + [[nodiscard]] bool attempt_settled_locked(RetryPolicy policy) const { + return discovered_ && !may_retry_locked(policy); + } + /// @brief Lazily discover the guest-to-host agent mapping. - void ensure_discovered() { + void ensure_discovered(RetryPolicy policy = RetryPolicy::kCachedOnly) { uint64_t generation = 0; { std::unique_lock lock(mutex_); - while (discovering_) + // Test the published attempt before waiting: a caller that would accept it must + // not park behind someone else's retry. Discovery is a sysfs topology walk plus a + // full ROCR agent iteration, and map()/is_guest() reach here once per copy and + // once per dispatch. The first discovery still waits, because discovered_ is + // false until an attempt has been published. + while (!attempt_settled_locked(policy) && discovering_) discovery_cv_.wait(lock); - if (discovered_) + if (attempt_settled_locked(policy)) return; discovering_ = true; generation = generation_; @@ -1643,19 +1817,41 @@ class AgentMapper { hsa_agent_t discovered_host{}; bool has_mapping = false; bool attempted_agent_search = false; - const bool mapping_required = config && config->guest_target.has_value(); + bool role_collision = false; + // No guest target means there is nothing to rediscover, and the enumeration + // guard never fires because dbt_guest_configured() is false. + DiscoveryOutcome outcome = DiscoveryOutcome::kFinal; if (config && config->guest_target) { auto *iterate_agents = layer().iterate_agents(); - if (iterate_agents) { + if (iterate_agents == nullptr) { + outcome = DiscoveryOutcome::kRetryable; + } else { std::optional host_node_id; bool can_search = true; - if (config->host_gpu_id != 0) { - host_node_id = node_id_for_kfd_gpu_id(config->host_gpu_id); - if (!host_node_id) { + if (config->host_gpu_id == 0) { + // load_dbt_guest_config_from_handoff() refuses an enabled DBT config with no + // resolved gpu_id, so reaching this means the handoff contract broke. Searching + // with an unconstrained host predicate is the first-ISA-match selection that + // picks the wrong GPU on a multi-GPU host; refuse instead of reintroducing it. + std::fprintf(stderr, "[rocjitsu-hooks] DBT config carries no resolved host KFD " + "gpu_id; refusing to guess a host agent\n"); + outcome = DiscoveryOutcome::kFinal; + can_search = false; + } else { + const TopologyNodeResult node = node_id_for_kfd_gpu_id(config->host_gpu_id); + if (node.status == TopologyLookup::kFound) { + host_node_id = node.node_id; + } else { + const bool absent = node.status == TopologyLookup::kAbsent; std::fprintf(stderr, - "[rocjitsu-hooks] failed to find topology node for host KFD gpu_id=%u\n", - config->host_gpu_id); + "[rocjitsu-hooks] failed to find topology node for host KFD gpu_id=%u " + "(%s)\n", + config->host_gpu_id, + absent ? "absent from a fully read topology" : "topology unreadable"); + // An unreadable topology is an I/O failure, not a verdict about the + // configured gpu_id. + outcome = absent ? DiscoveryOutcome::kFinal : DiscoveryOutcome::kRetryable; can_search = false; } } @@ -1664,11 +1860,22 @@ class AgentMapper { AgentSearchData search{this, *config->guest_target, config->target, host_node_id}; hsa_status_t status = iterate_agents(agent_callback, &search); attempted_agent_search = true; - has_mapping = (status == HSA_STATUS_SUCCESS || status == HSA_STATUS_INFO_BREAK) && - search.guest_agent.handle != 0 && search.host_agent.handle != 0 && + const bool iterated = status == HSA_STATUS_SUCCESS || status == HSA_STATUS_INFO_BREAK; + has_mapping = iterated && search.guest_agent.handle != 0 && + search.host_agent.handle != 0 && search.guest_agent.handle != search.host_agent.handle; discovered_guest = search.guest_agent; discovered_host = search.host_agent; + role_collision = search.role_collision && search.guest_agent.handle == 0; + if (has_mapping) + outcome = DiscoveryOutcome::kResolved; + else if (!iterated || search.visited == 0) + // ROCR refused the iteration or has published no agent yet. + outcome = DiscoveryOutcome::kRetryable; + else + // A complete agent list that does not hold the configured pair is a + // configuration verdict; a retry reads the same list. + outcome = DiscoveryOutcome::kFinal; } } } @@ -1680,8 +1887,14 @@ class AgentMapper { guest_ = discovered_guest; host_ = discovered_host; has_mapping_ = has_mapping; - mapping_required_ = mapping_required; discovered_ = true; + outcome_ = outcome; + if (outcome == DiscoveryOutcome::kRetryable) { + ++transient_attempts_; + // Re-arm the one-shot report: a refusal that will be retried must not + // consume the message that names the eventual permanent failure. + reported_failure_ = false; + } published = true; } discovering_ = false; @@ -1703,6 +1916,20 @@ class AgentMapper { static_cast(discovered_guest.handle), config->host_gpu_id, selected_node_id.value_or(0)); } + } else if (role_collision) { + // Naming the collision keeps "the one matching agent is already the host" + // from being reported as the generic "no agents matched", which sends the + // reader looking for a missing device instead of a missing guest node. + // The verdict is still failure: a mapping whose guest and host are the + // same agent would load guest code objects untranslated on real silicon, + // and this layer has nothing to offer a run that needs no translation. + const std::string_view guest_name = config->guest_target->name; + std::fprintf(stderr, + "[rocjitsu-hooks] HSA agent=%llu satisfies both the configured host and the " + "guest ISA '%.*s'; no distinct guest agent remains. A same-ISA DBT config " + "needs a separate synthetic guest agent -- refusing to run untranslated\n", + static_cast(discovered_host.handle), + static_cast(guest_name.size()), guest_name.data()); } else if (attempted_agent_search) { std::fprintf(stderr, "[rocjitsu-hooks] failed to find guest/host HSA agents for DBT\n"); } @@ -1713,7 +1940,9 @@ class AgentMapper { bool discovering_ = false; bool discovered_ = false; bool has_mapping_ = false; - bool mapping_required_ = false; + bool reported_failure_ = false; + DiscoveryOutcome outcome_ = DiscoveryOutcome::kRetryable; + uint32_t transient_attempts_ = 0; uint64_t generation_ = 0; hsa_agent_t guest_{}; hsa_agent_t host_{}; @@ -1807,8 +2036,16 @@ class MemoryPoolMapper { auto *iterate_pools = layer().amd_agent_iterate_memory_pools(); auto *get_info = layer().amd_memory_pool_get_info(); - hsa_agent_t guest = AgentMapper::instance().guest_agent(); - hsa_agent_t host = AgentMapper::instance().host_for_guest(); + // One locked read rather than composing guest_agent()/host_for_guest(), per + // the AgentMapper::snapshot() contract. It deliberately keeps the default + // RetryPolicy::kCachedOnly: pool discovery retries on every mapped-pool call + // and is cheap (two ROCR pool iterations), while agent discovery is a sysfs + // tree walk plus a full agent iteration plus a per-agent ISA iteration. + // Letting this path rediscover agents would multiply that cost by every + // hsa_amd_memory_pool_allocate made before pool convergence. + const AgentMapper::MappingSnapshot agents = AgentMapper::instance().snapshot(); + const hsa_agent_t guest = agents.guest; + const hsa_agent_t host = agents.host; if (guest.handle == 0 || host.handle == 0) return; @@ -1867,6 +2104,49 @@ void clear_agent_mapper() { AgentMapper::instance().clear(); } return AgentMapper::instance().map(agent); } +/// @brief Return true unless ROCR positively reports @p agent as a non-GPU device. +/// +/// @details Only used on the enumeration refusal path, where discovery failed +/// and the hook cannot say which physical GPU the guest was supposed to execute +/// on -- with no topology node it holds no agent handle at all, so device type +/// is the only discriminator left. Anything that cannot be positively +/// classified is reported as a GPU so an unclassifiable device is suppressed +/// rather than published. `install()` refuses a table with a null +/// hsa_agent_get_info_fn, so only the query-failed branch is reachable here; the +/// null check is a local guard against that precondition being relaxed. +[[nodiscard]] bool agent_is_gpu(hsa_agent_t agent) { + auto *get_info = layer().agent_get_info(); + if (get_info == nullptr) + return true; + hsa_device_type_t device_type{}; + if (get_info(agent, HSA_AGENT_INFO_DEVICE, &device_type) != HSA_STATUS_SUCCESS) + return true; + return device_type == HSA_DEVICE_TYPE_GPU; +} + +/// @brief Return true when DBT is configured but its guest/host mapping is unusable. +/// +/// @details Guards the entry points that can put code on, or dispatch to, real +/// silicon. Enumeration already suppresses every GPU agent in this state, so a +/// GPU handle only reaches them if it was cached before discovery failed or came +/// from a tool holding a pre-patch table pointer; without this the load path in +/// particular fails open, because `is_guest()` is false whenever the mapping +/// failed and the "not the guest, forward verbatim" branch would then load an +/// untranslated guest code object on the raw host. +/// +/// Deliberately NOT applied to the query, copy, and pool hooks (region/pool/ISA +/// iteration, pointer info, memory lock, async copy, profiling): those routinely +/// take CPU agents, none of them can execute a kernel, and refusing them would +/// break the host fine-grained pool discovery and host-side copies that the +/// enumeration guard goes out of its way to keep working. With no queue and no +/// loaded code object there is no dispatch, so the pair still fails closed. +/// +/// The hook only installs when `parse_config()` succeeds, and that always sets +/// `guest_target`, so a false here means `OnUnload()` has already run rather +/// than that this is a native non-DBT process; there is no non-DBT pass-through +/// case at this layer for it to mis-refuse. +[[nodiscard]] bool mapping_unusable() { return AgentMapper::instance().snapshot().failed; } + /// @brief Map one guest memory pool to the matching host memory pool. [[nodiscard]] hsa_amd_memory_pool_t mapped_memory_pool(hsa_amd_memory_pool_t pool) { return MemoryPoolMapper::instance().map(pool); @@ -3076,8 +3356,14 @@ hsa_status_t HSA_API rj_iterate_agents(hsa_status_t (*callback)(hsa_agent_t agen if (!original) return HSA_STATUS_ERROR; - hsa_agent_t guest = AgentMapper::instance().guest_agent(); - hsa_agent_t host = AgentMapper::instance().host_for_guest(); + // One locked read: reading the handles and the verdict separately lets a + // concurrent clear() pair a stale handle with a fresh verdict. This is also + // the only entry point that opts into rediscovery: it is the only one whose + // failure is unrecoverable, and it is cold enough to pay for a retry. + const AgentMapper::MappingSnapshot mapping = + AgentMapper::instance().snapshot(AgentMapper::RetryPolicy::kRetryTransient); + const hsa_agent_t guest = mapping.guest; + const hsa_agent_t host = mapping.host; if (guest.handle == 0 || host.handle == 0) { // Fail closed when DBT is configured but its host never resolved -- the // topology node for the configured gpu_id was not found, or ROCR never @@ -3086,10 +3372,30 @@ hsa_status_t HSA_API rj_iterate_agents(hsa_status_t (*callback)(hsa_agent_t agen // host as an application-visible device and let anything using the default // device run untranslated on it, which is exactly what the guest exists to // prevent. GuestKfd rejects the same unresolved host with ENODEV. - if (AgentMapper::instance().mapping_failed()) { - std::fprintf(stderr, "[rocjitsu-hooks] refusing to enumerate agents: DBT is enabled but no " - "guest/host agent mapping was resolved\n"); - return HSA_STATUS_ERROR; + // + // Fail closed for GPUs only, though. CPU agents carry no untranslated + // execution risk and are still needed for host fine-grained pools, + // hsa_amd_memory_lock, and host-side copies, so they pass through. A GPU + // client then sees a well-formed empty device list rather than an opaque + // HSA_STATUS_ERROR out of hsa_iterate_agents, which HIP/CLR reports as a + // fatal ROCR init failure instead of "no device". + if (mapping.failed) { + if (AgentMapper::instance().claim_failure_report()) + std::fprintf(stderr, "[rocjitsu-hooks] suppressing all GPU agents: DBT is enabled but no " + "guest/host agent mapping was resolved\n"); + + struct GpuSuppression { + hsa_status_t (*callback)(hsa_agent_t, void *) = nullptr; + void *data = nullptr; + } suppression{callback, data}; + + auto suppress_callback = [](hsa_agent_t agent, void *opaque) -> hsa_status_t { + auto *suppressed = static_cast(opaque); + if (agent_is_gpu(agent)) + return HSA_STATUS_SUCCESS; + return suppressed->callback(agent, suppressed->data); + }; + return original(suppress_callback, &suppression); } return original(callback, data); } @@ -3146,6 +3452,9 @@ hsa_status_t HSA_API rj_queue_create(hsa_agent_t agent, uint32_t size, hsa_queue auto *original = layer().queue_create(); if (!original) return HSA_STATUS_ERROR; + // A queue on a raw agent is the dispatch path the enumeration guard closes. + if (mapping_unusable()) + return HSA_STATUS_ERROR_INVALID_AGENT; hsa_agent_t mapped = AgentMapper::instance().map(agent); log_message(kLogVerbose, "queue_create agent=%llu mapped=%llu size=%u", static_cast(agent.handle), @@ -3184,6 +3493,9 @@ hsa_status_t HSA_API rj_amd_queue_intercept_create( auto *original = layer().amd_queue_intercept_create(); if (!original) return HSA_STATUS_ERROR; + // Same dispatch path as rj_queue_create, reached directly by tools. + if (mapping_unusable()) + return HSA_STATUS_ERROR_INVALID_AGENT; hsa_agent_t mapped = AgentMapper::instance().map(agent_handle); log_message(kLogVerbose, "queue_intercept_create agent=%llu mapped=%llu size=%u", static_cast(agent_handle.handle), @@ -3782,6 +4094,9 @@ hsa_status_t HSA_API rj_amd_agent_set_async_scratch_limit(hsa_agent_t agent, siz auto *original = layer().amd_agent_set_async_scratch_limit(); if (!original) return HSA_STATUS_ERROR; + // GPU-only scratch tuning; with no mapping this would reconfigure the raw host. + if (mapping_unusable()) + return HSA_STATUS_ERROR_INVALID_AGENT; hsa_agent_t mapped = mapped_agent(agent); log_message(kLogVerbose, "amd_agent_set_async_scratch_limit agent=%llu mapped=%llu threshold=%zu", static_cast(agent.handle), @@ -3823,6 +4138,9 @@ hsa_status_t HSA_API rj_amd_agent_preload(hsa_agent_t agent, uint64_t flags) { auto *original = layer().amd_agent_preload(); if (!original) return HSA_STATUS_ERROR; + // Stages code onto a GPU ahead of dispatch; GPU-only, so refuse with the rest. + if (mapping_unusable()) + return HSA_STATUS_ERROR_INVALID_AGENT; hsa_agent_t mapped = mapped_agent(agent); log_message(kLogVerbose, "amd_agent_preload agent=%llu mapped=%llu flags=0x%llx", static_cast(agent.handle), @@ -3873,6 +4191,15 @@ hsa_status_t HSA_API rj_executable_load_agent_code_object( return HSA_STATUS_ERROR; } + // is_guest() below is false whenever the mapping failed, which would send this + // load down the verbatim "not the guest" path and put an untranslated guest + // code object on real silicon. Refuse before reaching that branch. + if (mapping_unusable()) { + std::fprintf(stderr, "[rocjitsu-hooks] refusing code-object load: DBT is enabled but no " + "guest/host agent mapping was resolved\n"); + return HSA_STATUS_ERROR_INVALID_AGENT; + } + const bool guest_load = AgentMapper::instance().is_guest(agent); log_message(kLogVerbose, "load_agent_code_object exec=%llu agent=%llu guest=%d reader=%llu", static_cast(executable.handle), @@ -4099,6 +4426,11 @@ extern "C" RJ_HOOK_EXPORT bool OnLoad(HsaApiTable *table, uint64_t runtime_versi if (!config) return false; const bool signal_backtrace = config->signal_backtrace; + // The mapper is a process-wide singleton, so it outlives the layer. uninstall() + // clears it, but a discovery already in flight then republishes a kFinal miss + // behind that clear -- and nothing clears it again. Repeat the clear here so such + // a verdict cannot latch a permanent all-GPU suppression into this install. + clear_agent_mapper(); if (!layer().install(table, std::move(*config))) return false; // Virtual-LDS resource management lives in its own hook component. Give it diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.h b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.h index d480aab2ef43..9f47367d65f5 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.h +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/rj_hsa_dbt_test_seams.h @@ -5,8 +5,8 @@ /// @brief Internal test seams of the DBT HSA hook. /// /// @details The hook reads KFD topology from a fixed pair of sysfs roots, which -/// unit tests cannot populate. These declarations let tests redirect that -/// lookup at a synthetic tree. They are defined in rj_hsa_dbt_hooks.cpp with +/// unit tests cannot populate. This declaration lets tests redirect that +/// lookup at a synthetic tree. It is defined in rj_hsa_dbt_hooks.cpp with /// hidden visibility, so the production `librocjitsu_hooks.so` exports only its /// HSA tool entry points (`OnLoad` and `OnUnload`). The C entry point that /// drives the seam lives in rj_hsa_dbt_test_seams.cpp, which is linked into the @@ -14,9 +14,6 @@ #pragma once -#include -#include - namespace rocjitsu::hooks { /// @brief Set or clear the synthetic KFD topology root used by hook unit tests. @@ -25,10 +22,4 @@ namespace rocjitsu::hooks { /// restore the real sysfs lookup. void set_topology_nodes_root_for_test(const char *root); -/// @brief Return a snapshot of the synthetic KFD topology root, if one is set. -/// -/// @details Always empty in production: nothing outside the test-only library -/// can reach set_topology_nodes_root_for_test(). -[[nodiscard]] std::optional topology_nodes_root_for_test(); - } // namespace rocjitsu::hooks diff --git a/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp b/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp index ba71e739513d..e6fea252d4f2 100644 --- a/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp +++ b/emulation/rocjitsu/tests/dbt/hsa_hooks_unit_test.cpp @@ -47,13 +47,19 @@ constexpr hsa_agent_t kHostAgent{2}; // are tracked for doorbell forwarding but never rewritten, and host_lds_bytes // (derived from the guest target arch) does not apply to them. constexpr hsa_agent_t kUnrelatedAgent{3}; +// A CPU agent. Enumeration must keep publishing it even when the guest/host +// mapping is unresolved: host fine-grained pools, hsa_amd_memory_lock, and +// host-side copies all need it, and it cannot execute a guest kernel. +constexpr hsa_agent_t kCpuAgent{4}; constexpr hsa_isa_t kGuestIsa{950}; constexpr hsa_isa_t kHostIsa{1201}; constexpr hsa_amd_memory_pool_t kGuestPool{10}; constexpr hsa_amd_memory_pool_t kHostPool{20}; constexpr hsa_amd_memory_pool_t kHostKernargPool{21}; +constexpr hsa_amd_memory_pool_t kCpuFineGrainedPool{30}; constexpr uint32_t kGuestNodeId = 100; constexpr uint32_t kHostNodeId = 200; +constexpr uint32_t kCpuNodeId = 0; constexpr uint32_t kVirtualLdsWrapperStateOffsetForTest = 8; constexpr uint32_t kVirtualLdsWrapperSizeForTest = 32; constexpr uint16_t kVirtualLdsWrapperFlagsForTest = @@ -70,6 +76,11 @@ std::condition_variable g_agent_cv; bool g_block_agent_iteration = false; bool g_agent_iteration_entered = false; bool g_release_agent_iteration = false; +// Plain globals rather than g_agent_mutex-guarded state: only the +// single-threaded discovery-retry tests touch them, and the blocking multi- +// threaded test must not contend on them. +bool g_fail_agent_iteration = false; +int g_fake_iterate_agents_calls = 0; int g_fake_shutdown_calls = 0; hsa_amd_memory_pool_t g_last_allocate_pool{}; hsa_agent_t g_last_agent_memory_pool_agent{}; @@ -134,6 +145,10 @@ hsa_status_t HSA_API fake_iterate_agents(hsa_status_t (*callback)(hsa_agent_t, v if (callback == nullptr) return HSA_STATUS_ERROR_INVALID_ARGUMENT; + ++g_fake_iterate_agents_calls; + if (g_fail_agent_iteration) + return HSA_STATUS_ERROR; + { std::unique_lock lock(g_agent_mutex); if (g_block_agent_iteration) { @@ -154,12 +169,40 @@ hsa_status_t HSA_API fake_iterate_agents_host_first(hsa_status_t (*callback)(hsa if (callback == nullptr) return HSA_STATUS_ERROR_INVALID_ARGUMENT; + ++g_fake_iterate_agents_calls; hsa_status_t status = callback(kHostAgent, data); if (status != HSA_STATUS_SUCCESS) return status; return callback(kGuestAgent, data); } +hsa_status_t HSA_API fake_iterate_agents_with_cpu(hsa_status_t (*callback)(hsa_agent_t, void *), + void *data) { + if (callback == nullptr) + return HSA_STATUS_ERROR_INVALID_ARGUMENT; + + ++g_fake_iterate_agents_calls; + hsa_status_t status = callback(kCpuAgent, data); + if (status != HSA_STATUS_SUCCESS) + return status; + status = callback(kGuestAgent, data); + if (status != HSA_STATUS_SUCCESS) + return status; + return callback(kHostAgent, data); +} + +// Only the physical host is present -- the synthetic guest node never appeared. +// Paired with fake_agent_iterate_isas_overlapping this is the guest/host role +// collision: one agent satisfies both predicates and no distinct guest remains. +hsa_status_t HSA_API fake_iterate_agents_host_only(hsa_status_t (*callback)(hsa_agent_t, void *), + void *data) { + if (callback == nullptr) + return HSA_STATUS_ERROR_INVALID_ARGUMENT; + + ++g_fake_iterate_agents_calls; + return callback(kHostAgent, data); +} + hsa_status_t HSA_API fake_shut_down() { ++g_fake_shutdown_calls; return HSA_STATUS_SUCCESS; @@ -171,7 +214,8 @@ hsa_status_t HSA_API fake_agent_get_info(hsa_agent_t agent, hsa_agent_info_t att return HSA_STATUS_ERROR_INVALID_ARGUMENT; if (attribute == HSA_AGENT_INFO_DEVICE) { - *static_cast(value) = HSA_DEVICE_TYPE_GPU; + *static_cast(value) = + agent.handle == kCpuAgent.handle ? HSA_DEVICE_TYPE_CPU : HSA_DEVICE_TYPE_GPU; return HSA_STATUS_SUCCESS; } if (attribute == HSA_AGENT_INFO_ISA) { @@ -179,8 +223,11 @@ hsa_status_t HSA_API fake_agent_get_info(hsa_agent_t agent, hsa_agent_info_t att return HSA_STATUS_SUCCESS; } if (attribute == static_cast(HSA_AMD_AGENT_INFO_DRIVER_NODE_ID)) { - *static_cast(value) = - agent.handle == kGuestAgent.handle ? kGuestNodeId : kHostNodeId; + if (agent.handle == kCpuAgent.handle) + *static_cast(value) = kCpuNodeId; + else + *static_cast(value) = + agent.handle == kGuestAgent.handle ? kGuestNodeId : kHostNodeId; return HSA_STATUS_SUCCESS; } return HSA_STATUS_ERROR_INVALID_ARGUMENT; @@ -199,6 +246,23 @@ hsa_status_t HSA_API fake_agent_iterate_isas(hsa_agent_t agent, return HSA_STATUS_ERROR_INVALID_AGENT; } +// Every GPU advertises both the guest and the host ISA. This is the shape of a +// same-ISA DBT config (guest_isa == host_isa, the gfx1250 B0-on-A0 revision +// profile): both predicates match every agent, so only the host's node-id +// constraint can tell the two roles apart. +hsa_status_t HSA_API fake_agent_iterate_isas_overlapping( + hsa_agent_t agent, hsa_status_t (*callback)(hsa_isa_t, void *), void *data) { + if (callback == nullptr) + return HSA_STATUS_ERROR_INVALID_ARGUMENT; + if (agent.handle != kGuestAgent.handle && agent.handle != kHostAgent.handle) + return HSA_STATUS_ERROR_INVALID_AGENT; + + hsa_status_t status = callback(kGuestIsa, data); + if (status != HSA_STATUS_SUCCESS) + return status; + return callback(kHostIsa, data); +} + hsa_status_t HSA_API fake_isa_get_info_alt(hsa_isa_t isa, hsa_isa_info_t attribute, void *value) { if (value == nullptr) return HSA_STATUS_ERROR_INVALID_ARGUMENT; @@ -533,6 +597,8 @@ hsa_status_t HSA_API fake_amd_agent_iterate_memory_pools( return status; return callback(kHostKernargPool, data); } + if (agent.handle == kCpuAgent.handle) + return callback(kCpuFineGrainedPool, data); return HSA_STATUS_ERROR_INVALID_AGENT; } @@ -580,6 +646,19 @@ hsa_status_t HSA_API fake_amd_agent_memory_pool_get_info(hsa_agent_t agent, return HSA_STATUS_ERROR_INVALID_ARGUMENT; } +hsa_agent_t g_last_agent_preload_agent{}; +hsa_agent_t g_last_async_scratch_limit_agent{}; + +hsa_status_t HSA_API fake_amd_agent_preload(hsa_agent_t agent, uint64_t) { + g_last_agent_preload_agent = agent; + return HSA_STATUS_SUCCESS; +} + +hsa_status_t HSA_API fake_amd_agent_set_async_scratch_limit(hsa_agent_t agent, size_t) { + g_last_async_scratch_limit_agent = agent; + return HSA_STATUS_SUCCESS; +} + struct FakeApiTable { CoreApiTable core{}; AmdExtTable amd{}; @@ -622,16 +701,19 @@ struct FakeApiTable { amd.hsa_amd_vmem_set_access_fn = fake_amd_vmem_set_access; amd.hsa_amd_memory_pool_free_fn = fake_amd_memory_pool_free; amd.hsa_amd_agents_allow_access_fn = fake_amd_agents_allow_access; + amd.hsa_amd_agent_preload_fn = fake_amd_agent_preload; + amd.hsa_amd_agent_set_async_scratch_limit_fn = fake_amd_agent_set_async_scratch_limit; } }; -void write_runtime_config_path(const std::string &runtime_dir, bool include_resolved_host = true) { +void write_runtime_config_path(const std::string &runtime_dir, + bool create_host_topology_node = true) { setenv("ROCJITSU_RUNTIME_DIR", runtime_dir.c_str(), 1); const std::filesystem::path topology_root = std::filesystem::path(runtime_dir) / "topology" / "nodes"; std::filesystem::create_directories(topology_root); - if (include_resolved_host) { + if (create_host_topology_node) { const std::filesystem::path host_node = topology_root / std::to_string(kHostNodeId); std::filesystem::create_directories(host_node); std::ofstream(host_node / "gpu_id") << kResolvedHostGpuId << '\n'; @@ -644,10 +726,10 @@ void write_runtime_config_path(const std::string &runtime_dir, bool include_reso class InstalledHook { public: - explicit InstalledHook(FakeApiTable &api, bool include_resolved_host = true) + explicit InstalledHook(FakeApiTable &api, bool create_host_topology_node = true) : runtime_dir_("rocjitsu-hsa-hooks-unit-") { OnUnload(); - write_runtime_config_path(runtime_dir_.path(), include_resolved_host); + write_runtime_config_path(runtime_dir_.path(), create_host_topology_node); installed_ = OnLoad(&api.table, 0, 0, nullptr); } ~InstalledHook() { @@ -683,6 +765,13 @@ void reset_agent_blocker(bool enabled) { g_block_agent_iteration = enabled; g_agent_iteration_entered = false; g_release_agent_iteration = false; + g_fail_agent_iteration = false; + g_fake_iterate_agents_calls = 0; +} + +hsa_status_t HSA_API collect_agent_handles(hsa_agent_t agent, void *data) { + static_cast *>(data)->push_back(agent.handle); + return HSA_STATUS_SUCCESS; } void release_agent_blocker() { @@ -1009,16 +1098,21 @@ TEST(HsaHooksUnitTest, IterateAgentsDropsGuestOwnSlotWhenHostAppearsFirst) { } // The configured host gpu_id has no topology node here, so the hook cannot tell -// which physical agent the guest should execute on. Enumeration must fail rather -// than fall through to the raw agent list: publishing the physical host would let -// an application using the default device run untranslated on it. GuestKfd already -// rejects the same unresolved host with ENODEV. -TEST(HsaHooksUnitTest, MissingResolvedHostFailsAgentIteration) { +// which physical agent the guest should execute on. Every GPU must be suppressed +// rather than falling through to the raw agent list: publishing the physical host +// would let an application using the default device run untranslated on it. +// GuestKfd already rejects the same unresolved host with ENODEV. The status stays +// SUCCESS so a GPU client sees an empty device list instead of the opaque error +// HIP/CLR reports as a fatal ROCR init failure. +TEST(HsaHooksUnitTest, MissingResolvedHostSuppressesGpuAgents) { reset_pool_blocker(false); reset_agent_blocker(false); FakeApiTable api; - InstalledHook hook(api, false); + InstalledHook hook(api, /*create_host_topology_node=*/false); ASSERT_TRUE(hook.installed()); + // Without this anchor the expectations below are indistinguishable from an + // unpatched table forwarding straight to a fake that emits only GPU agents. + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents); std::vector seen; hsa_status_t status = api.core.hsa_iterate_agents_fn( @@ -1028,10 +1122,355 @@ TEST(HsaHooksUnitTest, MissingResolvedHostFailsAgentIteration) { }, &seen); - EXPECT_EQ(status, HSA_STATUS_ERROR); + EXPECT_EQ(status, HSA_STATUS_SUCCESS); EXPECT_TRUE(seen.empty()); } +// Suppression is GPU-only. A CPU agent cannot run a guest kernel, and callers +// that need one -- host fine-grained pool lookup, hsa_amd_memory_lock, host-side +// copies -- must keep working while the mapping is unresolved. +TEST(HsaHooksUnitTest, MissingResolvedHostStillPublishesCpuAgents) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + api.core.hsa_iterate_agents_fn = fake_iterate_agents_with_cpu; + InstalledHook hook(api, /*create_host_topology_node=*/false); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents_with_cpu); + + std::vector seen; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &seen), HSA_STATUS_SUCCESS); + EXPECT_EQ(seen, std::vector{kCpuAgent.handle}); +} + +// The GPU-only suppression must not have disturbed the success-path shadow +// callback: CPU agents still pass through untouched, and the guest is still +// emitted in the selected host's ordinal slot. +TEST(HsaHooksUnitTest, MappedEnumerationStillPublishesCpuAgents) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + api.core.hsa_iterate_agents_fn = fake_iterate_agents_with_cpu; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents_with_cpu); + + std::vector seen; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &seen), HSA_STATUS_SUCCESS); + EXPECT_EQ(seen, (std::vector{kCpuAgent.handle, kGuestAgent.handle})); +} + +// An unopenable topology root is an I/O failure, not proof the configured host +// gpu_id is absent. Latching it would poison enumeration for the process: every +// later hsa_iterate_agents would keep suppressing every GPU, even once the +// redirected topology tree became readable. +TEST(HsaHooksUnitTest, AgentMapperRetriesAfterUnreadableTopologyRoot) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents); + + // Redirect at a root that does not exist yet, so opendir fails outright. + rocjitsu::test::ScopedTempDirectory late("rocjitsu-late-topology-"); + const std::filesystem::path nodes = std::filesystem::path(late.path()) / "nodes"; + rj_hsa_dbt_set_topology_nodes_root_for_test(nodes.c_str()); + + std::vector first; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &first), HSA_STATUS_SUCCESS); + // The fake publishes only GPU agents, so a suppressed list is an empty list. + EXPECT_TRUE(first.empty()); + + const std::filesystem::path host_node = nodes / std::to_string(kHostNodeId); + std::filesystem::create_directories(host_node); + std::ofstream(host_node / "gpu_id") << kResolvedHostGpuId << '\n'; + + std::vector second; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &second), HSA_STATUS_SUCCESS); + EXPECT_EQ(second, std::vector{kGuestAgent.handle}); +} + +// A readable topology root whose node directory has no readable gpu_id is the other +// half of the same rule: the skipped node may be the one being searched for, so the +// scan cannot prove the configured gpu_id is absent. The verdict must stay retryable +// rather than latching as a permanent "absent" that suppresses every GPU for good. +TEST(HsaHooksUnitTest, AgentMapperRetriesAfterUnreadableTopologyNode) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents); + + // Unlike the root test above, this root opens fine; it is the per-node gpu_id read + // that fails, because the node directory exists with no gpu_id file in it. + rocjitsu::test::ScopedTempDirectory partial("rocjitsu-partial-topology-"); + const std::filesystem::path nodes = std::filesystem::path(partial.path()) / "nodes"; + const std::filesystem::path host_node = nodes / std::to_string(kHostNodeId); + std::filesystem::create_directories(host_node); + ASSERT_FALSE(std::filesystem::exists(host_node / "gpu_id")); + rj_hsa_dbt_set_topology_nodes_root_for_test(nodes.c_str()); + + std::vector first; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &first), HSA_STATUS_SUCCESS); + EXPECT_TRUE(first.empty()); + + std::ofstream(host_node / "gpu_id") << kResolvedHostGpuId << '\n'; + + std::vector second; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &second), HSA_STATUS_SUCCESS); + EXPECT_EQ(second, std::vector{kGuestAgent.handle}); +} + +// The retry must be budgeted. An application that polls the device list while +// ROCR stays broken would otherwise pay a sysfs topology walk plus a full agent +// iteration on every query. +TEST(HsaHooksUnitTest, AgentMapperStopsRediscoveringAfterTransientRetryBudget) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents); + + constexpr int kEnumerationCalls = 64; + g_fail_agent_iteration = true; + g_fake_iterate_agents_calls = 0; + for (int i = 0; i < kEnumerationCalls; ++i) { + std::vector seen; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &seen), HSA_STATUS_ERROR); + } + // Each refused enumeration still forwards to the original iterator once, to + // filter GPU agents out of the published list, so kEnumerationCalls of these + // are pass-throughs rather than rediscovery. A range rather than the exact + // bound, so the test does not pin kMaxTransientAttempts: at least one retry + // happened on top of the first attempt, and the mapper latched well before + // rediscovering on every call. + EXPECT_GE(g_fake_iterate_agents_calls, kEnumerationCalls + 2); + EXPECT_LT(g_fake_iterate_agents_calls, kEnumerationCalls * 2); + g_fail_agent_iteration = false; +} + +// Only the enumeration entry point opts into rediscovery. Everything reached +// per copy, per dispatch, or per allocation stays on the cached verdict. +TEST(HsaHooksUnitTest, HotPathAgentMappingDoesNotRerunDiscovery) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.amd.hsa_amd_memory_pool_allocate_fn, fake_amd_memory_pool_allocate); + + g_fail_agent_iteration = true; + g_fake_iterate_agents_calls = 0; + for (int i = 0; i < 16; ++i) { + void *ptr = nullptr; + EXPECT_EQ(api.amd.hsa_amd_memory_pool_allocate_fn(kGuestPool, 4096, 0, &ptr), + HSA_STATUS_SUCCESS); + } + // hsa_amd_memory_pool_allocate reaches AgentMapper through + // MemoryPoolMapper::ensure_discovered, which retries pool discovery on every + // call; none of those retries may rerun agent discovery. + EXPECT_EQ(g_fake_iterate_agents_calls, 1); + g_fail_agent_iteration = false; +} + +// Enumeration publishing no GPU is not by itself fail-closed: a handle cached +// before discovery failed, or one held by a co-loaded tool, still reaches the +// execution entry points. Each of the five that can put code on -- or dispatch +// to -- real silicon must refuse while the mapping is unusable. +TEST(HsaHooksUnitTest, UnresolvedMappingRefusesQueueCreate) { + reset_pool_blocker(false); + reset_agent_blocker(false); + reset_queue_fakes(); + FakeApiTable api; + InstalledHook hook(api, /*create_host_topology_node=*/false); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_queue_create_fn, fake_queue_create); + + hsa_queue_t *queue = nullptr; + EXPECT_EQ(api.core.hsa_queue_create_fn(kHostAgent, 4, HSA_QUEUE_TYPE_MULTI, nullptr, nullptr, 0, + 0, &queue), + HSA_STATUS_ERROR_INVALID_AGENT); + EXPECT_EQ(queue, nullptr); + EXPECT_EQ(g_last_queue_create_agent.handle, 0u); +} + +TEST(HsaHooksUnitTest, UnresolvedMappingRefusesInterceptQueueCreate) { + reset_pool_blocker(false); + reset_agent_blocker(false); + reset_queue_fakes(); + FakeApiTable api; + api.amd.hsa_amd_queue_intercept_create_fn = fake_amd_queue_intercept_create; + api.amd.hsa_amd_queue_intercept_register_fn = fake_amd_queue_intercept_register; + InstalledHook hook(api, /*create_host_topology_node=*/false); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.amd.hsa_amd_queue_intercept_create_fn, fake_amd_queue_intercept_create); + + hsa_queue_t *queue = nullptr; + EXPECT_EQ(api.amd.hsa_amd_queue_intercept_create_fn(kHostAgent, 4, HSA_QUEUE_TYPE_MULTI, nullptr, + nullptr, 0, 0, &queue), + HSA_STATUS_ERROR_INVALID_AGENT); + EXPECT_EQ(queue, nullptr); +} + +// The regression test for the load path specifically: is_guest() is false +// whenever the mapping failed, so without the guard this load would take the +// "not the guest, forward verbatim" branch and put an untranslated guest code +// object on the raw host. +TEST(HsaHooksUnitTest, UnresolvedMappingRefusesAgentCodeObjectLoad) { + reset_pool_blocker(false); + reset_agent_blocker(false); + reset_queue_fakes(); + FakeApiTable api; + InstalledHook hook(api, /*create_host_topology_node=*/false); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_executable_load_agent_code_object_fn, + fake_executable_load_agent_code_object); + + std::vector image(sizeof(rocjitsu::Elf64_Ehdr), 0); + write_struct(image, 0, make_amdgpu_elf_header(rocjitsu::EF_AMDGPU_MACH_AMDGCN_GFX950)); + hsa_code_object_reader_t reader{}; + ASSERT_EQ( + api.core.hsa_code_object_reader_create_from_memory_fn(image.data(), image.size(), &reader), + HSA_STATUS_SUCCESS); + + hsa_loaded_code_object_t loaded{}; + EXPECT_EQ(api.core.hsa_executable_load_agent_code_object_fn(kFakeExecutable, kHostAgent, reader, + nullptr, &loaded), + HSA_STATUS_ERROR_INVALID_AGENT); + EXPECT_EQ(g_fake_load_agent_calls, 0); +} + +TEST(HsaHooksUnitTest, UnresolvedMappingRefusesAgentPreload) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + InstalledHook hook(api, /*create_host_topology_node=*/false); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.amd.hsa_amd_agent_preload_fn, fake_amd_agent_preload); + + g_last_agent_preload_agent = {}; + EXPECT_EQ(api.amd.hsa_amd_agent_preload_fn(kHostAgent, 0), HSA_STATUS_ERROR_INVALID_AGENT); + EXPECT_EQ(g_last_agent_preload_agent.handle, 0u); +} + +TEST(HsaHooksUnitTest, UnresolvedMappingRefusesAsyncScratchLimit) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + InstalledHook hook(api, /*create_host_topology_node=*/false); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.amd.hsa_amd_agent_set_async_scratch_limit_fn, + fake_amd_agent_set_async_scratch_limit); + + g_last_async_scratch_limit_agent = {}; + EXPECT_EQ(api.amd.hsa_amd_agent_set_async_scratch_limit_fn(kHostAgent, 4096), + HSA_STATUS_ERROR_INVALID_AGENT); + EXPECT_EQ(g_last_async_scratch_limit_agent.handle, 0u); +} + +// The guard against over-applying the refusal. Query and allocation hooks +// legitimately take CPU agents and cannot execute a kernel; blanket-guarding +// them would undo the CPU pass-through that enumeration deliberately preserves. +TEST(HsaHooksUnitTest, UnresolvedMappingStillAllowsCpuPoolIteration) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + InstalledHook hook(api, /*create_host_topology_node=*/false); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.amd.hsa_amd_agent_iterate_memory_pools_fn, fake_amd_agent_iterate_memory_pools); + + std::vector pools; + EXPECT_EQ(api.amd.hsa_amd_agent_iterate_memory_pools_fn( + kCpuAgent, + [](hsa_amd_memory_pool_t pool, void *data) -> hsa_status_t { + static_cast *>(data)->push_back(pool.handle); + return HSA_STATUS_SUCCESS; + }, + &pools), + HSA_STATUS_SUCCESS); + EXPECT_EQ(pools, std::vector{kCpuFineGrainedPool.handle}); +} + +// The refusal is conditional on the mapping being unusable, not on the agent +// being unrelated to DBT: a resolved mapping must leave other GPUs alone. +TEST(HsaHooksUnitTest, ResolvedMappingStillAllowsUnrelatedAgentQueueCreate) { + reset_pool_blocker(false); + reset_agent_blocker(false); + reset_queue_fakes(); + FakeApiTable api; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_queue_create_fn, fake_queue_create); + + hsa_queue_t *queue = nullptr; + EXPECT_EQ(api.core.hsa_queue_create_fn(kUnrelatedAgent, 4, HSA_QUEUE_TYPE_MULTI, nullptr, nullptr, + 0, 0, &queue), + HSA_STATUS_SUCCESS); + ASSERT_NE(queue, nullptr); + EXPECT_EQ(g_last_queue_create_agent.handle, kUnrelatedAgent.handle); +} + +// Only the host predicate carries the node-id constraint, so when both agents +// advertise both ISAs -- the shape of a same-ISA config -- the guest predicate +// must not be allowed to claim the physical host. Enumeration still resolves a +// distinct pair, whichever side ROCR emits first. +TEST(HsaHooksUnitTest, SameIsaGuestAndHostSelectDistinctAgentsWhenGuestAppearsFirst) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + api.core.hsa_agent_iterate_isas_fn = fake_agent_iterate_isas_overlapping; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents); + + std::vector seen; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &seen), HSA_STATUS_SUCCESS); + EXPECT_EQ(seen, std::vector{kGuestAgent.handle}); +} + +TEST(HsaHooksUnitTest, SameIsaGuestAndHostSelectDistinctAgentsWhenHostAppearsFirst) { + reset_pool_blocker(false); + reset_agent_blocker(false); + FakeApiTable api; + api.core.hsa_agent_iterate_isas_fn = fake_agent_iterate_isas_overlapping; + api.core.hsa_iterate_agents_fn = fake_iterate_agents_host_first; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents_host_first); + + std::vector seen; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &seen), HSA_STATUS_SUCCESS); + EXPECT_EQ(seen, std::vector{kGuestAgent.handle}); +} + +// One agent satisfies both roles and no distinct guest exists. The mapping is a +// failure, not a pass-through: aliasing guest onto host would translate nothing +// and load guest code objects verbatim on real silicon. +TEST(HsaHooksUnitTest, SameAgentForGuestAndHostFailsClosed) { + reset_pool_blocker(false); + reset_agent_blocker(false); + reset_queue_fakes(); + FakeApiTable api; + api.core.hsa_agent_iterate_isas_fn = fake_agent_iterate_isas_overlapping; + api.core.hsa_iterate_agents_fn = fake_iterate_agents_host_only; + InstalledHook hook(api); + ASSERT_TRUE(hook.installed()); + ASSERT_NE(api.core.hsa_iterate_agents_fn, fake_iterate_agents_host_only); + + std::vector seen; + EXPECT_EQ(api.core.hsa_iterate_agents_fn(collect_agent_handles, &seen), HSA_STATUS_SUCCESS); + EXPECT_TRUE(seen.empty()); + + hsa_queue_t *queue = nullptr; + EXPECT_EQ(api.core.hsa_queue_create_fn(kHostAgent, 4, HSA_QUEUE_TYPE_MULTI, nullptr, nullptr, 0, + 0, &queue), + HSA_STATUS_ERROR_INVALID_AGENT); + EXPECT_EQ(queue, nullptr); +} + TEST(HsaHooksUnitTest, BatchCopyMapsScalarSourceAndDestinationAgents) { hsa_amd_memory_copy_op_t op{}; op.type = HSA_AMD_MEMORY_COPY_OP_LINEAR; From b2cfc323fb74f8f2839da107163baac2c041e368 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 21:48:47 +0000 Subject: [PATCH 26/31] fix(rocjitsu): refuse an unresolved DBT host gpu_id in the KFD layer GuestKfd::ensure_ready_locked() still derived its own host GPU by scanning the topology for the first ISA match when host_gpu_id_ was 0. This series added the arm that verifies a supplied gpu_id but left the arm that invents one, so a working implementation of the per-layer divergence bug remained -- the KFD interposer and the HSA hook could each pick a different physical GPU on a box with identical devices. It was dead in practice, but only because load_dbt_guest_config_from_ handoff() rejects an enabled DBT config carrying gpu_id 0, an invariant enforced in a different file with nothing here stating it. Refuse instead, matching the hook-side refusal, and delete the two helpers that had no other caller. The interposer reported the same invariant through Logger::debug_print, which is `if constexpr` compiled out unless debug logging is configured on, so the KFD half of the fail-closed contract was silent in every shipped build while the HSA half printed. Use Logger::warn per the logging rule this series documents. Drop apply_resolved_dbt_host_gpu_id()'s `source` parameter, which had a single production caller and one fixed test string. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/rocjitsu/config/dbt_guest_config.cpp | 7 +- .../src/rocjitsu/config/dbt_guest_config.h | 3 +- .../src/rocjitsu/kmd/linux/guest_kfd.cpp | 70 ++++++------------- .../src/rocjitsu/kmd/linux/interposer.cpp | 6 +- emulation/rocjitsu/tests/config_test.cpp | 18 ++--- 5 files changed, 36 insertions(+), 68 deletions(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp index c9db6de0868f..98555e167716 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.cpp @@ -151,8 +151,7 @@ DbtGuestConfig load_dbt_guest_config_from_file(const std::string &path) { false); } -void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view value, - std::string_view source) { +void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view value) { if (!config.enabled || config.host.gpu_id != 0) return; @@ -161,7 +160,7 @@ void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view val const char *end = begin + value.size(); auto [ptr, error] = std::from_chars(begin, end, gpu_id); if (error != std::errc{} || ptr != end || gpu_id == 0) - throw std::runtime_error(std::string(source) + " must contain a nonzero KFD gpu_id"); + throw std::runtime_error("runtime config handoff must contain a nonzero KFD gpu_id"); config.host.gpu_id = gpu_id; } @@ -218,7 +217,7 @@ std::optional parse_dbt_runtime_config_handoff(std::str DbtGuestConfig load_dbt_guest_config_from_handoff(const DbtRuntimeConfigHandoff &handoff) { DbtGuestConfig config = load_dbt_guest_config_from_file(handoff.config_path); if (handoff.resolved_gpu_id) { - apply_resolved_dbt_host_gpu_id(config, *handoff.resolved_gpu_id, "runtime config handoff"); + apply_resolved_dbt_host_gpu_id(config, *handoff.resolved_gpu_id); } else if (config.enabled && config.host.gpu_id == 0) { throw std::runtime_error("runtime config handoff must contain a resolved KFD gpu_id for " "automatic DBT host selection"); diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h index b8bfb5c3e8ee..fb90997227da 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/config/dbt_guest_config.h @@ -110,8 +110,7 @@ DbtGuestConfig load_dbt_guest_config_from_file(const std::string &path); /// @details Child processes read this value from the second line of the /// per-invocation config handoff. Explicit nonzero config values remain authoritative. /// @throws std::runtime_error when @p value is not a nonzero KFD gpu_id. -void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view value, - std::string_view source); +void apply_resolved_dbt_host_gpu_id(DbtGuestConfig &config, std::string_view value); /// @brief Atomically write the per-invocation runtime config handoff. /// @returns false when enabled DBT lacks a resolved host GPU or the handoff cannot be published. diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp index 7aac4cf90815..a509e1d8477d 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/guest_kfd.cpp @@ -176,36 +176,6 @@ uint32_t max_numeric_dir(const fs::path &dir) { return max_id; } -std::optional first_gpu_id_matching_isa_in_topology(const fs::path &topology_root, - std::string_view host_isa) { - std::optional target_version = kmd::gfx_target_version_from_name(host_isa); - if (!target_version) - return std::nullopt; - - fs::path nodes_dir = topology_root / "nodes"; - const uint32_t max_node = max_numeric_dir(nodes_dir); - for (uint32_t node_id = 1; node_id <= max_node; ++node_id) { - fs::path node_dir = nodes_dir / std::to_string(node_id); - const uint32_t gfx_target_version = - read_u32_property(node_dir / "properties", "gfx_target_version", 0); - if (gfx_target_version != *target_version) - continue; - - std::optional gpu_id = read_u32_file(node_dir / "gpu_id"); - if (gpu_id && *gpu_id != 0) - return gpu_id; - } - return std::nullopt; -} - -std::optional first_real_gpu_id_matching_isa(std::string_view host_isa) { - for (std::string_view topology_path : kRealTopologyPaths) { - if (auto gpu_id = first_gpu_id_matching_isa_in_topology(fs::path(topology_path), host_isa)) - return gpu_id; - } - return std::nullopt; -} - bool gpu_id_matches_isa_in_topology(const fs::path &topology_root, uint32_t gpu_id, std::string_view host_isa) { std::optional target_version = kmd::gfx_target_version_from_name(host_isa); @@ -646,26 +616,26 @@ bool GuestKfd::ensure_ready_locked() { if (!ensure_real_kfd_locked()) return false; if (host_gpu_id_ == 0) { - std::optional host_gpu_id; - if (execution_driver_) - host_gpu_id = first_gpu_id_matching_isa_in_topology(execution_driver_->topology_path(), - config_.host.isa); - else - host_gpu_id = first_real_gpu_id_matching_isa(config_.host.isa); - if (!host_gpu_id) { - errno = ENODEV; - return false; - } - host_gpu_id_ = *host_gpu_id; - } else { - const bool host_matches = - execution_driver_ ? gpu_id_matches_isa_in_topology(execution_driver_->topology_path(), - host_gpu_id_, config_.host.isa) - : real_gpu_id_matches_isa(host_gpu_id_, config_.host.isa); - if (!host_matches) { - errno = ENODEV; - return false; - } + // The launcher resolves the host GPU once and publishes it in the runtime handoff, and + // load_dbt_guest_config_from_handoff() refuses an enabled DBT config that arrives here + // unresolved. Re-deriving it by first-ISA-match would reintroduce the per-layer + // divergence that lets this driver and the hook layer pick different GPUs on a + // multi-GPU host, so refuse rather than guess. + util::Logger::warn("rocjitsu: DBT config carries no resolved host KFD gpu_id; refusing to " + "guess a host GPU"); + errno = ENODEV; + return false; + } + const bool host_matches = execution_driver_ + ? gpu_id_matches_isa_in_topology(execution_driver_->topology_path(), + host_gpu_id_, config_.host.isa) + : real_gpu_id_matches_isa(host_gpu_id_, config_.host.isa); + if (!host_matches) { + util::Logger::warn("rocjitsu: host KFD gpu_id=", host_gpu_id_, + " does not match the configured host ISA '", config_.host.isa, + "' in the KFD topology"); + errno = ENODEV; + return false; } const std::string host_topology = execution_driver_ ? execution_driver_->topology_path() : ""; if (!overlay_->generate(guest_, host_topology)) { diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp index f70d33c18dd5..189e0cfe1705 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/kmd/linux/interposer.cpp @@ -1239,7 +1239,11 @@ class InterposerContext { return nullptr; } } catch (const std::exception &e) { - util::Logger::debug_print("rocjitsu: failed to load child config: ", e.what()); + // This is where a broken runtime handoff lands, including an enabled DBT config + // with no resolved host gpu_id. Failing closed leaves the process with a null + // driver and an opaque downstream failure, so the reason must be audible in a + // default build the way the hook layer's equivalent refusal already is. + util::Logger::warn("rocjitsu: failed to load child config: ", e.what()); destroy_local_vm(); in_construction = false; return nullptr; diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 9429c0b36cf2..cc3ed22ec60d 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -572,9 +572,9 @@ TEST(ConfigLoaderTest, AppliesResolvedDbtHostGpuId) { config::DbtGuestConfig simulator = automatic; simulator.host.backend = config::DbtExecutionBackend::Simulator; - config::apply_resolved_dbt_host_gpu_id(automatic, "28851", "test handoff"); - config::apply_resolved_dbt_host_gpu_id(explicit_id, "28851", "test handoff"); - config::apply_resolved_dbt_host_gpu_id(simulator, "28851", "test handoff"); + config::apply_resolved_dbt_host_gpu_id(automatic, "28851"); + config::apply_resolved_dbt_host_gpu_id(explicit_id, "28851"); + config::apply_resolved_dbt_host_gpu_id(simulator, "28851"); EXPECT_EQ(automatic.host.gpu_id, 28851u); EXPECT_EQ(explicit_id.host.gpu_id, 8716u); @@ -587,9 +587,7 @@ TEST(ConfigLoaderTest, RejectsInvalidResolvedDbtHostGpuId) { for (std::string_view value : invalid_values) { config::DbtGuestConfig dbt; dbt.enabled = true; - EXPECT_THROW(config::apply_resolved_dbt_host_gpu_id(dbt, value, "test handoff"), - std::runtime_error) - << value; + EXPECT_THROW(config::apply_resolved_dbt_host_gpu_id(dbt, value), std::runtime_error) << value; } } @@ -598,8 +596,7 @@ TEST(ConfigLoaderTest, ExplicitDbtHostGpuIdOverridesResolvedHandoff) { dbt.enabled = true; dbt.host.gpu_id = 8716; - EXPECT_NO_THROW( - config::apply_resolved_dbt_host_gpu_id(dbt, "invalid-but-ignored", "test handoff")); + EXPECT_NO_THROW(config::apply_resolved_dbt_host_gpu_id(dbt, "invalid-but-ignored")); EXPECT_EQ(dbt.host.gpu_id, 8716u); } @@ -669,9 +666,8 @@ TEST(ConfigLoaderTest, RejectsEmptyResolvedGpuIdLineForEnabledDbt) { config::DbtGuestConfig dbt; dbt.enabled = true; - EXPECT_THROW( - config::apply_resolved_dbt_host_gpu_id(dbt, *handoff->resolved_gpu_id, "test handoff"), - std::runtime_error); + EXPECT_THROW(config::apply_resolved_dbt_host_gpu_id(dbt, *handoff->resolved_gpu_id), + std::runtime_error); } TEST(ConfigLoaderTest, LoadsDbtRuntimeConfigHandoffFromInvocationDirectory) { From 38f664801fa84ed45003ea7bb85370bef57e9daa Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 21:49:56 +0000 Subject: [PATCH 27/31] test(rocjitsu): distinguish an unbuildable long path from a real failure relocate_active_config_to_long_path() returns testing::AssertionResult, so every precondition failure was a red test -- including ones that say nothing about the code under test. A CI runner whose $ROCJITSU_INVOCATION_DIR is already deeper than the >4095-byte handoff being constructed would fail ReadsRuntimeHandoffLargerThan4095Bytes while the reader it exercises is fine. GTEST_SKIP expands to a bare return and cannot be issued from a function returning AssertionResult, which is why this stayed as it was. Return a tri-state instead and let the test body issue the verdict: environmental preconditions skip with a reason, genuine defects -- write failures, a built path that misses its target length -- stay red. Nothing is silently swallowed. Register the new case with SKIP_REGULAR_EXPRESSION so a skip cannot be reported as a pass. Also hoist config_test.cpp's local ScopedEnvironmentVariable into scoped_temp.h beside ScopedTempDirectory, and use it from guest_kfd_test rather than hand-rolling save/restore a third time. Co-Authored-By: Claude Opus 5 (1M context) --- emulation/rocjitsu/tests/CMakeLists.txt | 16 ++ emulation/rocjitsu/tests/config_test.cpp | 38 +-- emulation/rocjitsu/tests/guest_kfd_test.cpp | 245 +++++++++++++++----- emulation/rocjitsu/tests/scoped_temp.h | 29 +++ 4 files changed, 239 insertions(+), 89 deletions(-) diff --git a/emulation/rocjitsu/tests/CMakeLists.txt b/emulation/rocjitsu/tests/CMakeLists.txt index db65b6beb148..74348b8a1df7 100644 --- a/emulation/rocjitsu/tests/CMakeLists.txt +++ b/emulation/rocjitsu/tests/CMakeLists.txt @@ -1008,6 +1008,10 @@ rj_add_test( --gtest_filter=GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} TIMEOUT 60 + # The test skips when the runtime directory it is handed is already deeper than + # the >4095-byte path it needs to build. Without this the skip would be reported + # as a pass and the lost coverage would be invisible. + SKIP_REGULAR_EXPRESSION "\\[ SKIPPED \\]" INSTALL_COMMAND "\${RJ_INSTALLED_BINDIR}/rocjitsu" "--config" @@ -1016,6 +1020,18 @@ rj_add_test( "bin/guest_kfd_test" "--gtest_filter=GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes" ) +# Pure harness logic: no guest device, so no launcher wrapper. +rj_add_test( + NAME "GuestKfdConfigTest.LongPathHandoffSkipsWhenRuntimeDirIsTooDeep" + COMMAND + $ + --gtest_filter=GuestKfdConfigTest.LongPathHandoffSkipsWhenRuntimeDirIsTooDeep + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + TIMEOUT 60 + INSTALL_COMMAND + "bin/guest_kfd_test" + "--gtest_filter=GuestKfdConfigTest.LongPathHandoffSkipsWhenRuntimeDirIsTooDeep" +) if(RJ_HAS_GFX1201_GPU) add_test( NAME "GuestKfdMultiprocessTest.ForkedChildrenDoNotRemoveParentOverlay" diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index cc3ed22ec60d..7cd6f11bed75 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -28,7 +28,6 @@ RJ_DIAGNOSTIC_POP #include #include -#include #include #include #include @@ -50,26 +49,6 @@ test::ScopedTempFile write_temp_config(std::string_view json) { return file; } -class ScopedEnvironmentVariable { -public: - ScopedEnvironmentVariable(const char *name, const std::string &value) : name_(name) { - if (const char *original = std::getenv(name)) - original_ = original; - setenv(name_.c_str(), value.c_str(), 1); - } - - ~ScopedEnvironmentVariable() { - if (original_) - setenv(name_.c_str(), original_->c_str(), 1); - else - unsetenv(name_.c_str()); - } - -private: - std::string name_; - std::optional original_; -}; - TEST(ConfigLoaderTest, LoadCdna4Config) { std::string json = CONFIG_DIR_PATH + "/gfx950_cdna4.json"; auto loaded = config::load_config(json, rocjitsu::kEmbeddedSchema); @@ -399,6 +378,11 @@ TEST(ConfigLoaderTest, LoadsDbtOnlyConfigWithoutVmOrTopology) { TEST(ConfigLoaderTest, LoadsDbtGuestSiliconRevisions) { // gfx1250 A0 and B0 share an ELF machine ID, so the configured revisions // select the B0-to-A0 translation profile. + // + // This also pins guest_isa == host_isa as a legal configuration. The hook + // layer resolves the resulting agent-role overlap by matching the host first + // (only the host carries the node-id constraint) rather than by rejecting the + // config here, which would foreclose this profile. const auto file = write_temp_config(R"({ "dbt_guest": { "enabled": true, @@ -620,7 +604,7 @@ TEST(ConfigLoaderTest, ParsesRuntimeConfigHandoff) { TEST(ConfigLoaderTest, RoundTripsRuntimeConfigHandoff) { const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-round-trip-"); - ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", runtime.path()); + test::ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", runtime.path()); config::DbtGuestConfig dbt; dbt.enabled = true; dbt.host.gpu_id = 28851; @@ -639,7 +623,7 @@ TEST(ConfigLoaderTest, RoundTripsRuntimeConfigHandoff) { TEST(ConfigLoaderTest, RejectsUnresolvedAutomaticDbtHandoffWrite) { const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-unresolved-"); - ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", runtime.path()); + test::ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", runtime.path()); config::DbtGuestConfig dbt; dbt.enabled = true; @@ -651,7 +635,7 @@ TEST(ConfigLoaderTest, RuntimeConfigHandoffReportsDirectoryCreationFailure) { const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-write-failure-"); const std::filesystem::path blocked_root = std::filesystem::path(runtime.path()) / "blocked"; std::ofstream(blocked_root) << "not a directory"; - ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", blocked_root.string()); + test::ScopedEnvironmentVariable runtime_dir("ROCJITSU_RUNTIME_DIR", blocked_root.string()); config::DbtGuestConfig dbt; dbt.enabled = true; dbt.host.gpu_id = 28851; @@ -683,7 +667,7 @@ TEST(ConfigLoaderTest, LoadsDbtRuntimeConfigHandoffFromInvocationDirectory) { std::ofstream handoff(std::filesystem::path(runtime.path()) / "config_path"); handoff << config_file.path() << "\n28851\n"; } - ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); + test::ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); const std::optional loaded = config::load_dbt_guest_config_from_runtime_config(); @@ -706,14 +690,14 @@ TEST(ConfigLoaderTest, RejectsPathOnlyHandoffForAutomaticDbtHost) { std::ofstream handoff(std::filesystem::path(runtime.path()) / "config_path"); handoff << config_file.path() << '\n'; } - ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); + test::ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); EXPECT_THROW(config::load_dbt_guest_config_from_runtime_config(), std::runtime_error); } TEST(ConfigLoaderTest, AllowsPathOnlyHandoffWithoutAutomaticDbtHost) { const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-path-only-"); - ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); + test::ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, runtime.path()); for (const std::string_view dbt_guest : { R"("dbt_guest": {"enabled": true, "host_gpu_id": 28851})", diff --git a/emulation/rocjitsu/tests/guest_kfd_test.cpp b/emulation/rocjitsu/tests/guest_kfd_test.cpp index 5198ab9f1625..c0343fd6c31c 100644 --- a/emulation/rocjitsu/tests/guest_kfd_test.cpp +++ b/emulation/rocjitsu/tests/guest_kfd_test.cpp @@ -3,6 +3,8 @@ #include +#include "scoped_temp.h" + #include "rocjitsu/base/rj_compiler.h" #include "rocjitsu/kmd/linux/rpc.h" RJ_DIAGNOSTIC_PUSH @@ -28,8 +30,10 @@ RJ_DIAGNOSTIC_POP #include #include #include +#include #include #include +#include #include namespace { @@ -60,10 +64,11 @@ constexpr uint64_t kAllocSize = 4096; // tier has a handoff yet, return the highest-priority writable tier so // install_inline_dbt_config() writes where the reader looks first. // rpc_default_runtime_dir() already treats an unset OR empty $ROCJITSU_RUNTIME_DIR as -// "use $XDG_RUNTIME_DIR/rocjitsu or /tmp/rocjitsu-", so this never returns -// nullopt on that account — otherwise the test would write nowhere while the runtime -// hook still reads the default location. -std::optional config_handoff_dir() { +// "use $XDG_RUNTIME_DIR/rocjitsu or /tmp/rocjitsu-", so the per-PID and base tiers +// are always well-formed and a directory is always returned — otherwise the test would +// write nowhere while the runtime hook still reads the default location. The return +// type spells that out: callers cannot be asked to handle an absence that cannot occur. +std::filesystem::path config_handoff_dir() { std::vector tiers; if (const char *inv = std::getenv("ROCJITSU_INVOCATION_DIR"); inv && *inv) tiers.emplace_back(inv); @@ -81,11 +86,9 @@ std::optional config_handoff_dir() { } std::optional read_active_config_json() { - const auto dir = config_handoff_dir(); - if (!dir) - return std::nullopt; + const std::filesystem::path dir = config_handoff_dir(); - std::ifstream active_config(*dir / "config_path"); + std::ifstream active_config(dir / "config_path"); std::string configured_path; if (!std::getline(active_config, configured_path) || configured_path.empty()) return std::nullopt; @@ -99,12 +102,8 @@ std::optional read_active_config_json() { bool install_inline_dbt_config(std::string simulator_json, const char *host_isa, uint32_t lds_size_kb, std::string_view external_host_config = {}, bool include_resolved_gpu_id = true) { - const auto dir = config_handoff_dir(); - if (!dir) - return false; - try { - const std::filesystem::path runtime(*dir); + const std::filesystem::path runtime = config_handoff_dir(); std::filesystem::create_directories(runtime); const std::filesystem::path config_path = runtime / "inline_dbt_failure_config.json"; @@ -160,49 +159,123 @@ bool install_inline_dbt_config(std::string simulator_json, const char *host_isa, } } -// Move the active config to a path longer than PATH_MAX so the handoff reader is -// exercised against a path a fixed 4096-byte buffer cannot hold. +/// @brief Verdict from building the oversized config handoff: ok, skip, or fail. +/// @details Three states rather than testing::AssertionResult's two, because not every +/// way of not producing the handoff is a defect. Some preconditions depend on the +/// caller's runtime directory rather than on the handoff reader under test: a +/// $ROCJITSU_INVOCATION_DIR already longer than the path being built says nothing about +/// whether the reader copes with a handoff past 4095 bytes, so it must skip rather than +/// go red. Every state carries a reason, so a skip is still reported and never silently +/// swallowed. GTEST_SKIP expands to a bare `return` and cannot be issued from a function +/// that returns a value, so the verdict travels back to the TEST body, which issues the +/// skip or the failure itself. +class LongPathHandoff { +public: + enum class Status { kOk, kSkip, kFail }; + + /// @brief The oversized handoff was built and installed. + static LongPathHandoff ok() { return LongPathHandoff(Status::kOk, {}); } + /// @brief This environment cannot host the path, so the reader is untestable here. + static LongPathHandoff skip(std::string reason) { + return LongPathHandoff(Status::kSkip, std::move(reason)); + } + /// @brief A genuine defect in the helper's arithmetic or in the run itself. + static LongPathHandoff fail(std::string reason) { + return LongPathHandoff(Status::kFail, std::move(reason)); + } + + [[nodiscard]] Status status() const { return status_; } + [[nodiscard]] const std::string &reason() const { return reason_; } + +private: + LongPathHandoff(Status status, std::string reason) + : status_(status), reason_(std::move(reason)) {} + + Status status_; + std::string reason_; +}; + +constexpr std::string_view kLongConfigFileName = "config.json"; +constexpr size_t kMaxComponentLength = 200; // stays well under NAME_MAX +constexpr size_t kTargetPathLength = 4093; +constexpr size_t kTargetParentLength = kTargetPathLength - kLongConfigFileName.size() - 1; + +// The handoff directory as a path to build beneath, with any trailing separator +// dropped: the padding arithmetic charges one separator per appended component, so a +// directory that already ends in one would be charged twice. A bare root keeps its +// separator, since dropping it would leave a relative path. +std::string normalized_handoff_parent(const std::filesystem::path &dir) { + std::string parent = dir.string(); + while (parent.size() > 1 && parent.back() == '/') + parent.pop_back(); + return parent; +} + +// Bytes a normalized parent contributes to a path built beneath it. A bare root is the +// separator the first component would otherwise have to pay for, so it contributes none. +size_t handoff_parent_length(const std::string &parent) { + return parent == "/" ? 0 : parent.size(); +} + +/// @brief kSkip when the runtime directory is too deep to host the oversized handoff. +/// @details Split out of relocate_active_config_to_long_path() so the test can consult +/// it before it installs anything. A directory too deep for the >4095-byte path is also +/// too deep for the ordinary config the install writes, so a test that only checked at +/// relocation time would already have gone red on the install for the very same +/// environmental reason, and the skip would be unreachable. +LongPathHandoff long_path_handoff_supported() { + const std::filesystem::path dir = config_handoff_dir(); + const size_t parent_length = handoff_parent_length(normalized_handoff_parent(dir)); + + if (parent_length > kTargetParentLength) { + std::ostringstream reason; + reason << "handoff directory " << dir << " contributes " << parent_length + << " bytes, already past the " << kTargetParentLength << "-byte parent to build"; + return LongPathHandoff::skip(reason.str()); + } + if (kTargetParentLength - parent_length == 1) { + std::ostringstream reason; + reason << "handoff directory " << dir << " contributes " << parent_length + << " bytes, one short of " << kTargetParentLength + << ": no component fits in a separator plus one byte"; + return LongPathHandoff::skip(reason.str()); + } + return LongPathHandoff::ok(); +} + +// Move the active config to a path just under PATH_MAX so the handoff file the +// reader parses -- the path, a newline, and the resolved gpu_id line -- exceeds +// 4095 bytes and cannot be consumed by a single fixed 4096-byte read. // // The padding is planned up front rather than grown greedily. Each appended // component costs one separator plus at least one character, so a greedy loop can // land on a remainder it cannot spend: exactly on the target (where subtracting the // separator underflows size_t and asks std::string for a SIZE_MAX-long component) // or one byte short of it (where no legal component fits). Sizing the components -// from the byte budget keeps every intermediate length in range, and the remaining -// unspendable case -- a starting directory already at or past the budget -- is -// reported rather than silently returned as false, since it depends on the -// caller's runtime directory rather than on the behavior under test. -testing::AssertionResult relocate_active_config_to_long_path() { - const auto dir = config_handoff_dir(); - if (!dir) - return testing::AssertionFailure() << "no config handoff directory to relocate into"; - const auto json = read_active_config_json(); - if (!json) - return testing::AssertionFailure() << "no active config JSON to relocate"; +// from the byte budget keeps every intermediate length in range. The remaining +// unspendable cases -- a starting directory at or past the budget, or one byte short +// of it -- are the environmental ones long_path_handoff_supported() turns into kSkip. +// The self-check on the built path length and every write failure stay kFail, because +// those are defects in the helper or the run rather than in where it runs. +LongPathHandoff relocate_active_config_to_long_path() { + // Re-checked here rather than trusted from the caller, so the helper stays correct + // standalone and the budget arithmetic below cannot underflow. + if (LongPathHandoff supported = long_path_handoff_supported(); + supported.status() != LongPathHandoff::Status::kOk) + return supported; + + const std::filesystem::path dir = config_handoff_dir(); try { - constexpr std::string_view kConfigFileName = "config.json"; - constexpr size_t kMaxComponentLength = 200; // stays well under NAME_MAX - constexpr size_t kTargetPathLength = 4093; - constexpr size_t kTargetParentLength = kTargetPathLength - kConfigFileName.size() - 1; - - // Byte accounting below charges one separator per appended component, so drop - // any trailing separator the handoff directory already carries. A bare root - // supplies that separator itself, so it contributes no bytes of its own. - std::string parent = dir->string(); - while (parent.size() > 1 && parent.back() == '/') - parent.pop_back(); - const size_t parent_length = parent == "/" ? 0 : parent.size(); - - if (parent_length > kTargetParentLength) - return testing::AssertionFailure() - << "handoff directory " << *dir << " is " << parent.size() - << " bytes, already past the " << kTargetParentLength << "-byte parent to build"; + const std::string parent = normalized_handoff_parent(dir); + const size_t parent_length = handoff_parent_length(parent); const size_t pad_budget = kTargetParentLength - parent_length; - if (pad_budget == 1) - return testing::AssertionFailure() - << "handoff directory " << *dir << " is " << parent.size() << " bytes, one short of " - << kTargetParentLength << ": no component fits in a separator plus one byte"; + + // The caller asserts the config is installed before calling, so an unreadable + // one here is a defect in the run rather than a property of the environment. + const auto json = read_active_config_json(); + if (!json) + return LongPathHandoff::fail("no active config JSON to relocate"); std::filesystem::path config_dir(parent); if (pad_budget > 0) { @@ -214,26 +287,39 @@ testing::AssertionResult relocate_active_config_to_long_path() { config_dir /= std::string(chars / components + (i < chars % components ? 1 : 0), 'a'); } - std::filesystem::create_directories(config_dir); - const std::filesystem::path config_path = config_dir / kConfigFileName; - if (config_path.string().size() != kTargetPathLength) - return testing::AssertionFailure() - << "built a " << config_path.string().size() << "-byte config path from a " - << parent.size() << "-byte handoff directory, expected " << kTargetPathLength; + // Self-check on the sizing arithmetic above, deliberately before anything touches + // the filesystem: a miscomputed length must surface here as kFail, not as the + // ENAMETOOLONG the catch below would forgive as an environment limit. + const std::filesystem::path config_path = config_dir / kLongConfigFileName; + if (config_path.string().size() != kTargetPathLength) { + std::ostringstream reason; + reason << "built a " << config_path.string().size() << "-byte config path from a " + << parent_length << "-byte handoff directory, expected " << kTargetPathLength; + return LongPathHandoff::fail(reason.str()); + } + std::filesystem::create_directories(config_dir); std::ofstream config(config_path); config << *json; config.close(); if (!config) - return testing::AssertionFailure() << "failed to write config to the long path"; + return LongPathHandoff::fail("failed to write config to the long path"); - std::ofstream handoff(*dir / "config_path", std::ios::trunc); + std::ofstream handoff(dir / "config_path", std::ios::trunc); handoff << config_path.string() << "\n50148\n"; if (!handoff.good()) - return testing::AssertionFailure() << "failed to rewrite the config_path handoff"; - return testing::AssertionSuccess(); + return LongPathHandoff::fail("failed to rewrite the config_path handoff"); + return LongPathHandoff::ok(); } catch (const std::filesystem::filesystem_error &e) { - return testing::AssertionFailure() << "filesystem error building the long path: " << e.what(); + // The built path is already proven to be the intended length, so ENAMETOOLONG + // here means this filesystem refuses a path the kernel itself allows -- a shorter + // internal limit, or name-expanding encryption -- which is a property of where the + // test runs. Every other code (EACCES, ENOSPC) is a real failure. + std::ostringstream reason; + reason << "filesystem error building the " << kTargetPathLength << "-byte path: " << e.what(); + if (e.code() == std::errc::filename_too_long) + return LongPathHandoff::skip(reason.str()); + return LongPathHandoff::fail(reason.str()); } } @@ -471,9 +557,8 @@ TEST(GuestKfdMemoryTest, GuestAllocationMmapOffsetIsRejected) { } TEST(GuestKfdFailureTest, NonexistentSimulatorConfigFailsCleanly) { - const auto dir = config_handoff_dir(); - ASSERT_TRUE(dir.has_value()); - const std::string nonexistent = (*dir / "nonexistent_simulator_config.json").string(); + const std::string nonexistent = + (config_handoff_dir() / "nonexistent_simulator_config.json").string(); ASSERT_TRUE(install_inline_dbt_config(R"({"max_ticks": 1})", "gfx942", 64, nonexistent)); errno = 0; @@ -524,10 +609,24 @@ TEST(GuestKfdFailureTest, PathOnlyAutomaticDbtHandoffFailsClosed) { } TEST(GuestKfdConfigTest, ReadsRuntimeHandoffLargerThan4095Bytes) { + // Consulted before anything is installed: a runtime directory too deep to host the + // oversized handoff is also too deep to host the ordinary config the install below + // writes, so checking only at relocation time would surface that environmental limit + // as a red install and the skip would never be reached. + if (const LongPathHandoff supported = long_path_handoff_supported(); + supported.status() == LongPathHandoff::Status::kSkip) + GTEST_SKIP() << "cannot build the oversized handoff here: " << supported.reason(); + const auto simulator_json = read_active_config_json(); ASSERT_TRUE(simulator_json.has_value()); ASSERT_TRUE(install_inline_dbt_config(*simulator_json, "gfx942", 64)); - ASSERT_TRUE(relocate_active_config_to_long_path()); + + // A second skip site rather than a redundant one: the relocation can still find the + // filesystem itself unable to hold the path, which the gate above cannot predict. + const LongPathHandoff handoff = relocate_active_config_to_long_path(); + if (handoff.status() == LongPathHandoff::Status::kSkip) + GTEST_SKIP() << "cannot build the oversized handoff here: " << handoff.reason(); + ASSERT_TRUE(handoff.status() == LongPathHandoff::Status::kOk) << handoff.reason(); const int fd = open(kKfdPath, O_RDWR | O_CLOEXEC); ASSERT_GE(fd, 0) << std::strerror(errno); @@ -535,6 +634,28 @@ TEST(GuestKfdConfigTest, ReadsRuntimeHandoffLargerThan4095Bytes) { close(fd); } +// Pins the skip half of the classification above. A $ROCJITSU_INVOCATION_DIR deeper +// than the path the helper builds is a limit of where the test runs, not a defect in +// the handoff reader, so the helper must report kSkip with a reason rather than red. +// Nothing is created on disk: config_handoff_dir() falls back to the highest-priority +// tier when no tier holds a handoff, so a directory that does not exist is enough. +TEST(GuestKfdConfigTest, LongPathHandoffSkipsWhenRuntimeDirIsTooDeep) { + // 4082 bytes: one past the 4081-byte parent the helper targets, and still short + // enough that the kernel would accept it as a path. + std::string too_deep; + for (int i = 0; i < 20; ++i) + too_deep += "/" + std::string(200, 'd'); + too_deep += "/" + std::string(61, 'd'); + ASSERT_EQ(too_deep.size(), 4082u); + + const rocjitsu::test::ScopedEnvironmentVariable invocation_dir("ROCJITSU_INVOCATION_DIR", + too_deep); + const LongPathHandoff handoff = relocate_active_config_to_long_path(); + + EXPECT_TRUE(handoff.status() == LongPathHandoff::Status::kSkip) << handoff.reason(); + EXPECT_FALSE(handoff.reason().empty()); +} + // Overwriting the interposer's hidden real /dev/kfd fd number via dup2 must not // leave a stale KFD classification behind. In guest/DBT mode open("/dev/kfd") // returns an app-facing dup while the real fd stays internal; if some dup2/dup3 diff --git a/emulation/rocjitsu/tests/scoped_temp.h b/emulation/rocjitsu/tests/scoped_temp.h index c4961fdbec1e..f839001853fe 100644 --- a/emulation/rocjitsu/tests/scoped_temp.h +++ b/emulation/rocjitsu/tests/scoped_temp.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -109,4 +110,32 @@ class ScopedTempDirectory { std::string path_; }; +/// @brief Sets an environment variable for the current scope and restores it on exit. +/// @details Tests that steer config discovery through the environment share one process +/// with every other test in the binary, so an unrestored variable silently changes what +/// later tests resolve. Restoring means putting back the original value, or unsetting the +/// variable again when it was absent to begin with. +class ScopedEnvironmentVariable { +public: + ScopedEnvironmentVariable(const char *name, const std::string &value) : name_(name) { + if (const char *original = std::getenv(name)) + original_ = original; + setenv(name_.c_str(), value.c_str(), 1); + } + + ~ScopedEnvironmentVariable() { + if (original_) + setenv(name_.c_str(), original_->c_str(), 1); + else + unsetenv(name_.c_str()); + } + + ScopedEnvironmentVariable(const ScopedEnvironmentVariable &) = delete; + ScopedEnvironmentVariable &operator=(const ScopedEnvironmentVariable &) = delete; + +private: + std::string name_; + std::optional original_; +}; + } // namespace rocjitsu::test From 704314be75036c21322f96ac2e5f92a855b0814e Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 21:49:57 +0000 Subject: [PATCH 28/31] refactor(rocjitsu): name the unenumerable case in the gfx1250 A0 probe any_agent_could_be_a0() folded "an A0 part was found" and "agent enumeration failed" into one bool. Both correctly refuse the load, so the behaviour was already fail-safe -- but when a co-loaded HSA tool fails enumeration, the refusal is reported as an A0 incompatibility and the real cause is invisible. Split the verdict into kNone / kPossible / kUnenumerable so the last can log what actually happened. Both refusal branches still return HSA_STATUS_ERROR_INCOMPATIBLE_ARGUMENTS, so no caller observes a change. Co-Authored-By: Claude Opus 5 (1M context) --- .../rocjitsu/hooks/gfx1250_b0_a0_hotswap.cpp | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/gfx1250_b0_a0_hotswap.cpp b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/gfx1250_b0_a0_hotswap.cpp index 25bb9e95e457..7666bfb6bde6 100644 --- a/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/gfx1250_b0_a0_hotswap.cpp +++ b/emulation/rocjitsu/lib/rocjitsu/src/rocjitsu/hooks/gfx1250_b0_a0_hotswap.cpp @@ -456,10 +456,18 @@ AgentStepping classify_agent(const OriginalApi &api, hsa_agent_t agent) { return revision == 0 ? AgentStepping::kA0 : AgentStepping::kB0OrLater; } -bool any_agent_could_be_a0(const OriginalApi &api) { +/// @brief Whether an agent-less load could land on a gfx1250 A0. +/// @details kUnenumerable is a separate verdict only so the refusal can name agent +/// enumeration as the cause; it is treated exactly like kPossible by callers. A tool +/// loaded ahead of this hook in HSA_TOOLS_LIB owns hsa_iterate_agents_fn (this hook +/// never patches it), so a failing enumeration usually means that tool refused, not +/// that the machine has no agents. +enum class A0Risk { kNone, kPossible, kUnenumerable }; + +A0Risk assess_a0_risk(const OriginalApi &api) { auto *iterate = api.iterate_agents; if (iterate == nullptr) - return true; + return A0Risk::kUnenumerable; struct IterateData { const OriginalApi *api; bool found = false; @@ -475,7 +483,11 @@ bool any_agent_could_be_a0(const OriginalApi &api) { return HSA_STATUS_SUCCESS; }, &data); - return data.found || (status != HSA_STATUS_SUCCESS && status != HSA_STATUS_INFO_BREAK); + if (data.found) + return A0Risk::kPossible; + if (status != HSA_STATUS_SUCCESS && status != HSA_STATUS_INFO_BREAK) + return A0Risk::kUnenumerable; + return A0Risk::kNone; } hsa_status_t load_owned_bytes(hsa_executable_t executable, hsa_agent_t agent, const Blob &bytes, @@ -697,7 +709,15 @@ hsa_status_t HSA_API load_program_code_object(hsa_executable_t executable, if (api == nullptr) return HSA_STATUS_ERROR; auto *original = api->load_program; - if (any_agent_could_be_a0(*api)) + const A0Risk risk = assess_a0_risk(*api); + // Same status either way -- callers must not have to distinguish these -- but the + // unenumerable case is logged separately so a co-loaded tool that refuses agent + // iteration is not misread as an A0 incompatibility. + if (risk == A0Risk::kUnenumerable) { + log("agent enumeration failed; refusing agent-less program load"); + return HSA_STATUS_ERROR_INCOMPATIBLE_ARGUMENTS; + } + if (risk == A0Risk::kPossible) return HSA_STATUS_ERROR_INCOMPATIBLE_ARGUMENTS; return original(executable, reader, options, loaded); }); From efa146b58ad0b1a8cf52b54e54d8e17b4871cfbb Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 21:50:13 +0000 Subject: [PATCH 29/31] docs(rocjitsu): scope the no-fprintf rule to its real carve-outs CONTRIBUTING.md and docs/style.md both stated a blanket ban on fprintf, printf and std::cerr that the tree has never followed: the hook layer has ~30 tagged stderr diagnostics on hard-error paths, tools/ writes user-facing errors to std::cerr, and the signal backtrace handler uses raw ::write because it must stay async-signal-safe. The ban was also unactionable as written. Logger::print<>() and the group helpers compile to nothing unless RJ_LOG_GROUPS names the group at configure time, which defaults to OFF, so routing a fatal diagnostic through them silences it in every shipped build. Logger::warn() is the always-on channel and the docs never said so. State the actual rule -- group helpers for tracing, Logger::warn() for anything audible by default -- and name the two carve-outs with their reasons rather than pretending they do not exist. Also record that the DBT hook and the gfx1250 hotswap hook are mutually exclusive in HSA_TOOLS_LIB: both patch the same four code-object entries, nothing enforces load order, and neither exports HSA_AMD_TOOL_PRIORITY. Co-Authored-By: Claude Opus 5 (1M context) --- emulation/rocjitsu/CONTRIBUTING.md | 19 ++++++++-- emulation/rocjitsu/docs/rocjitsu_dbt_guest.md | 37 +++++++++++++++++++ emulation/rocjitsu/docs/style.md | 13 +++++-- 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/emulation/rocjitsu/CONTRIBUTING.md b/emulation/rocjitsu/CONTRIBUTING.md index dcb84b2324b6..a6f7c4e212f8 100644 --- a/emulation/rocjitsu/CONTRIBUTING.md +++ b/emulation/rocjitsu/CONTRIBUTING.md @@ -36,9 +36,9 @@ Before writing new infrastructure, check these existing libraries: -- **Logging** — Use `util/log.h` (`Logger::print<>()`), never `fprintf` - or `std::cerr`. Build with or without tracing via the logger's - compile-time group filters. +- **Logging** — Use `util/log.h`: `Logger::print<>()` / group helpers for + tracing (compile-time filtered), `Logger::warn()` for always-on messages. + See the Code style section for the two `stderr` carve-outs. - **Bit manipulation** — `util/bit.h` and `util/bitfield.h` cover masks, extraction, insertion, and typed bitfield access. - **Data types** — `util/data_types.h` provides FP16/BF16/FP8/BF8/FP4/FP6 @@ -75,7 +75,18 @@ Before writing new infrastructure, check these existing libraries: - **Comments.** Use `@brief` / `@details` doxygen style when documenting public APIs. Avoid decorative comment lines (`---`, `===`). Default to writing no comments; only comment when the *why* is non-obvious. -- **No `fprintf`.** Always use `Logger` from `util/log.h`. +- **Logging.** Use `Logger` from `util/log.h`. `Logger::print<>()` and the + per-group helpers (`Logger::dbt_hooks()`, `Logger::vm()`, ...) compile to + nothing unless the group is enabled at configure time + (`-DRJ_LOG_GROUPS=...`, default `OFF`), so they are for tracing only. Use + `Logger::warn()` for anything that must be audible in a default build. Do + not add `fprintf`/`printf`/`std::cerr` to library code. Two existing + carve-outs: the CLI front end (`tools/`) writes user-facing errors to + `std::cerr`, and the ROCR-interposed hook layer + (`lib/rocjitsu/src/rocjitsu/hooks/`) writes fatal diagnostics straight to + `stderr` — it runs inside another runtime's `OnLoad`, its messages must + survive a log-groups-OFF build, and its signal-backtrace path must stay + async-signal-safe (`rj_hsa_dbt_hooks.cpp` uses raw `::write`). - **Exceptions.** Exceptions are used only for unrecoverable errors during initialization and configuration (`ConfigError`) or when encountering invalid/unimplemented instructions during code object diff --git a/emulation/rocjitsu/docs/rocjitsu_dbt_guest.md b/emulation/rocjitsu/docs/rocjitsu_dbt_guest.md index 4032ffe0eef8..2fc469db93ed 100644 --- a/emulation/rocjitsu/docs/rocjitsu_dbt_guest.md +++ b/emulation/rocjitsu/docs/rocjitsu_dbt_guest.md @@ -166,6 +166,32 @@ public identity: guest agent, guest ISA, guest name, guest memory pools execution identity: host agent, host queues, host memory backing, host code object ``` +### When the mapping cannot be resolved + +If DBT is enabled but discovery cannot pair a guest agent with the configured +host — no topology node for the configured `gpu_id`, ROCR never published both +agents, or only one agent satisfies both roles — the hook must not let the +application reach real silicon untranslated. It fails closed in two layers: + +- `hsa_iterate_agents` suppresses every **GPU** agent and returns success with + the CPU agents still published. A GPU client sees a well-formed empty device + list rather than an enumeration error, which HIP/CLR reports as a fatal ROCR + init failure instead of "no device". CPU agents pass through because host + fine-grained pools, `hsa_amd_memory_lock`, and host-side copies need them and + none of them can execute a guest kernel. +- The five GPU-only execution entry points (`hsa_queue_create`, + `hsa_amd_queue_intercept_create`, `hsa_executable_load_agent_code_object`, + `hsa_amd_agent_set_async_scratch_limit`, `hsa_amd_agent_preload`) return + `HSA_STATUS_ERROR_INVALID_AGENT`. Enumeration alone is not enough: a handle + cached before discovery failed, or one held by a co-loaded tool, still reaches + them. The query, pool, and copy hooks are deliberately **not** guarded — they + routinely take CPU agents, and with no queue and no loaded code object there + is no dispatch for them to enable. + +One case this cannot close: a tool listed *before* rocjitsu in `HSA_TOOLS_LIB` +holds the pre-patch table and never sees either layer. A tool loaded after +rocjitsu goes through the patched table and is covered. + ## Known Limits and Follow-Ups - File-backed HSA code-object readers are not translated in the MVP. Add a way @@ -173,6 +199,17 @@ execution identity: host agent, host queues, host memory backing, host code obje Today the hook prints a warning when such a reader is created, and guest loads without registered memory bytes fail rather than retrying an incompatible original ELF. +- `librocjitsu_hooks.so` and the gfx1250 B0/A0 hotswap hook + (`libhsa_hotswap_rocjitsu.so`) are mutually exclusive in `HSA_TOOLS_LIB`. + Both patch the same four code-object entries of the HSA core API table + (`hsa_code_object_reader_create_from_file`, + `hsa_code_object_reader_create_from_memory`, `hsa_executable_destroy`, + `hsa_executable_load_agent_code_object`), so whichever tool ROCR loads + second wraps the first one's wrappers. Nothing enforces or detects that + order: it follows only the order of names in `HSA_TOOLS_LIB`, and neither + library exports `HSA_AMD_TOOL_PRIORITY` to pin its slot. Load exactly one + of them. The supported launch path does this for you — `rocjitsu` *sets* + `HSA_TOOLS_LIB` to the DBT hook rather than appending to it. - `HSA_TOOLS_DISABLE_REGISTER=1` is a workaround. The better design is a rocprofiler-register API-table interposer that applies the same shadowing before rocprofiler validates HSA agents. diff --git a/emulation/rocjitsu/docs/style.md b/emulation/rocjitsu/docs/style.md index 3f9b597467ac..62582918a63d 100644 --- a/emulation/rocjitsu/docs/style.md +++ b/emulation/rocjitsu/docs/style.md @@ -135,9 +135,16 @@ Foo foo = static_cast(bar); // OK but could use auto instead to avoid repet ## Logging -- Never use `fprintf`, `printf`, or `std::cerr`. Use `Logger` from `util/log.h` - (`Logger::print<>()`), which supports compile-time group filters for building - with or without tracing. +- Use `Logger` from `util/log.h`. `Logger::print<>()` and the per-group helpers + are compiled out unless the group is enabled at configure time + (`cmake -DRJ_LOG_GROUPS=...`; the default is `OFF`, see `cmake/rj_log.cmake`), + so use them for tracing only. +- Use `Logger::warn()` for messages that must appear in a default build. +- Do not add `fprintf`, `printf`, or `std::cerr` to library code. Existing + carve-outs are the CLI front end (`tools/`, user-facing `std::cerr` errors) + and the ROCR hook layer (`lib/rocjitsu/src/rocjitsu/hooks/`, fatal `stderr` + diagnostics that must survive a log-groups-OFF build, plus an + async-signal-safe `::write` backtrace path). ## Error Handling From 150db094b6a4afab447c30ac7c096b729765fae7 Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Mon, 3 Aug 2026 22:00:24 +0000 Subject: [PATCH 30/31] test(rocjitsu): pin both handoff consumers at the same oversized path The runtime config handoff has two consumers with two independently written readers: the KFD interposer's raw read loop, and the HSA hook's load_dbt_guest_config_from_runtime_config(). Only the interposer ever had the fixed 4096-byte read that truncated a handoff whose config path approaches PATH_MAX, and only the interposer got a regression test for it -- GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes. That leaves the actual invariant untested. What matters is not that one reader survives a long handoff but that both resolve the same host GPU from it: the two layers picking different physical GPUs on a multi-GPU host is the failure this series has been closing off, and nothing compared the two readers' answers. The hook side reads through an unbounded std::ifstream, so this is coverage rather than a fix -- but coverage that fails loudly if either reader is ever rewritten. Add ConfigLoaderTest.ReadsRuntimeHandoffLargerThan4095Bytes, named to match its KFD counterpart, driving the same >4095-byte handoff through the hook consumer end to end and asserting the resolved gpu_id. Hoist the long-path construction into tests/long_path_handoff.h rather than writing it a second time. The padding arithmetic is delicate -- each component costs a separator plus a character, so a greedy loop can strand a remainder it cannot spend -- and a divergent copy could quietly stop being oversized on one side while both tests still passed. Sharing it also carries the tri-state verdict across, so the new test skips with a reason on a workspace already too deep for the path instead of going red for a reason that says nothing about the reader. Both halves now assert test::kOversizedHandoffHostGpuId, and the helper self-checks that the payload it built really does exceed 4095 bytes. On the KFD side the id is proven by bring-up: ensure_ready_locked() refuses a gpu_id that matches no host-ISA GPU in the topology, so a visible overlay means the interposer read that exact value. A static assert pins the constant to the launcher config supplying that topology, so changing one without the other fails at compile time rather than turning the overlay check into a tautology. Co-Authored-By: Claude Opus 5 (1M context) --- emulation/rocjitsu/tests/config_test.cpp | 35 ++++ emulation/rocjitsu/tests/guest_kfd_test.cpp | 190 ++++------------- emulation/rocjitsu/tests/long_path_handoff.h | 204 +++++++++++++++++++ 3 files changed, 274 insertions(+), 155 deletions(-) create mode 100644 emulation/rocjitsu/tests/long_path_handoff.h diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 7cd6f11bed75..ea87e6233985 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -3,6 +3,7 @@ #include "aql_queue.h" #include "halt_snapshot_plugin.h" +#include "long_path_handoff.h" #include "scoped_temp.h" #include "embedded_schema.h" @@ -677,6 +678,40 @@ TEST(ConfigLoaderTest, LoadsDbtRuntimeConfigHandoffFromInvocationDirectory) { EXPECT_EQ(loaded->host.gpu_id, 28851u); } +// The HSA-hook half of a pair. GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes drives +// the same oversized handoff through the other consumer -- the KFD interposer's raw read loop, +// which is where a fixed 4096-byte read once truncated it. This reader has always been an +// unbounded std::ifstream, so the case is coverage rather than a fix; what it locks down is that +// the two independent readers agree. Both are built by install_oversized_handoff() and both +// assert test::kOversizedHandoffHostGpuId, so a reader that starts resolving a different host +// GPU from identical bytes fails here or there instead of silently splitting the two layers +// onto different GPUs on a multi-GPU host. +TEST(ConfigLoaderTest, ReadsRuntimeHandoffLargerThan4095Bytes) { + const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-oversized-"); + + // Same treatment as the KFD-side test: a temp directory already deeper than the path being + // built is a limit of where the test runs, not a defect in the reader, so it must skip. + const test::LongPathHandoff handoff = test::install_oversized_handoff(runtime.path(), R"({ + "dbt_guest": { + "enabled": true, + "guest_isa": "gfx950", + "host_isa": "gfx942" + } + })"); + if (handoff.status() == test::LongPathHandoff::Status::kSkip) + GTEST_SKIP() << "cannot build the oversized handoff here: " << handoff.reason(); + ASSERT_TRUE(handoff.status() == test::LongPathHandoff::Status::kOk) << handoff.reason(); + + const test::ScopedEnvironmentVariable invocation_dir(rocjitsu::kRpcInvocationDirEnv, + runtime.path()); + const std::optional loaded = + config::load_dbt_guest_config_from_runtime_config(); + + ASSERT_TRUE(loaded); + EXPECT_TRUE(loaded->enabled); + EXPECT_EQ(loaded->host.gpu_id, test::kOversizedHandoffHostGpuId); +} + TEST(ConfigLoaderTest, RejectsPathOnlyHandoffForAutomaticDbtHost) { const test::ScopedTempDirectory runtime("rocjitsu-runtime-config-automatic-"); const auto config_file = write_temp_config(R"({ diff --git a/emulation/rocjitsu/tests/guest_kfd_test.cpp b/emulation/rocjitsu/tests/guest_kfd_test.cpp index c0343fd6c31c..19a3dc16bda4 100644 --- a/emulation/rocjitsu/tests/guest_kfd_test.cpp +++ b/emulation/rocjitsu/tests/guest_kfd_test.cpp @@ -3,6 +3,7 @@ #include +#include "long_path_handoff.h" #include "scoped_temp.h" #include "rocjitsu/base/rj_compiler.h" @@ -33,7 +34,6 @@ RJ_DIAGNOSTIC_POP #include #include #include -#include #include namespace { @@ -159,168 +159,34 @@ bool install_inline_dbt_config(std::string simulator_json, const char *host_isa, } } -/// @brief Verdict from building the oversized config handoff: ok, skip, or fail. -/// @details Three states rather than testing::AssertionResult's two, because not every -/// way of not producing the handoff is a defect. Some preconditions depend on the -/// caller's runtime directory rather than on the handoff reader under test: a -/// $ROCJITSU_INVOCATION_DIR already longer than the path being built says nothing about -/// whether the reader copes with a handoff past 4095 bytes, so it must skip rather than -/// go red. Every state carries a reason, so a skip is still reported and never silently -/// swallowed. GTEST_SKIP expands to a bare `return` and cannot be issued from a function -/// that returns a value, so the verdict travels back to the TEST body, which issues the -/// skip or the failure itself. -class LongPathHandoff { -public: - enum class Status { kOk, kSkip, kFail }; - - /// @brief The oversized handoff was built and installed. - static LongPathHandoff ok() { return LongPathHandoff(Status::kOk, {}); } - /// @brief This environment cannot host the path, so the reader is untestable here. - static LongPathHandoff skip(std::string reason) { - return LongPathHandoff(Status::kSkip, std::move(reason)); - } - /// @brief A genuine defect in the helper's arithmetic or in the run itself. - static LongPathHandoff fail(std::string reason) { - return LongPathHandoff(Status::kFail, std::move(reason)); - } - - [[nodiscard]] Status status() const { return status_; } - [[nodiscard]] const std::string &reason() const { return reason_; } - -private: - LongPathHandoff(Status status, std::string reason) - : status_(status), reason_(std::move(reason)) {} - - Status status_; - std::string reason_; -}; - -constexpr std::string_view kLongConfigFileName = "config.json"; -constexpr size_t kMaxComponentLength = 200; // stays well under NAME_MAX -constexpr size_t kTargetPathLength = 4093; -constexpr size_t kTargetParentLength = kTargetPathLength - kLongConfigFileName.size() - 1; - -// The handoff directory as a path to build beneath, with any trailing separator -// dropped: the padding arithmetic charges one separator per appended component, so a -// directory that already ends in one would be charged twice. A bare root keeps its -// separator, since dropping it would leave a relative path. -std::string normalized_handoff_parent(const std::filesystem::path &dir) { - std::string parent = dir.string(); - while (parent.size() > 1 && parent.back() == '/') - parent.pop_back(); - return parent; -} - -// Bytes a normalized parent contributes to a path built beneath it. A bare root is the -// separator the first component would otherwise have to pay for, so it contributes none. -size_t handoff_parent_length(const std::string &parent) { - return parent == "/" ? 0 : parent.size(); -} +using rocjitsu::test::LongPathHandoff; -/// @brief kSkip when the runtime directory is too deep to host the oversized handoff. -/// @details Split out of relocate_active_config_to_long_path() so the test can consult -/// it before it installs anything. A directory too deep for the >4095-byte path is also -/// too deep for the ordinary config the install writes, so a test that only checked at -/// relocation time would already have gone red on the install for the very same -/// environmental reason, and the skip would be unreachable. +/// @brief kSkip when this process's runtime directory is too deep to host the oversized handoff. +/// @details Consulted before anything is installed, so the environmental limit surfaces as a +/// skip rather than as a red install for the very same reason. LongPathHandoff long_path_handoff_supported() { - const std::filesystem::path dir = config_handoff_dir(); - const size_t parent_length = handoff_parent_length(normalized_handoff_parent(dir)); - - if (parent_length > kTargetParentLength) { - std::ostringstream reason; - reason << "handoff directory " << dir << " contributes " << parent_length - << " bytes, already past the " << kTargetParentLength << "-byte parent to build"; - return LongPathHandoff::skip(reason.str()); - } - if (kTargetParentLength - parent_length == 1) { - std::ostringstream reason; - reason << "handoff directory " << dir << " contributes " << parent_length - << " bytes, one short of " << kTargetParentLength - << ": no component fits in a separator plus one byte"; - return LongPathHandoff::skip(reason.str()); - } - return LongPathHandoff::ok(); + return rocjitsu::test::long_path_handoff_supported(config_handoff_dir()); } -// Move the active config to a path just under PATH_MAX so the handoff file the -// reader parses -- the path, a newline, and the resolved gpu_id line -- exceeds -// 4095 bytes and cannot be consumed by a single fixed 4096-byte read. -// -// The padding is planned up front rather than grown greedily. Each appended -// component costs one separator plus at least one character, so a greedy loop can -// land on a remainder it cannot spend: exactly on the target (where subtracting the -// separator underflows size_t and asks std::string for a SIZE_MAX-long component) -// or one byte short of it (where no legal component fits). Sizing the components -// from the byte budget keeps every intermediate length in range. The remaining -// unspendable cases -- a starting directory at or past the budget, or one byte short -// of it -- are the environmental ones long_path_handoff_supported() turns into kSkip. -// The self-check on the built path length and every write failure stay kFail, because -// those are defects in the helper or the run rather than in where it runs. +// Move the active config to a path just under PATH_MAX so the handoff the KFD interposer +// parses exceeds 4095 bytes. The path arithmetic and the resolved gpu_id both live in +// long_path_handoff.h, shared with the HSA-hook side of the same pairing +// (ConfigLoaderTest.ReadsRuntimeHandoffLargerThan4095Bytes), so the two consumers are handed +// bytes built the same way and pinned to the same host GPU. LongPathHandoff relocate_active_config_to_long_path() { - // Re-checked here rather than trusted from the caller, so the helper stays correct - // standalone and the budget arithmetic below cannot underflow. - if (LongPathHandoff supported = long_path_handoff_supported(); + const std::filesystem::path dir = config_handoff_dir(); + // Checked before the config is read so the skip-half test, which installs nothing, reaches + // the environmental verdict instead of failing on the missing config below. + if (LongPathHandoff supported = rocjitsu::test::long_path_handoff_supported(dir); supported.status() != LongPathHandoff::Status::kOk) return supported; - const std::filesystem::path dir = config_handoff_dir(); - - try { - const std::string parent = normalized_handoff_parent(dir); - const size_t parent_length = handoff_parent_length(parent); - const size_t pad_budget = kTargetParentLength - parent_length; - - // The caller asserts the config is installed before calling, so an unreadable - // one here is a defect in the run rather than a property of the environment. - const auto json = read_active_config_json(); - if (!json) - return LongPathHandoff::fail("no active config JSON to relocate"); - - std::filesystem::path config_dir(parent); - if (pad_budget > 0) { - // Fewest components that keep each within kMaxComponentLength, with the - // characters left after the separators spread as evenly as possible. - const size_t components = (pad_budget + kMaxComponentLength) / (kMaxComponentLength + 1); - const size_t chars = pad_budget - components; - for (size_t i = 0; i < components; ++i) - config_dir /= std::string(chars / components + (i < chars % components ? 1 : 0), 'a'); - } - - // Self-check on the sizing arithmetic above, deliberately before anything touches - // the filesystem: a miscomputed length must surface here as kFail, not as the - // ENAMETOOLONG the catch below would forgive as an environment limit. - const std::filesystem::path config_path = config_dir / kLongConfigFileName; - if (config_path.string().size() != kTargetPathLength) { - std::ostringstream reason; - reason << "built a " << config_path.string().size() << "-byte config path from a " - << parent_length << "-byte handoff directory, expected " << kTargetPathLength; - return LongPathHandoff::fail(reason.str()); - } - - std::filesystem::create_directories(config_dir); - std::ofstream config(config_path); - config << *json; - config.close(); - if (!config) - return LongPathHandoff::fail("failed to write config to the long path"); - - std::ofstream handoff(dir / "config_path", std::ios::trunc); - handoff << config_path.string() << "\n50148\n"; - if (!handoff.good()) - return LongPathHandoff::fail("failed to rewrite the config_path handoff"); - return LongPathHandoff::ok(); - } catch (const std::filesystem::filesystem_error &e) { - // The built path is already proven to be the intended length, so ENAMETOOLONG - // here means this filesystem refuses a path the kernel itself allows -- a shorter - // internal limit, or name-expanding encryption -- which is a property of where the - // test runs. Every other code (EACCES, ENOSPC) is a real failure. - std::ostringstream reason; - reason << "filesystem error building the " << kTargetPathLength << "-byte path: " << e.what(); - if (e.code() == std::errc::filename_too_long) - return LongPathHandoff::skip(reason.str()); - return LongPathHandoff::fail(reason.str()); - } + // The caller asserts the config is installed before calling, so an unreadable + // one here is a defect in the run rather than a property of the environment. + const auto json = read_active_config_json(); + if (!json) + return LongPathHandoff::fail("no active config JSON to relocate"); + return rocjitsu::test::install_oversized_handoff(dir, *json); } bool read_gpu_id(const std::string &path, uint32_t *gpu_id) { @@ -608,6 +474,10 @@ TEST(GuestKfdFailureTest, PathOnlyAutomaticDbtHandoffFailsClosed) { close(fd); } +// The KFD half of a pair. ConfigLoaderTest.ReadsRuntimeHandoffLargerThan4095Bytes drives the +// same oversized handoff through the other consumer -- the HSA hook's +// load_dbt_guest_config_from_runtime_config() -- and both pin test::kOversizedHandoffHostGpuId, +// so the two independent readers cannot resolve different host GPUs from the same bytes. TEST(GuestKfdConfigTest, ReadsRuntimeHandoffLargerThan4095Bytes) { // Consulted before anything is installed: a runtime directory too deep to host the // oversized handoff is also too deep to host the ordinary config the install below @@ -628,6 +498,16 @@ TEST(GuestKfdConfigTest, ReadsRuntimeHandoffLargerThan4095Bytes) { GTEST_SKIP() << "cannot build the oversized handoff here: " << handoff.reason(); ASSERT_TRUE(handoff.status() == LongPathHandoff::Status::kOk) << handoff.reason(); + // The overlay only comes up once GuestKfd::ensure_ready_locked() has found a topology GPU + // carrying the host ISA under the gpu_id it read out of this >4095-byte handoff; an + // unresolved or wrong id fails closed with ENODEV, as PathOnlyAutomaticDbtHandoffFailsClosed + // pins. So a visible guest overlay here says the interposer resolved + // test::kOversizedHandoffHostGpuId specifically -- which only holds while the launcher config + // this test runs under declares that same GPU, hence the pin. + static_assert(rocjitsu::test::kOversizedHandoffHostGpuId == 50148, + "must stay the gpu_id in configs/gfx942_cdna3_kmd.json, the topology this test " + "is launched against; otherwise bring-up below stops proving which id was read"); + const int fd = open(kKfdPath, O_RDWR | O_CLOEXEC); ASSERT_GE(fd, 0) << std::strerror(errno); EXPECT_TRUE(guest_gpu_is_visible()); diff --git a/emulation/rocjitsu/tests/long_path_handoff.h b/emulation/rocjitsu/tests/long_path_handoff.h new file mode 100644 index 000000000000..da6158ea1b2d --- /dev/null +++ b/emulation/rocjitsu/tests/long_path_handoff.h @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rocjitsu::test { + +/// @brief Resolved host KFD gpu_id every oversized runtime handoff in the test suite carries. +/// +/// @details The runtime config handoff has two consumers with two independent readers: the +/// KFD interposer's raw read loop and the HSA hook's std::ifstream reader. A handoff longer +/// than 4095 bytes must resolve to the same host GPU through both, so the tests that pin that +/// pairing -- GuestKfdConfigTest.ReadsRuntimeHandoffLargerThan4095Bytes and +/// ConfigLoaderTest.ReadsRuntimeHandoffLargerThan4095Bytes -- read the id from here instead of +/// each spelling its own. A value only one of them agreed to would let the two readers drift +/// apart without any test noticing. +inline constexpr uint32_t kOversizedHandoffHostGpuId = 50148; + +/// @brief Verdict from building the oversized config handoff: ok, skip, or fail. +/// @details Three states rather than testing::AssertionResult's two, because not every +/// way of not producing the handoff is a defect. Some preconditions depend on the +/// caller's runtime directory rather than on the handoff reader under test: a directory +/// already longer than the path being built says nothing about whether the reader copes +/// with a handoff past 4095 bytes, so it must skip rather than go red. Every state carries +/// a reason, so a skip is still reported and never silently swallowed. GTEST_SKIP expands +/// to a bare `return` and cannot be issued from a function that returns a value, so the +/// verdict travels back to the TEST body, which issues the skip or the failure itself. +class LongPathHandoff { +public: + enum class Status { kOk, kSkip, kFail }; + + /// @brief The oversized handoff was built and installed. + static LongPathHandoff ok() { return LongPathHandoff(Status::kOk, {}); } + /// @brief This environment cannot host the path, so the reader is untestable here. + static LongPathHandoff skip(std::string reason) { + return LongPathHandoff(Status::kSkip, std::move(reason)); + } + /// @brief A genuine defect in the helper's arithmetic or in the run itself. + static LongPathHandoff fail(std::string reason) { + return LongPathHandoff(Status::kFail, std::move(reason)); + } + + [[nodiscard]] Status status() const { return status_; } + [[nodiscard]] const std::string &reason() const { return reason_; } + +private: + LongPathHandoff(Status status, std::string reason) + : status_(status), reason_(std::move(reason)) {} + + Status status_; + std::string reason_; +}; + +inline constexpr std::string_view kLongConfigFileName = "config.json"; +inline constexpr size_t kMaxComponentLength = 200; // stays well under NAME_MAX +inline constexpr size_t kTargetPathLength = 4093; +inline constexpr size_t kTargetParentLength = kTargetPathLength - kLongConfigFileName.size() - 1; + +/// @brief Smallest handoff the built payload must reach to be past a single 4096-byte read. +inline constexpr size_t kMinHandoffPayloadSize = 4096; + +/// @brief The handoff directory as a path to build beneath, with any trailing separator +/// dropped: the padding arithmetic charges one separator per appended component, so a +/// directory that already ends in one would be charged twice. A bare root keeps its +/// separator, since dropping it would leave a relative path. +inline std::string normalized_handoff_parent(const std::filesystem::path &dir) { + std::string parent = dir.string(); + while (parent.size() > 1 && parent.back() == '/') + parent.pop_back(); + return parent; +} + +/// @brief Bytes a normalized parent contributes to a path built beneath it. A bare root is the +/// separator the first component would otherwise have to pay for, so it contributes none. +inline size_t handoff_parent_length(const std::string &parent) { + return parent == "/" ? 0 : parent.size(); +} + +/// @brief kSkip when @p dir is too deep to host the oversized handoff. +/// @details Split out of install_oversized_handoff() so a test can consult it before it +/// installs anything. A directory too deep for the >4095-byte path is also too deep for the +/// ordinary config an install writes first, so a test that only checked at install time would +/// already have gone red for the very same environmental reason, and the skip would be +/// unreachable. +inline LongPathHandoff long_path_handoff_supported(const std::filesystem::path &dir) { + const size_t parent_length = handoff_parent_length(normalized_handoff_parent(dir)); + + if (parent_length > kTargetParentLength) { + std::ostringstream reason; + reason << "handoff directory " << dir << " contributes " << parent_length + << " bytes, already past the " << kTargetParentLength << "-byte parent to build"; + return LongPathHandoff::skip(reason.str()); + } + if (kTargetParentLength - parent_length == 1) { + std::ostringstream reason; + reason << "handoff directory " << dir << " contributes " << parent_length + << " bytes, one short of " << kTargetParentLength + << ": no component fits in a separator plus one byte"; + return LongPathHandoff::skip(reason.str()); + } + return LongPathHandoff::ok(); +} + +/// @brief Write @p config_json at a path just under PATH_MAX and point @p dir's handoff at it. +/// +/// @details The handoff the reader parses -- the config path, a newline, and +/// kOversizedHandoffHostGpuId -- then exceeds 4095 bytes and cannot be consumed by a single +/// fixed 4096-byte read. Shared by both consumers' tests so the bytes each one is handed are +/// built by the same arithmetic; a second copy of this could drift and quietly stop being +/// oversized on one side. +/// +/// The padding is planned up front rather than grown greedily. Each appended component costs +/// one separator plus at least one character, so a greedy loop can land on a remainder it +/// cannot spend: exactly on the target (where subtracting the separator underflows size_t and +/// asks std::string for a SIZE_MAX-long component) or one byte short of it (where no legal +/// component fits). Sizing the components from the byte budget keeps every intermediate length +/// in range. The remaining unspendable cases -- a starting directory at or past the budget, or +/// one byte short of it -- are the environmental ones long_path_handoff_supported() turns into +/// kSkip. The self-checks on the built path length and on the handoff size, and every write +/// failure, stay kFail, because those are defects in the helper or the run rather than in where +/// it runs. +inline LongPathHandoff install_oversized_handoff(const std::filesystem::path &dir, + std::string_view config_json) { + // Re-checked here rather than trusted from the caller, so the helper stays correct + // standalone and the budget arithmetic below cannot underflow. + if (LongPathHandoff supported = long_path_handoff_supported(dir); + supported.status() != LongPathHandoff::Status::kOk) + return supported; + + try { + const std::string parent = normalized_handoff_parent(dir); + const size_t parent_length = handoff_parent_length(parent); + const size_t pad_budget = kTargetParentLength - parent_length; + + std::filesystem::path config_dir(parent); + if (pad_budget > 0) { + // Fewest components that keep each within kMaxComponentLength, with the + // characters left after the separators spread as evenly as possible. + const size_t components = (pad_budget + kMaxComponentLength) / (kMaxComponentLength + 1); + const size_t chars = pad_budget - components; + for (size_t i = 0; i < components; ++i) + config_dir /= std::string(chars / components + (i < chars % components ? 1 : 0), 'a'); + } + + // Self-check on the sizing arithmetic above, deliberately before anything touches + // the filesystem: a miscomputed length must surface here as kFail, not as the + // ENAMETOOLONG the catch below would forgive as an environment limit. + const std::filesystem::path config_path = config_dir / kLongConfigFileName; + if (config_path.string().size() != kTargetPathLength) { + std::ostringstream reason; + reason << "built a " << config_path.string().size() << "-byte config path from a " + << parent_length << "-byte handoff directory, expected " << kTargetPathLength; + return LongPathHandoff::fail(reason.str()); + } + + std::filesystem::create_directories(config_dir); + std::ofstream config(config_path); + config << config_json; + config.close(); + if (!config) + return LongPathHandoff::fail("failed to write config to the long path"); + + std::ostringstream payload; + payload << config_path.string() << '\n' << kOversizedHandoffHostGpuId << '\n'; + // The point of the whole exercise, so it is checked rather than assumed: a handoff that + // fits in one 4096-byte read would still pass both tests while proving nothing. + if (payload.str().size() < kMinHandoffPayloadSize) { + std::ostringstream reason; + reason << "built a " << payload.str().size() << "-byte handoff, expected at least " + << kMinHandoffPayloadSize; + return LongPathHandoff::fail(reason.str()); + } + + std::filesystem::create_directories(dir); + std::ofstream handoff(dir / "config_path", std::ios::trunc); + handoff << payload.str(); + if (!handoff.good()) + return LongPathHandoff::fail("failed to rewrite the config_path handoff"); + return LongPathHandoff::ok(); + } catch (const std::filesystem::filesystem_error &error) { + // The built path is already proven to be the intended length, so ENAMETOOLONG + // here means this filesystem refuses a path the kernel itself allows -- a shorter + // internal limit, or name-expanding encryption -- which is a property of where the + // test runs. Every other code (EACCES, ENOSPC) is a real failure. + std::ostringstream reason; + reason << "filesystem error building the " << kTargetPathLength + << "-byte path: " << error.what(); + if (error.code() == std::errc::filename_too_long) + return LongPathHandoff::skip(reason.str()); + return LongPathHandoff::fail(reason.str()); + } +} + +} // namespace rocjitsu::test From 1d3ea5cf3947931349886e5fd4814091920cee3f Mon Sep 17 00:00:00 2001 From: "Rosa, Ada" Date: Tue, 4 Aug 2026 15:11:05 +0000 Subject: [PATCH 31/31] chore: fmt --- emulation/rocjitsu/tests/config_test.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/emulation/rocjitsu/tests/config_test.cpp b/emulation/rocjitsu/tests/config_test.cpp index 98fa3d01236c..b8ca989fba03 100644 --- a/emulation/rocjitsu/tests/config_test.cpp +++ b/emulation/rocjitsu/tests/config_test.cpp @@ -33,8 +33,8 @@ RJ_DIAGNOSTIC_POP #include #include #include -#include #include +#include #include #include #include