Skip to main content

harn_vm/
builtin_profile.rs

1//! Per-builtin wall-time attribution.
2//!
3//! The categorical [`crate::profile`] rollup answers "LLM vs tools vs steps",
4//! which is the right question for an agent run and the wrong one for a script
5//! that is simply slow: every builtin a pipeline calls — a project scan, a
6//! subprocess, a file read — lands in `vm/residual` alongside the script's own
7//! bytecode. A run that spends ten seconds inside one builtin reports
8//! `vm/residual 100.0%`, which names nothing.
9//!
10//! Builtins are called far too often to afford a [`crate::tracing::Span`] each
11//! (a string name, a metadata map, a vector push). This records two integers per
12//! builtin NAME instead — call count and total nanoseconds — behind an atomic
13//! that is only set when the operator asked for a profile. When it is off, the
14//! cost is one relaxed atomic load per builtin call.
15//!
16//! Two limits, stated so the table is not read for more than it says:
17//!
18//! Time is INCLUSIVE. A builtin that calls back into the VM (and so into other
19//! builtins) counts its callees' time as well as its own. That is the honest
20//! reading for the question this answers — "what is this run waiting on" — but
21//! it means the column does not sum to wall time, and the renderer says so.
22//!
23//! Each timed call carries its own measurement cost (a clock read and a lock),
24//! charged to the builtin being measured. That is noise against a builtin that
25//! takes ten seconds and a meaningful fraction of one that takes a microsecond,
26//! so this finds expensive builtins — it is not a microbenchmark of cheap ones.
27//! Rank the table; do not read a cheap builtin's per-call average as its true
28//! cost.
29
30use std::collections::HashMap;
31use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
32use std::sync::Mutex;
33use std::time::Duration;
34
35use serde::{Deserialize, Serialize};
36
37/// Whether to record. Read once per builtin call, so it stays an atomic rather
38/// than a lock.
39static ENABLED: AtomicBool = AtomicBool::new(false);
40
41static TOTALS: Mutex<Option<HashMap<String, BuiltinTotal>>> = Mutex::new(None);
42
43#[derive(Debug, Clone, Copy, Default)]
44struct BuiltinTotal {
45    calls: u64,
46    nanos: u128,
47}
48
49/// One builtin's aggregated cost across a run.
50#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
51pub struct BuiltinBucket {
52    pub name: String,
53    pub total_ms: u64,
54    pub calls: u64,
55    /// Mean call cost. The interesting split is one slow call versus a million
56    /// fast ones, and a total alone cannot tell them apart.
57    pub avg_ms: f64,
58}
59
60/// Start recording, discarding anything from a previous run.
61///
62/// The recorder is enabled per RUN but lives for the PROCESS, so the returned
63/// guard ends the recording when the run that asked for it finishes. Without
64/// it an embedder that profiles one script and not the next would keep paying
65/// for bookkeeping nobody reads, and fold the second run's builtins into the
66/// first run's totals.
67///
68/// The lifecycle belongs to the caller and nowhere else. It used to also end
69/// at `reset_thread_local_state`, which runs from ~150 test setups and from
70/// production entry points like `execute_conformance_source`; any of them
71/// firing mid-run disarmed the recorder and the profile named nothing.
72#[must_use = "recording ends when the returned guard drops"]
73pub fn enable() -> RecordingGuard {
74    let generation = GENERATION.fetch_add(1, Ordering::SeqCst).wrapping_add(1);
75    *TOTALS.lock().unwrap_or_else(|e| e.into_inner()) = Some(HashMap::new());
76    ENABLED.store(true, Ordering::Relaxed);
77    RecordingGuard { generation }
78}
79
80/// Which `enable()` currently owns the recorder.
81///
82/// The recorder is one process-global, so two overlapping profiled runs cannot
83/// both have their own. The generation makes the overlap degrade predictably
84/// instead of silently: the later `enable()` takes ownership, and the earlier
85/// guard's drop becomes a no-op rather than disarming a run that is still
86/// going. Whoever holds the current generation ends the recording.
87static GENERATION: AtomicU64 = AtomicU64::new(0);
88
89/// Ends builtin recording when dropped, unless a later [`enable`] has since
90/// taken ownership of the recorder.
91pub struct RecordingGuard {
92    generation: u64,
93}
94
95impl Drop for RecordingGuard {
96    fn drop(&mut self) {
97        if GENERATION.load(Ordering::SeqCst) == self.generation {
98            reset();
99        }
100    }
101}
102
103pub fn is_enabled() -> bool {
104    ENABLED.load(Ordering::Relaxed)
105}
106
107/// Record one completed builtin call.
108///
109/// Allocates only the first time a given builtin is seen. `entry()` would take
110/// an owned key and so allocate on every call — and the cost of measuring a
111/// builtin is charged to that builtin, so a per-call allocation shows up as the
112/// hot spot instead of finding it. `len` called 200k times reported a second of
113/// wall time that was almost entirely this function.
114pub fn record(name: &str, elapsed: Duration) {
115    if !is_enabled() {
116        return;
117    }
118    let mut guard = TOTALS.lock().unwrap_or_else(|e| e.into_inner());
119    let Some(totals) = guard.as_mut() else {
120        return;
121    };
122    let nanos = elapsed.as_nanos();
123    if let Some(entry) = totals.get_mut(name) {
124        entry.calls += 1;
125        entry.nanos += nanos;
126        return;
127    }
128    totals.insert(name.to_string(), BuiltinTotal { calls: 1, nanos });
129}
130
131/// Times one builtin call and records it on drop.
132///
133/// Scope-guard shaped, like [`crate::vm::ScopeSpan`], so a dispatch path adopts
134/// it with a binding rather than a matched pair of calls it could return early
135/// between. A builtin that throws still reports the time it burned first.
136///
137/// Borrows the name rather than owning it: a dispatch path always has the name
138/// alive across the call it is timing, and copying it per call would charge the
139/// copy to the builtin being measured.
140pub struct BuiltinTimer<'a> {
141    name: &'a str,
142    start: std::time::Instant,
143}
144
145impl<'a> BuiltinTimer<'a> {
146    /// `None` when profiling is off, which costs one relaxed atomic load and
147    /// keeps `Instant::now` off the hot path of every `to_string` call.
148    pub fn start(name: &'a str) -> Option<Self> {
149        if !is_enabled() {
150            return None;
151        }
152        Some(Self {
153            name,
154            start: std::time::Instant::now(),
155        })
156    }
157}
158
159impl Drop for BuiltinTimer<'_> {
160    fn drop(&mut self) {
161        record(self.name, self.start.elapsed());
162    }
163}
164
165/// The recorded buckets, most expensive first. Empty when profiling was off.
166pub fn snapshot() -> Vec<BuiltinBucket> {
167    let guard = TOTALS.lock().unwrap_or_else(|e| e.into_inner());
168    let Some(totals) = guard.as_ref() else {
169        return Vec::new();
170    };
171    let mut buckets: Vec<BuiltinBucket> = totals
172        .iter()
173        .map(|(name, total)| {
174            let total_ms = (total.nanos / 1_000_000) as u64;
175            BuiltinBucket {
176                name: name.clone(),
177                total_ms,
178                calls: total.calls,
179                avg_ms: if total.calls == 0 {
180                    0.0
181                } else {
182                    (total.nanos as f64 / total.calls as f64) / 1_000_000.0
183                },
184            }
185        })
186        .collect();
187    // Ties broken by name so the rendered table is stable across runs.
188    buckets.sort_by(|a, b| {
189        b.total_ms
190            .cmp(&a.total_ms)
191            .then_with(|| a.name.cmp(&b.name))
192    });
193    buckets
194}
195
196/// Stop recording and drop the totals.
197pub fn reset() {
198    ENABLED.store(false, Ordering::Relaxed);
199    *TOTALS.lock().unwrap_or_else(|e| e.into_inner()) = None;
200}
201
202/// The one lock serializing tests that touch the process-global recorder.
203///
204/// Lives outside `mod tests` because the lifecycle regression for this module
205/// sits next to `reset_thread_local_state` — it asserts that a global reset
206/// does NOT disarm an in-flight profiled run — and both sides must hold the
207/// SAME lock or they race each other's `enable`/`reset` under cargo's test
208/// threads.
209#[cfg(test)]
210pub(crate) fn test_lock() -> &'static Mutex<()> {
211    static TEST_LOCK: Mutex<()> = Mutex::new(());
212    &TEST_LOCK
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    /// The global recorder is process-wide, so these run under one lock rather
220    /// than racing each other through `enable`/`reset`.
221    use super::test_lock as TEST_LOCK;
222
223    #[test]
224    fn records_nothing_until_enabled() {
225        let _guard = TEST_LOCK().lock().unwrap_or_else(|e| e.into_inner());
226        reset();
227        record("project_fingerprint", Duration::from_millis(10));
228        assert!(
229            snapshot().is_empty(),
230            "a run without --profile must not pay for bookkeeping it never reads"
231        );
232    }
233
234    #[test]
235    fn aggregates_calls_by_name_and_ranks_by_total() {
236        let _guard = TEST_LOCK().lock().unwrap_or_else(|e| e.into_inner());
237        let _recording = enable();
238        record("to_string", Duration::from_millis(1));
239        record("to_string", Duration::from_millis(3));
240        record("project_fingerprint", Duration::from_millis(120));
241        let buckets = snapshot();
242        reset();
243
244        assert_eq!(buckets.len(), 2);
245        // One slow call outranks several fast ones: the ranking is by cost, not
246        // by how busy a builtin looks.
247        assert_eq!(buckets[0].name, "project_fingerprint");
248        assert_eq!(buckets[0].total_ms, 120);
249        assert_eq!(buckets[0].calls, 1);
250        assert_eq!(buckets[1].name, "to_string");
251        assert_eq!(buckets[1].total_ms, 4);
252        assert_eq!(buckets[1].calls, 2);
253        assert!((buckets[1].avg_ms - 2.0).abs() < 0.001);
254    }
255
256    #[test]
257    fn enable_discards_a_previous_run() {
258        let _guard = TEST_LOCK().lock().unwrap_or_else(|e| e.into_inner());
259        let _recording = enable();
260        record("run_shell", Duration::from_millis(5));
261        let _recording = enable();
262        let buckets = snapshot();
263        reset();
264        assert!(
265            buckets.is_empty(),
266            "a fresh run must not inherit stale totals"
267        );
268    }
269
270    /// Two profiled runs can overlap in one process — an embedder driving two
271    /// VMs, or a `--profile` run that starts while another is finishing. There
272    /// is only one recorder, so the later run owns it; the earlier run's guard
273    /// must not disarm it on the way out.
274    #[test]
275    fn an_outgoing_run_does_not_disarm_the_run_that_replaced_it() {
276        let _guard = TEST_LOCK().lock().unwrap_or_else(|e| e.into_inner());
277        let first = enable();
278        let second = enable();
279
280        drop(first);
281
282        assert!(
283            is_enabled(),
284            "the superseded run's guard must not stop the recorder"
285        );
286        record("run_shell", Duration::from_millis(5));
287        assert!(
288            !snapshot().is_empty(),
289            "the owning run must still be recording"
290        );
291
292        drop(second);
293        assert!(!is_enabled(), "the owning run's guard ends the recording");
294    }
295}