Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Performance Benchmarks

dvb-WarpPool uses Criterion for hot-path benchmarks. Three suites cover the critical code paths. The goal is not maximum throughput — a solo pool’s workload is small — but a regression baseline so that code changes are caught early when they unexpectedly become expensive.

Suites

BenchCrateHot-PathFrequency in Pool
validatewarppool-share-validatorShareValidator::validate() per accepted+rejected shareper-share (most frequent)
build_jobwarppool-job-builderJobBuilder::build() per new block templateper-job (~every 30-60s)
vardiffwarppool-stratum-v1VarDiff::observe_share() per accepted shareper-share

Running Locally

# Single bench
cargo bench -p warppool-share-validator --bench validate

# All three
cargo bench --workspace --benches

# Compile-check only, no runs (quick smoke before a long suite)
cargo bench --workspace --no-run

Criterion writes reports to target/criterion/<bench-name>/report/index.html. On a second run it compares against the first and reports drift (Performance has regressed. / Performance has improved.).

Baseline Numbers (2026-06-11, v1.0.18, MacBook M-Series, release build)

These numbers are a snapshot, not a hard contract — they can vary by 2× depending on hardware and CPU throttling. On Linux x86_64 server hardware the values are typically similar or better.

~4-7× faster than the 2026-05-27 snapshot. The June dependency round bumped sha2 to 0.11, whose rewritten backend uses the CPU’s SHA2 intrinsics on Apple Silicon / x86_64 — every SHA-dominated path (share validate, merkle root, job build) gained accordingly. VarDiff does no hashing and is unchanged.

validate (per share)

BenchTimeThroughput
validate_full/0 (no merkle branches)435 ns2.3M shares/s
validate_full/8 (typical regtest)1.12 µs890K shares/s
validate_full/12 (typical mainnet)1.48 µs677K shares/s
sha256d_80b_header76 ns
sha256d_500b_coinbase245 ns
merkle_root/12(hot-path portion) ~1.0 µs
reconstruct_coinbase~34 ns
build_header~4 ns

Take-away: validate scales with merkle-branch count (linearly). At 12 branches the pool can validate ~677K shares/s — five orders of magnitude above what a solo fleet produces (typically 1-5 shares/s). Validate is NEVER the bottleneck.

build_job (per job-refresh)

BenchTimeThroughput
build_job/0 (empty / regtest)1.25 µs
build_job/10018.2 µs
build_job/1000161 µs
build_job/4000 (typical full block)634 µs1.5K jobs/s
merkle_branches/4000318 µs

Take-away: Job-build scales with tx-count, dominated by merkle-branch computation. 634 µs / job for a full mainnet block is clearly visible but not a problem — templates arrive every 30+ seconds, not every ms.

vardiff (per share, EMA-update + retarget)

BenchTime
vardiff_observe_share_hold (stationary)5.2 ns
vardiff_observe_share_retarget (8-share burst)39.0 ns (~5ns/share)
difficulty_to_target_be~3.5 ns
vardiff_decision_variant_match~340 ps

Take-away: VarDiff is effectively free. Even under extreme load scenarios (>100K shares/s) it consumes <1ms/s of CPU.

Cross-Check (2026-06-12, v1.0.23, Umbrel Home — AMD Ryzen 7 8745HS, Debian 13)

Same suites, measured on a production Umbrel box while the pool and Bitcoin Core kept running (realistic operator conditions, ~5 GB RAM in use). Same order of magnitude as the M-Series snapshot — no regression; the per-path differences are silicon, not code:

BenchM-Series (v1.0.18)Ryzen 8745HS (v1.0.23)
validate_full/0435 ns334 ns
validate_full/81.12 µs1.15 µs
validate_full/121.48 µs1.56 µs (~642K shares/s)
sha256d_80b_header76 ns92 ns
sha256d_500b_coinbase245 ns252 ns
build_job/01.25 µs0.69 µs
build_job/4000634 µs598 µs
merkle_branches/4000318 µs385 µs
vardiff_observe_share_hold5.2 ns3.3 ns
vardiff_observe_share_retarget39.0 ns28.4 ns
difficulty_to_target_be~3.5 ns~5.3 ns

The paths benched here are untouched since the v1.0.18 snapshot (releases 1.0.19–1.0.23 changed UI, API, storage queries and stratum I/O — none of it on these paths), so matching numbers were expected; the box run confirms it on x86_64/SHA-NI.

End-to-End Load Test (2026-06-13, post-v1.1.0, Apple M2 Pro 32 GiB)

The Criterion suites above measure single functions. This section measures the whole serving path under swarm load, using the dvb-warppool-sim simulator in two setups:

  1. Stratum core, in-processscenario enterprise-load-smoke spawns a StratumServer plus N simulated miners in one process (VarDiff off, difficulty 1.0, every submit accepted): real TCP, real JSON parsing, real sha256d share validation, no DB.
  2. Full daemon, externalload <profile> --target … fires the miner swarm at a release daemon running against a regtest Bitcoin Core v31 (cookie RPC + ZMQ), i.e. the complete production pipeline: connection handling, class detection, VarDiff, validator, SQLite share sink, job broadcasts and the block-submit loop.

Simulated miners speak genuine Stratum V1 but do no real proof-of-work: submits are timer-driven from the persona’s nominal hashrate and carry random nonces. Plain TCP only (the sim client does not implement TLS).

Stratum core throughput (in-process)

MinersDurationAccepted sharesRateRejected
10060 s27,667~461 shares/s0
1,00060 s277,256~4,621 shares/s0

