# Performance

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](https://nativedesktop.formalsnake.dev/agents/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 with `cx.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 in `render`.
- No blocking calls in a handler: no `std::fs::read` of a large file, no synchronous HTTP, no
  `thread::sleep` (`clock-in-ui` catches 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}` and `motion.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

```sh
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.

