Skip to content

[AMDGPU] Surface QuadrantsAssertionError after in-kernel assert (fix barrier hang) - #871

Open
paveltc wants to merge 5 commits into
Genesis-Embodied-AI:mainfrom
AMD-Ecosystem:fix/amdgpu-assert-trap-translate
Open

[AMDGPU] Surface QuadrantsAssertionError after in-kernel assert (fix barrier hang)#871
paveltc wants to merge 5 commits into
Genesis-Embodied-AI:mainfrom
AMD-Ecosystem:fix/amdgpu-assert-trap-translate

Conversation

@paveltc

@paveltc paveltc commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

On AMDGPU, a failed in-kernel assert previously emitted asm("S_ENDPGM"), which only terminates the faulting wavefront. Peer wavefronts still waiting on s_barrier then deadlock, and the host hangs forever in hipStreamSynchronize. This PR replaces that with a dispatch-wide __builtin_trap() and translates the resulting fault back into a proper QuadrantsAssertionError on the host, preserving the debug-mode assertion contract without hanging.

CUDA / CPU / Metal paths are unchanged. All new behavior is gated on debug + Arch::amdgpu.

Approach

__builtin_trap() faults the whole dispatch, so the host gets hipErrorLaunchFailure (719) rather than a hang — but the context is then dead, so the usual device-side error-retrieval kernels can no longer run. To preserve the error message:

  1. Pinned host-coherent mirror. In materialize_runtime (debug + amdgpu only) we hipHostMalloc(...Coherent) an AmdgpuAssertErrorState and publish its device-mapped address into the runtime. This mirrors the existing adstack_overflow_flag_dev_ptr precedent and survives a device fault.
  2. Device publish before trap. In quadrants_assert_format, the faulting wavefront (serialized under the existing error_message_lock) copies the message template + arguments into the pinned buffer, issues a system-scope fence (amdgpu_system_mem_fence, patched to an LLVM seq_cst fence in llvm_context.cpp), stores error_code last, then __builtin_trap()s.
  3. Host translation. AMDGPUFunction::operator() intercepts hipErrorLaunchFailure, and a debug-only hook reads the pinned state and raises QuadrantsAssertionError (a subclass of AssertionError). Subsequent 719s on the now-dead context are ignored so Program teardown does not terminate() from a destructor.

Testing

Validated on an AMD Instinct MI308X (gfx942), ROCm 7.2.4, base main:

  • tests/python/test_assert.py::test_amdgpu_assert_raises — a failed assert raises QuadrantsAssertionError with the formatted message; isinstance(e, AssertionError) holds.
  • tests/python/test_assert.py::test_amdgpu_assert_barrier_no_hang — one thread asserts while siblings hit block.sync(); raises instead of hanging (the original bug).
  • CPU sanity (test_assert_*) unchanged.

Both new tests run each case in an isolated child subprocess (the HIP context is dead after a trap; HIP is unsafe after fork) with a wall-clock timeout that fails on the barrier-hang regression.

CI notes

Upstream AMDGPU CI (test_gpu.ymltest_linux_amdgpu, runs-on: amdgpu) runs bare-metal on the self-hosted runner — no container — which matches the environment where the trap returns a catchable hipErrorLaunchFailure. Some ROCm/HSA configs (notably inside Docker) instead escalate the trap to an uncatchable SIGABRT; the tests treat a SIGABRT-killed child as pytest.skip (environment limitation) while still failing on timeout or on a wrong/absent exception, so no runner goes spuriously red.

Known limitations / possible follow-ups

  • After an assert, the HIP context is dead — one assert per process (tests isolate per subprocess). Accepted debug-mode limitation.
  • The pinned struct is duplicated as a layout-compatible host view (AmdgpuAssertErrorStateHostView) in llvm_runtime_executor.cpp; a static_assert on size/offsets would harden this.
  • The launch-failure hook and surfaced-flag are process-global singletons (assume a single active Program).

Made with Cursor

ptcherni and others added 3 commits August 14, 2026 14:57
Replace S_ENDPGM with __builtin_trap so peer wavefronts waiting on
s_barrier do not hang the host, and publish assert state into pinned
coherent host memory so the host can format QuadrantsAssertionError
after hipErrorLaunchFailure (HIP context is dead afterward).

Co-authored-by: Cursor <cursoragent@cursor.com>
Timeout is enforced by the subprocess.run(..., timeout=) path instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
Some ROCm/HSA configurations (notably inside Docker) turn the in-kernel
__builtin_trap() into an uncatchable SIGABRT rather than returning a
catchable hipErrorLaunchFailure, so the host never raises
QuadrantsAssertionError. Treat a SIGABRT-killed child as a skip (an
environment limitation) while still failing on the wall-clock timeout
(barrier-hang regression) and on a wrong/absent exception. Upstream
AMDGPU CI runs bare-metal, where the trap is catchable.

Co-authored-by: Cursor <cursoragent@cursor.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb26004957

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread quadrants/rhi/amdgpu/amdgpu_driver.h Outdated
…inding

Previously, once an in-kernel assert surfaced, AMDGPUFunction::operator() swallowed
every subsequent hipErrorLaunchFailure (719) as success until the next
materialize_runtime(). That masks dead-context errors if user code catches the
QuadrantsAssertionError and keeps issuing GPU work (Codex Genesis-Embodied-AI#871 P1).

Now 719 is suppressed only where throwing would std::terminate(): during teardown
(g_amdgpu_device_in_teardown, opened in LlvmProgramImpl::pre_finalize() before the
finalize() syncs, cleared on the next materialize) or while unwinding
(std::uncaught_exceptions() > 0). Any other post-assert GPU call now raises a clear
hard error instead of returning stale/uninitialized results.

Adds test_amdgpu_assert_dead_context_reuse_raises to lock in the behavior.
All three amdgpu assert tests pass on the MI308X.

Co-authored-by: Cursor <cursoragent@cursor.com>
@paveltc

paveltc commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the P1 (post-assert launch-failure swallowing) in 376f1ff

Thanks @codex — good catch. The prior code set a "surfaced" flag on the first in-kernel assert and then swallowed every subsequent hipErrorLaunchFailure (719) as success until the next materialize_runtime(). As you noted, that masks dead-context errors if user code catches the QuadrantsAssertionError and keeps issuing GPU work.

Fix: 719 is now suppressed only in the two situations where throwing would std::terminate():

  • Teardowng_amdgpu_device_in_teardown, opened in LlvmProgramImpl::pre_finalize() (before Program::finalize() runs its teardown synchronize() calls on the now-dead context) and cleared again on the next materialize_runtime().
  • Stack unwindingstd::uncaught_exceptions() > 0 (e.g. an RAII destructor firing a dead-context HIP call while the QuadrantsAssertionError itself propagates).

Any other post-assert GPU call now raises a clear hard error instead of returning stale/uninitialized results:

if (amdgpu_device_assert_already_surfaced()) {
  if (amdgpu_device_in_teardown() || std::uncaught_exceptions() > 0) {
    return;  // swallow: throwing here would std::terminate()
  }
  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_);
}

Regression test: test_amdgpu_assert_dead_context_reuse_raises catches the first assertion, then reuses the context — and asserts that the reuse raises (not a silent success).

Validated on an MI308X (gfx942, ROCm 7.2.4): all three assert tests pass.

test_amdgpu_assert_raises                    PASS
test_amdgpu_assert_barrier_no_hang           PASS   (original barrier-hang scenario)
test_amdgpu_assert_dead_context_reuse_raises PASS   (reuse -> RuntimeError: "device context is unusable...")

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@hughperkins

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 376f1ff612

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread quadrants/runtime/llvm/runtime_module/runtime.cpp
Comment thread quadrants/runtime/llvm/runtime_module/llvm_runtime.h Outdated
@hughperkins

Copy link
Copy Markdown
Collaborator

Checked with Opus. Opus broadly likes the PR, but has a couple of concerns that appear worth addressing:

Screenshot 2026-08-24 at 11 48 43 Screenshot 2026-08-24 at 11 48 18

@hughperkins hughperkins added the awaiting-contributor-action awaiting-contributor-action label Aug 24, 2026
… ABI guards

Codex + Opus review fixes for the in-kernel assert trap path:

