Copied to clipboard
The Pass · Frame Pipeline & Ledger

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.

1.21.3 on crates.io
121 Frames in 5 s at 24 fps The 3:2 pulldown, on a 60 Hz grid
49% Asks passed over At 30 fps — counted, not invisible
16.67 ms Budget at 60 fps Derived from the rate, not a second knob
0 C++ or system deps Pure Rust, so CI can exercise it
The seam

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();
        });
}
Package and library names

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.

Boundary

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.

1 · Decide

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.

2 · Account

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.

✗ · Pace presentation

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.

Architecture

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.

Opens begin_frame Samples the window
Gathers evaluate_roots View state → roots
Measures layout_roots Taffy or Morphorm
Draws paint_roots Quads, text, atlases
the same three, plus four to close out
Ready to present end_frame Files the frame's cost
Swaps in complete_frame Focus changes dispatched
Closes finish_frame Views touched recorded
The ledger FrameCost Seven times, one frame
The ledger

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.

Frame record sample values · target 16.67 ms (60 fps)
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 — and deferred_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 and over_budget(budget) counts the frames past it — no second knob to keep in step with the policy.
  • Is it true in CI? A FakeClock drives the decisions and the recorded costs, so a frame budget is an assertion: assert!(ledger.phase_percentile(Phase::Paint, 0.99) < budget())
Simulator

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 grid
24 fps does not divide 60, which is the interesting case.
4.0 ms
Illustrative: compare it against the interval below.
Interval 41.67 ms
Drawn 121 / 300
Delivered 24.2 fps
Passed over 59.7%
The 3:2 cycle — gaps between drawn asks 3,2,3,2,3,2,3,2,3,2…
A 4.0 ms pass fits a 41.67 ms interval — but remember the budget covers the passes, not the platform presenting them.

Note 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.

Mechanics

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.

Reading the ledger between frames
// 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
One clock, two answers

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.

What it costs

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.

What it is not

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.

Telemetry

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.

Frame 482 · passes · 1.80 ms seven scopes · sample values, not a frame time
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.

Stacking the emitter onto the pass
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();
Why puffin, not a C++ profiler client

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.

How to watch it

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.