Skip to content

fix(hrr): make the capture writer safe across fork and in the crash path - #12255

Merged
andraghetti merged 21 commits into
developfrom
users/andraghetti/hrr-capture-fork-crash
Oct 8, 2026
Merged

andraghetti merged 21 commits into
developfrom
users/andraghetti/hrr-capture-fork-crash

Conversation

@andraghetti

@andraghetti andraghetti commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

The capture writer installs pthread_atfork handlers so that a forked child writes its own pid-<pid> archive. On develop (89103b1c577) the prepare handler held the writer's file mutex only while it flushed, and did not take the blob mutex at all, so a child could inherit either mutex locked by another thread of the parent and then block for ever when it reopened its archive. The crash path had two smaller problems: emergency_finalize() kept a 129 KiB buffer on the stack of the thread that crashed, and a writer thread did not wait when the crash callback was already flushing the event buffer.

Technical Details

All changes are in projects/clr/hipamd/src/hrr/hip_capture_writer.cpp.

  • atfork_prepare() takes g_reopen_mu, g_blob_mu and g_file_mu in that order and flushes the event buffer. It then takes g_unreplayable_mu and keeps all four mutexes locked across fork(). The new atfork_parent() and the start of atfork_child() unlock them, which is the usual pthread_atfork pattern. No other code path holds two of them in another order, so the new order cannot invert an existing one.
  • writer::close() clears g_reopen_after_fork under g_reopen_mu. A forked child that had not recorded before its shutdown would otherwise open a new archive when a fat-binary destructor records later. Nothing would finalize that archive.
  • atfork_child() sets g_reopen_after_fork only when the parent had its events file open. A child forked after the parent's capture shutdown opens no archive. A parent that is itself a child yet to open its archive passes its flag on unchanged.
  • claim_buf_locked() raises g_buf_busy for a thread that already holds g_file_mu, and yields while the crash callback holds the flag. BufWriteGuard and atfork_prepare() use it. The crash callback still takes g_buf_busy with one test_and_set and never touches the mutex.
  • emergency_finalize() builds the manifest in a static buffer at file scope instead of on the stack. CLR runs the crash callback on the stack of the thread that faulted (on Windows also for EXCEPTION_STACK_OVERFLOW), where 129 KiB may not be left. g_emergency_manifest_busy keeps concurrent crash callbacks out of that buffer, and a second entrant skips the manifest.
  • atfork_child() also clears g_buf_busy and g_emergency_manifest_busy. A crash callback running on another thread at the moment of fork() would otherwise leave them raised in a child that has no thread to clear them: the child's writers would wait on g_buf_busy for ever, and the child's own crash callback would skip its manifest.
  • open() prepares the archive in locals and publishes it under g_file_mu and g_buf_busy, file header included. A forked child opens its archive on another thread's first record, and a checkpoint, the crash callback or the shutdown could otherwise see the events fd before the header. flush() takes g_reopen_mu, so the shutdown waits for that open, and clears g_reopen_after_fork, so no archive opens after it.

A process that neither forks nor crashes behaves as before. A fork() now also waits for a blob-set update in progress on another thread, as it already waited for an event write.

Issue Tracking

JIRA ID: AIRUNTIME-2831

Test Plan

  • HRR capture objects compiled with ROCm Clang and -Wall -Wextra -Wno-unused-parameter -Wno-sign-compare -Wno-missing-field-initializers -Wno-unknown-attributes -Wno-inconsistent-missing-override, with and without HIP_HRR_CAPTURE_ENABLED.
  • HRR unit and integration suites on MI350X (gfx950), with capture compiled into CLR.
  • Fork stress, by hand: one thread captures hipMemcpy in a loop while the main thread forks 2000 times. Each child exits at once and counts as hung if it has not exited after 3 seconds. Three runs against this branch and three against the capture runtime of develop 89103b1c577, whose writer and CLR crash handler are identical to this branch's base.
  • Crash on a small stack, by hand: with capture on, a thread created with a 64 KiB stack and a 1 MiB guard raises SIGSEGV, and pid-<pid>/manifest.json is checked afterwards. Three runs against each runtime.
  • Crash during fork, by hand: the main thread raises SIGSEGV, and an fsync() defined in the test binary holds the crash callback inside the manifest write while a second thread forks. The child then raises SIGABRT: CLR installs its handlers with SA_RESETHAND, so the parent's SIGSEGV handler is already reset by then. The child's pid-<pid>/manifest.json is checked afterwards. Three runs against each runtime, and three against the first commit of this branch alone.

