Skip to content

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

Merged
kamilchodola merged 22 commits into
masterfrom
feature/perf-diag-mode
Sep 1, 2026
Merged

Add a perf profiling mode to the benchmark workflow#12952
kamilchodola merged 22 commits into
masterfrom
feature/perf-diag-mode

Conversation

@kamilchodola

@kamilchodola kamilchodola commented Aug 21, 2026

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. 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=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 Outdated
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

@benaadams benaadams closed this Aug 27, 2026
@benaadams benaadams reopened this Aug 27, 2026
@benaadams

Copy link
Copy Markdown
Member

@claude re-review

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 8m 55s —— View job


Re-review: perf profiling mode + EventPipe sidecar

Reviewed the eight branch commits since the last pass (bd9019bf66c8f7) and re-read the current state of every file the PR actually touches. Four inline comments posted.

  • Read the delta since the last review
  • Verify the prior finding is resolved
  • Review the new work (cpu-clock fallback, warm-up + deferred profiler start, EventPipe sidecar, nettrace-report.cs)
  • Cross-check CI wiring
  • Post review

Verdict

Last 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

# Was Now
1 (Low) On a combined dottrace+perf run an invalid fold exit 1s before the zip, discarding the valid .dtp perf_validation_failed is set, the zip runs, the exit 1 is deferred past it (f66c8f7, both jobs). test_expb_profile_archive_precedes_deferred_perf_failure pins the ordering by index comparison, so a future reorder reds CI.

Things I checked closely this round and found correct:

  • start_profilers sequencing. dotnet-trace attaches before perf, with the reason recorded (a perf failure must not leave a recorder for a retry to duplicate); the dotTrace start-message send is tracked separately from PROFILERS_STARTED_AT so a retry after a perf failure does not re-message a launcher that is already collecting. Both are pinned by test.
  • client_host_pid exclusion widened to dottrace|dotnet-trace. Load-bearing given the new ordering — the collector is running by the time perf looks for the client. ✔
  • stop_dotnet_trace_collector never signals the host docker exec PID, only the container-namespace collector after confirming it is still the one it started. Same conservatism as the perf identity guard, and the right call: a reused host PID can mislead the liveness poll but cannot get signalled.
  • Teardown order. dotnet-trace SIGINT → perf fold → docker stop. Both need the container up (IPC socket; --symfs /proc/<pid>/root), and both failures are latched and died only after umount and scratch removal, so a failed capture still cannot leak a mount.
  • perf_sampling_event probes cycles:u then cpu-clock:u and the chosen event is echoed by the preflight — the right shape for a rig where one box virtualises no PMU, and start_perf_recorder reuses the same resolved value rather than re-probing.
  • --duration formatting (390001:05:00) and the DOTNET_ROOT + DOTNET_ROLL_FORWARD=Major resolution, including the host/fxr requirement that turns the apphost's silent ".NET location: Not found" into a loud failure.
  • Test suite is still stdlib-only (24 cases), so last round's High stays fixed and rpc-bench-scripts keeps running under a bare setup-python.

Findings

