Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions .ai/PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Performance: benchmarks, profiling, and findings

Dash's user-visible speed lives almost entirely in the **renderer** (client-side
hydration, callback dispatch, Patch application). This doc covers the benchmark
harness that measures it, how to profile a slow scenario down to the hot
function, and the findings so far.

## The harness (`benchmarks/`)

A standalone harness - **not** part of the pytest suite, on purpose: timing is
noisy, so it uses generous thresholds and *reports* rather than flaking the test
matrix (see "CI job" below).

| file | role |
|--|--|
| `benchmarks/scenarios.py` | the scenarios: each has a `build(params) -> Dash` app and a `drive(b, params) -> {metric: ms}` interaction, plus warn/fail thresholds |
| `benchmarks/bench_app.py` | serves one scenario as a real app in its own process (`debug=False`, i.e. the **production** `min.js` bundle) |
| `benchmarks/run.py` | the runner + CPU profiler + threshold gating + markdown report |
| `benchmarks/baseline.json` | committed reference numbers the CI job compares against |

Each scenario runs in its own `bench_app` subprocess driven by a headless
Chrome. Timings are taken with `performance.now()` **inside the page** (not
Python-side), so they measure real client work - server round-trip + patch
apply + React render - without selenium's per-poll latency. Per scenario we drop
`warmup` runs then report **median / p90 / max** plus a **growth** ratio
(late-third ÷ early-third per-op time): `~1` is flat, a large value means the
per-op cost scales with accumulated state - an O(total) smell.

### Running locally

```bash
# prerequisites: production renderer bundle must be current
npm run build # (or: cd dash/dash-renderer && renderer build)

# all scenarios -> results.json + a printed markdown table
python -m benchmarks.run --out benchmarks/results.json

# a subset
python -m benchmarks.run --scenario patch_append_nested wildcard_all_resolve

# gate against the committed baseline (what CI runs); exit code 1 on a hard fail
python -m benchmarks.run --baseline benchmarks/baseline.json --summary-md summary.md
```

### Updating the baseline

The baseline is machine-sensitive (absolute ms). Regenerate it on the same
class of machine the CI job uses (GitHub `ubuntu-latest`) when scenarios change
or an intended optimization lands:

```bash
python -m benchmarks.run --out benchmarks/baseline.json
```

Commit the new `baseline.json` in the same PR, and say why in the message.

## CI job (`.github/workflows/benchmarks.yml`)

Runs on PRs that touch `dash/`, `benchmarks/`, or components. It builds the
production bundle, runs the harness against `baseline.json`, and:

- **hard-fails** the job only on an order-of-magnitude regression - a metric
over its absolute `fail_ms`, or `> 2x` the baseline p90;
- **warns** (without failing) on a smaller drift - over `warn_ms`, or `> 1.3x`
baseline - and always upserts a single sticky **PR comment** with the table so
the numbers are visible on every run;
- uploads `results.json` + `summary.md` as artifacts.

Thresholds live per-scenario in `scenarios.py` (`warn_ms` / `fail_ms`, keyed by
metric). Keep them generous: this is a smoke alarm, not a microbenchmark.

## Profiling a slow scenario

The runner can capture a **Chrome DevTools CPU profile** of a scenario and print
the hottest functions:

```bash
python -m benchmarks.run --profile wildcard_all_resolve
# -> benchmarks/profile.cpuprofile (load in Chrome DevTools > Performance,
# or in VS Code) + a printed "hottest functions" table
```

Profile mode serves the **dev** bundle (`dev_tools_serve_dev_bundles`, without
the rest of the dev tools) so the profile has **readable function names** -
the production bundle is minified to one-letter names. It warms up, then samples
`repeats` interactions at a 50µs sampling interval, aggregating self-time per
function by hit count.

Workflow: run the timing suite -> find a scenario with a high absolute time or
high `growth` -> `--profile` it -> read the hot functions -> the frame's
`file.dev.js:line` points straight into `dash/dash-renderer/src`.

## Findings

