diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs index b07e002..a570e9c 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/perf.rs @@ -1,33 +1,16 @@ //! Per-frame paint→encode→write stage timings for the live sidecar -//! loop, plus a tiny fixed-bucket histogram that aggregates the last N -//! frames so the main process can read out p50/p95/p99 latencies. +//! loop, plus exact fixed-window percentile summaries so the main +//! process can read out p50/p95/p99 latencies. //! //! Why this lives next to `live.rs`: the sidecar already owns the hot -//! loop. Sampling here costs one `Instant::now()` per stage boundary -//! (single rdtsc-ish syscall) and adds no allocations on the steady -//! state path. The aggregator carries fixed-size arrays — emitting a -//! summary is a constant-time walk over `BUCKET_COUNT` buckets per -//! stage. -//! -//! The buckets cover the physical range of a sidecar frame: 1 µs up -//! to ~262 ms, in power-of-2 µs steps. Bucket 0 is an underflow -//! sentinel for sub-microsecond samples, bucket `BUCKET_COUNT - 1` is -//! an overflow sentinel for anything past the top edge. The size is -//! chosen to fit the problem rather than the integer width — a 64-bit -//! log2 layout would leave ~40 dead buckets above 100 ms. +//! loop. Sampling here costs one `Instant::now()` per stage boundary. +//! Each stage keeps one preallocated window of nanosecond samples and +//! sorts only at the 60-frame summary boundary, so steady-state record +//! cost stays a single push per stage while p95 remains exact enough +//! for 120 fps gates. use std::time::{Duration, Instant}; -/// 1 underflow + 18 doublings from 1 µs to 262 144 µs + 1 overflow. -/// Top edge sits at ~262 ms, two orders of magnitude past a 60 fps -/// budget, which is enough headroom for a stalled frame without -/// wasting buckets on hours-long outliers. -const BUCKET_COUNT: usize = 20; - -/// Number of doubling buckets above the underflow sentinel. Bucket -/// `i` for `i` in `1..=DOUBLING_BUCKETS` covers `[2^(i-1), 2^i)` µs. -const DOUBLING_BUCKETS: usize = 18; - /// Per-frame stage timings captured by the live loop. /// /// `total_ns` is the real wall-clock span from request arrival to the @@ -68,87 +51,67 @@ pub(super) fn elapsed_ns(start: Instant) -> u64 { duration_to_ns(start.elapsed()) } -#[derive(Clone, Copy, Debug)] -struct StageHistogram { - buckets: [u32; BUCKET_COUNT], - count: u32, +#[derive(Debug)] +struct StageSamples { + values: Vec, } -impl StageHistogram { - const fn new() -> Self { - Self { buckets: [0; BUCKET_COUNT], count: 0 } +impl StageSamples { + fn new(window_size: usize) -> Self { + Self { values: Vec::with_capacity(window_size) } } fn record(&mut self, ns: u64) { - let bucket = bucket_for(ns); - self.buckets[bucket] = self.buckets[bucket].saturating_add(1); - self.count = self.count.saturating_add(1); + self.values.push(ns); } - fn percentile_us(&self, percentile: f64) -> u64 { - if self.count == 0 { - return 0; + fn len(&self) -> usize { + self.values.len() + } + + fn percentiles_us(&self) -> StagePercentiles { + let mut sorted = self.values.clone(); + sorted.sort_unstable(); + StagePercentiles { + p50: percentile_us(&sorted, 0.50), + p95: percentile_us(&sorted, 0.95), + p99: percentile_us(&sorted, 0.99), } - let target = ((self.count as f64) * percentile).ceil() as u32; - let target = target.max(1).min(self.count); - let mut running: u32 = 0; - for (bucket, count) in self.buckets.iter().enumerate() { - running = running.saturating_add(*count); - if running >= target { - return bucket_midpoint_us(bucket); - } - } - bucket_midpoint_us(BUCKET_COUNT - 1) } fn reset(&mut self) { - self.buckets = [0; BUCKET_COUNT]; - self.count = 0; + self.values.clear(); } } -/// Maps an observed nanosecond count to a bucket index. Bucket 0 is -/// the `<1 µs` underflow sentinel; bucket `BUCKET_COUNT - 1` catches -/// any sample past the top doubling edge. -fn bucket_for(ns: u64) -> usize { - if ns < 1_000 { - return 0; - } - let us = ns / 1_000; - // `us >= 1` here, so `64 - leading_zeros` is the position of the - // top set bit (1-indexed). That index doubles as the bucket - // number for `[2^(i-1), 2^i) µs`. - let bucket = 64 - us.leading_zeros() as usize; - bucket.min(BUCKET_COUNT - 1) +#[derive(Clone, Copy)] +struct StagePercentiles { + p50: u64, + p95: u64, + p99: u64, } -/// Returns a representative microsecond value for a bucket. For -/// doubling buckets that's the geometric midpoint `1.5 * 2^(i-1)`; -/// underflow reports 0 µs (which is honest — samples here are -/// genuinely sub-microsecond), and overflow reports the lower edge of -/// the overflow band. -fn bucket_midpoint_us(bucket: usize) -> u64 { - if bucket == 0 { +fn percentile_us(sorted_ns: &[u64], percentile: f64) -> u64 { + if sorted_ns.is_empty() { return 0; } - if bucket >= BUCKET_COUNT - 1 { - // Overflow band starts at `2^DOUBLING_BUCKETS` µs. - return 1u64 << DOUBLING_BUCKETS; - } - let low_us = 1u64 << (bucket - 1); - let high_us = 1u64 << bucket; - (low_us + high_us) / 2 + let target = ((sorted_ns.len() as f64) * percentile).ceil() as usize; + let index = target.max(1).min(sorted_ns.len()) - 1; + ns_to_us_ceil(sorted_ns[index]) +} + +fn ns_to_us_ceil(ns: u64) -> u64 { + ns.div_ceil(1_000) } /// Aggregates a rolling window of [`FrameStageTimings`] across N /// frames, exposing one [`FramePerfSummary`] per window flush. pub(super) struct FramePerfAggregator { - window_size: u32, - paint: StageHistogram, - encode: StageHistogram, - write: StageHistogram, - total: StageHistogram, - frames_in_window: u32, + window_size: usize, + paint: StageSamples, + encode: StageSamples, + write: StageSamples, + total: StageSamples, context_label: &'static str, } @@ -156,13 +119,13 @@ impl FramePerfAggregator { pub(super) const DEFAULT_WINDOW_SIZE: u32 = 60; pub(super) fn new(context_label: &'static str, window_size: u32) -> Self { + let window_size = usize::try_from(window_size.max(1)).unwrap_or(usize::MAX); Self { - window_size: window_size.max(1), - paint: StageHistogram::new(), - encode: StageHistogram::new(), - write: StageHistogram::new(), - total: StageHistogram::new(), - frames_in_window: 0, + window_size, + paint: StageSamples::new(window_size), + encode: StageSamples::new(window_size), + write: StageSamples::new(window_size), + total: StageSamples::new(window_size), context_label, } } @@ -172,31 +135,33 @@ impl FramePerfAggregator { self.encode.record(timings.encode_ns); self.write.record(timings.write_ns); self.total.record(timings.total_ns); - self.frames_in_window = self.frames_in_window.saturating_add(1); - if self.frames_in_window < self.window_size { + if self.paint.len() < self.window_size { return None; } + let paint = self.paint.percentiles_us(); + let encode = self.encode.percentiles_us(); + let write = self.write.percentiles_us(); + let total = self.total.percentiles_us(); let summary = FramePerfSummary { - window: self.frames_in_window, + window: u32::try_from(self.paint.len()).unwrap_or(u32::MAX), context: self.context_label, - paint_p50_us: self.paint.percentile_us(0.50), - paint_p95_us: self.paint.percentile_us(0.95), - paint_p99_us: self.paint.percentile_us(0.99), - encode_p50_us: self.encode.percentile_us(0.50), - encode_p95_us: self.encode.percentile_us(0.95), - encode_p99_us: self.encode.percentile_us(0.99), - write_p50_us: self.write.percentile_us(0.50), - write_p95_us: self.write.percentile_us(0.95), - write_p99_us: self.write.percentile_us(0.99), - total_p50_us: self.total.percentile_us(0.50), - total_p95_us: self.total.percentile_us(0.95), - total_p99_us: self.total.percentile_us(0.99), + paint_p50_us: paint.p50, + paint_p95_us: paint.p95, + paint_p99_us: paint.p99, + encode_p50_us: encode.p50, + encode_p95_us: encode.p95, + encode_p99_us: encode.p99, + write_p50_us: write.p50, + write_p95_us: write.p95, + write_p99_us: write.p99, + total_p50_us: total.p50, + total_p95_us: total.p95, + total_p99_us: total.p99, }; self.paint.reset(); self.encode.reset(); self.write.reset(); self.total.reset(); - self.frames_in_window = 0; Some(summary) } } @@ -221,44 +186,15 @@ pub(super) struct FramePerfSummary { #[cfg(test)] mod tests { - use super::{ - BUCKET_COUNT, DOUBLING_BUCKETS, FramePerfAggregator, FrameStageTimings, bucket_for, - bucket_midpoint_us, - }; + use super::{FramePerfAggregator, FrameStageTimings, percentile_us}; use std::time::Duration; #[test] - fn bucket_for_routes_sub_microsecond_samples_to_underflow() { - assert_eq!(bucket_for(0), 0); - assert_eq!(bucket_for(1), 0); - assert_eq!(bucket_for(999), 0); - } - - #[test] - fn bucket_for_walks_doublings_from_one_microsecond() { - assert_eq!(bucket_for(1_000), 1); - assert_eq!(bucket_for(1_999), 1); - assert_eq!(bucket_for(2_000), 2); - assert_eq!(bucket_for(3_999), 2); - assert_eq!(bucket_for(4_000), 3); - } - - #[test] - fn bucket_for_saturates_above_top_edge() { - let top_edge_us = 1u64 << DOUBLING_BUCKETS; - let beyond_ns = (top_edge_us + 1) * 1_000; - assert_eq!(bucket_for(beyond_ns), BUCKET_COUNT - 1); - assert_eq!(bucket_for(u64::MAX), BUCKET_COUNT - 1); - } - - #[test] - fn bucket_midpoint_is_monotonic_increasing() { - let mut last = 0; - for bucket in 1..BUCKET_COUNT { - let value = bucket_midpoint_us(bucket); - assert!(value >= last, "bucket {bucket} midpoint regressed"); - last = value; - } + fn percentile_us_uses_nearest_rank_and_ceils_microseconds() { + let sorted_ns = [1, 1_000, 1_001]; + assert_eq!(percentile_us(&sorted_ns, 0.50), 1); + assert_eq!(percentile_us(&sorted_ns, 0.95), 2); + assert_eq!(percentile_us(&sorted_ns, 0.99), 2); } #[test] @@ -301,10 +237,12 @@ mod tests { )); } let summary = summary.ok_or("4-frame window must flush")?; - assert!( - summary.paint_p50_us < summary.paint_p99_us, - "p99 must dominate p50 for increasing samples: {summary:?}" - ); + assert_eq!(summary.paint_p50_us, 100); + assert_eq!(summary.paint_p95_us, 10_000); + assert_eq!(summary.paint_p99_us, 10_000); + assert_eq!(summary.total_p50_us, 102); + assert_eq!(summary.total_p95_us, 10_002); + assert_eq!(summary.total_p99_us, 10_002); Ok(()) }