crates/testkit/src/bench.rs is a bounded measurement harness, and each benchmarked crate carries benches/ targets that use it. A benchmark run records integer nanosecond statistics for one operation on one machine. The harness has no assertions and no timing threshold anywhere, so no measured figure can fail CI, and a benchmark can never substitute for a test.

The harness and its per-crate micro-benchmarks measure single operations on a single host. Distributed and end-to-end throughput remain unmeasured, and no figure here is comparable across machines.

Why no benchmarking framework

The workspace pins every dependency to an exact version and .github/scripts/check-license-coverage.py pins locked_packages at 158 with a committed baseline. A benchmarking framework would add tens of locked packages, break that baseline, and widen the dependency and licence surface that document 51 records. The harness is therefore standard-library only, and the sole new dependency edge is a path dev-dependency on the existing in-workspace testkit. The locked package count is unchanged at 158.

Measurement method

A round executes a calibrated batch of operations under one Instant bracket, and the elapsed time is divided by the batch size before any statistic is taken. Calibration starts at one operation and doubles the batch until a round reaches DEFAULT_TARGET_ROUND (2 ms) or the batch reaches MAX_BATCH (2^24), so per-call clock overhead is not attributed to the operation and an expensive operation is never run more than twice as often as needed. Three unmeasured warmup rounds precede sampling. Every statistic is an integer nanosecond count; no floating point participates, and the even-sample median uses u128::midpoint rather than a sum that could overflow.

ASTROLUNE_BENCH_ROUNDS and ASTROLUNE_BENCH_TARGET_US shorten exploratory runs. Malformed or zero values are ignored rather than rejected, because an override exists to save time and must not stop a measurement. Overrides change only sampling duration and the reported round count.

Each suite prints a readable table followed by one astrolune.benchmark/1 JSON record, matching the schema convention of the other report tools in .github/scripts, so a run can be captured as an artifact without re-parsing the table.

What a measurement does not establish

  • Not correctness. Worker-count equivalence for parallel verification and parallel execution is established by the tests described in document 9 and document 29. A worker count that measures faster is not more correct, and a slower one is not wrong.
  • Not comparable across machines. Figures below state one host. They do not transfer to another CPU, another toolchain, or a virtualized runner.
  • Not statistically qualified. The harness reports the minimum, median, arithmetic mean and maximum of the sampled rounds. It reports no confidence interval, and a difference between two medians is not tested for significance.
  • Not operation-latency percentiles. A sample is a batch mean, not a single operation, so percentiles over samples would not be operation-latency percentiles. The harness deliberately does not print p95 or p99, and the p50/p95/p99 latency requirement of section 7.6 remains open.
  • Not throughput. No figure here is a block, transaction or quorum throughput claim, and none is measured under contention from another process or under any network condition.
  • Not reliable below about a microsecond. These figures were taken while the same workspace was being compiled, so sub-microsecond rows moved by factors of three to seven between repeat runs, and one 16 KiB encode row moved by seven as an allocator-state effect. Relative findings reproduced across runs; single small absolute values should not be quoted. A stray maximum_ns far above its median is a scheduling stall, not a tail latency.

Running the benchmarks

cargo bench --locked -p crypto
ASTROLUNE_BENCH_ROUNDS=5 ASTROLUNE_BENCH_TARGET_US=500 cargo bench --locked -p crypto

cargo bench builds with optimizations, so a debug-profile figure is not comparable.

Hosted runs

The Benchmarks (ubuntu-latest) and Benchmarks (windows-latest) CI jobs run every suite on both platforms and are part of the required checks. What they establish is narrow and deliberate: that every declared benchmark still compiles, executes and produces measurements on both platforms. What they do not establish is any figure worth comparing, because the runners are shared and their timings move between runs by more than the differences this document draws conclusions from.

Nothing in the job thresholds a duration. It fails only when a benchmark panics, when the process exits non-zero, when a suite reports no measurement, or when a declared [[bench]] target produces no suite at all. That last check matters most: .github/scripts/benchmark-report.py counts [[bench]] targets across the workspace manifests and refuses a run with fewer suites than targets, so a benchmark cannot quietly stop running and still report success. Counting targets rather than matching names is deliberate, since a target may name its suite differently, as crates/consensus/benches/potb.rs reports consensus-potb.