Numbers below are from `ubuntu-latest`-class hardware, production bundle, React
18. They move with the machine; trust the **shape** (flat vs growing, and which
function dominates), not the absolute ms.

### Snapshot (median per-op)

| scenario | median | growth | reading |
|--|--:|--:|--|
| initial_render_small (200 rows) | ~45 ms | 1.0x | fine |
| initial_render_large (3000 rows) | ~240 ms | 1.0x | linear in node count, expected |
| deep_nesting (120 deep) | ~27 ms | 1.0x | fine |
| patch_append_toplevel | ~52 ms | ~2x | flat enough; residual is shared O(total) traversal |
| patch_append_nested | ~54 ms | ~2.8x | same; the [[nested append fix]] keeps re-hydration O(appended) |
| patch_scalar_update_large (3000) | ~80 ms | ~0.8x | flat - in-place value change |
| callback_fanout (1 -> 300) | ~45 ms | 1.0x | fine |
| callback_chain (100 deep) | ~440 ms | 1.0x | ~100 sequential dispatches; inherent |
| wildcard_all_resolve (ALL over 400) | ~140 ms | 1.0x | was ~580 ms (O(n²)); now O(n), see below |
| full_children_replace (contrast) | ~850 ms | ~18x | O(total) every click *by design* - why Patch exists |

### 1. Wildcard (ALL / MATCH) resolution — was O(n²), now O(n) [fixed]

Profiling `wildcard_all_resolve` (one input change, `Output({...: ALL})` over
400 components) originally put ~45% of the time in ramda `_equals` +
`_functionName`. Root cause: `getPath` for a pattern-matching (dict) id did a
**linear** `find(propEq(values, 'values'), keyPaths)` over every component
sharing that id shape, and `propEq` is a **deep-equality** on the id-values
array. During an `ALL` resolution `getPath` is called once per resolved
component, so it was O(N) lookups × O(N) scan × O(k) equals ≈ **O(N²·k)**.

**Fix (`dash/dash-renderer/src/actions/paths.js`):** the paths table now
carries an `objIndex` - `{[keyStr]: {[valuesKey]: path}}`, where `valuesKey` is
`JSON.stringify(values)` - so `getPath` is an O(1) map lookup. `objs` stays an
ordered array because pattern matching (`resolveDeps`, `getAllPMCIds`) walks it
in order; `objIndex` is only for exact lookups. It's 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 (empty initial state, test fixture)
makes `getPath` fall back to the linear scan, so the two never disagree.
Result: `wildcard_all_resolve` went **~580 ms → ~140 ms (≈4x)**, and the
`_equals`/`_functionName` frames left the profile. What remains is O(N) - one
`assocPath` per resolved output - which is inherent to writing N updates.

### 2. Patch append (post-fix) has no single hotspot

After the [[nested append fix]], profiling `patch_append_nested` shows the cost
spread across ramda `_path`/curry internals, React reconciliation, and redux
`useSelector` snapshots - the inherent O(total) *cheap* traversal (persistence
walk + callback crawl + element mapping), not the O(total) *re-hydration* that
the fix removed. There is no dominant frame to cut; flattening it further means
making those three traversals skip byref-unchanged subtrees (a bigger change).

### 3. Ramda currying overhead in the per-node hot loops [fixed]

