Make frame_perf total a real end-to-end timing and right-size buckets

This commit is contained in:
2026-05-10 22:38:12 -04:00
parent f7027e6ea5
commit 320da3ddb5
2 changed files with 89 additions and 34 deletions
@@ -47,11 +47,23 @@ pub(super) fn run_live(args: LiveArgs) -> Result<(), LiveSidecarError> {
continue; continue;
} }
// `frame_started_at` is the honest start of the end-to-end
// frame: a request just arrived and we're about to do
// everything required to put bytes back on the pipe. The
// matching stop is the `stdout.flush()` inside
// `write_outcome`.
let frame_started_at = Instant::now();
let outcome = match serde_json::from_str::<LiveRequest>(&line) { let outcome = match serde_json::from_str::<LiveRequest>(&line) {
Ok(request) => handle_request(&mut host, &mut sessions, request), Ok(request) => handle_request(&mut host, &mut sessions, request),
Err(error) => Err(LiveSidecarError::Json(error)), Err(error) => Err(LiveSidecarError::Json(error)),
}; };
write_outcome(&mut stdout, &mut perf, &mut pending_summary, outcome)?; write_outcome(
&mut stdout,
&mut perf,
&mut pending_summary,
outcome,
frame_started_at,
)?;
} }
Ok(()) Ok(())
@@ -135,8 +147,11 @@ fn handle_request(
/// `rgba_byte_count` from the report, then reads that many bytes /// `rgba_byte_count` from the report, then reads that many bytes
/// from the same stream — no temp file round-trip. /// from the same stream — no temp file round-trip.
/// ///
/// After the bytes hit the pipe we fold paint+encode+write timings /// After the bytes hit the pipe we fold paint/encode/write/total
/// into the aggregator. Any summary it emits is stashed on /// timings into the aggregator. `total_ns` is the wall-clock span
/// from `frame_started_at` (request arrival) to the stdout flush
/// returning, so it captures every per-frame cost outside the three
/// measured stages. Any summary the aggregator emits is stashed on
/// `pending_summary` and rides out on the *next* response, because /// `pending_summary` and rides out on the *next* response, because
/// the protocol is one-line-per-response and an unsolicited summary /// the protocol is one-line-per-response and an unsolicited summary
/// line would desync the main process's read loop. /// line would desync the main process's read loop.
@@ -145,6 +160,7 @@ fn write_outcome(
perf: &mut FramePerfAggregator, perf: &mut FramePerfAggregator,
pending_summary: &mut Option<FramePerfSummary>, pending_summary: &mut Option<FramePerfSummary>,
outcome: Result<LiveOutcome, LiveSidecarError>, outcome: Result<LiveOutcome, LiveSidecarError>,
frame_started_at: Instant,
) -> Result<(), LiveSidecarError> { ) -> Result<(), LiveSidecarError> {
let mut outcome = outcome.unwrap_or_else(|error| LiveOutcome::error(error.to_string())); let mut outcome = outcome.unwrap_or_else(|error| LiveOutcome::error(error.to_string()));
let partial_timings = outcome.partial_timings.take(); let partial_timings = outcome.partial_timings.take();
@@ -161,15 +177,13 @@ fn write_outcome(
stdout.flush()?; stdout.flush()?;
if frame_present { if frame_present {
let write_ns = elapsed_ns(write_started_at); let write_ns = elapsed_ns(write_started_at);
let total_ns = elapsed_ns(frame_started_at);
let partial = partial_timings.unwrap_or(PartialFrameTimings { paint_ns: 0, encode_ns: 0 }); let partial = partial_timings.unwrap_or(PartialFrameTimings { paint_ns: 0, encode_ns: 0 });
let total = Duration::from_nanos(
partial.paint_ns.saturating_add(partial.encode_ns).saturating_add(write_ns),
);
let timings = FrameStageTimings::from_durations( let timings = FrameStageTimings::from_durations(
Duration::from_nanos(partial.paint_ns), Duration::from_nanos(partial.paint_ns),
Duration::from_nanos(partial.encode_ns), Duration::from_nanos(partial.encode_ns),
Duration::from_nanos(write_ns), Duration::from_nanos(write_ns),
total, Duration::from_nanos(total_ns),
); );
if let Some(summary) = perf.record(timings) { if let Some(summary) = perf.record(timings) {
*pending_summary = Some(summary); *pending_summary = Some(summary);
@@ -6,26 +6,34 @@
//! 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 //! (single rdtsc-ish syscall) and adds no allocations on the steady
//! state path. The aggregator carries fixed-size arrays — emitting a //! state path. The aggregator carries fixed-size arrays — emitting a
//! summary is a constant-time walk over 64 buckets per stage. //! summary is a constant-time walk over `BUCKET_COUNT` buckets per
//! stage.
//! //!
//! The buckets are log2-spaced from 1 µs up to ~17 s //! The buckets cover the physical range of a sidecar frame: 1 µs up
//! (`1 << 64` ns / 1000). Every observation falls into exactly one //! to ~262 ms, in power-of-2 µs steps. Bucket 0 is an underflow
//! bucket; the percentile pass is linear in `BUCKET_COUNT` and walks //! sentinel for sub-microsecond samples, bucket `BUCKET_COUNT - 1` is
//! the running cumulative count until it crosses the requested //! an overflow sentinel for anything past the top edge. The size is
//! threshold. Linear interpolation inside a bucket gives a closer //! chosen to fit the problem rather than the integer width — a 64-bit
//! number than "the bucket's lower bound" without bringing in a real //! log2 layout would leave ~40 dead buckets above 100 ms.
//! histogram crate. Karpathy heuristic: don't add a dep when 100 lines
//! of straight Rust covers the use case.
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
const BUCKET_COUNT: usize = 64; /// 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 recorded explicitly rather than summed so we keep /// `total_ns` is the real wall-clock span from request arrival to the
/// any per-frame overhead outside the three measured stages (e.g. /// stdout flush returning, so it captures every byte of overhead
/// snapshot reads, has-visible-content checks) accounted for. /// outside paint/encode/write (snapshot reads, JSON parse, scratch
/// allocations). It is measured at the loop boundary, not summed.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub(super) struct FrameStageTimings { pub(super) struct FrameStageTimings {
pub paint_ns: u64, pub paint_ns: u64,
@@ -99,22 +107,37 @@ impl StageHistogram {
} }
} }
/// 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 { fn bucket_for(ns: u64) -> usize {
if ns == 0 { if ns < 1_000 {
return 0; return 0;
} }
let log = 64 - ns.leading_zeros() as usize; let us = ns / 1_000;
log.min(BUCKET_COUNT - 1) // `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
/// 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 { fn bucket_midpoint_us(bucket: usize) -> u64 {
if bucket == 0 { if bucket == 0 {
return 0; return 0;
} }
let low_ns = 1u64.checked_shl((bucket - 1) as u32).unwrap_or(u64::MAX); if bucket >= BUCKET_COUNT - 1 {
let high_ns = 1u64.checked_shl(bucket as u32).unwrap_or(u64::MAX); // Overflow band starts at `2^DOUBLING_BUCKETS` µs.
let midpoint_ns = low_ns.saturating_add(high_ns) / 2; return 1u64 << DOUBLING_BUCKETS;
midpoint_ns / 1_000 }
let low_us = 1u64 << (bucket - 1);
let high_us = 1u64 << bucket;
(low_us + high_us) / 2
} }
/// Aggregates a rolling window of [`FrameStageTimings`] across N /// Aggregates a rolling window of [`FrameStageTimings`] across N
@@ -198,22 +221,40 @@ pub(super) struct FramePerfSummary {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{FramePerfAggregator, FrameStageTimings, bucket_for, bucket_midpoint_us}; use super::{
BUCKET_COUNT, DOUBLING_BUCKETS, FramePerfAggregator, FrameStageTimings, bucket_for,
bucket_midpoint_us,
};
use std::time::Duration; use std::time::Duration;
#[test] #[test]
fn bucket_for_handles_zero_and_small_values() { fn bucket_for_routes_sub_microsecond_samples_to_underflow() {
assert_eq!(bucket_for(0), 0); assert_eq!(bucket_for(0), 0);
assert_eq!(bucket_for(1), 1); assert_eq!(bucket_for(1), 0);
assert_eq!(bucket_for(2), 2); assert_eq!(bucket_for(999), 0);
assert_eq!(bucket_for(3), 2); }
assert_eq!(bucket_for(4), 3);
#[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] #[test]
fn bucket_midpoint_is_monotonic_increasing() { fn bucket_midpoint_is_monotonic_increasing() {
let mut last = 0; let mut last = 0;
for bucket in 1..64 { for bucket in 1..BUCKET_COUNT {
let value = bucket_midpoint_us(bucket); let value = bucket_midpoint_us(bucket);
assert!(value >= last, "bucket {bucket} midpoint regressed"); assert!(value >= last, "bucket {bucket} midpoint regressed");
last = value; last = value;