| 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:
- Read
crates/grida/AGENTS.md — crate conventions, test/check
commands, benchmarking instructions.
- 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.
- 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.
- Read existing benchmarks — look in
crates/grida/benches/
to understand what is already measured and how.
- 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.
cargo run -p grida_dev --release -- bench-report ./fixtures/ --frames 100 --output baseline.json
cargo run -p grida_dev --release -- bench-report ./fixtures/test-grida/bench.grida --frames 200
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?"
cargo run -p grida_dev --release -- bench ./fixtures/test-grida/bench.grida --list-scenes
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().
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
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.
cargo run -p grida_dev --release -- bench <path.grida> --list-scenes
cargo run -p grida_dev --release -- bench <path.grida> --translate first --frames 200
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
Per-frame dispatch trace (GRIDA_INVALIDATION_LOG=1): opt-in
stderr log showing which flush branch fires each frame, with phase
timings:
GRIDA_INVALIDATION_LOG=1 cargo run -p grida_dev --release -- \
bench <path.grida> --translate first --frames 20
[invalidation] geometry dirty=1 (fast path)
[invalidation] rebuild_partial affected=5663 anchor=0us geom=847us patch=2669us
Reads: the classifier picked the Geometry fast path; the subtree
enumeration produced 5663 affected ids; per-subtree geometry took
847µs; the in-place layer/R-tree patch took 2669µs. Use this to
confirm that the expected branch fires and to spot phase-level
regressions.
Fixtures for mutation work: fixtures/local/perf/grida/ holds
large real-world scenes (e.g. 01-135k.perf.grida at 136k nodes) that
expose O(scene) work in the invalidation pipeline. Use these to
validate that "fast paths" are actually sublinear in scene size.
Verifying a fast path landed correctly: when adding a new
ChangeKind or subtree-scoped rebuild, always re-run the mutation
bench with GRIDA_INVALIDATION_LOG=1 and confirm (1) the expected
branch label appears in the log, and (2) the phase costs are O(affected)
not O(scene). A "fast path" that still prints scene-sized numbers
isn't fast — just read the log.
4. Criterion benchmark (bench_camera)
Runs synthetic scenes on a raster (CPU) backend. This isolates
algorithmic costs from GPU behavior and produces statistically rigorous
results with confidence intervals.
cargo bench -p grida --bench bench_camera
cargo bench -p grida --bench bench_camera -- heavy_compositing/zoom
Read the benchmark file's header comments to understand the full matrix
of scenes, configs, and operations. The naming convention is
{scene}_{config}/{operation}.
When to use which
| Question | Use |
|---|
| What's slow across all fixtures? | Bulk report (bench-report) |
| Baseline before/after a change? | Bulk report (save JSON, compare) |
| Detailed investigation of one scene? | Single-scene GPU bench |
| Is dragging / resizing / color-picking a node slow? | Mutation bench (--translate) |
| Is my invalidation fast path actually sublinear? | Mutation bench + GRIDA_INVALIDATION_LOG=1 |
Which cache rebuild dominates apply_changes? | Mutation bench — reads the phase breakdown in the log |
Did a ChangeKind classifier change regress other kinds? | Mutation bench on several targets (different kinds) |
| Is the algorithm itself faster? | Criterion |
| Is there a statistical regression? | Criterion (has CI) |
| What's the real frame time with GPU overhead? | Single-scene GPU bench |
| Does a config toggle actually help? | Both GPU benchmarks + Criterion |
| Does it match what users see in the app? | realtime scenarios (sleep + settle simulation) |
| Are there frame drops during gestures? | Check p99 and MAX in scenario stats |
| Is slow panning janky (stable frame spikes)? | frameloop scenarios (real FrameLoop path) |
| Is resize janky? | Single-scene GPU bench with --resize |
| Is the SurfaceUI overlay causing slowdowns? | A/B with --overlay flag on GPU bench |
The Verification Workflow
Every performance change follows this sequence. No exceptions.
Critical: all GPU benchmarks must run sequentially. Never run two
bench processes at the same time — GPU contention, CPU cache thrashing,
and memory bandwidth competition produce unreliable numbers. Chain A/B
runs with &&.
Step 1: Baseline
Run the bulk benchmark report BEFORE any changes. Save the JSON output
so you can compare against it after the change.
cargo run -p grida_dev --release -- bench-report ./fixtures/ --frames 100 --output baseline.json
For algorithmic changes, also run Criterion to get statistical baselines.
Step 2: Implement
Make the change. After each logical step, verify:
cargo check -p grida --all-targets
cargo clippy -p grida --no-deps --all-targets --all-features
cargo test -p grida
Step 3: Measure
Run the same benchmarks AFTER the change. Compare the numbers.
Step 4: Regression check
Re-run the bulk benchmark report and compare against baseline.json.
cargo run -p grida_dev --release -- bench-report ./fixtures/ --frames 100 --output after.json
A zoom optimization must not regress pan. An effects optimization must
not regress non-effects scenes. The bulk report covers all scenes
automatically — compare the full set, not just the target.
Step 5: Accept or iterate
| Criterion | Required? |
|---|
| Target operation meets the fps goal | Yes |
| Non-target operations within 5% of baseline | Yes |
All cargo test -p grida tests pass | Yes |
| No new clippy warnings from changed files | Yes |
How to Design an Optimization
1. Measure first
Run benchmarks to quantify the problem with numbers. If you can't
measure it, you can't optimize it, and you can't verify the fix.
Identify which stage of the pipeline is the bottleneck (read the
per-stage breakdown from GPU bench).
2. Study Chromium's approach
Read the relevant documents in docs/wg/research/chromium/. Chromium
has solved most rendering performance problems at massive scale. The
research notes are organized by topic — use index.md as the map.
3. Adapt for our constraints
Our engine differs from Chromium in specific ways that affect which
strategies are feasible:
- Single thread — WASM cannot do shared-memory GPU threading.
Substitute parallelism with time budgets (cap per-frame work).
- Per-node cache — We cache individual nodes, not spatial tiles.
This gives us finer control over what to re-rasterize and when.
- Infinite canvas — No page boundaries. Viewport culling is the
primary mechanism for limiting work.
- Less dynamic content — No CSS reflow on zoom. The scene graph is
stable across frames. Leverage this for aggressive caching.
When adapting a Chromium strategy, always document what you borrowed,
what you changed, and why.
4. Write a proposal
Create a .plan.md file in docs/wg/feat-2d/ containing:
- The problem — benchmark numbers showing the current cost.
- Chromium's approach — what the reference architecture does.
- Our adaptation — what we'll do differently and why.
- Expected performance — target numbers for each affected scenario.
- Implementation steps — ordered so each step is independently
verifiable.
- Validation commands — exact benchmark commands and target metrics.
5. Implement incrementally
Each step should compile, pass tests, and produce a measurable
improvement. Run benchmarks after each step. If a step regresses a
non-target scenario, fix it before moving on.
6. Review for correctness
Performance bugs are worse than performance problems — a cache that
returns wrong data at high speed produces wrong frames silently.
For every cache/skip optimization, trace through:
- What state transitions mark entries invalid?
- Can an entry be returned when it shouldn't be?
- What happens at edge cases? (zero values, NaN, overflow, empty scenes)
- Is the invalidation flag cleared after re-rasterization?
- Does the multi-frame lifecycle converge to full quality?
How to Write and Audit Benchmarks
Writing rules
-
One conceptual operation per b.iter(). If a benchmark measures
multiple frames per sample, document it in a comment.
-
Continuous panning with reversal. Pan benchmarks should use
continuous motion (one direction for half the frames, then reverse)
to trigger cache misses and new-area discovery. The old alternating
±dx pattern only measured cache hits. For zoom, use bounded
oscillation with direction reversal at limits.
-
Setup outside b.iter(). Scene creation, renderer init, and
warm-up frames go before the measured closure.
-
black_box() on results. Wrap flush() return values to prevent
the compiler from optimizing away the work.
-
Verify camera classification. Trace through the camera API to
confirm each scenario triggers the intended CameraChangeKind. Read
how before_change() and change_kind() interact.
Auditing checklist
Before trusting any benchmark result, verify:
How to Read Optimization Docs
docs/wg/feat-2d/optimization.md is numbered by item. When the
codebase references "item 6b" or "item 17", it means that numbered
section. Read it as a living catalog — items are added as new strategies
are designed.
The document is organized by category:
- Transform & Geometry (items 1-3)
- Rendering Pipeline (items 4-14)
- Pan-Only Optimization (items 15-20)
- Zoom Asymmetry (items 21-23)
- Zoom & Interaction Optimization (items 24-30)
- Image, Text (items 31-33)
- Scene Loading & Layout (items 40-44)
- Engine-Level (items 34-39)
When working on a new optimization, check whether an item already
exists for it, and update or add to the document as part of the work.
Scene Loading & Layout Performance
Scene loading (Renderer::load_scene) is the cold-start bottleneck.
For large documents (100K–150K+ nodes), the layout phase dominates
load time. This is separate from frame rendering — it runs once per
scene switch, not per frame.
Key files
| File | Role |
|---|
crates/grida/src/runtime/scene.rs (load_scene) | Orchestrates the load pipeline |
crates/grida/src/layout/engine.rs | Layout engine (Taffy tree build + compute) |
crates/grida/src/runtime/config.rs | skip_layout flag |
crates/grida_dev/src/bench/load_bench.rs | load-bench CLI for per-stage timing |
crates/grida/benches/bench_load_scene.rs | Criterion benchmarks for layout at scale |
The load-bench tool
Primary diagnostic for scene loading. Reports per-stage timings.
cargo run -p grida_dev --release -- load-bench file.grida --iterations 5
cargo run -p grida_dev --release -- load-bench file.grida --list-scenes
cargo run -p grida_dev --release -- load-bench file.grida --skip-text
cargo run -p grida_dev --release -- load-bench file.grida --skip-layout
Cost breakdown
For 100K–150K node scenes, layout is ~95%+ of load_scene. The main
cost centers:
- Taffy tree construction — node insertion + ID mappings
- Text measurement — Skia paragraph layout calls per Taffy measure callback
- Flexbox computation —
compute_layout_with_measure() per subtree
- Layout extraction — DFS walk to read computed results
Key optimization: skip_layout
skip_layout bypasses Taffy entirely. compute_schema_only() copies
schema positions/sizes in a single walk — correct for absolute-positioned
documents. Set via runtime_renderer_set_skip_layout(true) before
loading a scene.
Pitfalls
These are failure modes learned from experience. Each one has caused
real bugs or wasted time.
Never run GPU benchmarks in parallel
GPU benchmarks must run sequentially, one at a time. Running two
bench processes simultaneously on the same machine causes GPU pipeline
contention, CPU cache thrashing, and memory bandwidth competition —
all of which distort timing data. When doing A/B comparisons (e.g.
with/without --overlay, before/after an optimization), always chain
the runs with &&:
cargo run -p grida_dev --release -- bench file.grida --scene 0 --frames 100 && \
cargo run -p grida_dev --release -- bench file.grida --scene 0 --frames 100 --overlay
cargo run ... --frames 100 &
cargo run ... --frames 100 --overlay &
This applies to all GPU benchmark invocations: bench, bench-report,
and any combination thereof. Criterion (CPU raster) is less sensitive
but should still be run alone for best accuracy.
GPU and raster backends behave differently
An optimization that helps on GPU may hurt on raster, and vice versa.
Compositor caching and atlas packing are GPU-only wins — on raster they
add pure overhead. Always run both benchmark systems.
Global invalidation is a performance cliff
Any operation that touches all cache entries on a hot path (e.g. every
frame during a gesture) will produce catastrophic frame times at scale.
The pattern to avoid: "on X change, invalidate everything." The pattern
to use: "on X change, mark stale, re-rasterize within budget."
Skia's save_layer has fixed overhead
It allocates an offscreen GPU texture regardless of content size. This
is a ~57us fixed cost per call. At node scale (hundreds/thousands),
it dominates frame time. Check optimization.md for when it can be
avoided and when it can't.
Benchmark oscillation drift
A benchmark whose camera drifts off-scene over iterations measures
empty frames — useless data that looks deceptively fast. Pinch-zoom
(set_zoom_at) is especially prone because each call shifts translation.
Use two-level alternation (A/B zoom values) so translations cancel.
Criterion measures raster, not GPU
Criterion runs on a CPU raster backend. It's excellent for measuring
algorithmic cost and detecting regressions in pipeline logic. But it
tells you nothing about GPU texture switching, GPU flush latency, or
Metal/GL driver behavior. Don't conclude "compositing doesn't help"
from Criterion results — it doesn't help on raster, which is expected.
Benchmark vs native viewer mismatch
Back-to-back frame benchmarks (no sleep between frames) can produce
misleadingly fast numbers because they never trigger settle frames.
The native viewer's 240Hz tick thread fires queue_stable() ~50ms
after the last interaction, clearing image caches. Use the realtime
or frameloop scenario types to produce numbers that match what users
actually see. Always check p99 and MAX — not just p50 — to
catch settle-induced spikes.
Most benchmarks bypass FrameLoop
All pan/zoom/circle/zigzag scenarios call queue_unstable() directly
— they never go through FrameLoop.poll(). This means they never
produce stable frames mid-interaction and cannot capture the jank
pattern where a stable frame interrupts slow panning or zooming. Only
the frameloop (pan) and frameloop_zoom scenarios use the real
FrameLoop decision path. When investigating panning or zooming
smoothness or adaptive timing, always use the frameloop /
frameloop_zoom scenarios.
Stable frames must recapture caches
When queue_stable() fires, it clears the pan/zoom image caches so
the stable frame renders at full quality. The stable frame's full
draw must recapture the caches afterwards, so the next unstable
frame gets a cache hit. Without recapture, every frame after settle
is also a full draw, producing 7fps instead of 100+fps. The capture
guard should be if self.backend.is_gpu() — NOT if !plan.stable.
SurfaceUI overlay is not free
The SurfaceUI overlay (frame titles, node badges, hit regions) runs
after content flush() and requires a second GPU flush. The overlay
cost is dominated by Skia paragraph creation (one per visible label) —
viewport culling skips off-screen labels, and style objects are hoisted
out of the per-label loop. On scenes with many labeled nodes at
fit-zoom (e.g. 500 visible labels), the overlay adds ~1.8ms per
frame. At typical editing zoom, most labels are culled and cost drops
to ~190µs. Standard benchmarks exclude overlay by default — use
--overlay to include it. If the app feels slower after adding new
overlay features (badges, labels, decorations), use the A/B overlay
benchmark to quantify the regression before optimizing.
Layout is the cold-start bottleneck, not rendering
For large documents (100K+ nodes), load_scene dominates cold start
— frame rendering optimizations do not help here. Use load-bench
(not bench) to measure this path. Use skip_layout for
absolute-positioned documents.
Timing overhead in budgeted loops
Instant::now() costs ~30ns per call. In a tight loop processing
thousands of cheap entries, the timing checks themselves can become
significant. Use elapsed() checks at reasonable intervals, not every
iteration.
Instant::now() is broken on emscripten
Under emscripten, Instant::now() is effectively constant, so durations
collapse to zero. Use crate::sys::perf_now() for timing: it maps to
emscripten_get_now() (performance.now()) on WASM and Instant on native.
WASM/native ratios are stage-dependent
WASM overhead is not a single multiplier. Roughly: simple compute is ~2-3x,
HashMap-heavy traversals can be 10-35x, and after Vec-indexing hot paths,
data-structure-bound stages drop to ~1-2x while compute-heavy stages stay
~5-15x+. Measure per stage.
Data structures matter much more in WASM
Large HashMaps (100K+ entries) may be fine on native but can be extremely
slow in WASM due to linear memory and weaker cache behavior. Prefer dense
Vec-indexed storage (DenseNodeMap<V>) for hot paths. See cache/fast_hash.rs.
Native profiles can mis-rank WASM bottlenecks
Native profiling finds stage costs, but not WASM amplification. Example:
native highlighted layers, while WASM was dominated by geometry because
per-node HashMap costs were amplified. Confirm priorities with WASM data.
WASM Performance
WASM is the primary shipping target. Native benchmarks show the algorithmic
ceiling; WASM benchmarks show delivered performance.
See docs/wg/feat-2d/wasm-benchmarking.md for the full strategy and
lessons learned. Key points:
Measurement inside WASM
load_scene emits per-stage timing via eprintln! + sys::perf_now().
Read the [load_scene] line in browser console (stderr) for
fonts/layout/geometry/effects/layers. This is the primary load_scene
WASM measurement path today.
Three-layer benchmarking model
- Native (
load-bench, Criterion): algorithmic ceiling + profiling
- WASM-on-Node: real WASM in headless/CI — implemented
- Browser: full pipeline (JS encode + WASM load + GPU render)
WASM-on-Node benchmark:
just --justfile crates/grida-canvas-wasm/justfile build
cd crates/grida-canvas-wasm && npx vitest run __test__/bench-load-scene.test.ts
WASM-on-Node results closely match browser WASM timings, confirming it as
a valid benchmarking layer for compute-heavy stages.
Known WASM-specific issues
- GPU-only paths can fail only on WASM (native runs CPU backend).
blit_content_cache and overlay-only fast path both had WASM-only bugs.
- Large enum access is the dominant WASM bottleneck. The
Node enum
(15 variants, each hundreds of bytes) causes cache-unfriendly memory access
that WASM amplifies to 30×+ native cost. Fix: Struct-of-Arrays (SoA) —
see docs/wg/feat-2d/wasm-load-scene-optimization.md.
- Deep recursion (
build_recursive, flatten_node) is costlier in WASM
due to stack-frame overhead in linear memory.
- JS↔WASM boundary is small for bulk calls (
switch_scene), but JS-side
FlatBuffers encoding is still ~10% of pipeline cost.