Each leg uploads a benchmarks-<os> artifact holding one astrolune.benchmark-report/1 document, retained for 90 days. Unlike the astrolune.suite-result/1 record, which excludes timings so two runs of one revision stay byte-identical, this record carries its measurements and is therefore reproducible by neither construction nor intent. It states both facts in a comparability field so a consumer cannot mistake it for a bound. Sampling is shortened to three rounds at a 300 us target round, since more sampling on a shared runner buys precision that the environment immediately spends.

Benchmarks are also compiled and linted on every run independently of that job: the cargo clippy --locked --workspace --all-targets --all-features -- -D warnings gate includes benches/, so a benchmark cannot rot without failing CI. The test gate does not run them, because a [[bench]] target defaults to test = false.

Measured figures

Host: AMD Ryzen 7 7800X3D, 8 physical and 8 logical cores, 31.1 GiB RAM, Windows 11 10.0.26200, rustc 1.99.0 (b940084d7 2026-09-28), release profile, 5 rounds at a 500 us target round. The host had other processes running; these are not quiet-machine figures.

Hash and signature primitives

OperationMedian
blake2s 32 B95 ns
blake2s 256 B304 ns
blake2s 1 KiB1 138 ns
blake2s 16 KiB18 218 ns
ed25519_public_key from secret13 885 ns
ed25519_sign28 756 ns
ed25519_verify strict33 375 ns

Hashing is linear in input length above the 32-byte block, as expected for a single-pass construction. Strict verification costs more than signing, which is the usual asymmetry for the strict checks ed25519_verify applies.

Bounded parallel verification

crates/crypto/src/batch.rs verifies independent strict Ed25519 requests across a bounded worker pool. MIN_PARALLEL_REQUESTS is 8, the documented threshold below which the serial path is taken regardless of the requested worker count. Medians in nanoseconds:

Requestsserialw1w2w4w8w16
8375 950276 450236 100250 250286 500287 700
642 286 3002 234 6001 229 400818 500532 800673 500
51218 250 00018 332 90010 730 8005 248 0003 190 5002 972 200

This is the first measurement of a path that document 9 previously qualified only functionally. At 512 requests the measured speedup from one worker to eight is 5.7x on eight logical cores. At 64 requests scaling stops at eight workers and sixteen is slower, which is the oversubscription the worker bound exists to limit. At 8 requests, the smallest count eligible for more than one worker, the spread between worker counts is within the run-to-run noise of this host, so these figures do not resolve whether parallelism helps at that size.

VRF

OperationMedian
VrfInput::seed242 ns
prove_vrf209 850 ns
verify_vrf119 475 ns
verify_ecvrf raw118 787 ns
VrfOutput::encode6 117 ns
VrfOutput::decode6 026 ns
verify_vrf rejecting a tampered proof9 876 ns

Two results are worth recording. VRF verification costs roughly 3.6x a strict Ed25519 verification on this host, which is the per-evaluation charge the rotating committee path pays. Rejecting a tampered proof costs about one twelfth of accepting a valid one, because decode_proof enforces unique wire bytes by re-encoding before any group operation runs; cheap rejection of malformed input is the desirable direction for a path exposed to hostile peers.

prove_vrf verifies its own proof before returning, so its figure includes one verification and is not a lower bound on proving alone. VrfOutput::encode costs about as much as a decode because it also calls decode_proof, so encoding an already-proven output is not the memory copy its signature suggests.

State commitments and proofs

Medians at 16, 256 and 4 096 equal-sized accounts. Recomputing the state root costs 8 585 ns, 136 125 ns and 2 332 500 ns. Subtracting an overlay-copy-only baseline isolates the hashing at 475, 472 and 475 ns per entry, so per-entry cost is flat and the root is linear in the account count. A snapshot handle is 30 ns at every size, which is the structural sharing document 10 describes rather than a copy.

Two asymmetries are worth recording.

Proof generation is linear in accounts while verification is logarithmic. At 4 096 accounts a membership proof costs 1 850 400 ns to generate and 2 523 ns to verify, a factor of 733. Absence proofs are about twice membership on both sides, as the two-witness design implies. Document 10 already states that proof generation recomputes a membership path for each neighbour and that incremental indexing remains future work; these figures quantify what that costs a node serving proofs today.

