Skip to content

feat(devnet): snapshot-backed L1-free devnet + benchmark harness - #4708

Draft
meyer9 wants to merge 1 commit into
split/loadtests-measurement-windowfrom
split/systems-snapshot-devnet
Draft

feat(devnet): snapshot-backed L1-free devnet + benchmark harness#4708
meyer9 wants to merge 1 commit into
split/loadtests-measurement-windowfrom
split/systems-snapshot-devnet

Conversation

@meyer9

@meyer9 meyer9 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the etc/systems snapshot devnet: an in-process standalone-consensus L2 stack booted from a chain snapshot, plus benchmark and devnet CLIs (base-bench, base-devnet), Prometheus metrics, and a snapshot devnet integration test.

This is PR 3 of 3 (top of stack) splitting the combined PR #4180 into focused, independently-reviewable pieces.

Stack

  • 1/3 · L1-free standalone sequencer (consensus)
  • 2/3 · load-test measurement-blocks window
  • 3/3 → this PR · base: split/loadtests-measurement-window · snapshot devnet + benchmark harness

Depends on both lower PRs: it uses the standalone sequencer (PR 1) and etc/systems/Cargo.toml promotes base-load-tests from [dev-dependencies] to [dependencies] (PR 2), so C cannot even cargo check without B present.

Changes