# Severity Where Issue
1 Medium run-rpc-benchmarks.yml:389 The --duration cap starts at attach. With the default warmup_seconds=0 the collector attaches before the json-bench clone/build/corpus-conversion, so the cap can elapse before the measured cell — and lib.sh:315 reports an already-exited collector as success. Green run, valid artifact, no measured traffic in it.
2 Medium run-rpc-benchmarks.yml:97, AGENTS.md:165, README.md:566 "during the measured phase only" is stated unconditionally, but holds only when corpus_warmup_duration is set — and its default is 0.
3 Low nettrace-report.cs:98 The catch-all cannot tell "truncated tail" from "nothing parsed": a zero-event failure renders as no GC events / no contention stop events and exits 0. Plus lenient --top parsing.
4 Low run-jsonbench.sh:102 The reuse marker keys on the ref name, not the resolved sha; a stranded marker (hard-cancelled job, cleanup skipped) silently reuses a checkout from a ref that has moved. Same stranding retains the ~142 MB corpus fixture, which used to be removed as soon as the tool returned.
5 Low lib.sh:248 The inner docker exec probe inherits the loop's stdin from the process substitution feeding it; benign today, and one </dev/null away from staying that way. Also: dotnet tool install … dotnet-trace is unpinned on a rig where expb's revision is pinned for exactly that reason.

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 (lib.sh:219 states it as the design rationale, and it is a genuinely better mechanism than expb's diagnostic port). That premise is realised only on the corpus_warmup_duration > 0 path. On the default path the collector covers node drain + prepare + cell, which matters concretely because nettrace-report.cs reports GC pause and contention as a percentage of the window — padding the window understates both, silently. Making dotnet_trace=true imply a non-zero warm-up would close #1 and #2 at once and make the documented claim true by construction.

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

perf-report.sh, perf-fold.awk and validate-folded-profile.sh are byte-identical to the last review, so all five reader Lows stand: the all-zero-count awk division (perf-report.sh:36, :56), the managed-frame regex duplicated with 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 no-op wait "$PERF_PID" in stop-node.sh:91.

Not findings

  • run-rpc-sweep.sh, corpus_parity.py, corpus_results.py and the performance is good label removal all arrived via the master merge (9627ceb), not this PR — I traced each to 7150854/master before excluding it. The sweep's warm-up rate change is not yours to defend here.
  • Warm-up failure does not block the measured cell (|| echo "::warning::"). Correct: a failed warm-up degrades the profile to noise, it does not invalidate the run, and the warning says so.
  • Orphaned docker exec across steps. The collector survives start-profilers.sh exiting and docker exec without -t does not die with its client — which is why the container-side collector PID is tracked separately. Deliberate and right.
  • Gating unchanged and still closed. perf and dotnet_trace are both workflow_dispatch-only, both rejected for jsonbench-sweep, both require client=nethermind at resolve, and pull_request/push hardcode false — so no profiled run can become a cached baseline.
  • zip -x '*/perf.data' still matches both collectors' path shapes; the reference node still starts with PERF: "false" / DOTNET_TRACE: "false", so no -reference capture slips past. ✔

Verification limits

Static reading only — python3 -m unittest discover -s scripts/rpc-bench was denied in this sandbox, so the 24-case suite is unrun here and #3/#4 are traced by hand through the C#/shell rather than observed. A local python -m unittest discover -s scripts/rpc-bench -p 'test*.py' would settle the suite in seconds.

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 start-profilers.sh, start_dotnet_trace_for_container, container_dotnet_root, stop_dotnet_trace_collector, the deferred-dotTrace control-file protocol and the warm-up/reuse handshake is new code exercised only against fake-docker fixtures. One perf=true dotnet_trace=true corpus_warmup_duration=60 dispatch per box would settle all of it — and would be the only way to see whether the .nettrace window actually lands where #1 and #2 say it should.


Recommendation: #1 and #2 are worth resolving here — even "make dotnet_trace imply a warm-up" would close both. #3#5 are fine as follow-ups. Note I cannot push the workflow half myself; GitHub App permissions exclude .github/workflows.
· branch feature/perf-diag-mode

Comment thread .github/workflows/run-rpc-benchmarks.yml
Comment thread .github/workflows/run-rpc-benchmarks.yml Outdated
Comment thread scripts/nettrace-report.cs
Comment thread scripts/rpc-bench/run-jsonbench.sh Outdated
Comment thread scripts/rpc-bench/lib.sh Outdated
…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.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 4m 11s —— View job


I'll analyze this and get back to you.

kamilchodola and others added 4 commits August 28, 2026 12:11
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.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 10m 13s —— View job


Re-review: perf profiling mode + EventPipe sidecar

Reviewed the five branch commits since the last pass (f66c8f77d49e88) and re-read the current state of the files they touch. Five inline comments posted.

  • Read the delta since the last review
  • Verify the prior findings are resolved
  • Review the new work (the dotnet_trace shape gate, the implied warm-up, the profiled warm-up hard-fail, the version pin, the reader fixes)
  • Cross-check CI wiring
  • Run the test suite
  • Post review

Verdict

Every finding from the last round is fixed, and the test suite is green at HEADCode Lint run 33202659057 passed Test RPC benchmark scripts on 7d49e88, the first time in this PR's history I have been able to say that rather than reason about it. No Critical or High. One Medium and four Lows, none of which touches client code or benchmark timings.

Prior findings — resolved

# Was Now
1 (Med) --duration cap starts at attach, which precedes the cell on the default path dotnet_trace=true implies a 60s warm-up when the dispatch sets none (resolve L392-399), and stop_dotnet_trace_collector no longer reports an already-exited collector as success — it logs the trace does not cover the measured phase and returns 1, latched into dotnet_trace_fail and died after umount
2 (Med) "measured phase only" stated unconditionally in three places All three qualified, and made true by construction: dotnet_trace is now rejected outright for anything but a single-node jsonbench, so there is no shape left where the claim is false
3 (Low) nettrace-report.cs cannot tell a truncated tail from nothing parsed; lenient --top eventCount == 0error: no events could be read + exit 1, placed after the catch so it also covers a Process() that returns having parsed nothing; --top validated, extra args rejected, path taken from the first non-flag argument
4 (Low) Reuse marker keyed on the ref name Keyed on the resolved sha (see the new Low below on where that resolution happens)
5 (Low) docker exec probe shares the loop's stdin; unpinned dotnet-trace </dev/null on both, with a regression test that makes the fake probe drain stdin so removing it fails; DOTNET_TRACE_VERSION pinned

The four reader Lows carried since the 24th are all closed too: the zero-count guard landed in require_file rather than the two END blocks (correct — compare's self_time runs inside process substitutions, so an awk exit 1 there would be invisible), perf-fold.awk strips before matching, the duplicated managed-frame regex is pinned equal by extracting the literal from both files, and compare now titles itself a.folded -> b.folded.

Two things I checked closely this round and found correct: the resolve body is pure bash + jq with no ${{ }} and no network, so resolve_script() genuinely executes it — test_dotnet_trace_is_only_resolved_where_a_warmup_precedes_the_measured_cell is a behavioural test of the gate, not a text assertion, and it covers corpus_warmup_duration: 0 (jq's // passes 0 through, so the explicit-zero rejection really fires). And Collect dotnet-trace is a separate always() step, so a partial trace is still uploaded even when stop-node.sh dies.

Findings

# Severity Where Issue
1 Medium run-rpc-benchmarks.yml:1142 A failed perf capture ships nothing — exit 1 precedes the zip, discarding perf-record.log, and neither stop-node.sh failure path tails it the way the dotnet-trace block does
2 Low start-node.sh:328 The DOTNET_TRACE_VERSION pin is behind [[ ! -x ... ]], so a box that already has /opt/dotnet-trace from an earlier unpinned run keeps whatever it has
3 Low start-node.sh:54 dotTrace's install stays unpinned, with the same drift argument and a stronger one — it perturbs the timings
4 Low run-jsonbench.sh:106 git ls-remote on every invocation buys nothing under the default pinned sha, adds a hard network failure to the local reuse path, and resolves independently in the warm-up and the cell
5 Low test_perf_reporting.py:908 The new executable-bit test excludes scripts/dottrace-report.sh, which is 100644 with a shebang and documented in AGENTS.md:230 as a by-path command

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 f66c8f7, with a different consequence: dotTrace and dotnet-trace have their own archives, so nothing else is lost, but perf-record.log is. Nothing else surfaces it — lib.sh tails it only when the recorder dies inside the first second, and stop-node.sh:97 / :126 log a bare line, unlike the dotnet-trace block at :49 which tails its collector log. So the first real perf=true dispatch that fails after a successful start yields ERROR: perf folding failed and nothing else, on a rig where reproducing costs a snapshot restore.

On #4 — the sub-point matters more than the headline. resolved_ref is computed separately in the warm-up and in the measured cell, so a tool_config.ref branch that moves between them misses the marker and re-clones, rebuilds and re-converts the corpus with the profilers already recording. That is the window the deferred start and the new warm-up hard-fail exist to keep clear, and it fails silently. Resolving once in resolve and passing the sha down closes it and removes the per-invocation round-trip at the same time.

Not findings, but worth stating

  • dotnet_trace capability narrowed. flood and ethcallchaos traces are now rejected outright, and start-node.sh's RPC-ready attach for DOTNET_TRACE is unreachable from the workflow (still valid for a direct script invocation, which the README says). Deliberate, documented, and the right trade if the per-window percentages are the point — flagging only so nobody rediscovers it as a regression.
  • The profiled warm-up hard-fail cascades. With Start profilers skipped, no PERF_PID/DOTNET_TRACE_PID is persisted, so stop-node.sh dies with perf profiling FAILED and the collect steps ::error:: too. Three red steps, one cause, and the real one is two steps up. Harmless — teardown still verifies the snapshot and unmounts before dying — but the last error in the run is the misleading one.
  • Gating unchanged and still closed. perf, dottrace and dotnet_trace are all workflow_dispatch-only, all require client=nethermind at resolve (now pinned by test for all three), and pull_request/push hardcode false, so no profiled run can become a cached baseline. ✔
  • perf-report.sh's require_file guard makes totalSamples > 0 for every view including compare's process substitutions; $NF and split($0, parts, " ") agree on which field the count is. ✔

Verification limits

The Python suite is confirmed green in CI at 7d49e88, not locally — python3 and bash invocations were both denied in this sandbox again, so findings 1-5 are traced by hand through the shell/YAML rather than observed. DOTNET_TRACE_VERSION=9.0.661903 is unverified either way: NuGet was unreachable from here, and a version that does not exist would die only on a runner with no cached /opt/dotnet-trace.

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 start-profilers.sh, the deferred-dotTrace control-file protocol, container_dotnet_root, the in-container map copy and the SIGINT flush window is exercised only against fake-docker fixtures. One perf=true dotnet_trace=true dispatch per box would settle it — and is now cheap to read, since the implied warm-up means the dispatch needs no extra tool_config.


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

ARCHIVE: ${{ runner.temp }}/perf-rpcbench.zip
run: |
set -euo pipefail
if bash scripts/validate-folded-profile.sh "${DIAG_DIR}/perf/perf.folded"; 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.

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 — no sed 's/^/ /' "$DIAG_DIR/perf/perf-record$SUFFIX.log", which is exactly what the dotnet-trace path does at stop-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:

Suggested change
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.)

Fix this →

# 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

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 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.

Suggested change
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.

Fix this →

# 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 }')" \

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 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.

Fix this →

# 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",

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 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.

Fix this →

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}"

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 — 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.
@kamilchodola

Copy link
Copy Markdown
Contributor Author

@claude re-review

@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kamilchodola's task in 3m 49s —— View job


Re-review: perf profiling mode + EventPipe sidecar

Reviewed the single branch commit since the last pass (7d49e88c93f400) and re-checked the state of every finding still open. One inline comment posted.

  • Read the delta since the last review
  • Verify the prior Medium is resolved
  • Re-read the perf/dotnet-trace collector paths end to end
  • Cross-check CI wiring
  • Post review

Verdict

The 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

# Was Now
1 (Med) A failed perf capture exit 1s before the zip, discarding perf-record.log perf_validation_failed is latched, the archive is built from whatever the capture left (perf.data still excluded), the recorder log is tailed into the job output, and the exit 1 is deferred past both — the ordering f66c8f7 gave the expb collector. test_a_failed_perf_capture_still_ships_its_recorder_log pins the zip-before-exit ordering by index comparison

Things I checked closely on the new code and found correct: the unmatched perf-record*.log glob degrades to a literal that [[ -s ]] skips, so the loop is safe when the directory is absent; find … -type f -not -name 'perf.data' never reports "something to archive" for a directory the zip -x would then empty, because start_perf_recorder always creates the recorder log; bash …validate… || perf_validation_failed=true suppresses set -e correctly and still prints the managed/native/unknown split; and the reference node is unaffected, since it starts with PERF: "false" so no suffixed capture exists for the two exclusions to disagree about.

I also re-derived one thing I had flagged as a possible hole and it is closed: run-jsonbench.sh runs under set -o pipefail, so git ls-remote … | awk propagates an unreachable-remote failure to the || die rather than silently yielding an empty resolved_ref.

Finding

# Severity Where Issue
1 Low run-rpc-benchmarks.yml:1156 The shipped log covers one of the four perf_fail paths; on the two most likely ones (map copy, perf script fold) it reads as a successful capture and the real cause is in the Stop node step output

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

  • start-node.sh:328 — the DOTNET_TRACE_VERSION pin sits behind [[ ! -x /opt/dotnet-trace/dotnet-trace ]], so the pin does not apply on a box that already installed the tool unpinned.
  • start-node.sh:351JetBrains.dotTrace.GlobalTools is still unpinned, twenty lines from the collector that just got pinned, and it is the one that perturbs timings.
  • run-jsonbench.sh:106git ls-remote on every invocation, resolved independently in the warm-up and the cell; a tool_config.ref branch that moves between them re-prepares inside the profiler window.
  • scripts/dottrace-report.sh — still 100644 with a #!/usr/bin/env bash shebang, documented at AGENTS.md:230 as a by-path command, and deliberately excluded from test_every_script_with_a_shebang_is_committed_executable. Either add it to the guarded set or say in the comment that the exclusion is knowing.

Not findings

  • The expb collector's two remaining pre-zip exit 1s. Both fire only when there are no profiling directories or no files in them — nothing is discarded, because there is nothing to discard. ✔
  • if-no-files-found: error on Upload perf profile. Correct on the success path; on a total-failure run it adds one redundant annotation, which is the pre-existing cascade shape, not a regression.
  • Gating unchanged and still closed. perf, dottrace and dotnet_trace are workflow_dispatch-only, all three require client=nethermind at resolve, and pull_request/push hardcode false, so no profiled run can become a cached baseline. ✔

Verification limits

Static reading only this round — python3 and bash invocations were denied in this sandbox, so the 25-case suite is unrun here and the finding is traced by hand through the shell. Code Lint is still in progress at c93f400 (run 33205679833); it was green at 7d49e88 last round, and this commit adds one text-assertion test with no new imports, so I would expect it to stay green — but that is an expectation, not an observation.

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, --symfs /proc/<pid>/root, the SIGINT flush window, container_dotnet_root and the deferred-dotTrace control-file protocol are exercised only against fake-docker fixtures — and, as the inline note explains, those are exactly the paths whose failure the newly-shipped recorder log does not describe.


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

fi

if [[ "${perf_validation_failed}" == "true" ]]; then
for recorder_log in "${DIAG_DIR}"/perf/perf-record*.log; do

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 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}/perf is absent entirely — which is what a failed Start profilers produces, since the profiled warm-up hard-fail skips it — this step emits a ::warning:: plus an ::error::, and Upload perf profile then adds a third annotation via if-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' and zip -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.

Fix this →

@kamilchodola
kamilchodola merged commit 3e3927d into master Sep 1, 2026
500 checks passed
@kamilchodola
kamilchodola deleted the feature/perf-diag-mode branch September 1, 2026 12:01
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