Seven cases now cover this PR in projects/hrr/tests/integration. Each ran on Daniel's CI branch, users/danielbolsson/hrr-development, merged with this branch.

  • 771f55e adds Unit_HRR_CaptureCrashOnSmallStack, the small-stack crash above. It also adds Unit_HRR_ForkWhileRecording, the fork stress cut to 200 forks with a 10-second deadline per child. That one checks that every child opened its own archive. Both pass in run 36909753240.
  • 403613f adds Unit_HRR_ForkWhileWriterHoldsLock. It holds a recording thread in its checkpoint fsync() and forks at the moment that thread would take the events mutex. It checks that the child exits and that both archives exist. It also adds Unit_HRR_CaptureCrashDuringFork, the crash-during-fork check above, with the same fsync() defined in the test binary. It checks both manifests. All four pass in run 37287880456. With the writer change reverted (run 37287883463), the first three fail on gfx94x, gfx110x, gfx1151 and gfx120x. Unit_HRR_CaptureCrashDuringFork passes there, because develop has no manifest flag to inherit.
  • 3daf63e adds Unit_HRR_ForkWhileNotingUnreplayable. A strlen() defined in the test binary holds a thread inside note_unreplayable(), and the main thread forks. It checks that the child exits and that both manifests name hipUserObjectCreate.
  • 09c9d27 adds Unit_HRR_ForkedChildRecordsAfterShutdown. A forked child records through the compiler dispatch table after its own hip_capture_shutdown(). It checks that the child leaves no archive. Both pass on six GPU families in run 37303308008. With 3daf63e and 09c9d27 reverted (run 37303311499), both fail on five of them.
  • a5fb668 adds Unit_HRR_ForkAfterCaptureShutdown. The parent forks after its own capture shutdown, and the child records before it exits. It checks that only the parent has an archive. It passes on five GPU families in run 37305365330. With a5fb668 reverted (run 37305368949), it fails on the same five.
  • 353fa9f adds Unit_HRR_ShutdownWhileChildOpensArchive. An mkdir() defined in the test binary holds a child's second thread inside the open of the child's archive while the child's main thread exits. It checks that both archives end in the clean-shutdown trailer and have a complete manifest. It passes on gfx94x, gfx103x, gfx120x, gfx1150 and gfx1151 in run 37444441420. With the writer change of 353fa9f reverted (run 37444444869), the child's archive has no trailer on gfx94x, gfx110x, gfx120x, gfx1150 and gfx1151. Runners with no visible GPU failed gfx110x in the first run and gfx103x in the second.

In these runs, the other failures are ones that Daniel's branch has without this PR. Those are gfx1150 runners with no visible GPU and the Unit_HRR_Regions_* fatal on gfx103x and gfx90a. gfx94x lost its runner during the suite in run 37305365330. The table below holds the checks by hand and predates these cases.

Test Result

Check develop 89103b1c577 This branch
HRR capture objects, capture compiled out not run 0 errors, 0 HRR warnings
HRR capture objects, HIP_HRR_CAPTURE_ENABLED not run 0 errors, 0 HRR warnings
HRR unit tests (23 test cases) not run pass
HRR integration tests (54 test cases) not run pass
Fork stress, hung children in each of 3 runs of 2000 forks 0, 1, 1 0, 0, 0
Crash on a 64 KiB stack, 3 runs no manifest.json manifest.json written with "complete": false
Crash during fork, child's manifest.json, 3 runs written with "complete": false written with "complete": false

The develop fork-stress count varies from run to run: a second set of three runs gave 0, 0, 1 hung children, and this branch 0, 0, 0 again.

With the first commit of this branch alone, the child's manifest.json was missing in all 3 runs of the crash-during-fork check, because the child inherited the manifest flag raised. The second commit clears it in atfork_child(). develop has no such flag.

