Skip to content

benchmarks CI: occasional 1.5-5.5h stalls — mmap page-fault crawl in etl mergeSortFiles during BenchmarkPruneSmallBatches flush #22361

Description

@yperbasis

Symptom

Since July 7 the bench / benchmarks (serial|parallel) jobs (ci-gate → test-bench.yml) occasionally take 1.5–5.5 hours instead of the usual ~33 minutes, blocking the merge queue. Twelve events on July 7–9 at roughly 6–8% incidence per job; both matrix shards, Intel and AMD runners, two runner-image versions, twelve unrelated PR branches. Examples: 339 min (parallel, success), 201 min (serial, success), 303 min (serial, cancelled).

Duration distribution of benchmarks (serial) successes (job-level sweep of ci-gate runs):

window n median p90 max
Jun 18–30 69 31 m 35 m 38 m
Jul 1–7 73 32 m 35 m 53 m
Jul 8–9 64 31 m 34 m 201 m

Where the time goes

Gap analysis of six affected job logs shows a single silent window every time, inside db/test, between the BenchmarkSharedDomains_ComputeCommitment result line and the BenchmarkPruneSmallBatches result line — i.e. in the latter's untimed setup (generation loop + 100 inline ComputeCommitment calls + Flush/Commit + BuildFiles). ok db/test self-reports 10,297–19,244 s vs ~733 s in a healthy run; the measured prune iteration stays at 0.4–1.7 s even in the worst case, and the benchmarks right after the window run at full speed.

Crucially, the VM is healthy during the stall: the other ~238 test packages — including MDBX-heavy ones — complete concurrently at normal speed, and a sweep of ~14,000 job durations shows no other CI job type moved between the windows. The stall is inside one process, in one phase.

Ruled out

  • Runner hardware pools / image rollout (outliers span both CPU vendors and two image versions), fleet-wide events (no other job type affected).
  • The mdbx-go bump in db: opt-in ReadAhead of Table with limited "ahead window" #21880 (Go-wrapper-only; both tags bundle the identical libmdbx 0.14.2 core, in-tree since May 20). ReadAhead itself is feature-flag-gated off.
  • Unbounded whale-fold fan-out: the execution/commitment: cap storage-fold fan-out at GOMAXPROCS #22330 cap was already in the tree of a July 9 outlier (verified by commit ancestry).
  • A poisoned queued PR (twelve unrelated branches), cache byte budgets (cachebudget caps the envelope at RAM/32), a plainly reproducible code regression (24 local runs on a 64 GB Mac at both main and a July 5 baseline: all clean, ~500–650 s each).

A/B experiment + watchdog

To separate code from environment, test-bench.yml was overlaid with a stall watchdog (SIGQUIT to the test binaries after 60 min → the Go runtime dumps all goroutine stacks into the job log; see #22358) on two adjacent-commit branches, 12 dispatches each (24 workload samples per arm):

Both stalled jobs produced goroutine dumps at exactly the 60-minute mark, with the identical stack on two independent VMs:

goroutine 348 [running, locked to thread]:
bytes.Compare(...)
github.com/erigontech/erigon/db/etl.(*Heap).Less (db/etl/heap.go:38)
github.com/erigontech/erigon/db/etl.down / heapPop
github.com/erigontech/erigon/db/etl.mergeSortFiles (db/etl/collector.go:319)   ← 61 providers
github.com/erigontech/erigon/db/etl.(*Collector).Load (db/etl/collector.go:256) ← table "CodeVals"
github.com/erigontech/erigon/db/state.(*DomainBufferedWriter).Flush (db/state/domain.go:497)
github.com/erigontech/erigon/db/state.(*TemporalMemBatch).Flush
github.com/erigontech/erigon/db/state/execctx.(*SharedDomains).Flush
github.com/erigontech/erigon/db/test.BenchmarkPruneSmallBatches (domain_shared_bench_test.go:310)

with the benchmark goroutine at [chan receive, 47 minutes].

Root cause

BenchmarkPruneSmallBatches's setup accumulates ~2 GB in sd.mem (random contract-code blobs dominate). SharedDomains.Flush spills the code domain through an ETL collector into ~61 files and k-way-merges them back (etl.mergeSortFiles). The spill files are read zero-copy via mmap (fileDataProvider / mmapBytesReader in db/etl/dataprovider.go), so the merge-heap keys/values alias mmap pages — db/etl/collector.go even carries a comment that element.Value may point into read-only mmap.

On a 16 GB / 4-core hosted runner whose page cache is under pressure, each bytes.Compare/copy that touches a cold spill page takes a major page fault at (possibly throttled) disk latency. At ~10 ms per fault, the ~165k-entry merge takes tens of minutes to hours — matching the observed 0.8–5.3 h continuum and its variability. Because mmap faults do not park goroutines, the dump shows the goroutine [running] in cmpbody — an I/O stall disguised as CPU work, which is what let it evade CPU-vs-I/O triage: concurrent packages (tiny I/O) run normally, and the moment the merge completes the working set shrinks, so the job "recovers" instantly. On a 64 GB dev machine the spill set stays fully cached and the merge is pure CPU (seconds) — hence zero local repro in 24 attempts.

#22154's role is amplifier, not defect-in-stack: none of its code appears in the stall stack (its code-store/state-cache write-through hooks SharedDomains.Commit, not Flush), but its aggregator-lifetime resident caches add memory pressure during the setup's 100 inline commitments, evicting spill pages on marginal VMs — consistent with onset tracking its July 7 merge and with the A/B split. Two-of-24 vs zero-of-24 is under-powered on its own (Fisher p≈0.24); the conviction comes from the dumps plus the mechanism.

Why nothing bounds a stalled run today

go test -timeout does not apply to benchmarks — testing.M.Run stops the timeout alarm before the benchmark phase (verified in the Go 1.25 sources). The job had no timeout-minutes, so it inherited GitHub's 6-hour default; stalled runs held the merge queue until cancellation. One stalled runner received an external shutdown signal mid-stall.

Follow-ups

  • Merge ci: bench stall watchdog (SIGQUIT stack dumps) #22358 — 90-min job timeout + the 60-min SIGQUIT watchdog (live-validated by the two A/B stalls above: fired on time, produced the dumps, failed fast).
  • Trim the db/test benchmark setups with testing.Short() guards per CI-GUIDELINES ("Checking benchmarks") — make test-bench already passes -short. The ~12-minute untimed setup is the exposure that turns page-cache pressure into a multi-hour queue stall.
  • Consider a durable db/etl fix so merges degrade gracefully under memory pressure: batched MADV_WILLNEED on the upcoming provider windows, or switching fileDataProvider to buffered pread with explicit readahead, so a cold page costs a scheduled read instead of a blocking fault per compare.
  • Delete the experiment branches yperbasis/bench-ab-with22154 / yperbasis/bench-ab-pre22154 once this is triaged.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions