Skip to main content

blitz_script/
script_stats.rs

1//! What the JavaScript side costs, per frame.
2//!
3//! The renderer has published real timings for a while: resolve, paint and
4//! present. Script execution had none, so a profile could show a 4ms frame and
5//! a UI that still felt slow, with nothing in between to look at. Every
6//! reactive update, event handler, timer callback and microtask drain runs
7//! through `ScriptDocument::poll`, so timing that one boundary accounts for the
8//! whole language runtime without threading a clock through Boa.
9//!
10//! Deliberately mirrors `blitz_shell::frame_stats`: a bounded ring, means and
11//! tails rather than a running average, and no data reported as zero when there
12//! is no data at all.
13
14use std::sync::Mutex;
15use std::time::Duration;
16
17/// How many polls to retain. Matches the frame ring so the two line up when
18/// read together.
19const CAPACITY: usize = 256;
20
21/// A poll that ran JavaScript. Polls that found nothing to do are counted but
22/// not retained: they are the idle case and would drag every average toward
23/// zero, hiding the handler that actually costs something.
24#[derive(Debug, Clone, Copy)]
25struct Poll {
26    duration: Duration,
27}
28
29/// Cumulative cost of one kind of work, so a poll can be attributed rather
30/// than merely measured. "JavaScript is slow" is not actionable; "scroll
31/// handlers cost 14ms of every 16ms poll" is.
32#[derive(Debug, Default, Clone, Copy)]
33struct Bucket {
34    calls: u64,
35    spent: Duration,
36    worst: Duration,
37}
38
39impl Bucket {
40    fn record(&mut self, duration: Duration) {
41        self.calls += 1;
42        self.spent += duration;
43        if duration > self.worst {
44            self.worst = duration;
45        }
46    }
47
48    fn absorb(&mut self, other: &Bucket) {
49        self.calls += other.calls;
50        self.spent += other.spent;
51        if other.worst > self.worst {
52            self.worst = other.worst;
53        }
54    }
55}
56
57#[derive(Debug, Default)]
58struct Log {
59    /// Buckets keyed by a compile-time label, for call sites hot enough that
60    /// allocating a `String` per call would be its own measurement error. DOM
61    /// construction runs thousands of times per mount.
62    statics: std::collections::BTreeMap<&'static str, Bucket>,
63    /// Event names are dynamic, so they are interned into a small set rather
64    /// than leaking a `String` per dispatch.
65    dynamic: std::collections::BTreeMap<String, Bucket>,
66    polls: Vec<Poll>,
67    /// Every poll, including the ones that did no work.
68    total: u64,
69    /// Polls that actually ran script.
70    productive: u64,
71    /// Cumulative time in the script runtime, idle polls included.
72    spent: Duration,
73}
74
75static LOG: Mutex<Option<Log>> = Mutex::new(None);
76
77thread_local! {
78    /// Per-thread buckets for the static labels, folded into [`LOG`] once per
79    /// poll.
80    ///
81    /// `record_static` runs per DOM node, so a 4,000-node mount calls it tens
82    /// of thousands of times. Taking the process-global lock there cost more
83    /// than several of the operations being timed, which inflated every
84    /// absolute the profile reported: the instrument was a measurable share of
85    /// the measurement. Script runs on one thread, so the accumulator can be
86    /// thread-local and the hot path needs no synchronisation at all.
87    static LOCAL_STATICS: std::cell::RefCell<Vec<(&'static str, Bucket)>> =
88        const { std::cell::RefCell::new(Vec::new()) };
89}
90
91/// Attribute a slice of script time to a fixed source, without allocating.
92///
93/// Use for anything called per DOM node. `record_work` takes a `&str` and
94/// interns it, which is fine per event and far too expensive per element.
95pub fn record_static(label: &'static str, duration: Duration) {
96    // `try_with`/`try_borrow_mut` rather than the panicking forms: this runs
97    // inside `Drop`, and a profiler that can panic during unwinding turns a
98    // recoverable error into an abort.
99    let _ = LOCAL_STATICS.try_with(|local| {
100        let Ok(mut buckets) = local.try_borrow_mut() else {
101            return;
102        };
103        // Linear scan over a fixed, tiny label set (one entry per DOM binding).
104        // Cheaper than hashing or an ordered map at this size, and identical
105        // literals share an address, so the common case is one word compare.
106        if let Some((_, bucket)) = buckets
107            .iter_mut()
108            .find(|(seen, _)| std::ptr::eq(*seen, label) || *seen == label)
109        {
110            bucket.record(duration);
111            return;
112        }
113        let mut bucket = Bucket::default();
114        bucket.record(duration);
115        buckets.push((label, bucket));
116    });
117}
118
119/// Fold the calling thread's static buckets into the shared log.
120///
121/// Only this thread's, by construction. Script and the diagnostics collection
122/// that reads these both run on the document thread, so that is the thread
123/// whose buckets matter; a reader on any other thread sees the totals as of the
124/// last poll rather than a torn half-update.
125fn drain_local_statics(log: &mut Log) {
126    let _ = LOCAL_STATICS.try_with(|local| {
127        let Ok(mut buckets) = local.try_borrow_mut() else {
128            return;
129        };
130        for (label, bucket) in buckets.iter_mut() {
131            log.statics.entry(*label).or_default().absorb(bucket);
132            *bucket = Bucket::default();
133        }
134    });
135}
136
137/// What crossed from JavaScript into the host, in bytes.
138///
139/// Every DOM binding that takes a string reaches `dom::to_rust_string`, and
140/// nothing else converts a `JsValue` into an owned Rust `String` on the way
141/// into the DOM. So one counter there accounts for the whole guest-to-host
142/// string traffic, which is the quantity a boundary design changes and a
143/// timing number cannot isolate.
144///
145/// Deliberately *not* interned, because Boa is not: a binding receives a
146/// `JsString` and copies it out on every call, so `createElement("tr")` pays
147/// for `"tr"` a thousand times in a thousand-row build. That repetition is the
148/// measurement, not an inefficiency in the counter.
149#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
150pub struct BoundaryCounters {
151    /// Strings converted out of the JavaScript heap.
152    pub strings_crossed: u64,
153    /// Their total length in UTF-8 bytes.
154    pub bytes_copied: u64,
155}
156
157thread_local! {
158    /// Script runs on the document thread, so a `Cell` is the whole
159    /// synchronisation story and the hot path needs no atomic.
160    static BOUNDARY: std::cell::Cell<BoundaryCounters> =
161        const { std::cell::Cell::new(BoundaryCounters { strings_crossed: 0, bytes_copied: 0 }) };
162}
163
164/// Record one string crossing. Gated on the same switch as [`Timed`], so a
165/// build that is not profiling pays one relaxed atomic load and nothing else.
166pub(crate) fn record_boundary_string(bytes: usize) {
167    if !blitz_traits::profiling::deep_profiling_enabled() {
168        return;
169    }
170    let _ = BOUNDARY.try_with(|cell| {
171        let mut counters = cell.get();
172        counters.strings_crossed += 1;
173        counters.bytes_copied += bytes as u64;
174        cell.set(counters);
175    });
176}
177
178/// What has crossed on this thread since the last [`reset_boundary_counters`].
179#[must_use]
180pub fn boundary_counters() -> BoundaryCounters {
181    BOUNDARY.try_with(std::cell::Cell::get).unwrap_or_default()
182}
183
184/// Zero the boundary counters for this thread.
185pub fn reset_boundary_counters() {
186    let _ = BOUNDARY.try_with(|cell| cell.set(BoundaryCounters::default()));
187}
188
189/// Attribute a slice of script time to a named source.
190///
191/// Called from the runtime around timer callbacks and DOM event dispatch. The
192/// label is the event name where there is one, so a profile says which handler
193/// is expensive instead of only that something was.
194pub fn record_work(label: &str, duration: Duration) {
195    let Ok(mut guard) = LOG.lock() else {
196        return;
197    };
198    let log = guard.get_or_insert_with(Log::default);
199    log.dynamic
200        .entry(label.to_string())
201        .or_default()
202        .record(duration);
203}
204
205/// The costliest sources seen so far, worst total first.
206#[must_use]
207pub fn work_breakdown() -> Vec<(String, u64, f64, f64)> {
208    let Ok(mut guard) = LOG.lock() else {
209        return Vec::new();
210    };
211    // Statics accumulate off-lock, so fold this thread's in before reading or
212    // the breakdown reports the state as of the previous poll.
213    let log = guard.get_or_insert_with(Log::default);
214    drain_local_statics(log);
215    let mut rows: Vec<(String, u64, f64, f64)> = log
216        .statics
217        .iter()
218        .map(|(label, bucket)| {
219            (
220                (*label).to_string(),
221                bucket.calls,
222                bucket.spent.as_secs_f64() * 1_000.0,
223                bucket.worst.as_secs_f64() * 1_000.0,
224            )
225        })
226        .chain(log.dynamic.iter().map(|(label, bucket)| {
227            (
228                label.clone(),
229                bucket.calls,
230                bucket.spent.as_secs_f64() * 1_000.0,
231                bucket.worst.as_secs_f64() * 1_000.0,
232            )
233        }))
234        .collect();
235    rows.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
236    rows
237}
238
239/// Record one `poll`. Cheap enough to leave on: a lock and a push.
240pub fn record_poll(duration: Duration, ran_script: bool) {
241    let Ok(mut guard) = LOG.lock() else {
242        return;
243    };
244    let log = guard.get_or_insert_with(Log::default);
245    // Once per poll is the natural fold point: the lock is already held, and a
246    // poll is the unit the rest of these numbers are reported in.
247    drain_local_statics(log);
248    log.total += 1;
249    log.spent += duration;
250    maybe_report(log);
251    if !ran_script {
252        return;
253    }
254    log.productive += 1;
255    if log.polls.len() == CAPACITY {
256        log.polls.remove(0);
257    }
258    log.polls.push(Poll { duration });
259}
260
261/// Mean, 95th percentile and worst case for the retained polls, in
262/// milliseconds.
263#[derive(Debug, Clone, Copy, PartialEq)]
264pub struct ScriptStatsSnapshot {
265    pub mean_ms: f64,
266    pub p95_ms: f64,
267    pub max_ms: f64,
268    /// Polls that ran script, out of the retained window.
269    pub window_polls: u64,
270    /// Every poll since launch.
271    pub total_polls: u64,
272    /// Polls that ran script since launch.
273    pub productive_polls: u64,
274    /// Total time in the script runtime since launch, in milliseconds.
275    pub spent_ms: f64,
276}
277
278/// `None` until script has actually run, so a caller reports "no data" rather
279/// than printing zeros that look like a measurement.
280#[must_use]
281pub fn latest_script_stats() -> Option<ScriptStatsSnapshot> {
282    // Permission, not an attached consumer: the same reason as
283    // `blitz_shell::frame_stats::latest_frame_stats`. Requiring a consumer to
284    // *read* what has already been recorded made the owner's toggle look inert,
285    // because the local `[blitz-frame]` log file has no consumer to attach.
286    // The recorders above stay gated on `deep_profiling_enabled`, which is the
287    // part that costs something per section.
288    if !blitz_traits::profiling::deep_profiling_permitted() {
289        return None;
290    }
291    let guard = LOG.lock().ok()?;
292    let log = guard.as_ref()?;
293    if log.polls.is_empty() {
294        return None;
295    }
296    let mut millis: Vec<f64> = log
297        .polls
298        .iter()
299        .map(|poll| poll.duration.as_secs_f64() * 1_000.0)
300        .collect();
301    let sum: f64 = millis.iter().sum();
302    let mean = sum / millis.len() as f64;
303    millis.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
304    // Nearest rank, so a short window still reports a real observation rather
305    // than an interpolation between two samples it does not have.
306    let rank = ((millis.len() as f64) * 0.95).ceil() as usize;
307    let p95 = millis[rank.saturating_sub(1).min(millis.len() - 1)];
308    Some(ScriptStatsSnapshot {
309        mean_ms: mean,
310        p95_ms: p95,
311        max_ms: *millis.last().unwrap_or(&0.0),
312        window_polls: millis.len() as u64,
313        total_polls: log.total,
314        productive_polls: log.productive,
315        spent_ms: log.spent.as_secs_f64() * 1_000.0,
316    })
317}
318
319/// Times a scope and attributes it on drop.
320///
321/// Every early return and `?` in a DOM binding is an exit path, and a manual
322/// stopwatch would miss most of them. This cannot.
323///
324/// Compiled out unless the `dom-stats` feature is on. The clock reads alone are
325/// two `mach_absolute_time` calls per DOM operation, which a release build has
326/// no reader for and should not pay. `debug-control` turns it on, so inspector
327/// builds keep the attribution; it can also be enabled by itself to profile a
328/// build shaped like the shipping one.
329#[cfg(feature = "dom-stats")]
330pub struct Timed {
331    label: &'static str,
332    started: Option<std::time::Instant>,
333}
334
335#[cfg(feature = "dom-stats")]
336impl Timed {
337    #[must_use]
338    pub(crate) fn new(ctx: &crate::state::DomCtx, label: &'static str) -> Self {
339        Self {
340            label,
341            started: ctx.deep_profiling_enabled().then(std::time::Instant::now),
342        }
343    }
344}
345
346#[cfg(feature = "dom-stats")]
347impl Drop for Timed {
348    fn drop(&mut self) {
349        if let Some(started) = self.started {
350            record_static(self.label, started.elapsed());
351        }
352    }
353}
354
355/// Discard every retained script and DOM timing sample.
356pub fn clear() {
357    if let Ok(mut log) = LOG.lock() {
358        *log = None;
359    }
360    let _ = LOCAL_STATICS.try_with(|local| {
361        if let Ok(mut buckets) = local.try_borrow_mut() {
362            buckets.clear();
363        }
364    });
365}
366
367/// The zero-cost stand-in. Same call sites, no clock, no bucket, no drop glue.
368#[cfg(not(feature = "dom-stats"))]
369pub struct Timed;
370
371#[cfg(not(feature = "dom-stats"))]
372impl Timed {
373    #[must_use]
374    #[inline(always)]
375    pub(crate) fn new(_ctx: &crate::state::DomCtx, _label: &'static str) -> Self {
376        Self
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    /// These share one process-global log, so they must not interleave. Without
385    /// this the suite passes or fails depending on thread scheduling, which is
386    /// worse than no test at all.
387    static SERIAL: Mutex<()> = Mutex::new(());
388
389    /// The serial lock, and a consumer holding sampling open for the test.
390    ///
391    /// Both are returned because both have to outlive the body: recording now
392    /// needs permission *and* an attached consumer, so a profiling guard
393    /// created and dropped inside this helper would stop collection before the
394    /// caller records anything.
395    struct TestCapture {
396        _serial: std::sync::MutexGuard<'static, ()>,
397        _sampling: blitz_traits::profiling::DeepProfilingGuard,
398    }
399
400    fn reset() -> TestCapture {
401        let guard = SERIAL
402            .lock()
403            .unwrap_or_else(|poisoned| poisoned.into_inner());
404        *LOG.lock().unwrap() = None;
405        // The static buckets outlive the shared log, so clearing only the log
406        // would leak the previous test's DOM samples into the next one.
407        LOCAL_STATICS.with(|local| local.borrow_mut().clear());
408        blitz_traits::profiling::set_deep_profiling_permitted(true);
409        let sampling =
410            blitz_traits::profiling::begin_deep_profiling().expect("permission was just granted");
411        TestCapture {
412            _serial: guard,
413            _sampling: sampling,
414        }
415    }
416
417    #[test]
418    fn static_labels_reach_the_breakdown_without_locking_per_call() {
419        let _serial = reset();
420        for _ in 0..3 {
421            record_static("dom:appendChild", Duration::from_micros(10));
422        }
423        record_static("dom:appendChild", Duration::from_micros(90));
424        let rows = work_breakdown();
425        let row = rows
426            .iter()
427            .find(|(label, ..)| label == "dom:appendChild")
428            .expect("the static bucket is reported");
429        assert_eq!(row.1, 4, "every call counted: {rows:?}");
430        assert!(
431            (row.3 - 0.09).abs() < 0.01,
432            "the worst call survives the total: {rows:?}"
433        );
434    }
435
436    #[test]
437    fn folding_twice_does_not_double_count() {
438        let _serial = reset();
439        record_static("dom:createElement", Duration::from_micros(50));
440        let first = work_breakdown();
441        let second = work_breakdown();
442        assert_eq!(
443            first, second,
444            "a drained bucket must not be added to the shared log again"
445        );
446    }
447
448    #[test]
449    fn nothing_is_reported_before_script_runs() {
450        let _serial = reset();
451        record_poll(Duration::from_millis(5), false);
452        assert!(
453            latest_script_stats().is_none(),
454            "idle polls are not a measurement of script cost"
455        );
456    }
457
458    #[test]
459    fn the_worst_poll_survives_the_mean() {
460        let _serial = reset();
461        for _ in 0..40 {
462            record_poll(Duration::from_millis(1), true);
463        }
464        record_poll(Duration::from_millis(60), true);
465        let stats = latest_script_stats().expect("script ran");
466        assert!(stats.mean_ms < 3.0, "one outlier must not move the mean");
467        assert!(
468            (stats.max_ms - 60.0).abs() < 1.0,
469            "the outlier is the whole point: {stats:?}"
470        );
471    }
472
473    #[test]
474    fn idle_polls_are_counted_without_diluting_the_window() {
475        let _serial = reset();
476        record_poll(Duration::from_millis(2), true);
477        for _ in 0..10 {
478            record_poll(Duration::from_micros(10), false);
479        }
480        let stats = latest_script_stats().expect("script ran");
481        assert_eq!(stats.window_polls, 1);
482        assert_eq!(stats.total_polls, 11);
483        assert_eq!(stats.productive_polls, 1);
484    }
485
486    #[cfg(feature = "dom-stats")]
487    #[test]
488    fn poll_keeps_its_selected_mode_when_the_global_flag_changes_inside_it() {
489        use blitz_dom::{Document, DocumentConfig};
490
491        let _serial = reset();
492        let mut document =
493            crate::ScriptDocument::from_html("<body></body>", DocumentConfig::default());
494        document.set_poll_hook(|document, _| {
495            // The poll selected profiling before this hook. Inner collectors
496            // must keep that selection rather than rereading the global.
497            blitz_traits::profiling::set_deep_profiling_permitted(false);
498            document.eval("document.body.appendChild(document.createElement('div'))");
499            true
500        });
501
502        assert!(document.poll(None));
503        blitz_traits::profiling::set_deep_profiling_permitted(true);
504
505        assert!(
506            work_breakdown()
507                .iter()
508                .any(|(label, ..)| label == "dom:createElement"),
509            "DOM attribution follows the enclosing poll mode"
510        );
511        assert!(latest_script_stats().is_some());
512    }
513
514    #[cfg(feature = "dom-stats")]
515    #[test]
516    fn disabled_poll_does_not_start_collecting_if_the_global_turns_on_inside_it() {
517        use blitz_dom::{Document, DocumentConfig};
518
519        let _serial = reset();
520        clear();
521        blitz_traits::profiling::set_deep_profiling_permitted(false);
522        let mut document =
523            crate::ScriptDocument::from_html("<body></body>", DocumentConfig::default());
524        document.set_poll_hook(|document, _| {
525            blitz_traits::profiling::set_deep_profiling_permitted(true);
526            document.eval("document.body.appendChild(document.createElement('div'))");
527            true
528        });
529
530        assert!(document.poll(None));
531
532        assert!(work_breakdown().is_empty());
533        assert!(latest_script_stats().is_none());
534    }
535}
536
537/// Print what the script runtime is costing, once a second, under
538/// `BLITZ_SCRIPT_STATS=1`.
539///
540/// [`latest_script_stats`] existed with no caller anywhere in the workspace, so
541/// none of this was reachable from a running browser: the frame log accounts
542/// for resolve, paint and present on the main thread, and script ran in the gap
543/// between them with nothing measuring it. On a page whose frame loop never
544/// settles, that gap is where unexplained CPU hides.
545fn maybe_report(log: &Log) {
546    use std::sync::OnceLock;
547    use std::time::Instant;
548
549    static ENABLED: OnceLock<bool> = OnceLock::new();
550    if !*ENABLED.get_or_init(|| {
551        matches!(
552            std::env::var("BLITZ_SCRIPT_STATS").ok().as_deref(),
553            Some("1") | Some("true")
554        )
555    }) {
556        return;
557    }
558
559    static LAST: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
560    let Ok(mut last) = LAST.lock() else { return };
561    let now = Instant::now();
562    if last.is_some_and(|t| now.duration_since(t) < Duration::from_secs(1)) {
563        return;
564    }
565    let elapsed = last.map(|t| now.duration_since(t));
566    *last = Some(now);
567    drop(last);
568
569    // Share of wall clock spent inside the script runtime since the last line,
570    // which is the number that says whether script is the cost or a rounding
571    // error. Cumulative totals cannot answer that on a long-running page.
572    let spent_ms = log.spent.as_secs_f64() * 1000.0;
573    static PREV_SPENT: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);
574    let delta_ms = if let Ok(mut prev) = PREV_SPENT.lock() {
575        let d = spent_ms - *prev;
576        *prev = spent_ms;
577        d
578    } else {
579        0.0
580    };
581    let share = elapsed
582        .map(|e| delta_ms / (e.as_secs_f64() * 1000.0) * 100.0)
583        .unwrap_or(0.0);
584
585    eprintln!(
586        "[script] polls={} productive={} spent={spent_ms:.0}ms last_second={delta_ms:.1}ms ({share:.1}% of wall clock)",
587        log.total, log.productive,
588    );
589}