render-perf
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.
适合你,如果你在开发 Rust 渲染引擎并需要提升渲染性能。
npx oh-my-skill add gridaco/grida/render-perfcurl -fsSL https://oh-my-skill.com/install.sh | bash -s -- gridaco/grida/render-perfnpx oh-my-skill verify gridaco/grida/render-perf怎么用
技能原文 SKILL.md
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 withindex.mdfor 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— thebenchandbench-reportsubcommands show how GPU benchmarks work with real.gridascene files.bench-reportis 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.
# 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 complexityfit_zoom— the zoom level fromfit_camera_to_scenepan/zoom— legacy passes with fullPassStatsscenarios[]— 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 distributionqueue_us,draw_us,mid_flush_us,compositor_us,flush_us— per-stage breakdownsettle_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?"
# 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().
# 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.
# 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
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.
# Run all cargo bench -p grida --bench bench_camera # Filter by scene, config, or operation (use substring matching) 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
±dxpattern 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. Wrapflush()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 howbefore_change()andchange_kind()interact.
Auditing checklist
Before trusting any benchmark result, verify:
- [ ] Setup cost is excluded from measurement
- [ ] No iterations produce no-ops (check for quantization skips)
- [ ] State does not accumulate/drift (check translation, zoom bounds)
- [ ] Config profiles take effect (check setter order vs scene loading)
- [ ] Multi-frame benchmarks document frames-per-sample
- [ ] The benchmark scenario actually exercises the codepath under test
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 # isolate tree + flexbox cost cargo run -p grida_dev --release -- load-bench file.grida --skip-layout # schema-only fast path
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 &&:
# CORRECT — sequential 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 # WRONG — parallel (results are unreliable) 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:
# Build WASM first just --justfile crates/grida-canvas-wasm/justfile build # Run benchmark (requires a large .grida fixture in fixtures/local/perf/local/) 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_cacheand overlay-only fast path both had WASM-only bugs. - Large enum access is the dominant WASM bottleneck. The
Nodeenum (15 variants, each hundreds of bytes) causes cache-unfriendly memory access that WASM amplifies to 30×+ native cost. Fix: Struct-of-Arrays (SoA) — seedocs/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.