Make frame_perf total a real end-to-end timing and right-size buckets
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user