In both crash checks every process ends on the signal it raised (SIGSEGV for the parent, SIGABRT for the forked child). Where the callback completes, the crash handler re-raises the signal once it returns. On develop the small-stack callback does not complete: its 129 KiB buffer does not fit on the 64 KiB stack, so it faults before it flushes anything (events.bin stays empty) and that second SIGSEGV ends the process. With a 256 KiB stack develop writes the manifest as well.

Windows was not built here. The fork handlers are POSIX only, and claim_buf_locked(), the manifest flag and the static buffer use only standard C++.

Submission Checklist

@andraghetti
andraghetti requested review from a team as code owners September 25, 2026 10:37
Copilot AI lite review requested due to automatic review settings September 25, 2026 10:37
@therock-pr-bot

therock-pr-bot Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

✅ All Policy Checks Passed

Check Status Details
📝 PR Description ✅ Pass —
⛔ Forbidden Files ✅ Pass —
🧪 Unit Test ✅ Pass —
🚫 Draft PR 🔜 To Be Enabled —
🚩 Feature Flag 🔜 To Be Enabled —
📊 Code Coverage 🔜 To Be Enabled —

🎉 All policy checks passed!

📖 Need help? See the Policy FAQ for details on every check and how to fix failures.

🙋 Wish to Override Policy?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Address unsafe child reinitialization after fork() and concurrent emergency-buffer access.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

Updates the HRR capture writer for safer fork() handling and crash-time finalization.

Changes:

  • Coordinates writer state across fork().
  • Synchronizes crash-buffer access.
  • Moves emergency manifest storage off the crashing thread’s stack.
File Summary
projects/​clr/​hipamd/​src/​hrr/​hip_capture_writer.cpp Implements fork synchronization and crash-path buffer changes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@andraghetti
andraghetti force-pushed the users/andraghetti/hrr-capture-fork-crash branch from 17a8f7c to 887def7 Compare September 25, 2026 11:36

@danielbolsson danielbolsson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed with a focus on fork safety and the crash path. No blocking issues from me. Holding g_blob_mu and g_file_mu across fork() and releasing them in the parent and child handlers is the right shape. I found no path that takes them in the opposite order. glibc also runs the parent handler when fork() fails, so a failed fork does not leave them locked. The other pthread_atfork prepare handlers in the tree (libhsakmt, rocSHMEM) take locks that are never held while calling into the HRR writer, so the prepare chain has no new cycle.

On the crash side, both new function-local statics are constant-initialized (ATOMIC_FLAG_INIT and a zero-initialized array), so they go in .bss with no __cxa_guard_* and are safe in the handler. claim_buf_locked() also fixes a subtle problem in the old guard: the old guard ignored the test_and_set result, and its destructor then cleared a flag that the crash callback held.

I left two small inline items, one non-blocking and one nit.

@mangupta mangupta left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 AI-assisted pre-review (for @mangupta)

@andraghetti
andraghetti force-pushed the users/andraghetti/hrr-capture-fork-crash branch from 887def7 to d9a8d5f Compare September 29, 2026 10:09

@danielbolsson danielbolsson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-reviewed at d9a8d5f. Both of my earlier comments are addressed.

Lock order checks out: open() and index_existing_blobs_locked() take g_blob_mu without g_file_mu held, so atfork_prepare() is the only path that holds both. atfork_child() clears both flags. The emergency manifest flag is released on the HRR_OPEN failure path. The unbounded claim_buf_locked() wait is fine: the crash callback holds the flag only for a single flush.

Non-blocking: the fork and crash cases are only tested manually. A small fork test that checks the child archive and manifest would be worth adding in a follow-up.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Fork and crash-handler interleavings need final human review, especially without automated regressions or a Windows build.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
Resolved since last review (1)

Unit_HRR_CaptureCrashOnSmallStack dies of SIGSEGV on a thread with a 64 KiB
stack and checks the manifest it leaves says "complete": false. CLR's crash
handler runs on the faulting thread's stack, so the old on-stack manifest
buffer overflowed there.

Unit_HRR_ForkWhileRecording forks 200 times while another thread records and
fails if a child does not exit within its deadline. It then expects one archive
per child besides the parent's. The lock windows are narrow, so it catches a
regression by chance rather than on every run.

