[ROCm] Fix teardown memory corruption and truncated traces on the rocprofiler-sdk backend - #1564
haishuok0525 wants to merge 1 commit into
Conversation
|
The following ciflow label(s) have been added but CI has not been triggered yet because the workflows are awaiting approval:
Once a maintainer approves the workflows (scroll to the bottom of the PR page), the corresponding CI jobs will be triggered automatically. Please ping one of the reviewers if you do not have access to approve and run workflows. |
ac576cf to
98ed0dc
Compare
98ed0dc to
8287434
Compare
8287434 to
d7501d2
Compare
Kernels launched by a HIP graph replay arrive with nothing indicating which graph or which node produced them, so a trace cannot separate per-node cost or follow one node across replays. On the CUDA side CUPTI reports graphId and graphNodeId on the kernel activity record; rocprofiler-sdk puts no graph identity on the dispatch record at all, and its HIP graph tracing exposes only EXEC_CREATE, EXEC_DESTROY and EXEC_LAUNCH, with no node-level operation, so the tool has to reconstruct it. This subscribes to ROCPROFILER_CALLBACK_TRACING_HIP_GRAPH to keep a per-thread stack of in-flight graph launches, and registers an external correlation id request service for kernel dispatches and memory copies. When the SDK asks for an external id during a graph launch, the callback returns the graph exec id in the high 32 bits and the ordinal of the dispatch within that launch in the low 32 bits, which buffer_callback puts on the row. Kineto's own External id travels through t_externalIds and externalCorrelations_ rather than this slot, so the two do not interact. Non-graph dispatches leave the slot at zero and emit no metadata, and an exec id too large to pack drops attribution with a warning rather than emitting an aliased id. The fields are emitted as "graph id" and "graph node id" under RocmMetadataFields, reusing the CudaMetadataFields key strings. "graph node id" deliberately carries the packed value rather than the bare ordinal, because that is the layout CUPTI uses and torch reads it back that way: _cuspy/_event_nodes.py recovers a launch's exec graph with "graph node id" >> 32, and _chrome_trace_export.py keys graph annotations on the whole value. That exporter is gated only on kineto_available(), so it is reachable on a ROCm build, and a bare per-replay ordinal would collide across execs and splice one graph's annotations onto another graph's nodes. One difference remains worth being explicit about: the low half is the position of the dispatch within the replay, where CUPTI reports an id assigned by CUDA in node-creation order. Both are stable identifiers within an exec, so "graph node id" remains a unique per-node key, but the ordinal follows dispatch order rather than creation order. Set KINETO_ROCM_DISABLE_GRAPH_ATTRIBUTION to opt out. Verified on a 32-node graph replayed 200 times on one GPU. Every graph-launched kernel that reaches the trace carries both fields, and on a run whose trace came out complete all 32 nodes appear exactly 200 times bar one at 199, which accounts for the single kernel that run was missing - so the ordinal is stable across replays and does not alias between nodes. With the opt-out set, no kernel carries either field and nothing else changes. Full-coverage numbers are only reproducible together with pytorch#1564. Without it the backend truncates the trace during teardown, to a median of 29% of the dispatches on this workload, which caps what attribution can be measured against. The truncation is FIFO and hits every node alike - each of the 32 appears 54 to 58 times rather than a few dropping out - so it is visible as a trace completeness problem rather than an attribution defect. The two changes are otherwise independent: this one neither reads nor modifies anything pytorch#1564 touches, and the branches merge cleanly. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Attaching the standalone reproducer referenced in the description, in case it is useful for reviewing this. It drives libkineto directly through its public API, so it needs no torch and no ROCm-specific profiler options — just a GPU and a ROCm build of this repo. It captures a HIP graph, replays it inside a trace window, and saves a chrome trace. Since the number of dispatches is known exactly (
gdb is not in the image I was testing in, so it installs a signal handler and prints its own frames. One caveat that turned out to matter: on a corrupted heap, that handler can itself wedge, because Build # libkineto
cmake -DKINETO_BACKEND=rocm -DROCM_SOURCE_DIR=/opt/rocm -DCMAKE_BUILD_TYPE=Release \
/path/to/kineto/libkineto
make -j kineto
# the repro, against the static lib just built
K=/path/to/kineto/libkineto
hipcc -std=c++20 -O2 -c e2e_graph.cpp -I$K/include -I$K/third_party/fmt/include -o repro.o
hipcc repro.o libkineto.a -L/opt/rocm/lib -lrocprofiler-sdk -ldl -lpthread -o reproRun export HIP_VISIBLE_DEVICES=0
for i in $(seq 20); do
rm -f out.json
timeout 60 env E2E_OUT=$PWD/out.json ./repro > run$i.log 2>&1
rc=$?
k=$(python3 -c 'import json;t=json.load(open("out.json"));
print(sum(1 for e in t.get("traceEvents",[]) if e.get("cat")=="kernel"))' 2>/dev/null || echo 0)
echo "run $i: rc=$rc kernels=$k/6400"
done
grep -h 'malloc()\|free():\|corrupted' run*.log | sort | uniq -c
On current main expect a mix of clean runs, segfaults and timeouts, and
|
| rocprofiler_flush_buffer(globalContext.buffer); | ||
| rocprofiler_stop_context(globalContext.context); | ||
|
|
||
| // Stopping the context does not wait for dispatches that are already in |
There was a problem hiding this comment.
Is continuously polling the only way to wait for rocprofiler-sdk to potentially finish draining? In the CUDA paths we have a CUPTI API which blocks and forces a flush, even for incomplete buffer records.
I see that the other changes have made concurrent modification on the rows vector safer, which is a good change, but I'm wondering if there's any way we can have a stronger guarantee on completeness here?
There was a problem hiding this comment.
Thanks, and sorry for the slow reply.
As far as I can tell, rocprofiler-sdk has no equivalent of CUPTI's forced flush. rocprofiler_flush_buffer() only hands over records already emplaced in the buffer, and a dispatch record is emplaced later, by the SDK's own signal handler thread once the completion signal fires. The SDK guarantees delivery at finalize, but not at a mid-run stop, which is the case we need here.
The option with a hard guarantee would be tracking correlation ids: note the highest one issued at stop and wait until its record comes through. That's a considerably bigger change. @mwootton is raising a behaviour change with the SDK team instead.
In the meantime I've cut this PR down to just the bounded wait: flush, stop, then flush until the row count has been stable for 30ms, with a 5s total timeout that warns. That alone fixes both the truncation and the crash on the repro (numbers are in the updated description). The locking changes are out of this PR and can follow separately if wanted.
There was a problem hiding this comment.
If we want to simplify this, wouldn't the safest thing be to keep the changes that guard concurrent modification instead? The sleep loop can still let stuff past if we're unlucky, and I'd much rather live with the consequences of dropping a few events than segfaulting a training job.
There was a problem hiding this comment.
That's fair, and some history would help here.
The first version of this PR did both: stop the context, flush until the row count stopped growing, and take rowsMutex_ / externalCorrelationsMutex_ in processActivities() and clearLogs().
After discussing it with @mwootton, I narrowed it for two reasons. First, the root cause is on the SDK side: rocprofiler-sdk has no reliable drain at a mid-run stop, and that is being raised with the SDK team. We didn't want the mutex to read as the fix for that, when what it really does is make a racy teardown survivable. Second, once the wait drains the in-flight records, nothing is appending to rows_ when processActivities() and clearLogs() run, so the normal path doesn't need the lock. I checked that before dropping it: over 40 runs with the wait alone there were 0 crashes, against 27 of 40 crashed or hung on main.
You're right that the loop is a heuristic, though. If the 5s timeout expires, or a long-running kernel completes after the row count already looked stable, a record can still land while those functions are running, and without the lock that's heap corruption again rather than a few missing events. I agree with your trade-off: a slightly short trace is much better than taking down a training job. I'm happy to put the locking back in this PR. @mwootton, any objection?
|
So the 816 and 817 lines are swapped in the original file. Trivial mistake.
is the correct order. I thought that should be sufficient. I had an agent dig through the rocprofiler_flush_buffer() implementation and it appears not (see the TLDR above). This is the same dumb situation as roctracer; and it probably needs the same workaround. The RoctracerLogger used to track the highest outstanding correlation id at the time of the 'stop' and waited for that record to come through. Rocprofiler-sdk can guaranty record delivery on finalize, but not mid flight stop; which is what we need here. The heap corruption if from the late callbacks happening after tracing is stopped; those should not be happening if the flushing is done correctly. So the stop -> flush -> readout phases SHOULD be exclusive without the mutex. We can add the mutex for safety. But in this case it is also "papering over" the bigger problem. It's a pretty big change to adopt the correlation tracking. I will inquire about a behavior change in the sdk. There are other backends that don't have this drain problem; so this is a choice. |
stopLogging() flushed the buffer and then stopped the context, and returned. rocprofiler-sdk emplaces a kernel dispatch record from its own completion-signal thread, and stopping the context does not wait for dispatches already in flight, so records keep arriving after collection has stopped. Teardown then ran while that thread was still appending to rows_ and externalCorrelations_. The published trace was missing most of the GPU work, and processActivities() and clearLogs(), which walk and clear those containers without the mutex, raced the appends and corrupted the heap. Flush, stop the context, then keep flushing until the row count has been stable for three polls 10ms apart, bounded by a 5s total timeout that warns and proceeds. Once the in-flight records have arrived nothing is appending any more, so teardown no longer races the callback thread. Measured on a HIP graph replay that dispatches 6400 kernels, MI355X, ROCm 10.0.0, rocprofiler-sdk 1.3.5, 40 runs per arm with a 60s timeout: main segfaulted or hung on 27 and never produced a complete trace (median about 1400 kernels). With this change there were no crashes or hangs and 39 runs were complete, one short by a single kernel. At 64000 dispatches, 10 of 10 were complete. Teardown grows by about 20ms. Co-authored-by: Cursor <cursoragent@cursor.com>
d7501d2 to
cdd9f50
Compare
Thanks Michael. Updated as you suggested: this PR now only fixes the ordering and adds the wait. It flushes, stops the context, then keeps flushing until the row count is stable, with a 5s total timeout that logs a warning. The locking changes are gone. The wait alone is enough on the repro. Over 40 runs, main segfaulted or hung on 27 and never produced a complete trace. With this change all 40 exited cleanly and 39 were complete; the one exception was short by a single kernel. Details are in the description. Agreed that correlation tracking or an SDK-side drain is the real fix. Thanks for raising it with the SDK team. |
|
|
||
| // Flush buffers | ||
| auto& globalContext = getGlobalContext(); | ||
| rocprofiler_flush_buffer(globalContext.buffer); |
There was a problem hiding this comment.
isn't this still wrong order (should be stop, then flush)? tbh we can probably just rocprofiler_stop_context and then let the loop below handle flush
There was a problem hiding this comment.
The flush before stop came from the same discussion: it follows the flush / disable / flush pattern in the SDK samples, so whatever is already buffered gets delivered before the context stops. You're right that it's redundant here, since the loop flushes after the stop anyway. I'll drop it, so it becomes stop, then the flush loop.
The bug
On the rocprofiler-sdk backend, stopping a trace corrupts the heap, and the trace it publishes is usually missing most of the GPU work without reporting anything.
Both symptoms have the same cause. rocprofiler-sdk does not emplace a kernel dispatch record at the moment a flush runs. The record is produced on the SDK's own signal handler thread (
rocprofiler::hsa::AsyncSignalHandler) once the dispatch completion signal fires, androcprofiler_stop_context()does not wait for sessions already in flight. So records keep arriving for tens of milliseconds after collection has stopped.RocprofLogger::stopLogging()flushed, stopped the context and returned, so the rest of teardown ran while that thread was still appending torows_andexternalCorrelations_:processActivities()ran was dropped, with no error or warning. The loss is FIFO, so the trace is a plausible-looking prefix of the real one: on a 32-node graph replayed 200 times, every node shows up 54 to 58 times instead of 200.processActivities()walks those containers andclearLogs()clears them without the mutex the appends hold, so they race the late appends. glibc catches it when it next looks at the heap (malloc(): unsorted double linked list corrupted), or the walk faults inprocessActivities(), or the process wedges.The fix
Flush, stop the context, then keep flushing until the row count has been stable for three polls 10ms apart, bounded by a 5s total timeout that logs a warning and proceeds.
Once the in-flight records have arrived, nothing is appending any more, so the rest of teardown no longer races the callback thread. That is why this fixes the corruption as well as the truncation without touching the locking.
This is deliberately the smallest change that fixes both symptoms. It is a workaround for rocprofiler-sdk offering no way to wait for in-flight records at a mid-run stop (it guarantees delivery at finalize only), and should be revisited once the SDK does.
Why a poll
rocprofiler-sdk has no equivalent of CUPTI's forced flush.
rocprofiler_flush_buffer()hands over only the records already emplaced in the buffer, and there is no call that waits for in-flight dispatch records to be emplaced. The alternative that gives a hard guarantee is to track correlation ids — note the highest one issued at stop and wait for its record — which is a considerably larger change. A behaviour change on the SDK side is being raised with the ROCm profiler team.A poll rather than a fixed sleep because a sleep has to be sized for the worst case: every stop pays the full duration, and when it turns out too short the trace is silently truncated again. The poll returns as soon as the rows stop growing, about 20ms on the runs below, and the 5s bound is only reached if records are still arriving, in which case it warns.
Validation
HIP graph replay dispatching 6400 kernels (32 nodes x 200 replays) through libkineto's public API, MI355X, ROCm 10.0.0, rocprofiler-sdk 1.3.5. Arms interleaved run by run, 60s timeout per run so a wedged process is counted rather than waited on:
One run with this change reported 6399 of 6400, without a timeout warning; main's plain-stream control arm showed the same single-kernel shortfall once, so it looks like a trace-window boundary effect rather than an undrained record. At 64000 dispatches this change is complete on 10 of 10 runs. The poll adds about 20ms to teardown (median 516ms vs 496ms end to end).
Alternatives measured
hipDeviceSynchronize(), then stop and flushCorrecting the ordering alone moves neither symptom (p = 0.80 on failures).
hipDeviceSynchronize()guarantees the completion signals fired, not that the SDK's handler has emplaced the records.Not in this PR
processActivities()andclearLogs()still touchrows_andexternalCorrelations_without their mutexes. With the wait in place that is no longer reachable in practice, but if the timeout ever expires while records are still arriving, it becomes a heap corruption again rather than a short trace.rows_ownership. Rows arenew'd andclearLogs()never deletes them, because the emitted activities point into them. That needs its own redesign.Repro
Plain
torch.profileraround a HIP graph replay, no ROCm-specific options needed. The standalone C++ reproducer used for the numbers above is in a comment below.