- Fix multi-wave publish/trap race (Codex P1): set the device-side
  runtime->error_code gate LAST, after the pinned assert state is fully
  published and fenced. Previously the gate was set first, so a peer wave
  could observe error_code==1, skip the locked block, and trap the whole
  dispatch while this wave was still copying -- leaving the host to read an
  unpublished pinned buffer (error_code==0) and surface a generic launch
  failure instead of QuadrantsAssertionError.

- Keep the offline cache safe (Codex P1): move assert_error_state_dev_ptr
  to the end of LLVMRuntime. Inserting it mid-struct shifted every later
  field; the default-on offline cache keys on the numeric version only, so
  an old cached kernel would misread the shifted fields. Appending at the
  tail preserves existing offsets.

- Guard ABI drift (Opus): add static_asserts pinning both
  AmdgpuAssertErrorState and its hand-mirrored host view
  (AmdgpuAssertErrorStateHostView) to the same canonical layout via the
  shared constants.

- Harden the fence patch (Opus): warn if amdgpu_system_mem_fence is not
  found during runtime-module patching instead of silently leaving the
  no-op host stub, which would break publish-before-trap ordering.

- Replace non-ASCII em dashes in added lines (Opus) so the Check non-ASCII
  characters CI job passes.

Co-authored-by: Cursor <cursoragent@cursor.com>
@paveltc

paveltc commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. Pushed c766253 addressing the concrete before-merge items, validated on MI308X (gfx942, ROCm 7.2.4).

Addressed in code

  • Non-ASCII (concern 3): replaced the 5 em dashes in added lines (amdgpu_driver.h, runtime.cpp, test_assert.py) with ASCII hyphens. Verified with the repo's own python/tools/check_non_ascii.py --diff-file against the PR diff — now clean.
  • Duplicated struct without static_assert (concern 4): added static_asserts on offsetof (all three fields) + sizeof for both AmdgpuAssertErrorState and AmdgpuAssertErrorStateHostView. The host TU can't include the device-runtime header, so both are pinned to the same canonical layout via the shared constants; a change to either that isn't mirrored breaks one of the asserts.
  • Fence null-check (concern 5, second half): the amdgpu_system_mem_fence patch now emits a QD_WARN if the symbol isn't found during runtime-module patching, instead of silently leaving the no-op host stub (which would break publish-before-trap ordering). Kept it a warning rather than a hard error so non-debug builds, which never trap, still run.

Also folded in the two Codex P1s (replies on those threads):

  • publish/trap race — the device-side runtime->error_code gate is now set last, after the pinned state is fully published and fenced;
  • offline-cache ABI — assert_error_state_dev_ptr moved to the end of LLVMRuntime so cached kernels don't misread shifted offsets.

Local validation (MI308X, gfx942, ROCm 7.2.4): clean build (the new static_asserts compile), and the three isolated subprocess tests pass — test_amdgpu_assert_raises, test_amdgpu_assert_barrier_no_hang, test_amdgpu_assert_dead_context_reuse_raises.