Throughput scales linearly with the swarm (the rate is set by the personas’ submit cadence, not by the server — validate alone clocks ~650K shares/s single-threaded, see above). No backpressure, no disconnects, zero rejects at 4.6K shares/s sustained.

Full daemon under swarm load (external process)

ProfileMinersSubmits processedDaemon RSS peakDaemon CPU
farm-large100~114 /s~48 MB~4–5 % of one core
farm-enterprise1,000~1,295 /s~47 MB~28–37 % of one core

Both runs: zero stale shares, zero unexpected disconnects, all miners connected through a ~2 s ramp and stayed connected for the full minute.

Reading the accepted/rejected split. On regtest the network target is the powlimit, so a random nonce “finds a block” with ~50 % probability — roughly half the submits were accepted as block-found events, the other half correctly rejected as above pool target (random nonces practically never meet a difficulty-1+ pool target). Real miners only submit hashes that already meet the pool target, so on mainnet this split becomes ~100 % accepts. The load-relevant number is the processed-submits rate: every submit walks the full validation path either way. A side effect worth noting: the ~50 % block hits made the daemon fire submitblock storms at Bitcoin Core, with concurrent blocks racing each other (losers logged as inconclusive) and every win triggering a clean-jobs broadcast to all 1,000 miners — the pool stayed healthy through job churn far beyond anything mainnet produces.

Reproduce:

# in-process core
dvb-warppool-sim scenario enterprise-load-smoke --miners 1000 --duration 60
# full daemon (point --target at a NON-production instance!)
dvb-warppool-sim load farm-enterprise --duration 60 --target tcp://127.0.0.1:13333

Template Source Latency — IPC vs GBT+ZMQ (2026-07-08, Bitcoin Core v31.1, regtest)

Not a Criterion micro-bench: this measures how fast a fresh block template is ready in the pool after a new chain tip, comparing the default getblocktemplate + ZMQ path against the opt-in Bitcoin Core IPC template source (Cap’n Proto, Core ≥ 31, [node] template_source = "ipc", new in v1.16.0). Release build, Core v31.1 from the official release binary, 50 rounds, median.

TemplateGBT + ZMQ (default)Core IPC (opt-in)
empty block88.9 ms5.0 ms
320 transactions101.4 ms12.3 ms

The gap is not CPU — it’s the notification path. Verified with Core’s -logtimemicros: Core’s ZMQ publish lags the UpdateTip by ~85 ms (it rides the validation queue / scheduler), so a GBT consumer only learns about the new tip ~85 ms late and then still has to call getblocktemplate. The IPC path’s waitNext hangs directly on the kernel’s block-connected notification, so it returns the moment the tip changes.

Honest reading: this is a latency improvement, not a hashrate one. For a solo pool it shrinks the window in which miners keep grinding a stale template right after a block change — fewer wasted hashes at the block boundary, nothing more. It is opt-in and needs Bitcoin Core v31 (the v30↔v31 IPC schema is wire-incompatible), and Core’s IPC mining interface is itself still experimental; GBT stays the default and is unaffected. See the IPC design note for the schema-maintenance trade-offs.

Interpretation

What you can read from the numbers:

QuestionHint
“Is my pool burning too much CPU?”No. At 10 shares/s and 12 merkle branches: ~15 µs share-validate time per second = 0.0015% CPU
“How many workers can my pool serve at most?”The Stratum connection cap (profile-dependent, 64-4096). Share-validate is not the limit — the load test above shows 1,000 live miners at ~⅓ of one core and <50 MB RSS
“Is ASIC-boost / merkle-tree caching worth it?”No, not in a solo pool. In a 10M-shares/s pool, per-template merkle-branch caching would be a factor of 5-10

CI

.github/workflows/benches.yml — executed by Forgejo Actions on the self-hosted runner — runs only on manual dispatch (operator clicks “Run workflow” in the Actions UI on git.warppool.org). It is deliberately not triggered on tag push (the fat-LTO bench profile OOM-compiles the big crates on the CI box, and a red bench run must never make a published release look failed) and not on every PR. Criterion runs are expensive (~5min build + 5min suite), and CI-runner noise makes microbench comparisons unreliable.

Reports are uploaded as artifact criterion-reports-<sha> with 30-day retention. The operator can download them and view them locally in the HTML report.

Regression Workflow

When a bench suddenly becomes 50% slower:

  1. Run cargo bench --bench <name> locally → confirm
  2. git bisect between the last known-good version and HEAD
  3. On dependency bumps: inspect the Cargo.lock diff (often pulls a new version of a transitive dep)

Criterion automatically stores the last baseline in target/criterion/ — when you bench locally, it compares against YOUR last run, not against CI. For a CI-vs-local comparison, download the artifact and place it locally under the target/criterion/ path.

What is Deliberately Not Benched

PathWhy not
Stratum V1 TCP I/OTokio async-IO is syscall-bound; criterion would be noise-dominated. Covered at the swarm level by the end-to-end load test above; tokio-console for inspection.
Bitcoin RPCNetwork IO + Bitcoin-Core-side dominates. The Phase 16.3 RPC-latency histogram is the right observation.
Translator V1↔V2 mappingPer-job (every 30-60s), not latency-critical. Would be effort for little benefit.
Storage SQLsqlx + WAL mode dominates. If needed, bench directly with the sqlite3 CLI.
Notifier sinksHTTP/SMTP IO, not CPU-bound. End-to-end latency is readable from the /metrics histogram.

See Also

  • Observability — Runtime metrics (Prometheus) instead of synthetic benches
  • Testing — Unit / integration / sim tests