Skip to content

Commit 1990910

Browse files
avi-starkwareclaude
andcommitted
add run_with_libfunc_profile and AotWithProgram
AotContractExecutor::run_with_libfunc_profile wraps run() with the per-call trace-id and lock bookkeeping the with-libfunc-profiling runtime needs, draining the collected Profile to a callback on success. AotWithProgram pairs a compiled executor with the Sierra program it was built from and exposes run()/run_with_profile() in the same shape as AotContractExecutor::run, so callers can swap between them without changing call sites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b2a6cf8 commit 1990910

4 files changed

Lines changed: 267 additions & 2 deletions

File tree

binaries/starknet-native-compile/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ edition.workspace = true
77
license.workspace = true
88
repository.workspace = true
99

10+
[features]
11+
# Builds an instrumented compiler whose emitted `.so` carries the libfunc-profiling
12+
# symbols that `cairo_native::AotContractExecutor::run_with_libfunc_profile` looks up.
13+
# Install with `cargo install ... --features with-libfunc-profiling` to profile native
14+
# execution; a binary built without it produces `.so`s with no profiler symbol.
15+
with-libfunc-profiling = ["cairo-native/with-libfunc-profiling"]
16+
1017
[dependencies]
1118
anyhow.workspace = true
1219
cairo-lang-sierra.workspace = true

src/executor.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
66
#[cfg(feature = "sierra-emu")]
77
pub use self::emu_contract_executor::EmuContractExecutor;
8+
#[cfg(feature = "with-libfunc-profiling")]
9+
pub use self::libfunc_profile::AotWithProgram;
810
pub use self::{aot::AotNativeExecutor, contract::AotContractExecutor, jit::JitNativeExecutor};
911
use crate::{
1012
arch::{AbiArgument, ValueWithInfoWrapper},
@@ -23,6 +25,11 @@ use crate::{
2325
values::Value,
2426
};
2527
use bumpalo::Bump;
28+
// Profiling and sierra-emu consumers (e.g. blockifier) only ever hold the Sierra
29+
// program as a shared handle. Export the `Arc` alias so they can name it without
30+
// taking a direct `cairo-lang-sierra` dependency.
31+
#[cfg(any(feature = "sierra-emu", feature = "with-libfunc-profiling"))]
32+
pub type ArcProgram = std::sync::Arc<cairo_lang_sierra::program::Program>;
2633
use cairo_lang_sierra::{
2734
extensions::{
2835
circuit::CircuitTypeConcrete,
@@ -44,6 +51,8 @@ mod contract;
4451
#[cfg(feature = "sierra-emu")]
4552
mod emu_contract_executor;
4653
mod jit;
54+
#[cfg(feature = "with-libfunc-profiling")]
55+
mod libfunc_profile;
4756

4857
#[cfg(target_arch = "aarch64")]
4958
global_asm!(include_str!("arch/aarch64.s"));

src/executor/libfunc_profile.rs

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
//! Profiling-instrumented run wrapper around [`AotContractExecutor::run`].
2+
//!
3+
//! Available under the `with-libfunc-profiling` feature (gated at the `mod`
4+
//! declaration in `src/executor.rs`).
5+
6+
use std::cell::Cell;
7+
use std::sync::atomic::{AtomicU64, Ordering};
8+
use std::sync::{Arc, Mutex};
9+
10+
use cairo_lang_sierra::program::Program;
11+
use starknet_types_core::felt::Felt;
12+
13+
use crate::error::{Error, Result};
14+
use crate::execution_result::ContractExecutionResult;
15+
use crate::executor::{AotContractExecutor, ArcProgram};
16+
use crate::metadata::profiler::{Profile, ProfilerBinding, ProfilerImpl, LIBFUNC_PROFILE};
17+
use crate::starknet::StarknetSyscallHandler;
18+
use crate::utils::BuiltinCosts;
19+
20+
/// An AOT executor paired with the Sierra program it was compiled from. The program is
21+
/// needed to resolve libfunc profiling samples against the program's declarations, so
22+
/// pairing the two spares callers from keeping their own copy around.
23+
#[derive(Debug)]
24+
pub struct AotWithProgram {
25+
pub executor: AotContractExecutor,
26+
pub program: ArcProgram,
27+
}
28+
29+
impl AotWithProgram {
30+
/// Run the contract entry point identified by `selector`, discarding the profile.
31+
///
32+
/// Mirrors [`AotContractExecutor::run`] so the executor types are interchangeable at
33+
/// the call site. The run is still routed through the profiling bookkeeping: the
34+
/// executor's shared library is compiled with profiling instrumentation, and running
35+
/// it without a live profile slot makes every statement log a missing-profiler
36+
/// error. Use [`Self::run_with_profile`] to capture the profile instead.
37+
pub fn run(
38+
&self,
39+
selector: Felt,
40+
args: &[Felt],
41+
gas: u64,
42+
builtin_costs: Option<BuiltinCosts>,
43+
syscall_handler: impl StarknetSyscallHandler,
44+
) -> Result<ContractExecutionResult> {
45+
self.executor.run_with_libfunc_profile(
46+
&self.program,
47+
selector,
48+
args,
49+
gas,
50+
builtin_costs,
51+
syscall_handler,
52+
|_profile| {},
53+
)
54+
}
55+
56+
/// Like [`Self::run`] but hands the captured libfunc profile -- together with the
57+
/// program this executor was paired with -- to `on_profile` after the call returns
58+
/// successfully. The program is included so callers don't have to keep their own
59+
/// copy around just to resolve libfunc samples.
60+
pub fn run_with_profile<H, F>(
61+
&self,
62+
selector: Felt,
63+
args: &[Felt],
64+
gas: u64,
65+
builtin_costs: Option<BuiltinCosts>,
66+
syscall_handler: H,
67+
on_profile: F,
68+
) -> Result<ContractExecutionResult>
69+
where
70+
H: StarknetSyscallHandler,
71+
F: FnOnce(Profile, ArcProgram),
72+
{
73+
let program_for_cb = Arc::clone(&self.program);
74+
self.executor.run_with_libfunc_profile(
75+
&self.program,
76+
selector,
77+
args,
78+
gas,
79+
builtin_costs,
80+
syscall_handler,
81+
move |profile| on_profile(profile, program_for_cb),
82+
)
83+
}
84+
}
85+
86+
/// Process-wide lock that serializes *top-level* profiled runs across threads. The
87+
/// profiler hot-swaps a process-global symbol (`cairo_native__profiler__profile_id`);
88+
/// a concurrent thread would race on that write and on the [`LIBFUNC_PROFILE`] slot
89+
/// bookkeeping. It is acquired only by the outermost profiled frame on each thread
90+
/// (see [`PROFILE_DEPTH`]); nested same-thread calls -- a profiled contract invoking
91+
/// another contract -- re-enter without re-locking, which would otherwise deadlock
92+
/// this non-reentrant mutex.
93+
static PROFILE_LOCK: Mutex<()> = Mutex::new(());
94+
95+
thread_local! {
96+
/// Nesting depth of profiled runs on the current thread. Only the outermost frame
97+
/// (depth 0) takes [`PROFILE_LOCK`]; deeper frames rely on the lock the outer frame
98+
/// already holds. The per-call `old_trace_id` save/restore keeps the global trace-id
99+
/// symbol correct across nesting without any additional locking.
100+
static PROFILE_DEPTH: Cell<usize> = const { Cell::new(0) };
101+
}
102+
103+
impl AotContractExecutor {
104+
/// Run the entrypoint with libfunc-level profiling instrumentation.
105+
///
106+
/// Wraps [`AotContractExecutor::run`] with the bookkeeping the
107+
/// `with-libfunc-profiling` runtime needs:
108+
///
109+
/// 1. Acquires [`PROFILE_LOCK`] so concurrent profile calls serialize on the
110+
/// global trace-id symbol. The lock is recovered if poisoned.
111+
/// 2. Looks up the executor's `cairo_native__profiler__profile_id` symbol. If
112+
/// absent (the .so was compiled without profiling instrumentation) the call
113+
/// returns an error before touching any global state.
114+
/// 3. Allocates a unique trace ID and inserts an empty `ProfilerImpl` slot in
115+
/// [`LIBFUNC_PROFILE`]; points the profile-id symbol at the new ID, saving
116+
/// the previous value.
117+
/// 4. Calls `run`. Per-statement samples accumulate in the slot via the runtime
118+
/// `push_stmt` callback.
119+
/// 5. Drains the slot. On success (and only on success) hands the resulting
120+
/// [`Profile`] to `on_profile`; on failure the callback is not invoked
121+
/// (partial profiles aren't meaningful).
122+
/// 6. A [`ProfilerGuard`] restores the previous trace ID and clears the slot on
123+
/// both the success and unwind paths.
124+
///
125+
/// `program` must be the Sierra program this executor was compiled from; it's used
126+
/// by `get_profile` to map runtime libfunc IDs back to declarations.
127+
#[allow(clippy::too_many_arguments)]
128+
pub fn run_with_libfunc_profile<H, F>(
129+
&self,
130+
program: &Arc<Program>,
131+
selector: Felt,
132+
args: &[Felt],
133+
gas: u64,
134+
builtin_costs: Option<BuiltinCosts>,
135+
syscall_handler: H,
136+
on_profile: F,
137+
) -> Result<ContractExecutionResult>
138+
where
139+
H: StarknetSyscallHandler,
140+
F: FnOnce(Profile),
141+
{
142+
// Acquire the cross-thread lock only at the outermost profiled frame on this
143+
// thread. A profiled contract that calls another contract re-enters this
144+
// function on the same thread; re-locking the non-reentrant `PROFILE_LOCK`
145+
// there would self-deadlock, so nested frames inherit the outer frame's lock.
146+
// Recover from a poisoned lock -- it only gates access to the global trace-id
147+
// symbol, on which we hold no data invariants.
148+
let _profile_lock = PROFILE_DEPTH
149+
.with(|depth| depth.get() == 0)
150+
.then(|| PROFILE_LOCK.lock().unwrap_or_else(|e| e.into_inner()));
151+
PROFILE_DEPTH.with(|depth| depth.set(depth.get() + 1));
152+
let _depth_guard = ProfileDepthGuard;
153+
154+
// Look up the profile-id symbol before touching any global state. If the
155+
// executor wasn't compiled with libfunc-profiling instrumentation, the
156+
// symbol is absent -- return a typed error rather than panicking.
157+
let trace_id_ptr = self
158+
.find_symbol_ptr(ProfilerBinding::ProfileId.symbol())
159+
.ok_or_else(|| {
160+
Error::UnexpectedValue(format!(
161+
"AOT executor missing libfunc-profiling symbol `{}`; \
162+
was the program compiled with libfunc-profiling enabled?",
163+
ProfilerBinding::ProfileId.symbol()
164+
))
165+
})?
166+
.cast::<u64>();
167+
168+
static COUNTER: AtomicU64 = AtomicU64::new(0);
169+
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
170+
171+
LIBFUNC_PROFILE
172+
.lock()
173+
.unwrap_or_else(|e| e.into_inner())
174+
.insert(counter, ProfilerImpl::new());
175+
176+
// SAFETY: the pointer targets a memref-global emitted into the executor's
177+
// shared library; the executor outlives the call. `PROFILE_LOCK` serializes
178+
// us against any other writer, and the JIT/AOT code reads through the same
179+
// address. Reads/writes are aligned `u64`s.
180+
let old_trace_id = unsafe { *trace_id_ptr };
181+
unsafe {
182+
*trace_id_ptr = counter;
183+
}
184+
185+
let _guard = ProfilerGuard {
186+
trace_id_ptr,
187+
old_trace_id,
188+
counter,
189+
};
190+
191+
let result = self.run(selector, args, gas, builtin_costs, syscall_handler);
192+
193+
// Drain the slot. `ProfilerGuard::drop` would also remove it; doing it here
194+
// means we hold the lock for the shortest time and can hand the profile to
195+
// the callback. Tolerate a poisoned mutex (we'd lose the profile, not state).
196+
let drained = LIBFUNC_PROFILE
197+
.lock()
198+
.unwrap_or_else(|e| e.into_inner())
199+
.remove(&counter);
200+
201+
// Only call the user's callback when `run` succeeded -- a partial profile
202+
// captured against an aborted execution wouldn't be meaningful.
203+
if let (Some(profiler), Ok(_)) = (drained, &result) {
204+
on_profile(profiler.get_profile(program));
205+
}
206+
207+
result
208+
}
209+
}
210+
211+
/// RAII cleanup for the profiler globals. Restores `*trace_id_ptr` on success or
212+
/// unwind. The [`LIBFUNC_PROFILE`] slot at `counter` is normally drained on the
213+
/// success path; this guard removes it if it's still occupied (panic case).
214+
struct ProfilerGuard {
215+
trace_id_ptr: *mut u64,
216+
old_trace_id: u64,
217+
counter: u64,
218+
}
219+
220+
impl Drop for ProfilerGuard {
221+
fn drop(&mut self) {
222+
// SAFETY: same provenance as the construction site. `PROFILE_LOCK` is held
223+
// by the enclosing scope (still in flight while we drop) so no other thread
224+
// races us.
225+
unsafe {
226+
*self.trace_id_ptr = self.old_trace_id;
227+
}
228+
// Tolerate a poisoned mutex silently -- Drop must not panic. Slot leak on
229+
// poison is intentional and matches the behavior of other Drop impls in
230+
// this crate; the alternative (panic in Drop) is worse.
231+
if let Ok(mut profile) = LIBFUNC_PROFILE.lock() {
232+
profile.remove(&self.counter);
233+
}
234+
}
235+
}
236+
237+
/// Decrements [`PROFILE_DEPTH`] on every exit path (including unwind) so the
238+
/// outermost-frame lock bookkeeping stays correct even if the profiled run panics.
239+
struct ProfileDepthGuard;
240+
241+
impl Drop for ProfileDepthGuard {
242+
fn drop(&mut self) {
243+
PROFILE_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
244+
}
245+
}

src/metadata/profiler.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ impl ProfilerMeta {
330330
/// Represents the entire profile of the execution.
331331
///
332332
/// It maps the libfunc ID to a libfunc profile.
333-
type Profile = HashMap<ConcreteLibfuncId, LibfuncProfileData>;
333+
pub type Profile = HashMap<ConcreteLibfuncId, LibfuncProfileData>;
334334

335335
/// Represents the profile data for a particular libfunc.
336336
#[derive(Default)]
@@ -362,7 +362,11 @@ impl ProfilerImpl {
362362

363363
// Push a profiler frame
364364
pub extern "C" fn push_stmt(profile_id: u64, statement_idx: u64, tick_delta: u64) {
365-
let mut profiler = LIBFUNC_PROFILE.lock().unwrap();
365+
// Invoked directly from compiled/JIT'd Cairo code across the C ABI: a panic
366+
// here (e.g. `.unwrap()` on a poisoned mutex) would unwind into machine code
367+
// that cannot handle it. Recover from poison instead of panicking, matching
368+
// the lock handling in `run_with_libfunc_profile`.
369+
let mut profiler = LIBFUNC_PROFILE.lock().unwrap_or_else(|e| e.into_inner());
366370

367371
let Some(profiler) = profiler.get_mut(&profile_id) else {
368372
eprintln!("Could not find libfunc profiler!");

0 commit comments

Comments
 (0)