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
| Bench | Crate | Hot-Path | Frequency in Pool |
|---|---|---|---|
validate | warppool-share-validator | ShareValidator::validate() per accepted+rejected share | per-share (most frequent) |
build_job | warppool-job-builder | JobBuilder::build() per new block template | per-job (~every 30-60s) |
vardiff | warppool-stratum-v1 | VarDiff::observe_share() per accepted share | per-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
sha2to 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)
| Bench | Time | Throughput |
|---|---|---|
validate_full/0 (no merkle branches) | 435 ns | 2.3M shares/s |
validate_full/8 (typical regtest) | 1.12 µs | 890K shares/s |
validate_full/12 (typical mainnet) | 1.48 µs | 677K shares/s |
sha256d_80b_header | 76 ns | — |
sha256d_500b_coinbase | 245 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)
| Bench | Time | Throughput |
|---|---|---|
build_job/0 (empty / regtest) | 1.25 µs | — |
build_job/100 | 18.2 µs | — |
build_job/1000 | 161 µs | — |
build_job/4000 (typical full block) | 634 µs | 1.5K jobs/s |
merkle_branches/4000 | 318 µ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)
| Bench | Time |
|---|---|
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:
| Bench | M-Series (v1.0.18) | Ryzen 8745HS (v1.0.23) |
|---|---|---|
validate_full/0 | 435 ns | 334 ns |
validate_full/8 | 1.12 µs | 1.15 µs |
validate_full/12 | 1.48 µs | 1.56 µs (~642K shares/s) |
sha256d_80b_header | 76 ns | 92 ns |
sha256d_500b_coinbase | 245 ns | 252 ns |
build_job/0 | 1.25 µs | 0.69 µs |
build_job/4000 | 634 µs | 598 µs |
merkle_branches/4000 | 318 µs | 385 µs |
vardiff_observe_share_hold | 5.2 ns | 3.3 ns |
vardiff_observe_share_retarget | 39.0 ns | 28.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:
- Stratum core, in-process —
scenario enterprise-load-smokespawns 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. - Full daemon, external —
load <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)
| Miners | Duration | Accepted shares | Rate | Rejected |
|---|---|---|---|---|
| 100 | 60 s | 27,667 | ~461 shares/s | 0 |
| 1,000 | 60 s | 277,256 | ~4,621 shares/s | 0 |
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)
| Profile | Miners | Submits processed | Daemon RSS peak | Daemon CPU |
|---|---|---|---|---|
| farm-large | 100 | ~114 /s | ~48 MB | ~4–5 % of one core |
| farm-enterprise | 1,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.
| Template | GBT + ZMQ (default) | Core IPC (opt-in) |
|---|---|---|
| empty block | 88.9 ms | 5.0 ms |
| 320 transactions | 101.4 ms | 12.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:
| Question | Hint |
|---|---|
| “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:
- Run
cargo bench --bench <name>locally → confirm git bisectbetween the last known-good version and HEAD- 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
| Path | Why not |
|---|---|
| Stratum V1 TCP I/O | Tokio 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 RPC | Network IO + Bitcoin-Core-side dominates. The Phase 16.3 RPC-latency histogram is the right observation. |
| Translator V1↔V2 mapping | Per-job (every 30-60s), not latency-critical. Would be effort for little benefit. |
| Storage SQL | sqlx + WAL mode dominates. If needed, bench directly with the sqlite3 CLI. |
| Notifier sinks | HTTP/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