zgui
Performance

Writing fast interfaces

The practical rules that follow from the cost model, each named against the measured behaviour it follows from.

The cost model says what each kind of change costs. This page says what to do about it. Every rule below names the measurement or the mechanism it follows from, and nothing is here because it sounds prudent.

Lists

Key every list, and never by index

use zgui::prelude::*;

#[derive(Clone, Debug)]
struct Todo {
    id: u64,
    label: String,
}

view! {
    column(class = "list") {
        for item in move || items.get(), key = |item: &Todo| item.id {
            row(class = "todo") { text {{item.label.clone()}} }
        }
    }
}

When the collection changes, the list compares the new key list with the old one and produces one step per position: keep, move, or create. A kept row is placed again and is not rebuilt. It keeps its nodes, its signals, its timers and the data it was built with.

That last clause is why an index is the wrong key. Insert one item at the front of a three-row list:

Key functionOld keysNew keysPlanWhat is drawn
item.id7, 8, 96, 7, 8, 9create, keep, keep, keepthe new row, then the three old ones
the index0, 1, 20, 1, 2, 3keep, keep, keep, createthe three old rows unchanged, then the last one twice

The second row is not a slow list. It is a wrong list: the new item never appears, and the old last item is drawn at two positions. An index is a position, and a position is not an identity.

A good key also decides the work. Inserting at the front of a thousand-row list is one node insertion, not a thousand rewrites.

Two rows that produce the same key make "which row is this" unanswerable, so the list panics and says so. The check runs in every profile, release included, because a check compiled out of the build you ship is a check for the one case that never happens.

Give each row its own signal

The list's only reactive dependency is the closure after in. Every write to that closure's sources re-runs it, and one re-run reads the whole collection, clones every key, builds a map of every key and produces a plan — work proportional to the whole list, before anything on screen has changed.

So keep what changes inside a row out of the collection:

#[derive(Clone, Debug)]
struct Todo {
    id: u64,
    label: String,
    done: RwSignal<bool>,     // the row's own state, not the list's
}

view! {
    for item in move || items.get(), key = |item: &Todo| item.id {
        row(
            class = "todo",
            class:done = move || item.done.get(),
            on:click = move |_| item.done.update(|done| *done = !*done)
        ) {
            text {{item.label.clone()}}
        }
    }
}

Ticking one item writes that item's done. The collection signal is untouched, so the list does not compare keys at all, and one class binding runs.

What that buys, measured: one class toggled on one element of an 1 851-box document is 11.34 µs (kitchen.click, docs/performance.md). Over a document of ten thousand controls, the same change restyles one element, lays out none, diffs six fragments, emits one primitive and takes one draw-order insertion — at every size from 1 250 controls to 10 000 (static-slope, docs/perf/reference-workloads.md).

A row that reads the collection to find itself has subscribed to the collection again. Give the row its value, or a Field handle, and let it read that.

Use a Selector for "which one is selected"

Selection written as a plain signal makes every row read it, so every row subscribes to it, and moving the selection wakes all of them. A Selector keeps one small signal per watched key.

use zgui::reactive::Selector;

let selected = RwSignal::new(0_u64);
let selector = Selector::new(move || selected.get());

view! {
    for item in move || items.get(), key = |item: &Todo| item.id {
        row(
            class = "todo",
            class:selected = move || selector.is_selected(&item.id),
            on:click = move |_| selected.set(item.id)
        ) {
            text {{item.label.clone()}}
        }
    }
}

Measured directly in the repository: eight rows produce eight first runs, and moving the selection produces exactly two more — the row that lost it and the row that gained it (only_the_two_affected_rows_re_run, crates/zgui-reactive/src/reexport/selector.rs). See Stores and selectors.

Read the narrow thing

An observer of a store's root, or of any ancestor field, re-runs on any descendant write. The isolation a store gives you is only there if you read at the depth the write happens at.

Hand a holeWakes on
Store<Settings>every write to every field. Never do this.
store.rows()every write to every row
store.rows().at_key(id).value()that one field of that one row
Field<u32>whatever field the handle names

The same rule applies to a plain signal holding a struct: a wide value read by many holes wakes them all, and each one re-runs to produce the same answer.

Branching

Prefer if to a closure hole

// Compares the answer. Adding a row to a list that already had rows swaps nothing.
if move || items.get().is_empty() {
    label(class = "empty") {"Nothing to do yet."}
} else {
    column(class = "list") { … }
}

if reads its condition inside one effect and compares the boolean. A closure hole compares nothing: it re-runs whenever any signal it read changed, and rebuilds what it produced.

The difference is not only work. Rebuilding a subtree runs its cleanups, cancels and restarts its timers, and rebinds every handle inside it — so a subtree rebuilt on an unrelated write is a subtree that loses whatever it was keeping, including a scroll position and a half-typed field.

BranchesUseCompares
two, known in advanceifthe boolean
many, of different view typesDynamicthe view type; the same type rebuilds in place
none — the branch produces a value, not a subtreea closure holenothing, but a text node compares the string it writes

The last row is worth keeping in mind: a hole that produces the same string writes nothing to the document, because the text node remembers its last value. That hole costs the closure and no more.

Where a memo pays for itself

A memo does two things: it runs its closure once per flush however many readers it has, and it stops the notification when the new value compares equal to the old one.

SituationMemo?Why
several holes, or several rows, read one derived valueyesone computation instead of one each
the answer is narrower than its inputs and the reader rebuilds a subtreeyesthe reader is woken only when the answer moves
one reader, and it is a text holenothe text node already compares the string it writes
in front of an if conditionnoif already compares the answer
a.get() + b.get()nothe memo's own bookkeeping costs more than the addition
the value is a large Vec or String that is cheap to rebuildnoevery run pays a full PartialEq over it

A memo is not a way to coalesce writes and does not need to be. The notification behind each task holds one slot, so a signal written a thousand times between two flushes costs one re-run, not a thousand (crates/zgui-reactive/src/executor/frame.rs).

Style

Toggle the class on the element, not on an ancestor

The repository measures exactly this, with one declaration reached two ways:

.cell.hot   { background-color: rgb(47, 107, 255) }   /* a class on one control */
.warm .cell { background-color: rgb(47, 107, 255) }   /* a class on the root, above every control */

One property, two reaches. The ratio of the local change to the ancestor change is gated at ≤ 0.09 and measured at 0.0679–0.0702 (STATIC-locality, crates/zgui-bench/src/bin/static-slope/criteria.rs). In the advisory numbers beside it, the local change costs 70.7–72.8 ns per control and the ancestor change 1 031–1 041 ns per control (docs/perf/reference-workloads.md).

Two mechanisms sit under that.

  • A class no selector mentions costs nothing at all. Before any style work is scheduled, the framework asks whether any selector mentions this class name, whether any mentions this attribute name, and which state bits could matter for this element. A change nothing depends on needs no record, no marking and no traversal.
  • A class an ancestor rule sits under reaches every descendant those rules could match. The dependency index is built from the rule set's invalidation map rather than from the matching map, precisely so that .warm is known to matter (crates/zgui-style/src/deps/class_set.rs).

For a theme, prefer a custom property on a container. It inherits, so one declaration re-themes the subtree with no rule matching anywhere (Styling). Prefer either to swapping a sheet: the frame in which the installed sheet set changes disables the filters above, so every mutation in that one frame takes the full path.

Prefer a colour change to a size change

Where the design accepts either, change the colour.

ChangeReaches
color, background-color, a border colour, a shadowrestyle, paint, draw
width, padding, gap, font-sizerestyle, layout, paint, draw

A repaint cost yields empty layout damage, so the box tree, the layout, the fragment geometry and the shaped text are all still valid and are all kept. A geometry cost relays out the box, its descendants, and its ancestors up to the first whose size does not depend on it. Both rows are measured in the cost model.

The same choice appears in animation: a transition on opacity or transform does not enter layout, while one on width does on every frame of it.

Damage is at most four disjoint rectangles, and adding a fifth merges the pair whose union wastes the least area (MAX_DAMAGE, crates/zgui-bits/src/damage_set/mod.rs). A frame that changes five unrelated places therefore redraws the bounding box of two of them. Changes that happen together are cheaper when they are near each other.

