From eeeabac5d99862e20951ececb5745ac5e75e4dd1 Mon Sep 17 00:00:00 2001 From: markosg04 Date: Wed, 25 Mar 2026 14:43:17 -0400 Subject: [PATCH 1/8] chore: workspace scaffolding for modular crates --- Cargo.toml | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 935b71d83..5e490eb70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -217,6 +217,55 @@ expect_used = "deny" [workspace.lints.rust] unused_results = "warn" +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } + +# Pedantic overrides — suppressed because they're too noisy for math-heavy ZK code +missing_errors_doc = "allow" +missing_panics_doc = "allow" +must_use_candidate = "allow" +doc_markdown = "allow" +similar_names = "allow" +too_many_lines = "allow" +module_name_repetitions = "allow" +struct_excessive_bools = "allow" +fn_params_excessive_bools = "allow" +items_after_statements = "allow" +uninlined_format_args = "allow" +return_self_not_must_use = "allow" +default_trait_access = "allow" +match_same_arms = "allow" +manual_let_else = "allow" +used_underscore_binding = "allow" +no_effect_underscore_binding = "allow" +needless_pass_by_value = "allow" +trivially_copy_pass_by_ref = "allow" +redundant_closure_for_method_calls = "allow" +unnecessary_wraps = "allow" +if_not_else = "allow" + +# Numeric/math code — ZK cryptography uses these patterns pervasively +float_cmp = "allow" +many_single_char_names = "allow" +wildcard_imports = "allow" +inline_always = "allow" +checked_conversions = "allow" + +# Cast lints — field arithmetic requires intentional casting +cast_possible_truncation = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_possible_wrap = "allow" +cast_lossless = "allow" + +# Code quality — hard denies to catch AI-generated slop +dbg_macro = "deny" +todo = "deny" +unimplemented = "deny" +print_stdout = "deny" +print_stderr = "deny" +undocumented_unsafe_blocks = "deny" + [workspace.dependencies] # Cryptography and Math ark-bn254 = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout", default-features = false } @@ -235,6 +284,7 @@ sha3 = "0.10.8" blake2 = "0.10" blake3 = { version = "1.5.0" } light-poseidon = "0.4" +digest = "0.10" jolt-optimizations = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } dory = { package = "dory-pcs", version = "0.3.0", features = ["backends", "cache", "disk-persistence"] } From 240e60cc9a85d15e72727378d7774c09f5530e90 Mon Sep 17 00:00:00 2001 From: markosg04 Date: Wed, 25 Mar 2026 14:43:26 -0400 Subject: [PATCH 2/8] feat: add jolt-profiling crate --- Cargo.toml | 1 + crates/jolt-profiling/Cargo.toml | 53 +++++++++ crates/jolt-profiling/README.md | 62 ++++++++++ crates/jolt-profiling/src/flamegraph.rs | 55 +++++++++ crates/jolt-profiling/src/lib.rs | 59 ++++++++++ crates/jolt-profiling/src/memory.rs | 125 ++++++++++++++++++++ crates/jolt-profiling/src/monitor.rs | 99 ++++++++++++++++ crates/jolt-profiling/src/pprof_guard.rs | 144 +++++++++++++++++++++++ crates/jolt-profiling/src/setup.rs | 139 ++++++++++++++++++++++ crates/jolt-profiling/src/units.rs | 55 +++++++++ 10 files changed, 792 insertions(+) create mode 100644 crates/jolt-profiling/Cargo.toml create mode 100644 crates/jolt-profiling/README.md create mode 100644 crates/jolt-profiling/src/flamegraph.rs create mode 100644 crates/jolt-profiling/src/lib.rs create mode 100644 crates/jolt-profiling/src/memory.rs create mode 100644 crates/jolt-profiling/src/monitor.rs create mode 100644 crates/jolt-profiling/src/pprof_guard.rs create mode 100644 crates/jolt-profiling/src/setup.rs create mode 100644 crates/jolt-profiling/src/units.rs diff --git a/Cargo.toml b/Cargo.toml index 5e490eb70..c1638d12c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ keywords = ["SNARK", "cryptography", "proofs"] [workspace] members = [ + "crates/jolt-profiling", "crates/jolt-field", "jolt-core", "tracer", diff --git a/crates/jolt-profiling/Cargo.toml b/crates/jolt-profiling/Cargo.toml new file mode 100644 index 000000000..53a2c8329 --- /dev/null +++ b/crates/jolt-profiling/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "jolt-profiling" +version = "0.1.0" +authors = ["Jolt Contributors"] +edition = "2021" +description = "Profiling and tracing infrastructure for the Jolt proving system" +license = "MIT OR Apache-2.0" +repository = "https://github.com/a16z/jolt" +keywords = ["profiling", "tracing", "performance"] +categories = ["development-tools::profiling"] +publish = false + +[features] +default = [] +monitor = ["dep:sysinfo"] +pprof = ["dep:pprof", "dep:prost"] +allocative = ["dep:inferno", "dep:allocative"] + +[dependencies] +tracing.workspace = true +tracing-chrome.workspace = true +tracing-subscriber.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +memory-stats.workspace = true + +# Optional: system metrics monitoring +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.sysinfo] +version = "0.37" +optional = true + +# Optional: CPU profiling via pprof +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.pprof] +version = "0.15" +features = ["prost-codec", "flamegraph", "frame-pointer"] +optional = true + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.prost] +version = "0.14" +optional = true + +# Optional: heap flamegraphs +[dependencies.inferno] +workspace = true +optional = true + +[dependencies.allocative] +workspace = true +optional = true + +[package.metadata.cargo-machete] +# prost is required by pprof's prost-codec feature but not directly imported +ignored = ["prost"] diff --git a/crates/jolt-profiling/README.md b/crates/jolt-profiling/README.md new file mode 100644 index 000000000..0654a9000 --- /dev/null +++ b/crates/jolt-profiling/README.md @@ -0,0 +1,62 @@ +# jolt-profiling + +Profiling and tracing infrastructure for the Jolt proving system. + +Part of the [Jolt](https://github.com/a16z/jolt) zkVM. + +## Overview + +Provides a unified interface for performance analysis across all Jolt crates. Individual library crates instrument their functions with `#[tracing::instrument]`; the host binary depends on `jolt-profiling` to configure the subscriber that captures those spans. + +## Public API + +### Tracing Setup + +- **`setup_tracing(formats, trace_name)`** — Initializes the global tracing subscriber. Supports console output (`Default`) and Perfetto/Chrome JSON traces (`Chrome`). Returns flush guards that must be kept alive. +- **`TracingFormat`** — Output format enum: `Default` (console), `Chrome` (Perfetto JSON). + +### Memory Profiling + +- **`start_memory_tracing_span(label)` / `end_memory_tracing_span(label)`** — Tracks physical memory deltas across labeled code regions. +- **`report_memory_usage()`** — Logs all collected memory deltas and warns about unclosed spans. +- **`print_current_memory_usage(label)`** — Logs current physical memory at point of call. + +### System Metrics (`monitor` feature) + +- **`MetricsMonitor::start(interval_secs)`** — Spawns a background thread sampling CPU usage, memory, active cores, and thread count. Outputs structured `counters.*` fields for Perfetto postprocessing. + +### CPU Profiling (`pprof` feature) + +- **`pprof_scope!(label)`** — Creates a scoped CPU profiler guard that writes a `.pb` flamegraph on drop. +- **`PprofGuard`** — The underlying guard type (stub when `pprof` feature is off). + +### Heap Flamegraphs (`allocative` feature) + +- **`print_data_structure_heap_usage(label, data)`** — Logs heap size of `Allocative`-instrumented values. +- **`write_flamegraph_svg(flamegraph, path)`** — Renders an `allocative::FlameGraphBuilder` to an SVG file. + +## Feature Flags + +| Flag | Description | +|------|-------------| +| `monitor` | Background system metrics sampling (CPU, memory, cores) | +| `pprof` | Scoped CPU profiling via `pprof` with `.pb` output | +| `allocative` | Heap flamegraph generation from `allocative`-instrumented types | + +## Dependency Position + +``` +tracing ─┐ +tracing-chrome ─┤ +tracing-subscriber ─┼─► jolt-profiling +memory-stats ─┤ +sysinfo (opt) ─┤ +pprof (opt) ─┤ +allocative (opt) ─┘ +``` + +Imported by host binaries and benchmarks. Library crates depend only on `tracing`. + +## License + +MIT OR Apache-2.0 diff --git a/crates/jolt-profiling/src/flamegraph.rs b/crates/jolt-profiling/src/flamegraph.rs new file mode 100644 index 000000000..1ce95a9ca --- /dev/null +++ b/crates/jolt-profiling/src/flamegraph.rs @@ -0,0 +1,55 @@ +//! Heap flamegraph generation from `allocative`-instrumented data structures. + +use std::{fs::File, io::Cursor, path::Path}; + +use allocative::{Allocative, FlameGraphBuilder}; +use inferno::flamegraph::Options; + +use crate::units::{format_memory_size, BYTES_PER_GIB}; + +/// Logs the heap allocation size of an `Allocative`-instrumented value. +pub fn print_data_structure_heap_usage(label: &str, data: &T) { + if tracing::enabled!(tracing::Level::DEBUG) { + let memory_gib = allocative::size_of_unique_allocated_data(data) as f64 / BYTES_PER_GIB; + tracing::debug!( + label = label, + usage = %format_memory_size(memory_gib), + "heap allocation size" + ); + } +} + +/// Renders a [`FlameGraphBuilder`] to an SVG flamegraph file. +/// +/// Uses `inferno` for rendering with MiB units and flame-chart mode. +/// Logs a warning and returns on I/O failure instead of panicking. +pub fn write_flamegraph_svg>(flamegraph: FlameGraphBuilder, path: P) { + let mut opts = Options::default(); + opts.color_diffusion = true; + opts.count_name = String::from("MiB"); + opts.factor = 1.0 / BYTES_PER_GIB * 1024.0; + opts.flame_chart = true; + + let flamegraph_src = flamegraph.finish_and_write_flame_graph(); + let input = Cursor::new(flamegraph_src); + + let output = match File::create(path.as_ref()) { + Ok(f) => f, + Err(e) => { + tracing::warn!( + path = %path.as_ref().display(), + error = %e, + "failed to create flamegraph SVG file" + ); + return; + } + }; + + if let Err(e) = inferno::flamegraph::from_reader(&mut opts, input, output) { + tracing::warn!( + path = %path.as_ref().display(), + error = %e, + "failed to render flamegraph SVG" + ); + } +} diff --git a/crates/jolt-profiling/src/lib.rs b/crates/jolt-profiling/src/lib.rs new file mode 100644 index 000000000..e55dc533c --- /dev/null +++ b/crates/jolt-profiling/src/lib.rs @@ -0,0 +1,59 @@ +//! Profiling and tracing infrastructure for the Jolt proving system. +//! +//! Provides a unified interface for performance analysis across all Jolt crates: +//! +//! - **Tracing subscriber setup** — configures `tracing-chrome` (Perfetto/Chrome JSON) +//! and `tracing-subscriber` (console output) for the host binary. +//! - **Memory profiling** — tracks memory deltas across proving stages via `memory-stats`. +//! - **System metrics monitoring** (`monitor` feature) — background thread sampling +//! CPU usage, memory, active cores, and thread count. Outputs structured counter events +//! compatible with the Perfetto postprocessing script. +//! - **CPU profiling** (`pprof` feature) — scoped `pprof` guards that write `.pb` +//! flamegraph files on drop. +//! - **Heap flamegraphs** (`allocative` feature) — generates SVG flamegraphs from +//! `allocative`-instrumented data structures. +//! +//! # Usage +//! +//! Individual crates add `tracing` as a dependency and instrument their functions with +//! `#[tracing::instrument]`. The host binary (e.g. `jolt-zkvm` CLI) depends on +//! `jolt-profiling` to configure the subscriber that captures those spans. +//! +//! ```no_run +//! use jolt_profiling::{setup_tracing, TracingFormat}; +//! +//! let _guards = setup_tracing( +//! &[TracingFormat::Chrome], +//! "my_benchmark_20260306", +//! ); +//! // All tracing spans from any Jolt crate now flow to Perfetto JSON output. +//! ``` + +pub mod setup; + +#[cfg(not(target_arch = "wasm32"))] +pub mod memory; + +#[cfg(all(not(target_arch = "wasm32"), feature = "monitor"))] +pub mod monitor; + +mod pprof_guard; + +#[cfg(feature = "allocative")] +pub mod flamegraph; + +mod units; + +pub use setup::{setup_tracing, TracingFormat, TracingGuards}; +pub use units::{format_memory_size, BYTES_PER_GIB, BYTES_PER_MIB}; + +#[cfg(not(target_arch = "wasm32"))] +pub use memory::{ + end_memory_tracing_span, print_current_memory_usage, report_memory_usage, + start_memory_tracing_span, +}; + +#[cfg(all(not(target_arch = "wasm32"), feature = "monitor"))] +pub use monitor::MetricsMonitor; + +pub use pprof_guard::PprofGuard; diff --git a/crates/jolt-profiling/src/memory.rs b/crates/jolt-profiling/src/memory.rs new file mode 100644 index 000000000..507893605 --- /dev/null +++ b/crates/jolt-profiling/src/memory.rs @@ -0,0 +1,125 @@ +//! Memory profiling utilities. +//! +//! Tracks physical memory deltas across labeled spans. Call +//! [`start_memory_tracing_span`] before the section and +//! [`end_memory_tracing_span`] after, then [`report_memory_usage`] to +//! log all collected deltas. + +use memory_stats::memory_stats; +use std::{ + collections::BTreeMap, + sync::{LazyLock, Mutex}, +}; + +use crate::units::{format_memory_size, BYTES_PER_GIB}; + +static MEMORY_USAGE_MAP: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); +static MEMORY_DELTA_MAP: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); + +/// Records the current physical memory usage at the start of a labeled span. +/// +/// Logs a warning and returns without recording if memory stats are unavailable. +/// +/// # Panics +/// +/// Panics if a span with the same label is already open (nested spans need distinct labels). +pub fn start_memory_tracing_span(label: &'static str) { + let Some(stats) = memory_stats() else { + tracing::warn!( + span = label, + "memory stats unavailable, skipping span start" + ); + return; + }; + let memory_gib = stats.physical_mem as f64 / BYTES_PER_GIB; + let mut map = MEMORY_USAGE_MAP.lock().unwrap(); + assert_eq!( + map.insert(label, memory_gib), + None, + "duplicate memory span label: {label}" + ); +} + +/// Closes a labeled memory span and records the memory delta (in GiB). +/// +/// Logs a warning and returns without recording if memory stats are unavailable. +/// +/// # Panics +/// +/// Panics if no span with the given label was previously opened. +pub fn end_memory_tracing_span(label: &'static str) { + let Some(stats) = memory_stats() else { + tracing::warn!(span = label, "memory stats unavailable, skipping span end"); + return; + }; + let memory_gib_end = stats.physical_mem as f64 / BYTES_PER_GIB; + let mut memory_usage_map = MEMORY_USAGE_MAP.lock().unwrap(); + let memory_gib_start = memory_usage_map + .remove(label) + .unwrap_or_else(|| panic!("no open memory span: {label}")); + + let delta = memory_gib_end - memory_gib_start; + let mut memory_delta_map = MEMORY_DELTA_MAP.lock().unwrap(); + assert_eq!(memory_delta_map.insert(label, delta), None); +} + +/// Logs all collected memory deltas and warns about any unclosed spans. +pub fn report_memory_usage() { + let memory_usage_map = MEMORY_USAGE_MAP.lock().unwrap(); + for label in memory_usage_map.keys() { + tracing::warn!(span = label, "unclosed memory tracing span"); + } + + let memory_delta_map = MEMORY_DELTA_MAP.lock().unwrap(); + for (label, delta) in memory_delta_map.iter() { + tracing::info!( + span = label, + delta = %format_memory_size(*delta), + "memory delta" + ); + } +} + +/// Logs the current physical memory usage at the point of call. +pub fn print_current_memory_usage(label: &str) { + if tracing::enabled!(tracing::Level::DEBUG) { + if let Some(usage) = memory_stats() { + let memory_gib = usage.physical_mem as f64 / BYTES_PER_GIB; + tracing::debug!( + label = label, + usage = %format_memory_size(memory_gib), + "current memory usage" + ); + } else { + tracing::debug!(label = label, "memory stats unavailable"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn memory_span_start_end_records_delta() { + start_memory_tracing_span("test_span_lifecycle"); + end_memory_tracing_span("test_span_lifecycle"); + let map = MEMORY_DELTA_MAP.lock().unwrap(); + assert!(map.contains_key("test_span_lifecycle")); + } + + #[test] + #[should_panic(expected = "duplicate memory span label")] + fn duplicate_span_label_panics() { + start_memory_tracing_span("test_span_dup"); + start_memory_tracing_span("test_span_dup"); + } + + #[test] + #[should_panic(expected = "no open memory span")] + fn end_without_start_panics() { + end_memory_tracing_span("test_span_nonexistent"); + } +} diff --git a/crates/jolt-profiling/src/monitor.rs b/crates/jolt-profiling/src/monitor.rs new file mode 100644 index 000000000..5f3232dba --- /dev/null +++ b/crates/jolt-profiling/src/monitor.rs @@ -0,0 +1,99 @@ +//! Background system metrics monitor. +//! +//! Spawns a thread that periodically samples CPU usage, memory, active cores, +//! and thread count. Metrics are emitted as `tracing::debug!` events with +//! structured `counters.*` fields, compatible with the Perfetto postprocessing +//! script (`scripts/postprocess_trace.py`). + +use memory_stats::memory_stats; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +use sysinfo::System; + +use crate::units::BYTES_PER_GIB; + +/// Background monitor that samples system metrics at a fixed interval. +/// +/// Drop the monitor to terminate the background thread. The destructor +/// signals the thread and joins it. +#[must_use = "monitor stops when dropped"] +pub struct MetricsMonitor { + handle: Option>, + stop_flag: Arc, +} + +impl MetricsMonitor { + /// Starts the monitor with the given sampling interval (in seconds). + /// + /// Spawns a background thread named `"metrics-monitor"` that logs: + /// - `counters.memory_gib` — physical memory usage + /// - `counters.cpu_percent` — global CPU utilization + /// - `counters.cores_active_avg` — average active cores + /// - `counters.cores_active` — cores with >0.1% usage + /// - `counters.thread_count` — active thread count (Linux only, 0 elsewhere) + pub fn start(interval_secs: f64) -> Self { + let stop_flag = Arc::new(AtomicBool::new(false)); + let stop = stop_flag.clone(); + + let handle = thread::Builder::new() + .name("metrics-monitor".to_string()) + .spawn(move || { + let interval = Duration::from_millis((interval_secs * 1000.0) as u64); + let mut system = System::new_all(); + + thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL); + + while !stop.load(Ordering::Relaxed) { + system.refresh_all(); + + let memory_gib = memory_stats() + .map(|s| s.physical_mem as f64 / BYTES_PER_GIB) + .unwrap_or(0.0); + let cpu_percent = system.global_cpu_usage(); + let cores_active_avg = cpu_percent / 100.0 * (system.cpus().len() as f32); + let active_cores = system + .cpus() + .iter() + .filter(|cpu| cpu.cpu_usage() > 0.1) + .count(); + + #[cfg(target_os = "linux")] + let active_threads = std::fs::read_dir("/proc/self/task") + .map(|entries| entries.count()) + .unwrap_or(0); + + #[cfg(not(target_os = "linux"))] + let active_threads = 0_usize; + + tracing::debug!( + counters.memory_gib = memory_gib, + counters.cpu_percent = cpu_percent, + counters.cores_active_avg = cores_active_avg, + counters.cores_active = active_cores, + counters.thread_count = active_threads, + ); + + thread::sleep(interval); + } + + tracing::info!("MetricsMonitor stopping"); + }) + .expect("Failed to spawn metrics monitor thread"); + + MetricsMonitor { + handle: Some(handle), + stop_flag, + } + } +} + +impl Drop for MetricsMonitor { + fn drop(&mut self) { + self.stop_flag.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} diff --git a/crates/jolt-profiling/src/pprof_guard.rs b/crates/jolt-profiling/src/pprof_guard.rs new file mode 100644 index 000000000..f019d60ec --- /dev/null +++ b/crates/jolt-profiling/src/pprof_guard.rs @@ -0,0 +1,144 @@ +//! Scoped CPU profiler guard for `pprof` integration. +//! +//! Use the [`pprof_scope!`] macro to create a guard that starts a CPU profiler +//! on creation and writes a `.pb` flamegraph file on drop. +//! +//! Requires the `pprof` feature. Without it, the macro expands to `None::`. +//! +//! ```no_run +//! use jolt_profiling::pprof_scope; +//! +//! let _guard = pprof_scope!("my_function"); +//! // ... profiled code ... +//! // guard drops here, writing benchmark-runs/pprof/my_function.pb +//! ``` +//! +//! View with: `go tool pprof -http=:8080 benchmark-runs/pprof/my_function.pb` + +/// Guard that holds a running pprof profiler and writes output on drop. +#[cfg(feature = "pprof")] +pub struct PprofGuard { + guard: pprof::ProfilerGuard<'static>, + label: &'static str, +} + +#[cfg(feature = "pprof")] +impl PprofGuard { + /// Creates a new profiler guard with the given label and sampling frequency. + /// + /// The label determines the output filename: `{PPROF_PREFIX}{label}.pb`. + /// Typically called via the [`pprof_scope!`] macro rather than directly. + pub fn new(label: &'static str, frequency: i32) -> Self { + Self { + guard: pprof::ProfilerGuardBuilder::default() + .frequency(frequency) + .blocklist(&["libc", "libgcc", "pthread", "vdso"]) + .build() + .expect("Failed to initialize profiler"), + label, + } + } +} + +/// Stub type when `pprof` feature is not enabled. +#[cfg(not(feature = "pprof"))] +pub struct PprofGuard; + +#[cfg(feature = "pprof")] +impl Drop for PprofGuard { + fn drop(&mut self) { + use std::io::Write; + + let Ok(report) = self.guard.report().build() else { + tracing::warn!(label = self.label, "failed to build pprof report"); + return; + }; + + let prefix = crate::setup::PPROF_PREFIX + .get() + .map(String::as_str) + .unwrap_or("benchmark-runs/pprof/"); + let filename = format!("{prefix}{}.pb", self.label); + + if let Some(dir) = std::path::Path::new(&filename).parent() { + let _ = std::fs::create_dir_all(dir); + } + + let Ok(mut f) = std::fs::File::create(&filename) else { + tracing::warn!(path = %filename, "failed to create pprof output file"); + return; + }; + + if let Ok(p) = report.pprof() { + use pprof::protos::Message; + let mut buf = Vec::new(); + if p.encode(&mut buf).is_ok() { + if f.write_all(&buf).is_ok() { + tracing::info!(path = %filename, "wrote pprof profile"); + } else { + tracing::warn!(path = %filename, "failed to write pprof data"); + } + } + } + } +} + +/// Creates a scoped CPU profiler guard. +/// +/// With the `pprof` feature enabled, returns `Some(PprofGuard)` that writes a +/// `.pb` file on drop. Without the feature, returns `None::`. +/// +/// When called without arguments, uses `"default"` as the label. +/// +/// Configure via environment variables: +/// - `PPROF_PREFIX` — output directory prefix (default: `"benchmark-runs/pprof/"`) +/// - `PPROF_FREQ` — sampling frequency in Hz (default: 100) +#[macro_export] +macro_rules! pprof_scope { + ($label:expr) => {{ + #[cfg(feature = "pprof")] + { + Some($crate::PprofGuard::new( + $label, + std::env::var("PPROF_FREQ") + .unwrap_or_else(|_| "100".to_string()) + .parse::() + .unwrap_or(100), + )) + } + #[cfg(not(feature = "pprof"))] + None::<$crate::PprofGuard> + }}; + () => { + $crate::pprof_scope!("default") + }; +} + +#[cfg(test)] +mod tests { + #[test] + fn pprof_scope_without_feature_returns_none() { + let guard = pprof_scope!("test_label"); + #[cfg(not(feature = "pprof"))] + assert!(guard.is_none()); + #[cfg(feature = "pprof")] + assert!(guard.is_some()); + } + + #[test] + fn pprof_scope_no_arg_variant() { + let guard = pprof_scope!(); + #[cfg(not(feature = "pprof"))] + assert!(guard.is_none()); + #[cfg(feature = "pprof")] + assert!(guard.is_some()); + } + + #[test] + fn pprof_guard_stub_exists() { + #[cfg(not(feature = "pprof"))] + { + let _guard = super::PprofGuard; + } + } +} diff --git a/crates/jolt-profiling/src/setup.rs b/crates/jolt-profiling/src/setup.rs new file mode 100644 index 000000000..6a16c61da --- /dev/null +++ b/crates/jolt-profiling/src/setup.rs @@ -0,0 +1,139 @@ +//! Tracing subscriber configuration for Perfetto and console output. +//! +//! Call [`setup_tracing`] once at binary startup. The returned [`TracingGuards`] +//! must be held alive for the duration of the program — dropping them flushes +//! and closes trace files. + +use std::any::Any; +use std::sync::OnceLock; + +use tracing_chrome::ChromeLayerBuilder; +use tracing_subscriber::{fmt::format::FmtSpan, prelude::*, EnvFilter}; + +/// Thread-safe storage for the pprof output prefix. +/// +/// Initialized once during [`setup_tracing`] and read by [`PprofGuard`](crate::PprofGuard) +/// on drop. Avoids `std::env::set_var` which is unsound in multi-threaded contexts. +pub(crate) static PPROF_PREFIX: OnceLock = OnceLock::new(); + +/// Output format for tracing subscribers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TracingFormat { + /// Console output with span close events and compact formatting. + Default, + /// Chrome/Perfetto JSON trace file. View at . + Chrome, +} + +/// Opaque container for tracing flush guards. +/// +/// Must be held alive for the duration of profiling. Dropping this flushes +/// all pending trace data and stops background monitors. +#[must_use = "guards must be held alive for the duration of profiling"] +pub struct TracingGuards(#[allow(dead_code)] Vec>); + +/// Initializes the global tracing subscriber with the requested output formats. +/// +/// Always installs a minimal log layer that respects `RUST_LOG`. Additional +/// layers are added based on the `formats` slice. +/// +/// Returns a [`TracingGuards`] value that **must be kept alive** until the +/// program exits. Dropping the guards flushes pending trace data. +/// +/// # Chrome format +/// +/// Writes to `benchmark-runs/perfetto_traces/{trace_name}.json`. +/// Open in [Perfetto UI](https://ui.perfetto.dev/) for timeline visualization. +/// +/// # Panics +/// +/// Panics if called more than once (the global subscriber can only be set once). +pub fn setup_tracing(formats: &[TracingFormat], trace_name: &str) -> TracingGuards { + PPROF_PREFIX.get_or_init(|| { + std::env::var("PPROF_PREFIX") + .unwrap_or_else(|_| format!("benchmark-runs/pprof/{trace_name}_")) + }); + + let mut layers = Vec::new(); + + let log_layer = tracing_subscriber::fmt::layer() + .compact() + .with_target(false) + .with_file(false) + .with_line_number(false) + .with_thread_ids(false) + .with_thread_names(false) + .with_filter(EnvFilter::from_default_env()) + .boxed(); + layers.push(log_layer); + + let mut guards: Vec> = vec![]; + + if formats.contains(&TracingFormat::Default) { + let collector_layer = tracing_subscriber::fmt::layer() + .with_span_events(FmtSpan::CLOSE) + .compact() + .with_target(false) + .with_file(false) + .with_line_number(false) + .with_thread_ids(false) + .with_thread_names(false) + .boxed(); + layers.push(collector_layer); + } + if formats.contains(&TracingFormat::Chrome) { + let trace_file = format!("benchmark-runs/perfetto_traces/{trace_name}.json"); + std::fs::create_dir_all("benchmark-runs/perfetto_traces").ok(); + let (chrome_layer, guard) = ChromeLayerBuilder::new() + .include_args(true) + .file(trace_file) + .build(); + layers.push(chrome_layer.boxed()); + guards.push(Box::new(guard)); + tracing::info!( + "Chrome tracing enabled. Output: benchmark-runs/perfetto_traces/{trace_name}.json" + ); + } + + tracing_subscriber::registry().with(layers).init(); + + #[cfg(all(not(target_arch = "wasm32"), feature = "monitor"))] + guards.push(Box::new({ + tracing::info!( + "Starting MetricsMonitor — run python3 scripts/postprocess_trace.py on the output" + ); + crate::monitor::MetricsMonitor::start( + std::env::var("MONITOR_INTERVAL") + .unwrap_or_else(|_| "0.1".to_string()) + .parse::() + .unwrap_or(0.1), + ) + })); + + TracingGuards(guards) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracing_format_is_copy() { + let fmt = TracingFormat::Chrome; + let fmt2 = fmt; + assert_eq!(fmt, fmt2); + } + + #[test] + fn tracing_format_debug() { + let fmt = TracingFormat::Default; + let s = format!("{fmt:?}"); + assert_eq!(s, "Default"); + } + + #[test] + fn tracing_format_eq() { + assert_eq!(TracingFormat::Chrome, TracingFormat::Chrome); + assert_ne!(TracingFormat::Chrome, TracingFormat::Default); + } +} diff --git a/crates/jolt-profiling/src/units.rs b/crates/jolt-profiling/src/units.rs new file mode 100644 index 000000000..e8b657a77 --- /dev/null +++ b/crates/jolt-profiling/src/units.rs @@ -0,0 +1,55 @@ +//! Memory size unit constants and formatting helpers. + +/// Bytes per gibibyte (GiB, binary, 2^30). +pub const BYTES_PER_GIB: f64 = 1_073_741_824.0; + +/// Bytes per mebibyte (MiB, binary, 2^20). +pub const BYTES_PER_MIB: f64 = 1_048_576.0; + +/// Formats a memory size given in GiB to a human-readable string. +/// +/// Uses GiB for values >= 1.0, otherwise MiB. +pub fn format_memory_size(gib: f64) -> String { + if gib >= 1.0 { + format!("{gib:.2} GiB") + } else { + format!("{:.2} MiB", gib * 1024.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_large_value_uses_gib() { + assert_eq!(format_memory_size(2.5), "2.50 GiB"); + } + + #[test] + fn format_exactly_one_gib() { + assert_eq!(format_memory_size(1.0), "1.00 GiB"); + } + + #[test] + fn format_small_value_uses_mib() { + assert_eq!(format_memory_size(0.5), "512.00 MiB"); + } + + #[test] + fn format_zero() { + assert_eq!(format_memory_size(0.0), "0.00 MiB"); + } + + #[test] + fn format_tiny_value() { + let result = format_memory_size(0.001); + assert!(result.contains("MiB")); + } + + #[test] + fn constants_are_correct() { + assert_eq!(BYTES_PER_GIB, (1u64 << 30) as f64); + assert_eq!(BYTES_PER_MIB, (1u64 << 20) as f64); + } +} From b003fbaf49f6625965d07fdb583eb2a83ceb1bbb Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:04:55 -0400 Subject: [PATCH 3/8] fix: align jolt-profiling with workspace lints after rebase Remove duplicate [workspace.lints.clippy] section introduced by rebase, add workspace lint inheritance to jolt-profiling, bump sysinfo to workspace version, and fix all lint violations. --- Cargo.lock | 15 ++++++++ Cargo.toml | 49 ------------------------ crates/jolt-profiling/Cargo.toml | 5 ++- crates/jolt-profiling/src/memory.rs | 4 ++ crates/jolt-profiling/src/monitor.rs | 1 + crates/jolt-profiling/src/pprof_guard.rs | 1 + crates/jolt-profiling/src/setup.rs | 6 +-- 7 files changed, 28 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb2cca639..cc22d786a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2790,6 +2790,21 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "jolt-profiling" +version = "0.1.0" +dependencies = [ + "allocative", + "inferno 0.12.6", + "memory-stats", + "pprof", + "prost 0.14.3", + "sysinfo", + "tracing", + "tracing-chrome", + "tracing-subscriber", +] + [[package]] name = "jolt-sdk" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c1638d12c..f28c36361 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -218,55 +218,6 @@ expect_used = "deny" [workspace.lints.rust] unused_results = "warn" -[workspace.lints.clippy] -pedantic = { level = "warn", priority = -1 } - -# Pedantic overrides — suppressed because they're too noisy for math-heavy ZK code -missing_errors_doc = "allow" -missing_panics_doc = "allow" -must_use_candidate = "allow" -doc_markdown = "allow" -similar_names = "allow" -too_many_lines = "allow" -module_name_repetitions = "allow" -struct_excessive_bools = "allow" -fn_params_excessive_bools = "allow" -items_after_statements = "allow" -uninlined_format_args = "allow" -return_self_not_must_use = "allow" -default_trait_access = "allow" -match_same_arms = "allow" -manual_let_else = "allow" -used_underscore_binding = "allow" -no_effect_underscore_binding = "allow" -needless_pass_by_value = "allow" -trivially_copy_pass_by_ref = "allow" -redundant_closure_for_method_calls = "allow" -unnecessary_wraps = "allow" -if_not_else = "allow" - -# Numeric/math code — ZK cryptography uses these patterns pervasively -float_cmp = "allow" -many_single_char_names = "allow" -wildcard_imports = "allow" -inline_always = "allow" -checked_conversions = "allow" - -# Cast lints — field arithmetic requires intentional casting -cast_possible_truncation = "allow" -cast_sign_loss = "allow" -cast_precision_loss = "allow" -cast_possible_wrap = "allow" -cast_lossless = "allow" - -# Code quality — hard denies to catch AI-generated slop -dbg_macro = "deny" -todo = "deny" -unimplemented = "deny" -print_stdout = "deny" -print_stderr = "deny" -undocumented_unsafe_blocks = "deny" - [workspace.dependencies] # Cryptography and Math ark-bn254 = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout", default-features = false } diff --git a/crates/jolt-profiling/Cargo.toml b/crates/jolt-profiling/Cargo.toml index 53a2c8329..44495bb02 100644 --- a/crates/jolt-profiling/Cargo.toml +++ b/crates/jolt-profiling/Cargo.toml @@ -10,6 +10,9 @@ keywords = ["profiling", "tracing", "performance"] categories = ["development-tools::profiling"] publish = false +[lints] +workspace = true + [features] default = [] monitor = ["dep:sysinfo"] @@ -26,7 +29,7 @@ memory-stats.workspace = true # Optional: system metrics monitoring [target.'cfg(not(target_arch = "wasm32"))'.dependencies.sysinfo] -version = "0.37" +workspace = true optional = true # Optional: CPU profiling via pprof diff --git a/crates/jolt-profiling/src/memory.rs b/crates/jolt-profiling/src/memory.rs index 507893605..1717278cf 100644 --- a/crates/jolt-profiling/src/memory.rs +++ b/crates/jolt-profiling/src/memory.rs @@ -25,6 +25,7 @@ static MEMORY_DELTA_MAP: LazyLock>> = /// # Panics /// /// Panics if a span with the same label is already open (nested spans need distinct labels). +#[expect(clippy::unwrap_used)] pub fn start_memory_tracing_span(label: &'static str) { let Some(stats) = memory_stats() else { tracing::warn!( @@ -49,6 +50,7 @@ pub fn start_memory_tracing_span(label: &'static str) { /// # Panics /// /// Panics if no span with the given label was previously opened. +#[expect(clippy::unwrap_used, clippy::panic)] pub fn end_memory_tracing_span(label: &'static str) { let Some(stats) = memory_stats() else { tracing::warn!(span = label, "memory stats unavailable, skipping span end"); @@ -66,6 +68,7 @@ pub fn end_memory_tracing_span(label: &'static str) { } /// Logs all collected memory deltas and warns about any unclosed spans. +#[expect(clippy::unwrap_used)] pub fn report_memory_usage() { let memory_usage_map = MEMORY_USAGE_MAP.lock().unwrap(); for label in memory_usage_map.keys() { @@ -99,6 +102,7 @@ pub fn print_current_memory_usage(label: &str) { } #[cfg(test)] +#[expect(clippy::unwrap_used)] mod tests { use super::*; diff --git a/crates/jolt-profiling/src/monitor.rs b/crates/jolt-profiling/src/monitor.rs index 5f3232dba..0bcd1cfdb 100644 --- a/crates/jolt-profiling/src/monitor.rs +++ b/crates/jolt-profiling/src/monitor.rs @@ -33,6 +33,7 @@ impl MetricsMonitor { /// - `counters.cores_active_avg` — average active cores /// - `counters.cores_active` — cores with >0.1% usage /// - `counters.thread_count` — active thread count (Linux only, 0 elsewhere) + #[expect(clippy::expect_used)] pub fn start(interval_secs: f64) -> Self { let stop_flag = Arc::new(AtomicBool::new(false)); let stop = stop_flag.clone(); diff --git a/crates/jolt-profiling/src/pprof_guard.rs b/crates/jolt-profiling/src/pprof_guard.rs index f019d60ec..3f41e0c65 100644 --- a/crates/jolt-profiling/src/pprof_guard.rs +++ b/crates/jolt-profiling/src/pprof_guard.rs @@ -28,6 +28,7 @@ impl PprofGuard { /// /// The label determines the output filename: `{PPROF_PREFIX}{label}.pb`. /// Typically called via the [`pprof_scope!`] macro rather than directly. + #[expect(clippy::expect_used)] pub fn new(label: &'static str, frequency: i32) -> Self { Self { guard: pprof::ProfilerGuardBuilder::default() diff --git a/crates/jolt-profiling/src/setup.rs b/crates/jolt-profiling/src/setup.rs index 6a16c61da..8e7bacfc0 100644 --- a/crates/jolt-profiling/src/setup.rs +++ b/crates/jolt-profiling/src/setup.rs @@ -30,7 +30,7 @@ pub enum TracingFormat { /// Must be held alive for the duration of profiling. Dropping this flushes /// all pending trace data and stops background monitors. #[must_use = "guards must be held alive for the duration of profiling"] -pub struct TracingGuards(#[allow(dead_code)] Vec>); +pub struct TracingGuards(#[expect(dead_code)] Vec>); /// Initializes the global tracing subscriber with the requested output formats. /// @@ -49,7 +49,7 @@ pub struct TracingGuards(#[allow(dead_code)] Vec>); /// /// Panics if called more than once (the global subscriber can only be set once). pub fn setup_tracing(formats: &[TracingFormat], trace_name: &str) -> TracingGuards { - PPROF_PREFIX.get_or_init(|| { + let _ = PPROF_PREFIX.get_or_init(|| { std::env::var("PPROF_PREFIX") .unwrap_or_else(|_| format!("benchmark-runs/pprof/{trace_name}_")) }); @@ -83,7 +83,7 @@ pub fn setup_tracing(formats: &[TracingFormat], trace_name: &str) -> TracingGuar } if formats.contains(&TracingFormat::Chrome) { let trace_file = format!("benchmark-runs/perfetto_traces/{trace_name}.json"); - std::fs::create_dir_all("benchmark-runs/perfetto_traces").ok(); + let _ = std::fs::create_dir_all("benchmark-runs/perfetto_traces"); let (chrome_layer, guard) = ChromeLayerBuilder::new() .include_args(true) .file(trace_file) From 358e85ac2b206406b86104b5e0565f13ed934d3d Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:15:39 -0400 Subject: [PATCH 4/8] fix: address review feedback in jolt-profiling - Use System::new() + refresh_cpu_all() instead of new_all()/refresh_all() - Fix format_memory_size for negative deltas (use abs comparison) - Use BYTES_PER_MIB directly in flamegraph factor - Drop first mutex before acquiring second in end_memory_tracing_span - Clamp monitor interval to 50ms minimum - Re-export flamegraph functions at crate root - Use Acquire/Release ordering for monitor stop flag --- crates/jolt-profiling/src/flamegraph.rs | 4 ++-- crates/jolt-profiling/src/lib.rs | 2 ++ crates/jolt-profiling/src/memory.rs | 9 +++++---- crates/jolt-profiling/src/monitor.rs | 10 +++++----- crates/jolt-profiling/src/units.rs | 2 +- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/jolt-profiling/src/flamegraph.rs b/crates/jolt-profiling/src/flamegraph.rs index 1ce95a9ca..1a38fa3c0 100644 --- a/crates/jolt-profiling/src/flamegraph.rs +++ b/crates/jolt-profiling/src/flamegraph.rs @@ -5,7 +5,7 @@ use std::{fs::File, io::Cursor, path::Path}; use allocative::{Allocative, FlameGraphBuilder}; use inferno::flamegraph::Options; -use crate::units::{format_memory_size, BYTES_PER_GIB}; +use crate::units::{format_memory_size, BYTES_PER_GIB, BYTES_PER_MIB}; /// Logs the heap allocation size of an `Allocative`-instrumented value. pub fn print_data_structure_heap_usage(label: &str, data: &T) { @@ -27,7 +27,7 @@ pub fn write_flamegraph_svg>(flamegraph: FlameGraphBuilder, path: let mut opts = Options::default(); opts.color_diffusion = true; opts.count_name = String::from("MiB"); - opts.factor = 1.0 / BYTES_PER_GIB * 1024.0; + opts.factor = 1.0 / BYTES_PER_MIB; opts.flame_chart = true; let flamegraph_src = flamegraph.finish_and_write_flame_graph(); diff --git a/crates/jolt-profiling/src/lib.rs b/crates/jolt-profiling/src/lib.rs index e55dc533c..83badd47c 100644 --- a/crates/jolt-profiling/src/lib.rs +++ b/crates/jolt-profiling/src/lib.rs @@ -41,6 +41,8 @@ mod pprof_guard; #[cfg(feature = "allocative")] pub mod flamegraph; +#[cfg(feature = "allocative")] +pub use flamegraph::{print_data_structure_heap_usage, write_flamegraph_svg}; mod units; diff --git a/crates/jolt-profiling/src/memory.rs b/crates/jolt-profiling/src/memory.rs index 1717278cf..4ee9b068d 100644 --- a/crates/jolt-profiling/src/memory.rs +++ b/crates/jolt-profiling/src/memory.rs @@ -57,10 +57,11 @@ pub fn end_memory_tracing_span(label: &'static str) { return; }; let memory_gib_end = stats.physical_mem as f64 / BYTES_PER_GIB; - let mut memory_usage_map = MEMORY_USAGE_MAP.lock().unwrap(); - let memory_gib_start = memory_usage_map - .remove(label) - .unwrap_or_else(|| panic!("no open memory span: {label}")); + let memory_gib_start = { + let mut map = MEMORY_USAGE_MAP.lock().unwrap(); + map.remove(label) + .unwrap_or_else(|| panic!("no open memory span: {label}")) + }; let delta = memory_gib_end - memory_gib_start; let mut memory_delta_map = MEMORY_DELTA_MAP.lock().unwrap(); diff --git a/crates/jolt-profiling/src/monitor.rs b/crates/jolt-profiling/src/monitor.rs index 0bcd1cfdb..a7724c063 100644 --- a/crates/jolt-profiling/src/monitor.rs +++ b/crates/jolt-profiling/src/monitor.rs @@ -41,13 +41,13 @@ impl MetricsMonitor { let handle = thread::Builder::new() .name("metrics-monitor".to_string()) .spawn(move || { - let interval = Duration::from_millis((interval_secs * 1000.0) as u64); - let mut system = System::new_all(); + let interval = Duration::from_millis(((interval_secs * 1000.0) as u64).max(50)); + let mut system = System::new(); thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL); - while !stop.load(Ordering::Relaxed) { - system.refresh_all(); + while !stop.load(Ordering::Acquire) { + system.refresh_cpu_all(); let memory_gib = memory_stats() .map(|s| s.physical_mem as f64 / BYTES_PER_GIB) @@ -92,7 +92,7 @@ impl MetricsMonitor { impl Drop for MetricsMonitor { fn drop(&mut self) { - self.stop_flag.store(true, Ordering::Relaxed); + self.stop_flag.store(true, Ordering::Release); if let Some(handle) = self.handle.take() { let _ = handle.join(); } diff --git a/crates/jolt-profiling/src/units.rs b/crates/jolt-profiling/src/units.rs index e8b657a77..b8cf4288d 100644 --- a/crates/jolt-profiling/src/units.rs +++ b/crates/jolt-profiling/src/units.rs @@ -10,7 +10,7 @@ pub const BYTES_PER_MIB: f64 = 1_048_576.0; /// /// Uses GiB for values >= 1.0, otherwise MiB. pub fn format_memory_size(gib: f64) -> String { - if gib >= 1.0 { + if gib.abs() >= 1.0 { format!("{gib:.2} GiB") } else { format!("{:.2} MiB", gib * 1024.0) From 4d10d394c71d91495703714e3e263e649e27aae9 Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:28:10 -0400 Subject: [PATCH 5/8] refactor: move pprof/prost to workspace deps, replace README with doc comments --- Cargo.toml | 2 ++ crates/jolt-profiling/Cargo.toml | 5 ++- crates/jolt-profiling/README.md | 62 -------------------------------- crates/jolt-profiling/src/lib.rs | 13 +++++++ 4 files changed, 17 insertions(+), 65 deletions(-) delete mode 100644 crates/jolt-profiling/README.md diff --git a/Cargo.toml b/Cargo.toml index f28c36361..6a4ca7078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -291,6 +291,8 @@ tracing-chrome = "0.7.1" tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"] } inferno = { version = "0.12.3" } allocative = { git = "https://github.com/facebookexperimental/allocative", rev = "85b773d85d526d068ce94724ff7a7b81203fc95e" } +pprof = { version = "0.15", features = ["prost-codec", "flamegraph", "frame-pointer"] } +prost = "0.14" # Parsing syn = { version = "2", features = ["full"] } diff --git a/crates/jolt-profiling/Cargo.toml b/crates/jolt-profiling/Cargo.toml index 44495bb02..62a08cdb9 100644 --- a/crates/jolt-profiling/Cargo.toml +++ b/crates/jolt-profiling/Cargo.toml @@ -34,12 +34,11 @@ optional = true # Optional: CPU profiling via pprof [target.'cfg(not(target_arch = "wasm32"))'.dependencies.pprof] -version = "0.15" -features = ["prost-codec", "flamegraph", "frame-pointer"] +workspace = true optional = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies.prost] -version = "0.14" +workspace = true optional = true # Optional: heap flamegraphs diff --git a/crates/jolt-profiling/README.md b/crates/jolt-profiling/README.md deleted file mode 100644 index 0654a9000..000000000 --- a/crates/jolt-profiling/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# jolt-profiling - -Profiling and tracing infrastructure for the Jolt proving system. - -Part of the [Jolt](https://github.com/a16z/jolt) zkVM. - -## Overview - -Provides a unified interface for performance analysis across all Jolt crates. Individual library crates instrument their functions with `#[tracing::instrument]`; the host binary depends on `jolt-profiling` to configure the subscriber that captures those spans. - -## Public API - -### Tracing Setup - -- **`setup_tracing(formats, trace_name)`** — Initializes the global tracing subscriber. Supports console output (`Default`) and Perfetto/Chrome JSON traces (`Chrome`). Returns flush guards that must be kept alive. -- **`TracingFormat`** — Output format enum: `Default` (console), `Chrome` (Perfetto JSON). - -### Memory Profiling - -- **`start_memory_tracing_span(label)` / `end_memory_tracing_span(label)`** — Tracks physical memory deltas across labeled code regions. -- **`report_memory_usage()`** — Logs all collected memory deltas and warns about unclosed spans. -- **`print_current_memory_usage(label)`** — Logs current physical memory at point of call. - -### System Metrics (`monitor` feature) - -- **`MetricsMonitor::start(interval_secs)`** — Spawns a background thread sampling CPU usage, memory, active cores, and thread count. Outputs structured `counters.*` fields for Perfetto postprocessing. - -### CPU Profiling (`pprof` feature) - -- **`pprof_scope!(label)`** — Creates a scoped CPU profiler guard that writes a `.pb` flamegraph on drop. -- **`PprofGuard`** — The underlying guard type (stub when `pprof` feature is off). - -### Heap Flamegraphs (`allocative` feature) - -- **`print_data_structure_heap_usage(label, data)`** — Logs heap size of `Allocative`-instrumented values. -- **`write_flamegraph_svg(flamegraph, path)`** — Renders an `allocative::FlameGraphBuilder` to an SVG file. - -## Feature Flags - -| Flag | Description | -|------|-------------| -| `monitor` | Background system metrics sampling (CPU, memory, cores) | -| `pprof` | Scoped CPU profiling via `pprof` with `.pb` output | -| `allocative` | Heap flamegraph generation from `allocative`-instrumented types | - -## Dependency Position - -``` -tracing ─┐ -tracing-chrome ─┤ -tracing-subscriber ─┼─► jolt-profiling -memory-stats ─┤ -sysinfo (opt) ─┤ -pprof (opt) ─┤ -allocative (opt) ─┘ -``` - -Imported by host binaries and benchmarks. Library crates depend only on `tracing`. - -## License - -MIT OR Apache-2.0 diff --git a/crates/jolt-profiling/src/lib.rs b/crates/jolt-profiling/src/lib.rs index 83badd47c..0151935ab 100644 --- a/crates/jolt-profiling/src/lib.rs +++ b/crates/jolt-profiling/src/lib.rs @@ -28,6 +28,19 @@ //! ); //! // All tracing spans from any Jolt crate now flow to Perfetto JSON output. //! ``` +//! +//! # Feature Flags +//! +//! | Flag | Description | +//! |------|-------------| +//! | `monitor` | Background system metrics sampling (CPU, memory, cores) | +//! | `pprof` | Scoped CPU profiling via `pprof` with `.pb` output | +//! | `allocative` | Heap flamegraph generation from `allocative`-instrumented types | +//! +//! # Dependency Position +//! +//! This is a leaf crate — imported by host binaries and benchmarks. +//! Library crates depend only on `tracing` for instrumentation. pub mod setup; From ee50ab62d67a3b9312f0b816833268ca5b7c0900 Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:31:16 -0400 Subject: [PATCH 6/8] style: flatten Cargo.toml dependency sections --- crates/jolt-profiling/Cargo.toml | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/crates/jolt-profiling/Cargo.toml b/crates/jolt-profiling/Cargo.toml index 62a08cdb9..d33348fab 100644 --- a/crates/jolt-profiling/Cargo.toml +++ b/crates/jolt-profiling/Cargo.toml @@ -23,32 +23,14 @@ allocative = ["dep:inferno", "dep:allocative"] tracing.workspace = true tracing-chrome.workspace = true tracing-subscriber.workspace = true +inferno = { workspace = true, optional = true } +allocative = { workspace = true, optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] memory-stats.workspace = true - -# Optional: system metrics monitoring -[target.'cfg(not(target_arch = "wasm32"))'.dependencies.sysinfo] -workspace = true -optional = true - -# Optional: CPU profiling via pprof -[target.'cfg(not(target_arch = "wasm32"))'.dependencies.pprof] -workspace = true -optional = true - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies.prost] -workspace = true -optional = true - -# Optional: heap flamegraphs -[dependencies.inferno] -workspace = true -optional = true - -[dependencies.allocative] -workspace = true -optional = true +sysinfo = { workspace = true, optional = true } +pprof = { workspace = true, optional = true } +prost = { workspace = true, optional = true } [package.metadata.cargo-machete] # prost is required by pprof's prost-codec feature but not directly imported From 9bb52947331f52b84857688f4c8e6c6cd09bec99 Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:39:42 -0400 Subject: [PATCH 7/8] feat: add wasm32 no-op stubs for memory and monitor APIs Downstream crates no longer need #[cfg] gates at every call site. --- crates/jolt-profiling/src/lib.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/jolt-profiling/src/lib.rs b/crates/jolt-profiling/src/lib.rs index 0151935ab..f13a74e7e 100644 --- a/crates/jolt-profiling/src/lib.rs +++ b/crates/jolt-profiling/src/lib.rs @@ -68,7 +68,30 @@ pub use memory::{ start_memory_tracing_span, }; +#[cfg(target_arch = "wasm32")] +pub fn start_memory_tracing_span(_label: &'static str) {} + +#[cfg(target_arch = "wasm32")] +pub fn end_memory_tracing_span(_label: &'static str) {} + +#[cfg(target_arch = "wasm32")] +pub fn report_memory_usage() {} + +#[cfg(target_arch = "wasm32")] +pub fn print_current_memory_usage(_label: &str) {} + #[cfg(all(not(target_arch = "wasm32"), feature = "monitor"))] pub use monitor::MetricsMonitor; +#[cfg(all(target_arch = "wasm32", feature = "monitor"))] +#[must_use = "monitor stops when dropped"] +pub struct MetricsMonitor; + +#[cfg(all(target_arch = "wasm32", feature = "monitor"))] +impl MetricsMonitor { + pub fn start(_interval_secs: f64) -> Self { + Self + } +} + pub use pprof_guard::PprofGuard; From ab2bee63402a2b32f269baa8d8727049da8ba0fb Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:48:10 -0400 Subject: [PATCH 8/8] refactor: replace unwrap/expect with graceful error handling Mutex locks recover from poisoning via into_inner. PprofGuard::new and MetricsMonitor::start log warnings on failure instead of panicking. --- crates/jolt-profiling/src/memory.rs | 54 ++++++++++-------------- crates/jolt-profiling/src/monitor.rs | 19 +++++---- crates/jolt-profiling/src/pprof_guard.rs | 24 ++++++----- 3 files changed, 47 insertions(+), 50 deletions(-) diff --git a/crates/jolt-profiling/src/memory.rs b/crates/jolt-profiling/src/memory.rs index 4ee9b068d..4970eab02 100644 --- a/crates/jolt-profiling/src/memory.rs +++ b/crates/jolt-profiling/src/memory.rs @@ -20,12 +20,8 @@ static MEMORY_DELTA_MAP: LazyLock>> = /// Records the current physical memory usage at the start of a labeled span. /// -/// Logs a warning and returns without recording if memory stats are unavailable. -/// -/// # Panics -/// -/// Panics if a span with the same label is already open (nested spans need distinct labels). -#[expect(clippy::unwrap_used)] +/// Logs a warning and returns without recording if memory stats are unavailable +/// or if a span with the same label is already open. pub fn start_memory_tracing_span(label: &'static str) { let Some(stats) = memory_stats() else { tracing::warn!( @@ -35,48 +31,46 @@ pub fn start_memory_tracing_span(label: &'static str) { return; }; let memory_gib = stats.physical_mem as f64 / BYTES_PER_GIB; - let mut map = MEMORY_USAGE_MAP.lock().unwrap(); - assert_eq!( - map.insert(label, memory_gib), - None, - "duplicate memory span label: {label}" - ); + let mut map = MEMORY_USAGE_MAP.lock().unwrap_or_else(|e| e.into_inner()); + if map.insert(label, memory_gib).is_some() { + tracing::warn!(span = label, "duplicate memory span label, overwriting"); + } } /// Closes a labeled memory span and records the memory delta (in GiB). /// -/// Logs a warning and returns without recording if memory stats are unavailable. -/// -/// # Panics -/// -/// Panics if no span with the given label was previously opened. -#[expect(clippy::unwrap_used, clippy::panic)] +/// Logs a warning and returns without recording if memory stats are unavailable +/// or if no matching span was opened. pub fn end_memory_tracing_span(label: &'static str) { let Some(stats) = memory_stats() else { tracing::warn!(span = label, "memory stats unavailable, skipping span end"); return; }; let memory_gib_end = stats.physical_mem as f64 / BYTES_PER_GIB; - let memory_gib_start = { - let mut map = MEMORY_USAGE_MAP.lock().unwrap(); - map.remove(label) - .unwrap_or_else(|| panic!("no open memory span: {label}")) + let Some(memory_gib_start) = MEMORY_USAGE_MAP + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(label) + else { + tracing::warn!(span = label, "no open memory span, skipping span end"); + return; }; let delta = memory_gib_end - memory_gib_start; - let mut memory_delta_map = MEMORY_DELTA_MAP.lock().unwrap(); - assert_eq!(memory_delta_map.insert(label, delta), None); + let _ = MEMORY_DELTA_MAP + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(label, delta); } /// Logs all collected memory deltas and warns about any unclosed spans. -#[expect(clippy::unwrap_used)] pub fn report_memory_usage() { - let memory_usage_map = MEMORY_USAGE_MAP.lock().unwrap(); + let memory_usage_map = MEMORY_USAGE_MAP.lock().unwrap_or_else(|e| e.into_inner()); for label in memory_usage_map.keys() { tracing::warn!(span = label, "unclosed memory tracing span"); } - let memory_delta_map = MEMORY_DELTA_MAP.lock().unwrap(); + let memory_delta_map = MEMORY_DELTA_MAP.lock().unwrap_or_else(|e| e.into_inner()); for (label, delta) in memory_delta_map.iter() { tracing::info!( span = label, @@ -116,15 +110,13 @@ mod tests { } #[test] - #[should_panic(expected = "duplicate memory span label")] - fn duplicate_span_label_panics() { + fn duplicate_span_warns_without_panic() { start_memory_tracing_span("test_span_dup"); start_memory_tracing_span("test_span_dup"); } #[test] - #[should_panic(expected = "no open memory span")] - fn end_without_start_panics() { + fn end_without_start_warns_without_panic() { end_memory_tracing_span("test_span_nonexistent"); } } diff --git a/crates/jolt-profiling/src/monitor.rs b/crates/jolt-profiling/src/monitor.rs index a7724c063..f021dee60 100644 --- a/crates/jolt-profiling/src/monitor.rs +++ b/crates/jolt-profiling/src/monitor.rs @@ -33,12 +33,11 @@ impl MetricsMonitor { /// - `counters.cores_active_avg` — average active cores /// - `counters.cores_active` — cores with >0.1% usage /// - `counters.thread_count` — active thread count (Linux only, 0 elsewhere) - #[expect(clippy::expect_used)] pub fn start(interval_secs: f64) -> Self { let stop_flag = Arc::new(AtomicBool::new(false)); let stop = stop_flag.clone(); - let handle = thread::Builder::new() + let spawn_result = thread::Builder::new() .name("metrics-monitor".to_string()) .spawn(move || { let interval = Duration::from_millis(((interval_secs * 1000.0) as u64).max(50)); @@ -80,13 +79,17 @@ impl MetricsMonitor { } tracing::info!("MetricsMonitor stopping"); - }) - .expect("Failed to spawn metrics monitor thread"); + }); - MetricsMonitor { - handle: Some(handle), - stop_flag, - } + let handle = match spawn_result { + Ok(h) => Some(h), + Err(e) => { + tracing::warn!(error = %e, "failed to spawn metrics monitor thread"); + None + } + }; + + MetricsMonitor { handle, stop_flag } } } diff --git a/crates/jolt-profiling/src/pprof_guard.rs b/crates/jolt-profiling/src/pprof_guard.rs index 3f41e0c65..376c2d417 100644 --- a/crates/jolt-profiling/src/pprof_guard.rs +++ b/crates/jolt-profiling/src/pprof_guard.rs @@ -28,15 +28,17 @@ impl PprofGuard { /// /// The label determines the output filename: `{PPROF_PREFIX}{label}.pb`. /// Typically called via the [`pprof_scope!`] macro rather than directly. - #[expect(clippy::expect_used)] - pub fn new(label: &'static str, frequency: i32) -> Self { - Self { - guard: pprof::ProfilerGuardBuilder::default() - .frequency(frequency) - .blocklist(&["libc", "libgcc", "pthread", "vdso"]) - .build() - .expect("Failed to initialize profiler"), - label, + pub fn new(label: &'static str, frequency: i32) -> Option { + match pprof::ProfilerGuardBuilder::default() + .frequency(frequency) + .blocklist(&["libc", "libgcc", "pthread", "vdso"]) + .build() + { + Ok(guard) => Some(Self { guard, label }), + Err(e) => { + tracing::warn!(label = label, error = %e, "failed to initialize profiler"); + None + } } } } @@ -99,13 +101,13 @@ macro_rules! pprof_scope { ($label:expr) => {{ #[cfg(feature = "pprof")] { - Some($crate::PprofGuard::new( + $crate::PprofGuard::new( $label, std::env::var("PPROF_FREQ") .unwrap_or_else(|_| "100".to_string()) .parse::() .unwrap_or(100), - )) + ) } #[cfg(not(feature = "pprof"))] None::<$crate::PprofGuard>