diff --git a/.gitignore b/.gitignore index 59b16d66..798ba55e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,9 @@ target .idea/ .DS_Store + +# Zeth specific /cache/ +/block-benchmarks.csv +/opcode-profile.csv +/trace.json.gz diff --git a/CHANGELOG.md b/CHANGELOG.md index 46297258..eba0cc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New feature `unsafe-pre-merge` must be enabled to prove pre-merge blocks. - Implement versioned caching for input files. This ensures backward compatibility with older cache files by converting them to the current schema on the fly. +- Add `cycle-tracker` feature and helper script for detailed profiling of zkVM cycle costs per opcode. ## [0.2.1](https://github.com/boundless-xyz/zeth/releases/tag/v0.2.1) - 2025-08-05 diff --git a/Cargo.lock b/Cargo.lock index be03a45f..ee3af89d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -445,15 +445,16 @@ dependencies = [ [[package]] name = "alloy-eip7928" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6ae911a2fc304a7cb80a79fb7bed6d1474aed4e7c203df1f8ff538f64fc78d" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" dependencies = [ "alloy-primitives", "alloy-rlp", "borsh", "once_cell", "serde", + "thiserror", ] [[package]] @@ -1554,6 +1555,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1589,9 +1599,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -1599,9 +1609,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -1798,6 +1808,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1927,9 +1946,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -2902,6 +2921,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.32" @@ -3039,6 +3064,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3076,9 +3110,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -3089,6 +3123,20 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version 0.4.1", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3198,9 +3246,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -3462,7 +3510,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3473,16 +3521,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3583,9 +3621,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -4328,6 +4366,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "heapless", "serde", ] @@ -4760,6 +4799,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.12.28" @@ -5836,6 +5881,35 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version 0.4.1", + "syn 2.0.117", + "unicode-ident", +] + [[package]] name = "ruint" version = "1.18.0" @@ -6234,11 +6308,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.19.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -6253,9 +6328,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.19.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -6418,6 +6493,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -6733,9 +6811,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -6882,20 +6960,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "bitflags 2.11.1", "bytes", "futures-util", "http 1.4.0", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -7174,9 +7252,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -7187,9 +7265,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -7197,9 +7275,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7207,9 +7285,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -7220,9 +7298,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -7290,9 +7368,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -7744,9 +7822,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -7836,6 +7914,7 @@ version = "0.3.0" dependencies = [ "alloy-primitives", "alloy-rlp", + "postcard", "reth-chainspec", "reth-ethereum-primitives 2.1.0", "reth-evm", @@ -7844,8 +7923,13 @@ dependencies = [ "reth-trie-common", "risc0-crypto-evm", "risc0-ethereum-trie", + "risc0-zkvm", + "rstest", "serde", + "serde_json", + "sha2", "stateless", + "tracing", "tries", ] @@ -7859,11 +7943,13 @@ dependencies = [ "blst", "bytemuck", "clap", + "flate2", "guests", "humansize", "reth-chainspec", "reth-ethereum-primitives 1.11.3", "reth-ethereum-primitives 2.1.0", + "reth-evm", "reth-primitives-traits 1.11.3", "risc0-zkvm", "serde", diff --git a/Cargo.toml b/Cargo.toml index dafb9107..08ea04d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,8 +57,11 @@ async-trait = "0.1" blst = "0.3.15" bytemuck = "1.23" clap = "4.5" +flate2 = "1.1" humansize = "2.1" itertools = "0.14" +num-bigint = "0.4" +postcard = "1.1" serde = "1.0" serde_json = "1.0" serde_with = "3" diff --git a/README.md b/README.md index cfa250f3..85b5eae8 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,27 @@ ETH_RPC_URL="" cargo run --release --bin cli --features "unsafe-pr **Warning**: Proofs generated with this flag are not fully sound and should not be used in production. +## Cycle Profiling + +Zeth includes an optional `cycle-tracker` feature that allows you to profile the execution of blocks inside the zkVM. This generates detailed reports on how many cycles are spent on each EVM opcode and precompile. + +### Enabling the Tracker + +To use this feature, you must build the project with the `cycle-tracker` feature flag enabled: +```bash +cargo build --release --features cycle-tracker +```` + +When running the prover with this feature, you can set the `TRACE_FILE` environment variable to specify the output path for the raw trace data (defaults to `trace.json.gz`). +The output is a Gzip-compressed JSON file containing a map of trace identifiers (e.g., opcode names) to lists of (cycles, gas) usage tuples. + +### Analysis Scripts + +Two Python scripts are provided in the `scripts/` directory to help with benchmarking and profiling. **Note:** These scripts operate on cached input files, so ensure you have populated your `cache/` directory (e.g., by running `cli validate` on target blocks) before running them. + +* **`scripts/trace.py`**: Automates the process of building Zeth with the tracker enabled, running proofs for all cached blocks, and aggregating the cycle usage data into a CSV file (`opcode-profile.csv`). +* **`scripts/benchmark.py`**: Builds Zeth *without* the tracker (for maximum performance) and measures wall-clock execution time and total cycle counts for cached blocks. + ## Additional Resources * [RISC Zero Developer Portal](https://dev.risczero.com/) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index c8e0194b..34209ee0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -7,6 +7,7 @@ license = { workspace = true } repository = { workspace = true } [features] +cycle-tracker = ["postcard", "risc0-zkvm", "tracing"] # Allow proving of pre-merge (Proof-of-Work) blocks. # This is considered unsafe because reth does not correctly validate all difficulty constraints. unsafe-pre-merge = [] @@ -14,6 +15,7 @@ unsafe-pre-merge = [] [dependencies] alloy-primitives = { workspace = true, features = ["map"] } alloy-rlp = { workspace = true } +postcard = { workspace = true, optional = true } reth-chainspec = { workspace = true } reth-ethereum-primitives = { workspace = true } reth-evm = { workspace = true } @@ -21,15 +23,23 @@ reth-evm-ethereum = { workspace = true } reth-primitives-traits = { workspace = true } reth-trie-common = { workspace = true } risc0-ethereum-trie = { workspace = true } +risc0-zkvm = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } stateless = { workspace = true } tries = { workspace = true } +tracing = { workspace = true, optional = true } + # R0VM-accelerated precompile primitives (zkVM-only). The crate itself assumes # the zkVM target and wouldn't link on host; the adapter `impl Crypto` block is # cfg-gated to match. [target.'cfg(all(target_os = "zkvm", target_vendor = "risc0"))'.dependencies] risc0-crypto-evm = { workspace = true } +[dev-dependencies] +rstest = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } + [lints] workspace = true diff --git a/crates/core/src/cycle_tracker/collector.rs b/crates/core/src/cycle_tracker/collector.rs new file mode 100644 index 00000000..ab319363 --- /dev/null +++ b/crates/core/src/cycle_tracker/collector.rs @@ -0,0 +1,173 @@ +// Copyright 2026 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::types::{EventKind, TraceFdEvent, TraceId}; +use alloy_primitives::bytes::{Buf, BytesMut}; +use std::{fmt::Display, io}; + +/// A frame in the call stack maintained by the trace processor. +#[derive(Debug, Clone)] +struct StackFrame { + id: TraceId<'static>, + start_cycles: u64, + start_gas: u64, +} + +/// Processes trace events and maintains a call stack. +/// +/// Converts raw enter/exit/total events into structured [`TraceEvent`]s +/// with computed depths and durations. +#[derive(Debug, Clone)] +struct TraceProcessor { + stack: Vec, + callback: F, +} + +/// A processed trace event with call depth and cycle count. +#[derive(Clone, Debug)] +pub struct TraceEvent { + /// The identifier for this traced section + pub id: TraceId<'static>, + pub depth: usize, + pub cycles: u64, + pub gas: u64, +} + +impl Display for TraceEvent { + /// Default callback that prints trace events with indentation. + /// + /// Output format: + /// ```text + /// initialization: 1234 + /// [ OP] 0x01: 56 + /// [PRE] 0x0000...0001: 789 + /// ``` + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let indent = " ".repeat(self.depth); + writeln!(f, "{}{}: {} cycles", indent, self.id, self.cycles) + } +} + +impl TraceProcessor { + /// Creates a new processor with a custom callback. + /// + /// The callback is invoked for each completed trace event. + fn new(callback: F) -> Self { + Self { stack: Vec::new(), callback } + } + + fn process(&mut self, event: TraceFdEvent) { + match event.kind { + EventKind::Complete => { + let id = event.id.into_owned(); + let depth = self.stack.len(); + (self.callback)(TraceEvent { id, depth, cycles: event.cycles, gas: event.gas }); + } + EventKind::Enter => { + let id = event.id.into_owned(); + self.stack.push(StackFrame { + id, + start_cycles: event.cycles, + start_gas: event.gas, + }); + } + EventKind::Exit => { + if let Some(frame) = self.stack.pop() { + let id = event.id.into_owned(); + + // Safety Check: Verify we are popping what we expect + if frame.id == id { + let cycles = event.cycles.saturating_sub(frame.start_cycles); + let gas = event.gas.saturating_sub(frame.start_gas); + (self.callback)(TraceEvent { id, depth: self.stack.len(), cycles, gas }); + } else { + tracing::warn!("Trace Mismatch: Entered {:?} but exited {id:?}", frame.id); + } + } else { + tracing::warn!("Stack Underflow: Exited {:?} without entering it", event.id); + } + } + } + } +} + +/// Host-side collector that processes the trace stream. +/// +/// Implements [`io::Write`] so it can be passed to RiscZero's `write_fd`. +/// Deserializes COBS-framed events and invokes the processor callback. +/// +/// # Example +/// +/// ```rust,no_run +/// # use zeth_core::cycle_tracker::{CYCLE_TRACKER_FD, TraceCollector}; +/// # use risc0_zkvm::{ExecutorEnv, default_executor}; +/// # const GUEST_ELF: &[u8] = &[]; +/// # fn main() -> Result<(), Box> { +/// let mut collector = TraceCollector::new(|event| println!("{event}")); +/// +/// let env = ExecutorEnv::builder().write_fd(CYCLE_TRACKER_FD, collector).build()?; +/// +/// let exec = default_executor(); +/// exec.execute(env, GUEST_ELF)?; +/// # Ok(()) } +/// ``` +#[derive(Debug, Clone)] +pub struct TraceCollector { + buffer: BytesMut, + processor: TraceProcessor, +} + +impl TraceCollector { + /// Creates a new collector with a custom processor. + /// + /// Useful for collecting statistics or custom output formatting. + /// + /// # Example + /// ```rust + /// # use zeth_core::cycle_tracker::TraceCollector; + /// let collector = TraceCollector::new(|event| { + /// if event.cycles > 1000 { + /// println!("Expensive: {} took {} cycles", event.id, event.cycles); + /// } + /// }); + /// ``` + pub fn new(callback: F) -> Self { + Self { buffer: BytesMut::default(), processor: TraceProcessor::new(callback) } + } +} + +impl io::Write for TraceCollector { + /// Buffers partial frames and deserializes complete ones (delimited by zero bytes). + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.extend_from_slice(buf); + + while let Some(zero_pos) = self.buffer.iter().position(|&b| b == 0) { + let frame_len = zero_pos + 1; + let frame = &mut self.buffer[..frame_len]; + + match postcard::from_bytes_cobs::(frame) { + Ok(event) => self.processor.process(event), + Err(e) => return Err(io::Error::other(e)), + } + + self.buffer.advance(frame_len); + } + + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} diff --git a/crates/core/src/cycle_tracker/evm.rs b/crates/core/src/cycle_tracker/evm.rs new file mode 100644 index 00000000..01eea7e9 --- /dev/null +++ b/crates/core/src/cycle_tracker/evm.rs @@ -0,0 +1,257 @@ +// Copyright 2026 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::tracer::CycleTracer; +use reth_evm::{ + ConfigureEvm, Database, EvmEnvFor, EvmFactory, ExecutionCtxFor, OnStateHook, + block::{BlockExecutionResult, BlockExecutorFactory, BlockExecutorFor}, + execute::{BlockExecutionError, BlockExecutor, Executor}, + revm::{ + Inspector, + context::{ContextTr, JournalTr}, + database::{State, states::bundle_state::BundleRetention}, + interpreter::{ + CallInputs, CallOutcome, FrameInput, Interpreter, InterpreterAction, + interpreter_types::Jumps, + }, + primitives::hardfork::SpecId, + }, +}; +use reth_evm_ethereum::EthEvmConfig; +use reth_primitives_traits::{ + BlockTy, HeaderTy, NodePrimitives, RecoveredBlock, SealedBlock, SealedHeader, +}; +use std::fmt::Debug; + +/// A wrapper around [`EthEvmConfig`] that enables cycle tracking during execution. +#[derive(Debug, Clone)] +pub struct CycleTrackerEvmConfig(EthEvmConfig); + +impl CycleTrackerEvmConfig { + /// Creates a new cycle tracking configuration wrapping the provided inner config. + pub fn new(config: EthEvmConfig) -> Self { + Self(config) + } +} + +impl ConfigureEvm for CycleTrackerEvmConfig +where + EthEvmConfig: ConfigureEvm< + BlockExecutorFactory: BlockExecutorFactory>, + >, + ChainSpec: Clone + Debug, + EvmF: Clone + Debug, +{ + type Primitives = as ConfigureEvm>::Primitives; + type Error = as ConfigureEvm>::Error; + type NextBlockEnvCtx = as ConfigureEvm>::NextBlockEnvCtx; + type BlockExecutorFactory = + as ConfigureEvm>::BlockExecutorFactory; + type BlockAssembler = as ConfigureEvm>::BlockAssembler; + + fn block_executor_factory(&self) -> &Self::BlockExecutorFactory { + self.0.block_executor_factory() + } + + fn block_assembler(&self) -> &Self::BlockAssembler { + self.0.block_assembler() + } + + fn evm_env(&self, header: &HeaderTy) -> Result, Self::Error> { + self.0.evm_env(header) + } + + fn next_evm_env( + &self, + parent: &HeaderTy, + attributes: &Self::NextBlockEnvCtx, + ) -> Result, Self::Error> { + self.0.next_evm_env(parent, attributes) + } + + fn context_for_block<'a>( + &self, + block: &'a SealedBlock>, + ) -> Result, Self::Error> { + self.0.context_for_block(block) + } + + fn context_for_next_block( + &self, + parent: &SealedHeader>, + attributes: Self::NextBlockEnvCtx, + ) -> Result, Self::Error> { + self.0.context_for_next_block(parent, attributes) + } + + fn executor( + &self, + db: DB, + ) -> impl Executor { + // override the default implementation to execute with cycle tracking + CycleTrackerBlockExecutor::new(self, db) + } +} + +struct CycleTrackerBlockExecutor { + strategy_factory: F, + db: State, +} + +impl CycleTrackerBlockExecutor { + pub(crate) fn new(strategy_factory: F, db: DB) -> Self { + let db = State::builder().with_database(db).with_bundle_update().build(); + Self { strategy_factory, db } + } +} + +impl CycleTrackerBlockExecutor +where + F: ConfigureEvm< + BlockExecutorFactory: BlockExecutorFactory>, + >, + DB: Database, +{ + /// Creates a strategy for execution of a given block with the inspector. + fn executor_for_block<'a>( + &'a mut self, + block: &'a SealedBlock<<::Primitives as NodePrimitives>::Block>, + ) -> Result< + impl BlockExecutorFor< + 'a, + ::BlockExecutorFactory, + &'a mut State, + CycleTrackerInspector<'a>, + >, + ::Error, + > { + let evm_env = self.strategy_factory.evm_env(block.header())?; + + let inspector = CycleTrackerInspector::default(); + let evm = + self.strategy_factory.evm_with_env_and_inspector(&mut self.db, evm_env, inspector); + + let ctx = self.strategy_factory.context_for_block(block)?; + Ok(self.strategy_factory.create_executor(evm, ctx)) + } +} + +impl Executor for CycleTrackerBlockExecutor +where + F: ConfigureEvm< + BlockExecutorFactory: BlockExecutorFactory>, + >, + DB: Database, +{ + type Primitives = F::Primitives; + type Error = BlockExecutionError; + + fn execute_one( + &mut self, + block: &RecoveredBlock<::Block>, + ) -> Result::Receipt>, Self::Error> + { + let result = self + .executor_for_block(block) + .map_err(BlockExecutionError::other)? + .execute_block(block.transactions_recovered())?; + + self.db.merge_transitions(BundleRetention::Reverts); + + Ok(result) + } + + fn execute_one_with_state_hook( + &mut self, + _: &RecoveredBlock<::Block>, + _: H, + ) -> Result::Receipt>, Self::Error> + where + H: OnStateHook + 'static, + { + unimplemented!() + } + + fn into_state(self) -> State { + self.db + } + + fn size_hint(&self) -> usize { + self.db.bundle_state.size_hint() + } +} + +#[derive(Clone, Debug, Default)] +struct CycleTrackerInspector<'a> { + // last opcode executed + last_opcode: Option, + // true when the current call is to a precompile (cached between call/call_end) + in_precompile: bool, + // since cycle tracking is performance-critical, we use a local tracer instead of a global one + tracer: CycleTracer<'a>, +} + +impl Inspector for CycleTrackerInspector<'_> { + #[inline] + fn initialize_interp(&mut self, _interp: &mut Interpreter, _context: &mut CTX) { + // make sure the last opcode is reset, even if the interpreter is reused + self.last_opcode = None; + } + + #[inline] + fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + let opcode = interp.bytecode.opcode(); + + // keep track of the last opcode executed + self.last_opcode = Some(opcode); + self.tracer.enter_with_gas(opcode, interp.gas.total_gas_spent()) + } + + #[inline] + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + if let Some(opcode) = self.last_opcode.take() { + let mut gas = interp.gas.total_gas_spent(); + + // Calls and creations include the gas limit in the gas cost. We need to subtract this + // amount because we want to track how much gas the opcode itself consumes. + if let Some(InterpreterAction::NewFrame(frame)) = &interp.bytecode.action { + let gas_limit = match frame { + FrameInput::Empty => 0, + FrameInput::Call(input) => input.gas_limit, + FrameInput::Create(input) => input.gas_limit(), + }; + gas = gas.saturating_sub(gas_limit); + } + self.tracer.exit_with_gas(opcode, gas) + } + } + + #[inline] + fn call(&mut self, context: &mut CTX, inputs: &mut CallInputs) -> Option { + self.in_precompile = + context.journal_ref().precompile_addresses().contains(&inputs.bytecode_address); + if self.in_precompile { + self.tracer.enter_with_gas(inputs.bytecode_address, 0); + } + None + } + + #[inline] + fn call_end(&mut self, _context: &mut CTX, inputs: &CallInputs, outcome: &mut CallOutcome) { + if self.in_precompile { + self.tracer + .exit_with_gas(inputs.bytecode_address, outcome.result.gas.total_gas_spent()); + } + } +} diff --git a/crates/core/src/cycle_tracker/mod.rs b/crates/core/src/cycle_tracker/mod.rs new file mode 100644 index 00000000..dcfe622e --- /dev/null +++ b/crates/core/src/cycle_tracker/mod.rs @@ -0,0 +1,31 @@ +// Copyright 2026 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#[cfg(not(target_os = "zkvm"))] +mod collector; +mod evm; +mod tracer; +mod types; + +pub use types::{CYCLE_TRACKER_FD, TraceId}; + +#[cfg(not(target_os = "zkvm"))] +pub use collector::{TraceCollector, TraceEvent}; + +pub mod guest { + pub use super::{ + evm::CycleTrackerEvmConfig, + tracer::{CycleTracer, Span, enter, exit, span}, + }; +} diff --git a/crates/core/src/cycle_tracker/tracer.rs b/crates/core/src/cycle_tracker/tracer.rs new file mode 100644 index 00000000..541050f5 --- /dev/null +++ b/crates/core/src/cycle_tracker/tracer.rs @@ -0,0 +1,258 @@ +// Copyright 2026 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::types::{EventKind, IntoTraceId, TraceFdEvent, TraceId}; +use serde::Serialize; +use std::cell::RefCell; + +#[cfg(target_os = "zkvm")] +mod platform { + use crate::cycle_tracker::types; + use risc0_zkvm::guest::env::{FdWriter, Write}; + + pub(super) use risc0_zkvm::guest::env::cycle_count; + + #[inline(always)] + pub(super) fn write_slice(buf: &[u8]) { + FdWriter::new(types::CYCLE_TRACKER_FD, |_| {}).write_slice(buf); + } +} + +#[cfg(not(target_os = "zkvm"))] +mod platform { + /// Returns 0 to prevent panics when instrumented code runs on the host. + pub(super) fn cycle_count() -> u64 { + 0 + } + /// No-op on the host; trace data is discarded. + pub(super) fn write_slice(_: &[u8]) {} +} + +/// Guest-side tracer for recording cycle counts. +/// +/// This struct is responsible for serializing trace events using [postcard] with COBS framing and +/// writing them to the trace file descriptor. +/// +/// # Optimization Strategy +/// +/// To minimize I/O overhead, the tracer employs a "write-combining" strategy: +/// If `exit` is called immediately after `enter` for the same ID (a leaf span), the tracer +/// calculates the diff and writes a single event. +/// +/// # Performance Note +/// +/// For hot loops (like EVM opcode execution), instantiate this struct directly and keep it alive to +/// avoid the overhead of Thread Local Storage (TLS) associated with the global [`enter`] and +/// [`exit`] functions. +#[derive(Clone, Debug)] +pub struct CycleTracer<'a> { + /// Stores the pending `enter` event that has not yet been written to the trace. + last_enter: Option<(TraceId<'a>, u64, u64)>, + /// A pre-allocated scratch buffer used for serializing individual events. + scratch: [u8; 64], +} + +impl<'a> Default for CycleTracer<'a> { + fn default() -> Self { + Self::new() + } +} + +impl<'a> Drop for CycleTracer<'a> { + fn drop(&mut self) { + // ensure any pending "enter" is flushed if it is dropped before the corresponding "exit" + if let Some((id, cycles, gas)) = self.last_enter.take() { + self.send(EventKind::Enter, id, cycles, gas) + } + } +} + +impl<'a> CycleTracer<'a> { + /// Creates a new tracer with a pre-allocated 64-byte serialization buffer. + #[inline] + pub const fn new() -> Self { + Self { last_enter: None, scratch: [0; 64] } + } + + /// Records the start of a traced section. + /// + /// If a previous [`CycleTracer::enter`] is pending, it's flushed before recording the new one. + /// This batching behavior minimizes syscalls. + /// + /// # Example + /// ```rust + /// # let mut tracer = zeth_core::cycle_tracker::guest::CycleTracer::new(); + /// tracer.enter("function_name"); + /// // ... work ... + /// tracer.exit("function_name"); + /// ``` + #[inline(always)] + pub fn enter(&mut self, id: impl IntoTraceId<'a>) { + self.enter_with_gas(id, 0) + } + + /// Records the start of a traced section with an associated gas metric. + /// + /// It captures the current cycle count and the provided `gas` value (typically cumulative gas + /// spent). The `gas` value is stored and used later in [`CycleTracer::exit_with_gas`] to + /// calculate the exact amount of gas consumed during this span (`end_gas - start_gas`). + pub fn enter_with_gas(&mut self, id: impl IntoTraceId<'a>, gas: u64) { + let id = id.into_trace_id(); + let cycles = platform::cycle_count(); + + // If we have a pending enter that wasn't closed, flush it now. + // This handles nested calls like: enter(A) -> enter(B) -> exit(B) -> exit(A) + if let Some((enter_id, enter_cycles, enter_gas)) = + self.last_enter.replace((id, cycles, gas)) + { + self.send(EventKind::Enter, enter_id, enter_cycles, enter_gas); + } + } + + /// Records the end of a traced section. + #[inline(always)] + pub fn exit(&mut self, id: impl IntoTraceId<'a>) { + self.exit_with_gas(id, 0) + } + + /// Records the end of a traced section with an associated gas metric. + /// + /// It captures the current cycle count and provided `gas` value (typically cumulative gas + /// spent). If the pending enter event matches this exit (same ID), the tracer emits a single + /// event containing the net cycles and gas used. + pub fn exit_with_gas(&mut self, id: impl IntoTraceId<'a>, gas: u64) { + let cycles = platform::cycle_count(); + let id = id.into_trace_id(); + match self.last_enter.take() { + None => self.send(EventKind::Exit, id, cycles, gas), + Some((enter_id, enter_cycles, enter_gas)) => { + if enter_id == id { + self.send( + EventKind::Complete, + id, + cycles.saturating_sub(enter_cycles), + gas.saturating_sub(enter_gas), + ); + } else { + self.send(EventKind::Enter, enter_id, enter_cycles, enter_gas); + self.send(EventKind::Exit, id, cycles, gas); + } + } + } + } + + /// Sends the corresponding event via the file descriptor. + fn send(&mut self, kind: EventKind, id: TraceId, cycles: u64, gas: u64) { + let event = TraceFdEvent { kind, id, cycles, gas }; + let encoded = self.serialize(&event).expect("should serialize"); + platform::write_slice(encoded); + } + + fn serialize(&mut self, value: &T) -> postcard::Result<&mut [u8]> { + match postcard::to_slice_cobs(value, &mut self.scratch) { + Ok(encoded) => Ok(encoded), + Err(postcard::Error::SerializeBufferFull) => { + panic!("buffer too small, use shorter ID") + } + Err(err) => Err(err), + } + } +} + +thread_local! { + static GLOBAL_TRACER: RefCell> = const { RefCell::new(CycleTracer::new()) }; +} + +/// Records the start of a traced section using the thread-local global tracer. +/// +/// Use this for high-level logic. For tight loops, use [`CycleTracer`] directly. +pub fn enter(id: impl IntoTraceId<'static>) { + GLOBAL_TRACER.with_borrow_mut(move |t| t.enter(id)) +} + +/// Records the end of a traced section using the thread-local global tracer. +/// +/// Use this for high-level logic. For tight loops, use [`CycleTracer`] directly. +pub fn exit(id: impl IntoTraceId<'static>) { + GLOBAL_TRACER.with_borrow_mut(move |t| t.exit(id)) +} + +/// Creates a RAII guard for a traced section using the global tracer. +/// +/// # Example +/// ```rust +/// use zeth_core::cycle_tracker::guest::span; +/// pub fn process() { +/// let _outer = span("process"); +/// { +/// let _inner = span("inner"); +/// } +/// } +/// ``` +pub fn span(id: impl IntoTraceId<'static>) -> Span { + let id = id.into_trace_id(); + enter(&id); + Span { id } +} + +/// RAII guard that calls [`exit`] when dropped. +#[derive(Debug)] +#[must_use] +pub struct Span { + id: TraceId<'static>, +} + +impl Drop for Span { + #[inline(always)] + fn drop(&mut self) { + exit(&self.id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Address; + + #[test] + fn serialize() { + let mut tracer = CycleTracer::new(); + + // Custom event + let event = TraceFdEvent { + kind: EventKind::Complete, + id: TraceId::Custom("x".repeat(32).into()), + cycles: u64::MAX, + gas: u64::MAX, + }; + let encoded = tracer.serialize(&event).unwrap(); + assert_eq!(event, postcard::from_bytes_cobs(encoded).unwrap()); + + // Precompile event + let event = TraceFdEvent { + kind: EventKind::Complete, + id: TraceId::Precompile(Address::repeat_byte(0xff)), + cycles: u64::MAX, + gas: u64::MAX, + }; + let encoded = tracer.serialize(&event).unwrap(); + assert_eq!(event, postcard::from_bytes_cobs(encoded).unwrap()); + + // Opcode event + let event = + TraceFdEvent { kind: EventKind::Enter, id: TraceId::Opcode(0), cycles: 0, gas: 0 }; + let encoded = tracer.serialize(&event).unwrap(); + assert_eq!(event, postcard::from_bytes_cobs(encoded).unwrap()); + } +} diff --git a/crates/core/src/cycle_tracker/types.rs b/crates/core/src/cycle_tracker/types.rs new file mode 100644 index 00000000..e7ec78a8 --- /dev/null +++ b/crates/core/src/cycle_tracker/types.rs @@ -0,0 +1,118 @@ +// Copyright 2026 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloy_primitives::Address; +use serde::{Deserialize, Serialize}; +use std::{borrow::Cow, fmt::Display}; + +/// The file descriptor used for trace communication. +/// +/// This must be configured on the Host via [`risc0_zkvm::ExecutorEnvBuilder::write_fd`] and is used +/// by the Guest to stream trace events. +pub const CYCLE_TRACKER_FD: u32 = 0x10; + +/// Identifier for a traced code section. +#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum TraceId<'a> { + // Custom name + #[serde(borrow)] + Custom(Cow<'a, str>), + // Opcode execution + Opcode(u8), + // Precompile invocation + Precompile(Address), +} + +impl Display for TraceId<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TraceId::Custom(s) => write!(f, "[ FN] {s}"), + TraceId::Opcode(op) => write!(f, "[ OP] 0x{op:02x}"), + TraceId::Precompile(addr) => write!(f, "[PRE] {addr}"), + } + } +} + +impl<'a> TraceId<'a> { + /// Converts a borrowed `TraceId` into an owned one. + pub fn into_owned(self) -> TraceId<'static> { + match self { + TraceId::Custom(s) => TraceId::Custom(Cow::Owned(s.into_owned())), + TraceId::Opcode(op) => TraceId::Opcode(op), + TraceId::Precompile(addr) => TraceId::Precompile(addr), + } + } +} + +/// Helper trait for ergonomic trace ID construction. +pub trait IntoTraceId<'a> { + fn into_trace_id(self) -> TraceId<'a>; +} + +impl<'a> IntoTraceId<'a> for TraceId<'a> { + fn into_trace_id(self) -> TraceId<'a> { + self + } +} + +impl<'a> IntoTraceId<'a> for &TraceId<'a> { + fn into_trace_id(self) -> TraceId<'a> { + self.clone() + } +} + +impl<'a> IntoTraceId<'a> for &'a str { + fn into_trace_id(self) -> TraceId<'a> { + TraceId::Custom(Cow::Borrowed(self)) + } +} + +impl<'a> IntoTraceId<'a> for String { + fn into_trace_id(self) -> TraceId<'a> { + TraceId::Custom(Cow::Owned(self)) + } +} + +impl<'a> IntoTraceId<'a> for u8 { + fn into_trace_id(self) -> TraceId<'a> { + TraceId::Opcode(self) + } +} + +impl<'a> IntoTraceId<'a> for Address { + fn into_trace_id(self) -> TraceId<'a> { + TraceId::Precompile(self) + } +} + +/// Internal event type for wire protocol. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) enum EventKind { + /// Marks the start of a traced scope (e.g., entering an opcode or function). + Enter, + /// Marks the end of a traced scope that was previously opened with [`EventKind::Enter`]. + Exit, + /// Represents a complete, atomic execution of a scope (Enter + Exit). + Complete, +} + +/// The packet sent over the trace file descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct TraceFdEvent<'a> { + pub kind: EventKind, + #[serde(borrow)] + pub id: TraceId<'a>, + pub cycles: u64, + pub gas: u64, +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 39cb2b3f..72490c3f 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -33,6 +33,9 @@ pub use stateless::{ExecutionWitness, StatelessTrie, UncompressedPublicKey}; pub type EthEvmConfig = reth_evm_ethereum::EthEvmConfig; +#[cfg(feature = "cycle-tracker")] +pub mod cycle_tracker; + /// Serde adapter for [`Block`] that uses RLP encoding for binary serializers. /// /// Human-readable formats (JSON) use `Block`'s default serde. Binary formats (risc0 zkVM) @@ -109,6 +112,9 @@ where #[cfg(all(target_os = "zkvm", target_vendor = "risc0"))] assert!(install_r0vm_crypto()); + #[cfg(all(feature = "cycle-tracker", target_os = "zkvm", target_vendor = "risc0"))] + let config = cycle_tracker::guest::CycleTrackerEvmConfig::new(config); + let output = stateless::stateless_validation_with_trie::( block, signers, witness, chain_spec, config, )?; diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index c5fcf572..92582d21 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -7,6 +7,7 @@ license = { workspace = true } repository = { workspace = true } [features] +cycle-tracker = ["flate2", "reth-evm", "guests/cycle-tracker", "zeth-core/cycle-tracker"] unsafe-pre-merge = ["guests/unsafe-pre-merge", "zeth-core/unsafe-pre-merge"] [dependencies] @@ -16,10 +17,12 @@ anyhow = { workspace = true } blst = { workspace = true } bytemuck = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } +flate2 = { workspace = true, optional = true } guests = { workspace = true } humansize = { workspace = true } reth-chainspec = { workspace = true } reth-ethereum-primitives = { workspace = true } +reth-evm = { workspace = true, optional = true } # Pinned v1 aliases used only for deserializing legacy v1/v2 cache files (bincode-compat # format from reth v1, which uses alloy 1.x types). The resulting v1 block is re-encoded # via RLP and decoded back into the workspace (v2+) block type. diff --git a/crates/host/src/cycle_tracker.rs b/crates/host/src/cycle_tracker.rs new file mode 100644 index 00000000..ca3c187a --- /dev/null +++ b/crates/host/src/cycle_tracker.rs @@ -0,0 +1,127 @@ +// Copyright 2026 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use alloy::consensus::Header; +use reth_chainspec::ChainSpec; +use risc0_zkvm::ExecutorEnvBuilder; +use std::sync::Arc; + +#[cfg(feature = "cycle-tracker")] +pub(crate) use implementation::*; + +#[cfg(not(feature = "cycle-tracker"))] +pub(crate) use noop::*; + +#[cfg(feature = "cycle-tracker")] +mod implementation { + use super::*; + use alloy::primitives::map::AddressMap; + use flate2::{Compression, write::GzEncoder}; + use reth_evm::{ + ConfigureEvm, + revm::{ + bytecode::OpCode, + precompile::{PrecompileSpecId, Precompiles}, + }, + }; + use std::{collections::HashMap, env, fs::File, io::BufWriter}; + use zeth_core::{ + EthEvmConfig, + cycle_tracker::{CYCLE_TRACKER_FD, TraceCollector, TraceEvent, TraceId}, + }; + + const TRACE_FILE_ENV: &str = "TRACE_FILE"; + const DEFAULT_TRACE_FILE: &str = "trace.json.gz"; + + pub(crate) struct HostCycleTracker { + metrics: HashMap>, + precompiles: AddressMap<&'static str>, + } + + /// Manages the collection and saving of execution cycle traces from the zkVM guest. + /// + /// This struct runs on the host and listens to trace events streamed from the guest via a + /// dedicated file descriptor. It aggregates these events and saves them to a file for later + /// analysis. + impl HostCycleTracker { + const UNKNOWN_LABEL: &'static str = "unknown"; + + pub(crate) fn new(chain_spec: Arc, header: &Header) -> Self { + // initialize precompiles map for decoding addresses + let spec_id = EthEvmConfig::new(chain_spec).evm_env(header).unwrap().cfg_env.spec; + let precompiles = Precompiles::new(PrecompileSpecId::from_spec_id(spec_id)).inner(); + + Self { + metrics: HashMap::default(), + precompiles: precompiles.iter().map(|(a, p)| (*a, p.id().name())).collect(), + } + } + + /// Attaches the cycle tracker to the zkVM executor environment. + pub(crate) fn attach<'a>(&'a mut self, env_builder: &mut ExecutorEnvBuilder<'a>) { + let collector = TraceCollector::new(move |event| { + self.process(event); + }); + env_builder.write_fd(CYCLE_TRACKER_FD, collector); + } + + fn process(&mut self, event: TraceEvent) { + let key = match event.id { + TraceId::Custom(s) => s.into_owned(), + TraceId::Opcode(op) => match OpCode::new(op) { + Some(opcode) => opcode.to_string(), + None => Self::UNKNOWN_LABEL.to_string(), + }, + TraceId::Precompile(addr) => match self.precompiles.get(&addr) { + Some(precompile) => precompile.to_string(), + None => Self::UNKNOWN_LABEL.to_string(), + }, + }; + + self.metrics.entry(key).or_default().push((event.cycles, event.gas)); + } + + /// Saves the collected trace metrics to a Gzip-compressed JSON file. + /// + /// The output path is determined by the `TRACE_FILE` environment variable, defaulting to + /// `trace.json.gz` if unset. + pub(crate) fn save(self) -> anyhow::Result<()> { + let path = env::var_os(TRACE_FILE_ENV).unwrap_or(DEFAULT_TRACE_FILE.into()); + let file = File::create(path)?; + let encoder = GzEncoder::new(BufWriter::new(file), Compression::fast()); + serde_json::to_writer(encoder, &self.metrics)?; + + Ok(()) + } + } +} + +#[cfg(not(feature = "cycle-tracker"))] +mod noop { + use super::*; + + pub(crate) struct HostCycleTracker; + + impl HostCycleTracker { + pub(crate) fn new(_: Arc, _: &Header) -> Self { + Self + } + + pub(crate) fn attach(&mut self, _: &mut ExecutorEnvBuilder) {} + + pub(crate) fn save(self) -> anyhow::Result<()> { + Ok(()) + } + } +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 00444efb..4edad789 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -30,6 +30,8 @@ use stateless::{ExecutionWitness, UncompressedPublicKey}; use std::{path::Path, sync::Arc}; use zeth_core::Input; +mod cycle_tracker; + /// Processes Ethereum blocks, including creating inputs, validating, and proving. pub struct BlockProcessor

