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