A prepared transition costs the same whether it changes 1 account or 256: 2.314, 2.531, 2.331 and 2.474 ms for 1, 2, 16 and 256 changes at 4 096 accounts, all equal within noise to recomputing the root over zero changes. The whole-map copy and whole-tree rehash dominate, so block cost tracks the total account count rather than the block size. Document 10 records the limitation; the measurement shows the change count is not the variable that matters.

Durable storage

Durability dominates publication. Committing a block through the append-only log costs about 2.2 ms regardless of delta width, while the same work against InMemoryStorage costs 3.6 to 4.8 us for zero or one change, roughly a factor of 450. Widening the delta to 64 changes moves the log figure by about 10 percent, so the two sync_all calls, not the payload, set the cost.

read_finalized is flat near 140 us across 16, 64 and 256 blocks, which is the offset-direct read the height index exists to give. transaction_location is 20 to 32 ns over the same sweep, the shallow growth of an in-memory map.

Recovery is the one measured result that contradicted a documented bound. With a constant key set, reopening a 16, 64 and 256 block log costs 1.50, 1.77 and 3.67 ms, consistent with linear replay. With one new key per block the same sweep costs 1.36, 2.88 and 18.56 ms, so the work grows close to quadratically because replay re-executes every transition and each recomputes a root over the whole reconstructed state. Document 22 previously recorded recovery as linear replay without qualification and now states the blocks-times-accounts growth.

read_state_at measures faster at greater depth, 33 900 ns at depth 1 against 17 209 ns at depth 64. That is not a faster undo walk: reconstruction ends in one full root recomputation, and in a fixture that adds a key per block a deeper read reconstructs a smaller tree. The sweep therefore measures reconstructed tree size more than distance walked, and should not be read as a depth cost.

Codec and transaction admission

Three observations are recorded as measured facts rather than acted on here, because each one changes production code on the admission path and belongs in a change of its own.

SignedValidator::validate re-derives the sender address from the account's already-registered public key on every call. address_from_public_key measures 178 ns standalone, and the gap between the rejection that stops before the derivation and the one immediately after it is 174 ns, at 262 against 436 ns. The step is 0.5 percent of a signature-verified acceptance and therefore invisible there, but it is the largest single component of every rejection that gets past the sender lookup, and the registered key cannot change between calls.

Resources and AccountState encode field by field into a zero-capacity vector and pay a reallocation per field, while BlockHeader and ExecutionReceipt extend once from a stack canonical_bytes() array. The result is a 32-byte Resources encode at 100 ns against a 200-byte BlockHeader at 35 ns.

Rejection is cheap on the transaction codec and is not cheap on the contract codec. A trailing byte or truncated signature is rejected in 56 to 59 ns against 599 ns to accept the same fixture, because read_exact borrows and finish runs before anything owned is allocated. The contract payload copies each call key as it reads, so a key ordering violation at the final pair costs 9 012 ns against 8 642 ns to accept — a late rejection costs more than acceptance there.

Separately, estimate_encoded_len is 9 to 220 times cheaper than encoding to measure a length, at 1 against 224 ns with no access list and 183 against 1 617 ns at 256 entries, which is what lets shape validation reject on size before allocating. Decode is slower than encode for the repeated-field types, reversing the primitive relationship: at 256 access-list entries decode is 8 643 ns against 1 483 ns to encode, because encode grows one buffer while decode performs an owned allocation per item.

Parallel execution and the runtime

The full scaling result and the per-optimization findings are recorded in document 29, which previously stated that local execution performance had not been benchmarked. In summary: the best observed speedup over the serial path is 2.56x at 128 independent transfers and four workers, scaling saturates at four workers on an eight-core host, and below about eight transactions per worker the parallel path loses. The per-attempt worker pool costs roughly 50 us per thread against a 41 us payment, so one worker thread costs about one transaction.

Two named optimizations show no measurable wall-clock benefit: the execution-parent read cache, which has nothing to recover against an in-memory parent, and bounded declared-key prefetch, whose InMemoryState::prefetch is a no-op. Both remain justified on the read-count grounds their tests establish, and both are recorded here as unmeasured in wall clock rather than as wins.