{ /// The provider for fetching data from the Ethereum network. @@ -142,20 +144,31 @@ impl BlockProcessor

{ opts: ProverOpts, ) -> Result<(Receipt, Digest)> { let (elf, image_id) = self.elf()?; + let chain_spec = self.chain_spec.clone(); // prove in a blocking thread using the default prover - let info = tokio::task::spawn_blocking(move || { - let mut env_builder = ExecutorEnvBuilder::default(); - if let Some(po2) = po2 { - env_builder.segment_limit_po2(po2); - } - let env = env_builder.write(&input)?.build()?; - default_prover().prove_with_opts(env, elf, &opts) + let proof = tokio::task::spawn_blocking(move || { + // initialize Tracker (Real or No-Op based on feature flag) + let mut tracer = cycle_tracker::HostCycleTracker::new(chain_spec, &input.block.header); + + let proof = { + let mut env_builder = ExecutorEnvBuilder::default(); + if let Some(po2) = po2 { + env_builder.segment_limit_po2(po2); + } + tracer.attach(&mut env_builder); + let env = env_builder.write(&input)?.build()?; + default_prover().prove_with_opts(env, elf, &opts)? + }; + // save trace to file + tracer.save().context("failed to save traces")?; + + Ok::<_, anyhow::Error>(proof) }) .await .context("prover task panicked")??; - Ok((info.receipt, image_id)) + Ok((proof.receipt, image_id)) } /// Gets the input from the filesystem cache, or returns None. @@ -205,7 +218,7 @@ pub fn to_zkvm_input_bytes(input: &Input) -> Vec { bytemuck::cast_slice(words.as_slice()).to_vec() } -/// Recovers the signing [`VerifyingKey`] from each transaction's signature. +/// Recovers the signing public key from each transaction's signature. pub fn recover_signers<'a, I>(txs: I) -> Result> where I: IntoIterator, diff --git a/guests/Cargo.toml b/guests/Cargo.toml index d4f86985..25d59f83 100644 --- a/guests/Cargo.toml +++ b/guests/Cargo.toml @@ -10,6 +10,7 @@ repository = { workspace = true } methods = ["stateless-client"] [features] +cycle-tracker = [] unsafe-pre-merge = [] [build-dependencies] diff --git a/guests/build.rs b/guests/build.rs index 0b9a7a42..f25ac458 100644 --- a/guests/build.rs +++ b/guests/build.rs @@ -30,9 +30,13 @@ fn main() { let mut guest_opts = GuestOptionsBuilder::default(); - // pass the unsafe-pre-merge feature through to the guest - #[cfg(feature = "unsafe-pre-merge")] - guest_opts.features(vec!["unsafe-pre-merge".to_string()]); + let features = vec![ + #[cfg(feature = "unsafe-pre-merge")] + "unsafe-pre-merge".to_string(), + #[cfg(feature = "cycle-tracker")] + "cycle-tracker".to_string(), + ]; + guest_opts.features(features); // Use Docker for deterministic builds if RISC0_USE_DOCKER is set. if env::var("RISC0_USE_DOCKER").is_ok() { diff --git a/guests/stateless-client/Cargo.lock b/guests/stateless-client/Cargo.lock index 618daf21..ef0737c5 100644 --- a/guests/stateless-client/Cargo.lock +++ b/guests/stateless-client/Cargo.lock @@ -804,6 +804,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1952,6 +1961,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2000,6 +2018,20 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version 0.4.1", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3001,6 +3033,7 @@ dependencies = [ "cobs", "embedded-io 0.4.0", "embedded-io 0.6.1", + "heapless", "serde", ] @@ -4734,6 +4767,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -5991,6 +6027,7 @@ version = "0.3.0" dependencies = [ "alloy-primitives", "alloy-rlp", + "postcard", "reth-chainspec", "reth-ethereum-primitives", "reth-evm", @@ -5999,8 +6036,10 @@ dependencies = [ "reth-trie-common", "risc0-crypto-evm", "risc0-ethereum-trie", + "risc0-zkvm", "serde", "stateless", + "tracing", "tries", ] diff --git a/guests/stateless-client/Cargo.toml b/guests/stateless-client/Cargo.toml index 4473e052..58df5587 100644 --- a/guests/stateless-client/Cargo.toml +++ b/guests/stateless-client/Cargo.toml @@ -24,6 +24,7 @@ zeth-chainspec = { path = "../../crates/chainspec" } zeth-core = { path = "../../crates/core" } [features] +cycle-tracker = ["zeth-core/cycle-tracker"] unsafe-pre-merge = ["zeth-core/unsafe-pre-merge"] [patch.crates-io] diff --git a/guests/stateless-client/src/lib.rs b/guests/stateless-client/src/lib.rs index 5dd5a531..a1758ef8 100644 --- a/guests/stateless-client/src/lib.rs +++ b/guests/stateless-client/src/lib.rs @@ -22,13 +22,8 @@ pub fn entry(evm_config: EthEvmConfig) { let chain_spec = evm_config.chain_spec(); env::log(&format!("EVM config: {chain_spec}")); - env::log("cycle-tracker-report-start: read_input"); let input: Input = env::read(); - env::log("cycle-tracker-report-end: read_input"); - - env::log("cycle-tracker-report-start: validation"); let block_hash = validate_block(input, evm_config).unwrap(); - env::log("cycle-tracker-report-end: validation"); env::commit_slice(block_hash.as_slice()); } diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100755 index 00000000..5d29f531 --- /dev/null +++ b/scripts/benchmark.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +import argparse, glob, json, os, re, subprocess, sys, urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +_CACHE_RE = re.compile(r"^input_(0x[0-9a-fA-F]+)(?:\.v(\d))?$") + +# Configuration +ETH_RPC_URL = os.environ.get("ETH_RPC_URL", "https://ethereum-rpc.publicnode.com") +CLI_BIN = "./target/release/cli" +CSV_FILE = "block-benchmarks.csv" + + +def find_cached_blocks(): + best = {} # block_hash -> version + for f in glob.glob("cache/input_0x*.json"): + m = _CACHE_RE.match(Path(f).stem) + if not m: + continue + block_hash, ver = m.group(1), int(m.group(2) or 0) + if ver > best.get(block_hash, -1): + best[block_hash] = ver + return list(best) + + +def build(): + print("Building for raw performance (tracing disabled)...") + subprocess.check_call(["cargo", "build", "--release"]) + + +def get_block(block_hash): + payload = { + "jsonrpc": "2.0", + "method": "eth_getBlockByHash", + "params": [block_hash, False], + "id": 1 + } + req = urllib.request.Request( + ETH_RPC_URL, + data=json.dumps(payload).encode('utf-8'), + headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req) as response: + data = json.load(response) + return data.get("result") + + +def parse_metrics(block_hash, output): + # Helper to extract regex matches + def get_val(pattern, default="N/A"): + match = re.search(pattern, output) + return match.group(1) if match else default + + # 1. Execution Time + if time_match := re.search(r"execution time: ([0-9.]+)(ms|s)", output): + val, unit = time_match.groups() + exec_time = f"{float(val) / 1000:.6f}" if unit == "ms" else val + else: + exec_time = "N/A" + + # 2. Cycles & Counts + metrics = { + "total_cycles": get_val(r"(\d+) total cycles"), + "user_cycles": get_val(r"(\d+) user cycles"), + "paging_cycles": get_val(r"(\d+) paging cycles"), + "bigint_cycles": get_val(r"BigInt calls, (\d+) cycles"), + "keccak_calls": get_val(r"(\d+) Keccak calls"), + } + + # 3. Gas & Block Number + try: + block_data = get_block(block_hash) + block_number = int(block_data['number'], 16) + gas_used = int(block_data['gasUsed'], 16) + except Exception: + block_number = "N/A" + gas_used = "N/A" + + return [ + str(block_number), exec_time, metrics['total_cycles'], metrics['user_cycles'], metrics['paging_cycles'], + metrics['bigint_cycles'], metrics['keccak_calls'], str(gas_used) + ] + + +def run_benchmark(block_hash): + print(f"Benchmarking block: {block_hash}") + + my_env = os.environ.copy() + my_env["RUST_LOG"] = "info" + my_env["RISC0_INFO"] = "true" + my_env["RISC0_DEV_MODE"] = "true" + + cmd = [CLI_BIN, "--eth-rpc-url", ETH_RPC_URL, "--block", block_hash, "prove"] + + try: + result = subprocess.run(cmd, env=my_env, capture_output=True, text=True, check=True) + return parse_metrics(block_hash, result.stdout + result.stderr) + except subprocess.CalledProcessError as e: + print(f"Error proving {block_hash}: {e.stderr}", file=sys.stderr) + return None + + +def main(): + parser = argparse.ArgumentParser(description="Run Zeth benchmarks") + parser.add_argument("--jobs", type=int, default=4) + args = parser.parse_args() + + build() + + blocks = find_cached_blocks() + print(f"Benchmarking {len(blocks)} blocks with {args.jobs} jobs...") + + # Write Header + with open(CSV_FILE, "w") as f: + f.write( + "block_number,execution_time,total_cycles,user_cycles,paging_cycles,bigint_cycles,keccak_calls,gas_used\n") + + with ThreadPoolExecutor(max_workers=args.jobs) as executor: + for result in executor.map(run_benchmark, blocks): + if result: + with open(CSV_FILE, "a") as f: + f.write(",".join(result) + "\n") + + print(f"Done. Results saved to {CSV_FILE}") + + +if __name__ == "__main__": + main() diff --git a/scripts/trace.py b/scripts/trace.py new file mode 100755 index 00000000..f4d4e918 --- /dev/null +++ b/scripts/trace.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 + +import argparse, csv, glob, gzip, json, os, re, subprocess, sys, tempfile +from array import array +from concurrent.futures import ThreadPoolExecutor +from collections import defaultdict +from pathlib import Path + +_CACHE_RE = re.compile(r"^input_(0x[0-9a-fA-F]+)(?:\.v(\d))?$") + +# Configuration +ETH_RPC_URL = os.environ.get("ETH_RPC_URL", "https://ethereum-rpc.publicnode.com") +CLI_BIN = "./target/release/cli" +CSV_FILE = "opcode-profile.csv" + + +def find_cached_blocks(): + best = {} # block_hash -> version + for f in glob.glob("cache/input_0x*.json"): + m = _CACHE_RE.match(Path(f).stem) + if not m: + continue + block_hash, ver = m.group(1), int(m.group(2) or 0) + if ver > best.get(block_hash, -1): + best[block_hash] = ver + return list(best) + + +def build(): + print("Building with cycle-tracker enabled...") + subprocess.check_call( + ["cargo", "build", "--release", "--features", "cycle-tracker"] + ) + + +def run_trace(block_hash, output_dir): + # Create a unique path inside the temporary directory + trace_file = os.path.join(output_dir, f"trace_{block_hash}.json.gz") + + print(f"Tracing block: {block_hash}") + + my_env = os.environ.copy() + my_env["RISC0_DEV_MODE"] = "true" + my_env["TRACE_FILE"] = trace_file + + cmd = [CLI_BIN, "--eth-rpc-url", ETH_RPC_URL, "--block", block_hash, "prove"] + + try: + subprocess.run(cmd, env=my_env, capture_output=True, text=True, check=True) + return trace_file + except subprocess.CalledProcessError as e: + print(f"Error proving {block_hash}: {e.stderr}", file=sys.stderr) + return None + + +def analyze_traces(trace_files, output_csv): + print("Analyzing trace data...") + + cycle_data = defaultdict(lambda: array('Q')) + gas_data = defaultdict(lambda: array('Q')) + + # Helper function defined ONCE, outside the loop + def median_sorted(s): + if not s: return 0 + n = len(s) + mid = n // 2 + return (s[mid - 1] + s[mid]) // 2 if n % 2 == 0 else s[mid] + + for i, filename in enumerate(trace_files, 1): + print(f" Loading trace {i}/{len(trace_files)}: {os.path.basename(filename)}") + with gzip.open(filename, "rb") as f: + trace = json.load(f) + + for name, entries in trace.items(): + cycles = cycle_data[name] + gas = gas_data[name] + for c, g in entries: + cycles.append(c) + gas.append(g) + + # Free the large decoded JSON dict before loading the next file + del trace + + with open(output_csv, "w") as f: + writer = csv.writer(f) + header = [ + "name", + "count", + "min cpg", + "median cpg", + "max cpg", + "min cycles", + "median cycles", + "max cycles", + "total cycles", + ] + writer.writerow(header) + + for name in sorted(cycle_data.keys()): # Sort keys for consistent CSV output + cycle_arr = cycle_data[name] + gas_arr = gas_data[name] + + # Calculate CPG (Cycles Per Gas) + cpg_list = sorted(c // g for c, g in zip(cycle_arr, gas_arr) if g > 0) + # Use the raw cycle array for cycle stats + cycle_list = sorted(cycle_arr) + + if cpg_list: + cpg_min, cpg_med, cpg_max = cpg_list[0], median_sorted(cpg_list), cpg_list[-1] + else: + cpg_min, cpg_med, cpg_max = "N/A", "N/A", "N/A" + + writer.writerow( + [ + name, + len(cycle_arr), + cpg_min, + cpg_med, + cpg_max, + cycle_list[0], + median_sorted(cycle_list), + cycle_list[-1], + sum(cycle_arr), + ] + ) + + +def main(): + parser = argparse.ArgumentParser(description="Run Zeth cycle profiling") + parser.add_argument("--jobs", type=int, default=4) + args = parser.parse_args() + + build() + + blocks = find_cached_blocks() + print(f"Profiling {len(blocks)} blocks with {args.jobs} jobs...") + + with tempfile.TemporaryDirectory() as temp_dir: + print(f"Using temporary directory: {temp_dir}") + + generated_files = [] + with ThreadPoolExecutor(max_workers=args.jobs) as executor: + for res in executor.map(lambda h: run_trace(h, temp_dir), blocks): + if res: + generated_files.append(res) + + analyze_traces(generated_files, CSV_FILE) + + print(f"Done. Results saved to {CSV_FILE}") + + +if __name__ == "__main__": + main()