Skip to content

Performance benchmark suite + O(1) wildcard resolution + faster layout crawl - #3954

Open
T4rk1n wants to merge 3 commits into
perf/patch-append-rehydrationfrom
perf/benchmark-harness
Open

Performance benchmark suite + O(1) wildcard resolution + faster layout crawl#3954
T4rk1n wants to merge 3 commits into
perf/patch-append-rehydrationfrom
perf/benchmark-harness

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3948 (base branch perf/patch-append-rehydration), so the diff here is only the benchmark work and the two optimizations it surfaced. Review/merge #3948 first.

What's here

1. A platform performance benchmark harness (benchmarks/)

Standalone, not part of the pytest suite — timing is noisy, so it reports rather than flaking the test matrix. 10 scenarios across the platform (initial/deep hydration, Patch append top-level + nested, scalar Patch update, full-list replacement, callback fan-out, ALL-wildcard resolution, deep callback chain). Each runs in its own subprocess against the production bundle, driven by headless Chrome, timed with in-page performance.now(), aggregated as median/p90/max plus a growth ratio that flags O(total) creep. --profile <scenario> captures a Chrome CPU profile + a hottest-functions table.

2. CI job (.github/workflows/benchmarks.yml)

Runs on PRs touching dash/, benchmarks/, or components. Builds the production renderer, runs vs the committed baseline.json, hard-fails only on an order-of-magnitude regression, warns without failing on smaller drift, and upserts a sticky PR comment with the results table.

3. Wildcard resolution O(n²) → O(1) — found by the harness

getPath for a pattern-matching (dict) id did a linear deep-equality scan over every component sharing the id's key set, called once per resolved component — so an ALL dispatch over N components was O(N²). Added an objIndex hash map (valuesKey → path) maintained inline in computePaths (copy-on-write) and appendPaths; the ordered objs array stays for pattern iteration. ~580ms → ~140ms (≈4x) on ALL over 400 components.

4. Ramda currying overhead in the per-node layout crawl — also found by the harness

crawlLayout (run on every component on every path recompute + callback gather) used curried path/pathOr per node. Replaced with direct property access on the hot common path. Same-machine A/B: patch_append ~16%, initial render + wildcard a few %. No behavior change.

.ai/PERFORMANCE.md documents the methodology, how to profile, and the findings (including two that are not fixed here: callback_chain is network-bound, and layouts deeper than ~250 fail to serialize).

Testing

  • Renderer unit: 55/55 (adds nested/paths coverage).
  • Integration green across test_wildcards, test_basic_callback, test_multiple_callbacks, test_layout_paths_with_callbacks, test_patch, test_children_reorder.
  • Both optimizations verified with same-machine A/B benchmark runs.

Note

baseline.json was generated locally; absolute thresholds work anywhere, but the baseline-ratio comparison is best regenerated on ubuntu-latest (adopt the first CI run's results.json).

T4rk1n added 3 commits August 18, 2026 12:23
A standalone benchmark harness for the renderer's hot paths, kept out of the
pytest suite on purpose: timing is noisy, so it reports rather than flaking the
test matrix.

- benchmarks/scenarios.py: 10 scenarios across the platform - initial/​deep
  hydration, Patch append (top-level + nested), scalar Patch update, full-list
  replacement (contrast), callback fan-out, ALL-wildcard resolution, and a deep
  callback chain. Each is a real Dash app + a browser-side interaction with
  warn/fail thresholds.
- benchmarks/run.py: runs each scenario in its own bench_app subprocess against
  the production bundle, driven by headless Chrome. Timings use in-page
  performance.now() (no selenium-poll latency), aggregated as median/p90/max
  plus a growth ratio (late vs early per-op time) that flags O(total) creep.
  Also gates against a committed baseline and CPU-profiles a scenario
  (--profile) into a .cpuprofile + a hottest-functions table.
- benchmarks/baseline.json: reference numbers on ubuntu-latest-class hardware.
- .github/workflows/benchmarks.yml: PR job that builds the production renderer,
  runs the harness vs the baseline, hard-fails only on an order-of-magnitude
  regression, warns (without failing) on smaller drift, and upserts a sticky PR
  comment with the results table.
- .ai/PERFORMANCE.md: how to run, how to profile, and the findings - including
  that ALL/MATCH wildcard resolution is O(n^2) (linear deep-equals getPath over
  the objs table; a hash index would make it O(1)), that post-fix Patch append
  has no single hotspot left, and that layouts deeper than ~250 fail to
  serialize.

Removes the earlier pytest timing guards (tests/integration/renderer/
test_patch_append_perf.py); that coverage now lives in the harness + CI job.
Profiling the wildcard benchmark (one input change resolving an ALL callback
over 400 components) showed ~45% of the time in ramda `_equals`/`_functionName`:
getPath for a pattern-matching (dict) id did a linear `find(propEq(values,
'values'), keyPaths)` over every component sharing that id's key set, and it is
called once per resolved component - so an ALL dispatch over N components was
O(N^2) deep-equality comparisons.

The paths table now carries `objIndex`: {[keyStr]: {[valuesKey]: path}} where
valuesKey is JSON.stringify(values), so getPath is an O(1) map lookup. The
ordered `objs` array is untouched (resolveDeps / getAllPMCIds still walk it in
order for MATCH/ALLSMALLER); objIndex is only for exact lookups. It is
maintained inline in computePaths - copy-on-write per keyStr, so re-resolving
one chunk doesn't rebuild the index for unrelated components - and extended
incrementally in appendPaths. A table without an index (initial empty state,
hand-built test fixture) makes getPath fall back to the linear scan, so the two
can never disagree.

Result on the benchmark: wildcard_all_resolve ~580ms -> ~140ms (~4x, isolated),
with the _equals/_functionName frames gone from the profile. Renderer unit
suite 55/55; integration green across test_wildcards, multiple_callbacks,
layout_paths_with_callbacks, basic_callback, patch, children_reorder (69).

Baseline regenerated (full-suite numbers run hotter than isolated, but the
wildcard drop from 632ms to 217ms p90 is clearly captured); loosened the
intentionally-slow full_children_replace reference threshold to match.
Profiling every benchmark showed ~10-15% in ramda's curry machinery
(f1/f2/f3, _isPlaceholder, curried path/pathOr). It came from crawlLayout -
run on every component on every path recompute and callback gather - calling
curried path(['props','children'], obj)/pathOr(...) per node, plus the
path(['props','id'], child) in each crawl callback (paths.js, dependencies.js).

Replace those with direct property access (obj.props && obj.props.children,
etc.) and native array concat on the hot common path, leaving the rare declared
childrenProps ([]/{}) branch untouched. The crawled nodes are always plain
component objects, so this is equivalent to the curried path, just without the
dispatch and placeholder checks.

Same-machine A/B: patch_append_nested ~16% faster, initial render and wildcard
resolution a few percent, no behavior change. Renderer unit 55/55; integration
green across wildcards, basic/multiple callbacks, layout paths, patch, reorder.

Also: the benchmark profiler now keys anonymous frames by source location
(they were collapsing into one opaque bucket), and the baseline is refreshed.
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown

Dash performance benchmarks

❌ perf regression

scenario metric p90 (ms) median growth baseline p90 note
callback_chain graph_ms 4.9 4.9 1.0x 0.9 5.4x baseline
full_children_replace replace_ms 5146.5 1924.7 17.59x 2429.4 2.1x baseline
initial_render_large render_ms 643.7 553.5 1.06x 227.0 2.8x baseline
initial_render_small render_ms 103.8 95.3 0.92x 45.9 2.3x baseline
patch_append_nested append_ms 191.9 142.8 2.47x 74.9 2.6x baseline
patch_append_toplevel append_ms 182.7 134.9 2.55x 65.0 2.8x baseline
patch_scalar_update_large update_ms 237.0 218.2 0.9x 85.9 2.8x baseline
wildcard_all_resolve wildcard_ms 304.9 289.4 1.01x 138.6 2.2x baseline
wildcard_all_resolve graph_ms 1.7 1.7 1.0x 0.5 3.4x baseline
⚠️ callback_fanout fanout_ms 86.9 82.9 1.0x 47.3 1.8x baseline
⚠️ deep_nesting render_ms 57.4 53.3 0.95x 28.8 2.0x baseline
callback_chain chain_ms 529.8 491.6 1.0x 430.7

growth = late-third / early-third per-op time; ~1 is flat, a large value means the per-op cost scales with accumulated state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants