Fling: the vsync clock, the frame ask, and a report that can say what it measured

Iris, from her phone: "some stuttering when flinging in particular.
Harder to notice with my finger directly moving the scroll." Her fling
phase was 103fps on a 120Hz screen at p50 6.3ms.

Two of the four things found are corrections to the instrument, not the
renderer. The swapchain acquire -- `get_current_texture`, which *blocks*
until the compositor frees an image -- was inside the span the report
called iris's CPU work, so a fling comfortably ahead of the display read
as milliseconds of being slow. A frame is now three measured parts
(`FrameParts`: build, acquire, submit), per phase as well as per run. And
nothing could say a frame was never *produced*: `late` counts frames that
cost too much, which a reader does not see, while a frame that never
happens leaves the last one up for two refreshes, which is the stutter.
`PhaseStats::missed` counts vsyncs nothing was drawn for. It closes on
the emulator: 1548 frames + 452 missed over 33.0s at 60Hz is 1980
vsyncs.

The other two are the frame loop. `Choreographer.postFrameCallback`
schedules for the next vsync after the call, and iris asked at the *end*
of the callback -- so any frame whose work ran past the boundary
registered too late and got the vsync after, one frame over budget
silently costing a second. It is asked for immediately after
`tick_animations` now, on both backends. And the fling was advanced on
`Instant::now()` rather than the vsync `do_frame` carries: frames are
presented on an even cadence whatever clock computes them, so sampling
the spline at "whenever the callback ran" moves the content unevenly with
no frame late enough to appear in any report -- and a drag never had it,
which is the asymmetry Iris described. `PointerClock` is `DeviceClock`
and the view keeps one, anchored by whichever of a touch or a frame comes
first, so a fling is advanced on the clock its velocity was measured on.

`opt-level` for the Android release build goes from "s" to 3. The table
in RUST.md picked "s" on bytes alone; over the same warm fling eight
times iris's own per-frame work is p90 0.15ms/p99 0.42ms at "s" against
p90 0.09ms/p99 0.26ms at 3, for 1.8 MB of arm64 APK.

