Skip to content

Commit 8c89aab

Browse files
authored
Merge branch 'dev' into dependabot/pip/pip-dependencies-ed78e3790b
2 parents fb9a6da + 407ac3a commit 8c89aab

38 files changed

Lines changed: 3279 additions & 186 deletions

File tree

‎.ai/PERFORMANCE.md‎

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Performance: benchmarks, profiling, and findings
2+
3+
Dash's user-visible speed lives almost entirely in the **renderer** (client-side
4+
hydration, callback dispatch, Patch application). This doc covers the benchmark
5+
harness that measures it, how to profile a slow scenario down to the hot
6+
function, and the findings so far.
7+
8+
## The harness (`benchmarks/`)
9+
10+
A standalone harness - **not** part of the pytest suite, on purpose: timing is
11+
noisy, so it uses generous thresholds and *reports* rather than flaking the test
12+
matrix (see "CI job" below).
13+
14+
| file | role |
15+
|--|--|
16+
| `benchmarks/scenarios.py` | the scenarios: each has a `build(params) -> Dash` app and a `drive(b, params) -> {metric: ms}` interaction, plus warn/fail thresholds |
17+
| `benchmarks/bench_app.py` | serves one scenario as a real app in its own process (`debug=False`, i.e. the **production** `min.js` bundle) |
18+
| `benchmarks/run.py` | the runner + CPU profiler + threshold gating + markdown report |
19+
| `benchmarks/baseline.json` | committed reference numbers the CI job compares against |
20+
21+
Each scenario runs in its own `bench_app` subprocess driven by a headless
22+
Chrome. Timings are taken with `performance.now()` **inside the page** (not
23+
Python-side), so they measure real client work - server round-trip + patch
24+
apply + React render - without selenium's per-poll latency. Per scenario we drop
25+
`warmup` runs then report **median / p90 / max** plus a **growth** ratio
26+
(late-third ÷ early-third per-op time): `~1` is flat, a large value means the
27+
per-op cost scales with accumulated state - an O(total) smell.
28+
29+
### Running locally
30+
31+
```bash
32+
# prerequisites: production renderer bundle must be current
33+
npm run build # (or: cd dash/dash-renderer && renderer build)
34+
35+
# all scenarios -> results.json + a printed markdown table
36+
python -m benchmarks.run --out benchmarks/results.json
37+
38+
# a subset
39+
python -m benchmarks.run --scenario patch_append_nested wildcard_all_resolve
40+
41+
# gate against the committed baseline (what CI runs); exit code 1 on a hard fail
42+
python -m benchmarks.run --baseline benchmarks/baseline.json --summary-md summary.md
43+
```
44+
45+
### Updating the baseline
46+
47+
`baseline.json` holds absolute ms, but you do **not** need to regenerate it on a
48+
CI-class machine: the baseline-ratio gate divides out a per-run *machine scale*
49+
(see "Machine-independent gating" below), so a baseline captured on your laptop
50+
compares correctly against numbers measured on a slower CI runner. Regenerate it
51+
when scenarios change or an intended optimization lands:
52+
53+
```bash
54+
python -m benchmarks.run --out benchmarks/baseline.json
55+
```
56+
57+
Commit the new `baseline.json` in the same PR, and say why in the message.
58+
59+
To capture the baseline on CI hardware instead (so the machine scale is ~1.0x
60+
for subsequent PRs), run the **Performance Benchmarks** workflow via
61+
`workflow_dispatch` with `regenerate_baseline` checked, on the branch you want
62+
to refresh (usually the default branch, right after a merge). It measures a
63+
fresh baseline on the runner and opens a PR updating `benchmarks/baseline.json`.
64+
65+
### Machine-independent gating
66+
67+
The same code runs ~2-4x slower on a shared CI runner than on a dev machine, and
68+
even two `ubuntu-latest` runners vary run-to-run - so comparing raw ms against a
69+
committed baseline flakes. To avoid that, the runner measures a fixed
70+
**calibration scenario** (`initial_render_small`) in the same run and computes a
71+
**machine scale** = `this-run calibration median ÷ baseline calibration median`.
72+
The baseline-ratio check then divides each scenario's raw ratio by that scale,
73+
so a uniformly N× slower machine reports ~1.0× (no regression) while a *genuine*
74+
regression still shows through. The scale is printed in the summary/PR comment.
75+
76+
The absolute `warn_ms` / `fail_ms` ceilings in `scenarios.py` are **not** scaled
77+
- they stay generous order-of-magnitude guards and double as the backstop for a
78+
global slowdown that would also drag the calibration workload (and so hide
79+
inside the scale). If a run doesn't include `initial_render_small` (e.g. a
80+
`--scenario` subset), gating falls back to a raw-ms comparison and says so.
81+
82+
## CI job (`.github/workflows/benchmarks.yml`)
83+
84+
Runs on PRs that touch `dash/`, `benchmarks/`, or components. It builds the
85+
production bundle, runs the harness against `baseline.json`, and:
86+
87+
- **hard-fails** the job only on an order-of-magnitude regression - a metric
88+
over its absolute `fail_ms`, or `> 2x` the baseline p90 *after normalizing out
89+
machine speed* (see "Machine-independent gating" above);
90+
- **warns** (without failing) on a smaller drift - over `warn_ms`, or `> 1.3x`
91+
the normalized baseline - and always upserts a single sticky **PR comment**
92+
with the table (and the machine scale) so the numbers are visible on every run;
93+
- uploads `results.json` + `summary.md` as artifacts.
94+
95+
Thresholds live per-scenario in `scenarios.py` (`warn_ms` / `fail_ms`, keyed by
96+
metric). Keep them generous: this is a smoke alarm, not a microbenchmark.
97+
98+
## Profiling a slow scenario
99+
100+
The runner can capture a **Chrome DevTools CPU profile** of a scenario and print
101+
the hottest functions:
102+
103+
```bash
104+
python -m benchmarks.run --profile wildcard_all_resolve
105+
# -> benchmarks/profile.cpuprofile (load in Chrome DevTools > Performance,
106+
# or in VS Code) + a printed "hottest functions" table
107+
```
108+
109+
Profile mode serves the **dev** bundle (`dev_tools_serve_dev_bundles`, without
110+
the rest of the dev tools) so the profile has **readable function names** -
111+
the production bundle is minified to one-letter names. It warms up, then samples
112+
`repeats` interactions at a 50µs sampling interval, aggregating self-time per
113+
function by hit count.
114+
115+
Workflow: run the timing suite -> find a scenario with a high absolute time or
116+
high `growth` -> `--profile` it -> read the hot functions -> the frame's
117+
`file.dev.js:line` points straight into `dash/dash-renderer/src`.
118+
119+
## Findings
120+
121+
Numbers below are from `ubuntu-latest`-class hardware, production bundle, React
122+
18. They move with the machine; trust the **shape** (flat vs growing, and which
123+
function dominates), not the absolute ms.
124+
125+
### Snapshot (median per-op)
126+
127+
| scenario | median | growth | reading |
128+
|--|--:|--:|--|
129+
| initial_render_small (200 rows) | ~45 ms | 1.0x | fine |
130+
| initial_render_large (3000 rows) | ~240 ms | 1.0x | linear in node count, expected |
131+
| deep_nesting (120 deep) | ~27 ms | 1.0x | fine |
132+
| patch_append_toplevel | ~52 ms | ~2x | flat enough; residual is shared O(total) traversal |
133+
| patch_append_nested | ~54 ms | ~2.8x | same; the [[nested append fix]] keeps re-hydration O(appended) |
134+
| patch_scalar_update_large (3000) | ~80 ms | ~0.8x | flat - in-place value change |
135+
| callback_fanout (1 -> 300) | ~45 ms | 1.0x | fine |
136+
| callback_chain (100 deep) | ~440 ms | 1.0x | ~100 sequential dispatches; inherent |
137+
| wildcard_all_resolve (ALL over 400) | ~140 ms | 1.0x | was ~580 ms (O(n²)); now O(n), see below |
138+
| full_children_replace (contrast) | ~850 ms | ~18x | O(total) every click *by design* - why Patch exists |
139+
140+
### 1. Wildcard (ALL / MATCH) resolution — was O(n²), now O(n) [fixed]
141+
142+
Profiling `wildcard_all_resolve` (one input change, `Output({...: ALL})` over
143+
400 components) originally put ~45% of the time in ramda `_equals` +
144+
`_functionName`. Root cause: `getPath` for a pattern-matching (dict) id did a
145+
**linear** `find(propEq(values, 'values'), keyPaths)` over every component
146+
sharing that id shape, and `propEq` is a **deep-equality** on the id-values
147+
array. During an `ALL` resolution `getPath` is called once per resolved
148+
component, so it was O(N) lookups × O(N) scan × O(k) equals ≈ **O(N²·k)**.
149+
150+
**Fix (`dash/dash-renderer/src/actions/paths.js`):** the paths table now
151+
carries an `objIndex` - `{[keyStr]: {[valuesKey]: path}}`, where `valuesKey` is
152+
`JSON.stringify(values)` - so `getPath` is an O(1) map lookup. `objs` stays an
153+
ordered array because pattern matching (`resolveDeps`, `getAllPMCIds`) walks it
154+
in order; `objIndex` is only for exact lookups. It's maintained inline in
155+
`computePaths` (copy-on-write per keyStr, so re-resolving one chunk doesn't
156+
rebuild the index for unrelated components) and extended incrementally in
157+
`appendPaths`; a table without an index (empty initial state, test fixture)
158+
makes `getPath` fall back to the linear scan, so the two never disagree.
159+
Result: `wildcard_all_resolve` went **~580 ms → ~140 ms (≈4x)**, and the
160+
`_equals`/`_functionName` frames left the profile. What remains is O(N) - one
161+
`assocPath` per resolved output - which is inherent to writing N updates.
162+
163+
### 2. Patch append (post-fix) has no single hotspot
164+
165+
After the [[nested append fix]], profiling `patch_append_nested` shows the cost
166+
spread across ramda `_path`/curry internals, React reconciliation, and redux
167+
`useSelector` snapshots - the inherent O(total) *cheap* traversal (persistence
168+
walk + callback crawl + element mapping), not the O(total) *re-hydration* that
169+
the fix removed. There is no dominant frame to cut; flattening it further means
170+
making those three traversals skip byref-unchanged subtrees (a bigger change).
171+
172+
### 3. Ramda currying overhead in the per-node hot loops [fixed]
173+
174+
Across every scenario the profiles showed ~10-15% in ramda's curry machinery -
175+
`f1`/`f2`/`f3` (the arity dispatchers), `_isPlaceholder`, and curried `path`/
176+
`pathOr`. It came from `crawlLayout` (utils.js), which runs the callback on
177+
*every* component on *every* path recompute and callback gather, calling
178+
curried `path(['props','children'], obj)` / `pathOr(...)` per node, plus the
179+
`path(['props','id'], child)` in each crawl callback (`paths.js`,
180+
`dependencies.js`). Replacing those with direct property access (`obj.props &&
181+
obj.props.children`, etc.) and native array `concat` on the hot common path -
182+
leaving the rare declared-`childrenProps` branch alone - cut `patch_append` by
183+
~16% and initial render / wildcard by ~3-4% in a same-machine A/B. Low risk:
184+
the crawled nodes are always plain component objects, so direct access is
185+
equivalent to the curried `path`, just without the dispatch and placeholder
186+
checks. When touching these traversals, prefer direct access over curried
187+
ramda - the per-node multiplier makes it matter.
188+
189+
### 4. Full children replacement is the O(total) baseline
190+
191+
`full_children_replace` grows ~18x across a run and is ~15x slower than the
192+
equivalent `Patch().extend()`. This is expected and is the reason `Patch`
193+
exists for growing containers; it is kept as a contrast with loose thresholds.
194+
195+
### 5. Layouts deeper than ~250 nested components fail to serialize
196+
197+
`/_dash-layout` raises "Recursion limit reached" (the JSON encoder's recursion
198+
limit) for a component tree nested deeper than ~254. The `deep_nesting`
199+
scenario is capped at 120 to stay clear. Worth remembering before recommending
200+
deeply-recursive layouts.

0 commit comments

Comments
 (0)