Fix GPU device index resolution and init/shutdown lifecycle races - #8
Fix GPU device index resolution and init/shutdown lifecycle races#8vancraar wants to merge 4 commits into
Conversation
gpu_amd_hardware_sampler used the HIP-relative device index directly as the ROCm SMI index for every rsmi_dev_* call. ROCm SMI's own device enumeration isn't filtered by HIP_VISIBLE_DEVICES/ROCR_VISIBLE_DEVICES the way HIP's is, so under a non-default visibility mask the sampler measured the wrong physical GPU. Fix: resolve the actual ROCm SMI index once at construction via a PCI-bus-ID match against every ROCm SMI device, throwing if no match is found instead of silently falling back to the HIP-relative index. The original HIP-relative index is kept in a new hip_device_id_ member for the one HIP call that still needs HIP-space. Also adds the missing find_dependency(MPI) to hwsConfig.cmake.in, alongside the existing ones for CUDA/HIP/ROCm-SMI/Level-Zero.
The constructor's rollback on a failed device resolution kept ROCm SMI's shared instances_/init_finished_ lifecycle keyed off is_first_instance. Under concurrent construction of several gpu_amd_hardware_sampler instances, that could shut ROCm SMI down while a sibling instance was still using it, or leave a sibling stuck forever waiting on init_finished_. Fixed by mirroring the destructor's own "last instance out" check (instances_ reaching zero, and only if ROCm SMI was actually initialized) instead, and by moving rsmi_init() itself into the try block so its own failure is handled the same way. resolve_rsmi_device_id() also silently used the first ROCm SMI device whose PCI bus ID matched. bdfid_to_pci_bus_id() intentionally drops the BDFID's partition bits, so on a partitioned MI-series accelerator several ROCm SMI entries can share one normalized bus ID - silently picking the first would reintroduce the exact silently-wrong-device failure mode this fix exists to close. Now throws on an ambiguous match instead. Also drops the device_id()/hip_device_id()/pci_bus_id() getters and enumerate_all_amd_gpu_pci_bus_ids(): unused on this branch, they only exist to serve the accel<->GPU correlation-hints feature that stays on feature/cray-pm-counters.
gpu_nvidia_hardware_sampler had the same bug just fixed for AMD: nvmlDeviceGetHandleByIndex() was called with the raw CUDA-relative device index. NVML's own device enumeration is not filtered by CUDA_VISIBLE_DEVICES the way CUDA's is, so under a non-default visibility mask the sampler silently measured the wrong physical GPU. Confirmed on real hardware (2x NVIDIA RTX 3090): masking CUDA_VISIBLE_DEVICES to the second GPU and constructing with CUDA-relative index 0 resolved to the first physical GPU before this fix, and correctly to the second after. Fix mirrors gpu_amd_hardware_sampler's resolve_rsmi_device_id(): resolve the true NVML index once at construction via a PCI-bus-ID match against every NVML device, throwing on zero or more than one match instead of guessing. NVIDIA MIG instances are not disambiguated by this - NVML's device enumeration only exposes physical parent GPUs, not MIG device handles, so a MIG-sliced CUDA device still resolves to (and samples) its whole parent GPU; documented as a known limitation rather than silently claimed as handled. The pre-existing instances_/init_finished_ busy-wait (shared by both the AMD and NVIDIA backends, unchanged by either fix) can still deadlock a concurrent waiter if the first instance's rsmi_init()/nvmlInit() call itself fails, or race a destructor's shutdown against a new constructor's init. Both are latent, pre-existing issues unreachable through hws's own API (system_hardware_sampler constructs sequentially) - tracked separately rather than fixed here to keep this change scoped to device index resolution.
gpu_amd_hardware_sampler, gpu_nvidia_hardware_sampler and
gpu_intel_hardware_sampler each used an atomic instances_ counter plus an
atomic init_finished_ flag to lazily call rsmi_init()/nvmlInit()/zeInit()
exactly once, with later constructors busy-waiting on the flag. This has
two bugs, both pre-existing (unrelated to and unchanged by the device
index resolution fixes elsewhere on this branch):
- if the first instance's init call itself throws, init_finished_ never
becomes true, and any other constructor already spinning on
`while (!init_finished_) {}` blocks forever - the failure is never
signaled to waiters.
- a destructor decrementing instances_ to zero and beginning
rsmi_shut_down()/nvmlShutdown() can race a new constructor concurrently
incrementing from zero and calling rsmi_init()/nvmlInit() again - the
atomics serialize the counter, not the actual init/shutdown calls.
Both are only reachable by constructing multiple samplers of the same
backend concurrently from different threads - system_hardware_sampler
itself always constructs sequentially - but they're part of the public
API surface (nothing stops a caller from doing this directly).
Fix: replace the atomic counter/flag pair with a single mutex that's held
for the whole "is this the first/last instance?" decision plus the actual
init/shutdown call, for all three backends. This makes the decision and
the call one atomic step, so:
- a failing init call leaves the shared count untouched and the mutex
unlocked - the next constructor to acquire it simply retries the init
call itself instead of ever spinning on a flag that might never be set.
- a shutdown and a concurrent init can no longer interleave, since both
hold the same mutex around their respective counter check and call.
gpu_intel_hardware_sampler has no equivalent shut down call (Level Zero
has none), so it only needed the simpler "has zeInit() ever succeeded"
half of this - a single mutex-guarded bool instead of a counter.
Verified with a 16-thread x 200-iteration concurrent construct/destruct
stress test against the NVIDIA backend on real hardware (2x RTX 3090):
0 failures, no hangs. The AMD and Intel changes are the identical pattern
with different underlying API calls, reviewed by inspection but not
independently stress-tested on real ROCm/Level-Zero hardware this round.
| nvmlPciInfo_st pcie_info{}; | ||
| if (nvmlDeviceGetHandleByIndex_v2(nvml_idx, &device) == NVML_SUCCESS && nvmlDeviceGetPciInfo_v3(device, &pcie_info) == NVML_SUCCESS) { | ||
| const std::string nvml_bus_id = detail::format_pci_bus_id(static_cast<std::uint32_t>(pcie_info.domain), static_cast<std::uint32_t>(pcie_info.bus), static_cast<std::uint32_t>(pcie_info.device)); | ||
| if (nvml_bus_id == cuda_bus_id) { |
There was a problem hiding this comment.
The string comparison here must not be case-sensitive.
detail::format_pci_bus_id returns the bus ID in lowercase (e.g., 0000:c1:00.0).
detail::nvidia_device_pci_bus_id uses cudaDeviceGetPCIBusId which returns it in uppercase (e.g., 0000:C1:00.0). As a result, the bus IDs will not be matched here, even though they should be matched.
| std::optional<std::uint32_t> resolved{}; | ||
| for (std::uint32_t rsmi_idx = 0; rsmi_idx < rsmi_count; ++rsmi_idx) { | ||
| std::uint64_t bdfid{}; | ||
| if (rsmi_dev_pci_id_get(rsmi_idx, &bdfid) == RSMI_STATUS_SUCCESS && bdfid_to_pci_bus_id(bdfid) == hip_bus_id) { |
There was a problem hiding this comment.
Same comment as for the NVIDIA device ID resolution.
Even though this might work fine here for AMD, I would still use a non-case-sensitive comparison for the bus IDs
|
|
||
| std::string nvidia_device_pci_bus_id(const int local_index) { | ||
| char bus_id[64] = {}; | ||
| HWS_CUDA_ERROR_CHECK(cudaDeviceGetPCIBusId(bus_id, sizeof(bus_id), local_index)); |
There was a problem hiding this comment.
HWS_CUDA_ERROR_CHECK only checks the return value when HWS_ENABLE_ERROR_CHECKS is enabled via cmake.
Since this function call is critical to correctness, we should check whether it succeeded every time, regardless of how HWS_ENABLE_ERROR_CHECKS is set.
|
|
||
| std::string amd_device_pci_bus_id(const int local_index) { | ||
| char bus_id[64] = {}; | ||
| HWS_HIP_ERROR_CHECK(hipDeviceGetPCIBusId(bus_id, sizeof(bus_id), local_index)); |
There was a problem hiding this comment.
Same comment as for the NVIDIA backend:
HWS_HIP_ERROR_CHECK only checks for correctness when HWS_ENABLE_ERROR_CHECKS is enabled via CMake. We should check here every time.
Problem 1: ROCm SMI device index resolution (AMD)
gpu_amd_hardware_sampler used the HIP-relative device index directly as
the ROCm SMI (rsmi_dev_*) index. ROCm SMI's own device enumeration is not
filtered by HIP_VISIBLE_DEVICES/ROCR_VISIBLE_DEVICES the way HIP's is, so
under a non-default visibility mask the sampler silently measured the
wrong physical GPU.
Fix: resolve the true ROCm SMI index once at construction via a
PCI-bus-ID match against every ROCm SMI device (resolve_rsmi_device_id()
in src/hws/gpu_amd/hardware_sampler.cpp), throwing on zero or more than
one match instead of guessing.
Problem 2: NVML device index resolution (NVIDIA)
Same bug, same fix, on the NVIDIA backend: gpu_nvidia_hardware_sampler
passed the CUDA-relative index directly to nvmlDeviceGetHandleByIndex(),
whose enumeration NVML doesn't filter by CUDA_VISIBLE_DEVICES either.
Fix mirrors the AMD one: resolve_nvml_device_id() in
src/hws/gpu_nvidia/hardware_sampler.cpp.
Problem 3: init/shutdown lifecycle race (all three GPU backends)
gpu_amd_hardware_sampler, gpu_nvidia_hardware_sampler and
gpu_intel_hardware_sampler each used an atomic counter plus an atomic
flag to lazily call rsmi_init()/nvmlInit()/zeInit() exactly once, with
later constructors busy-waiting on the flag. This has two bugs,
pre-existing and unrelated to problems 1/2 above:
becomes true, and any waiting constructor spins forever.
new constructor concurrently incrementing from zero and calling init
again - the atomics serialize the counter, not the actual calls.
Both are only reachable by constructing multiple samplers of the same
backend concurrently from different threads (system_hardware_sampler
itself always constructs sequentially), but they're part of the public
API surface.
Fix: replace the atomic counter/flag pair with a single mutex held for
the whole check-plus-call in both constructor and destructor, for all
three backends. gpu_intel_hardware_sampler has no shutdown call (Level
Zero has none), so it only needed the simpler "has zeInit() ever
succeeded" half of this.
Known limitations
Partitioned MI-series accelerators (SPX/CPX modes) and NVIDIA MIG
instances: both resolvers throw on an ambiguous PCI-bus-ID match rather
than guessing, but neither resolves a specific partition/instance. Not
exercised on real partitioned/MIG hardware.
Scope note
This was split out of feature/cray-pm-counters, where the AMD fix was
originally developed entangled with an accel<->GPU correlation-hints
feature. That branch still contains its own copy of the AMD fix; once
this PR merges, feature/cray-pm-counters will be rebased onto the new
develop to drop the duplicate before its own PR opens.