Across every scenario the profiles showed ~10-15% in ramda's curry machinery -
`f1`/`f2`/`f3` (the arity dispatchers), `_isPlaceholder`, and curried `path`/
`pathOr`. It came from `crawlLayout` (utils.js), which runs the callback 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`). Replacing 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 alone - cut `patch_append` by
~16% and initial render / wildcard by ~3-4% in a same-machine A/B. Low risk:
the crawled nodes are always plain component objects, so direct access is
equivalent to the curried `path`, just without the dispatch and placeholder
checks. When touching these traversals, prefer direct access over curried
ramda - the per-node multiplier makes it matter.

### 4. Full children replacement is the O(total) baseline

`full_children_replace` grows ~18x across a run and is ~15x slower than the
equivalent `Patch().extend()`. This is expected and is the reason `Patch`
exists for growing containers; it is kept as a contrast with loose thresholds.

### 5. Layouts deeper than ~250 nested components fail to serialize

`/_dash-layout` raises "Recursion limit reached" (the JSON encoder's recursion
limit) for a component tree nested deeper than ~254. The `deep_nesting`
scenario is capped at 120 to stay clear. Worth remembering before recommending
deeply-recursive layouts.
121 changes: 121 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
name: Performance Benchmarks

# Separate from the test suite on purpose: these are timing benchmarks, so they
# use generous thresholds and report rather than flake the test matrix. The job
# hard-fails only on an order-of-magnitude ("fail") regression; a smaller
# ("warn") drift is surfaced as a sticky PR comment without failing the build.

on:
pull_request:
paths:
- 'dash/**'
- 'benchmarks/**'
- 'components/**'
- '@plotly/**'
workflow_dispatch:

permissions:
contents: read
pull-requests: write

concurrency:
group: benchmarks-${{ github.ref }}
cancel-in-progress: true

jobs:
benchmarks:
name: Run performance benchmarks
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm

- name: Install NPM dependencies
run: npm ci

Check warning on line 41 in .github/workflows/benchmarks.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--ignore-scripts" allows lifecycle scripts to run during package installation.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaAWkGyzBXGTVaHSObbr&open=AaAWkGyzBXGTVaHSObbr&pullRequest=3954

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip

- name: Install Dash (editable)
run: |
python -m pip install --upgrade pip

Check warning on line 51 in .github/workflows/benchmarks.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaAWkGyzBXGTVaHSObbs&open=AaAWkGyzBXGTVaHSObbs&pullRequest=3954
python -m pip install "setuptools<80.0.0"

Check warning on line 52 in .github/workflows/benchmarks.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaAWkGyzBXGTVaHSObbt&open=AaAWkGyzBXGTVaHSObbt&pullRequest=3954
python -m pip install -e .[ci,dev,testing]

Check warning on line 53 in .github/workflows/benchmarks.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaAWkGyzBXGTVaHSObbu&open=AaAWkGyzBXGTVaHSObbu&pullRequest=3954

- name: Build the production renderer bundle
# The benchmarks serve debug=False, i.e. dash_renderer.min.js - what
# real users get - so the minified bundle must be current.
run: npm run build

- name: Set up Chrome and ChromeDriver
uses: browser-actions/setup-chrome@v1

Check failure on line 61 in .github/workflows/benchmarks.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use full commit SHA hash for this dependency.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaAWkGyzBXGTVaHSObbv&open=AaAWkGyzBXGTVaHSObbv&pullRequest=3954
with:
chrome-version: stable

- name: Set up virtual display
run: |
sudo apt-get update
sudo apt-get install -y xvfb
sudo Xvfb :99 -ac -screen 0 1400x1000x24 &
echo "DISPLAY=:99" >> $GITHUB_ENV

- name: Run benchmarks
id: bench
run: |
set +e
python -m benchmarks.run \
--baseline benchmarks/baseline.json \
--out benchmarks/results.json \
--summary-md benchmarks/summary.md
echo "status=$?" >> "$GITHUB_OUTPUT"

- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v4
with:
name: benchmark-results
path: |
benchmarks/results.json
benchmarks/summary.md
retention-days: 30

- name: Comment results on the PR
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Dash performance benchmarks\n\n_no summary produced_';
try { body = fs.readFileSync('benchmarks/summary.md', 'utf8'); } catch (e) {}
const marker = '<!-- dash-benchmarks -->';
body = `${marker}\n${body}`;
const {owner, repo} = context.repo;
const issue_number = context.issue.number;
const comments = await github.paginate(
github.rest.issues.listComments, {owner, repo, issue_number});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment(
{owner, repo, comment_id: existing.id, body});
} else {
await github.rest.issues.createComment(
{owner, repo, issue_number, body});
}

- name: Fail on hard regression
if: always()
run: |
if [ "${{ steps.bench.outputs.status }}" != "0" ]; then
echo "Benchmarks exceeded a hard (fail) threshold. See the PR comment."
exit 1
fi
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`).

### Fixed
- Speed up the layout crawl the renderer runs on every path recompute and callback gather (`crawlLayout` and its callers) by replacing curried-ramda `path`/`pathOr` lookups with direct property access on the per-node hot path. Cut a `Patch().append()` into a large container by ~16% and initial render / wildcard resolution by a few percent, with no behavior change.
- Fix pattern-matching (`MATCH`/`ALL`/`ALLSMALLER`) callbacks getting quadratically slower as the number of matching components grows. Resolving a wildcard dispatch looked up each component's path with a linear deep-equality scan over every component sharing the id's key set, so resolving an `ALL` callback over N components was O(N²). The renderer now keeps an O(1) id→path index alongside the ordered table, cutting an `ALL` update over 400 components from ~580ms to ~140ms (~4x). No app changes required.
- [#3941](https://github.com/plotly/dash/pull/3941) Fix the FastAPI and Quart backends opening a WebSocket connection on every page load, even for apps with no WebSocket callbacks. The renderer keyed the connection on the mere presence of WebSocket infrastructure (always advertised by these backends) rather than on whether it was needed. The socket now opens eagerly only when `websocket_callbacks=True`; with just per-callback `websocket=True` it opens lazily on the first such callback dispatch, and an app with no WebSocket callbacks never opens one. Fixes [#3939](https://github.com/plotly/dash/issues/3939).
- [#3916](https://github.com/plotly/dash/pull/3916) Fixed a regression where dragging multiple files into `dcc.Upload` would upload only the first file when `multiple=True`
- [#3922](https://github.com/plotly/dash/pull/3922) Fix `dcc.Input(type="number")` stepper behavior when only `min` is set.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4. `.ai/COMPONENTS.md` - Component system, generation, resources
5. `.ai/TESTING.md` - Testing framework, fixtures, patterns, type compliance
6. `.ai/TROUBLESHOOTING.md` - Common errors and solutions
7. `.ai/PERFORMANCE.md` - Benchmark harness, profiling, and performance findings

## Project Overview

Expand Down
5 changes: 5 additions & 0 deletions benchmarks/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Generated benchmark artifacts (baseline.json is committed on purpose)
results.json
summary.md
*.cpuprofile
__pycache__/
41 changes: 41 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Dash performance benchmarks

Standalone timing benchmarks for the renderer's hot paths (initial hydration,
callbacks, wildcards, Patch). Kept out of the pytest suite on purpose - timing
is noisy, so this reports rather than flaking tests. See
[`.ai/PERFORMANCE.md`](../.ai/PERFORMANCE.md) for the full methodology,
profiling guide, and findings.

## Quick start

```bash
npm run build # production renderer bundle
python -m benchmarks.run # run everything, print a table
python -m benchmarks.run --scenario patch_append_nested # just one
python -m benchmarks.run --profile wildcard_all_resolve # CPU-profile one
```

## Layout

- `scenarios.py` - the scenarios (app + interaction + thresholds)
- `bench_app.py` - serves one scenario in its own process (production bundle)
- `run.py` - runner, CPU profiler, threshold gating, markdown report
- `baseline.json` - committed reference the CI job compares against

## Adding a scenario

Add a `build`/`drive` pair and register it in `scenarios.py`:

```python
def _build_x(params): ... # returns a Dash app; ends its layout with READY
def _drive_x(b, params): ... # returns {"metric_ms": <in-browser ms>}

scenario(
name="x", description="...", params={...},
warn_ms={"metric_ms": 500}, fail_ms={"metric_ms": 2000},
)((_build_x, _drive_x))
```

`b` is the browser helper (`b.timed`, `b.render_time`, `b.reload`, `b.state`,
`b.graph_time`). Every layout must end with the shared `READY` sentinel so the
harness can detect "fully hydrated". Then regenerate `baseline.json`.
1 change: 1 addition & 0 deletions benchmarks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Dash performance benchmark harness."""
Loading
Loading