Co-Authored-By: Claude <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The child atfork handler still invokes non-async-signal-safe C++ and filesystem operations that can deadlock after a multithreaded fork.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (1)

@mangupta

mangupta commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

🤖 AI-assisted pre-review (for @mangupta)

Re-reviewed the updated head (771f55e49855) — no new findings at or above the posting threshold.

The atfork child handler reopened the archive itself, but the child of a
multithreaded process may make only async-signal-safe calls until it
execs, and open() allocates, takes locks and goes through stdio and the
filesystem library. The handler now only drops the parent's events fd and
paths, and the first record, blob or code object in the child opens its
pid-<pid> sub-archive. A child that execs or exits without recording
leaves no archive; the fork test now has every other child record a call
and counts archives accordingly.

Co-Authored-By: Claude <noreply@anthropic.com>
The develop merge brought in hrr_disk_space_test.cc, which defines fsync
for the integration test binary to fake the output file system. The fork
tests in hrr_workload_test.cc already defined fsync to hold a thread at
the writer's checkpoint, so the binary failed to link with a duplicate
symbol. The workload file now provides hrr_workload_fsync_hook, which
runs the fork tests' hook, and the disk-space fsync calls it before
doing what it did before.

Co-Authored-By: Claude <noreply@anthropic.com>
@mangupta

mangupta commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

🤖 AI-assisted pre-review (for @mangupta)

Re-reviewed the updated head (a966925df1b6) — no new findings at or above the posting threshold.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Interacting fork and crash synchronization paths warrant human review, particularly with Windows validation outstanding.

Review effort: Balanced
Findings: None

Resolved since last review (1)

# Conflicts:
#	projects/clr/hipamd/src/hrr/hip_capture_writer.cpp

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

An in-flight record can still follow the shutdown trailer, and the regression does not detect that invalid ordering.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)

@mangupta

mangupta commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🤖 AI-assisted pre-review (for @mangupta)

Re-reviewed the updated head (3d0e6999f63b) — no new findings at or above the posting threshold.

The capture shutdown writes the trailer and the manifests, then closes
events.bin. A thread that recorded in between, such as a forked child's
first record finishing the open of its archive, landed after the trailer:
the trailer then undercounted the events, and a resume dropped the record.

flush() now marks events.bin finalized, with or without a trailer, and a
record after that is dropped, as one after close() is.

Unit_HRR_RecordAfterCaptureShutdown holds a second thread inside a recorded
call until the shutdown reaches its first manifest. It then checks that
events.bin ends in the trailer and that the trailer counts every event. The
fork shutdown case checks the same, since load_archive() reads on past a
trailer.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

An unresolved fork/shutdown race needs correction, and the cross-platform synchronization changes warrant final human review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (2)

hip_capture_shutdown() runs flush(), which writes the trailer and the
manifests, then close(), which closes events.bin. A thread that forks in
between left the child with a reopen flag, because the parent's fd was
still open. The child's first record then opened a new archive that
nothing finalized: the parent's exiting thread runs the shutdown, so it is
no longer pending in the child. atfork_child now arms the reopen only for
an archive that is open and not finalized.

Unit_HRR_ForkBetweenCaptureFlushAndClose holds the shutdown at the first
lock close() takes, through a pthread_mutex_lock hook, while a second
thread forks. The child records through the compiler dispatch table. The
test checks that only the parent has an archive, and that it is complete.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🔵 Needs a closer look

Fork, crash-handler, and shutdown synchronization require final human review, particularly with Windows behavior unvalidated.

Review effort: Balanced
Findings: None

Resolved since last review (1)

@mangupta

mangupta commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🤖 AI-assisted pre-review (for @mangupta)

Re-reviewed the updated head (b63e6609ed29) — no new findings at or above the posting threshold.

@andraghetti
andraghetti merged commit 1db968c into develop Oct 8, 2026
411 of 455 checks passed
@andraghetti
andraghetti deleted the users/andraghetti/hrr-capture-fork-crash branch October 8, 2026 09:58
andraghetti added a commit that referenced this pull request Oct 9, 2026
Resolve hip_capture_writer.cpp against #12255: the pinned host snapshot
fork hooks run around the writer's fork handlers, prepare before the
writer takes its locks and parent/child after it releases them, and the
snapshot counters reset in open() inside the locked publish block.
@systems-assistant

