22//!
33//! # What this measures
44//!
5- //! The full `get_header` pipeline end-to-end: HTTP fan-out to N in-process mock
6- //! relays, response parsing, header validation, signature verification, and bid
7- //! selection. This is wall-clock timing — useful for local development feedback
8- //! and catching latency regressions across relay counts.
5+ //! The per-request `get_header` pipeline: HTTP request to a single in-process
6+ //! mock relay, response parsing, header validation, signature verification, and
7+ //! bid selection. This is wall-clock timing — useful for local development
8+ //! feedback and catching latency regressions across validation configurations.
9+ //!
10+ //! A single relay is used because relay fan-out uses `join_all` (not
11+ //! `tokio::spawn`), so all futures are polled on the same task. HTTP requests
12+ //! are truly concurrent but CPU-bound validation work (deserialization, BLS sig
13+ //! verification) is interleaved on one thread. Validation cost therefore scales
14+ //! roughly linearly with relay count — one relay is sufficient to measure the
15+ //! per-relay cost, and N relays can be estimated as ~N× that baseline.
16+ //!
17+ //! # Benchmark dimensions
18+ //!
19+ //! **Validation mode** (`HeaderValidationMode`):
20+ //! - `None` — light path: skips full deserialization and sig verification,
21+ //! extracts only fork + bid value, forwards raw bytes. Fastest option,
22+ //! requires complete trust in relays.
23+ //! - `Standard` — full deserialization, header validation (block hash, parent
24+ //! hash, timestamp, fork), BLS signature verification. Default mode.
25+ //! - `Extra` — Standard + parent block validation via RPC. NOTE: without a live
26+ //! RPC endpoint the parent block fetch returns None and `extra_validation` is
27+ //! skipped, so Extra degrades to Standard in this bench. It is included to
28+ //! catch any overhead from the mode flag itself and Accept header
29+ //! differences. A meaningful Extra benchmark would require a mock RPC server.
30+ //!
31+ //! **Encoding type** (`EncodingType`):
32+ //! - JSON only — validator requests `application/json`
33+ //! - SSZ only — validator requests `application/octet-stream`
34+ //! - Both — validator accepts either (CB picks the best available)
35+ //!
36+ //! Note: in Standard and Extra modes, `get_header` always requests both
37+ //! encodings from relays regardless of what the validator asked for, because it
38+ //! needs to unpack the body. The encoding dimension therefore only affects the
39+ //! None (light) path where the response is forwarded raw and must match what
40+ //! the validator accepts.
41+ //!
42+ //! Total: 3 modes × 3 encodings = 9 benchmark cases.
943//!
1044//! Criterion runs each benchmark hundreds of times, applies statistical
1145//! analysis, and reports mean ± standard deviation. Results are saved to
1751//! # Run all benchmarks
1852//! cargo bench --package cb-bench-micro
1953//!
20- //! # Run a specific variant by filter
21- //! cargo bench --package cb-bench-micro -- 3_relays
54+ //! # Run only the light (None) mode benchmarks
55+ //! cargo bench --package cb-bench-micro -- none
56+ //!
57+ //! # Compare modes for SSZ encoding
58+ //! cargo bench --package cb-bench-micro -- ssz
2259//!
2360//! # Save a named baseline to compare against later
2461//! cargo bench --package cb-bench-micro -- --save-baseline main
3168//!
3269//! - PBS HTTP server overhead (we call `get_header()` directly, bypassing axum
3370//! routing)
34- //! - Mock relay startup time (servers are started once in setup, before timing
71+ //! - Mock relay startup time (server is started once in setup, before timing
3572//! begins)
3673//! - `HeaderMap` allocation (created once in setup, cloned cheaply per
3774//! iteration)
75+ //! - Extra mode's RPC fetch (no live RPC endpoint in bench environment)
3876
3977use std:: { collections:: HashSet , path:: PathBuf , sync:: Arc } ;
4078
4179use alloy:: primitives:: B256 ;
4280use axum:: http:: HeaderMap ;
43- use cb_common:: { pbs:: GetHeaderParams , signer:: random_secret, types:: Chain , utils:: EncodingType } ;
81+ use cb_common:: {
82+ config:: HeaderValidationMode , pbs:: GetHeaderParams , signer:: random_secret, types:: Chain ,
83+ utils:: EncodingType ,
84+ } ;
4485use cb_pbs:: { PbsState , get_header} ;
4586use cb_tests:: {
4687 mock_relay:: { MockRelayState , start_mock_relay_service_with_listener} ,
@@ -49,101 +90,111 @@ use cb_tests::{
4990use criterion:: { Criterion , black_box, criterion_group, criterion_main} ;
5091
5192const CHAIN : Chain = Chain :: Hoodi ;
52- const MAX_RELAYS : usize = 5 ;
53- const RELAY_COUNTS : [ usize ; 3 ] = [ 1 , 3 , MAX_RELAYS ] ;
5493
55- /// Benchmarks `get_header` across three relay-count variants.
94+ const MODES : [ ( HeaderValidationMode , & str ) ; 3 ] = [
95+ ( HeaderValidationMode :: None , "none" ) ,
96+ ( HeaderValidationMode :: Standard , "standard" ) ,
97+ // Extra degrades to Standard without a live RPC endpoint — included to
98+ // measure any overhead from the mode flag and Accept header differences.
99+ // See module doc comment for details.
100+ ( HeaderValidationMode :: Extra , "extra" ) ,
101+ ] ;
102+
103+ const ENCODINGS : [ ( & str , & [ EncodingType ] ) ; 3 ] = [
104+ ( "json" , & [ EncodingType :: Json ] ) ,
105+ ( "ssz" , & [ EncodingType :: Ssz ] ) ,
106+ ( "both" , & [ EncodingType :: Json , EncodingType :: Ssz ] ) ,
107+ ] ;
108+
109+ /// Build a `PbsState` for a specific validation mode with a single relay.
110+ ///
111+ /// Port 0 is used because we call `get_header()` directly — no PBS server is
112+ /// started, so the port is never bound. The actual relay endpoint is carried
113+ /// inside the `RelayClient` object.
114+ fn make_pbs_state ( mode : HeaderValidationMode , relay : cb_common:: pbs:: RelayClient ) -> PbsState {
115+ let mut pbs_config = get_pbs_config ( 0 ) ;
116+ pbs_config. header_validation_mode = mode;
117+ let config = to_pbs_config ( CHAIN , pbs_config, vec ! [ relay] ) ;
118+ PbsState :: new ( config, PathBuf :: new ( ) )
119+ }
120+
121+ /// Benchmarks `get_header` across all validation modes and encoding types.
56122///
57123/// # Setup (runs once, not measured)
58124///
59- /// All MAX_RELAYS mock relays are started up-front and shared across variants.
60- /// Each variant gets its own `PbsState` pointing to a different relay subset.
61- /// The mock relays are in-process axum servers on localhost.
125+ /// A single mock relay is started up-front and shared across all variants.
126+ /// Each variant gets its own `PbsState` configured with the appropriate
127+ /// `HeaderValidationMode`. The mock relay is an in-process axum server on
128+ /// localhost.
62129///
63130/// # Per-iteration (measured)
64131///
65132/// Each call to `b.iter(|| ...)` runs `get_header()` once:
66- /// - Fans out HTTP requests to N mock relays concurrently
67- /// - Parses and validates each relay response (header data + BLS signature )
68- /// - Selects the highest-value bid
133+ /// - Sends an HTTP request to the mock relay
134+ /// - Parses and validates the relay response (or skips in None mode )
135+ /// - Returns the bid
69136///
70137/// `black_box(...)` prevents the compiler from optimizing away inputs or the
71- /// return value. Without it, the optimizer could see that the result is unused
72- /// and eliminate the call entirely, producing a meaningless zero measurement.
138+ /// return value.
139+ ///
140+ /// # Criterion grouping
141+ ///
142+ /// Groups are structured as `get_header/{encoding}` with the validation mode
143+ /// as the bench function name. Each Criterion chart directly compares None vs
144+ /// Standard vs Extra for the same encoding — the comparison that matters most
145+ /// for understanding the latency cost of validation.
73146fn bench_get_header ( c : & mut Criterion ) {
74147 let rt = tokio:: runtime:: Runtime :: new ( ) . expect ( "tokio runtime" ) ;
75148
76- // Start all mock relays once and build one PbsState per relay-count variant.
77- // All relays share the same MockRelayState (and therefore the same signing
78- // key). Each relay gets its own OS-assigned port via get_free_listener() so
79- // there is no TOCTOU race and no hardcoded port reservations.
80- let ( states, params) = rt. block_on ( async {
149+ // Start a single mock relay. It gets its own OS-assigned port via
150+ // get_free_listener() so there is no TOCTOU race.
151+ let ( relay_client, params) = rt. block_on ( async {
81152 let signer = random_secret ( ) ;
82153 let pubkey = signer. public_key ( ) ;
83154 let mock_state = Arc :: new ( MockRelayState :: new ( CHAIN , signer) ) ;
84155
85- let mut relay_clients = Vec :: with_capacity ( MAX_RELAYS ) ;
86- for _ in 0 ..MAX_RELAYS {
87- let listener = get_free_listener ( ) . await ;
88- let port = listener. local_addr ( ) . unwrap ( ) . port ( ) ;
89- tokio:: spawn ( start_mock_relay_service_with_listener ( mock_state. clone ( ) , listener) ) ;
90- relay_clients. push ( generate_mock_relay ( port, pubkey. clone ( ) ) . expect ( "relay client" ) ) ;
91- }
156+ let listener = get_free_listener ( ) . await ;
157+ let port = listener. local_addr ( ) . unwrap ( ) . port ( ) ;
158+ tokio:: spawn ( start_mock_relay_service_with_listener ( mock_state, listener) ) ;
159+ let relay_client = generate_mock_relay ( port, pubkey. clone ( ) ) . expect ( "relay client" ) ;
92160
93- // Give all servers time to start accepting before benchmarking begins.
161+ // Give the server time to start accepting before benchmarking begins.
94162 tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 200 ) ) . await ;
95163
96164 let params = GetHeaderParams { slot : 0 , parent_hash : B256 :: ZERO , pubkey } ;
97-
98- // Port 0 here is the port the PBS service itself would bind to for incoming
99- // validator requests. We call get_header() as a function directly, so no
100- // PBS server is started and this port is never used. The actual relay
101- // endpoints are carried inside the RelayClient objects.
102- let states: Vec < PbsState > = RELAY_COUNTS
103- . iter ( )
104- . map ( |& n| {
105- let config = to_pbs_config ( CHAIN , get_pbs_config ( 0 ) , relay_clients[ ..n] . to_vec ( ) ) ;
106- PbsState :: new ( config, PathBuf :: new ( ) )
107- } )
108- . collect ( ) ;
109-
110- ( states, params)
165+ ( relay_client, params)
111166 } ) ;
112167
113168 // Empty HeaderMap matches what the PBS route handler receives for requests
114169 // without custom headers. Created once here to avoid measuring its
115170 // allocation per iteration.
116171 let headers = HeaderMap :: new ( ) ;
117172
118- // A BenchmarkGroup groups related functions so Criterion produces a single
119- // comparison table and chart. All variants share the name "get_header/".
120- let mut group = c. benchmark_group ( "get_header" ) ;
121-
122- for ( i, relay_count) in RELAY_COUNTS . iter ( ) . enumerate ( ) {
123- let state = states[ i] . clone ( ) ;
124- let params = params. clone ( ) ;
125- let headers = headers. clone ( ) ;
126-
127- // bench_function registers one timing function. The closure receives a
128- // `Bencher` — calling `b.iter(|| ...)` is the measured hot loop.
129- // Everything outside `b.iter` is setup and not timed.
130- group. bench_function ( format ! ( "{relay_count}_relays" ) , |b| {
131- b. iter ( || {
132- // block_on drives the async future to completion on the shared
133- // runtime. get_header takes owned args, so we clone cheap types
134- // (Arc-backed state, stack-sized params) on each iteration.
135- rt. block_on ( get_header (
136- black_box ( params. clone ( ) ) ,
137- black_box ( headers. clone ( ) ) ,
138- black_box ( state. clone ( ) ) ,
139- black_box ( HashSet :: from ( [ EncodingType :: Json , EncodingType :: Ssz ] ) ) ,
140- ) )
141- . expect ( "get_header failed" )
142- } )
143- } ) ;
144- }
173+ for & ( encoding_name, encoding_types) in & ENCODINGS {
174+ let encodings: HashSet < EncodingType > = encoding_types. iter ( ) . copied ( ) . collect ( ) ;
175+ let mut group = c. benchmark_group ( format ! ( "get_header/{encoding_name}" ) ) ;
176+
177+ for & ( mode, mode_name) in & MODES {
178+ let state = make_pbs_state ( mode, relay_client. clone ( ) ) ;
179+ let params = params. clone ( ) ;
180+ let headers = headers. clone ( ) ;
181+ let encodings = encodings. clone ( ) ;
182+
183+ group. bench_function ( mode_name, |b| {
184+ b. iter ( || {
185+ rt. block_on ( get_header (
186+ black_box ( params. clone ( ) ) ,
187+ black_box ( headers. clone ( ) ) ,
188+ black_box ( state. clone ( ) ) ,
189+ black_box ( encodings. clone ( ) ) ,
190+ ) )
191+ . expect ( "get_header failed" )
192+ } )
193+ } ) ;
194+ }
145195
146- group. finish ( ) ;
196+ group. finish ( ) ;
197+ }
147198}
148199
149200// criterion_group! registers bench_get_header as a benchmark group named
0 commit comments