Acknowledged, not code-changed

  • Concerns 1 & 2 (CI hasn't run / tests may skip): agreed this is the main gap and it needs a maintainer to approve the fork PR's Actions run. On my bare-metal MI308X the trap is catchable and the tests exercise the real path (not the SIGABRT skip branch); whether the self-hosted runner behaves the same is exactly what an approved CI run would confirm.
  • Concern 5 (cross-arch fence visibility): the seq_cst-fence-before-trap host-visibility guarantee is empirically validated on gfx942 (CDNA) only; I haven't verified RDNA. The path is debug-mode + Arch::amdgpu only, so the blast radius is limited, but I'll flag it as gfx942-validated.
  • Concerns 6 & 7 (process-global singletons / one-assert-per-process): acknowledged as documented limitations, accepted vs. a hang for typical single-Program debug usage.

@hughperkins

Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c766253441

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// 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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install assert state for bounds-check-only mode

When AMDGPU is initialized with check_out_of_bound=True and debug=False, the bounds-check pass still emits calls to this assertion path, but materialize_runtime() installs the pinned state and launch-failure hook only when config_.debug is true (llvm_runtime_executor.cpp:967). An out-of-bounds access therefore executes this trap with no state to translate the resulting 719: the next synchronization raises a generic HIP launch failure and leaves the context permanently unusable instead of reporting the bounds error. Allocate the state whenever assertions can be emitted, or restrict the destructive trap to the debug configuration.

Useful? React with 👍 / 👎.

Comment on lines +686 to +689
// 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the dead-context recovery requirement

On AMDGPU this changes the user-facing assertion flow so that catching QuadrantsAssertionError does not permit continued use or even reinitialization in the same process—the HIP context is permanently dead and recovery requires a fresh process. The assertions section in docs/source/user_guide/debug.md does not mention this backend-specific constraint, and this commit contains no docs/ update, so users following the documented catch-and-continue model can unexpectedly lose the whole GPU session. Add the recovery/lifetime caveat to the user-facing debug documentation.

AGENTS.md reference: AGENTS.md:L15-L22

Useful? React with 👍 / 👎.

@hughperkins

Copy link
Copy Markdown
Collaborator

Hi, could you create the pr from paveltc rather than AMD-Ecosystem please, so I'm able to push changes to it easily? (which I think will be faster / more efficient, than asking you to make the changes, iteratveily?)

@paveltc

paveltc commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

The reason it's created from AMD-Ecosystem is actually because that's the new company policy for creating PRs. I could redo it, what is the problem with it being from AMD-Ecosystem?

@hughperkins

Copy link
Copy Markdown
Collaborator

Note: working, and commenting, at #895

  • once that PR looks ok-ish, will PR those changes onto this PR branch

@hughperkins

Copy link
Copy Markdown
Collaborator

Agent says:

AMD GPU CI finding: the __builtin_trap() assert path breaks the debug-assert test suite on the AMD runner

Heads-up from validating this PR's changes on the parallel [DONOTMERGE] PR #895 (it carries this branch's full diff plus a few review fixes). #895 is the first time the full test_gpu / Test Linux AMD GPU job has actually run end-to-end against the trap code: that job is gated on this fork PR, and on #895 it finally ran to completion (3h45m).

Result: 35 failed, 3870 passed. Every one of the 35 failures is an AMDGPU debug-mode assert / bounds-check / error-raising test.

Run: https://github.com/Genesis-Embodied-AI/quadrants/actions/runs/33201317112/job/98958973203

Mechanism. On this runner __builtin_trap() escalates to an HSA hardware exception rather than a catchable hipErrorLaunchFailure. From a failing child's stderr:

:0:rocdevice.cpp :2994: Callback: Queue 0x... aborting with error : HSA_STATUS_ERROR_EXCEPTION ... hardware exception. code: 0x1016
[E ...] Received signal 6 (Aborted)

That produces two failure shapes:

  • In-process debug tests crash the worker. ~30 tests that trigger an assert/OOB in the same process (e.g. test_assert_message, test_assert_basic, test_out_of_bound, test_matrix_oob, test_ndarray::test_scalar_ndarray_oob, test_buffer_view::test_debug_oob_*, test_debug::test_cpu_debug_snode_*_out_of_bound) die with worker 'gwNN' crashed. A process abort cannot be caught as QuadrantsAssertionError, and it appears to wedge the shared device (some unrelated GPU tests on other workers crash too).
  • Subprocess-isolated tests time out. test_amdgpu_assert_raises, test_amdgpu_assert_barrier_no_hang, and test_amdgpu_assert_dead_context_reuse_raises all hit TimeoutError: AMDGPU assert child exceeded 45s (possible s_barrier deadlock / missing trap regression). The child receives signal 6 but then hangs, so the 45s timeout SIGKILLs it (returncode -9); the returncode == -signal.SIGABRT skip in _run_amdgpu_assert_child never fires because the process never cleanly returns -6.

Net: on this AMD CI environment the trap-based approach does not turn a barrier-hang into a catchable QuadrantsAssertionError; it turns every debug-mode assertion into a process abort (or a hang). This is the "containerized ROCm/HSA escalates the trap to SIGABRT" caveat that landed in debug.md, except it is the default behavior on this runner rather than a rare edge case, and it takes out the whole debug-assert suite rather than just the new tests.

Transparency note on blast radius. Addressing an earlier Codex P1, I added an AMDGPU offline-cache-key revision so pre-change cached kernels (old S_ENDPGM) are invalidated. If the self-hosted runner keeps a persistent offline cache, that invalidation would have forced the generic debug tests to recompile with the trap too, which may be why the failures span the whole suite rather than only the new test_amdgpu_assert_* tests. That said, the new test_amdgpu_assert_* tests fail here on their own (they always compile fresh trap code), so this is not purely a cache artifact.

