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/// Print what the script runtime is costing, once a second, under
347/// `BLITZ_SCRIPT_STATS=1`.
348fn maybe_report(log: &Log) {
349    use std::sync::OnceLock;
350    use std::time::Instant;
351
352    static ENABLED: OnceLock<bool> = OnceLock::new();
353    if !*ENABLED.get_or_init(|| {
354        matches!(
355            std::env::var("BLITZ_SCRIPT_STATS").ok().as_deref(),
356            Some("1") | Some("true")
357        )
358    }) {
359        return;
360    }
361
362    static LAST: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
363    let Ok(mut last) = LAST.lock() else { return };
364    let now = Instant::now();
365    if last.is_some_and(|time| now.duration_since(time) < Duration::from_secs(1)) {
366        return;
367    }
368    let elapsed = last.map(|time| now.duration_since(time));
369    *last = Some(now);
370    drop(last);
371
372    let spent_ms = log.spent.as_secs_f64() * 1000.0;
373    static PREV_SPENT: std::sync::Mutex<f64> = std::sync::Mutex::new(0.0);
374    let delta_ms = if let Ok(mut previous) = PREV_SPENT.lock() {
375        let delta = spent_ms - *previous;
376        *previous = spent_ms;
377        delta
378    } else {
379        0.0
380    };
381    let share = elapsed
382        .map(|duration| delta_ms / (duration.as_secs_f64() * 1000.0) * 100.0)
383        .unwrap_or(0.0);
384
385    eprintln!(
386        "[script] polls={} productive={} spent={spent_ms:.0}ms last_second={delta_ms:.1}ms ({share:.1}% of wall clock)",
387        log.total, log.productive,
388    );
389}
390
391#[cfg(feature = "dom-stats")]
392impl Drop for Timed {
393    fn drop(&mut self) {
394        if let Some(started) = self.started {
395            record_static(self.label, started.elapsed());
396        }
397    }
398}
399
400/// Discard every retained script and DOM timing sample.
401pub fn clear() {
402    if let Ok(mut log) = LOG.lock() {
403        *log = None;
404    }
405    let _ = LOCAL_STATICS.try_with(|local| {
406        if let Ok(mut buckets) = local.try_borrow_mut() {
407            buckets.clear();
408        }
409    });
410}
411
412/// The zero-cost stand-in. Same call sites, no clock, no bucket, no drop glue.
413#[cfg(not(feature = "dom-stats"))]
414pub struct Timed;
415
416#[cfg(not(feature = "dom-stats"))]
417impl Timed {
418    #[must_use]
419    #[inline(always)]
420    pub(crate) fn new(_ctx: &crate::state::DomCtx, _label: &'static str) -> Self {
421        Self
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    /// These share one process-global log, so they must not interleave. Without
430    /// this the suite passes or fails depending on thread scheduling, which is
431    /// worse than no test at all.
432    static SERIAL: Mutex<()> = Mutex::new(());
433
434    /// The serial lock, and a consumer holding sampling open for the test.
435    ///
436    /// Both are returned because both have to outlive the body: recording now
437    /// needs permission *and* an attached consumer, so a profiling guard
438    /// created and dropped inside this helper would stop collection before the
439    /// caller records anything.
440    struct TestCapture {
441        _serial: std::sync::MutexGuard<'static, ()>,
442        _sampling: blitz_traits::profiling::DeepProfilingGuard,
443    }
444
445    fn reset() -> TestCapture {
446        let guard = SERIAL
447            .lock()
448            .unwrap_or_else(|poisoned| poisoned.into_inner());
449        *LOG.lock().unwrap() = None;
450        // The static buckets outlive the shared log, so clearing only the log
451        // would leak the previous test's DOM samples into the next one.
452        LOCAL_STATICS.with(|local| local.borrow_mut().clear());
453        blitz_traits::profiling::set_deep_profiling_permitted(true);
454        let sampling =
455            blitz_traits::profiling::begin_deep_profiling().expect("permission was just granted");
456        TestCapture {
457            _serial: guard,
458            _sampling: sampling,
459        }
460    }
461
462    #[test]
463    fn static_labels_reach_the_breakdown_without_locking_per_call() {
464        let _serial = reset();
465        for _ in 0..3 {
466            record_static("dom:appendChild", Duration::from_micros(10));
467        }
468        record_static("dom:appendChild", Duration::from_micros(90));
469        let rows = work_breakdown();
470        let row = rows
471            .iter()
472            .find(|(label, ..)| label == "dom:appendChild")
473            .expect("the static bucket is reported");
474        assert_eq!(row.1, 4, "every call counted: {rows:?}");
475        assert!(
476            (row.3 - 0.09).abs() < 0.01,
477            "the worst call survives the total: {rows:?}"
478        );
479    }
480
481    #[test]
482    fn folding_twice_does_not_double_count() {
483        let _serial = reset();
484        record_static("dom:createElement", Duration::from_micros(50));
485        let first = work_breakdown();
486        let second = work_breakdown();
487        assert_eq!(
488            first, second,
489            "a drained bucket must not be added to the shared log again"
490        );
491    }
492
493    #[test]
494    fn nothing_is_reported_before_script_runs() {
495        let _serial = reset();
496        record_poll(Duration::from_millis(5), false);
497        assert!(
498            latest_script_stats().is_none(),
499            "idle polls are not a measurement of script cost"
500        );
501    }
502
503    #[test]
504    fn the_worst_poll_survives_the_mean() {
505        let _serial = reset();
506        for _ in 0..40 {
507            record_poll(Duration::from_millis(1), true);
508        }
509        record_poll(Duration::from_millis(60), true);
510        let stats = latest_script_stats().expect("script ran");
511        assert!(stats.mean_ms < 3.0, "one outlier must not move the mean");
512        assert!(
513            (stats.max_ms - 60.0).abs() < 1.0,
514            "the outlier is the whole point: {stats:?}"
515        );
516    }
517
518    #[test]
519    fn idle_polls_are_counted_without_diluting_the_window() {
520        let _serial = reset();
521        record_poll(Duration::from_millis(2), true);
522        for _ in 0..10 {
523            record_poll(Duration::from_micros(10), false);
524        }
525        let stats = latest_script_stats().expect("script ran");
526        assert_eq!(stats.window_polls, 1);
527        assert_eq!(stats.total_polls, 11);
528        assert_eq!(stats.productive_polls, 1);
529    }
530
531    #[cfg(feature = "dom-stats")]
532    #[test]
533    fn poll_keeps_its_selected_mode_when_the_global_flag_changes_inside_it() {
534        use blitz_dom::{Document, DocumentConfig};
535
536        let _serial = reset();
537        let mut document =
538            crate::ScriptDocument::from_html("<body></body>", DocumentConfig::default());
539        document.set_poll_hook(|document, _| {
540            // The poll selected profiling before this hook. Inner collectors
541            // must keep that selection rather than rereading the global.
542            blitz_traits::profiling::set_deep_profiling_permitted(false);
543            document.eval("document.body.appendChild(document.createElement('div'))");
544            true
545        });
546
547        assert!(document.poll(None));
548        blitz_traits::profiling::set_deep_profiling_permitted(true);
549
550        assert!(
551            work_breakdown()
552                .iter()
553                .any(|(label, ..)| label == "dom:createElement"),
554            "DOM attribution follows the enclosing poll mode"
555        );
556        assert!(latest_script_stats().is_some());
557    }
558
559    #[cfg(feature = "dom-stats")]
560    #[test]
561    fn disabled_poll_does_not_start_collecting_if_the_global_turns_on_inside_it() {
562        use blitz_dom::{Document, DocumentConfig};
563
564        let _serial = reset();
565        clear();
566        blitz_traits::profiling::set_deep_profiling_permitted(false);
567        let mut document =
568            crate::ScriptDocument::from_html("<body></body>", DocumentConfig::default());
569        document.set_poll_hook(|document, _| {
570            blitz_traits::profiling::set_deep_profiling_permitted(true);
571            document.eval("document.body.appendChild(document.createElement('div'))");
572            true
573        });
574
575        assert!(document.poll(None));
576
577        assert!(work_breakdown().is_empty());
578        assert!(latest_script_stats().is_none());
579    }
580}