Skip to content

Add a perf profiling mode to the benchmark workflow - #12952

Open
kamilchodola wants to merge 7 commits into
masterfrom
feature/perf-diag-mode
Open

Add a perf profiling mode to the benchmark workflow#12952
kamilchodola wants to merge 7 commits into
masterfrom
feature/perf-diag-mode

Conversation

@kamilchodola

Copy link
Copy Markdown
Contributor

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 perf workflow input that records a host-side Linux perf profile alongside the run, plus scripts/perf-report.sh to 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).

perf is independent of dottrace. 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 on dottrace alone; only artifact collection was widened.

Verification

Run 32536259998 on the arm64 runner with perf=true and dottrace=sampling — which also exercises the hardest path, perf locating the client PID underneath the dotTrace launcher. Artifact carried perf.data (5.3MB, 14,344 samples), perf.folded (41MB) and the dotTrace .dtp and .nettrace together.

Symbolization on that profile:

share of samples
managed (runtime perf map) 19.00%
native, resolved 54.60%
[unknown] 26.39%

The residual is almost entirely the stripped libcoreclr.so and librocksdb.so shipped 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:

  8744  60.96%  .NET              5442  37.94%  rocksdb:low
comm=.NET                                      comm=rocksdb:low
  [unknown] (libcoreclr.so)   16.39%             snappy CompressFragment     22.33%
  [unknown] (libclrjit.so)     6.22%             [unknown] (librocksdb.so)   19.90%
  secp256k1_fe_mul_inner       5.15%             snappy DecompressBranchless 12.70%
  secp256k1_fe_sqr_inner       3.84%             snappy LittleEndian::Load32 10.92%
  KeccakHash::KeccakF1600Sca   2.20%             LZ4_compress_fast_extState   2.35%

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 libclrjit share is inflated by startup JIT and none of this is a steady-state claim.)

scripts/perf-report.sh was 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.Method where the perf map emits <ret> [Assembly] Type::Method(args), and head under pipefail made every report exit 141.

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

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 5m 39s —— View job


Review: perf profiling mode for the benchmark workflow

Reviewed the full diff against origin/master, plus the surrounding workflow (resolve step, both run jobs, artifact collection, the Reporter job gate, and the master-metrics cache path). Five inline comments posted.

  • Read the workflow diff
  • Read scripts/perf-report.sh
  • Read the AGENTS.md changes
  • Cross-check the resolve/gating/cache paths
  • Post review

Verdict

The design is sound and the workflow wiring is correct. Everything I found is in scripts/perf-report.sh — nothing that touches client code or benchmark timings. No Critical or High findings. One Medium, five Low.

The feature itself is well-motivated: dotTrace's [Native or optimized code] node genuinely is a dead end, and the verification run showing 38% of process CPU in RocksDB compaction — invisible in every dotTrace snapshot to date — is exactly the kind of thing this should surface. The AGENTS.md caveats (partial symbolization, comm-splitting before attributing anything, CPU-share vs wall-clock not being comparable with dotTrace) are the right guardrails and are the part most likely to stop a future agent drawing a wrong conclusion.

What I checked and found clean

  • Gating. perf is settable only via workflow_dispatch; the pull_request and push branches both hardcode perf="false". Forks cannot enable it, and since the master-metrics cache save is gated on push + master (L1174), a perf-perturbed run can never become the cached A/B baseline. That is the failure mode that would have mattered most, and it is closed.
  • Injection surface. DISPATCH_PERF goes through env: rather than being interpolated into a run: body, matching the existing safe pattern in this file.
  • Flag composition. With dottrace=false, dottrace_flag starts empty and becomes " --perf"; it is used unquoted at L966/L2114 so the leading space is harmless.
  • Reporter job. Still gated on dottrace == 'true' alone, so a perf-only run does not spin up the Windows job for a snapshot that does not exist.
  • total_time recursion handling. The delete seen / in seen guard is right — a recursive frame is counted once per stack, so inclusive time cannot exceed 100%.
  • Count parsing. sub(/ +[0-9]+$/, "") after taking the last space-delimited token survives frames containing spaces, which managed perf-map frames always do. That is the defect the second commit fixed and the fix is correct.

Findings

# Severity Where Issue
1 Medium perf-report.sh:102 sort/join run under the ambient locale. Frame names are almost entirely punctuation, which UTF-8 collation treats as ignorable — distinct frames can compare equal and join emits a cross-product, silently corrupting the deltas. LC_ALL=C fixes it and is faster on a 41MB profile. Fix this →
2 Low perf-report.sh:24 NATIVE_FILTER's [^ ]*:: fails on generic instantiations whose type arguments contain spaces, leaking managed frames into the native listing.
3 Low perf-report.sh:66 print_table hardcodes the Self % header, but cmd_total feeds it inclusive time — the total view labels inclusive numbers as self time.
4 Low perf-report.sh:117 compare with odd N drops the middle row with no ... marker when NR == n; compare a b 1 prints zero data rows (half = int(1/2) = 0).
5 Low perf-report.sh:77 Frames are truncated to 72 chars from the left, cutting the method name off managed frames that all share a long instance void [Assembly] Namespace. prefix.
6 Low–Med workflow L1216 / L2347 Profiling requested but nothing collected → plain echo + exit 0. A green run with no profile and no visible signal. Pre-existing for dottrace, but perf makes it likelier.

Two notes rather than findings

Merge order. uv tool install --from "${expb_source}" expb resolves expb from main by default, so --perf must land in execution-payloads-benchmarks#27 before this merges, or every perf=true dispatch fails on an unknown flag. Presumably already coordinated — flagging only so the ordering is explicit.

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 head under pipefail exiting 141) that a four-line fixture would have caught. scripts/rpc-bench already has unit tests wired into code-lint.yml, so the precedent for testing helper scripts in this repo exists. Not blocking a diagnostics tool, but the two defects found by hand are an argument that a fixture would pay for itself — the next change to the frame-format regex has nothing to catch it.

Verification limits

I read the script closely but could not execute it — the review sandbox denied writing fixtures and running bash. Findings 2–5 are derived from reading the awk/shell logic rather than from observed output; the arithmetic in #4 I traced by hand (n=1 → half=0 → first loop 1<=0false; second loop starts atNR+1). Worth a quick local perf-report.sh compare a.folded b.folded 1` to confirm before acting on it. The workflow YAML I could only inspect statically; the PR's own run 32536259998 covers the path I could not exercise.


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.
· branch feature/perf-diag-mode

Comment thread scripts/perf-report.sh Outdated
Comment thread scripts/perf-report.sh Outdated
Comment thread scripts/perf-report.sh Outdated
Comment thread scripts/perf-report.sh
Comment thread .github/workflows/run-expb-reproducible-benchmarks.yml Outdated
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 benaadams left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Local review: unique findings not already covered by the existing inline discussions.

Comment thread scripts/rpc-bench/start-node.sh Outdated
Comment thread scripts/rpc-bench/start-node.sh
Comment thread .github/workflows/run-rpc-benchmarks.yml
Comment thread .github/workflows/run-rpc-benchmarks.yml Outdated
Comment thread .github/workflows/run-expb-reproducible-benchmarks.yml Outdated
Comment thread scripts/perf-fold.awk
Comment thread .github/workflows/run-expb-reproducible-benchmarks.yml Outdated
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.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 8m 16s —— View job


Re-review: perf profiling mode

Reviewed the delta since my last pass (00081b6, 3b4a406, 7cf4077) plus the whole diff against the branch point. Six inline comments posted.

  • Read the full diff since the last review
  • Check which prior findings were addressed
  • Review the new files (perf-fold.awk, validate-folded-profile.sh, test_perf_reporting.py)
  • Review the rpc-bench node lifecycle wiring
  • Cross-check CI wiring for the new test suite
  • Post review

Verdict

Every 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 --perf capability probe — is a clear step up in rigour. One High, which is a CI breakage rather than a design problem, and two Mediums.

Prior findings — all resolved

