Performance
Budgets on the slowest machine, per-view rendering and keeping the main thread free.
The reference machine is e1504g: an Intel Core i3-N305 laptop with an integrated GPU and a 60 Hz panel. If it is smooth there, it is smooth everywhere. Rules in short: donts.md.
Budgets
| Measure | Budget | Measured so far |
|---|---|---|
| Cold start to first frame | under 300 ms | 106 ms median for a bare GPUI window |
| Idle CPU over 60 s, focused and unfocused | 0.0% | 0.000% for a bare GPUI window |
| Key press to present, p95 | under 16.7 ms at 60 Hz | |
| Scrolling 100k rows | no missed frames at the display rate | p95 17.0 ms, 1 to 2 missed of about 600 frames at 60 Hz (the first frame) |
| Resident memory | under 80 MB | 177 MB for a bare GPUI window while the GL path is still linked |
| One view's render | under 1 ms and under 500 nodes |
Idle means idle. A still window does no work: no frame runs unless a view is dirty or an animation is live, so a timer that polls or a view that notifies itself every frame shows up as CPU on a window nobody is touching.
How rendering stays cheap
cx.notify()re-renders one view. Its child entities are boundaries and stay as they were. Cost is the size of the dirty view, never the window, so split a busy screen into views along the lines where state changes: the sidebar, the list, the editor, the status bar.- nd compares the new element tree with the retained one and sends only changed properties to the backend. Building the same tree again is cheap; building a bigger tree than needed is not.
- Lists render the rows near the viewport. With
set_items_versioned, an owner re-render reuses every row whose version did not change. Prove it in tier 1 withcx.render_count(..)on a row view before and after an unrelated update.
Keep the main thread free
The main thread runs input, layout and every frame. Anything that can take more than a frame goes elsewhere:
- Network, disk, parsing, decoding:
nd::system::spawn(future, |result, cx| ..). The future runs on a worker; the closure brings the result back to the main thread. - Images:
image(..)decodes at display size on worker threads, newest request first, with an LRU in memory and a disk cache for downloads. Never decode a full-size image inrender. - No blocking calls in a handler: no
std::fs::readof a large file, no synchronous HTTP, nothread::sleep(clock-in-uicatches the last one).
Frame timing and monitors
Each window has its own frame clock at its display's refresh rate: 60 Hz on e1504g, 120 Hz ProMotion on a MacBook, 240 Hz and 144 Hz side by side on g815. A window dragged to another display switches clocks. So:
- Animate against time, never frame counts: sample
window.now()and let the frame clock decide how often that is. - Use the theme's
motion.{fast, normal, slow}andmotion.spring. They also honour the user's reduced-motion setting. - An animation re-renders its view every frame while it runs, so keep animated content in its own small view.
Measuring
nd diag scroll --rows 100000 # scroll a virtual list every frame, check the frame budget
cargo build --release # budgets are release numbers; debug builds are not comparable
nd diag scroll passes at no more than 2 missed frames with p95 within 1.25 display periods. On the GPUI hosts ND_ANIMATE=<seconds> re-renders every window every frame for that long and prints the frame line, which is how a heavy view shows its cost. Measure on the slowest machine you have, from a release build, before and after the change, and put the number in the commit or the README.