Skip to main content

sp1_core_executor/
report.rs

1use std::{
2    fmt::{Display, Formatter, Result as FmtResult},
3    ops::{Add, AddAssign},
4};
5
6use enum_map::{EnumArray, EnumMap};
7use hashbrown::HashMap;
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    events::{generate_execution_report, MemInstrEvent, PrecompileEvent, SyscallEvent},
12    ITypeRecord, Opcode, SyscallCode,
13};
14
15/// This constant is chosen for backwards compatibility with the V4 gas model: with this factor,
16/// the gas costs of op-succinct blocks in V6 will approximately match those in V4.
17const GAS_NORMALIZATION_FACTOR: u64 = 191;
18
19/// An execution report.
20///
21/// The serde format is stable only within a single SP1 version, since `Opcode`/`SyscallCode`
22/// gain variants across releases. The serialized `gas` field is the raw value; call
23/// [`ExecutionReport::gas`] for the normalized number.
24#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ExecutionReport {
26    /// The opcode counts.
27    pub opcode_counts: Box<EnumMap<Opcode, u64>>,
28    /// The syscall counts.
29    pub syscall_counts: Box<EnumMap<SyscallCode, u64>>,
30    /// The cycle tracker counts.
31    pub cycle_tracker: HashMap<String, u64>,
32    /// Tracker for the number of `cycle-tracker-report-*` invocations for a specific label.
33    pub invocation_tracker: HashMap<String, u64>,
34    /// The unique memory address counts.
35    pub touched_memory_addresses: u64,
36    /// The final exit code of the execution.
37    pub exit_code: u64,
38    /// The unnormalized gas, if it was calculated. Should not be accessed directly. Use `gas()` instead.
39    pub(crate) gas: Option<u64>,
40}
41
42impl ExecutionReport {
43    /// Compute the total number of instructions run during the execution.
44    #[must_use]
45    pub fn total_instruction_count(&self) -> u64 {
46        self.opcode_counts.values().sum()
47    }
48
49    /// Compute the total number of syscalls made during the execution.
50    #[must_use]
51    pub fn total_syscall_count(&self) -> u64 {
52        self.syscall_counts.values().sum()
53    }
54
55    /// The total size expected size (in bytes) of the execution report.
56    #[must_use]
57    pub fn total_record_size(&self) -> u64 {
58        // todo!(n): make this precise.
59
60        // Fix some average bound for each opcode.
61        let avg_opcode_record_size = std::mem::size_of::<(MemInstrEvent, ITypeRecord)>();
62        let total_opcode_records_size_bytes =
63            self.opcode_counts.values().sum::<u64>() * avg_opcode_record_size as u64;
64
65        // Take the maximum size of each precompile + 512 bytes for the vecs
66        // todo: can we fix the array sizes in the precompile events?
67        let syscall_avg_record_size = std::mem::size_of::<(SyscallEvent, PrecompileEvent)>() + 512;
68        let total_syscall_records_size_bytes =
69            self.syscall_counts.values().sum::<u64>() * syscall_avg_record_size as u64;
70
71        total_opcode_records_size_bytes + total_syscall_records_size_bytes
72    }
73
74    /// Normalize the internal gas so that op-succinct blocks have approximately the same gas
75    /// on v4 and v6.
76    #[must_use]
77    pub fn gas(&self) -> Option<u64> {
78        // Using integer arithmetic to avoid f64 precision warnings.
79        self.gas.map(|g| g * 10 / GAS_NORMALIZATION_FACTOR)
80    }
81}
82
83/// Combines two `HashMap`s together. If a key is in both maps, the values are added together.
84fn counts_add_assign<K, V>(lhs: &mut EnumMap<K, V>, rhs: EnumMap<K, V>)
85where
86    K: EnumArray<V>,
87    V: AddAssign,
88{
89    for (k, v) in rhs {
90        lhs[k] += v;
91    }
92}
93
94impl AddAssign for ExecutionReport {
95    fn add_assign(&mut self, rhs: Self) {
96        counts_add_assign(&mut self.opcode_counts, *rhs.opcode_counts);
97        counts_add_assign(&mut self.syscall_counts, *rhs.syscall_counts);
98        self.touched_memory_addresses += rhs.touched_memory_addresses;
99
100        // Merge cycle_tracker and invocation_tracker
101        for (label, count) in rhs.cycle_tracker {
102            *self.cycle_tracker.entry(label).or_insert(0) += count;
103        }
104        for (label, count) in rhs.invocation_tracker {
105            *self.invocation_tracker.entry(label).or_insert(0) += count;
106        }
107
108        // Sum gas costs if both have gas
109        self.gas = match (self.gas, rhs.gas) {
110            (Some(c1), Some(c2)) => Some(c1 + c2),
111            (Some(g), None) | (None, Some(g)) => Some(g),
112            (None, None) => None,
113        };
114
115        // The exit code value must either be `0` or the final exit code, so taking an `OR` works.
116        self.exit_code |= rhs.exit_code;
117    }
118}
119
120impl Add for ExecutionReport {
121    type Output = Self;
122
123    fn add(mut self, rhs: Self) -> Self::Output {
124        self += rhs;
125        self
126    }
127}
128
129impl Display for ExecutionReport {
130    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
131        if let Some(gas) = self.gas() {
132            writeln!(f, "gas: {gas:?}")?;
133        }
134        writeln!(f, "opcode counts ({} total instructions):", self.total_instruction_count())?;
135        for line in generate_execution_report(self.opcode_counts.as_ref()) {
136            writeln!(f, "  {line}")?;
137        }
138
139        writeln!(f, "syscall counts ({} total syscall instructions):", self.total_syscall_count())?;
140        for line in generate_execution_report(self.syscall_counts.as_ref()) {
141            writeln!(f, "  {line}")?;
142        }
143
144        Ok(())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn populated_report() -> ExecutionReport {
153        let mut report = ExecutionReport::default();
154        report.opcode_counts[Opcode::ADD] = 7;
155        report.opcode_counts[Opcode::SUB] = 3;
156        report.syscall_counts[SyscallCode::HALT] = 1;
157        report.cycle_tracker.insert("setup".to_string(), 100);
158        report.invocation_tracker.insert("setup".to_string(), 2);
159        report.touched_memory_addresses = 42;
160        report.exit_code = 0;
161        report.gas = Some(1_000);
162        report
163    }
164
165    /// A populated `ExecutionReport` must round-trip through serde's human-readable (JSON) format.
166    /// Derived `PartialEq` compares every field, including the `pub(crate)` `gas`.
167    #[test]
168    fn execution_report_json_round_trip() {
169        let report = populated_report();
170        let json = serde_json::to_string(&report).expect("serialize");
171        let decoded: ExecutionReport = serde_json::from_str(&json).expect("deserialize");
172        assert_eq!(report, decoded);
173    }
174
175    /// `enum_map`'s `Serialize`/`Deserialize` takes a separate code path for non-human-readable
176    /// formats (a fixed-length tuple rather than a map), and SP1 uses bincode for persistence, so
177    /// prove the binary path round-trips too.
178    #[test]
179    fn execution_report_bincode_round_trip() {
180        let report = populated_report();
181        let bytes = bincode::serialize(&report).expect("serialize");
182        let decoded: ExecutionReport = bincode::deserialize(&bytes).expect("deserialize");
183        assert_eq!(report, decoded);
184    }
185
186    /// The `gas` field is `Option<u64>`; the `None` branch (e.g. a report whose gas was never
187    /// computed) must also survive the round trip in both formats.
188    #[test]
189    fn execution_report_default_and_none_gas_round_trip() {
190        let report = ExecutionReport::default();
191        assert_eq!(report.gas, None);
192
193        let json: ExecutionReport =
194            serde_json::from_str(&serde_json::to_string(&report).expect("ser")).expect("de");
195        assert_eq!(report, json);
196
197        let bin: ExecutionReport =
198            bincode::deserialize(&bincode::serialize(&report).expect("ser")).expect("de");
199        assert_eq!(report, bin);
200    }
201}