Pausing further code changes on this until you have had a chance to weigh in on the trap mechanism. Happy to help however is useful - e.g. trying an alternative halt mechanism or reworking the debug-assert tests to run in isolated subprocesses - just say which direction you prefer.

@paveltc

paveltc commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for running this end-to-end on #895 -- that's the data point we were missing, and it's decisive. I pulled the job log (run 33201317112) and confirmed the mechanism:

  • 35 failed, 3870 passed -- all 35 are the AMDGPU debug-assert / OOB / bounds-check tests.
  • The trap escalates to HSA_STATUS_ERROR_EXCEPTION -> Received signal 6 (SIGABRT), not a catchable hipErrorLaunchFailure. 29 distinct xdist workers crash, so the abort also wedges the shared device and takes down bystander GPU tests.
  • Our 3 subprocess-isolated tests time out (exceeded 45s, returncode: -9): the child gets signal 6 but hangs before returning cleanly, so the -SIGABRT skip branch never fires.

Conclusion: this isn't a test-harness gap -- it falsifies the PR's core assumption. s_trap catchability is HSA/ROCm-config-dependent (my MI308X dev box is in the catchable-719 regime; this runner is in the abort regime, same arch). So the "trap -> translate 719 -> QuadrantsAssertionError" contract can't be relied on across AMD environments, and reworking the tests to tolerate the abort would just paper over a real user-facing regression (process abort + dead device instead of a catchable exception) on any abort-regime config.

Proposed direction -- move the halt off the hardware trap onto a cooperative, memory-signalled exit, keeping all the pinned-buffer/host-translation machinery we already built:

  • assert/OOB failure writes the message + sets the device-global assert_failed flag (existing infra), then the dispatch exits without __builtin_trap();
  • the host raises QuadrantsAssertionError after a normal sync() reads the flag -- no trap, so no SIGABRT, no device wedge, and the context stays alive (which also removes the one-assert-per-process / dead-context limitation and fixes the non-debug check_out_of_bound path).

The crux to prove out is the workgroup-uniform exit around s_barrier (making all lanes leave together so peers don't deadlock -- the original bug). Since the abort vs. catchable-719 behavior looks config-driven rather than hardware-driven, I'll try to reproduce the abort regime on my MI308X by matching the runner's containerized ROCm/HSA setup (my bare-metal box lands in the catchable regime), and validate the prototype there; if I can't flip it locally, I may ask you to run a quick probe on the runner. In parallel I'll do a cheap check of whether an HSA trap-handler / queue-error setting can make s_trap catchable on the runner -- if it can, that's a much smaller fix; if not, the cooperative exit is the robust path.

Either way it stays debug/check_out_of_bound + Arch::amdgpu only, with no user-facing API change.

@paveltc

paveltc commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my comment above: I mis-stated the runner architecture. The AMD CI runner is not the same arch as my dev box.

Per scripts/build_checkpoint_yield_check_hsaco.py (gfx1011 -- RDNA1 (Radeon Pro V520 / Navi 12) -- the AWS g4ad CI runner) and the QD_AMDGPU_V520=1 export in test_gpu.yml, the amdgpu runner is an AWS g4ad instance with a Radeon Pro V520 -- gfx1011 (RDNA1 / Navi 12). My validation box is gfx942 (CDNA3, MI308X).

That's a different GPU architecture, so the __builtin_trap() -> SIGABRT vs. catchable hipErrorLaunchFailure split is most likely (at least partly) arch-driven -- RDNA1 trap/exception handling differs from CDNA3 -- rather than the containerized-HSA config difference I suggested. It also means I can't faithfully reproduce the runner's regime on my CDNA3 box; validating any fix needs a gfx1011 environment, so I'm arranging access to a g4ad (V520 / gfx1011) to prototype and validate there.

If anything this strengthens the case for the cooperative, non-trap exit: it removes reliance on s_trap semantics that we now know differ across AMD architectures (catchable on gfx942, an uncatchable abort on gfx1011).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-contributor-action awaiting-contributor-action

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants