- name
- render-perf
- description
- Guides performance optimization work on the Grida Canvas Rust rendering engine (grida crate). Use when asked to benchmark, profile, optimize, or analyze rendering performance — panning, zooming, compositing, caching, culling, or frame budgeting.
# Grida Canvas Engine — Performance Development
Workflow and reasoning framework for render performance work on the
Grida Canvas Rust engine.
## When to Use This Skill
- Benchmarking canvas rendering
- Optimizing frame time for any camera operation
- Designing or modifying caching strategies
- Analyzing benchmark results
- Writing or auditing benchmarks
- Investigating performance regressions
---
## How to Orient Yourself
Before touching any code, build context by reading these sources in order:
1. **Read `crates/grida/AGENTS.md`** — crate conventions, test/check
commands, benchmarking instructions.
2. **Read `docs/wg/feat-2d/optimization.md`** — the master optimization
document. It describes every optimization strategy, measured costs, and
the rendering pipeline. This is the single most important file.
3. **Browse `docs/wg/research/chromium/`** — research notes on how Chromium
solves equivalent problems. Start with `index.md` for the map, then
read whichever documents match the problem at hand.
4. **Read existing benchmarks** — look in `crates/grida/benches/`
to understand what is already measured and how.
5. **Read `crates/grida_dev/src/main.rs`** — the `bench` and `bench-report`
subcommands show how GPU benchmarks work with real `.grida` scene files.
`bench-report` is the bulk mode that outputs JSON across all fixtures.
Use `grep` and `glob` to discover the current state of code rather than
relying on hardcoded paths. File locations shift as the engine evolves.
### Key discovery queries
| What you need | How to find it |
| ----------------------------------------- | --------------------------------------------------------------------------------------------------- |
| The renderer entry point | `grep "struct Renderer" --include="*.rs"` in `crates/grida/src/` |
| Camera change classification | `grep "enum CameraChangeKind" --include="*.rs"` |
| How compositor cache works | `grep "struct LayerImage" --include="*.rs"` and read the containing module |
| Promotion heuristics | `grep "fn should_promote" --include="*.rs"` |
| Where zoom invalidation happens | `grep "zoom_changed\|mark_all_stale\|invalidate_all" --include="*.rs"` in `src/runtime/` |
| Frame pipeline flow | Search for `fn queue\|fn flush\|fn draw\|fn frame` in the renderer file |
| Benchmark fixture scenes | `--list-scenes` flag on `grida_dev bench`, or run `bench-report` on a directory |
| Config toggles (compositing, atlas, etc.) | `grep "set_layer_compositing\|set_compositor_atlas\|set_interaction_render_scale" --include="*.rs"` |
| Existing `.plan.md` proposals | `glob "docs/wg/feat-2d/*.plan.md"` |
| Scene loading pipeline | `grep "fn load_scene" --include="*.rs"` in `src/runtime/scene.rs` |
| Layout engine entry point | `grep "fn compute\b" --include="*.rs"` in `src/layout/engine.rs` |
| Text measurement stats | `grep "ParagraphMeasureStats" --include="*.rs"` |
| Skip-layout config | `grep "skip_layout" --include="*.rs"` in `src/runtime/` |
| Load-bench CLI tool | Read `crates/grida_dev/src/bench/load_bench.rs` |
---
## The Benchmark Systems
There are four complementary benchmarks. The bulk report is the
recommended starting point for camera-motion work; the node-mutation
bench is the go-to for invalidation-pipeline work; the single-scene
bench and Criterion provide deeper investigation when needed.
| Class | What moves between frames | Tool | Measures |
| --------------- | ---------------------------------- | ---------------------------------- | ---------------------------------------------- |
| Camera motion | `Camera2D` (pan/zoom) | `grida_dev bench` scenarios | draw + compositor + caches on stable scene |
| Node mutation | Scene graph (translate / replace) | `grida_dev bench --translate` | `apply_changes` (invalidation pipeline) + draw |
| Bulk regression | All scenes, camera motion | `grida_dev bench-report` (JSON) | per-scene PassStats across the fixture set |
| Algorithmic | Synthetic grid, various operations | `cargo bench -p grida` (Criterion) | CPU-only cost with statistical CI |
### 1. Bulk benchmark report (`grida_dev bench-report`)
Runs all scenes in all `.grida` files and outputs a compact **JSON
report**. Use this to establish baselines, detect regressions across
the full fixture set, and identify which scenes/stages are slowest.
```sh
# All fixtures — recommended first step
cargo run -p grida_dev --release -- bench-report ./fixtures/ --frames 100 --output baseline.json
# Single file
cargo run -p grida_dev --release -- bench-report ./fixtures/test-grida/bench.grida --frames 200
# Local fixtures for broader coverage
cargo run -p grida_dev --release -- bench-report ./fixtures/local/ --frames 100 --output baseline-local.json
```
The JSON report contains per-scene results with:
- `nodes`, `effects_nodes` — scene complexity
- `fit_zoom` — the zoom level from `fit_camera_to_scene`
- `pan` / `zoom` — legacy passes with full `PassStats`
- `scenarios[]` — expanded scenario matrix (see below)
- `errors[]` — files that failed to load
Each `PassStats` contains:
- `avg_us`, `fps`, `min_us`, `p50_us`, `p95_us`, `p99_us`, `max_us` — latency distribution
- `queue_us`, `draw_us`, `mid_flush_us`, `compositor_us`, `flush_us` — per-stage breakdown
- `settle_us` — cost of the stable (settle) frame after the pass ends
Progress goes to stderr, JSON to stdout (or `--output path`). This
keeps the JSON clean for programmatic consumption.
### 2. Single-scene GPU benchmark (`grida_dev bench`)
Runs real scene data on the actual GPU backend (Metal/GL). This is the
ground truth for "does the user experience improve?"
```sh
# List scenes to pick the right one
cargo run -p grida_dev --release -- bench ./fixtures/test-grida/bench.grida --list-scenes
# Run a specific scene
cargo run -p grida_dev --release -- bench ./fixtures/test-grida/bench.grida --scene <N> --frames 200
```
**Always use `--release`.** Debug builds are 20-30x slower and produce
meaningless data.
The output reports legacy pan/zoom, then an expanded scenario matrix
covering 20+ scenarios across multiple gesture types. Each scenario
reports `min/p50/p95/p99/MAX` plus per-stage breakdown and settle cost.
**Scenario types in the expanded matrix:**
| Kind | Scenarios | What it tests |
| ----------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `pan` | slow/fast × fit/zoomed | Linear back-and-forth panning |
| `circle_pan` | small/large radius × fit/zoomed | Circular trackpad gesture (unpredictable edges) |
| `zigzag` | fast (continuous) / slow (with pauses) × fit/zoomed | Diagonal reading pattern with direction changes |
| `zoom` | slow/fast × around-fit/high | Zoom oscillation at different levels |
| `pan_with_settle` | slow/fast × fit/zoomed | Pan with settle frames interleaved every 12 frames |
| `zoom_with_settle` | slow/fast × fit/high | Zoom with settle frames interleaved every 12 frames — captures cache-cold spike after settle nukes zoom cache |
| `baseline_nocache_zoom` | slow/fast × fit/high | Forces `stable=true` on every zoom frame — no-cache baseline measuring raw full-draw cost for A/B comparison |
| `realtime` | fast/slow × fit/zoomed | **Real-time event loop simulation** with sleep, 240Hz tick thread, and settle countdown matching the native viewer |
| `frameloop` | 16/50/80/120/200/300/500ms interval | **Real FrameLoop path** — the only bench that captures stable-frame jank during panning (see below) |
| `frameloop_zoom` | 16/50/80/120/200/500ms interval | **Real FrameLoop path for zoom** — captures stable-frame intrusion during zoom gestures |
| `resize` | alternating viewport sizes | `--resize` flag. Measures `resize()` + `redraw()` cost per cycle (layout rebuild + cache invalidation + repaint) |
**SurfaceUI overlay measurement (`--overlay`):**
By default, benchmarks measure content rendering only — the SurfaceUI
overlay (frame titles, node badges, hit regions) is **not** included.
Pass `--overlay` to include overlay drawing after each content flush,
matching the real `Application::frame()` pipeline where
`draw_and_flush_devtools_overlay()` runs after `Renderer::flush()`.
```sh
# A/B test: content only vs content + overlay (MUST be sequential, never parallel)
cargo run -p grida_dev --release -- bench ./fixtures/test-grida/L0.grida --scene 22 --frames 200 && \
cargo run -p grida_dev --release -- bench ./fixtures/test-grida/L0.grida --scene 22 --frames 200 --overlay
# Bulk report with overlay
cargo run -p grida_dev --release -- bench-report ./fixtures/ --frames 100 --overlay --output overlay.json
```
The overlay cost is opt-in because it is a devtools feature, not user
content. Overlay cost scales with visible labeled nodes — viewport
culling skips off-screen labels, so zoomed-in views are nearly free.
At fit-zoom on large scenes (e.g. 500 labels visible), overlay
adds ~1.8ms per frame (paragraph layout dominates). At typical editing
zoom, the cost drops to ~190µs or less.
| Question | Flag |
| ---------------------------------------- | -------------- |
| Is content rendering fast enough? | (no flag) |
| Is the overlay adding visible latency? | `--overlay` |
| Compare content-only vs content+overlay? | Run both, diff |
The `realtime` scenarios use actual `thread::sleep()` between frames
and simulate the native viewer's 240Hz tick thread + settle countdown.
These produce frame timings that match what users actually see,
including settle-induced frame drops at their natural frequency.
The `frameloop` and `frameloop_zoom` scenarios go through the actual
`FrameLoop.poll()` / `complete()` path — the same code path as
`Application::frame()`. All other pan/zoom scenarios bypass `FrameLoop`
and call `queue_unstable()` directly, which means they never produce
stable frames mid-interaction. The `frameloop` scenarios sweep event
intervals from 16ms (fast flick) to 500ms (discrete clicks) and reveal
how `FrameLoop`'s stable-frame decisions affect the frame time
distribution at each speed. Use these when investigating panning or
zooming jank, adaptive timing, or pan/zoom image cache behavior.
**Choosing scenes:** Use `--list-scenes` to see what's available. Pick
scenes that stress the subsystem you're optimizing. For effects/caching
work, look for scenes with high promoted-node counts. For culling work,
look for scenes with many visible nodes but few effects.
### 3. Node mutation benchmark (`grida_dev bench --translate`)
Measures the **invalidation pipeline cost** — not camera motion.
Every frame mutates the scene graph (translates a target node), then
runs `apply_changes` + `queue_unstable` + `flush`. This is the bench
that captures real drag-a-node behavior, identical to what
`Application::frame()` + `EditorDocument::apply_and_mark` exercise in
the demo.
```sh
# List scenes, pick a target
cargo run -p grida_dev --release -- bench <path.grida> --list-scenes
# Translate the first suitable root node for 200 frames
cargo run -p grida_dev --release -- bench <path.grida> --translate first --frames 200
# Translate a specific node by id
cargo run -p grida_dev --release -- bench <path.grida> --translate 42 --frames 200
```
Output includes a separate line for **`apply_changes` timing** — this
is the cost of running the classifier + dirty-set dispatch + partial
rebuild, isolated from the draw phase. Treat this as the authoritative
number for "is invalidation slow?"
```
apply_changes: avg=3720us p50=3288us p95=30714us MAX=30714us
total frame: avg=65344us p50=62159us fps=15.3
queue: 1.3ms draw: 10ms mid_flush: 49ms compositor: 1µs flush: 7µs settle: 83ms
```
View on GitHub