Skip to main content

reth_bench/bench/
output.rs

1//! Contains various benchmark output formats, either for logging or for
2//! serialization to / from files.
3
4use alloy_primitives::B256;
5use csv::Writer;
6use eyre::OptionExt;
7use reth_primitives_traits::constants::GIGAGAS;
8use serde::{ser::SerializeStruct, Deserialize, Serialize};
9use std::{fs, path::Path, time::Duration};
10use tracing::info;
11
12/// This is the suffix for gas output csv files.
13pub(crate) const GAS_OUTPUT_SUFFIX: &str = "total_gas.csv";
14
15/// This is the suffix for combined output csv files.
16pub(crate) const COMBINED_OUTPUT_SUFFIX: &str = "combined_latency.csv";
17
18/// This is the suffix for new payload output csv files.
19pub(crate) const NEW_PAYLOAD_OUTPUT_SUFFIX: &str = "new_payload_latency.csv";
20
21/// Serialized format for gas ramp payloads on disk.
22#[derive(Debug, Serialize, Deserialize)]
23pub(crate) struct GasRampPayloadFile {
24    /// Engine API version (1-5).
25    pub(crate) version: u8,
26    /// The block hash for FCU.
27    pub(crate) block_hash: B256,
28    /// The params to pass to newPayload.
29    pub(crate) params: serde_json::Value,
30}
31
32/// This represents the results of a single `newPayload` call in the benchmark, containing the gas
33/// used and the `newPayload` latency.
34#[derive(Debug)]
35pub(crate) struct NewPayloadResult {
36    /// The gas used in the `newPayload` call.
37    pub(crate) gas_used: u64,
38    /// The latency of the `newPayload` call.
39    pub(crate) latency: Duration,
40}
41
42impl NewPayloadResult {
43    /// Returns the gas per second processed in the `newPayload` call.
44    pub(crate) fn gas_per_second(&self) -> f64 {
45        self.gas_used as f64 / self.latency.as_secs_f64()
46    }
47}
48
49impl std::fmt::Display for NewPayloadResult {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        write!(
52            f,
53            "New payload processed at {:.4} Ggas/s, used {} total gas. Latency: {:?}",
54            self.gas_per_second() / GIGAGAS as f64,
55            self.gas_used,
56            self.latency
57        )
58    }
59}
60
61/// This is another [`Serialize`] implementation for the [`NewPayloadResult`] struct, serializing
62/// the duration as microseconds because the csv writer would fail otherwise.
63impl Serialize for NewPayloadResult {
64    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
65    where
66        S: serde::ser::Serializer,
67    {
68        // convert the time to microseconds
69        let time = self.latency.as_micros();
70        let mut state = serializer.serialize_struct("NewPayloadResult", 2)?;
71        state.serialize_field("gas_used", &self.gas_used)?;
72        state.serialize_field("latency", &time)?;
73        state.end()
74    }
75}
76
77/// This represents the combined results of a `newPayload` call and a `forkchoiceUpdated` call in
78/// the benchmark, containing the gas used, the `newPayload` latency, and the `forkchoiceUpdated`
79/// latency.
80#[derive(Debug)]
81pub(crate) struct CombinedResult {
82    /// The block number of the block being processed.
83    pub(crate) block_number: u64,
84    /// The gas limit of the block.
85    pub(crate) gas_limit: u64,
86    /// The number of transactions in the block.
87    pub(crate) transaction_count: u64,
88    /// The `newPayload` result.
89    pub(crate) new_payload_result: NewPayloadResult,
90    /// The latency of the `forkchoiceUpdated` call.
91    pub(crate) fcu_latency: Duration,
92    /// The latency of both calls combined.
93    pub(crate) total_latency: Duration,
94}
95
96impl CombinedResult {
97    /// Returns the gas per second, including the `newPayload` _and_ `forkchoiceUpdated` duration.
98    pub(crate) fn combined_gas_per_second(&self) -> f64 {
99        self.new_payload_result.gas_used as f64 / self.total_latency.as_secs_f64()
100    }
101}
102
103impl std::fmt::Display for CombinedResult {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        write!(
106            f,
107            "Block {} processed at {:.4} Ggas/s, used {} total gas. Combined: {:.4} Ggas/s. fcu: {:?}, newPayload: {:?}",
108            self.block_number,
109            self.new_payload_result.gas_per_second() / GIGAGAS as f64,
110            self.new_payload_result.gas_used,
111            self.combined_gas_per_second() / GIGAGAS as f64,
112            self.fcu_latency,
113            self.new_payload_result.latency
114        )
115    }
116}
117
118/// This is a [`Serialize`] implementation for the [`CombinedResult`] struct, serializing the
119/// durations as microseconds because the csv writer would fail otherwise.
120impl Serialize for CombinedResult {
121    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
122    where
123        S: serde::ser::Serializer,
124    {
125        // convert the time to microseconds
126        let fcu_latency = self.fcu_latency.as_micros();
127        let new_payload_latency = self.new_payload_result.latency.as_micros();
128        let total_latency = self.total_latency.as_micros();
129        let mut state = serializer.serialize_struct("CombinedResult", 7)?;
130
131        // flatten the new payload result because this is meant for CSV writing
132        state.serialize_field("block_number", &self.block_number)?;
133        state.serialize_field("gas_limit", &self.gas_limit)?;
134        state.serialize_field("transaction_count", &self.transaction_count)?;
135        state.serialize_field("gas_used", &self.new_payload_result.gas_used)?;
136        state.serialize_field("new_payload_latency", &new_payload_latency)?;
137        state.serialize_field("fcu_latency", &fcu_latency)?;
138        state.serialize_field("total_latency", &total_latency)?;
139        state.end()
140    }
141}
142
143/// This represents a row of total gas data in the benchmark.
144#[derive(Debug)]
145pub(crate) struct TotalGasRow {
146    /// The block number of the block being processed.
147    pub(crate) block_number: u64,
148    /// The number of transactions in the block.
149    pub(crate) transaction_count: u64,
150    /// The total gas used in the block.
151    pub(crate) gas_used: u64,
152    /// Time since the start of the benchmark.
153    pub(crate) time: Duration,
154}
155
156/// This represents the aggregated output, meant to show gas per second metrics, of a benchmark run.
157#[derive(Debug)]
158pub(crate) struct TotalGasOutput {
159    /// The total gas used in the benchmark.
160    pub(crate) total_gas_used: u64,
161    /// The total wall-clock duration of the benchmark (includes wait times).
162    pub(crate) total_duration: Duration,
163    /// The total execution-only duration (excludes wait times).
164    pub(crate) execution_duration: Duration,
165    /// The number of blocks processed.
166    pub(crate) blocks_processed: u64,
167}
168
169impl TotalGasOutput {
170    /// Create a new [`TotalGasOutput`] from gas rows only.
171    ///
172    /// Use this when execution-only timing is not available (e.g., `new_payload_only`).
173    /// `execution_duration` will equal `total_duration`.
174    pub(crate) fn new(rows: Vec<TotalGasRow>) -> eyre::Result<Self> {
175        let total_duration = rows.last().map(|row| row.time).ok_or_eyre("empty results")?;
176        let blocks_processed = rows.len() as u64;
177        let total_gas_used: u64 = rows.into_iter().map(|row| row.gas_used).sum();
178
179        Ok(Self {
180            total_gas_used,
181            total_duration,
182            execution_duration: total_duration,
183            blocks_processed,
184        })
185    }
186
187    /// Create a new [`TotalGasOutput`] from gas rows and combined results.
188    ///
189    /// - `rows`: Used for total gas and wall-clock duration
190    /// - `combined_results`: Used for execution-only duration (sum of `total_latency`)
191    pub(crate) fn with_combined_results(
192        rows: Vec<TotalGasRow>,
193        combined_results: &[CombinedResult],
194    ) -> eyre::Result<Self> {
195        let total_duration = rows.last().map(|row| row.time).ok_or_eyre("empty results")?;
196        let blocks_processed = rows.len() as u64;
197        let total_gas_used: u64 = rows.into_iter().map(|row| row.gas_used).sum();
198
199        // Sum execution-only time from combined results
200        let execution_duration: Duration = combined_results.iter().map(|r| r.total_latency).sum();
201
202        Ok(Self { total_gas_used, total_duration, execution_duration, blocks_processed })
203    }
204
205    /// Return the total gigagas per second based on wall-clock time.
206    pub(crate) fn total_gigagas_per_second(&self) -> f64 {
207        self.total_gas_used as f64 / self.total_duration.as_secs_f64() / GIGAGAS as f64
208    }
209
210    /// Return the execution-only gigagas per second (excludes wait times).
211    pub(crate) fn execution_gigagas_per_second(&self) -> f64 {
212        self.total_gas_used as f64 / self.execution_duration.as_secs_f64() / GIGAGAS as f64
213    }
214}
215
216/// Write benchmark results to CSV files.
217///
218/// Writes two files to the output directory:
219/// - `combined_latency.csv`: Per-block latency results
220/// - `total_gas.csv`: Per-block gas usage over time
221pub(crate) fn write_benchmark_results(
222    output_dir: &Path,
223    gas_results: &[TotalGasRow],
224    combined_results: &[CombinedResult],
225) -> eyre::Result<()> {
226    fs::create_dir_all(output_dir)?;
227
228    let output_path = output_dir.join(COMBINED_OUTPUT_SUFFIX);
229    info!(target: "reth-bench", "Writing engine api call latency output to file: {:?}", output_path);
230    let mut writer = Writer::from_path(&output_path)?;
231    for result in combined_results {
232        writer.serialize(result)?;
233    }
234    writer.flush()?;
235
236    let output_path = output_dir.join(GAS_OUTPUT_SUFFIX);
237    info!(target: "reth-bench", "Writing total gas output to file: {:?}", output_path);
238    let mut writer = Writer::from_path(&output_path)?;
239    for row in gas_results {
240        writer.serialize(row)?;
241    }
242    writer.flush()?;
243
244    info!(target: "reth-bench", "Finished writing benchmark output files to {:?}.", output_dir);
245    Ok(())
246}
247
248/// This serializes the `time` field of the [`TotalGasRow`] to microseconds.
249///
250/// This is essentially just for the csv writer, which would have headers
251impl Serialize for TotalGasRow {
252    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
253    where
254        S: serde::ser::Serializer,
255    {
256        // convert the time to microseconds
257        let time = self.time.as_micros();
258        let mut state = serializer.serialize_struct("TotalGasRow", 4)?;
259        state.serialize_field("block_number", &self.block_number)?;
260        state.serialize_field("transaction_count", &self.transaction_count)?;
261        state.serialize_field("gas_used", &self.gas_used)?;
262        state.serialize_field("time", &time)?;
263        state.end()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use csv::Writer;
271    use std::io::BufRead;
272
273    #[test]
274    fn test_write_total_gas_row_csv() {
275        let row = TotalGasRow {
276            block_number: 1,
277            transaction_count: 10,
278            gas_used: 1_000,
279            time: Duration::from_secs(1),
280        };
281
282        let mut writer = Writer::from_writer(vec![]);
283        writer.serialize(row).unwrap();
284        let result = writer.into_inner().unwrap();
285
286        // parse into Lines
287        let mut result = result.as_slice().lines();
288
289        // assert header
290        let expected_first_line = "block_number,transaction_count,gas_used,time";
291        let first_line = result.next().unwrap().unwrap();
292        assert_eq!(first_line, expected_first_line);
293
294        let expected_second_line = "1,10,1000,1000000";
295        let second_line = result.next().unwrap().unwrap();
296        assert_eq!(second_line, expected_second_line);
297    }
298}