`app-rust/tests/fling_profile.rs` is the rig that established what a
fling frame actually costs and is kept for next time (Iris: "please keep
the profiling rig around for future use"): only one frame in six lays
anything out, and the multi-millisecond spikes are all first-pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-09 00:49:28 -04:00
1 parent 227f5e1295
commit 144c402181
10 files changed
+477 -151

No files matched your search

+285 -57
View File
@@ -49,6 +49,29 @@ pub struct PhaseStats {
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// This phase's own medians of the three parts a frame is made of --
/// see [`FrameParts`]. Per phase as well as per run because the parts
/// do not divide the same way in every phase: a fling frame spends
/// most of itself in `acquire` (waiting its turn at the swapchain,
/// which is the display pacing the app and not work) while a
/// streaming frame spends it in `build`, and a run-wide median cannot
/// say that.
/// Vsyncs that went by with no frame produced for them, counted from
/// the gap between consecutive frames rather than from their cost.
///
/// **`late` and this are different questions and the second is the
/// one a reader sees.** A frame can be over budget and still be shown
/// on the next vsync; a frame that is never produced leaves the
/// previous one on screen for two refreshes, which is the stutter.
/// Nothing in a report could say this before 2026-09-09 -- the two
/// were folded together under `late`, so "we drew every frame, some
/// slowly" and "we skipped 1 frame in 8" read identically.
///
/// Zero on the first frame of a run, whose gap is unknowable.
pub missed: u64,
pub build_p50: Duration,
pub acquire_p50: Duration,
pub submit_p50: Duration,
/// `false` if this phase's frame count exceeds how many samples of it
/// are still in the ring -- the percentiles above are then computed
/// over whatever survived, not the whole phase. UI_RULES.md: this is
@@ -71,7 +94,11 @@ impl std::fmt::Display for PhaseStats {
" (ring evicted some of this phase)"
},
)?;
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
writeln!(
f,
" late: {} ({:.1}%) missed vsyncs: {}",
self.late, self.late_percent, self.missed,
)?;
writeln!(
f,
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
@@ -79,10 +106,78 @@ impl std::fmt::Display for PhaseStats {
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
)?;
writeln!(
f,
" build p50 {:.1}ms acquire p50 {:.1}ms submit p50 {:.1}ms",
self.build_p50.as_secs_f64() * 1000.0,
self.acquire_p50.as_secs_f64() * 1000.0,
self.submit_p50.as_secs_f64() * 1000.0,
)?;
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
}
}
/// The parts one frame's wall time divides into, measured rather than
/// inferred: what a caller hands [`FrameReport::record`].
///
/// The three are consecutive and together they are `total`, so whatever
/// is left after `acquire` and `submit` is the frame's own work -- laying
/// out, shaping text, building primitives and recording the render pass.
/// That leftover is what a report calls `build`.
///
/// **`acquire` is the one that is not work.** It is the wait inside
/// `Surface::get_current_texture` for a swapchain image to come free,
/// which is the display pacing the app: an app that draws faster than the
/// screen refreshes spends *most* of every frame there, and that is the
/// healthy state rather than a slow one. It was inside the CPU half until
/// 2026-09-09, which made a fling's frames read as several milliseconds
/// of iris being slow when they were milliseconds of iris waiting its
/// turn -- UI_RULES.md's rule against presenting an inferred value as a
/// measured one, arriving in a diagnostic.
#[derive(Clone, Copy, Default, Debug)]
pub struct FrameParts {
/// Redraw start to after `present()` was called -- the span the whole
/// report is about.
pub total: Duration,
/// The wait for a swapchain image (`get_current_texture`).
pub acquire: Duration,
/// `queue.submit` plus `present()`.
pub submit: Duration,
}
impl FrameParts {
/// A frame measured as one span, with no parts -- honest for a caller
/// that never measured them (they read as zero and `build` reads as
/// the whole frame) rather than fabricating a split.
pub fn whole(total: Duration) -> Self {
Self {
total,
acquire: Duration::ZERO,
submit: Duration::ZERO,
}
}
/// The two waits a renderer's `draw` measures, with `total` left at
/// zero for the frame loop around it to fill in -- it is the only
/// caller that knows when the frame started.
pub fn waits(acquire: Duration, submit: Duration) -> Self {
Self {
total: Duration::ZERO,
acquire,
submit,
}
}
/// What is left once the two measured waits are taken off: this
/// frame's own work. Saturating, since the three come from different
/// `Instant` pairs on a clock a caller owns.
pub fn build(&self) -> Duration {
self.total
.saturating_sub(self.acquire)
.saturating_sub(self.submit)
}
}
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
/// (RUST.md's I5 box, "Measurements taken" (b)): it instruments Android's
@@ -111,8 +206,21 @@ pub struct FrameReport {
/// same lifetime -- kept as a second ring rather than a ring of pairs so
/// the existing `ring`/percentile code above is untouched (RUST.md's I5
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
/// See [`FrameParts`] for how the three rings divide a frame up.
submit_ring: Box<[Duration; RING_CAPACITY]>,
/// The `acquire` half of each sample in `ring`, same index, same
/// lifetime -- see [`FrameParts::acquire`], which is the part that is
/// a wait rather than work.
acquire_ring: Box<[Duration; RING_CAPACITY]>,
/// How long before each sample the *previous* frame was, same index,
/// same lifetime -- the frame's own cadence rather than its cost. See
/// [`PhaseStats::missed`] for why a report needs both.
gap_ring: Box<[Duration; RING_CAPACITY]>,
/// When the last recorded frame was, on whatever clock the caller
/// dates its frames with -- `None` until the first one since a
/// `reset`, whose gap is unknowable and is recorded as zero rather
/// than guessed at.
last_at: Option<Instant>,
/// The absolute (0-based, since the last `reset`) frame index each
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
/// slices against `PhaseMark::start_index` to tell which recorded
@@ -145,12 +253,18 @@ pub struct FrameStats {
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// Median of `redraw_to_submit` -- iris's own CPU work (layout, text,
/// primitive building) up to and including building the `queue.submit`
/// call, per frame. RUST.md's I5 "Where iris's frame time goes" split,
/// added 2026-09-05 to answer "CPU or GPU?" with a number rather than a
/// guess.
/// Median of [`FrameParts::build`] -- iris's own CPU work per frame:
/// laying out, shaping text, building primitives and recording the
/// render pass. RUST.md's I5 "Where iris's frame time goes" split,
/// added 2026-09-05 to answer "CPU or GPU?" with a number rather than
/// a guess, and corrected on 2026-09-09 to stop counting the
/// swapchain wait below as iris's own work.
pub cpu_p50: Duration,
/// Median of [`FrameParts::acquire`]: the wait for a swapchain image.
/// **Not work** -- see that field's doc. A large number here beside a
/// small `cpu_p50` is an app comfortably ahead of the display, which
/// is what it should look like.
pub acquire_p50: Duration,
/// Median of `submit_to_present` -- the `queue.submit` call itself plus
/// `present()`, i.e. wherever the driver/GPU/compositor wait actually
/// happens. Same caveat as the type's own doc: `present()` is not
@@ -177,9 +291,10 @@ impl std::fmt::Display for FrameStats {
)?;
write!(
f,
" cpu_p50={:.1}ms gpu_wait_p50={:.1}ms (redraw-start-to-submit vs. \
submit-to-after-present)",
" cpu_p50={:.1}ms acquire_p50={:.1}ms gpu_wait_p50={:.1}ms (own work vs. \
waiting for a swapchain image vs. submit-to-after-present)",
self.cpu_p50.as_secs_f64() * 1000.0,
self.acquire_p50.as_secs_f64() * 1000.0,
self.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
@@ -190,6 +305,9 @@ impl FrameReport {
Self {
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
acquire_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
gap_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
last_at: None,
index_ring: Box::new([0; RING_CAPACITY]),
len: 0,
pos: 0,
@@ -199,28 +317,26 @@ impl FrameReport {
}
}
/// Record one frame's elapsed wall time, with no CPU/GPU split (the
/// `submit_to_present` half is recorded as zero, so `cpu_p50` reads as
/// the whole frame and `gpu_wait_p50` as nothing -- honest for a caller
/// that never measured the split, rather than fabricating one). O(1),
/// no allocation.
pub fn record(&mut self, elapsed: Duration) {
self.record_split(elapsed, Duration::ZERO);
}
/// Record one frame's elapsed wall time, split at `queue.submit`:
/// `submit_to_present` is the `queue.submit()` call plus `present()`;
/// `total - submit_to_present` is everything before it (layout, text,
/// primitive building). RUST.md's I5 "Where iris's frame time goes"
/// CPU/GPU split, added 2026-09-05. O(1), no allocation.
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
self.ring[self.pos] = total;
self.submit_ring[self.pos] = submit_to_present;
/// Record one frame, split into [`FrameParts`]. O(1), no allocation.
///
/// One entry point rather than one per shape of measurement: a caller
/// with nothing but a total passes `FrameParts::whole(total)`, which
/// says so in the type instead of leaving the report to guess from a
/// zero.
pub fn record(&mut self, at: Instant, parts: FrameParts) {
self.gap_ring[self.pos] = match self.last_at {
Some(last) => at.saturating_duration_since(last),
None => Duration::ZERO,
};
self.last_at = Some(at);
self.ring[self.pos] = parts.total;
self.submit_ring[self.pos] = parts.submit;
self.acquire_ring[self.pos] = parts.acquire;
self.index_ring[self.pos] = self.total_frames;
self.pos = (self.pos + 1) % RING_CAPACITY;
self.len = (self.len + 1).min(RING_CAPACITY);
self.total_frames += 1;
if total > JANK_THRESHOLD {
if parts.total > JANK_THRESHOLD {
self.janky_frames += 1;
}
}
@@ -236,9 +352,20 @@ impl FrameReport {
self.pos = 0;
self.total_frames = 0;
self.janky_frames = 0;
self.last_at = None;
self.phases.clear();
}
/// One recorded slot's three parts, back as the type they were
/// recorded in.
fn parts(&self, slot: usize) -> FrameParts {
FrameParts {
total: self.ring[slot],
acquire: self.acquire_ring[slot],
submit: self.submit_ring[slot],
}
}
/// Marks the start of a named phase at the current moment -- every
/// frame recorded from here until the next `mark_phase` (or `reset`)
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
@@ -281,13 +408,13 @@ impl FrameReport {
None => (self.total_frames, now),
};
let frames = end_index.saturating_sub(phase.start_index);
let mut samples: Vec<Duration> = (0..self.len)
let slots: Vec<usize> = (0..self.len)
.filter(|&j| {
let idx = self.index_ring[j];
idx >= phase.start_index && idx < end_index
})
.map(|j| self.ring[j])
.collect();
let mut samples: Vec<Duration> = slots.iter().map(|&j| self.ring[j]).collect();
let complete = samples.len() as u64 >= frames;
if samples.is_empty() {
return PhaseStats {
@@ -300,9 +427,40 @@ impl FrameReport {
p90: Duration::ZERO,
p99: Duration::ZERO,
worst: Duration::ZERO,
missed: 0,
build_p50: Duration::ZERO,
acquire_p50: Duration::ZERO,
submit_p50: Duration::ZERO,
complete,
};
}
// Each part gets its own sort: medians do not distribute
// over subtraction, so `build`'s median is not `total`'s
// minus the other two.
let part_p50 = |part: &dyn Fn(usize) -> Duration| {
let mut v: Vec<Duration> = slots.iter().map(|&j| part(j)).collect();
v.sort_unstable();
v[v.len() / 2]
};
// A gap of more than one and a half budgets means at
// least one vsync came and went unanswered; the count is
// how many, so a frame arriving three periods late says 2.
//
// **The phase's own first frame is skipped**: its gap
// reaches back into the previous phase, across whatever
// the run did between the two -- a bench pausing a second
// between phases would otherwise open each one with sixty
// "missed" frames nobody was waiting for.
let missed: u64 = slots
.iter()
.filter(|&&j| self.index_ring[j] > phase.start_index)
.map(|&j| self.gap_ring[j])
.filter(|gap| *gap > budget.mul_f64(1.5))
.map(|gap| (gap.as_secs_f64() / budget.as_secs_f64()).round() as u64 - 1)
.sum();
let build_p50 = part_p50(&|j| self.parts(j).build());
let acquire_p50 = part_p50(&|j| self.acquire_ring[j]);
let submit_p50 = part_p50(&|j| self.submit_ring[j]);
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
@@ -316,6 +474,10 @@ impl FrameReport {
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("checked not empty above"),
missed,
build_p50,
acquire_p50,
submit_p50,
complete,
}
})
@@ -337,11 +499,8 @@ impl FrameReport {
// medians do not distribute over subtraction, and each needs its
// own sort.
let submit_samples: Vec<Duration> = self.submit_ring[..self.len].to_vec();
let cpu_samples: Vec<Duration> = self.ring[..self.len]
.iter()
.zip(self.submit_ring[..self.len].iter())
.map(|(&total, &submit_to_present)| total.saturating_sub(submit_to_present))
.collect();
let acquire_samples: Vec<Duration> = self.acquire_ring[..self.len].to_vec();
let cpu_samples: Vec<Duration> = (0..self.len).map(|j| self.parts(j).build()).collect();
let median = |mut v: Vec<Duration>| {
v.sort_unstable();
v[v.len() / 2]
@@ -355,6 +514,7 @@ impl FrameReport {
p99: pct(99),
worst: *samples.last().expect("len > 0 checked above"),
cpu_p50: median(cpu_samples),
acquire_p50: median(acquire_samples),
gpu_wait_p50: median(submit_samples),
})
}
@@ -400,7 +560,7 @@ mod tests {
#[test]
fn one_frame_is_every_percentile_and_the_worst() {
let mut r = FrameReport::new();
r.record(Duration::from_millis(10));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(10)));
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, 1);
assert_eq!(stats.p50, Duration::from_millis(10));
@@ -415,7 +575,7 @@ mod tests {
// 100 samples, 1ms..=100ms, fed out of order so the ring's own
// order is not what gives the right answer -- the sort has to.
for ms in (1..=100).rev() {
r.record(Duration::from_millis(ms));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(ms)));
}
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, 100);
@@ -428,8 +588,14 @@ mod tests {
#[test]
fn jank_threshold_matches_gfxinfos_60hz_budget() {
let mut r = FrameReport::new();
r.record(Duration::from_nanos(16_666_667)); // exactly on budget: not janky
r.record(Duration::from_nanos(16_666_668)); // one ns over: janky
r.record(
Instant::now(),
FrameParts::whole(Duration::from_nanos(16_666_667)),
); // exactly on budget: not janky
r.record(
Instant::now(),
FrameParts::whole(Duration::from_nanos(16_666_668)),
); // one ns over: janky
let stats = r.report().unwrap();
assert_eq!(stats.janky_percent, 50.0);
}
@@ -440,12 +606,12 @@ mod tests {
// the percentage must reset to 0, not divide by a stale count.
let mut r = FrameReport::new();
for _ in 0..10 {
r.record(Duration::from_millis(50));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(50)));
}
assert_eq!(r.report().unwrap().janky_percent, 100.0);
r.reset();
assert!(r.report().is_none());
r.record(Duration::from_millis(1));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(1)));
assert_eq!(r.report().unwrap().janky_percent, 0.0);
}
@@ -455,32 +621,94 @@ mod tests {
// not fabricate a GPU-wait number -- it reads as zero, and the CPU
// half reads as the whole frame.
let mut r = FrameReport::new();
r.record(Duration::from_millis(20));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(20)));
let stats = r.report().unwrap();
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
assert_eq!(stats.gpu_wait_p50, Duration::ZERO);
}
#[test]
fn record_split_reports_each_halfs_own_median() {
fn each_part_reports_its_own_median_and_build_excludes_the_wait() {
let mut r = FrameReport::new();
// Three frames: total is always 30ms, but the CPU/GPU-wait split
// moves, so the two medians must be independent of each other and
// of `total`'s own median.
r.record_split(Duration::from_millis(30), Duration::from_millis(5));
r.record_split(Duration::from_millis(30), Duration::from_millis(10));
r.record_split(Duration::from_millis(30), Duration::from_millis(20));
// Three frames of the same 30ms total, with the split moving:
// each part needs its own sort, and `build` is what is left after
// both waits -- not the total, which is the bug this replaced
// (the swapchain wait used to be counted as iris's own work).
for (acquire, submit) in [(5, 2), (10, 3), (20, 4)] {
r.record(
Instant::now(),
FrameParts {
total: Duration::from_millis(30),
acquire: Duration::from_millis(acquire),
submit: Duration::from_millis(submit),
},
);
}
let stats = r.report().unwrap();
assert_eq!(stats.p50, Duration::from_millis(30));
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(10));
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
assert_eq!(stats.acquire_p50, Duration::from_millis(10));
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(3));
// 30-5-2=23, 30-10-3=17, 30-20-4=6 -> median 17.
assert_eq!(stats.cpu_p50, Duration::from_millis(17));
}
#[test]
fn a_gap_of_more_than_one_vsync_is_counted_as_a_missed_frame() {
// Cost and cadence are separate questions: every frame here is
// well inside its budget, so `late` is zero, and the run still
// skipped three vsyncs -- which is what a reader sees as a
// stutter and what nothing in a report could say before.
let mut r = FrameReport::new();
let base = Instant::now();
let budget = Duration::from_nanos(16_666_667);
r.mark_phase("fling");
// Frames at 0, 1, 2, 4 (one skipped), 5, 8 (two skipped) budgets.
for step in [0u32, 1, 2, 4, 5, 8] {
r.record(
base + budget * step,
FrameParts::whole(Duration::from_millis(2)),
);
}
let phase = r.phase_stats(Instant::now(), 60.0).remove(0);
assert_eq!(phase.late, 0, "no frame here was over its budget");
assert_eq!(phase.missed, 3);
}
#[test]
fn a_phase_does_not_inherit_the_pause_before_it() {
// The half the fix above had no reason to touch: a bench rests
// between phases, and that rest reaches the next phase's first
// frame as its gap. Charging it there would open every phase with
// a large invented `missed`.
let mut r = FrameReport::new();
let base = Instant::now();
let budget = Duration::from_nanos(16_666_667);
r.mark_phase("fling");
for step in [0u32, 1, 2] {
r.record(base + budget * step, FrameParts::whole(Duration::ZERO));
}
// A second of rest, then the next phase starts clean.
let after = base + Duration::from_secs(1);
r.mark_phase("type");
for step in [0u32, 1, 2] {
r.record(after + budget * step, FrameParts::whole(Duration::ZERO));
}
let phases = r.phase_stats(Instant::now(), 60.0);
assert_eq!(phases[0].missed, 0);
assert_eq!(
phases[1].missed, 0,
"the rest between phases is not a stutter"
);
}
#[test]
fn ring_wraps_without_growing_past_capacity() {
let mut r = FrameReport::new();
for i in 0..(RING_CAPACITY * 2) {
r.record(Duration::from_millis(1 + (i % 5) as u64));
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(1 + (i % 5) as u64)),
);
}
let stats = r.report().unwrap();
// total_frames keeps the full count even once the ring has wrapped.
@@ -494,7 +722,7 @@ mod tests {
#[test]
fn no_marks_means_no_phases() {
let mut r = FrameReport::new();
r.record(Duration::from_millis(5));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(5)));
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
@@ -503,11 +731,11 @@ mod tests {
let mut r = FrameReport::new();
r.mark_phase("a");
for _ in 0..5 {
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(10))); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
}
r.mark_phase("b");
for _ in 0..3 {
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(20))); // 20ms: late at 60Hz
}
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
@@ -529,7 +757,7 @@ mod tests {
fn the_last_phase_runs_until_now() {
let mut r = FrameReport::new();
r.mark_phase("only");
r.record(Duration::from_millis(1));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(1)));
std::thread::sleep(Duration::from_millis(20));
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
@@ -541,7 +769,7 @@ mod tests {
fn reset_clears_phase_marks() {
let mut r = FrameReport::new();
r.mark_phase("a");
r.record(Duration::from_millis(1));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(1)));
r.reset();
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
@@ -550,7 +778,7 @@ mod tests {
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
let mut r = FrameReport::new();
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
r.record(Duration::from_millis(10));
r.record(Instant::now(), FrameParts::whole(Duration::from_millis(10)));
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
}
+1 -1
View File
@@ -24,7 +24,7 @@ mod util;
pub use atlas::*;
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
pub use frame_report::{FrameParts, FrameReport, FrameStats, JANK_THRESHOLD};
pub use primitive::*;
pub use sdf::{distance_from_rect, rounded_rect_coverage};