Copy link
Copy Markdown
Contributor

TheRock Submodule Bump Activity

Newest first

anujshuk-amd pushed a commit that referenced this pull request Oct 12, 2026
…ath (#12255)

* fix(hrr): make the capture writer safe across fork and in the crash path

* fix(hrr): clear the emergency manifest flag in a forked child

* docs(hrr): state the writer lock order and what bounds the buffer wait

* test(hrr): cover fork while recording and a crash on a small stack
  
  Unit_HRR_CaptureCrashOnSmallStack dies of SIGSEGV on a thread with a 64 KiB
  stack and checks the manifest it leaves says "complete": false. CLR's crash
  handler runs on the faulting thread's stack, so the old on-stack manifest
  buffer overflowed there.
  
  Unit_HRR_ForkWhileRecording forks 200 times while another thread records and
  fails if a child does not exit within its deadline. It then expects one archive
  per child besides the parent's. The lock windows are narrow, so it catches a
  regression by chance rather than on every run.

* fix(hrr): reopen a forked child's archive on its first record
  
  The atfork child handler reopened the archive itself, but the child of a
  multithreaded process may make only async-signal-safe calls until it
  execs, and open() allocates, takes locks and goes through stdio and the
  filesystem library. The handler now only drops the parent's events fd and
  paths, and the first record, blob or code object in the child opens its
  pid-<pid> sub-archive. A child that execs or exits without recording
  leaves no archive; the fork test now has every other child record a call
  and counts archives accordingly.

* fix(hrr): declare the fork reopen state apart from develop's new globals
  
  develop now declares the unreplayable-API set right after g_written_blobs,
  where the reopen flag and mutex were added, so the branch no longer merged
  cleanly. Declare them before the blob set instead. No change in behaviour.

* fix(hrr): clear the inherited capture-incomplete flag in a forked child
  
  The child records into a new archive, so an event the parent dropped
  before the fork does not make the child's archive incomplete.

* fix(hrr): stop the fork-while-recording loop at the first hung child
  
  A regression that hung every child would have waited ten seconds for each
  of them, past the roundtrip case's timeout, and left the later ones behind.
  The loop now stops after killing and reaping the first.

* test(hrr): cover the writer mutex across fork and a crash during fork
  
  Unit_HRR_ForkWhileWriterHoldsLock forks at the moment another thread could
  take the capture writer's events mutex if the writer's prepare handler let it
  go before fork(), and checks that no thread got it and that the child, which
  reopens its archive under that mutex, exits. Unit_HRR_CaptureCrashDuringFork
  forks while the parent's crash callback owns the emergency manifest buffer and
  checks that the child still writes its own manifest when it crashes. Both stop
  the writer at an fsync() that the test binary interposes; with no hook set it
  is libc's fsync.

* fix(hrr): hold the unreplayable-API mutex across fork
  
  atfork_prepare() now also takes g_unreplayable_mu, after the other writer
  mutexes, and both fork handlers release it. A thread inside
  note_unreplayable() at fork() otherwise left the child that mutex locked,
  and a child that recorded and then exited normally blocked for ever when its
  capture shutdown wrote the manifest under it.
  
  Unit_HRR_ForkWhileNotingUnreplayable holds a thread inside
  note_unreplayable() with a strlen() that the test binary interposes, forks,
  and has the child record and exit through its capture shutdown. It checks
  that the child exits, that fork() waited for the held thread, and that both
  manifests list the API.

* fix(hrr): keep a forked child from opening its archive after close
  
  writer::close() now clears g_reopen_after_fork under g_reopen_mu. A forked
  child that had not recorded when its capture shutdown ran kept the flag set,
  so a later record, such as the one a fat-binary destructor makes through the
  compiler dispatch table, opened a new archive that nothing finalized.
  
  Unit_HRR_ForkedChildRecordsAfterShutdown forks a child that exits normally
  without recording and records after its capture shutdown, and checks that
  only the parent has an archive.

* fix(hrr): keep a child forked after close from opening an archive
  
  atfork_child() set the reopen flag whenever the writer had a base
  directory, also in a process that had already closed its writer. A child
  forked after the parent's capture shutdown then opened its own archive on
  its first record, and nothing finalized it.
  
  The child now sets the flag only when the parent had its events file open.
  A parent that is itself a child yet to open its archive passes its flag on
  unchanged.
  
  Unit_HRR_ForkAfterCaptureShutdown forks after the capture shutdown, records
  in the child and checks that only the parent has an archive.

* fix(hrr): finalize a forked child's archive only after it is open
  
  A forked child opens its archive on its first record, under g_reopen_mu
  only. open() published g_output_dir and the events fd before the file
  header was in the buffer, and without the writer mutex. A capture
  shutdown on another thread of the child could then write the manifest
  as complete, or the trailer before the header. A checkpoint or the crash
  callback could flush the buffer while open() reset it.
  
  open() now prepares the archive in locals and publishes it under the
  writer mutex and g_buf_busy, header included. It still takes g_blob_mu
  before g_file_mu and never both at once. flush() takes g_reopen_mu, so
  it waits for an open in progress, and clears the reopen flag, so no
  archive opens after it.
  
  Unit_HRR_ShutdownWhileChildOpensArchive holds a child's recording thread
  in that open, with an mkdir() defined in the test binary, while the
  child's main thread exits. It checks that both archives end in the
  clean-shutdown trailer and have a complete manifest.

* test(hrr): record in the forked child of the disk-space fork tests
  
  A forked child now opens its archive on its first record rather than in
  atfork_child(). The children of Unit_HRR_DiskSpace_RefusedForkChild and
  Unit_HRR_DiskSpace_ForkAfterStop exited without recording, so the refused
  child never reached open() and printed no "Capture disabled", and the
  fork-after-stop child could no longer show that it ignores the stop. Each
  child now pushes and pops a launch configuration before it exits, which
  records through the compiler dispatch table without touching the device.

* test(hrr): share one fsync between the fork and disk-space tests
  
  The develop merge brought in hrr_disk_space_test.cc, which defines fsync
  for the integration test binary to fake the output file system. The fork
  tests in hrr_workload_test.cc already defined fsync to hold a thread at
  the writer's checkpoint, so the binary failed to link with a duplicate
  symbol. The workload file now provides hrr_workload_fsync_hook, which
  runs the fork tests' hook, and the disk-space fsync calls it before
  doing what it did before.

* fix(hrr): drop a record that comes after the capture's trailer
  
  The capture shutdown writes the trailer and the manifests, then closes
  events.bin. A thread that recorded in between, such as a forked child's
  first record finishing the open of its archive, landed after the trailer:
  the trailer then undercounted the events, and a resume dropped the record.
  
  flush() now marks events.bin finalized, with or without a trailer, and a
  record after that is dropped, as one after close() is.
  
  Unit_HRR_RecordAfterCaptureShutdown holds a second thread inside a recorded
  call until the shutdown reaches its first manifest. It then checks that
  events.bin ends in the trailer and that the trailer counts every event. The
  fork shutdown case checks the same, since load_archive() reads on past a
  trailer.

* fix(hrr): open no archive in a child forked after the capture's flush
  
  hip_capture_shutdown() runs flush(), which writes the trailer and the
  manifests, then close(), which closes events.bin. A thread that forks in
  between left the child with a reopen flag, because the parent's fd was
  still open. The child's first record then opened a new archive that
  nothing finalized: the parent's exiting thread runs the shutdown, so it is
  no longer pending in the child. atfork_child now arms the reopen only for
  an archive that is open and not finalized.
  
  Unit_HRR_ForkBetweenCaptureFlushAndClose holds the shutdown at the first
  lock close() takes, through a pthread_mutex_lock hook, while a second
  thread forks. The child records through the compiler dispatch table. The
  test checks that only the parent has an archive, and that it is complete.
andraghetti added a commit that referenced this pull request Oct 14, 2026
…ddresses (#13186)

## Motivation

Replay gives every allocation a new address. A kernel that reads a
device address the application stored in memory, rather than received as
an argument, then reads the capture-time address and faults. vLLM's MRV2
path copies the block table's address to the device as plain data, and
`_compute_slot_mappings_kernel` faults on replay (ROCM-31827).

This change places `hipMalloc` and `hipMallocAsync` allocations at the
addresses capture recorded. An allocation that cannot be placed falls
back to the usual allocator.

## Technical Details

- Before `hipInit`, replay holds the recorded `hipMalloc` and
`hipMallocAsync` ranges with `PROT_NONE` placeholders, so the runtime
cannot take them. Allocations that hold an address exported with
`hipIpcGetMemHandle`, and any allocation over the same pages, are left
out, because IPC refuses VMM memory.
- Each allocation drops its placeholder, then reserves its recorded
address with `hipMemAddressReserve`, creates and maps the memory, and
grants access to its device and to every peer that can reach it. If a
step misses, the allocation falls back and is counted. Replay prints
`Placement : N placed at capture address, M fell back`.
- `hipFree` unmaps at once. `hipFreeAsync` synchronizes its stream
first, or keeps the mapping while a graph capture is open. The range is
then held again for the next allocation recorded there.
- gfx1201 on Linux 7.0.0-34 with the in-box amdgpu driver keeps a stale
GPU translation after `hipMemUnmap`. After each unmap, replay allocates
and frees 4 MiB, which makes KFD flush the TLBs. It skips the flush
while a graph capture is open.
- `--kernel-filter` releases placed allocations after the warm-up pass,
so the timed pass places them again.
- Placement is off with `--no-placement`, with `--guard-segments`, and
on Windows. With it off, replay behaves as on `develop`.
- Not placed: `hipExtMallocWithFlags`, pool allocations, region
segments, and the application's own VMM reservations. They keep today's
behaviour. `DESIGN.md` lists these limits.

## Issue Tracking

JIRA ID: AIRUNTIME-2893 (defect ROCM-31827)

## Test Plan

- `Unit_HRR_VaPlacement_StoredPointer`: a kernel dereferences a device
pointer the application stored with a host-to-device copy. The test
replays with placement on, with `--no-placement`, and with
`--kernel-filter`.
- `Unit_HRR_Playback_PlacementPlan` and
`Unit_HRR_Playback_PlacementHeldRanges`: the placement plan, including
the IPC exclusion, and the held ranges.
- The existing `Unit_HRR_MemcpyPeerRoundtrip` covers peer access on two
GPUs.
- Built `amdhip64` with `-DHIP_HRR_CAPTURE_ENABLED`, `hrr-playback` and
the tests for gfx950. Ran every Catch2 case, hidden ones included, on an
MI350X with one GPU, then the whole suite with two GPUs visible.
- Negative control: the same build with the placement code removed.
- Merged into Daniel's HRR CI branch and ran its matrix.

## Test Result

| Check | With this change | Without it |
| --- | --- | --- |
| Unit cases, MI350X | 25 of 25 pass | both placement cases fail |
| Integration cases, hidden ones included, MI350X | All pass except
cases that fail on `develop` too: `Unit_HRR_FirstMallocRoundtrip`,
`Unit_HRR_ApiMatrix_T2_Roundtrip`, `Unit_HRR_ApiMatrix_T4_Roundtrip`,
the 11 `Unit_HRR_DiskSpace_*_Direct` workloads, and six hidden fork,
crash and capture workloads from #12253 and #12255 that fail when run
without their visible test | `StoredPointer` fails |
| Whole suite, two MI350X GPUs | same as above; `MemcpyPeerRoundtrip`
passes | |
| HRR CI matrix | gfx94x, gfx950, gfx103x, gfx110x, gfx120x, gfx1150,
gfx1151, gfx1153 and both unit jobs pass. `MemcpyPeerRoundtrip` ran and
passed on gfx950 and on a two-GPU gfx110x host. gfx90a stopped at
`NullStreamMemsetOrdering` on the smci250 hosts, as on the base |
`StoredPointer` fails on every family |

## Submission Checklist

- [x] Look over the contributing guidelines at
https://github.com/ROCm/rocm-systems/blob/develop/CONTRIBUTING.md.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants