diff --git a/quadrants/rhi/amdgpu/amdgpu_driver.cpp b/quadrants/rhi/amdgpu/amdgpu_driver.cpp index 76f528ac12..809e770019 100644 --- a/quadrants/rhi/amdgpu/amdgpu_driver.cpp +++ b/quadrants/rhi/amdgpu/amdgpu_driver.cpp @@ -6,6 +6,41 @@ namespace quadrants { namespace lang { +namespace { + +AmdgpuLaunchFailureHook g_amdgpu_launch_failure_hook = nullptr; +bool g_amdgpu_device_assert_already_surfaced = false; +bool g_amdgpu_device_in_teardown = false; + +} // namespace + +void set_amdgpu_launch_failure_hook(AmdgpuLaunchFailureHook hook) { + g_amdgpu_launch_failure_hook = hook; +} + +AmdgpuLaunchFailureHook get_amdgpu_launch_failure_hook() { + return g_amdgpu_launch_failure_hook; +} + +bool amdgpu_device_assert_already_surfaced() { + return g_amdgpu_device_assert_already_surfaced; +} + +void amdgpu_reset_device_assert_surfaced_flag() { + g_amdgpu_device_assert_already_surfaced = false; +} + +void amdgpu_mark_device_assert_surfaced() { + g_amdgpu_device_assert_already_surfaced = true; +} + +bool amdgpu_device_in_teardown() { + return g_amdgpu_device_in_teardown; +} + +void amdgpu_set_device_in_teardown(bool in_teardown) { + g_amdgpu_device_in_teardown = in_teardown; +} std::string get_amdgpu_error_message(uint32 err) { auto err_name_ptr = AMDGPUDriver::get_instance_without_context().get_error_name(err); diff --git a/quadrants/rhi/amdgpu/amdgpu_driver.h b/quadrants/rhi/amdgpu/amdgpu_driver.h index 1ec5f26634..5829899e40 100644 --- a/quadrants/rhi/amdgpu/amdgpu_driver.h +++ b/quadrants/rhi/amdgpu/amdgpu_driver.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "quadrants/common/dynamic_loader.h" @@ -37,10 +38,33 @@ constexpr uint32 HIP_DEVICE_MINOR = 332 / 4; // offsetof(hipDeviceProp_t, minor) / 4 constexpr uint32 HIP_DEVICE_MINOR_6 = 364 / 4; constexpr uint32 HIP_ERROR_ASSERT = 710; +// hipErrorLaunchFailure - returned by the first hipStreamSynchronize after a device +// __builtin_trap() (AMDGPU in-kernel assert path). Catchable; does not abort the process. +constexpr uint32 HIP_ERROR_LAUNCH_FAILURE = 719; constexpr uint32 HIP_JIT_MAX_REGISTERS = 0; constexpr uint32 HIP_POINTER_ATTRIBUTE_MEMORY_TYPE = 2; constexpr uint32 HIP_SUCCESS = 0; constexpr uint32 HIP_MEMORYTYPE_DEVICE = 1; +// hipHostMallocCoherent - required for host-visible reads of assert state after a trap. +constexpr uint32 HIP_HOST_MALLOC_COHERENT = 0x40000000; + +// Optional hook invoked from AMDGPUFunction::operator() on HIP_ERROR_LAUNCH_FAILURE before the +// generic QD_ERROR. LlvmRuntimeExecutor registers this in debug+amdgpu mode to surface +// QuadrantsAssertionError from pinned assert state. May throw. +using AmdgpuLaunchFailureHook = void (*)(); +void set_amdgpu_launch_failure_hook(AmdgpuLaunchFailureHook hook); +AmdgpuLaunchFailureHook get_amdgpu_launch_failure_hook(); +// True after a debug-mode device assert has been surfaced as QuadrantsAssertionError. +// The HIP context is dead afterward, so subsequent calls also return launch failure. +bool amdgpu_device_assert_already_surfaced(); +void amdgpu_reset_device_assert_surfaced_flag(); +void amdgpu_mark_device_assert_surfaced(); +// True while a Program/executor is tearing down. Dead-context launch failures are only +// suppressed inside this window (or while unwinding the just-thrown assertion) so destructors +// do not std::terminate(); outside it, post-assert launch failures are surfaced as hard errors +// instead of silently reported as success on a dead context. Set by LlvmRuntimeExecutor. +bool amdgpu_device_in_teardown(); +void amdgpu_set_device_in_teardown(bool in_teardown); // `hipFuncAttributeMaxDynamicSharedMemorySize` from the `hipFuncAttribute` enum in ROCm/clr // hipamd/include/hip/hip_runtime_api.h. Used with `kernel_set_attribute` (`hipFuncSetAttribute`) to opt in to >48 KB // of dynamic shared memory for graph kernel nodes that request it. @@ -87,6 +111,28 @@ class AMDGPUFunction { void operator()(Args... args) { auto err = call(args...); + // Intercept launch failure before the generic HIP error so a debug-mode in-kernel assert + // can raise QuadrantsAssertionError from pinned host memory (context is dead after trap). + if (err == HIP_ERROR_LAUNCH_FAILURE) { + if (auto hook = get_amdgpu_launch_failure_hook()) { + hook(); // may throw QuadrantsAssertionError on first surfacing + } + if (amdgpu_device_assert_already_surfaced()) { + // The HIP context is dead after the trap, so every later call also returns launch + // failure. Suppress only where throwing would std::terminate(): inside teardown + // destructors, or while unwinding the just-thrown QuadrantsAssertionError. Anywhere + // else (e.g. user code that caught the assertion and kept issuing GPU work) fail + // loudly instead of returning stale/uninitialized results as success. + if (amdgpu_device_in_teardown() || std::uncaught_exceptions() > 0) { + return; + } + QD_ERROR( + "AMDGPU device context is unusable after an in-kernel assertion failure; " + "re-initialize Quadrants in a fresh process before issuing further GPU work " + "(while calling {} ({}))", + name_, symbol_name_); + } + } QD_ERROR_IF(err, get_error_message(err)); } diff --git a/quadrants/runtime/llvm/llvm_context.cpp b/quadrants/runtime/llvm/llvm_context.cpp index d116503575..0e49a5bca4 100644 --- a/quadrants/runtime/llvm/llvm_context.cpp +++ b/quadrants/runtime/llvm/llvm_context.cpp @@ -717,6 +717,28 @@ std::unique_ptr QuadrantsLLVMContext::module_from_file(const std:: patch_fence("block_mem_fence", "workgroup"); patch_fence("grid_mem_fence", "agent"); + // System-scope fence for the AMDGPU in-kernel assert path: publish pinned assert state to the + // host before `__builtin_trap()`. Default LLVM syncscope is system (includes host memory). + { + auto func = module->getFunction("amdgpu_system_mem_fence"); + if (func) { + func->deleteBody(); + auto bb = llvm::BasicBlock::Create(*ctx, "entry", func); + IRBuilder<> builder(*ctx); + builder.SetInsertPoint(bb); + builder.CreateFence(llvm::AtomicOrdering::SequentiallyConsistent); + builder.CreateRetVoid(); + QuadrantsLLVMContext::mark_inline(func); + } else { + // If the symbol name ever drifts, the runtime.cpp fallback (a host-atomic stub) is a no-op on device, + // which would silently break the publish-before-trap ordering the in-kernel assert path depends on. + // Warn loudly rather than fail so non-debug builds (which never trap) still run. + QD_WARN( + "amdgpu_system_mem_fence not found while patching the AMDGPU runtime module; the in-kernel assert " + "publish-before-trap fence will be a device no-op. QuadrantsAssertionError may not surface correctly."); + } + } + link_module_with_amdgpu_libdevice(module); patch_amdgpu_kernel_dim("block_dim", llvm::ConstantInt::get(llvm::Type::getInt32Ty(*ctx), 0)); patch_amdgpu_kernel_dim("grid_dim", llvm::ConstantInt::get(llvm::Type::getInt32Ty(*ctx), 0)); diff --git a/quadrants/runtime/llvm/llvm_runtime_executor.cpp b/quadrants/runtime/llvm/llvm_runtime_executor.cpp index c541b6af32..95cf946084 100644 --- a/quadrants/runtime/llvm/llvm_runtime_executor.cpp +++ b/quadrants/runtime/llvm/llvm_runtime_executor.cpp @@ -1,11 +1,14 @@ #include "quadrants/runtime/llvm/llvm_runtime_executor.h" #include "quadrants/program/adstack_size_expr_eval.h" +#include +#include #include #include #include #include #include +#include #include #include "quadrants/ir/stmt_op_types.h" @@ -32,6 +35,10 @@ #include "quadrants/rhi/amdgpu/amdgpu_context.h" #endif +#include "quadrants/common/exceptions.h" +#include "quadrants/inc/constants.h" +#include "quadrants/util/str.h" + namespace quadrants::lang { namespace { void assert_failed_host(const char *msg) { @@ -42,6 +49,54 @@ void *host_allocate_aligned(HostMemoryPool *memory_pool, std::size_t size, std:: return memory_pool->allocate(size, alignment); } +#if defined(QD_WITH_AMDGPU) +// Layout-compatible with `AmdgpuAssertErrorState` in llvm_runtime.h (must stay in sync). The host TU cannot include +// the device-runtime header, so both structs are pinned to the same canonical layout via the shared constants below; +// a change to either that is not mirrored breaks one of these static_asserts. +struct AmdgpuAssertErrorStateHostView { + int64_t error_code; + char error_message_template[quadrants_error_message_max_length]; + uint64_t error_message_arguments[quadrants_error_message_max_num_arguments]; +}; + +static_assert(offsetof(AmdgpuAssertErrorStateHostView, error_code) == 0, "AmdgpuAssertErrorStateHostView layout drift"); +static_assert(offsetof(AmdgpuAssertErrorStateHostView, error_message_template) == sizeof(int64_t), + "AmdgpuAssertErrorStateHostView layout drift"); +static_assert(offsetof(AmdgpuAssertErrorStateHostView, error_message_arguments) == + sizeof(int64_t) + quadrants_error_message_max_length, + "AmdgpuAssertErrorStateHostView layout drift"); +static_assert(sizeof(AmdgpuAssertErrorStateHostView) == + sizeof(int64_t) + quadrants_error_message_max_length + + quadrants_error_message_max_num_arguments * sizeof(uint64_t), + "AmdgpuAssertErrorStateHostView layout drift"); + +// Host pointer published for the AMDGPU launch-failure hook (see amdgpu_driver.h). Only set while a +// debug+amdgpu LlvmRuntimeExecutor owns a live pinned assert-error state. +AmdgpuAssertErrorStateHostView *g_amdgpu_assert_error_state_host = nullptr; + +void amdgpu_launch_failure_assert_hook() { + auto *st = g_amdgpu_assert_error_state_host; + if (st == nullptr) { + return; + } + std::atomic_thread_fence(std::memory_order_acquire); + const int64_t code = __atomic_load_n(&st->error_code, __ATOMIC_SEQ_CST); + if (code != 1) { + return; + } + // Consume so Program teardown / subsequent HIP calls on the dead context do not re-throw. + __atomic_store_n(&st->error_code, (int64_t)0, __ATOMIC_SEQ_CST); + amdgpu_mark_device_assert_surfaced(); + // Context is dead after the trap; format solely from the pinned buffer (no device retrieval). + std::string error_message_template(st->error_message_template); + const auto error_message_formatted = + format_error_message(error_message_template, [st](int argument_id) -> uint64 { + return st->error_message_arguments[argument_id]; + }); + throw QuadrantsAssertionError(error_message_formatted); +} +#endif + } // namespace LlvmRuntimeExecutor::LlvmRuntimeExecutor(CompileConfig &config, KernelProfilerBase *profiler, ProgramImpl *program_impl) @@ -493,6 +548,15 @@ uint64_t *LlvmRuntimeExecutor::get_device_alloc_info_ptr(const DeviceAllocation void LlvmRuntimeExecutor::finalize() { profiler_ = nullptr; +#if defined(QD_WITH_AMDGPU) + // Entering teardown: from here the driver may issue calls on a HIP context that a prior + // in-kernel assert left dead. Allow those launch failures to be swallowed (see + // AMDGPUFunction::operator()) so destructors do not std::terminate(); outside teardown a + // post-assert launch failure is still surfaced as a hard error. + if (config_.arch == Arch::amdgpu) { + amdgpu_set_device_in_teardown(true); + } +#endif // Release the host-owned adstack heap before the device teardown below so its `DeviceAllocationGuard` destructor // runs while the RHI device is still valid. The destructor drops the allocation back to the driver memory pool // (or to the host allocator on CPU); deferring past `llvm_device()->clear()` would leak it. @@ -589,6 +653,18 @@ void LlvmRuntimeExecutor::finalize() { adstack_overflow_task_id_host_ptr_ = nullptr; adstack_overflow_task_id_dev_ptr_ = nullptr; } +#if defined(QD_WITH_AMDGPU) + if (assert_error_state_host_ptr_ != nullptr) { + if (g_amdgpu_assert_error_state_host == assert_error_state_host_ptr_) { + g_amdgpu_assert_error_state_host = nullptr; + set_amdgpu_launch_failure_hook(nullptr); + } + if (config_.arch == Arch::amdgpu) { + AMDGPUDriver::get_instance().mem_free_host(assert_error_state_host_ptr_); + } + assert_error_state_host_ptr_ = nullptr; + } +#endif if (config_.arch == Arch::cuda || config_.arch == Arch::amdgpu) { preallocated_runtime_objects_allocs_.reset(); preallocated_runtime_memory_allocs_.reset(); @@ -884,6 +960,28 @@ void LlvmRuntimeExecutor::materialize_runtime(KernelProfilerBase *profiler, uint runtime_jit->call("runtime_set_adstack_overflow_task_id_dev_ptr", llvm_runtime_, adstack_overflow_task_id_dev_ptr_); } + + // AMDGPU debug assert: allocate pinned coherent host memory for assert state so the host can + // format QuadrantsAssertionError after `__builtin_trap()` kills the dispatch (HIP context dead; + // device retrieval kernels cannot run). Gated on debug + amdgpu only. + if (config_.debug && config_.arch == Arch::amdgpu) { +#if defined(QD_WITH_AMDGPU) + void *host_slot = nullptr; + AMDGPUDriver::get_instance().mem_alloc_host(&host_slot, sizeof(AmdgpuAssertErrorStateHostView), + HIP_HOST_MALLOC_COHERENT); + QD_ASSERT(host_slot != nullptr); + std::memset(host_slot, 0, sizeof(AmdgpuAssertErrorStateHostView)); + assert_error_state_host_ptr_ = host_slot; + g_amdgpu_assert_error_state_host = static_cast(host_slot); + amdgpu_reset_device_assert_surfaced_flag(); + amdgpu_set_device_in_teardown(false); + set_amdgpu_launch_failure_hook(amdgpu_launch_failure_assert_hook); + // UVA: host pointer is also a valid device pointer on GFX9+. + runtime_jit->call("runtime_set_assert_error_state_dev_ptr", llvm_runtime_, host_slot); +#else + QD_NOT_IMPLEMENTED; +#endif + } } void LlvmRuntimeExecutor::destroy_snode_tree(SNodeTree *snode_tree) { diff --git a/quadrants/runtime/llvm/llvm_runtime_executor.h b/quadrants/runtime/llvm/llvm_runtime_executor.h index 43e83f0510..a1bae1d4ff 100644 --- a/quadrants/runtime/llvm/llvm_runtime_executor.h +++ b/quadrants/runtime/llvm/llvm_runtime_executor.h @@ -303,6 +303,10 @@ class LlvmRuntimeExecutor { int64_t *adstack_overflow_task_id_host_ptr_{nullptr}; void *adstack_overflow_task_id_dev_ptr_{nullptr}; + // AMDGPU debug-only: pinned host mirror of in-kernel assert state (`AmdgpuAssertErrorState` in + // llvm_runtime.h). Allocated in materialize_runtime when config_.debug && arch==amdgpu. + void *assert_error_state_host_ptr_{nullptr}; + std::unique_ptr thread_pool_{nullptr}; std::shared_ptr device_{nullptr}; diff --git a/quadrants/runtime/llvm/runtime_module/adstack_runtime.cpp b/quadrants/runtime/llvm/runtime_module/adstack_runtime.cpp index 9377a63278..82ff91fd55 100644 --- a/quadrants/runtime/llvm/runtime_module/adstack_runtime.cpp +++ b/quadrants/runtime/llvm/runtime_module/adstack_runtime.cpp @@ -787,6 +787,7 @@ void adstack_runtime_zero_init(LLVMRuntime *runtime) { runtime->adstack_bound_row_capacities_capacity = 0; runtime->adstack_overflow_flag_dev_ptr = nullptr; runtime->adstack_overflow_task_id_dev_ptr = nullptr; + runtime->assert_error_state_dev_ptr = nullptr; } extern "C" { // local stack operations diff --git a/quadrants/runtime/llvm/runtime_module/llvm_runtime.h b/quadrants/runtime/llvm/runtime_module/llvm_runtime.h index 6ce887e512..bbce8fec80 100644 --- a/quadrants/runtime/llvm/runtime_module/llvm_runtime.h +++ b/quadrants/runtime/llvm/runtime_module/llvm_runtime.h @@ -103,6 +103,26 @@ struct PreallocatedMemoryChunk { std::size_t preallocated_size = 0; }; +// AMDGPU debug-only pinned assert mirror. Host allocates via hipHostMalloc(Coherent); device publishes +// here before `__builtin_trap()` so the host can format QuadrantsAssertionError after the HIP context dies. +// See `LLVMRuntime::assert_error_state_dev_ptr`. +struct AmdgpuAssertErrorState { + i64 error_code; + char error_message_template[quadrants_error_message_max_length]; + uint64 error_message_arguments[quadrants_error_message_max_num_arguments]; +}; + +// Keep in exact layout sync with `AmdgpuAssertErrorStateHostView` in llvm_runtime_executor.cpp, which is hand-mirrored +// (the host TU cannot include this device-runtime header). Both structs are pinned to this canonical layout expressed +// via the shared constants, so neither can drift without breaking its own static_assert. +static_assert(offsetof(AmdgpuAssertErrorState, error_code) == 0, "AmdgpuAssertErrorState layout drift"); +static_assert(offsetof(AmdgpuAssertErrorState, error_message_template) == sizeof(i64), "AmdgpuAssertErrorState layout drift"); +static_assert(offsetof(AmdgpuAssertErrorState, error_message_arguments) == sizeof(i64) + quadrants_error_message_max_length, + "AmdgpuAssertErrorState layout drift"); +static_assert(sizeof(AmdgpuAssertErrorState) == + sizeof(i64) + quadrants_error_message_max_length + quadrants_error_message_max_num_arguments * sizeof(uint64), + "AmdgpuAssertErrorState layout drift"); + struct LLVMRuntime { PreallocatedMemoryChunk runtime_objects_chunk; PreallocatedMemoryChunk runtime_memory_chunk; @@ -143,6 +163,7 @@ struct LLVMRuntime { uint64 error_message_arguments[quadrants_error_message_max_num_arguments]; i32 error_message_lock = 0; i64 error_code = 0; + // Dedicated overflow signal. Pointer to a 64-bit slot in pinned host memory (CUDA `cuMemAllocHost_v2`, // HIP `hipHostMalloc`; CPU plain malloc; on this struct stored as the device-mapped address obtained via // `cuMemHostGetDevicePointer` / HIP equivalent). The kernel-side `stack_push` writes via a system-wide @@ -226,6 +247,13 @@ struct LLVMRuntime { i64 total_requested_memory; + // AMDGPU debug-only: device-mapped pointer to pinned `AmdgpuAssertErrorState` (see struct doc above). + // nullptr on non-AMDGPU / non-debug; nullptr-guarded in `quadrants_assert_format`. Appended at the end of + // the struct on purpose: the default-on offline cache keys on the numeric Quadrants version (not the runtime + // layout), so inserting a field mid-struct would shift every following field's offset and let an old cached + // kernel misread them. Keeping new fields at the tail preserves existing offsets for cached kernels. + AmdgpuAssertErrorState *assert_error_state_dev_ptr = nullptr; + template void set_result(std::size_t i, T t) { static_assert(sizeof(T) <= sizeof(uint64)); diff --git a/quadrants/runtime/llvm/runtime_module/runtime.cpp b/quadrants/runtime/llvm/runtime_module/runtime.cpp index 939eaae713..62b36cebc2 100644 --- a/quadrants/runtime/llvm/runtime_module/runtime.cpp +++ b/quadrants/runtime/llvm/runtime_module/runtime.cpp @@ -590,6 +590,20 @@ void runtime_retrieve_error_message_argument(LLVMRuntime *runtime, int argument_ runtime->set_result(quadrants_result_buffer_error_id, runtime->error_message_arguments[argument_id]); } +// Publish the device-mapped address of the pinned AmdgpuAssertErrorState allocated by the host in +// materialize_runtime (debug + AMDGPU only). `runtime_`-prefixed so it survives AMDGPU eliminate-unused. +extern "C" void runtime_set_assert_error_state_dev_ptr(LLVMRuntime *runtime, void *dev_ptr) { + runtime->assert_error_state_dev_ptr = (AmdgpuAssertErrorState *)dev_ptr; +} + +#if ARCH_amdgpu +// Stub patched in llvm_context.cpp to an LLVM system-scope fence. Host-clang cannot emit AMDGCN fence +// builtins when compiling runtime.cpp to bitcode; the JIT retarget replaces this body. +void amdgpu_system_mem_fence() { + __atomic_thread_fence(__ATOMIC_SEQ_CST); +} +#endif + void runtime_ListManager_get_num_active_chunks(LLVMRuntime *runtime, ListManager *list_manager) { runtime->set_result(quadrants_result_buffer_runtime_query_id, list_manager->get_num_active_chunks()); } @@ -631,14 +645,37 @@ void quadrants_assert_format(LLVMRuntime *runtime, u1 test, const char *format, if (!runtime->error_code) { locked_task(&runtime->error_message_lock, [&] { if (!runtime->error_code) { - runtime->error_code = 1; // Assertion failure - memset(runtime->error_message_template, 0, quadrants_error_message_max_length); memcpy(runtime->error_message_template, format, std::min(quadrants_strlen(format), quadrants_error_message_max_length - 1)); for (int i = 0; i < num_arguments; i++) { runtime->error_message_arguments[i] = arguments[i]; } +#if ARCH_amdgpu + // Mirror into pinned host-mapped memory before trapping. Host reads this after + // hipErrorLaunchFailure; device retrieval kernels cannot run once the context is dead. + if (runtime->assert_error_state_dev_ptr) { + auto *st = runtime->assert_error_state_dev_ptr; + memset(st->error_message_template, 0, quadrants_error_message_max_length); + memcpy(st->error_message_template, runtime->error_message_template, quadrants_error_message_max_length); + for (int i = 0; i < quadrants_error_message_max_num_arguments; i++) { + st->error_message_arguments[i] = runtime->error_message_arguments[i]; + } + amdgpu_system_mem_fence(); + // Publish error_code last so a host that observes 1 also sees the message bytes. + __atomic_store_n(&st->error_code, (i64)1, __ATOMIC_SEQ_CST); + } + // Fence before flipping the device-side gate so any peer wave that later observes + // error_code == 1 is guaranteed to also see the published pinned payload above. + amdgpu_system_mem_fence(); +#endif + // Set the device-side gate last, after the pinned state is fully published. A peer wave that + // observes error_code == 1 skips this block and traps the whole dispatch; if the gate were set + // first (as before), that peer could trap while this wave is still copying, leaving the host to + // read an unpublished pinned buffer (error_code == 0) and surface a generic launch failure + // instead of QuadrantsAssertionError. Waves that still observe 0 block on error_message_lock + // until publication completes. + runtime->error_code = 1; // Assertion failure } }); } @@ -646,18 +683,12 @@ void quadrants_assert_format(LLVMRuntime *runtime, u1 test, const char *format, // Kill this CUDA thread. asm("exit;"); #elif ARCH_amdgpu - asm("S_ENDPGM"); - // TODO: properly kill this CPU thread here, considering the containing - // ThreadPool structure. - - // std::terminate(); - - // Note that std::terminate() will throw an signal 6 - // (Aborted), which will be caught by Quadrants's signal handler. The assert - // failure message will NOT be properly printed since Quadrants exits after - // receiving that signal. It is better than nothing when debugging the - // runtime, since otherwise the whole program may crash if the kernel - // continues after assertion failure. + // Trap the whole dispatch so peer wavefronts waiting on s_barrier do not hang the host + // (the previous `S_ENDPGM` only killed the faulting wavefront). After the trap the HIP + // context is dead (`hipErrorLaunchFailure` on subsequent calls) - an accepted debug-mode + // limitation; the host surfaces QuadrantsAssertionError from the pinned state above. + amdgpu_system_mem_fence(); + __builtin_trap(); #endif } diff --git a/quadrants/runtime/program_impls/llvm/llvm_program.h b/quadrants/runtime/program_impls/llvm/llvm_program.h index 13848c52e5..5aabcc1f82 100644 --- a/quadrants/runtime/program_impls/llvm/llvm_program.h +++ b/quadrants/runtime/program_impls/llvm/llvm_program.h @@ -34,6 +34,13 @@ namespace cpu { class CpuDevice; } // namespace cpu +#if defined(QD_WITH_AMDGPU) +// Declared in quadrants/rhi/amdgpu/amdgpu_driver.h. Forward-declared here (instead of pulling in +// the RHI header) so pre_finalize() can open the teardown window before Program::finalize() runs +// its teardown synchronize() calls on a possibly dead HIP context. +void amdgpu_set_device_in_teardown(bool in_teardown); +#endif + class LlvmProgramImpl : public ProgramImpl { public: LlvmProgramImpl(CompileConfig &config, KernelProfilerBase *profiler); @@ -106,6 +113,16 @@ class LlvmProgramImpl : public ProgramImpl { // user can still call `qd.sync()` explicitly before finalize to observe the raise. void pre_finalize() override { finalizing_ = true; +#if defined(QD_WITH_AMDGPU) + // Program::finalize() issues two teardown synchronize() calls *before* runtime_exec_->finalize() + // sets this flag. A prior in-kernel assert leaves the HIP context dead, so those syncs would + // return hipErrorLaunchFailure and AMDGPUFunction::operator() would throw into the ~Program() + // path (std::terminate). Open the teardown window here so dead-context launch failures are + // swallowed during teardown; materialize_runtime() clears it again on the next init. + if (config->arch == Arch::amdgpu) { + amdgpu_set_device_in_teardown(true); + } +#endif } void finalize() override { diff --git a/tests/python/test_assert.py b/tests/python/test_assert.py index 345f31c2fb..a15b794e63 100644 --- a/tests/python/test_assert.py +++ b/tests/python/test_assert.py @@ -6,6 +6,7 @@ from quadrants.lang.misc import get_host_arch_list from tests import test_utils +from quadrants.lang.misc import is_arch_supported u = platform.uname() if u.system == "linux" and u.machine in ("arm64", "aarch64"): @@ -172,3 +173,169 @@ def foo(): with pytest.raises(qd.QuadrantsTypeError, match="Static assert with non-static condition"): foo() + + +# --------------------------------------------------------------------------- +# AMDGPU: assert must raise QuadrantsAssertionError (not hang / generic HIP error) +# --------------------------------------------------------------------------- +# Background: S_ENDPGM only kills the faulting wavefront, so peers waiting on s_barrier +# deadlock the host on hipStreamSynchronize. Approach B uses __builtin_trap() + pinned +# host assert state so the host can still format QuadrantsAssertionError after the trap +# (HIP context is then dead - accepted debug-mode limitation). +# +# Each case runs in a child *subprocess* (not fork: HIP is unsafe after fork) so a dead +# context cannot poison sibling tests. Override the child interpreter with env +# QD_TEST_PYTHON when the parent was started under a non-default dynamic loader. + + +def _amdgpu_available_for_assert_tests() -> bool: + return qd.amdgpu in test_utils.expected_archs() and is_arch_supported(qd.amdgpu) + + +def _run_amdgpu_assert_child(script: str, timeout_s: int = 45) -> None: + import os + import signal + import subprocess + import sys + import tempfile + import textwrap + + env = os.environ.copy() + env["QD_WANTED_ARCHS"] = "amdgpu" + env.setdefault("HSA_DISABLE_COREDUMP_ON_EXCEPTION", "1") + exe = os.environ.get("QD_TEST_PYTHON", sys.executable) + # Write a real .py file so quadrants' inspect-based frontend can recover source. + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f: + f.write(textwrap.dedent(script)) + path = f.name + try: + proc = subprocess.run( + [exe, path], + capture_output=True, + text=True, + timeout=timeout_s, + env=env, + ) + except subprocess.TimeoutExpired as e: + raise TimeoutError( + f"AMDGPU assert child exceeded {timeout_s}s " + "(possible s_barrier deadlock / missing trap regression)" + ) from e + finally: + try: + os.unlink(path) + except OSError: + pass + # Some environments (notably inside Docker on certain ROCm/HSA configs) escalate the + # in-kernel `__builtin_trap()` to an uncatchable SIGABRT instead of returning a catchable + # hipErrorLaunchFailure, so the host never gets to raise QuadrantsAssertionError. That is an + # environment limitation, not a regression in this code path (upstream AMDGPU CI runs + # bare-metal, where the trap is catchable). Skip rather than fail so such runners stay green; + # a genuine hang still trips the wall-clock timeout above, and a wrong/absent exception still + # surfaces as a non-zero exit below. + if proc.returncode == -signal.SIGABRT: + pytest.skip( + "AMDGPU trap escalated to SIGABRT (HSA cannot deliver a catchable " + "hipErrorLaunchFailure in this environment; expected on some containerized runners)" + ) + if proc.returncode != 0: + raise AssertionError( + f"AMDGPU assert child failed (exit {proc.returncode}).\n" + f"STDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}" + ) + + +@pytest.mark.skipif(not _amdgpu_available_for_assert_tests(), reason="AMDGPU not available/wanted") +def test_amdgpu_assert_raises(): + _run_amdgpu_assert_child( + """ + import quadrants as qd + qd.init(arch=qd.amdgpu, debug=True, gdb_trigger=False) + + @qd.kernel + def boom(): + assert False, "amdgpu assert probe" + + try: + boom() + except qd.QuadrantsAssertionError as e: + assert "amdgpu assert probe" in str(e) + assert isinstance(e, AssertionError) + else: + raise SystemExit("expected QuadrantsAssertionError") + """ + ) + + +@pytest.mark.skipif(not _amdgpu_available_for_assert_tests(), reason="AMDGPU not available/wanted") +def test_amdgpu_assert_barrier_no_hang(): + """One thread asserts while siblings hit block.sync - must raise, not hang.""" + _run_amdgpu_assert_child( + """ + import quadrants as qd + qd.init(arch=qd.amdgpu, debug=True, gdb_trigger=False) + n = 256 + + @qd.kernel + def boom_with_barrier(): + qd.loop_config(block_dim=n) + for i in range(n): + # Thread 0 fails the assert; other threads reach the barrier. With S_ENDPGM this + # deadlocks; with __builtin_trap the dispatch faults and the host raises. + assert i != 0, "barrier assert probe" + qd.simt.block.sync() + + try: + boom_with_barrier() + except qd.QuadrantsAssertionError as e: + assert "barrier assert probe" in str(e) + else: + raise SystemExit("expected QuadrantsAssertionError") + """ + ) + + +@pytest.mark.skipif(not _amdgpu_available_for_assert_tests(), reason="AMDGPU not available/wanted") +def test_amdgpu_assert_dead_context_reuse_raises(): + """After an assert is caught, the HIP context is dead: further GPU work must raise a + hard error, not silently 'succeed' on the dead context (Codex #871 P1).""" + _run_amdgpu_assert_child( + """ + import quadrants as qd + qd.init(arch=qd.amdgpu, debug=True, gdb_trigger=False) + + @qd.kernel + def boom(): + assert False, "amdgpu assert probe" + + @qd.kernel + def add_one(x: qd.types.ndarray(dtype=qd.i32, ndim=1)): + for i in range(x.shape[0]): + x[i] = x[i] + 1 + + # Allocate the array while the context is still alive; reusing it after the assert is + # what must fail loudly. (Allocation itself also goes through the dead context once the + # assert has fired, so it must happen first.) + arr = qd.ndarray(qd.i32, shape=(8,)) + + try: + boom() + except qd.QuadrantsAssertionError: + pass + else: + raise SystemExit("expected QuadrantsAssertionError from the first kernel") + + # Context is dead now. In debug mode the kernel launch synchronizes and checks the + # runtime error, so a subsequent launch must surface a hard error rather than + # returning stale/uninitialized results as success. + try: + add_one(arr) + except qd.QuadrantsAssertionError: + # Another assertion would also be acceptable, but must not be a silent success. + pass + except Exception: + pass # expected: hard error on the dead context + else: + raise SystemExit("post-assert GPU work silently succeeded on a dead HIP context") + """ + )