Repository navigation
fix(hrr): swap the capture shims into the dispatch table slot by slot - #12258
Conversation
✅ All Policy Checks Passed
📖 Need help? See the Policy FAQ for details on every check and how to fix failures. |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Critical issues remain with non-atomic readers and function-pointer slot reinterpretation.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
Updates HRR dispatch-table installation to use per-slot atomic operations, preserving external slot changes during uninstall.
Changes:
- Replaces bulk table copies with slot-wise installation and conditional restoration.
- Updates compiler-table generation and generated output.
- Documents the revised dispatch behavior.
| File | Summary |
|---|---|
projects/hrr/tools/gen_hrr_api_args.py |
Generates the compiler-table installation call. |
projects/hrr/DESIGN.md |
Documents slot-wise dispatch behavior. |
projects/clr/hipamd/src/hrr/hip_capture.h |
Declares the compiler-table installer. |
projects/clr/hipamd/src/hrr/hip_capture.cpp |
Implements atomic slot installation and restoration. |
projects/clr/hipamd/src/hrr/hip_capture_generated.cpp |
Uses the new compiler-table installer. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
9940cd7 to
de0f4a1
Compare
danielbolsson
left a comment
There was a problem hiding this comment.
Thanks for this. Replacing the whole-table memcpy with per-slot release stores and CAS is a clear improvement. The size field is no longer overwritten, and an install or uninstall that runs before hip_capture_build_table() now writes nothing instead of copying a zeroed table over the live one. I checked the layout assumptions against hip_api_trace.hpp/.cpp: all 545 runtime and 9 compiler members after size are function pointers, the live size equals sizeof(Table), and the slots are 8-byte aligned. Regenerating with gen_hrr_api_args.py reproduces the committed hip_capture_generated.cpp byte for byte.
I found nothing blocking. There is one non-blocking teardown point and a few nits, all inline. The reader-side atomic loads and the function-pointer vs void* aliasing that Copilot raised are already covered (AIRUNTIME-2837), so I haven't repeated them.
One more nit I couldn't anchor inline: projects/hrr/DESIGN.md:664 still says "Shutdown uninstalls shims". With this change, slots that another component re-wrapped are deliberately left in place, so a short qualifier there would keep it consistent with the new paragraph at line 281.
de0f4a1 to
a41fedc
Compare
|
Also qualified the shutdown line in DESIGN.md: it now says wrapped slots keep their wrapper and that events reaching a shim after the trailer are dropped. |
danielbolsson
left a comment
There was a problem hiding this comment.
Approving. Swapping the capture shims in slot by slot is correct and fixes the torn-table race. Ordering, layout (static_asserts, 8-byte-aligned slots) and chaining with rocprofiler-sdk all check out. The generator output is byte-identical to the checked-in files (gen_hrr_api_args.py --check-hrr-coverage passes with a clean tree). No blocking issues. Follow-ups, most important first:
- The CLR profiler still copies the whole live table (outside this diff):
profiler/hip_clr_dispatch_wrappers.cpp:6101-6126.- Install and Remove each
memcpythe full dispatch tables, fromhipProfilerEnableExt/DisableExton any thread after init. That is the same race this PR fixes. - The profiler's "next" snapshot holds HRR shim pointers. A disable after HRR's atexit uninstall would put the shims back, which only the new post-trailer check makes harmless.
- Worth a follow-up (ideally one shared slot-wise helper). The PR description should also say that the in-tree profiler changes the table after init.
- Install and Remove each
- Blobs and code objects are still written after the trailer.
write_blobandwrite_code_objectonly checkg_events_fd < 0. A late shim can write an orphan blob and bumpg_blob_countafter the manifest is finalised. Suggest adding|| g_trailer_written(both already holdg_file_mu). - DESIGN.md wording:
- Around line 291 and lines 676-678, the text implies the compiler table is uninstalled too. It never is; its shims stay live through static destruction.
- Line 49 still describes installing a "parallel capture table".
- Line 287 calls
hip_table_interface.cpp"shared with rocprofiler".
- Platform branches in
hip_capture.cpp:DispatchSlottests GCC/Clang first, butstore_slot/replace_slottestIS_WINDOWSfirst. On clang-cl themay_aliasprotection is bypassed.- The helpers have no
#else #error.
- Nits:
g_trailer_writtenis a plainbool, written without the lock inemergency_finalize. That path is unreachable today, butstd::atomic<bool>would be safer.- Dropped post-trailer events are silent, and the header still says
flush()is "safe to call multiple times" although it is now terminal. #include <cstring>in the generated file is unused now.- A one-line comment on why
install_shimsis last-writer-wins would help.
Not verified: I didn't build or run anything, including Windows and clang-cl builds. A reader-side test that records after the trailer are not replayed would be cheap using the existing hrr_recovery_test.cc fixtures.
mangupta
left a comment
There was a problem hiding this comment.
🤖 AI-assisted pre-review (for @mangupta)
[IMPORTANT] write_event_raw gates on g_trailer_written, so any non-terminal trailer write silently stops capture for the rest of the process
The new early return in write_event_raw keys the "stop recording" decision on g_trailer_written — the flag that records that an EOF trailer has been emitted — rather than on a shutdown/closing latch. g_trailer_written is only cleared by writer::open() and in the fork child (per the author's reply on r4120637536), so if the trailer is ever written on a path that is not process teardown, every subsequent event is dropped for the remaining lifetime of the process while g_events_fd is still valid and the archive stays open.
Suggested fix: Latch a dedicated closing flag (e.g. g_closing) set under the same lock that writes the trailer on the shutdown path only, and test that flag here instead of g_trailer_written — this is what the reviewer originally proposed. If writer::flush() is genuinely shutdown-only, clear g_trailer_written at the end of a non-terminal flush or assert the shutdown-only precondition at the trailer write site so a future checkpoint caller cannot silently disable capture. Also record the drop (a counter or one-shot log) so a truncated archive is diagnosable rather than silent.
|
I'm not sure a separate |
|
Follow-ups 2 to 5 are in 295fd7ecc7 and 0331f1c607 apart from a log for the dropped events, and the profiler's whole-table copy has its own ticket, AIRUNTIME-2845. It builds with and without capture here (GCC 11.4, and the HRR objects with AMD Clang 23), the HRR suites pass on MI350X, and Windows CI built hip-clr with MSVC 19.44 on the previous head, but I haven't tried clang-cl; the numbers are in the description's Test Result. The reader-side test would fail until the reader stops at the trailer. Should we create a separate small ticket for it? |
The capture preamble keeps develop's <cstring>: the shims generated since #10641 call std::memcpy and std::strlen.
|
Merged develop in 05aed750c1, which brings |
|
Decision record for the finding "Clear inherited unreplayable API metadata when resetting child archives" (r5414764727, |
|
🤖 AI-assisted pre-review (for @mangupta) Re-reviewed the updated head ( |
libamdhip64 registers with rocprofiler-register only in a shared Linux build with HIP_ENABLE_ROCPROFILER_REGISTER on. In any other build no tool gets the HIP table, so the four tests that need it failed. The workload now checks whether the runtime registered and, only when it did not, reports it and the driver skips. A registered runtime that withholds the table still fails.
…raghetti/hrr-capture-dispatch-swap
|
🤖 AI-assisted pre-review (for @mangupta) Re-reviewed the updated head ( |
|
🤖 AI-assisted pre-review (for @mangupta) Re-reviewed the updated head ( |
# Conflicts: # projects/clr/hipamd/src/hrr/hip_capture_writer.cpp
|
🤖 AI-assisted pre-review (for @mangupta) Re-reviewed the updated head ( |
# Conflicts: # projects/clr/hipamd/src/hrr/hip_capture.cpp # projects/clr/hipamd/src/hrr/hip_capture_writer.cpp # projects/hrr/tests/integration/CMakeLists.txt
|
🤖 AI-assisted pre-review (for @mangupta) Re-reviewed the updated head ( |
Two threads capturing the same new bytes both missed g_written_blobs and both reserved space for the blob, so with room for one copy above the reserve the second reservation stopped the capture. A caller now claims the key before reserving; another caller with the same bytes waits for the claim to end and returns the hash only once the file is on disk. Also unset HIP_HRR_CAPTURE_OUTPUT in the secure-execution test's uncaptured run, so a test launched under capture does not pass it on.
|
🤖 AI-assisted pre-review (for @mangupta) Re-reviewed the updated head ( |
…#12258) * fix(hrr): swap the capture shims into the dispatch table slot by slot * fix(hrr): drop capture events that arrive after the EOF trailer * docs(hrr): wrap the new DESIGN.md paragraphs like the rest * docs(hrr): say that shutdown leaves the compiler table shims in place * fix(hrr): drop blobs after the trailer and make the slot guards agree * fix(hrr): order blob claims with the trailer so the manifest counts them * fix(hrr): wait for claimed blob writes before finalizing the archive flush() closed blob and code object claims but did not wait for the writes already claimed, so one that failed afterwards could leave a trailer and a complete manifest whose blob count names a file that is not there. Count the claimed writes under g_blob_mu, have flush() wait for them before it decides on the trailer, and mark the capture incomplete when one fails. * fix(hrr): declare the claimed-write count apart from develop's new globals develop now declares the unreplayable-API set right after g_written_blobs, where the in-flight count and its condition variable were added, so the branch no longer merged cleanly. Declare them before the blob set instead. No change in behaviour. * fix(hrr): clear the claimed-write count when a fresh archive opens A forked child inherited the count of blob writes its parent's other threads had in flight. None of those threads exists in the child, so its flush() waited for them forever. open() starts a fresh archive with no claimed writes, as it already starts one with no written blobs. * fix(hrr): hold the writer locks across fork A thread holding g_blob_mu or g_file_mu at fork() leaves the lock taken in the child, where atfork_child() and open() then block on it. Take both in the prepare handler, release them in the parent, and reset the claimed-write count in the child before releasing them there, on every open path rather than only a fresh archive. * fix(hrr): say what the fork child's writer reopen relies on The comment claimed the reopen in the atfork child handler is safe because the child is single-threaded. It is not async-signal-safe; it relies on glibc's fork resetting malloc's locks and the stdio list lock, and on the writer's own locks being held across fork(). * fix(hrr): finish a claimed blob write on every way out, a throw included An ensure_dir() or allocation throw after the claim left the write counted in flight, and flush() then waited for it forever. ClaimedWrite finishes the claim from its destructor and counts the write as failed unless it was reported done. * fix(hrr): clear the inherited incomplete flag in a fork child The child writes its own pid-<pid> archive, but kept the parent's incomplete flag, so any failure the parent had recorded cost the child its trailer and reported its archive as complete: false. * fix(hrr): clear the incomplete flag when a fresh archive is opened A failed blob write marks the process incomplete, and a later archive opened fresh in the same process inherited it and lost its trailer. Clear it with the rest of the fresh-archive state, as the fork child does. * fix(hrr): ignore mark_incomplete past the trailer decision flush() cuts off blob and code object writes by setting g_trailer_written under g_blob_mu, but a shim could still mark the capture incomplete after that, leaving a clean trailer beside manifest.complete=false or an error logged for an event dropped after shutdown. mark_incomplete now takes g_blob_mu and returns once the trailer is decided. A claimed write that fails still counts, since flush() waits for it before reading the flag. * test(hrr): cover the slot-by-slot uninstall and the writer's shutdown cut-off A wrapper put into a dispatch slot after install keeps its slot through uninstall while the other slots are restored. A shim still reached through that wrapper adds no record after the trailer and no blob the manifest does not count. A failed blob write leaves the archive without its trailer, and a child forked afterwards still gets its own trailer while the parent keeps capturing. The workloads reach the live table through rocprofiler-register, with a small tool library built next to the tests. * fix(hrr): list unreplayable APIs with their events, and fail blob writes that fail at close A shim still reached through a wrapped slot after flush() called note_unreplayable() before write_event_raw(), so the manifest could list an API whose event the shutdown cut-off dropped. The note is now staged per thread and listed by write_event_raw() under the lock that accepts the event, or dropped with it. atomic_write_file() now counts a failing fclose() as a failed write, so a blob whose buffered data cannot be flushed marks the capture incomplete instead of being published truncated. Unit_HRR_DispatchSwap_NoUnreplayableAfterTrailer calls the hipUserObjectCreate shim as the capture opens its manifest, through an fopen() hook in the preloaded dispatch tool. Unit_HRR_FailedBlobClose_LeavesCaptureIncomplete makes a 2 KiB blob fail only at fclose() under a 1 KiB file size limit. * fix(hrr): list only the archive's own unreplayable APIs A forked child and a fresh archive started with the unreplayable APIs of the archive before them, so the child's manifest named calls only the parent made. Clear the list where the incomplete flag is cleared, and hold its lock across fork() so the child can take it. The generated shims noted a truncated argument only on the first truncation in the process, so a child's own truncated call was not listed. They now note every truncation and still warn once. * test(hrr): skip the dispatch-table tests when HIP does not register libamdhip64 registers with rocprofiler-register only in a shared Linux build with HIP_ENABLE_ROCPROFILER_REGISTER on. In any other build no tool gets the HIP table, so the four tests that need it failed. The workload now checks whether the runtime registered and, only when it did not, reports it and the driver skips. A registered runtime that withholds the table still fails. * fix(hrr): charge the free-space reserve once per deduplicated blob Two threads capturing the same new bytes both missed g_written_blobs and both reserved space for the blob, so with room for one copy above the reserve the second reservation stopped the capture. A caller now claims the key before reserving; another caller with the same bytes waits for the claim to end and returns the hash only once the file is on disk. Also unset HIP_HRR_CAPTURE_OUTPUT in the secure-execution test's uncaptured run, so a test launched under capture does not pass it on.
TheRock Submodule Bump ActivityNewest first
|


Motivation
HRR capture puts its shims into the live HIP dispatch table during
hip::init()and takes them out again at exit. Ondevelop(89103b1c577) both steps copy a wholeHipDispatchTableover the live one withmemcpywhile other threads may be calling through it. A caller can read a slot in the middle of the copy, and a bulk copy does not guarantee that each function pointer is written in one store. Uninstall also copies back the entire snapshot taken at install, which discards any slot another component changed afterhip::init(). Tools that register through rocprofiler-register normally change the table at its first access, beforehip::init(), so they are not affected; a tool attached to a running process through rocprofiler-register's attach path receives the table later. The in-tree CLR profiler also writes the tables after HRR has installed its shims: it copies whole tables over the live ones duringhip::init()whenGPU_CLR_PROFILE_OUTPUTis set, and later fromhipProfilerEnableExt()andhipProfilerDisableExt()on any thread. With capture on, the table it puts back on disable already holds HRR's shims. That code is left for a follow-up, AIRUNTIME-2845.Technical Details
HipDispatchTableandHipCompilerDispatchTableare asize_tfollowed only by function pointers, whichHIP_ENFORCE_ABIinhip_api_trace.cppenforces outside Windows. Install and uninstall now walk the pointer slots instead of copying the struct. Astatic_assertinhip_capture.cppstates the layout againstsize_t, because those ABI checks are not compiled on Windows.sizefield and the slots without a shim are never written.amdhip64is built as C++17 on Linux and C++20 on Windows, sostd::atomic_refis available only on Windows. One#ifpicks the slot type and both atomics together. UnderIS_WINDOWSthe slots are a plain view written throughstd::atomic_ref, as inpacket_store_release()inrocclr/device/rocm/rocvirtual.cpp; MSVC does no type-based alias analysis and clang-cl disables it by default. Elsewhere they are amay_aliasview written with the__atomicbuiltins of GCC and Clang, and any other compiler stops at an#error.write_event_raw(),write_blob()andwrite_code_object()inhip_capture_writer.cpptherefore drop anything that reaches them once the clean-shutdown trailer is written: the reader would replay such an event as part of a complete archive, and such a blob would be missing from the manifest'sblob_count. A blob or code object that is still being written whenflush()runs is counted when it claims its file underg_blob_mu, before the write, andflush()sets the flag underg_blob_muas well, so the manifest counts every file claimed before the trailer and a call that only reaches the claim after it is dropped.open()and the fork child already reset the flag, which is now astd::atomic<bool>becauseemergency_finalize()uses it without either lock. The comment onflush()says it ends the capture and thatcheckpoint()is the flush to use while capture runs.note_unreplayable()only stages its note, andwrite_event_raw()lists it inmanifest.unreplayable_apisunder the same lock that accepts the event, so a dropped event lists nothing.atomic_write_file()counts a failingfclose()as a failed write, because a buffered write can fail only there.g_events_finalized, whichflush()sets to cut off events and blob claims. The writer locks are now taken in the orderg_reopen_mu,g_file_mu,g_blob_mu,g_unreplayable_mu, andatfork_prepare()follows it.flush()waits for claimed writes underg_blob_muwhile it holdsg_file_mu, so develop's order,g_blob_mubeforeg_file_mu, could deadlock it againstfork(). A forked child lists only its own unreplayable APIs:open()clears the list for a new archive.Unit_HRR_ForkWhileNotingUnreplayabletherefore expects the API in the parent's manifest and not in the child's.g_blob_claims) before reserving; another caller with the same bytes waits for the claim to end and gets the hash only once the file is on disk. From #12252: the secure-execution test's uncaptured run unsetsHIP_HRR_CAPTURE_OUTPUT, so a test launched under capture does not pass it on.projects/hrr/tools/gen_hrr_api_args.py. It now emits a call to the newhip_capture_install_compiler_table()instead of thememcpy, andhip_capture_generated.cppis regenerated; that call is the only change across the three generated files. The compiler table loses its shims, the same way, only when the archive cannot be opened (#12253), never at shutdown.GetHipDispatchTable()is markedNO_VECTORIZEso that GCC does not vectorise the table's one-time initialisation into code that runs ahead of the initialisation check on every call. It is a performance workaround, it does not change how slots are read or written, and Clang ignores the attribute. It matters here only as a reminder that a plain slot-by-slot copy loop gets vectorised back into stores that span two slots; atomic stores are never merged or widened.hip_table_interface.cppstill load slots with ordinary reads (AIRUNTIME-2837). With every write now a single aligned pointer-sized store, a caller gets either the old function or the new one.projects/hrr/DESIGN.mddescribes the new install, uninstall and shutdown, including that the compiler table keeps its shims at shutdown.Issue Tracking
JIRA ID: AIRUNTIME-2834, AIRUNTIME-2832
Test Plan
gen_hrr_api_args.pyand compare with the committed generated files.amdhip64with GCC 11.4, with and withoutHIP_HRR_CAPTURE_ENABLED, and compile the three HRR objects again with AMD Clang 23 and-Wall -Wextra.hip_capture_writer.cppcompiled on its own against small stubs for logging and the clock, with GCC 13 and Clang 23. One driver opens an archive, writes three events, a blob and a code object, callsflush(), writes one more of each as a wrapped slot would, callsclose(), and counts the records after the trailer and the blob and code object files the manifest does not count. A second driver keeps writing blobs and events from another thread while shutdown runsflush()andclose(). Both run against the writer on develop and after this change.fopen()andsnprintf()wrapped, so that the driver can hold one thread just before it claims a blob or code object, or inside the write, while the main thread runsflush(). A third case has a second call for the same content return while the first is held and record its event before the trailer. Built with GCC 13.3, Clang 18.1, GCC 11.4 and AMD Clang 20, and the threaded cases also run under ThreadSanitizer with GCC 13.hip_capture.cppcompiled on its own with GCC 13 and Clang 23, the non-Windows branch as C++17 and theIS_WINDOWSbranch as C++20 with libstdc++'sstd::atomic_ref, with a driver that installs and uninstalls shims on a small table.projects/hrr/tests/integration/hrr_dispatch_swap_test.ccreaches the live table through rocprofiler-register, withhrr_dispatch_tool.ccas the tool library. The tool stands in front offopen()andopen(), since the manifest is opened withopen(). It covers the wrapped slot across uninstall, the trailer cut-off, a blob write that fails infwrite()or only infclose(), an unreplayable call made after the trailer, and fork() after a failed write.Test Result
amdhip64, capture compiled out (GCC 11.4)src/hrramdhip64,HIP_HRR_CAPTURE_ENABLED(GCC 11.4)src/hrr-Wall -Wextra, both configurationslibamdhip64)Unit_HRR_DiskSpace_DuplicateChargedOnce, MI350Xhip_capture_writer.cppafter ff982e7b3e, with the CLR build's compile command and AMD Clang 20, both configurationsWindows was not built locally. The Windows CI build of an earlier head, 37967472f0, compiled
hip-clrwith MSVC 19.44 and passed (job); for MSVC, 0331f1c607 only reorders the statements it compiled, and clang-cl now takes the same plain view.Submission Checklist