should_render is the throttle. A RatePolicy
is read per frame from the metrics the seam already hands in, so one
install can run a focused window at 60 and a backgrounded one at 15.
Watch the pass. Time every ticket down to the microsecond.
Swaps replace what does the work. Wraps observe and pace it. The frame pipeline seam does two things: it decides whether an ask does the frame's work, and it sees every pass the frame is made of.
One decorator to install.
The frame pipeline is where every stage of every frame passes through — the one seam nothing reaches the screen without. FramePipeline is that boundary, and a decorator wraps it rather than replacing it, so an existing div() tree is untouched.
bite-gp-pass does both in one decorator — a
policy-driven rate on one side; a ledger of what the passes cost and how
many asks were passed over on the other; and, behind a
puffin feature, one profiler scope per pass — and it never
touches a single div().
The name is the kitchen's. The pass is the hot counter where every plate is checked, timed and paced before it leaves — and the frame pipeline is that one place in the engine: every stage of every frame goes through it, and nothing reaches the screen without it.
# Cargo.toml
[dependencies]
bite-gpui = "1.21"
bite-gp-pass = "1.21"
use gpui::*;
use gpui_pass::{FramePipelineExt, Ledger, RatePolicy};
use core::num::NonZeroU32;
fn main() {
let ledger = Ledger::shared(Ledger::DEFAULT_CAPACITY); // 240 frames, made once
application()
.with_frame_pipeline({
let ledger = ledger.clone();
move |_window_id| {
Box::new(
StandardImmediatePipeline.pass(
RatePolicy::at(NonZeroU32::new(60).unwrap())
.inactive_at(NonZeroU32::new(15).unwrap()), // a background window
ledger.clone(),
),
)
}
})
.run(|cx: &mut App| {
cx.open_window(WindowOptions::default(), |_, cx| {
cx.new(|_| MyView)
})
.unwrap();
});
}
The published package is bite-gp-pass, and its library
target is gpui_pass — so
cargo add bite-gp-pass and use gpui_pass::…,
with no package = key in your manifest.
Two things, and one thing it cannot do.
A pipeline is asked one question before a frame's work begins, and then handed every pass of the frames it admitted. That leaves room for exactly two jobs, and the honesty of the crate is in naming the third thing as impossible.
Every pass is timed into a Ledger that keeps the recent
frames rather than a running average, and counts the asks the throttle
passed over to draw them.
The seam names no swapchain, no vblank and no timestamp, and
WindowMetrics carries no refresh rate. What emerges is
the right rate landing on the display's grid — never a lock
to the raster. Where rigid cadence matters, count refreshes in the
platform.
The frame, as seven passes.
FramePipeline hands each pass through the pipeline in turn, so a decorator times each one without reaching into the engine. The ledger's FrameCost has a slot for every one — a pass a frame did not run reads as zero, not as missing.
A ticket for every frame.
Wrapping the pipeline in the facade's own InstrumentedPipeline gives you running totals of the passes that drew. It cannot give you the frames that did not — and it keeps no frame to measure a tail against. The ledger is both.
| phase | cost | note |
|---|---|---|
| begin_frame | 0.04 ms | — |
| evaluate_roots | 0.41 ms | — |
| layout_roots | 0.02 ms | cached subtree |
| paint_roots | 1.12 ms | the tail |
| finish_frame | 0.18 ms | — |
| Total passes | 1.77 ms | of 16.67 ms |
deferred_since_previous: 2 — this frame stood in for two asks the throttle passed over.
What the ledger answers
-
Was this frame held back, or was it slow? Totals
across drawn frames cannot tell the difference. The ledger counts
the asks that did not draw, so
deferral_ratio()says a throttle is dropping half the display's refreshes — anddeferred_since_previous()attributes them to the frame that stood in for them. -
What is the tail, not the mean?
phase_percentile(Phase::Paint, 0.99)reads the p99 out of the ring. A mean hides the one frame that hitched. -
Does the work fit the rate?
budget()is the interval the policy implies andover_budget(budget)counts the frames past it — no second knob to keep in step with the policy. -
Is it true in CI? A
FakeClockdrives the decisions and the recorded costs, so a frame budget is an assertion:assert!(ledger.phase_percentile(Phase::Paint, 0.99) < budget())
The admission rule, run for real.
This is not an illustration: the page runs the crate's own admission rule — a theoretical arrival time, one interval of slack as the burst, and the remainder carried — over five seconds of a 60 Hz ask grid, and reports what it admits. It is the same arithmetic tests/throttling.rs asserts.
300 asks · 60 Hz gridNote what 144 fps shows here: the grid still only offers 60 asks a second, so the throttle admits every one and the rate that arrives is the display's. That is the seam's ceiling, not the crate's.
One decorator, and still composable.
PassPipeline replaces the facade's .max_fps(n) and a metrics cell at once, because the decision and the accounting share a clock. It is still a decorator, so it stacks under anything else that wraps a pipeline.
// In a debug overlay's render, between frames — a RefCell borrow is taken.
let ledger = ledger.borrow();
let drawn = ledger.drawn();
let passed = ledger.deferred(); // the asks the throttle turned away
let p99 = ledger.phase_percentile(Phase::Paint, 0.99);
let over = ledger.over_budget(budget); // frames past the rate's own interval
The same Clock that decides whether an ask is too soon
times the passes. Install RealClock and the numbers are
the machine's; install FakeClock and they are the
test's.
The decision is one comparison against a carried schedule; the ledger writes a duration per pass into a ring sized once. No allocation per frame, and no new dependency — the crate's only runtime dependency is the trait it implements.
Not a pacer — it cannot place a pixel on a vblank. And the ledger is
a summary rather than a timeline: it sees the passes, not a syscall,
a GPU wait or which view was slow. A scope per pass is the other
shape, and it is the second decorator below, behind the
puffin feature.
A scope per pass, read back in-process.
The ledger keeps a summary — what each pass cost, how many asks were passed over. A timeline is the other shape a measurement takes, and it is PuffinPipeline, behind the off-by-default puffin feature: one puffin scope for every pass it forwards, and a frame boundary at the top of each frame. It is the same passes the ledger times, in the shape a viewer draws.
| scope | span |
|---|---|
| gpui::begin_frame | 0.04 ms |
| gpui::evaluate_roots | 0.41 ms |
| gpui::layout_roots | 0.02 ms |
| gpui::paint_roots | 1.12 ms |
| gpui::finish_frame | 0.18 ms |
| gpui::complete_frame | 0.02 ms |
| gpui::end_frame | 0.01 ms |
| Seven passes | one scope each |
should_render is deliberately unscoped — it runs per ask, not per drawn frame. The present is below the seam: the GPU submit and the swap are the platform's, and no pipeline sees them. A 60 fps frame is 16.67 ms.
use gpui_pass::PuffinPipeline;
// A scope measures whatever it encloses — wrap the pass in the emitter.
let pipeline = PuffinPipeline::new(
StandardImmediatePipeline.pass(policy, ledger.clone()),
);
// Scopes are off by default, and the sink is the caller's half.
// In-process, for a test or an example:
puffin::set_scopes_on(true);
let view = puffin::GlobalFrameView::default();
It is pure Rust, so a consumer links nothing it was not already
building — no C++ toolchain in every build. And it records scopes
in-process, so a test can turn them on, drive real frames
and read the frames back: tests/puffin.rs asserts
exactly one scope per pass, and
examples/puffin_scopes.rs prints the waterfall with no
display. A profiler whose data lives only inside an external GUI
cannot be checked in CI.
Emitting the scopes is the crate's half; the sink is yours.
puffin_http serves them over TCP for
puffin_viewer, or puffin_egui draws them
inside the application — whatever
GlobalProfiler::lock().add_sink was handed. The crate
depends on none of them.