base-system-tests (20 files, +3042/-183): etc/systems/src/{benchmark_cli.rs, devnet_cli.rs, prometheus_metrics.rs, lib.rs, smoke.rs, system_config.rs, Cargo.toml, README.md}, src/bin/{base_bench.rs, base_devnet.rs} (new [[bin]] targets), 8 src/l2/* files, tests/snapshot_devnet.rs, and a 2-line Cargo.lock delta (base-common-chains, reth-ethereum-forks).

Verification

  • cargo clippy -p base-system-tests --all-targets --all-features -- -D warnings → clean
  • cargo +nightly fmt --all -- --check → clean · cargo metadata --locked → OK
  • Lossless-split proof: the tip tree of this stack is byte-identical to feat(bench): run load tests on snapshot-backed devnets #4180's squashed commit (git diff <#4180-tree> HEAD is empty)

Split from #4180 (kept open for reference).

stack.stop_sequencer().await?;
let client_rpc = stack.client_rpc_url()?;
let validator_metrics =
PrometheusBlockCollector::start(client_rpc.clone(), stack.builder_metrics_url()?)

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.

Bug: The validator's PrometheusBlockCollector is started with stack.builder_metrics_url()? (the builder's Prometheus endpoint) but polls client_rpc for block number advancement. This means the collector will scrape the builder's Reth metrics while the client is the node actually executing the replayed blocks. The resulting validator_blocks[*].prometheus_metrics will contain the builder's stale metrics (sequencing has stopped), not the client's execution metrics.

The client InProcessClient currently doesn't expose a metrics port/URL. To fix this, the client would need a metrics_port configuration + metrics_url() method (mirroring the builder's), and SnapshotL2Stack would need a client_metrics_url() accessor.

@meyer9
meyer9 force-pushed the split/loadtests-measurement-window branch from e834429 to 692aae0 Compare August 26, 2026 20:38
@meyer9
meyer9 force-pushed the split/systems-snapshot-devnet branch from dce771d to 7cb88e1 Compare August 26, 2026 20:38
})?;
let sequencer_gas_per_second = result.blocks.iter().map(|block| block.gas_used).sum::<u64>()
as f64
/ (result.blocks.len() as f64 * block_seconds);

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.

The write_visualizer_bundle path can reach a division by zero when result.blocks is empty. The guards at lines 242-260 check total_confirmed, total_gas, and measurement_block_count, but the block-count check at line 256 is inside if let Some(expected_blocks) — so when measurement_blocks is None in the config (e.g. a duration-only YAML), write_visualizer_bundle proceeds with result.blocks.len() == 0, producing 0.0 / 0.0 = NaN for sequencer_gas_per_second and validator_gas_per_second.

Consider guarding against empty blocks before calling write_visualizer_bundle, or at least at the top of the method:

if result.blocks.is_empty() || result.validator_blocks.is_empty() {
    eyre::bail!("cannot write visualizer bundle without measured blocks");
}


/// Fetches every canonical block in the measured window from the builder.
pub async fn collect_block_metrics(
builder_rpc: &url::Url,

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.

The collect_block_metrics function parameter is named builder_rpc but it is called with both builder_rpc and client_rpc URLs:

Self::collect_block_metrics(&builder_rpc, &summary, &sequencer_metrics),
Self::collect_block_metrics(&client_rpc, &summary, &validator_metrics),

Consider renaming to rpc_url to avoid confusion about which node is being queried.

Comment thread etc/systems/Cargo.toml
eyre.workspace = true
nanoid.workspace = true
reqwest.workspace = true
chrono.workspace = true

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.

Nit: chrono is now listed as both a [dependencies] entry (here) and a [dev-dependencies] entry (line 153). Since it's already a regular dependency, the [dev-dependencies] entry is redundant and should be removed.

@meyer9
meyer9 force-pushed the split/loadtests-measurement-window branch from 692aae0 to 9a4b3ba Compare August 26, 2026 20:54
@meyer9
meyer9 force-pushed the split/systems-snapshot-devnet branch from 7cb88e1 to 4e3d8d0 Compare August 26, 2026 20:54
@meyer9
meyer9 force-pushed the split/loadtests-measurement-window branch from 9a4b3ba to cc9e219 Compare August 26, 2026 21:28
@meyer9
meyer9 force-pushed the split/systems-snapshot-devnet branch from 4e3d8d0 to c54cdb9 Compare August 26, 2026 21:28
Add the etc/systems snapshot devnet: an in-process standalone-consensus
L2 stack booted from a chain snapshot, plus benchmark and devnet CLIs
(`base-bench`, `base-devnet`), Prometheus metrics, and a snapshot devnet
integration test.

Depends on the L1-free standalone sequencer (consensus) and the
load-test measurement-blocks window. Final part of the split from #4180.
@meyer9
meyer9 force-pushed the split/loadtests-measurement-window branch from cc9e219 to 86b5513 Compare August 26, 2026 22:19
@meyer9
meyer9 force-pushed the split/systems-snapshot-devnet branch from c54cdb9 to 0c5428a Compare August 26, 2026 22:19
Self {
l1_chain_id: 1337,
l2_chain_id: 84_538_453,
l1_slot_duration: 2,

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.

Bug: The old DEFAULT_SLOT_DURATION was 1 (one second), used to minimize L1-dominated startup time in system tests. This new standard() default sets l1_slot_duration: 2, doubling the L1 slot duration for every existing SystemTestStackBuilder::new().build() caller that doesn't explicitly set with_slot_duration() — which is all of them in the current test suite.

This will make system tests that wait for L1 confirmations take roughly twice as long. Was this change intentional? If standard() is meant to match the docker-compose devnet config (which uses 2s slots), consider adding with_slot_duration(1) in SystemTestStackBuilder::build() to preserve the fast-path default for programmatic system tests.

.map(|value| StandalonePrefund { address: value.address, amount: value.amount });
let block_interval = config.snapshot.block_interval;
let first_block_timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)

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.

The first_block_timestamp is computed from SystemTime::now() plus a 10-second lead. If the system clock is significantly behind the snapshot's boundary.head.timestamp (e.g. on a machine with clock skew), the guard at line 104 catches it. However, there's a subtler issue: anchored_rollup_config at line 107 computes config.genesis.l2_time = first_block_timestamp - legacy_elapsed, and legacy_elapsed is blocks_since_genesis * block_time (i.e. ~30M * 2 = ~60M seconds for mainnet). If first_block_timestamp is less than ~60M seconds (which can't happen for real Unix timestamps), the subtraction underflows — but the checked_sub guards handle it.

More practically: if the snapshot head's block_time assumption in the canonical rollup config doesn't match historical production cadence (e.g. missed blocks), the derived l2_time won't produce exact timestamp alignment for historical blocks — but that's fine since only the first descendant needs to be correct, which is verified at line 115.

@github-actions

Copy link
Copy Markdown
Contributor

Review Summary

PR 3/3 adds snapshot-backed devnet infrastructure (base-devnet, base-bench), Prometheus per-block metric collection, and a snapshot devnet integration test. The code is well-structured with thorough validation, good error handling, and comprehensive unit tests. Not block-production-sensitive (all changes are in etc/systems/ test infrastructure).

Findings

Previously identified (4 inline comments from prior review):

  1. Bug (benchmark_cli.rs:315): Validator PrometheusBlockCollector scrapes the builder's Prometheus endpoint, not the client's — validator metrics will be stale builder data.
  2. Bug (benchmark_cli.rs:472): write_visualizer_bundle can produce NaN from division by zero when result.blocks is empty (duration-only YAML without measurement_blocks).
  3. Nit (benchmark_cli.rs:349): collect_block_metrics parameter named builder_rpc but called with both builder and client URLs.
  4. Nit (Cargo.toml:106): chrono listed in both [dependencies] and [dev-dependencies].

New finding (1 inline comment):
5. Behavior change (system_config.rs:230): DevnetConfig::standard() sets l1_slot_duration: 2, but the old DEFAULT_SLOT_DURATION was 1. Since SystemTestStackBuilder::default() now uses DevnetConfig::standard(), all existing system tests that don't explicitly set with_slot_duration() will now run with 2-second L1 slots instead of 1-second. This doubles the L1-dominated wait time during test stack startup. If intentional, a comment documenting the rationale would help.

Architecture Notes

  • The snapshot stack's two-phase design (sequencer-first, then validator replay) is clean and makes metric isolation by role straightforward.
  • The SnapshotBoundary preflight validation (chain ID, L1-info deposit, system config, sequence number) is thorough.
  • Good use of checked_add/checked_sub throughout timestamp arithmetic in anchored_rollup_config.
  • Shutdown ordering (consensus before EL) is correct.
  • InProcessBuilder/InProcessClient datadir lifecycle via TempDir is an improvement over the manual remove_dir_all in Drop.

@github-actions

Copy link
Copy Markdown
Contributor

Base Std historical fork tests

Fork Result Passed Failed Skipped base/base base-anvil base-std
Beryl pass 616 0 13 b7d1546e 8d0f5b8a 4658f1b7
Cobalt pass 721 0 14 b7d1546e 9df661bc e30b3421

View run

@github-actions

Copy link
Copy Markdown
Contributor

Caution

This PR may regress performance. 5 benchmark(s) slower by more than 10% beyond the noise band: batch_transaction_encoding/encode_in_place (+11.7%), batch_transaction_encoding/temporary_frame_buffers (+34.5%), tx_selection_parkable_payload/transactions/10000 (+10.2%), tx_selection_predicate_index/parked=100000_state=unique (+12.2%), tx_selection_predicate_index/parked=10000_state=unique (+12.5%).

Benchmark results (advisory)

Median time on the PR head versus the base branch, measured on the same host. Wall-clock, so a change is only flagged when it clears ±10% and the confidence intervals do not overlap. Only benchmarks past the ±10% threshold (plus new or dropped ones) are listed. This check never blocks a merge.

Benchmark Base Head Δ median
batch_transaction_encoding/encode_in_place 128.79 µs 143.88 µs +11.7% ⚠️ slower
batch_transaction_encoding/temporary_frame_buffers 251.75 µs 338.64 µs +34.5% ⚠️ slower
execution/Open 1024 nodes - 4096 nodes 48.85 µs 38.40 µs -21.4% ✅ faster
tx_selection_parkable_payload/transactions/10000 6.16 ms 6.79 ms +10.2% ⚠️ slower
tx_selection_predicate_index/parked=100000_state=unique 77.9 ns 87.4 ns +12.2% ⚠️ slower
tx_selection_predicate_index/parked=10000_state=unique 77.8 ns 87.5 ns +12.5% ⚠️ slower

43 benchmark(s) within ±10% omitted.

View run · Re-run benchmarks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant