What is measured, where the counters are, and what they are protecting.
This document does not contain benchmark figures for hardware you do not have. It names the counters, says what each one defends, and reports the numbers actually observed on the one machine this was developed against.
| Goal | Enforced by |
|---|---|
| Idle CPU near zero when nothing changes | RedrawPolicy::Idle blocks the loop |
| No layout work for paint-only updates | DirtyFlags::PAINT does not propagate |
| Very low allocation per frame | Reused scenes, growable buffers, pooled targets |
| Minimal draw calls | Run-length batching, one pipeline for the common primitive |
| Fast text cache hits | Shaping cache, size-independent glyph fields |
Every layer reports what it did. These are the numbers to watch; a regression shows up here before it shows up as a dropped frame.
| Counter | Watch for |
|---|---|
nodes_laid_out |
Anything but zero on a paint-only frame. The headline number. |
nodes_restyled |
Growth means styles are rebuilt when they did not change |
passes / skipped_passes |
A low skip ratio means something is over-invalidating |
nodes_laid_out counts layout-algorithm invocations, not distinct nodes: a flex container may
size a child twice under different constraints, and hiding that would make the number reassuring
rather than useful.
| Counter | Watch for |
|---|---|
nodes_created |
Anything but zero on a steady frame means identity is unstable |
nodes_reused |
Should equal elements in steady state |
nodes_removed |
Churn means elements are appearing and disappearing |
elements_culled |
Should be large in a scrolled list; zero means culling is not working |
| Counter | Watch for |
|---|---|
commands |
Total recorded |
culled |
Rejected before an instance was written |
batches |
Draw calls. A jump at constant wall-clock is still a regression |
quads / glyphs / triangles |
Instance counts by kind |
| Counter | Watch for |
|---|---|
draw_calls, pipeline_switches |
State changes, the thing batching exists to reduce |
bytes_uploaded |
Should fall to near-constant once caches are warm |
layers |
Each one is a render target and an extra pass |
| Counter | Watch for |
|---|---|
TextSystemStats::hit_rate |
Should sit near 1.0; a low rate means a key changes every frame |
glyph_texels_uploaded |
Should reach zero once every on-screen glyph is rasterised |
AtlasStats::utilization |
Low with many pages means packing is fragmenting |
CacheStats::collisions |
Expected to stay at zero forever |
GrowableBuffer::grew() counts reallocations over the buffer's lifetime. In steady state it stops
increasing. A number that keeps climbing means a workload is still growing, or is oscillating
around a capacity boundary.
Designed out rather than optimised later:
Scene::resetclears nine vectors and keeps every allocation. A freshSceneper frame would re-grow all nine every time.GrowableBuffergrows geometrically to a power of two and never shrinks in normal operation.Tessellatorholdslyon's scratch buffers across frames.- Offscreen layer targets come from a pool keyed on size, released after 60 unused frames. Freeing them the moment one frame skips them would reallocate every time a menu opened and closed.
RealtimeFramecarries a scratchVecfor envelope extraction; a test asserts its capacity stops changing after the second frame.- Hit testing has a
hit_test_all_intovariant, because hit testing runs on every mouse move and allocating there would be a per-frame allocation in the most frequent code path in the engine. SmallVecinline capacity covers glyph runs, gradient stops, the canvas state stack, the hit chain and handler lists.
Every cache has a key, a byte budget, an eviction policy and hit/miss counters. There is no cache in the engine that can grow without bound, because unbounded growth inside a host process that runs for eight hours is not a performance problem, it is a crash.
| Cache | Keyed on | Bounded by |
|---|---|---|
| Shaping | text + style + width | Bytes, LRU |
| Glyph atlas | GlyphKey |
Page count, LRU page eviction, idle-frame eviction |
| Image | Content hash | Bytes, LRU, with a this-frame guard |
| Pipeline | kind + target format + sample count | Bounded by construction — a fixed set |
| SVG geometry | id + size + scale | Bytes, LRU |
The surface and every offscreen layer are multisampled 4× so that tessellated paths have smooth edges. That is not free:
- The surface's multisampled attachment is
width × height × 4 bytes × samples. At 1180×720 that is roughly 13 MB; at 4K it is roughly 130 MB. - Every layer allocates a second texture, because a multisampled texture cannot be sampled and has to resolve into a plain one before compositing.
WgpuRenderer::memory_usageaccounts for both, so the cost is visible rather than inferred.
Two things keep it from being worse than it needs to be. The multisampled attachment stores
Discard on any pass that will not be resumed, so the 4× buffer is never written back to memory —
only the resolve is. And the count is chosen once at startup from what the formats actually support,
so a device that cannot manage 4× silently runs at the highest it can rather than failing.
SurfaceOptions::msaa_samples = 1 turns it off entirely. Everything except tessellated paths is
antialiased analytically and looks identical either way.
Culling happens before an instance is written, not after, and increments SceneStats::culled.
Two cases need care and both have tests: a shadow must be culled against its blurred footprint, and
a stroke against bounds grown by half its width.
Batching is run-length over adjacent compatible commands. A global sort would batch better and draw wrong, because UI is painted back to front. Within a compatible run the merge is total: 500 rectangles sharing a clip become one draw call, and a test asserts exactly that.
NVIDIA GTX 1060 3 GB, Vulkan, Windows 11, --release, 180 frames of the plug-in demo:
draw calls: 26
pipeline switches: 25
quad instances: 10018
glyph instances: 178
mesh triangles: 1980
elements built: 39
elements painted: 39
nodes created: 0
nodes reused: 39
nodes laid out: 0
worst laid out: 0
cpu this frame: 1.150 ms
worst cpu frame: 2.243 ms
glyph texels up: 0
Read that as: ten thousand rectangles, multilingual MTSDF text and three continuously-animating realtime visualisations, at roughly one millisecond of CPU per frame, with the layout engine doing nothing at all.
The 26 draw calls are what painter's order costs. The frame alternates panel backgrounds, labels and meshes, and merging across that boundary would draw the labels underneath the panels. Twenty-six for a full plug-in editor is the expected shape, not a defect.
These are figures from one GPU on one machine, reported because they were measured. They are not a guarantee about yours.
# Bounded run that prints the report above.
SPHEREKIT_DEMO_FRAMES=180 cargo run -p spherekit --example plugin_ui_demo --release
# GPU-side validation: every shader through naga, every pipeline built.
cargo test -p spherekit-wgpu --test pipelines -- --nocaptureOn Windows, set CARGO_INCREMENTAL=0 if a workspace-wide test run fails at the link step; see the
build notes in the README.
Honesty about the gaps:
- There is no Criterion benchmark suite yet. The counters above are what exists, and the demo's bounded run is the only end-to-end measurement. A benchmark crate is on the roadmap.
- GPU frame time is not measured.
FrameStats::gpu_msisNone; timestamp queries are detected as a capability but not yet used. - No golden-image or screenshot tests exist, and there is no GPU readback path to build them on.
spherekit-text'sglyph_quad_probeexample renders text to a PNG, but it re-implements the glyph shader on the CPU rather than capturing a frame, so it can check geometry and not the GPU. The two examples print a measured report underSPHEREKIT_DEMO_FRAMES, which catches structural regressions — a jump in draw calls, a nonzeronodes_laid_out— but not visual ones. - The figures above come from one discrete NVIDIA GPU. Integrated graphics, Metal and a software adapter are architecturally supported and untested for performance.