‹ 首页

render-perf

@gridaco · 收录于 今天 · 上游提交 今天

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 安装 校验哈希
npx oh-my-skill add gridaco/grida/render-perf
/ 通过 bash 安装
curl -fsSL https://oh-my-skill.com/install.sh | bash -s -- gridaco/grida/render-perf
/ 已经装过?验证本机副本,不用重装
npx oh-my-skill verify gridaco/grida/render-perf
安装目标可用 --agent / --scope 或 --to 明确指定;省略时只会在唯一已存在的 agent 目录上自动选择,零命中或多命中会停止并提示。content_hash 缺失或不一致均拒装。
2558GitHub stars
~6.9K上下文体积 · 单文件
索引托管

怎么用

技能原文 SKILL.md作者撰写 · Apache-2.0 · 2c73d55

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.

# 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?"

# 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
  1. One conceptual operation per b.iter(). If a benchmark measures multiple frames per sample, document it in a comment.
  1. 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.
  1. Setup outside b.iter(). Scene creation, renderer init, and warm-up frames go before the measured closure.
  1. black_box() on results. Wrap flush() return values to prevent the compiler from optimizing away the work.
  1. 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:

  • [ ] 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:

  1. Taffy tree construction — node insertion + ID mappings
  2. Text measurement — Skia paragraph layout calls per Taffy measure callback
  3. Flexbox computationcompute_layout_with_measure() per subtree
  4. 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
  1. Native (load-bench, Criterion): algorithmic ceiling + profiling
  2. WASM-on-Node: real WASM in headless/CI — implemented
  3. 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_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.
按 Apache-2.0 许可原样转载,未经改动 · 在 GitHub 查看 →

评论

登录即可评论;带「已验证安装」的,是发布者名下有本店的安装或持有记录。