Add a perf profiling mode to the benchmark workflow - #12952
Conversation
dotTrace cannot see past a P/Invoke: everything below the boundary collapses into a single `[Native or optimized code]` node, routinely the third-largest entry in a snapshot with no breakdown at all. RocksDB, the allocator, memory zeroing and GC all land in there together. Add a `perf` input that records a host-side Linux perf profile alongside the run. perf walks one stack across the managed/native boundary, naming managed frames from the runtime's perf map and native frames from the container's shared objects, so that time is attributed per callee. The profile ships as folded stacks next to the existing dotTrace artifacts. perf runs on the host, so no image change is needed; expb sets the perf-map environment on the client container. scripts/perf-report.sh reads the folded output with the same shape as dottrace-report.sh - top/total/native/compare - reporting shares of the profile so two runs of different length stay comparable.
Reading the first captured profile surfaced two defects. The managed-frame pattern assumed `Namespace.Method`, but the runtime's perf map emits `<ret> [Assembly] Type::Method(args)`, so `native` listed managed frames as unmanaged. And `head` closing the pipe under `pipefail` made every report exit 141; awk now applies the row limit itself. Correct the documented caveats to what the run actually showed: kernel frames do resolve, the capture spans every thread of the process including RocksDB's compaction pool, and symbolization is partial rather than complete.
|
Claude finished @kamilchodola's task in 5m 39s —— View job Review: perf profiling mode for the benchmark workflowReviewed the full diff against
VerdictThe design is sound and the workflow wiring is correct. Everything I found is in The feature itself is well-motivated: dotTrace's What I checked and found clean
Findings
Two notes rather than findingsMerge order. No test for the reader. AGENTS.md asks for a regression test with every bug fix, and the second commit fixed two real defects (the managed-frame pattern, and Verification limitsI read the script closely but could not execute it — the review sandbox denied writing fixtures and running Recommendation: fix #1 before merge (it can silently produce wrong A/B deltas, which is the tool's main job). The rest are fine as follow-ups or as acknowledged-and-deferred. |
The eth_call benchmarks launch nodes through start-node.sh rather than expb, so they need their own perf hook. Same three requirements as the expb sidecar: the perf-map environment on the client, the host PID from docker top, and folding before teardown while /proc/<pid>/root still resolves the container's shared objects. perf-fold.awk keeps the shell path free of a Python dependency; it was verified to produce byte-identical output to the expb folder on the same fixture. Only the primary node is profiled - the reference client is left alone.
benaadams
left a comment
There was a problem hiding this comment.
Local review: unique findings not already covered by the existing inline discussions.
The sweep step keeps its own env block and never carried DOTTRACE, so attaching PERF beside the existing DOTTRACE entries reached the single-node path and the log scan but not run-rpc-sweep.sh: the corpus sweep started its nodes with perf disabled and produced no profile.
|
@claude re-review |
|
Claude finished @kamilchodola's task in 8m 16s —— View job Re-review: perf profiling modeReviewed the delta since my last pass (
VerdictEvery finding from the previous round is fixed, several of them properly rather than minimally. The new work — the recorder-identity guard, the fail-closed collectors, the Prior findings — all resolved
benaadams' four also land: perf env scoped after dotTrace's Two things I checked closely and found correct: the Findings
On #1 — this is the only thing I'd hold the merge for, and it's mechanical. On #3 — this is the one worth a design thought rather than a patch. The premise of the feature is that perf attributes both sides of the P/Invoke boundary. If the managed side silently drops out — perf resolving Not findings
Verification limitsI read the scripts closely but could not execute the new test suite — Recommendation: fix #1 before merge. #2 and #3 are worth an answer — even "the runner user is root" and "acknowledged, follow-up" would settle both — and the Lows are fine as follow-ups. |
|
@claude re-review |
|
@claude re-review |
|
Claude finished @benaadams's task in 8m 55s —— View job Re-review: perf profiling mode + EventPipe sidecarReviewed the eight branch commits since the last pass (
VerdictLast round's finding is fixed, and the new work is a real extension rather than a patch: the profilers can now start after a warm-up instead of at RPC-ready, which is the thing that was actually limiting the profiles. No Critical or High. Two Mediums, both on the dotnet-trace sidecar and both about the same gap — the window it covers is not what three docs say it covers, and nothing detects when it covers even less. Three Lows. Prior finding — resolved
Things I checked closely this round and found correct:
Findings
On #1 and #2 — these are one gap seen from two sides, and worth an answer together rather than two patches. The premise of the sidecar is that attaching late excludes everything before the cell ( Still open from earlier rounds — unchanged files, fine as follow-ups
Not findings
Verification limitsStatic reading only — Unchanged from the last three rounds, and now larger: neither the rpc-bench perf path nor the new dotnet-trace path has a linked end-to-end run. The only verification run, 32536259998, is expb and predates all of this. Everything in Recommendation: #1 and #2 are worth resolving here — even "make |
…Pipe readers Review follow-ups, all of them cases where a broken capture reads as a clean one. perf reporting - perf-report.sh: every view divides by the profile's total sample count, which is zero for an all-zero or truncated profile. `top`/`total`/`native` aborted with awk's division-by-zero fatal; `compare` swallowed it in a process substitution and printed an empty table. require_file now refuses such a profile up front, stopping at the first positive count. - perf-fold.awk: trailing whitespace on a frame line pushed the closing paren into the DSO name, so one library was folded into two frames - "(libx.so)" and "(libx.so))" - splitting its share. Strip before matching. - perf-report.sh: `compare` bound the profile names into awk and never used them, leaving bare "A %"/"B %" columns as the only clue to which direction a `+` delta points. They are in the title now. - The managed-frame regex is duplicated in perf-report.sh and validate-folded-profile.sh, with a test each that passes if only one is fixed; a test now pins the two literals together. - The recorder test asserted four lines of lib.sh byte-for-byte. Assert the invariant instead - perf launched directly, no sudo/as_root wrapper, $! kept. dotnet-trace sidecar - The --duration cap runs from the attach. Without corpus_warmup_duration that is RPC-ready, ahead of json-bench's clone, image build and corpus conversion, so the cap could elapse before the measured cell; the margin now covers the preparation on that path. - stop_dotnet_trace_collector treated an already-exited collector as success, so a trace that ended before the cell shipped green. It fails now. - nettrace-report.cs returned 0 on an unreadable trace, rendering "no GC events" and "no contention stop events" - indistinguishable from a quiet node. Zero events is an error. --top is validated rather than silently defaulted, and the path is no longer taken from args[0] when args[0] is a flag. - The "measured phase only" claim was unconditional in the input description, AGENTS.md and the README; qualified in all three. - Pin the dotnet-trace install, like expb and json-bench are pinned. rpc-bench - perf=true now refuses a non-Nethermind client in resolve, next to the identical dottrace and dotnet_trace checks, rather than in start-node.sh after the runner, image and snapshot have been paid for. - run-jsonbench.sh keyed its reusable preparation on the ref name, so a branch that moved upstream since a hard-cancelled job matched the marker and the image tag and was reused silently. Key it on the resolved commit. - container_dotnet_root's hostfxr probe shared the loop's stdin with the runtime listing feeding it; </dev/null keeps a future `docker exec -i` from eating it. - stop-node.sh: drop the `wait` on the perf recorder. It is not a child of that shell, so it never waited; the identity-checked poll above is the guarantee. - README: perf's root-runner prerequisite is not arm64-only, and a comparison dispatch profiles the primary node alone.
|
@claude re-review |
|
Claude encountered an error after 4m 11s —— View job I'll analyze this and get back to you. |
run-jsonbench.sh, start-profilers.sh and run-rpc-sweep.sh are invoked by path - by the workflow, by run-rpc-sweep.sh and by the script suite - but were committed 100644, so the only thing making them runnable was the workflow's own 'chmod +x scripts/rpc-bench/*.sh'. The suite has no such step, so run-jsonbench.sh exited 126 there as soon as a test execed it directly. A test pins the invariant on the git index mode rather than the working tree, which reports nothing useful about the bit on Windows.
…s on The EventPipe collector is attached between the warm-up and the measured cell, which is the whole reason for attaching late rather than opening a diagnostic port. With corpus_warmup_duration unset - its default - there was no warm-up to attach after, so the collector went on at RPC-ready and its window also covered json-bench's clone, runner image build and corpus conversion. Three places claimed "the measured phase only", and nettrace-report.cs states GC pause and contention as a share of that window, so the padding silently understated both. The elapsed --duration cap was the other face of the same gap: it starts at the attach, so on that path it could expire before the cell began. dotnet_trace now resolves only for a single-node jsonbench run - the one shape with a warm-up - and supplies the canonical 60s when the dispatch sets none; an explicit corpus_warmup_duration=0 is refused rather than quietly profiled. The cap goes back to cell duration plus ten minutes, counted from an attach that is now always immediately ahead of the cell, and the padded-margin workaround is gone. The claim in the input description, AGENTS.md and the README is true by construction, and the README's install snippet names the version pin. The regression test runs the workflow's resolve body under bash rather than matching its source, so the accepted shapes and the implied warm-up are checked by behaviour.
The fake `perf record` slept 4 seconds, while the three profiler tests that use it end by killing it after a start sequence and, in one case, a collector-stop poll that ticks in whole seconds. On a loaded box that budget is gone before the teardown kill lands and the test fails with "kill: No such process" - seen once in a full-suite run here, and nothing to do with the code under test. The fixture's own docstring already promises a recorder that stays alive; give it a lifetime that outlives the fixture.
…ip perf-report.sh executable The deferred profiler start is what makes 'the measured phase only' true, but a warm-up that dies before writing the reuse marker leaves the clone, runner image build and corpus conversion to be redone by the measured cell - inside the window. nettrace-report states GC pause and lock contention as a share of that window, so both would be silently deflated with nothing in the artifact showing it. A failed warm-up is now fatal whenever a profiler is enabled; unprofiled it still only warns, since a cold cell is a valid measurement. Also commits scripts/perf-report.sh executable - AGENTS.md and the README document it as a command to run by path, and it was 100644, which is exit 126 on a fresh clone. The shebang invariant test only scanned scripts/rpc-bench, so it could not see it; it now also covers the two perf-flow scripts one level up, and no further, since unrelated scripts there predate this flow.
|
@claude re-review |
|
Claude finished @kamilchodola's task in 10m 13s —— View job Re-review: perf profiling mode + EventPipe sidecarReviewed the five branch commits since the last pass (
VerdictEvery finding from the last round is fixed, and the test suite is green at HEAD — Code Lint run 33202659057 passed Prior findings — resolved
The four reader Lows carried since the 24th are all closed too: the zero-count guard landed in Two things I checked closely this round and found correct: the Findings
On #1 — the only one I would fix while in here, and it is a reorder. It is the same shape as the expb collector fixed in On #4 — the sub-point matters more than the headline. Not findings, but worth stating
Verification limitsThe Python suite is confirmed green in CI at Unchanged from the last four rounds: neither the rpc-bench perf path nor the dotnet-trace path has a linked end-to-end run. The only verification run, 32536259998, is expb and predates all of it. Everything in Recommendation: #1 is a small reorder and worth doing here; #2-#5 are fine as follow-ups. Nothing blocks merge on correctness. Note I cannot push the workflow half of #1 myself — GitHub App permissions exclude |
| ARCHIVE: ${{ runner.temp }}/perf-rpcbench.zip | ||
| run: | | ||
| set -euo pipefail | ||
| if bash scripts/validate-folded-profile.sh "${DIAG_DIR}/perf/perf.folded"; then |
There was a problem hiding this comment.
Medium — a failed perf capture on rpc-bench ships nothing, including the one file that says why.
This is the shape that f66c8f7 just fixed on the expb collector, unfixed here: validate, then exit 1 before the zip. The consequence differs because dotTrace and dotnet-trace have their own steps and archives, so nothing else is discarded — but ${DIAG_DIR}/perf/ also holds perf-record.log, and that goes with it.
That log is the only record of why a capture failed, and nothing else surfaces it:
start_perf_for_container(lib.sh) tails it only when the recorder dies within the first second.stop-node.sh:97(perf recorded no data) and:126(perf folding failed or produced an empty perf.folded) both log a bare line and move on — nosed 's/^/ /' "$DIAG_DIR/perf/perf-record$SUFFIX.log", which is exactly what the dotnet-trace path does atstop-node.sh:49.
So a recorder that starts, runs and then writes a truncated or unsymbolizable perf.data — the failure mode this rig has never exercised — produces a red run whose entire diagnostic content is ERROR: perf folding failed. Reproducing costs another snapshot restore.
Zip first, fail after — the always() upload then preserves the recorder log, and the step is still red:
| if bash scripts/validate-folded-profile.sh "${DIAG_DIR}/perf/perf.folded"; then | |
| if [[ -d "${DIAG_DIR}/perf" ]] && [[ -n "$(find "${DIAG_DIR}/perf" -type f 2>/dev/null)" ]]; then | |
| # Archive before validating: perf-record.log is the only record of why a capture | |
| # failed, and discarding it costs another snapshot restore to reproduce. | |
| ( cd "${DIAG_DIR}" && zip -9r "${ARCHIVE}" perf -x '*/perf.data' ) | |
| echo "Archived perf profile to ${ARCHIVE}" | |
| fi | |
| if bash scripts/validate-folded-profile.sh "${DIAG_DIR}/perf/perf.folded"; then | |
| echo "Validated ${DIAG_DIR}/perf/perf.folded" | |
| else | |
| echo "::error::perf was requested but no positive-sample ${DIAG_DIR}/perf/perf.folded was produced." | |
| exit 1 | |
| fi |
(Alternatively, dump the recorder log from stop-node.sh on both failure paths the way the dotnet-trace block already does — cheaper, and it puts the reason in the step output rather than in an artifact.)
| # dotnet-trace (nethermind only): mount the host tool read-only plus an output dir; the collector is | ||
| # attached with docker exec by start_profilers, so nothing about the node's launch changes. | ||
| if [[ "$DOTNET_TRACE" == "true" ]]; then | ||
| if [[ ! -x "$DOTNET_TRACE_HOST_PATH/dotnet-trace" ]]; then |
There was a problem hiding this comment.
Low — the pin is only honoured on a box that has never installed the tool.
Pinning is the right call and the rationale on start-node.sh:52-54 is correct. But the --version only ever runs behind [[ ! -x "$DOTNET_TRACE_HOST_PATH/dotnet-trace" ]], so a runner that already carries /opt/dotnet-trace from an earlier, unpinned run keeps whatever version it happens to have — silently, with the log line that would have named the version never printed.
That is not hypothetical for these two boxes: README.md:628 describes /opt/dotnet-trace as something "already installed by previous runs", and dotnet_trace has been on this branch since fec9fe9 with an unpinned installer. Whichever box has run it once is now the box the pin does not apply to — which inverts the guarantee, since a fresh box gets the pinned version and the box with history does not.
Cheapest fix that keeps the "install once" behaviour: check the version, not just the path.
| if [[ ! -x "$DOTNET_TRACE_HOST_PATH/dotnet-trace" ]]; then | |
| if [[ "$("$DOTNET_TRACE_HOST_PATH/dotnet-trace" --version 2>/dev/null | tr -d '\r')" != "$DOTNET_TRACE_VERSION" ]]; then |
Second, smaller point on the same block: DOTNET_TRACE_VERSION=9.0.661903 has not been exercised anywhere reachable from this PR — a version that does not exist on NuGet makes every dotnet_trace=true dispatch die at install on a fresh box, and the only place that shows up is the runner. One dotnet tool install --version 9.0.661903 --tool-path /tmp/x dotnet-trace locally would settle it.
| # hard-cancelled job left a marker behind would otherwise match both the marker and the image tag | ||
| # (derived from the same name) and be reused silently. ls-remote lists nothing for a raw commit sha, | ||
| # which is already exact. | ||
| resolved_ref="$(git ls-remote "$JB_REPO" "$JB_REF" | awk 'NR == 1 { print $1 }')" \ |
There was a problem hiding this comment.
Low — this buys nothing in the default configuration and adds a mandatory network call plus a new die on the reuse path.
JB_REF defaults to a pinned commit sha (run-jsonbench.sh:28), and git ls-remote <url> <sha> matches no ref and prints nothing, so resolved_ref is empty and prepared_id falls back to the name — identical to the previous behaviour. The moving-branch hole this closes is only reachable when a dispatch explicitly overrides tool_config.ref with a branch.
What it costs on every invocation, including a fully local reuse that needs nothing from GitHub: one round-trip whose only failure mode is die "failed to reach ...". A transient network blip on a self-hosted box now fails a run whose checkout, image and 142 MB corpus fixture are already sitting in scratch.
And the narrow case it closes has a sibling it does not: resolved_ref is computed independently in the warm-up and in the measured cell. A branch that moves between those two steps resolves differently, misses the marker, and re-clones + rebuilds + re-converts the corpus — with perf and dotnet-trace already recording. That is exactly the window the deferred profiler start and the new warm-up hard-fail (run-rpc-benchmarks.yml:935-945) exist to keep clear, and it would be silent: Cloning ... in the tool log, nothing in the annotations.
Resolving once in the resolve job and passing the sha down as the effective ref closes both — the cell then reuses by construction, and the network call happens where a failure is cheap.
| # rpc-bench plus the two perf-flow scripts one level up, which AGENTS.md documents as commands to | ||
| # run by path. Deliberately not the whole scripts/ tree: unrelated scripts there predate this flow. | ||
| listing = subprocess.run( | ||
| ["git", "ls-files", "-s", "--", "scripts/rpc-bench", |
There was a problem hiding this comment.
Low — the exclusion leaves out the one script AGENTS.md documents the same way and that is still committed non-executable.
This test is a good addition and it pins the regression b2c5b5c fixed. The comment justifies the scope as "the two perf-flow scripts one level up, which AGENTS.md documents as commands to run by path" — but scripts/dottrace-report.sh is documented identically at AGENTS.md:230 ("Use scripts/dottrace-report.sh: top <report.xml> [N]"), has a #!/usr/bin/env bash shebang, and is committed 100644:
$ git ls-files -s scripts/dottrace-report.sh
100644 6a15572e ... scripts/dottrace-report.sh
So an agent following that AGENTS.md line gets exit 126 — the same failure this test exists to prevent, one line above the one it does cover. It is pre-existing rather than introduced here, and AGENTS.md's "be conservative on unrelated code" argues against a drive-by, so either is defensible: add it to the guarded set (git update-index --chmod=+x, one line in this list), or say in the comment that it is knowingly excluded as pre-existing so the next reader does not have to re-derive it.
| DOTNET_TRACE_HOST_PATH="${DOTNET_TRACE_HOST_PATH:-/opt/dotnet-trace}" | ||
| # Pinned like every other tool on this rig: an unpinned install would drift the collector between | ||
| # runs whose numbers are meant to be comparable. | ||
| DOTNET_TRACE_VERSION="${DOTNET_TRACE_VERSION:-9.0.661903}" |
There was a problem hiding this comment.
Low — dotTrace stays unpinned twenty lines below the collector that just got pinned.
The rationale on the two lines above — "an unpinned install would drift the collector between runs whose numbers are meant to be comparable" — applies at least as strongly to JetBrains.dotTrace.GlobalTools at start-node.sh:351, and arguably more: dotnet-trace records runtime events with negligible overhead, while dotTrace wraps the client's entrypoint and perturbs the timings the rig exists to produce. A version bump between two A/B arms taken days apart would shift them with nothing in the log to say so.
Same treatment, one variable and two --version flags — and worth doing together with the version-aware guard from the other comment, since [[ ! -x "$DOTTRACE_HOST_PATH/dottrace" ]] has the identical "pin never applies on a box that already has it" property.
exit 1 preceded the zip, so a run that produced no positive-sample perf.folded uploaded nothing at all - discarding perf-record*.log, the one file that says why. Archive whatever the capture left (still excluding perf.data), tail the recorder log into the job output, then fail: the ordering the expb workflow already uses.
|
@claude re-review |
|
Claude finished @kamilchodola's task in 3m 49s —— View job Re-review: perf profiling mode + EventPipe sidecarReviewed the single branch commit since the last pass (
VerdictThe delta is one commit, it fixes last round's only Medium, and it does it the right way round. No Critical, High, or Medium. One new Low, plus four carried from earlier rounds — none of which touches client code or benchmark timings. Prior finding — resolved
Things I checked closely on the new code and found correct: the unmatched I also re-derived one thing I had flagged as a possible hole and it is closed: Finding
Not worth holding a merge for — the cause is visible in the job log either way, it just isn't where the new error message points. Details and a one-line fix are in the inline comment. Carried from earlier rounds, unchanged files, still fine as follow-ups
Not findings
Verification limitsStatic reading only this round — Unchanged from the last five rounds: neither the rpc-bench perf path nor the dotnet-trace path has a linked end-to-end run. The only verification run, 32536259998, is expb and predates all of it. The in-container map copy, Recommendation: nothing blocks merge on correctness. The one new Low is a message-text change; the four carried ones are follow-ups. Note I cannot push the workflow half myself — GitHub App permissions exclude |
| fi | ||
|
|
||
| if [[ "${perf_validation_failed}" == "true" ]]; then | ||
| for recorder_log in "${DIAG_DIR}"/perf/perf-record*.log; do |
There was a problem hiding this comment.
Low — the log this ships explains the failure mode that was already visible, not the one that is most likely.
The reorder is right and it closes last round's finding: perf-record*.log now survives, and the glob covers the suffixed reference form even though nothing produces one today (the reference node starts with PERF: "false"). Unmatched glob stays literal and [[ -s ]] skips it, so the loop is safe when the directory is absent.
The nuance is what the log contains. perf-record.log is perf record's own stdout/stderr, and stop-node.sh sets perf_fail=1 on four distinct paths, only one of which the recorder log describes:
| path | what perf-record.log says |
|---|---|
| recorder never started | already tailed inline by lib.sh:200 — this step never runs |
perf recorded no data (stop-node.sh:96) |
the useful case: whatever perf refused to do |
map copy failed (:111) |
Captured and wrote N MB — nothing about the map |
perf script/fold failed (:126) |
Captured and wrote N MB — the real cause is perf script's stderr |
The last two are the likely ones on a first perf=true dispatch (the in-container map copy and --symfs resolution are the untested parts), and for both the recorder log reads as a successful capture. Their actual cause is in the Stop node step's own output, two steps up — visible, but nothing here points at it.
One line in the failure branch would close the gap:
echo "::error::perf was requested but no positive-sample ${DIAG_DIR}/perf/perf.folded was produced. If the recorder log above shows a successful capture, the cause is in the 'Stop node' step (perf map copy or perf script)."Two smaller notes on the same block, neither worth a patch on its own:
- When
${DIAG_DIR}/perfis absent entirely — which is what a failedStart profilersproduces, since the profiled warm-up hard-fail skips it — this step emits a::warning::plus an::error::, andUpload perf profilethen adds a third annotation viaif-no-files-found: error. Three annotations, one cause, and the real one is further up. Same "cascade" shape noted last round; the reorder makes it one line longer rather than shorter. find … -not -name 'perf.data'andzip -x '*/perf.data'express the same exclusion two different ways. They agree today only because the suffix is always empty on a perf path;-not -name 'perf*.data'would keep them from drifting apart if a second profiled node is ever added.
Problem
dotTrace cannot see past a P/Invoke. Everything below the boundary collapses into a single
[Native or optimized code]node with no breakdown - on a recent snapshot that node was the third-largest entry in the whole profile (166s of own time, above every individual Nethermind frame). RocksDB, the allocator, memory zeroing, the JIT and the GC all land in there together, indistinguishable.Change
A
perfworkflow input that records a host-side Linux perf profile alongside the run, plusscripts/perf-report.shto read the result. perf walks one stack across the managed/native boundary - managed frames from the runtime's perf map, native frames from the container's shared objects - so that time is attributed per callee.No image change is needed: perf runs on the host and expb sets the perf-map environment on the client container (companion PR: execution-payloads-benchmarks#27).
perfis independent ofdottrace. Enabling both samples the process twice, which perturbs timings, so perf runs are for attribution and A/B numbers should come from dottrace-only or unprofiled runs. The Reporter XML job stays gated ondottracealone; only artifact collection was widened. Perf validation remains fail-closed, but a missing or invalid fold now fails only after the combined profiling archive is created, preserving valid dotTrace and EventPipe evidence.Verification
Run 32536259998 on the arm64 runner with
perf=trueanddottrace=sampling- which also exercises the hardest path, perf locating the client PID underneath the dotTrace launcher. Artifact carriedperf.data(5.3MB, 14,344 samples),perf.folded(41MB) and the dotTrace.dtpand.nettracetogether.Symbolization on that profile:
[unknown]The residual is almost entirely the stripped
libcoreclr.soandlibrocksdb.soshipped in the image. So perf narrows dotTrace's opaque node to a named library plus a resolved majority; it does not eliminate it.What the profile actually attributes, split by thread - the capture covers every thread of the process, so this split matters before drawing conclusions:
Two readings that were invisible in dotTrace: 38% of process CPU was RocksDB background compaction, roughly half of it snappy, and secp256k1 recovery was ~11% of runtime-thread CPU. (Short 20-payload run, so the
libclrjitshare is inflated by startup JIT and none of this is a steady-state claim.)scripts/perf-report.shwas exercised on that profile and on synthetic fixtures across all four subcommands:top,total(inclusive time, verified summing correctly across shared prefixes),native,compare(recovers injected shifts exactly).Reading real data also caught two defects in the reader, fixed in the second commit: the managed-frame pattern assumed
Namespace.Methodwhere the perf map emits<ret> [Assembly] Type::Method(args), andheadunderpipefailmade every report exit 141.