In the runtime, engine construction is 51 ns and identical across all six profiles, so building a WasmRuntime per transaction is not a cost. A contract call's floor is about 8.9 us, of which roughly 6.8 us is compile and validate, and execute_call recompiles the module on every call, so that cost is paid per transaction in addition to the separate validate in execute_contract. Fuel exhaustion is linear in the budget at 22.6 us, 143 us and 1.53 ms for 10 000, 100 000 and 1 000 000 fuel, which implies roughly 15 ms of wall clock for a call that legally burns 10 000 000 fuel.

The disqualified lazy profiles are visible in wall clock as well as in fuel. Lazy validates a 256-step body in 13 860 ns against the reference profile's 41 343 ns, then executes it in 46 056 ns against 44 906 ns. The work moves to the executing call rather than disappearing, which is the wall-clock counterpart of the charged-fuel divergence document 29 records, and it is why both modes are disqualified.

Consensus verification

Finality certificate verification is sublinear from 4 to 64 seats and linear above it. Per-signature cost is 35 050 ns at 4 seats, which equals a single vote/verify at 34 850 ns because 4 is below MIN_PARALLEL_REQUESTS, then falls to 13 520 ns at 64 and 12 711 ns at 128 seats as the bounded worker pool engages. A 2x size increase from 64 to 128 costs 1.88x the time, so the path is linear with a slope near 12.7 us per signature once the pool is saturated. Nothing superlinear appears.

The non-cryptographic half of verification is negligible: the identity lookup, checked weighted sum and threshold comparison total 46 to 3 242 ns across the same sweep, which is 0.03 to 0.20 percent of verification. Strict Ed25519 is about 99.8 percent of the cost.

AdmissionCertificate::verify is now linear in roster size. Over a 7.75x roster increase from 4 to 31 the time grows 4.19x, where the O(roster^2) behaviour a prior review removed would have grown it about 60x, and per-approval cost falls with roster size for the same batching reason as above. This is the regression evidence that fix deserved.

One residual is visible in the data rather than inferred, and is recorded here rather than changed: AdmissionCertificate::verify builds the incumbent AuthenticatedCommittee twice, once inside the request check and once again afterwards. Rebuilding a 31-key context measures 227 825 ns, so roughly 43 percent of the 1 063 400 ns certificate verification at the maximum roster is spent registering the same keys twice. That is linear rather than quadratic, so it is not a return of the old defect, but it is an available saving if the request check accepted a pre-resolved context.

The bounded verified VRF transition cache is the clearest optimization in the measured set, and it contrasts with the execution-parent cache above. A reuse guard costs 24 to 185 ns across rosters of 4 to 31 and avoids a cold transition of 1.08 to 8.85 ms, a reduction of 4.3 to 4.8 times ten to the fourth. Only the decision is timed; what a caller does with a retained committee after a hit lives in crates/node and is not measured here.

Two smaller results are worth recording. HistoricalEvidence::verify is essentially independent of history length at 101 875 to 105 087 ns over a 68x range of recorded heights, because it is dominated by one context rebuild and two signature verifications. CommitteeHistory::prove is linear in recorded heights at 531 to 563 ns per entry while proof verification is logarithmic at about 400 ns per path node, the same generation-versus-verification asymmetry the state proofs show. VRF batch framing is not a byte copy either: encode and decode both cost about 12.7 us per contribution because the proof point is parsed in both directions.

Relation to the required benchmark suite

Section 7.6 lists what must be measured before a public testnet. This harness addresses part of that list on one local machine only. Signature verification by batch size and worker count is measured above. Sequential and parallel execution, committee sizes, and snapshot read scaling are measured by the per-crate benchmarks in their respective crates.

The following section 7.6 requirements are not addressed and remain open: proposal-to-prevote, prevote-to-precommit and certificate latency at p50/p95/p99; compact-block reconstruction success and fallback bandwidth; AOT and JIT warm-up, native-cache hit rate and interpreter parity, none of which exist to measure; behavior under packet loss, partitions, slow disks, corrupt frames and worker failure; and the confidence intervals and multi-machine topology a calibration report must state. Those are distributed measurements on provisioned hardware, not micro-benchmarks, and document 52 keeps them as release activities.

Source: docs/56-performance-measurement.md
Independent MDX copy · source SHA-256: 0337690f609626dbab58ac2f8b6488579c38e2b880de5fca9c2cf125f1d75618