# Was Now
1 sort/join collation export LC_ALL=C + per-command LC_ALL=C on join/sort; regression test asserts byte-wise behaviour under an ambient en_US.utf8
2 [^ ]*:: misses generic instantiations \[[A-Za-z0-9_.-]+\][[:space:]].*::; test covers TrieStore\1::Commit`
3 total labelled Self % print_table takes the label; cmd_total passes Total %, asserted both ways
4 odd N drops a row, N=1 prints nothing half = int((n+1)/2) with a floor of 1; test pins N=1→2 rows, N=3→4
5 left-truncation cuts the method name display_frame keeps the tail; test asserts the discriminating suffix survives
6 requested-but-absent profile exits green ::error:: + exit 1 on every perf path in both collectors, if-no-files-found: error on the rpc-bench upload
no test for the reader test_perf_reporting.py, 8 cases, wired into the existing rpc-bench-scripts discovery
merge-order vs expb#27 revision pin plus an execute-scenarios --help probe that fails with an actionable message

benaadams' four also land: perf env scoped after dotTrace's -- via /usr/bin/env rather than container-wide; assert_no_mounts_under + rm -rf before recreating $DIAG_DIR/perf; perf=true + jsonbench-sweep rejected in resolve; profile.foldedperf.folded in the input description.

Two things I checked closely and found correct: the /proc/<pid>/stat field arithmetic in perf_recorder_identity (${stat##*) } drops fields 1–2, so stat_fields[19] is field 22, starttime — right), and the leaf ordering in perf-fold.awk (flush() walks depth→1, emitting comm;root;…;leaf, which is what perf-report.sh's frames[m] assumes). The identity guard before each kill is genuinely good — signalling a reused PID from a stale state file is a real hazard on a long-lived self-hosted box.

Findings

# Severity Where Issue
1 High test_perf_reporting.py:15 import yaml breaks the Test RPC benchmark scripts CI job — no install step, and every other suite there is stdlib-only
2 Medium start-node.sh:354 perf record/perf script bypass as_root; the rpc-bench path has no linked end-to-end run
3 Medium validate-folded-profile.sh:23 The gate proves perf ran, not that symbolization worked — an all-[unknown] profile passes
4 Low run-expb-…yml:873 The expb revision pin outlives the problem it solves; no removal condition recorded
5 Low run-rpc-benchmarks.yml:985 Archives perf.data; the expb collector excludes it. --freq is per-thread, so the rpc-bench cell is far larger
6 Low start-node.sh:341 Comment claims the window excludes warm-up; the warm-up runs after this script exits

On #1 — this is the only thing I'd hold the merge for, and it's mechanical. .github/workflows/code-lint.yml:52-69 runs unittest discover under a bare actions/setup-python with no dependency install; PyYAML isn't in the tool-cache CPython. unittest turns the ModuleNotFoundError into a failing _FailedTest, so the job goes red on this PR and every PR after it. It's unverified either way right now: the last Code Lint run on this branch is 32565421954 at 3b4a406, and the test file landed in 00081b6. The yaml use is one assertion block; the rest of that same test already asserts against raw workflow text, so dropping the dependency is a smaller change than adding an install step.

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 /tmp/perf-<hostpid>.map against the host rather than the container's mount namespace, or DOTNET_PerfMapEnabled not reaching the client — you get a green run, a valid artifact, and a native-only profile that reads as a finished answer. The PR description already computes the number that catches it (19/55/26); emitting that split at fold time and failing on zero managed share would make the guarantee match the claim.

Not findings

  • compare N yields N+1 rows for odd N (N=1→2). Deliberate and pinned by test; the docs promise "sorted by delta", not an exact count.
  • The perf input is gated to workflow_dispatch on both workflows; pull_request/push branches hardcode false, so the master-metrics cache can't take a perf-perturbed baseline. Still closed.
  • The generate-dottrace-reports download pattern correctly follows the profiling-*/dottrace-* rename, and the job stays gated on dottrace alone.
  • entrypoint.sh ends in exec ./nethermind, so docker top sees exactly one matching process — the awk … {print $1; exit} PID pick is unambiguous in the non-dotTrace case, and the !~ /dottrace/ exclusion handles the launcher case.

Verification limits

I read the scripts closely but could not execute the new test suite — python3 invocations were denied in this sandbox, so #1 rests on static reading of code-lint.yml plus the absence of any yaml import in the four pre-existing suites, not on an observed failure. Worth a local python -m unittest discover -s scripts/rpc-bench -p 'test*.py' in a clean venv to confirm before acting. Neither perf path was exercised; the only linked verification run is expb-side, whose capture is implemented in expb rather than in this diff.


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.
· branch feature/perf-diag-mode

Comment thread scripts/rpc-bench/test_perf_reporting.py Outdated
Comment thread scripts/rpc-bench/start-node.sh Outdated
Comment thread scripts/validate-folded-profile.sh
Comment thread .github/workflows/run-expb-reproducible-benchmarks.yml
Comment thread .github/workflows/run-rpc-benchmarks.yml Outdated
Comment thread scripts/rpc-bench/start-node.sh Outdated
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 7m 44s —— View job


Re-review: perf profiling mode

Reviewed the delta since the last pass (0b12014) and re-read the current state of every new file end to end. Six inline comments posted.

  • Read the delta since the last review
  • Verify the prior High/Medium findings are resolved
  • Review the current state of the scripts end to end
  • Cross-check CI wiring
  • Post review

Verdict

The three findings I'd have held the merge for are all properly fixed. No Critical or High. One Medium (documentation) and four Lows, none of which touch client code or benchmark timings.

Prior findings — resolved

# Was Now
1 (High) import yaml breaks Test RPC benchmark scripts Dependency gone; the four yaml.safe_load assertions are now regex-based (WORKFLOW_JOB_PATTERN / WORKFLOW_NAMED_STEP_PATTERN / WORKFLOW_STEP_IF_PATTERN), and each parser is itself verified by a mutation test that removes the gate and asserts the check fails. Suite is stdlib-only again, matching the other four in scripts/rpc-bench/.
2 (Medium) perf bypasses as_root require_perf_access requires uid 0 outright, with the reason in the message (a sudo wrapper would make $! the wrapper's PID and break identity tracking); start_perf_recorder keeps perf record as the direct child. Called both from start-node.sh:74 and from a workflow preflight step placed before Ensure Docker is installed, so it fails in seconds rather than after a snapshot restore.
3 (Medium) The gate proves perf ran, not that symbolization worked validate-folded-profile.sh now classifies every leaf and exit managed == 0, printing managed=… (x%), native=… (y%), unknown=… (z%) — the number AGENTS.md tells the reader to check first, now emitted rather than left to compute. Three-case test pins all-unknown → fail, all-native → fail, mixed → pass.
4 (Low) Pin outlives its problem Removal condition recorded inline in both jobs, pinned by test.
5 (Low) rpc-bench archives perf.data -x '*/perf.data' on both collectors, input description and AGENTS.md updated.
6 (Low) Comment claims the window excludes warm-up Corrected to "excludes startup but includes the benchmark warm-up"; the test asserts the old wording is gone.

Things I checked closely this round and found correct: -x '*/perf.data' covers both collectors' path shapes (relative perf/… in rpc-bench, absolute in expb) and SUFFIX is never non-empty on a perf path, since the reference node is started with PERF: "false" — so no perf-reference.data slips past the exclude. Teardown ordering in stop-node.sh is right: perf failures set perf_fail=1 and die only after umount and scratch removal, so a failed capture can't leak a mount. The perf map is copied inside the container to the host-PID name and --symfs /proc/<pid>/root is applied while the container is still up — and if perf's namespace lookup ever misses that copy, the new managed-share gate is what catches it. lib.sh is function-definitions only, so the workflow's source preflight has no side effects.

Findings

# Severity Where Issue
1 Medium lib.sh:45 The rpc-bench perf mode is undocumented: scripts/rpc-bench/README.md — AGENTS.md's named "full reference", which documents dottrace in a table and a whole flow section — is untouched, and AGENTS.md's rpc-bench section never mentions perf. The new root requirement, the perf.data exclusion and the sweep rejection are recorded nowhere.
2 Low perf-report.sh:36 All-zero (or truncated) counts → awk "division by zero" fatal instead of a clean error. The suite builds the fixture that trips it but only feeds it to the validator.
3 Low validate-folded-profile.sh:47 The managed-frame regex is now duplicated with perf-report.sh:25 and nothing pins the copies equal — that exact pattern has already been corrected twice in this PR.
4 Low perf-fold.awk:42 Trailing whitespace on a frame line leaves a stray ) in the DSO name, splitting one library's samples across two [unknown] (…) frames.
5 Low perf-report.sh:110 a/b basenames bound into awk and never used; compare is the one view whose output can't say which file the + delta favours.
6 Low test_perf_reporting.py:312 Asserts four lines of lib.sh byte-for-byte, so any reflow reds CI — and a sudo inserted elsewhere in the function would still pass. A regex over the function body expresses the no-wrapper invariant directly.

Not findings

  • Orphaned recorder. I checked whether a cancelled job could leave a root perf record writing unbounded to the scratch disk. It can't: perf record --pid exits when its target exits, and start-node.sh does docker rm -fv before each run. Self-limiting.
  • comm = $1 in the folder truncates multi-word thread names (.NET TP Worker.NET). That's what produces the comm=.NET grouping AGENTS.md's awk -F';' '$1==".NET"' guidance depends on, so it's load-bearing rather than a bug.
  • Gating unchanged and still closed: perf is workflow_dispatch-only on both workflows, pull_request/push hardcode false, so the master-metrics cache can't take a perf-perturbed baseline.
  • compare N yielding N+1 rows for odd N — deliberate, pinned by test.

Verification limits

I read every changed file but could not execute anything: python3 and bash invocations, and writes outside the repo, were all denied in this sandbox. Findings 2 and 4 are traced by hand through the awk (totalSamples unset → 0 in the END divisor; RLENGTH spanning (…) plus the trailing [[:space:]]* the pattern also matches) rather than observed — a local python -m unittest discover -s scripts/rpc-bench -p 'test*.py' plus perf-report.sh top on a zero-count fixture would confirm both in a minute.

Also unchanged from last round: the rpc-bench perf path has still not been run end-to-end. The only linked verification, 32536259998, is expb, whose capture lives in expb rather than in this diff. Everything in start-node.sh/stop-node.sh — the docker top field layout, NSpid resolution, the in-container map copy, the SIGINT flush window — is new code, and require_perf_access now turns "does this runner execute as root?" from a latent question into a gating one. One perf=true dispatch per box would settle all of it.


Recommendation: #1 is a small patch and worth doing here. #2#6 are fine as follow-ups; nothing blocks merge on correctness.
· branch feature/perf-diag-mode

Comment thread scripts/perf-report.sh
self[frames[m]] += count
totalSamples += count
} END {
for (f in self) printf "%s\t%d\t%.4f\n", f, self[f], self[f] * 100 / totalSamples

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.

Low — every reporting subcommand aborts with an awk "division by zero" when no line carries a positive count.

totalSamples only accumulates the trailing integer, so a folded file whose counts are all 0 — or one truncated mid-write so the last field isn't a number — leaves it at 0, and this END divides by it. gawk and mawk both treat that as a fatal error, so the user gets a raw interpreter message rather than the clean require_file-style diagnostic that exists two functions down. total_time's END (L56) and, transitively, compare have the same shape.

The suite already builds exactly the fixture that trips it — zero.folded = .NET;Frame 0 in test_report_and_validator_reject_profiles_without_positive_samples — but only feeds it to the validator, never to top.

A guard in both END blocks costs one line:

Suggested change
for (f in self) printf "%s\t%d\t%.4f\n", f, self[f], self[f] * 100 / totalSamples
if (totalSamples == 0) { print "error: profile has no positive sample counts" > "/dev/stderr"; exit 1 }
for (f in self) printf "%s\t%d\t%.4f\n", f, self[f], self[f] * 100 / totalSamples

Fix this →

leaf = frames[frame_count]
total += count
if (leaf ~ /\[[A-Za-z0-9_.-]+\][[:space:]].*::/) {
managed += count

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.

Low — the managed-frame pattern is now duplicated, with nothing pinning the two copies together.

This is the good fix for last round's #3 — the gate now proves symbolization worked, and printing the split gives the reader the number AGENTS.md tells them to check first. One maintenance snag: the frame-format regex \[[A-Za-z0-9_.-]+\][[:space:]].*:: now exists twice, here and as NATIVE_FILTER in scripts/perf-report.sh:25.

That exact pattern is the one that was already wrong once (commit 3288549 fixed it against a real profile) and was widened a second time last round for generic instantiations. The next correction has to land in two files, and the tests cover each copy separately — test_native_view_excludes_generic_managed_frames and test_folded_profile_validator_requires_managed_samples_and_reports_leaf_split would both keep passing if only one were updated, and the failure mode is silent: a hard-failing gate that classifies a whole profile as unmanaged.

Cheapest fix that keeps the two honest is a test asserting the literals are identical — grep the pattern out of each file and assertEqual. A shared perf-frames.awk include would be cleaner but is more churn than the duplication warrants.

Fix this →

Comment thread scripts/perf-fold.awk
line = $0
dso = ""
if (match(line, /\([^)]*\)[[:space:]]*$/)) {
dso = substr(line, RSTART + 1, RLENGTH - 2)

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.

Low — trailing whitespace on a frame line leaves a stray ) inside the DSO name.

The match pattern is \([^)]*\)[[:space:]]*$, so RLENGTH covers the parentheses plus any trailing whitespace, but RLENGTH - 2 assumes it covers only (…). With one trailing space the extracted text is libcoreclr.so) — the following sub(/[[:space:]]*$/, "", dso) strips the space but not the paren that shifted into the window, and the label renders as [unknown] (libcoreclr.so)).

Cosmetic in the common case, but it splits counts: the same DSO sampled on lines with and without trailing whitespace becomes two distinct [unknown] (…) frames, so a share that should read as one library is reported as two. Stripping first makes the arithmetic exact:

Suggested change
dso = substr(line, RSTART + 1, RLENGTH - 2)
sub(/[[:space:]]+$/, "", line)
if (match(line, /\([^)]*\)$/)) {
dso = substr(line, RSTART + 1, RLENGTH - 2)

(and the sub(/[[:space:]]*$/, "", dso) two lines below becomes redundant.)

Comment thread scripts/perf-report.sh
<(self_time "$1" | LC_ALL=C sort -t$'\t' -k1,1) \
<(self_time "$2" | LC_ALL=C sort -t$'\t' -k1,1) \
| awk -F'\t' -v a="$(basename "$1")" -v b="$(basename "$2")" -v n="$n" '
{ delta = $3 - $2; printf "%s\t%.4f\t%.4f\t%.4f\n", $1, $2, $3, delta }' \

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.

Low — a and b are computed and passed into awk but never used, so the compare table never says which file is A.

Both -v a="$(basename "$1")" and -v b="$(basename "$2")" are bound on L109 and referenced nowhere; the header on L122 prints bare A % / B %. Every other subcommand puts the profile name in its title (… — $(basename "$1")), so compare is the one view where the output alone cannot tell you which direction a + delta points — which matters most here, since that sign is the whole answer.

Either use them or drop them:

Suggested change
{ delta = $3 - $2; printf "%s\t%.4f\t%.4f\t%.4f\n", $1, $2, $3, delta }' \
{ delta = $3 - $2; printf "%s\t%.4f\t%.4f\t%.4f\n", $1, $2, $3, delta }' \

with the second awk taking -v a=… -v b=… and printing printf "\n Largest self-time shifts, %s -> %s (percentage points of profile)\n\n", a, b.

self.assertEqual(commands[0], "stat --event cycles:u -- true")
self.assertIn("record --event cycles:u --freq 99 --call-graph fp --pid 4321", commands[1])
self.assertIn(
' perf record --event cycles:u --freq "$frequency" --call-graph fp --pid "$node_pid" \\\n'

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.

Low — this assertion pins four lines of lib.sh byte-for-byte, including the line breaks and continuations.

The intent is right and worth keeping: prove perf record is not wrapped in sudo/as_root, because a wrapper would make $! the wrapper's PID and quietly break the identity tracking that the rest of this teardown depends on. But asserting the literal source text means any reflow — joining the continuations, reordering --freq before --event, reindenting — reds the job with no behavioral change, and the same assertion would pass if someone inserted sudo elsewhere in the function.

A narrower check on the same function body expresses the invariant directly and survives formatting:

recorder = re.search(r"(?ms)^start_perf_recorder\(\) \{.*?^\}", RPC_LIB.read_text(encoding="utf-8"))["0"]
self.assertRegex(recorder, r"(?m)^\s*perf record\b")        # launched directly
self.assertNotRegex(recorder, r"\b(sudo|as_root)\b")        # no wrapper between perf and $!
self.assertIn("PERF_RECORDER_PID=$!", recorder)

The two behavioural halves of this test (the recorded argv, and the preflight running before any record) are the valuable part and need no change.

Comment thread scripts/rpc-bench/lib.sh
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 5m 31s —— View job


Re-review: perf profiling mode

Reviewed the delta since the last pass (19437b4) and re-read the current state of the perf paths end to end. Four inline comments posted.

  • Read the delta since the last review
  • Verify the prior Medium is resolved
  • Re-read the scripts and both collectors
  • Cross-check CI wiring
  • Post review

Verdict

The delta is documentation-only and it closes last round's Medium. No Critical, High, or Medium. Four Lows, all new this round; nothing touches client code or benchmark timings.

Prior finding — resolved

# Was Now
1 (Medium) rpc-bench perf mode documented nowhere New Linux perf flow section in the README, a perf row in the inputs table, a runner-prerequisite bullet, and an AGENTS.md cross-reference stating that perf-report.sh reads folded profiles from both workflows.

I checked each claim in the new section against the code rather than taking it on trust, and all seven hold: cycles:u (lib.sh:59), root-only with the recorder-PID rationale (lib.sh:43-53), rejection for jsonbench-sweep (run-rpc-benchmarks.yml:299), sampling starting after wait_for_rpc so startup is excluded and warm-up included (start-node.sh:344), the fold on shutdown (stop-node.sh:93-98), the managed-share gate covering all-native and all-unknown (validate-folded-profile.sh:46-58), and perf.data excluded from perf-rpcbench (run-rpc-benchmarks.yml:993). The test suite is still stdlib-only, so last round's High stays fixed.

Findings

# Severity Where Issue
1 Low run-expb-…yml:1292 On a combined dottrace+perf run, an invalid fold exit 1s before the zip, discarding the valid .dtp/.nettrace with it. Zip first, fail after.
2 Low run-rpc-benchmarks.yml:299 Resolve guards perf against the sweep tool but not against a non-Nethermind client; that check sits in start-node.sh:65, i.e. after the snapshot restore. dottrace guards both at resolve.
3 Low stop-node.sh:67 wait "$PERF_PID" is a no-op — the recorder is start-node.sh's child, not this shell's — and reads as the flush guarantee that the poll above actually provides.
4 Low scripts/rpc-bench/README.md:530 The new prerequisite sits under the reproducible-benchmarks-arm heading although perf works on amd64 too (the default arch); plus a stale Files row and no note that a comparison run profiles only the primary node.

On #1 — the only one I'd fix while in here. It fails in the most expensive place: the fold is the last step after a full restore and benchmark run, and the dotTrace snapshot is precisely the fallback you want when perf symbolization didn't work. Reordering keeps the fail-closed behaviour (step still red) while the always() upload preserves what was captured.

Still open from earlier rounds, unchanged and fine as follow-ups

All Low, all in the reader rather than the capture: the all-zero-count awk division (perf-report.sh:36, :56), the managed-frame regex duplicated between perf-report.sh:25 and validate-folded-profile.sh:46, the trailing-whitespace stray paren in perf-fold.awk:42, the unused a/b bindings in cmd_compare, and the byte-for-byte lib.sh assertion in the test suite.

Not findings

  • zip -x '*/perf.data' matches both collectors' path shapes (relative perf/…, absolute mnt/…) — zip wildcards cross / without -ws. ✔
  • perf-fold.awk calls flush() from END, so a trailing sample with no terminating blank line is not dropped. ✔
  • validate-folded-profile.sh's second awk divides by total, but the first gate already guarantees ≥1 row matching the same regex, so total > 0 there. ✔
  • mapfile + "${arr[@]}" on an empty array under set -u is safe on bash ≥4.4; the runners are on 5.x. ✔
  • Gating unchanged and still closed: perf is workflow_dispatch-only on both workflows, pull_request/push hardcode false, so the master-metrics cache cannot take a perf-perturbed baseline. ✔

Verification limits

Static reading only — python3 and bash invocations were both denied in this sandbox, so I could not run python -m unittest discover -s scripts/rpc-bench -p 'test*.py' or exercise perf-report.sh on a fixture. Findings 1-4 are traced through the shell/YAML by hand.

Unchanged from the last two rounds: the rpc-bench perf path still has no linked end-to-end run. The only verification run, 32536259998, is expb, whose capture lives in expb rather than in this diff. Everything in start-node.sh/stop-node.sh — the docker top field layout, NSpid resolution, the in-container map copy, the SIGINT flush window — plus require_perf_access, which now turns "does this runner execute as root?" into a gating question, is still unrun. One perf=true dispatch per box would settle all of it, and would also be the only way to confirm the new managed-share gate passes on a real capture rather than on fixtures.


Recommendation: #1 is a small reorder and worth doing here; #2-#4 are fine as follow-ups. Nothing blocks merge on correctness. Note I can't push the workflow half of #1/#2 myself — GitHub App permissions exclude .github/workflows.
· branch feature/perf-diag-mode

Comment on lines +1282 to +1300
if [[ "${PERF}" == "true" ]]; then
mapfile -t folded_profiles < <(find "${profiling_dirs[@]}" -type f -name 'perf.folded' -newer "${MARKER}" 2>/dev/null)
valid_folded_profiles=()
for folded_profile in "${folded_profiles[@]}"; do
if bash scripts/validate-folded-profile.sh "${folded_profile}"; then
valid_folded_profiles+=("${folded_profile}")
else
echo "::warning::Ignoring invalid perf.folded profile: ${folded_profile}"
fi
done
if [[ "${#valid_folded_profiles[@]}" -eq 0 ]]; then
echo "::error::perf was requested but no perf.folded profile with a positive sample count was produced."
exit 1
fi
echo "Validated positive-sample perf.folded profile(s):"
printf ' %s\n' "${valid_folded_profiles[@]}"
fi

zip -9r "${archive}" "${profiling_dirs[@]}" -x '*/perf.data'

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.

Low — on a dottrace=true perf=true run, an invalid fold discards the valid dotTrace snapshot too.

The perf gate exit 1s at L1294 before the zip at L1300, and the whole capture lives in one archive. So a combined run that produces a perfectly good .dtp (and .nettrace) but a perf.folded the validator rejects — no managed leaf samples, a truncated perf.data after a SIGKILLed recorder — throws the dotTrace half away as well. The Upload profiling artifact step then finds nothing and, with if-no-files-found: ignore (L1309), no-ops silently.

That's the expensive direction to fail in: the fold is the last thing that happens, after a full snapshot restore and benchmark run, and the dotTrace data is exactly what you'd fall back to when perf symbolization didn't work.

Zipping first keeps the fail-closed behaviour (the step still exits non-zero, the job still goes red) while the always() upload preserves what was actually captured:

Suggested change
if [[ "${PERF}" == "true" ]]; then
mapfile -t folded_profiles < <(find "${profiling_dirs[@]}" -type f -name 'perf.folded' -newer "${MARKER}" 2>/dev/null)
valid_folded_profiles=()
for folded_profile in "${folded_profiles[@]}"; do
if bash scripts/validate-folded-profile.sh "${folded_profile}"; then
valid_folded_profiles+=("${folded_profile}")
else
echo "::warning::Ignoring invalid perf.folded profile: ${folded_profile}"
fi
done
if [[ "${#valid_folded_profiles[@]}" -eq 0 ]]; then
echo "::error::perf was requested but no perf.folded profile with a positive sample count was produced."
exit 1
fi
echo "Validated positive-sample perf.folded profile(s):"
printf ' %s\n' "${valid_folded_profiles[@]}"
fi
zip -9r "${archive}" "${profiling_dirs[@]}" -x '*/perf.data'
if [[ "${PERF}" == "true" ]]; then
mapfile -t folded_profiles < <(find "${profiling_dirs[@]}" -type f -name 'perf.folded' -newer "${MARKER}" 2>/dev/null)
valid_folded_profiles=()
for folded_profile in "${folded_profiles[@]}"; do
if bash scripts/validate-folded-profile.sh "${folded_profile}"; then
valid_folded_profiles+=("${folded_profile}")
else
echo "::warning::Ignoring invalid perf.folded profile: ${folded_profile}"
fi
done
fi
zip -9r "${archive}" "${profiling_dirs[@]}" -x '*/perf.data'
# Fail after archiving: a bad fold must not discard a valid dotTrace snapshot
# collected in the same run.
if [[ "${PERF}" == "true" ]]; then
if [[ "${#valid_folded_profiles[@]}" -eq 0 ]]; then
echo "::error::perf was requested but no perf.folded profile with a positive sample count was produced."
exit 1
fi
echo "Validated positive-sample perf.folded profile(s):"
printf ' %s\n' "${valid_folded_profiles[@]}"
fi

Applies to the benchmark-multi copy at L2482-2497 as well. (Note I can't push workflow edits myself — GitHub App permissions exclude .github/workflows.)

Fix this →

echo "dottrace requires client=nethermind (dotTrace is .NET-specific)."
exit 1
fi
if [[ "${perf}" == "true" && "${benchmark_tool}" == "jsonbench-sweep" ]]; then

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.

Low — perf gets a resolve-time guard for the tool but not for the client, unlike dottrace.

Four lines above, dottrace refuses a non-Nethermind client at resolve time (L295-298). perf has the same restriction — start-node.sh:65 dies with "perf profiling is wired for CLIENT=nethermind (it needs the runtime perf map)", and the input description says "(nethermind only)" — but the check lives in the node script, which runs after the runner has been picked, the image pulled and the DB snapshot restored.

That's inconsistent with the direction the rest of this round went: require_perf_access was deliberately hoisted into a preflight step ahead of Ensure Docker is installed so a misconfigured runner fails in seconds. The client check is the same class of precondition and is one line here:

Suggested change
if [[ "${perf}" == "true" && "${benchmark_tool}" == "jsonbench-sweep" ]]; then
if [[ "${perf}" == "true" && "${benchmark_tool}" == "jsonbench-sweep" ]]; then
echo "::error::perf=true is not supported with benchmark_tool=jsonbench-sweep. Use a single-node benchmark tool; sweep capture needs per-cell profile isolation."
exit 1
fi
if [[ "${perf}" == "true" && "${client}" != "nethermind" ]]; then
echo "::error::perf=true requires client=nethermind (managed frames come from the runtime's perf map)."
exit 1
fi

(start-node.sh's check is still worth keeping as the direct-invocation guard.)

perf_fail=1
fi
fi
wait "$PERF_PID" 2>/dev/null || true

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.

Low — this wait can never wait: the recorder is not a child of this shell.

perf record is backgrounded in start-node.sh; its PID travels here through node.env. By the time stop-node.sh runs, its parent has exited and the recorder has been reparented, so wait "$PERF_PID" returns immediately with "pid N is not a child of this shell" — swallowed by || true. It's a no-op on every path, including the one it looks like it protects (SIGINT sent, perf still writing out perf.data).

What actually guarantees the flush is the poll at L47-56, which breaks only once kill -0 fails — i.e. after the recorder has fully exited. That's correct, so this is dead code rather than a bug. The reason to remove it is that it reads as the flush guarantee, and the next person tuning the timeout may trim the loop believing wait backs it up.

Suggested change
wait "$PERF_PID" 2>/dev/null || true
# No `wait` here: the recorder was started by start-node.sh, so it is not a child
# of this shell. The identity-checked poll above is what confirms it exited.

- **`jq`, `curl`, `git`**, **`python3` + `pip`** (flood; json-bench also renders
its benchmark config via `python3` + PyYAML), and the **.NET SDK** (only if
`/opt/dottrace` is not already installed by previous expb dotTrace runs).
- **Host `perf` and a root runner process** when using `perf: true`; `perf` must

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.

Low — the new prerequisite is filed under an arm64-only heading, but perf runs on both boxes.

This section opens "The reproducible-benchmarks-arm self-hosted runner must provide:" (L517), while perf: true is available for arch=amd64 too — and amd64 is the default. As written, a reader provisioning the amd64 box has no reason to think the root requirement applies to them, which is the one prerequisite that turns a dispatch into an immediate die rather than a degraded run.

Cheapest fix is to say so in the bullet:

Suggested change
- **Host `perf` and a root runner process** when using `perf: true`; `perf` must
- **Host `perf` and a root runner process** on *either* runner when using
`perf: true`; `perf` must be able to sample `cycles:u` (see
[Linux perf flow](#linux-perf-flow)).

Two smaller doc notes while you're in this file:

  • The Files table (L539) still describes stop-node.sh as "Graceful stop → collect logs + dotTrace → verify snapshot unchanged → tear down". It now also stops the recorder and folds the profile — and does so before the graceful stop, which is the non-obvious part worth a word.
  • The new section doesn't mention that a comparison run profiles only the primary node (the reference instance is started with PERF: "false", run-rpc-benchmarks.yml:777). Anyone expecting two profiles from an A/B dispatch will look for the second one.

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.

3 participants