perf(sidecar): report exact live frame percentiles
This commit is contained in:
@@ -1,33 +1,16 @@
|
|||||||
//! Per-frame paint→encode→write stage timings for the live sidecar
|
//! Per-frame paint→encode→write stage timings for the live sidecar
|
||||||
//! loop, plus a tiny fixed-bucket histogram that aggregates the last N
|
//! loop, plus exact fixed-window percentile summaries so the main
|
||||||
//! frames so the main process can read out p50/p95/p99 latencies.
|
//! process can read out p50/p95/p99 latencies.
|
||||||
//!
|
//!
|
||||||
//! Why this lives next to `live.rs`: the sidecar already owns the hot
|
//! Why this lives next to `live.rs`: the sidecar already owns the hot
|
||||||
//! loop. Sampling here costs one `Instant::now()` per stage boundary
|
//! loop. Sampling here costs one `Instant::now()` per stage boundary.
|
||||||
//! (single rdtsc-ish syscall) and adds no allocations on the steady
|
//! Each stage keeps one preallocated window of nanosecond samples and
|
||||||
//! state path. The aggregator carries fixed-size arrays — emitting a
|
//! sorts only at the 60-frame summary boundary, so steady-state record
|
||||||
//! summary is a constant-time walk over `BUCKET_COUNT` buckets per
|
//! cost stays a single push per stage while p95 remains exact enough
|
||||||
//! stage.
|
//! for 120 fps gates.
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
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.
|
/// Per-frame stage timings captured by the live loop.
|
||||||
///
|
///
|
||||||
/// `total_ns` is the real wall-clock span from request arrival to the
|
/// `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())
|
duration_to_ns(start.elapsed())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Debug)]
|
||||||
struct StageHistogram {
|
struct StageSamples {
|
||||||
buckets: [u32; BUCKET_COUNT],
|
values: Vec<u64>,
|
||||||
count: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StageHistogram {
|
impl StageSamples {
|
||||||
const fn new() -> Self {
|
fn new(window_size: usize) -> Self {
|
||||||
Self { buckets: [0; BUCKET_COUNT], count: 0 }
|
Self { values: Vec::with_capacity(window_size) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record(&mut self, ns: u64) {
|
fn record(&mut self, ns: u64) {
|
||||||
let bucket = bucket_for(ns);
|
self.values.push(ns);
|
||||||
self.buckets[bucket] = self.buckets[bucket].saturating_add(1);
|
|
||||||
self.count = self.count.saturating_add(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn percentile_us(&self, percentile: f64) -> u64 {
|
fn len(&self) -> usize {
|
||||||
if self.count == 0 {
|
self.values.len()
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
let target = ((self.count as f64) * percentile).ceil() as u32;
|
|
||||||
let target = target.max(1).min(self.count);
|
fn percentiles_us(&self) -> StagePercentiles {
|
||||||
let mut running: u32 = 0;
|
let mut sorted = self.values.clone();
|
||||||
for (bucket, count) in self.buckets.iter().enumerate() {
|
sorted.sort_unstable();
|
||||||
running = running.saturating_add(*count);
|
StagePercentiles {
|
||||||
if running >= target {
|
p50: percentile_us(&sorted, 0.50),
|
||||||
return bucket_midpoint_us(bucket);
|
p95: percentile_us(&sorted, 0.95),
|
||||||
|
p99: percentile_us(&sorted, 0.99),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bucket_midpoint_us(BUCKET_COUNT - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reset(&mut self) {
|
fn reset(&mut self) {
|
||||||
self.buckets = [0; BUCKET_COUNT];
|
self.values.clear();
|
||||||
self.count = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps an observed nanosecond count to a bucket index. Bucket 0 is
|
#[derive(Clone, Copy)]
|
||||||
/// the `<1 µs` underflow sentinel; bucket `BUCKET_COUNT - 1` catches
|
struct StagePercentiles {
|
||||||
/// any sample past the top doubling edge.
|
p50: u64,
|
||||||
fn bucket_for(ns: u64) -> usize {
|
p95: u64,
|
||||||
if ns < 1_000 {
|
p99: u64,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a representative microsecond value for a bucket. For
|
fn percentile_us(sorted_ns: &[u64], percentile: f64) -> u64 {
|
||||||
/// doubling buckets that's the geometric midpoint `1.5 * 2^(i-1)`;
|
if sorted_ns.is_empty() {
|
||||||
/// 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 {
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if bucket >= BUCKET_COUNT - 1 {
|
let target = ((sorted_ns.len() as f64) * percentile).ceil() as usize;
|
||||||
// Overflow band starts at `2^DOUBLING_BUCKETS` µs.
|
let index = target.max(1).min(sorted_ns.len()) - 1;
|
||||||
return 1u64 << DOUBLING_BUCKETS;
|
ns_to_us_ceil(sorted_ns[index])
|
||||||
}
|
}
|
||||||
let low_us = 1u64 << (bucket - 1);
|
|
||||||
let high_us = 1u64 << bucket;
|
fn ns_to_us_ceil(ns: u64) -> u64 {
|
||||||
(low_us + high_us) / 2
|
ns.div_ceil(1_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregates a rolling window of [`FrameStageTimings`] across N
|
/// Aggregates a rolling window of [`FrameStageTimings`] across N
|
||||||
/// frames, exposing one [`FramePerfSummary`] per window flush.
|
/// frames, exposing one [`FramePerfSummary`] per window flush.
|
||||||
pub(super) struct FramePerfAggregator {
|
pub(super) struct FramePerfAggregator {
|
||||||
window_size: u32,
|
window_size: usize,
|
||||||
paint: StageHistogram,
|
paint: StageSamples,
|
||||||
encode: StageHistogram,
|
encode: StageSamples,
|
||||||
write: StageHistogram,
|
write: StageSamples,
|
||||||
total: StageHistogram,
|
total: StageSamples,
|
||||||
frames_in_window: u32,
|
|
||||||
context_label: &'static str,
|
context_label: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,13 +119,13 @@ impl FramePerfAggregator {
|
|||||||
pub(super) const DEFAULT_WINDOW_SIZE: u32 = 60;
|
pub(super) const DEFAULT_WINDOW_SIZE: u32 = 60;
|
||||||
|
|
||||||
pub(super) fn new(context_label: &'static str, window_size: u32) -> Self {
|
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 {
|
Self {
|
||||||
window_size: window_size.max(1),
|
window_size,
|
||||||
paint: StageHistogram::new(),
|
paint: StageSamples::new(window_size),
|
||||||
encode: StageHistogram::new(),
|
encode: StageSamples::new(window_size),
|
||||||
write: StageHistogram::new(),
|
write: StageSamples::new(window_size),
|
||||||
total: StageHistogram::new(),
|
total: StageSamples::new(window_size),
|
||||||
frames_in_window: 0,
|
|
||||||
context_label,
|
context_label,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,31 +135,33 @@ impl FramePerfAggregator {
|
|||||||
self.encode.record(timings.encode_ns);
|
self.encode.record(timings.encode_ns);
|
||||||
self.write.record(timings.write_ns);
|
self.write.record(timings.write_ns);
|
||||||
self.total.record(timings.total_ns);
|
self.total.record(timings.total_ns);
|
||||||
self.frames_in_window = self.frames_in_window.saturating_add(1);
|
if self.paint.len() < self.window_size {
|
||||||
if self.frames_in_window < self.window_size {
|
|
||||||
return None;
|
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 {
|
let summary = FramePerfSummary {
|
||||||
window: self.frames_in_window,
|
window: u32::try_from(self.paint.len()).unwrap_or(u32::MAX),
|
||||||
context: self.context_label,
|
context: self.context_label,
|
||||||
paint_p50_us: self.paint.percentile_us(0.50),
|
paint_p50_us: paint.p50,
|
||||||
paint_p95_us: self.paint.percentile_us(0.95),
|
paint_p95_us: paint.p95,
|
||||||
paint_p99_us: self.paint.percentile_us(0.99),
|
paint_p99_us: paint.p99,
|
||||||
encode_p50_us: self.encode.percentile_us(0.50),
|
encode_p50_us: encode.p50,
|
||||||
encode_p95_us: self.encode.percentile_us(0.95),
|
encode_p95_us: encode.p95,
|
||||||
encode_p99_us: self.encode.percentile_us(0.99),
|
encode_p99_us: encode.p99,
|
||||||
write_p50_us: self.write.percentile_us(0.50),
|
write_p50_us: write.p50,
|
||||||
write_p95_us: self.write.percentile_us(0.95),
|
write_p95_us: write.p95,
|
||||||
write_p99_us: self.write.percentile_us(0.99),
|
write_p99_us: write.p99,
|
||||||
total_p50_us: self.total.percentile_us(0.50),
|
total_p50_us: total.p50,
|
||||||
total_p95_us: self.total.percentile_us(0.95),
|
total_p95_us: total.p95,
|
||||||
total_p99_us: self.total.percentile_us(0.99),
|
total_p99_us: total.p99,
|
||||||
};
|
};
|
||||||
self.paint.reset();
|
self.paint.reset();
|
||||||
self.encode.reset();
|
self.encode.reset();
|
||||||
self.write.reset();
|
self.write.reset();
|
||||||
self.total.reset();
|
self.total.reset();
|
||||||
self.frames_in_window = 0;
|
|
||||||
Some(summary)
|
Some(summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,44 +186,15 @@ pub(super) struct FramePerfSummary {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{FramePerfAggregator, FrameStageTimings, percentile_us};
|
||||||
BUCKET_COUNT, DOUBLING_BUCKETS, FramePerfAggregator, FrameStageTimings, bucket_for,
|
|
||||||
bucket_midpoint_us,
|
|
||||||
};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bucket_for_routes_sub_microsecond_samples_to_underflow() {
|
fn percentile_us_uses_nearest_rank_and_ceils_microseconds() {
|
||||||
assert_eq!(bucket_for(0), 0);
|
let sorted_ns = [1, 1_000, 1_001];
|
||||||
assert_eq!(bucket_for(1), 0);
|
assert_eq!(percentile_us(&sorted_ns, 0.50), 1);
|
||||||
assert_eq!(bucket_for(999), 0);
|
assert_eq!(percentile_us(&sorted_ns, 0.95), 2);
|
||||||
}
|
assert_eq!(percentile_us(&sorted_ns, 0.99), 2);
|
||||||
|
|
||||||
#[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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -301,10 +237,12 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
let summary = summary.ok_or("4-frame window must flush")?;
|
let summary = summary.ok_or("4-frame window must flush")?;
|
||||||
assert!(
|
assert_eq!(summary.paint_p50_us, 100);
|
||||||
summary.paint_p50_us < summary.paint_p99_us,
|
assert_eq!(summary.paint_p95_us, 10_000);
|
||||||
"p99 must dominate p50 for increasing samples: {summary:?}"
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user