Avoid intrinsic sizing you do not need

fit-content, min-content, max-content and an auto flex basis all ask the content how big it wants to be, and for text that means shaping it. A fixed size, a percentage or a flex share asks nothing.

For the content keywords, zgui keeps intrinsic answers across frames. A new layout pass does not measure an unchanged fit-content box again. A content, style, scale, or gutter change invalidates the affected answer. Avoid unnecessary intrinsic sizing because the first measurement and each valid invalidation still lay out the complete subtree at min-content and max-content.

The case to watch is a grid. Track sizing measures every item at min-content and again at max-content, once per pass of an algorithm that runs several passes per axis, with an area estimate that moves between the passes. One item is asked ten questions of the same shape and different numbers. The framework holds sixteen complete size-only answers per box for this case.

The instrument is sizes_measured against sizes_held. A grid whose items are sized intrinsically and whose measurements are missing shows up there first.

Know which ancestors composite their whole subtree

A stacking context is a subtree that is painted as one unit, at one place in its parent's paint order. An offscreen target is a texture that subtree is drawn into before it is placed. They are different lists, and only the second one costs a frame.

Establishing a stacking context is nearly free and changes the paint order. Anything that changes whether one is established owes a restack, a re-hit and a repaint, and moves the whole subtree in front of or behind content it used to sit beside. Layout has the list.

An offscreen target is the expensive one:

On an ancestorTarget?
opacity below 1, subtree ink that does not overlap itself, nothing blending belowno — the alpha is multiplied into each primitive's own paint
opacity below 1, subtree ink that overlaps, or any blending descendantyes
opacity: 0no — the group contributes no pixel
a two-dimensional transform, translate, rotate or scaleno — the draw call applies it
perspective, transform-style: preserve-3d, backface-visibility: hiddenyes
any filter or backdrop-filteryes
mix-blend-mode other than normalyes
isolation: isolateyes
a clip-path shape or box keywordyes

All of it is decided in crates/zgui-paint/src/emit/group.rs and crates/zgui-paint/src/lower/. The counter is group_targets.

So a fade written as opacity on a panel whose children do not overlap costs no target, and the same fade over overlapping content costs one. A filter: blur(…) on an ancestor costs one unconditionally, on every frame that reaches it.

Long lists

Virtualise

Build the rows the scrollport can show, and let the container's own padding stand in for the rest.

const ROW: f32 = 24.0;
const OVERSCAN: usize = 4;

const SHEET: &str = css!(
    ".list__pane {
        display: flex;
        flex-direction: column;
        padding-top: var(--lead, 0px);
        padding-bottom: var(--trail, 0px);
    }
    .list__pane > * { height: 24px; flex: none }"
);

#[component]
fn Rows(rows: Signal<Vec<Todo>>) -> impl IntoView {
    let offset = RwSignal::new(0.0_f32);
    let port = RwSignal::new(0.0_f32);

    let first = move || ((offset.get() / ROW) as usize).saturating_sub(OVERSCAN);
    let count = move || (port.get() / ROW).ceil() as usize + OVERSCAN * 2;
    let shown = move || {
        let all = rows.get();
        let start = first().min(all.len());
        let end = (start + count()).min(all.len());
        all[start..end].to_vec()
    };

    view! {
        scroll(
            class = "list",
            on:scroll = move |ev| {
                offset.set(ev.offset.y.0);
                port.set(ev.scrollport.height.0);
            }
        ) {
            column(
                class = "list__pane",
                var:--lead = move || Some(format!("{}px", first() as f32 * ROW)),
                var:--trail = move || {
                    let below = rows.get().len().saturating_sub(first() + count());
                    Some(format!("{}px", below as f32 * ROW))
                }
            ) {
                for item in move || shown(), key = |item: &Todo| item.id {
                    row(class = "todo") { text {{item.label.clone()}} }
                }
            }
        }
    }
}

Three details in that sketch are deliberate.

  • The space above and below is padding on the container, not two empty elements. A box that exists only to be empty is a box the style engine, layout and the painter each visit for nothing, twice a frame, for ever.
  • The window slides, so the keys of the rows that stayed do not change. Moving down by one row destroys one row, builds one row, and leaves the rest exactly where they are. That is the recycle frame measured below.
  • A virtualiser that has only a row count keys by the model index. That is an identity while the model does not move under the window, and stops being one the moment the model gains, loses or reorders rows. When the rows have identities of their own, use them.

The scrollport height arrives with the scroll event, so it is not known before the first scroll. Scrolling has the complete version — measuring the container, the glide and the scrollbars.

What it is worth

MeasurementValueSource
one translation frame, 10 000-row virtual list44.18 µs, with 0 restyles, 0 relayouts and 0 hit-index rebuildsscroll.translation, docs/performance.md
one recycle frame — rows entering and leaving1021.42 µsscroll.recycle, docs/performance.md
what one row shift actually does8 boxes rebuilt, 29 nodes relaid out, 404 primitives emitteddocs/performance.md
a wheel over 100 000 rows ÷ the same over 12 5000.9991–1.0123, gated at ≤ 1.05LIST-virtualisation-wheel
fragments rebuilt per drawn glide frame7.52LIST-rebuilds-wheel
frames of a scroll that declared the whole surface damaged0, gated at ≤ 0LIST-full-frames

The 8 and the 29 are the row that arrived. The 404 are the viewport, and they are what the rest of a recycle frame costs. Eight times the data changes the wheel cost by an amount indistinguishable from zero.

Without virtualisation the same shape is a cliff rather than a slope. Over a document with every row mounted, a glide frame costs 172 ns per box at 17 507 boxes, 287 at 70 007, and 5 660 at 140 007 — about 792 ms a frame at twenty thousand rows (unvirtualised-probe, docs/perf/reference-workloads.md). A rigid scroller crosses a 16.6 ms budget at roughly 160 000 boxes (docs/perf/glide-split.md).

Carrying rows past a port is not free: one glide frame costs about nine tenths of repainting the same realised rows from their styles (LIST-glide = 0.92). Virtualisation wins by making the number of realised rows a property of the window instead of a property of the data.

Resize

A resize is the one interaction that genuinely invalidates everything, and there is nothing to keep. The cost model has the measured frame and the pacing table.

Do not debounce it yourself. Two mitigations are already in the frame loop. A configure arriving less than one frame of the output after the last resize frame is recorded and answered at the merged deadline, and a resize that repeats the extent runs no frame at all — the repository asserts the second by name (a_resize_that_repeats_the_extent_runs_no_frame_at_all, crates/zgui/tests/wall_clock.rs).

What is left for you is the size of the document and what a size change drags in with it.

LeverWhy
Fewer boxes in the windowa resize costs about 1.00 µs per box (resize-slope advisory, docs/perf/reference-workloads.md). Virtualising a list is the largest single reduction available.
Percentages and flex shares instead of vw and vha viewport unit resolves at computed-value time, so every size change makes the frame scan every element to find the ones that read one (crates/zgui-style/src/device/viewport.rs). A document with none marks nothing.
Few media-query boundaries, far aparta resize that crosses no boundary disturbs no rule at all. One that crosses a boundary re-collects that origin's rules and restyles every element.
No intrinsic sizing you do not needthe first measurement and valid invalidations are nested subtree layouts; unchanged intrinsic answers are reused during resize.

A scale-factor change is worse than a resize and you cannot avoid it: it throws away every box's layout and misses both caches keyed on device size. Do not treat a display change as a resize you can smooth over.

Finding out which one is your problem

Do this in order. Each step is cheaper than the one after it.

Count the frames. Install a FrameProbe and see whether frames are being drawn at all. A still document must draw zero. A window that draws when nothing happened is a different bug from a window whose frames are expensive.

Read the counters. They say what a frame did, in numbers rather than durations, so an answer survives being measured on a slow machine.

Set ZGUI_FULL_DAMAGE=1 and restart. If the artefact goes away, it is a damage-tracking problem and not a drawing one. This is the first thing to try when something looks wrong on screen.

Set ZGUI_LATENCY=/tmp/trace.jsonl and reproduce the interaction. The desktop backend starts the recording for you, and the frame loop marks every stage. What is recorded is instants, so every gap between two consecutive marks is visible whether or not anyone named the thing that filled it.

Open the inspector with F12 in a program that has one wired in, to see what one element computed to and why it is the size it is.

The probe

use std::cell::Cell;
use std::rc::Rc;
use zgui::runtime::{FrameProbe, Window};

#[derive(Default)]
struct Frames(Cell<u64>);

impl FrameProbe for Frames {
    fn frame_ended(&self, window: &Window) {
        self.0.set(self.0.get() + 1);
        // The scene, the damage, the layout and the caches this frame left behind.
        let _ = (window.scene(), window.damage());
    }
}

fn main() -> Result<(), zgui::Error> {
    let probe: Rc<dyn FrameProbe> = Rc::new(Frames::default());
    app().with_probe(probe).run(|| view! { Shell() })
}

The probe is called once at the end of every frame and handed the window as it stands. It takes &self, so anything it keeps goes through a cell or a signal. One seam, one occupant: installing a second replaces the first.

The counters

Add the crate that owns them. The framework already compiles them in — zgui-runtime names the feature — but the umbrella does not re-export them, so an application that wants to read them names the crate itself.

[dependencies]
zgui-profile = { path = "../zgui/crates/zgui-profile", features = ["counters"] }
use zgui_profile::{COUNTERS_ENABLED, Counter, Counters, counter};

assert!(COUNTERS_ENABLED, "this build has no counters compiled in");

let before: Counters = counter::snapshot();
// … the interaction …
let after = counter::snapshot();
let frame = before.delta(&after);

println!("{frame:?}");                              // only the non-zero fields
println!("{}", frame.get(Counter::ElementsRestyled));

Counters accumulate until counter::reset() is called; nothing resets them per frame. Take two snapshots and subtract.

The questionThe counterWhat a healthy answer looks like
Did one change restyle more than one thing?elements_restyledthe elements you actually changed
Is a stage walking the whole document?nodes_visitedthis is the counter that notices a traversal touching six thousand clean nodes to service one
Was the box tree rebuilt?boxes_rebuiltzero for a colour, a class or a hover
Did layout run at all?layouts_held against nodes_relaid_outheld, for any frame that moved no box
Did layout start from the root?layout_reached_rootrarely; a resize, a scale change, a first frame
Are intrinsic measurements missing?sizes_held against sizes_measuredmostly held once the document has settled
Is text being reshaped?text_shaped, text_rebrokenshaping is more expensive than breaking
Are glyphs being rasterised again?glyphs_rasterised against glyphs_placedzero rasterised on a repaint of unchanged text
Is the display list being replayed?chunks_translated against chunks_reencodedtranslated, for anything that only moved
Is anything being culled?primitives_culled against primitives_emittedover the hover-storm scenario, 96 % of primitives considered are culled
Are ancestors forcing offscreen targets?group_targetsone per genuinely isolated subtree, and no more
Is the hit index being rebuilt?hit_index_rebuildszero while scrolling
Is the loop spinning?wakesone per thing that asked for one

Read the pairs as pairs. A counter of avoided work reads zero when everything is broken and zero when everything is perfect, so it means nothing without the counter of work done beside it.

The checklist

  • Every list has a key, and no key is an index into the rendered list.
  • Row state lives in the row, not in the collection signal.
  • "Which one is selected" is a Selector, not a signal every row reads.
  • Holes read a store field or a Field handle, never the store root.
  • A two-way branch is if; many branches are Dynamic; a value is a closure hole.
  • Memos sit where several readers share one answer, and nowhere else.
  • A class that changes often is on the element it affects, not on an ancestor every rule sits under.
  • A theme is custom properties on a container, not a sheet swap.
  • Where the design accepts either, the change is a colour and not a size.
  • Intrinsic sizes are used where they are needed and not by default, especially inside a grid.
  • No ancestor carries a filter, a blend mode or an isolation it does not need.
  • Any list that can hold thousands of rows is virtualised.
  • No vw or vh where a percentage or a flex share would do.
  • The application has a FrameProbe, and you have read its counters at least once.

Next

On this page