|
| 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