From b1c9bd0c80489c4f13bdbdfc0081ca0faef627b4 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Aug 2026 12:23:55 -0400 Subject: [PATCH 1/3] Add platform performance benchmark harness, CI job, and profiling docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .ai/PERFORMANCE.md | 150 ++++++++++ .github/workflows/benchmarks.yml | 121 ++++++++ CLAUDE.md | 1 + benchmarks/.gitignore | 5 + benchmarks/README.md | 41 +++ benchmarks/__init__.py | 1 + benchmarks/baseline.json | 118 ++++++++ benchmarks/bench_app.py | 37 +++ benchmarks/run.py | 453 ++++++++++++++++++++++++++++++ benchmarks/scenarios.py | 466 +++++++++++++++++++++++++++++++ 10 files changed, 1393 insertions(+) create mode 100644 .ai/PERFORMANCE.md create mode 100644 .github/workflows/benchmarks.yml create mode 100644 benchmarks/.gitignore create mode 100644 benchmarks/README.md create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/baseline.json create mode 100644 benchmarks/bench_app.py create mode 100644 benchmarks/run.py create mode 100644 benchmarks/scenarios.py diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md new file mode 100644 index 0000000000..ab2541bc43 --- /dev/null +++ b/.ai/PERFORMANCE.md @@ -0,0 +1,150 @@ +# 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)** | **~580 ms** | 1.0x | **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 is O(n²) — the top opportunity + +Profiling `wildcard_all_resolve` (one input change, `Output({...: ALL})` over +400 components) puts ~45% of the time in ramda `_equals` + `_functionName` and +another chunk in `keys` / `_assoc` / `type`. Root cause: `getPath` for a +pattern-matching (dict) id does a **linear** `find(propEq(values, 'values'), +keyPaths)` over every component sharing that id shape +(`dash/dash-renderer/src/actions/paths.js`), and `propEq` is a **deep-equality** +on the id-values array. During an `ALL` resolution `getPath` is called per +component, so it is O(N) lookups × O(N) scan × O(k) equals ≈ **O(N²·k)**. + +Optimization not yet taken: index `paths.objs[keyStr]` by a hash of the values +(e.g. a `JSON.stringify(values)` key) so `getPath` is O(1). That turns wildcard +resolution from O(N²) into O(N). Left as a follow-up because it touches the +paths table shape that several call sites read. + +### 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. 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. + +### 4. 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. diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000000..0f5d7f2bc5 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -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 + + - 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 + python -m pip install "setuptools<80.0.0" + python -m pip install -e .[ci,dev,testing] + + - 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 + 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 = ''; + 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 diff --git a/CLAUDE.md b/CLAUDE.md index 61cc22b9cb..ec9c7c399e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 0000000000..60e8dc325d --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,5 @@ +# Generated benchmark artifacts (baseline.json is committed on purpose) +results.json +summary.md +*.cpuprofile +__pycache__/ diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000000..2e5888567c --- /dev/null +++ b/benchmarks/README.md @@ -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": } + +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`. diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000000..593f0bf897 --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1 @@ +"""Dash performance benchmark harness.""" diff --git a/benchmarks/baseline.json b/benchmarks/baseline.json new file mode 100644 index 0000000000..3188cf2a9c --- /dev/null +++ b/benchmarks/baseline.json @@ -0,0 +1,118 @@ +{ + "initial_render_small": { + "render_ms": { + "n": 8, + "min": 39.4, + "median": 43.8, + "p90": 47.5, + "max": 49.3, + "growth": 1.06 + } + }, + "initial_render_large": { + "render_ms": { + "n": 6, + "min": 221.5, + "median": 237.1, + "p90": 275.2, + "max": 307.6, + "growth": 0.79 + } + }, + "deep_nesting": { + "render_ms": { + "n": 8, + "min": 27.9, + "median": 28.6, + "p90": 29.7, + "max": 30.1, + "growth": 1.01 + } + }, + "patch_append_toplevel": { + "append_ms": { + "n": 14, + "min": 23.7, + "median": 44.7, + "p90": 65.4, + "max": 73.2, + "growth": 2.21 + } + }, + "patch_append_nested": { + "append_ms": { + "n": 14, + "min": 26.4, + "median": 51.8, + "p90": 80.6, + "max": 88.8, + "growth": 2.67 + } + }, + "full_children_replace": { + "replace_ms": { + "n": 10, + "min": 56.6, + "median": 853.6, + "p90": 2539.7, + "max": 3500.8, + "growth": 20.37 + } + }, + "patch_scalar_update_large": { + "update_ms": { + "n": 12, + "min": 76.9, + "median": 84.3, + "p90": 98.5, + "max": 103.0, + "growth": 0.85 + } + }, + "callback_fanout": { + "fanout_ms": { + "n": 12, + "min": 39.1, + "median": 42.2, + "p90": 47.0, + "max": 49.2, + "growth": 0.91 + } + }, + "wildcard_all_resolve": { + "wildcard_ms": { + "n": 12, + "min": 604.5, + "median": 618.1, + "p90": 632.5, + "max": 643.8, + "growth": 0.99 + }, + "graph_ms": { + "n": 12, + "min": 0.6, + "median": 0.6, + "p90": 0.6, + "max": 0.6, + "growth": 1.0 + } + }, + "callback_chain": { + "chain_ms": { + "n": 8, + "min": 454.7, + "median": 469.5, + "p90": 476.5, + "max": 499.4, + "growth": 0.95 + }, + "graph_ms": { + "n": 8, + "min": 2.1, + "median": 2.1, + "p90": 2.1, + "max": 2.1, + "growth": 1.0 + } + } +} \ No newline at end of file diff --git a/benchmarks/bench_app.py b/benchmarks/bench_app.py new file mode 100644 index 0000000000..7ae54e64a0 --- /dev/null +++ b/benchmarks/bench_app.py @@ -0,0 +1,37 @@ +"""Serve one benchmark scenario as a real Dash app in its own process. + + BENCH= BENCH_PARAMS='{"n": 3000}' BENCH_PORT=8090 \ + python -m benchmarks.bench_app + +Run with ``debug=False`` so it serves the *production* renderer bundle +(``dash_renderer.min.js``) - that is what real users get and what we want to +measure. Build it first with ``npm run build`` (or ``renderer build``). +""" +import json +import os + +from benchmarks.scenarios import SCENARIOS + + +def main(): + name = os.environ["BENCH"] + params = json.loads(os.environ.get("BENCH_PARAMS", "{}")) + port = int(os.environ.get("BENCH_PORT", "8090")) + + scenario = SCENARIOS[name] + app = scenario.build({**scenario.params, **params}) + # threaded so many rapid callback requests don't serialize on one worker; + # debug off => production (minified) bundle, no dev overhead. BENCH_DEV + # serves the *un*minified bundle instead (readable function names for + # profiling) without turning on the rest of the dev tools. + app.run( + host="127.0.0.1", + port=port, + debug=False, + threaded=True, + dev_tools_serve_dev_bundles=bool(os.environ.get("BENCH_DEV")), + ) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/run.py b/benchmarks/run.py new file mode 100644 index 0000000000..373caf4cb3 --- /dev/null +++ b/benchmarks/run.py @@ -0,0 +1,453 @@ +"""Run the Dash performance benchmarks and profile them. + +Measure everything (writes results.json + a markdown summary):: + + python -m benchmarks.run --out benchmarks/results.json + +Measure some scenarios:: + + python -m benchmarks.run --scenario patch_append_nested initial_render_large + +Compare against a saved baseline and gate on thresholds (this is what CI does):: + + python -m benchmarks.run --baseline benchmarks/baseline.json \ + --summary-md summary.md + +CPU-profile a single scenario (saves a .cpuprofile loadable in Chrome DevTools +/ VS Code, and prints the hottest functions):: + + python -m benchmarks.run --profile patch_append_nested + +Each scenario runs in its own ``bench_app`` subprocess serving the *production* +renderer bundle, driven by a headless Chrome. Timings come from +``performance.now()`` inside the page, aggregated across repeats as median / p90 +/ max after dropping warm-up runs. +""" +from __future__ import annotations + +import argparse +import contextlib +import json +import os +import socket +import statistics +import subprocess +import sys +import time +from collections import defaultdict + +from selenium import webdriver +from selenium.webdriver.chrome.options import Options + +from benchmarks.scenarios import SCENARIOS, Scenario + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +POLL = 0.003 # seconds between DOM polls while waiting for a scenario step + + +# --------------------------------------------------------------------------- +# Browser helper handed to each scenario's drive() +# --------------------------------------------------------------------------- + + +class Browser: + def __init__(self, driver, base_url): + self.driver = driver + self.base_url = base_url + # per-scenario scratch, e.g. cumulative child counts across repeats + self.state: dict = {} + + def js(self, script): + return self.driver.execute_script(script) + + def reload(self): + self.driver.get(self.base_url) + # React tracks an input's value through its own setter, so a plain + # `el.value = x` is ignored by onChange. __setVal goes through the + # native prototype setter so a dispatched 'input' reaches React. + self.js( + "window.__setVal = function(el, v){" + "var d=Object.getOwnPropertyDescriptor(" + "window.HTMLInputElement.prototype,'value');" + "d.set.call(el, v);" + "el.dispatchEvent(new Event('input',{bubbles:true}));};" + ) + + def wait(self, expr, timeout=60): + deadline = time.perf_counter() + timeout + while True: + if self.js(f"return Boolean({expr});"): + return + if time.perf_counter() > deadline: + raise TimeoutError(f"timed out waiting for: {expr}") + time.sleep(POLL) + + def render_time(self, ready_sel, timeout=60): + """Reload already happened; return ms from navigation responseEnd to + the ready sentinel being in the DOM (i.e. hydration time).""" + self.wait(f"document.querySelector('{ready_sel}')", timeout) + return float( + self.js( + "var nav=performance.getEntriesByType('navigation')[0];" + "return performance.now() - (nav ? nav.responseEnd : 0);" + ) + ) + + def timed(self, trigger_js, done_expr, timeout=60): + """Stamp t0, fire trigger_js, poll done_expr; return the in-browser ms + measured at the moment done_expr first holds.""" + self.js("window.__bt0 = performance.now();" + trigger_js) + deadline = time.perf_counter() + timeout + while True: + ms = self.js( + f"return ({done_expr}) ? (performance.now() - window.__bt0) : -1;" + ) + if ms is not None and ms >= 0: + return float(ms) + if time.perf_counter() > deadline: + raise TimeoutError(f"timed out waiting for: {done_expr}") + time.sleep(POLL) + + def graph_time(self): + val = self.js( + "return (window.dash_component_api" + " && window.dash_component_api.callbackGraphTime) || null;" + ) + return float(val) if val is not None else None + + +# --------------------------------------------------------------------------- +# Process + driver lifecycle +# --------------------------------------------------------------------------- + + +def _free_port(): + with contextlib.closing(socket.socket()) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@contextlib.contextmanager +def serve(scenario: Scenario, params: dict, port: int, dev: bool = False): + env = { + **os.environ, + "BENCH": scenario.name, + "BENCH_PARAMS": json.dumps(params), + "BENCH_PORT": str(port), + "BENCH_DEV": "1" if dev else "", + } + proc = subprocess.Popen( + [sys.executable, "-m", "benchmarks.bench_app"], + cwd=REPO_ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + deadline = time.perf_counter() + 40 + while True: + with contextlib.closing(socket.socket()) as s: + if s.connect_ex(("127.0.0.1", port)) == 0: + break + if proc.poll() is not None: + raise RuntimeError(f"{scenario.name} app exited early") + if time.perf_counter() > deadline: + raise TimeoutError(f"{scenario.name} app never came up") + time.sleep(0.05) + yield f"http://127.0.0.1:{port}/" + finally: + proc.terminate() + with contextlib.suppress(Exception): + proc.wait(timeout=10) + + +def make_driver(): + opts = Options() + for a in ( + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--window-size=1400,1000", + ): + opts.add_argument(a) + return webdriver.Chrome(options=opts) + + +# --------------------------------------------------------------------------- +# Aggregation +# --------------------------------------------------------------------------- + + +def _pct(values, p): + if not values: + return None + s = sorted(values) + k = min(len(s) - 1, int(round((p / 100) * (len(s) - 1)))) + return s[k] + + +def summarize(series): + """series: dict[metric] -> list of per-repeat floats (warm-up removed).""" + out = {} + for metric, vals in series.items(): + vals = [v for v in vals if v is not None] + if not vals: + continue + third = max(1, len(vals) // 3) + out[metric] = { + "n": len(vals), + "min": round(min(vals), 1), + "median": round(statistics.median(vals), 1), + "p90": round(_pct(vals, 90), 1), + "max": round(max(vals), 1), + # growth = late third vs early third; ~1 means flat, >>1 means the + # per-op cost scales with the accumulated state (an O(total) smell). + "growth": round( + statistics.median(vals[-third:]) + / max(1e-6, statistics.median(vals[:third])), + 2, + ), + } + return out + + +# --------------------------------------------------------------------------- +# Running a scenario +# --------------------------------------------------------------------------- + + +def run_scenario(scenario: Scenario, params: dict): + series = defaultdict(list) + port = _free_port() + with serve(scenario, params, port) as url: + driver = make_driver() + try: + b = Browser(driver, url) + b.reload() + b.wait("document.querySelector('#bench-ready')") + total = scenario.warmup + scenario.repeats + for i in range(total): + metrics = scenario.drive(b, params) + if i >= scenario.warmup: + for k, v in metrics.items(): + series[k].append(v) + finally: + driver.quit() + return summarize(series) + + +# --------------------------------------------------------------------------- +# CPU profiling (Chrome DevTools Profiler via CDP) +# --------------------------------------------------------------------------- + + +def profile_scenario(scenario: Scenario, params: dict, out_path: str): + port = _free_port() + # dev bundle => readable function names in the CPU profile. + with serve(scenario, params, port, dev=True) as url: + driver = make_driver() + try: + b = Browser(driver, url) + b.reload() + b.wait("document.querySelector('#bench-ready')") + # warm up so we profile steady state, not first-run JIT + for _ in range(max(scenario.warmup, 2)): + scenario.drive(b, params) + driver.execute_cdp_cmd("Profiler.enable", {}) + driver.execute_cdp_cmd("Profiler.setSamplingInterval", {"interval": 50}) + driver.execute_cdp_cmd("Profiler.start", {}) + for _ in range(max(scenario.repeats, 6)): + scenario.drive(b, params) + profile = driver.execute_cdp_cmd("Profiler.stop", {})["profile"] + finally: + driver.quit() + + with open(out_path, "w") as f: + json.dump(profile, f) + return profile, out_path + + +def profile_hot_functions(profile, top=25): + """Aggregate self-time (via hitCount) per function from a CPU profile.""" + deltas = profile.get("timeDeltas") or [] + interval_us = statistics.median(deltas) if deltas else 0.0 + hits_by_fn: dict = defaultdict(int) + loc_by_fn: dict = {} + for node in profile["nodes"]: + frame = node["callFrame"] + name = frame.get("functionName") or "(anonymous)" + hits_by_fn[name] += node.get("hitCount", 0) + if name not in loc_by_fn: + short = frame.get("url", "").rsplit("/", 1)[-1] + loc_by_fn[name] = f"{short}:{frame.get('lineNumber', '')}" if short else "" + total_hits = sum(hits_by_fn.values()) or 1 + ranked = sorted(hits_by_fn.items(), key=lambda kv: kv[1], reverse=True) + lines = [] + for name, hits in ranked[:top]: + self_ms = hits * interval_us / 1000.0 + pct = 100.0 * hits / total_hits + lines.append((round(self_ms, 1), round(pct, 1), name, loc_by_fn[name])) + return lines + + +# --------------------------------------------------------------------------- +# Threshold gating + reporting +# --------------------------------------------------------------------------- + + +def gate(results, scenarios, baseline=None): + """Return (rows, worst) where worst is 'ok' | 'warn' | 'fail'. + + A metric fails on the absolute fail_ms ceiling, or (if a baseline exists) + on a >2x regression vs baseline p90. It warns on warn_ms, or a >1.3x + baseline regression.""" + severity = {"ok": 0, "warn": 1, "fail": 2} + rows = [] + worst = "ok" + for name, res in results.items(): + sc = scenarios[name] + base = (baseline or {}).get(name, {}) + for metric, stats in res.items(): + p90 = stats["p90"] + level = "ok" + reasons = [] + fail_ms = sc.fail_ms.get(metric) + warn_ms = sc.warn_ms.get(metric) + if fail_ms is not None and p90 > fail_ms: + level = "fail" + reasons.append(f"p90 {p90}ms > fail {fail_ms}ms") + elif warn_ms is not None and p90 > warn_ms: + level = "warn" + reasons.append(f"p90 {p90}ms > warn {warn_ms}ms") + base_p90 = base.get(metric, {}).get("p90") + if base_p90: + ratio = p90 / base_p90 + if ratio > 2.0: + level = "fail" + reasons.append(f"{ratio:.1f}x baseline") + elif ratio > 1.3 and level != "fail": + level = "warn" if level == "ok" else level + reasons.append(f"{ratio:.1f}x baseline") + if severity[level] > severity[worst]: + worst = level + rows.append( + { + "scenario": name, + "metric": metric, + "p90": p90, + "median": stats["median"], + "growth": stats["growth"], + "base_p90": base.get(metric, {}).get("p90"), + "level": level, + "reasons": "; ".join(reasons), + } + ) + return rows, worst + + +def markdown(rows, worst, errors=None): + icon = {"ok": "✅", "warn": "⚠️", "fail": "❌"} + head = { + "ok": "✅ all within thresholds", + "warn": "⚠️ regressions to review", + "fail": "❌ perf regression", + } + out = ["## Dash performance benchmarks", "", f"**{head[worst]}**", ""] + if errors: + out.append("**Scenarios that failed to run:**") + out += [f"- `{name}`: {err}" for name, err in errors.items()] + out.append("") + out.append( + "| | scenario | metric | p90 (ms) | median | growth | baseline p90 | note |" + ) + out.append("|--|--|--|--:|--:|--:|--:|--|") + order = {"fail": 0, "warn": 1, "ok": 2} + for r in sorted(rows, key=lambda r: (order[r["level"]], r["scenario"])): + out.append( + f"| {icon[r['level']]} | {r['scenario']} | {r['metric']} " + f"| {r['p90']} | {r['median']} | {r['growth']}x " + f"| {r['base_p90'] if r['base_p90'] else '-'} | {r['reasons']} |" + ) + out.append("") + out.append( + "_growth = late-third / early-third per-op time; ~1 is flat, a large " + "value means the per-op cost scales with accumulated state._" + ) + return "\n".join(out) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--scenario", nargs="*", help="subset of scenarios to run") + ap.add_argument("--out", default="benchmarks/results.json") + ap.add_argument("--summary-md", help="write a markdown summary here") + ap.add_argument("--baseline", help="results.json to compare against") + ap.add_argument("--profile", help="CPU-profile this one scenario and exit") + ap.add_argument("--profile-out", default="benchmarks/profile.cpuprofile") + args = ap.parse_args() + + if args.profile: + sc = SCENARIOS[args.profile] + profile, path = profile_scenario(sc, sc.params, args.profile_out) + print( + f"\nCPU profile saved to {path} " + "(load in Chrome DevTools > Performance, or VS Code)\n" + ) + print(f"Hottest functions during {sc.name}:\n") + print(f"{'self ms':>8} {'%':>5} function") + for self_ms, pct, name, loc in profile_hot_functions(profile): + tag = f" [{loc}]" if loc else "" + print(f"{self_ms:>8} {pct:>5} {name or '(anonymous)'}{tag}") + return 0 + + names = args.scenario or list(SCENARIOS) + results = {} + errors = {} + for name in names: + sc = SCENARIOS[name] + print(f"running {name} ...", flush=True) + t0 = time.perf_counter() + try: + results[name] = run_scenario(sc, sc.params) + except Exception as exc: # keep the suite going if one app misbehaves + errors[name] = f"{type(exc).__name__}: {exc}" + print(f" ERROR: {errors[name]}", flush=True) + continue + dur = time.perf_counter() - t0 + for metric, stats in results[name].items(): + print( + f" {metric:14s} median={stats['median']:>7}ms " + f"p90={stats['p90']:>7}ms growth={stats['growth']}x " + f"({dur:.0f}s)" + ) + + os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True) + with open(args.out, "w") as f: + json.dump(results, f, indent=2) + print(f"\nwrote {args.out}") + + baseline = None + if args.baseline and os.path.exists(args.baseline): + with open(args.baseline) as f: + baseline = json.load(f) + + rows, worst = gate(results, SCENARIOS, baseline) + if errors: + worst = "fail" + md = markdown(rows, worst, errors) + if args.summary_md: + with open(args.summary_md, "w") as f: + f.write(md + "\n") + print("\n" + md) + + return 1 if worst == "fail" else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/scenarios.py b/benchmarks/scenarios.py new file mode 100644 index 0000000000..43ef16023c --- /dev/null +++ b/benchmarks/scenarios.py @@ -0,0 +1,466 @@ +"""Platform-wide performance scenarios for Dash. + +Each :class:`Scenario` bundles + +* ``build(params) -> Dash`` - constructs an app that exercises one dimension of + the platform (initial render, callbacks, wildcards, Patch, ...). Runs in a + *subprocess* (see ``bench_app.py``), so it must not import selenium. +* ``drive(b, params) -> dict[str, float]`` - runs the interaction and returns + named timings in milliseconds. Runs in the *harness* (see ``run.py``) and + talks to the page only through the small ``b`` browser helper, so it needs no + selenium import either. + +Timings are taken with ``performance.now()`` *inside the browser* (see +``b.timed``), so they measure real client work - server round-trip + patch +apply + React render - without selenium's per-poll latency leaking in. + +Thresholds are deliberately generous (see ``warn_ms`` / ``fail_ms``): this is a +signal for "something got materially slower", not a microbenchmark. ``fail_ms`` +is meant to catch an order-of-magnitude regression; ``warn_ms`` flags a smaller +drift that is worth a look and gets surfaced as a PR comment. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from dash import ALL, Dash, Input, Output, Patch, dcc, html + +# --------------------------------------------------------------------------- +# Shared building blocks +# --------------------------------------------------------------------------- + + +def _row(i): + """A small component subtree (3 nodes) - closer to a real list row.""" + return html.Div( + [html.Span(f"label {i}"), html.Span(f"value {i}", id=f"val-{i}")], + className="row", + ) + + +# The last-rendered sentinel every layout ends with, so the harness can detect +# "fully hydrated" precisely regardless of what the scenario put on the page. +READY = html.Div("ready", id="bench-ready") + + +# --------------------------------------------------------------------------- +# Scenario definition +# --------------------------------------------------------------------------- + + +@dataclass +class Scenario: + name: str + description: str + build: Callable[[dict], Dash] + drive: Callable[..., dict] + params: dict = field(default_factory=dict) + # Thresholds keyed by metric name. Missing metric => not gated. + warn_ms: dict = field(default_factory=dict) + fail_ms: dict = field(default_factory=dict) + # How many times to repeat the measured interaction (per process launch) + # and how many leading runs to discard as warm-up. + repeats: int = 12 + warmup: int = 2 + + +SCENARIOS: dict[str, Scenario] = {} + + +def scenario(**kw): + def register(fns): + build, drive = fns + sc = Scenario(build=build, drive=drive, **kw) + SCENARIOS[sc.name] = sc + return fns + + return register + + +# =========================================================================== +# 1. Initial render / hydration +# =========================================================================== + + +def _build_initial(params): + app = Dash(__name__) + n = params["n"] + app.layout = html.Div([html.Div([_row(i) for i in range(n)], id="content"), READY]) + return app + + +def _drive_initial(b, params): + # Reload a few times; measure hydration = time from the navigation response + # to the ready sentinel being present in the DOM. + b.reload() + return {"render_ms": b.render_time("#bench-ready")} + + +scenario( + name="initial_render_small", + description="Hydrate a 200-row layout on load", + params={"n": 200}, + warn_ms={"render_ms": 400}, + fail_ms={"render_ms": 1500}, + repeats=8, + warmup=1, +)((_build_initial, _drive_initial)) + +scenario( + name="initial_render_large", + description="Hydrate a 3000-row layout on load", + params={"n": 3000}, + warn_ms={"render_ms": 2500}, + fail_ms={"render_ms": 6000}, + repeats=6, + warmup=1, +)((_build_initial, _drive_initial)) + + +# =========================================================================== +# 2. Deep nesting hydration +# =========================================================================== + + +def _build_deep(params): + app = Dash(__name__) + node = READY + for i in range(params["depth"]): + node = html.Div(node, id=f"depth-{i}", className="wrap") + app.layout = html.Div(node, id="content") + return app + + +scenario( + # Depth is capped at 120: Dash layouts deeper than ~250 nested components + # fail to serialize (the JSON encoder's recursion limit), so this stays + # well under that while still stressing per-depth hydration. + name="deep_nesting", + description="Hydrate a single 120-deep component chain", + params={"depth": 120}, + warn_ms={"render_ms": 300}, + fail_ms={"render_ms": 1500}, + repeats=8, + warmup=1, +)((_build_deep, _drive_initial)) + + +# =========================================================================== +# 3. Patch append - top-level and nested (the append/rehydration path) +# =========================================================================== + + +def _build_patch_append(params): + app = Dash(__name__) + nested = params["nested"] + if nested: + container = html.Span([html.Span([], id="inner")], id="container") + else: + container = html.Span([], id="container") + app.layout = html.Div([html.Button("go", id="btn", n_clicks=0), container, READY]) + + @app.callback( + Output("container", "children"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + def grow(n): + p = Patch() + target = p[0]["props"]["children"] if nested else p + target.extend([html.Span(f"{n}.{i}") for i in range(params["batch"])]) + return p + + return app + + +def _drive_patch_append(b, params): + # Each click appends `batch` children; measure the click when the container + # is already large (the tail of the run), which is where an O(total) + # regression shows. `growth_ms` is that late-append time; the harness also + # reports how it compares to early appends via the per-repeat series. + grown = ( + "(document.getElementById('inner')" + "||document.getElementById('container')).childElementCount" + ) + batch = params["batch"] + # returns the in-browser ms for this single append + expected = b.state.get("count", 0) + batch + ms = b.timed( + "document.getElementById('btn').click()", + f"{grown} >= {expected}", + ) + b.state["count"] = expected + return {"append_ms": ms} + + +for _nested in (False, True): + scenario( + name=f"patch_append_{'nested' if _nested else 'toplevel'}", + description=("Nested" if _nested else "Top-level") + + " Patch().extend() into a growing container (per-append cost)", + params={"nested": _nested, "batch": 200}, + warn_ms={"append_ms": 250}, + fail_ms={"append_ms": 1500}, + repeats=14, + warmup=2, + )((_build_patch_append, _drive_patch_append)) + + +# =========================================================================== +# 4. Full children replacement (contrast to Patch append) +# =========================================================================== + + +def _build_full_replace(params): + app = Dash(__name__) + app.layout = html.Div( + [html.Button("go", id="btn", n_clicks=0), html.Div(id="container"), READY] + ) + + @app.callback( + Output("container", "children"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + def grow(n): + return [html.Span(f"{i}") for i in range(n * params["batch"])] + + return app + + +def _drive_full_replace(b, params): + batch = params["batch"] + expected = b.state.get("count", 0) + batch + ms = b.timed( + "document.getElementById('btn').click()", + f"document.getElementById('container').childElementCount >= {expected}", + ) + b.state["count"] = expected + return {"replace_ms": ms} + + +scenario( + # Intentionally the slow path: returning the whole list is O(total) every + # click, unlike Patch. Kept as a reference contrast, so its thresholds are + # loose - we only want to catch it getting *even* slower. + name="full_children_replace", + description="Rebuild the whole children list from a callback each click", + params={"batch": 200}, + warn_ms={"replace_ms": 4000}, + fail_ms={"replace_ms": 10000}, + repeats=10, + warmup=1, +)((_build_full_replace, _drive_full_replace)) + + +# =========================================================================== +# 5. Patch scalar update into a large list (in-place value change) +# =========================================================================== + + +def _build_patch_scalar(params): + app = Dash(__name__) + n = params["n"] + app.layout = html.Div( + [ + html.Button("go", id="btn", n_clicks=0), + html.Span([html.Span(f"s{i}") for i in range(n)], id="container"), + READY, + ] + ) + + @app.callback( + Output("container", "children"), + Input("btn", "n_clicks"), + prevent_initial_call=True, + ) + def touch(n_clicks): + p = Patch() + # flip a scalar on the first child - moves no component + p[0]["props"]["children"] = f"y{n_clicks}" + return p + + return app + + +def _drive_patch_scalar(b, params): + click = b.state.get("click", 0) + 1 + ms = b.timed( + "document.getElementById('btn').click()", + "document.getElementById('container').firstElementChild.textContent" + f" === 'y{click}'", + ) + b.state["click"] = click + return {"update_ms": ms} + + +scenario( + name="patch_scalar_update_large", + description="Patch a single scalar prop inside a 3000-child container", + params={"n": 3000}, + warn_ms={"update_ms": 300}, + fail_ms={"update_ms": 1500}, + repeats=12, + warmup=2, +)((_build_patch_scalar, _drive_patch_scalar)) + + +# =========================================================================== +# 6. Callback fan-out: one input -> many outputs +# =========================================================================== + + +def _build_fanout(params): + app = Dash(__name__) + n = params["n"] + app.layout = html.Div( + [ + dcc.Input(id="src", value="0"), + html.Div([html.Div(id=f"out-{i}") for i in range(n)], id="content"), + READY, + ] + ) + + @app.callback( + [Output(f"out-{i}", "children") for i in range(n)], + Input("src", "value"), + prevent_initial_call=True, + ) + def fan(v): + return [f"{v}-{i}" for i in range(n)] + + return app + + +def _drive_fanout(b, params): + n = params["n"] + click = b.state.get("click", 0) + 1 + ms = b.timed( + f"__setVal(document.getElementById('src'), '{click}')", + f"document.getElementById('out-{n - 1}').textContent === '{click}-{n - 1}'", + ) + b.state["click"] = click + return {"fanout_ms": ms} + + +scenario( + name="callback_fanout", + description="One Input drives 300 Outputs through a single callback", + params={"n": 300}, + warn_ms={"fanout_ms": 600}, + fail_ms={"fanout_ms": 2500}, + repeats=12, + warmup=2, +)((_build_fanout, _drive_fanout)) + + +# =========================================================================== +# 7. Wildcard (MATCH) resolution over many pattern-matched components +# =========================================================================== + + +def _build_wildcard(params): + app = Dash(__name__) + n = params["n"] + rows = [] + for i in range(n): + rows.append( + html.Div( + [ + dcc.Input(id={"type": "in", "i": i}, value="0"), + html.Div(id={"type": "out", "i": i}, className="wout"), + ] + ) + ) + app.layout = html.Div([html.Div(rows, id="content"), READY]) + + @app.callback( + Output({"type": "out", "i": ALL}, "children"), + Input({"type": "in", "i": ALL}, "value"), + prevent_initial_call=True, + ) + def each(values): + return [f"{v}!" for v in values] + + return app + + +def _drive_wildcard(b, params): + # Change one input; the ALL callback must resolve across all n components. + click = b.state.get("click", 0) + 1 + ms = b.timed( + f"__setVal(document.querySelectorAll('#content input')[0], '{click}')", + f"document.querySelectorAll('.wout')[0].textContent === '{click}!'", + ) + b.state["click"] = click + return { + "wildcard_ms": ms, + # renderer-reported graph compute time for this dispatch + "graph_ms": b.graph_time(), + } + + +scenario( + name="wildcard_all_resolve", + description="One input change resolves an ALL callback over 400 components", + params={"n": 400}, + warn_ms={"wildcard_ms": 800, "graph_ms": 150}, + fail_ms={"wildcard_ms": 3000, "graph_ms": 800}, + repeats=12, + warmup=2, +)((_build_wildcard, _drive_wildcard)) + + +# =========================================================================== +# 8. Callback graph compute (dependency graph scaling) +# =========================================================================== + + +def _build_graph(params): + app = Dash(__name__) + n = params["n"] + # A chain: src -> c0 -> c1 -> ... plus cross links, to build a non-trivial + # dependency graph the renderer has to resolve on each dispatch. + app.layout = html.Div( + [dcc.Input(id="src", value="0")] + + [html.Div(id=f"c-{i}") for i in range(n)] + + [READY] + ) + + for i in range(n): + src = "src" if i == 0 else f"c-{i - 1}" + + @app.callback( + Output(f"c-{i}", "children"), + Input(src, "value" if i == 0 else "children"), + prevent_initial_call=True, + ) + def step(v, _i=i): + return f"{v}-{_i}" + + return app + + +def _drive_graph(b, params): + n = params["n"] + click = b.state.get("click", 0) + 1 + ms = b.timed( + f"__setVal(document.getElementById('src'), '{click}')", + # chain concatenates: c-k = "-0-1-...-k"; the last one starting + # with this click's value means the dispatch propagated end to end. + f"document.getElementById('c-{n - 1}').textContent.startsWith('{click}-')", + ) + b.state["click"] = click + return {"chain_ms": ms, "graph_ms": b.graph_time()} + + +scenario( + name="callback_chain", + description="A 100-deep callback chain resolves end to end", + params={"n": 100}, + warn_ms={"chain_ms": 2500, "graph_ms": 100}, + fail_ms={"chain_ms": 8000, "graph_ms": 600}, + repeats=8, + warmup=1, +)((_build_graph, _drive_graph)) From a49967f67bac23d121c6200d79cfef74cc4b8470 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Aug 2026 14:06:44 -0400 Subject: [PATCH 2/3] Make pattern-matching (MATCH/ALL) path resolution O(1) instead of O(n) 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. --- .ai/PERFORMANCE.md | 35 ++++--- CHANGELOG.md | 1 + benchmarks/baseline.json | 114 +++++++++++------------ benchmarks/scenarios.py | 4 +- dash/dash-renderer/src/actions/paths.js | 84 +++++++++++++++-- dash/dash-renderer/src/reducers/paths.js | 2 +- 6 files changed, 160 insertions(+), 80 deletions(-) diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md index ab2541bc43..4b81a224a2 100644 --- a/.ai/PERFORMANCE.md +++ b/.ai/PERFORMANCE.md @@ -108,24 +108,31 @@ function dominates), not the absolute ms. | 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)** | **~580 ms** | 1.0x | **O(n²), see below** | +| 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 is O(n²) — the top opportunity +### 1. Wildcard (ALL / MATCH) resolution — was O(n²), now O(n) [fixed] Profiling `wildcard_all_resolve` (one input change, `Output({...: ALL})` over -400 components) puts ~45% of the time in ramda `_equals` + `_functionName` and -another chunk in `keys` / `_assoc` / `type`. Root cause: `getPath` for a -pattern-matching (dict) id does a **linear** `find(propEq(values, 'values'), -keyPaths)` over every component sharing that id shape -(`dash/dash-renderer/src/actions/paths.js`), and `propEq` is a **deep-equality** -on the id-values array. During an `ALL` resolution `getPath` is called per -component, so it is O(N) lookups × O(N) scan × O(k) equals ≈ **O(N²·k)**. - -Optimization not yet taken: index `paths.objs[keyStr]` by a hash of the values -(e.g. a `JSON.stringify(values)` key) so `getPath` is O(1). That turns wildcard -resolution from O(N²) into O(N). Left as a follow-up because it touches the -paths table shape that several call sites read. +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b900a39347..3712d26b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ 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 +- 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. diff --git a/benchmarks/baseline.json b/benchmarks/baseline.json index 3188cf2a9c..5552ef16b3 100644 --- a/benchmarks/baseline.json +++ b/benchmarks/baseline.json @@ -2,116 +2,116 @@ "initial_render_small": { "render_ms": { "n": 8, - "min": 39.4, - "median": 43.8, - "p90": 47.5, - "max": 49.3, + "min": 59.7, + "median": 68.6, + "p90": 70.5, + "max": 74.9, "growth": 1.06 } }, "initial_render_large": { "render_ms": { "n": 6, - "min": 221.5, - "median": 237.1, - "p90": 275.2, - "max": 307.6, - "growth": 0.79 + "min": 351.0, + "median": 372.5, + "p90": 421.3, + "max": 433.7, + "growth": 0.91 } }, "deep_nesting": { "render_ms": { "n": 8, - "min": 27.9, - "median": 28.6, - "p90": 29.7, - "max": 30.1, - "growth": 1.01 + "min": 40.5, + "median": 44.2, + "p90": 45.1, + "max": 48.4, + "growth": 0.96 } }, "patch_append_toplevel": { "append_ms": { "n": 14, - "min": 23.7, - "median": 44.7, - "p90": 65.4, - "max": 73.2, - "growth": 2.21 + "min": 35.5, + "median": 72.6, + "p90": 93.1, + "max": 100.5, + "growth": 2.35 } }, "patch_append_nested": { "append_ms": { "n": 14, - "min": 26.4, - "median": 51.8, - "p90": 80.6, - "max": 88.8, - "growth": 2.67 + "min": 41.8, + "median": 94.5, + "p90": 126.1, + "max": 131.4, + "growth": 2.45 } }, "full_children_replace": { "replace_ms": { "n": 10, - "min": 56.6, - "median": 853.6, - "p90": 2539.7, - "max": 3500.8, - "growth": 20.37 + "min": 113.7, + "median": 1376.9, + "p90": 4508.7, + "max": 5921.4, + "growth": 20.63 } }, "patch_scalar_update_large": { "update_ms": { "n": 12, - "min": 76.9, - "median": 84.3, - "p90": 98.5, - "max": 103.0, - "growth": 0.85 + "min": 116.0, + "median": 125.7, + "p90": 139.3, + "max": 140.9, + "growth": 1.05 } }, "callback_fanout": { "fanout_ms": { "n": 12, - "min": 39.1, - "median": 42.2, - "p90": 47.0, - "max": 49.2, - "growth": 0.91 + "min": 54.0, + "median": 59.8, + "p90": 66.1, + "max": 66.3, + "growth": 0.88 } }, "wildcard_all_resolve": { "wildcard_ms": { "n": 12, - "min": 604.5, - "median": 618.1, - "p90": 632.5, - "max": 643.8, - "growth": 0.99 + "min": 193.3, + "median": 199.9, + "p90": 217.2, + "max": 219.6, + "growth": 0.92 }, "graph_ms": { "n": 12, - "min": 0.6, - "median": 0.6, - "p90": 0.6, - "max": 0.6, + "min": 1.2, + "median": 1.2, + "p90": 1.2, + "max": 1.2, "growth": 1.0 } }, "callback_chain": { "chain_ms": { "n": 8, - "min": 454.7, - "median": 469.5, - "p90": 476.5, - "max": 499.4, - "growth": 0.95 + "min": 613.8, + "median": 700.2, + "p90": 743.2, + "max": 751.3, + "growth": 1.07 }, "graph_ms": { "n": 8, - "min": 2.1, - "median": 2.1, - "p90": 2.1, - "max": 2.1, + "min": 1.0, + "median": 1.0, + "p90": 1.0, + "max": 1.0, "growth": 1.0 } } diff --git a/benchmarks/scenarios.py b/benchmarks/scenarios.py index 43ef16023c..1acd41ae46 100644 --- a/benchmarks/scenarios.py +++ b/benchmarks/scenarios.py @@ -247,8 +247,8 @@ def _drive_full_replace(b, params): name="full_children_replace", description="Rebuild the whole children list from a callback each click", params={"batch": 200}, - warn_ms={"replace_ms": 4000}, - fail_ms={"replace_ms": 10000}, + warn_ms={"replace_ms": 6000}, + fail_ms={"replace_ms": 12000}, repeats=10, warmup=1, )((_build_full_replace, _drive_full_replace)) diff --git a/dash/dash-renderer/src/actions/paths.js b/dash/dash-renderer/src/actions/paths.js index 051216adf8..36c91a0a83 100644 --- a/dash/dash-renderer/src/actions/paths.js +++ b/dash/dash-renderer/src/actions/paths.js @@ -16,14 +16,30 @@ import {crawlLayout} from './utils'; * state.paths has structure: * { * strs: {[id]: path} // for regular string ids - * objs: {[keyStr]: [{values, path}]} // for wildcard ids + * objs: {[keyStr]: [{values, path}]} // for wildcard ids, in layout order + * objIndex: {[keyStr]: {[valuesKey]: path}} // O(1) exact lookup for getPath * } * keyStr: sorted keys of the id, joined with ',' into one string * values: array of values in the id, in order of keys + * valuesKey: those values serialized, so an exact id resolves to its path + * without a linear scan of every component that shares the id's key set + * (what made resolving an ALL/MATCH callback over N components O(N^2)) + * + * `objs` stays an ordered array because pattern matching (MATCH/ALLSMALLER) + * and `getAllPMCIds` walk it in order; `objIndex` is the fast path only for + * exact lookups. A paths object that predates `objIndex` (an empty initial + * state, a hand-built test fixture) simply has none, and getPath falls back + * to the linear scan - so the two are always kept consistent by construction. */ +const valuesKey = values => JSON.stringify(values); + export function computePaths(subTree, startingPath, oldPaths, events) { - const {strs: oldStrs, objs: oldObjs} = oldPaths || {strs: {}, objs: {}}; + const { + strs: oldStrs, + objs: oldObjs, + objIndex: oldObjIndex = {} + } = oldPaths || {strs: {}, objs: {}}; const diffHead = path => startingPath.some((v, i) => path[i] !== v); @@ -31,9 +47,33 @@ export function computePaths(subTree, startingPath, oldPaths, events) { // if we're updating a subtree, clear out all of the existing items const strs = spLen ? filter(diffHead, oldStrs) : {}; const objs = {}; + + // objIndex mirrors objs as a valuesKey->path map for O(1) getPath. For a + // subtree update we keep the old maps and only touch the keyStrs whose + // entries actually change (copy-on-write), so re-resolving one small + // chunk doesn't rebuild the index for every unrelated wildcard component. + const objIndex = spLen ? {...oldObjIndex} : {}; + const touched = new Set(); + const editMap = keyStr => { + if (!touched.has(keyStr)) { + objIndex[keyStr] = {...(objIndex[keyStr] || {})}; + touched.add(keyStr); + } + return objIndex[keyStr]; + }; + if (spLen) { forEachObjIndexed((oldValPaths, oldKeys) => { - const newVals = filter(({path}) => diffHead(path), oldValPaths); + const newVals = []; + oldValPaths.forEach(entry => { + if (diffHead(entry.path)) { + newVals.push(entry); // outside the chunk: carried over + } else { + // inside the chunk being replaced: drop it, the crawl + // below re-adds whatever is still there + delete editMap(oldKeys)[valuesKey(entry.values)]; + } + }); if (newVals.length) { objs[oldKeys] = newVals; } @@ -56,6 +96,12 @@ export function computePaths(subTree, startingPath, oldPaths, events) { } else { objs[keyStr] = insert(index, item, paths); } + const map = editMap(keyStr); + const k = valuesKey(values); + if (!(k in map)) { + // first match wins, matching the old `find` semantics + map[k] = item.path; + } } else { strs[id] = concat(startingPath, itempath); } @@ -64,7 +110,7 @@ export function computePaths(subTree, startingPath, oldPaths, events) { // We include an event emitter here because it will be used along with // paths to determine when the app is ready for callbacks. - return {strs, objs, events: events || oldPaths.events}; + return {strs, objs, objIndex, events: events || oldPaths.events}; } /* @@ -81,6 +127,10 @@ export function appendPaths(newItems, startingPath, appendOffset, oldPaths) { const strs = {...oldPaths.strs}; const objs = {...oldPaths.objs}; const newObjItems = {}; + // Only extend the index if the old table already had one - otherwise + // it would be incomplete (missing the pre-existing entries) and getPath + // must keep falling back to the linear scan on `objs`. + const objIndex = oldPaths.objIndex ? {...oldPaths.objIndex} : null; newItems.forEach((child, i) => { crawlLayout(child, (c, itempath) => { @@ -107,20 +157,42 @@ export function appendPaths(newItems, startingPath, appendOffset, oldPaths) { Object.keys(newObjItems).forEach(keyStr => { objs[keyStr] = concat(objs[keyStr] || [], newObjItems[keyStr]); + if (objIndex) { + const map = {...(objIndex[keyStr] || {})}; + newObjItems[keyStr].forEach(({values, path: p}) => { + const k = valuesKey(values); + if (!(k in map)) { + map[k] = p; + } + }); + objIndex[keyStr] = map; + } }); - return {strs, objs, events: oldPaths.events}; + return { + strs, + objs, + ...(objIndex ? {objIndex} : {}), + events: oldPaths.events + }; } export function getPath(paths, id) { if (typeof id === 'object') { const keys = Object.keys(id).sort(); const keyStr = keys.join(','); + const values = props(keys, id); + // O(1) exact lookup when the table carries an index (always, once it + // has been through computePaths/appendPaths). A present index is + // complete, so a miss means the id genuinely isn't on the page. + if (paths.objIndex) { + const map = paths.objIndex[keyStr]; + return (map && map[valuesKey(values)]) || false; + } const keyPaths = paths.objs[keyStr]; if (!keyPaths) { return false; } - const values = props(keys, id); const pathObj = find(propEq(values, 'values'), keyPaths); return pathObj && pathObj.path; } diff --git a/dash/dash-renderer/src/reducers/paths.js b/dash/dash-renderer/src/reducers/paths.js index fd48700108..bbd61fa2b8 100644 --- a/dash/dash-renderer/src/reducers/paths.js +++ b/dash/dash-renderer/src/reducers/paths.js @@ -1,6 +1,6 @@ import {getAction} from '../actions/constants'; -const initialPaths = {strs: {}, objs: {}}; +const initialPaths = {strs: {}, objs: {}, objIndex: {}}; const paths = (state = initialPaths, action) => { if (action.type === getAction('SET_PATHS')) { From e429cb1d4ab27cc082340f07f3a5c6692873e282 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 18 Aug 2026 14:56:37 -0400 Subject: [PATCH 3/3] Cut ramda currying overhead in the per-node layout crawl 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. --- .ai/PERFORMANCE.md | 21 +++- CHANGELOG.md | 1 + benchmarks/baseline.json | 116 +++++++++--------- benchmarks/run.py | 9 +- .../dash-renderer/src/actions/dependencies.js | 2 +- dash/dash-renderer/src/actions/paths.js | 5 +- dash/dash-renderer/src/actions/utils.js | 30 ++--- 7 files changed, 98 insertions(+), 86 deletions(-) diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md index 4b81a224a2..59247ace62 100644 --- a/.ai/PERFORMANCE.md +++ b/.ai/PERFORMANCE.md @@ -143,13 +143,30 @@ 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. Full children replacement is the O(total) baseline +### 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. -### 4. Layouts deeper than ~250 nested components fail to serialize +### 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` diff --git a/CHANGELOG.md b/CHANGELOG.md index 3712d26b0e..e56fbc6e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ 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` diff --git a/benchmarks/baseline.json b/benchmarks/baseline.json index 5552ef16b3..1ac956e460 100644 --- a/benchmarks/baseline.json +++ b/benchmarks/baseline.json @@ -2,116 +2,116 @@ "initial_render_small": { "render_ms": { "n": 8, - "min": 59.7, - "median": 68.6, - "p90": 70.5, - "max": 74.9, - "growth": 1.06 + "min": 37.8, + "median": 43.1, + "p90": 45.9, + "max": 46.8, + "growth": 0.98 } }, "initial_render_large": { "render_ms": { "n": 6, - "min": 351.0, - "median": 372.5, - "p90": 421.3, - "max": 433.7, - "growth": 0.91 + "min": 212.0, + "median": 220.1, + "p90": 227.0, + "max": 235.2, + "growth": 1.0 } }, "deep_nesting": { "render_ms": { "n": 8, - "min": 40.5, - "median": 44.2, - "p90": 45.1, - "max": 48.4, - "growth": 0.96 + "min": 26.2, + "median": 27.7, + "p90": 28.8, + "max": 29.2, + "growth": 0.99 } }, "patch_append_toplevel": { "append_ms": { "n": 14, - "min": 35.5, - "median": 72.6, - "p90": 93.1, - "max": 100.5, - "growth": 2.35 + "min": 21.0, + "median": 43.0, + "p90": 65.0, + "max": 74.3, + "growth": 2.64 } }, "patch_append_nested": { "append_ms": { "n": 14, - "min": 41.8, - "median": 94.5, - "p90": 126.1, - "max": 131.4, - "growth": 2.45 + "min": 22.8, + "median": 47.4, + "p90": 74.9, + "max": 80.9, + "growth": 2.63 } }, "full_children_replace": { "replace_ms": { "n": 10, - "min": 113.7, - "median": 1376.9, - "p90": 4508.7, - "max": 5921.4, - "growth": 20.63 + "min": 55.9, + "median": 828.4, + "p90": 2429.4, + "max": 3386.5, + "growth": 18.82 } }, "patch_scalar_update_large": { "update_ms": { "n": 12, - "min": 116.0, - "median": 125.7, - "p90": 139.3, - "max": 140.9, - "growth": 1.05 + "min": 69.4, + "median": 76.1, + "p90": 85.9, + "max": 93.7, + "growth": 0.94 } }, "callback_fanout": { "fanout_ms": { "n": 12, - "min": 54.0, - "median": 59.8, - "p90": 66.1, - "max": 66.3, - "growth": 0.88 + "min": 36.9, + "median": 40.8, + "p90": 47.3, + "max": 64.8, + "growth": 0.89 } }, "wildcard_all_resolve": { "wildcard_ms": { "n": 12, - "min": 193.3, - "median": 199.9, - "p90": 217.2, - "max": 219.6, - "growth": 0.92 + "min": 130.0, + "median": 135.9, + "p90": 138.6, + "max": 142.5, + "growth": 1.0 }, "graph_ms": { "n": 12, - "min": 1.2, - "median": 1.2, - "p90": 1.2, - "max": 1.2, + "min": 0.5, + "median": 0.5, + "p90": 0.5, + "max": 0.5, "growth": 1.0 } }, "callback_chain": { "chain_ms": { "n": 8, - "min": 613.8, - "median": 700.2, - "p90": 743.2, - "max": 751.3, - "growth": 1.07 + "min": 405.5, + "median": 419.4, + "p90": 430.7, + "max": 446.8, + "growth": 1.01 }, "graph_ms": { "n": 8, - "min": 1.0, - "median": 1.0, - "p90": 1.0, - "max": 1.0, + "min": 0.9, + "median": 0.9, + "p90": 0.9, + "max": 0.9, "growth": 1.0 } } diff --git a/benchmarks/run.py b/benchmarks/run.py index 373caf4cb3..d25b74eb52 100644 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -276,11 +276,14 @@ def profile_hot_functions(profile, top=25): loc_by_fn: dict = {} for node in profile["nodes"]: frame = node["callFrame"] - name = frame.get("functionName") or "(anonymous)" + short = frame.get("url", "").rsplit("/", 1)[-1] + loc = f"{short}:{frame.get('lineNumber', '')}" if short else "" + # Key anonymous frames by location so they don't all collapse into one + # opaque "(anonymous)" bucket - that is usually where the time is. + name = frame.get("functionName") or f"(anon) {loc or '?'}" hits_by_fn[name] += node.get("hitCount", 0) if name not in loc_by_fn: - short = frame.get("url", "").rsplit("/", 1)[-1] - loc_by_fn[name] = f"{short}:{frame.get('lineNumber', '')}" if short else "" + loc_by_fn[name] = loc total_hits = sum(hits_by_fn.values()) or 1 ranked = sorted(hits_by_fn.items(), key=lambda kv: kv[1], reverse=True) lines = [] diff --git a/dash/dash-renderer/src/actions/dependencies.js b/dash/dash-renderer/src/actions/dependencies.js index a2206c28bb..2ddca0fa4e 100644 --- a/dash/dash-renderer/src/actions/dependencies.js +++ b/dash/dash-renderer/src/actions/dependencies.js @@ -1399,7 +1399,7 @@ export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) { } crawlLayout(layoutChunk, child => { - const id = path(['props', 'id'], child); + const id = child.props && child.props.id; if (id) { if (typeof id === 'string' && !removedArrayInputsOnly) { handleOneId(id, graphs.outputMap[id], graphs.inputMap[id]); diff --git a/dash/dash-renderer/src/actions/paths.js b/dash/dash-renderer/src/actions/paths.js index 36c91a0a83..35ccbeeb58 100644 --- a/dash/dash-renderer/src/actions/paths.js +++ b/dash/dash-renderer/src/actions/paths.js @@ -4,7 +4,6 @@ import { find, forEachObjIndexed, insert, - path, propEq, props, indexOf @@ -81,7 +80,7 @@ export function computePaths(subTree, startingPath, oldPaths, events) { } crawlLayout(subTree, (child, itempath) => { - const id = path(['props', 'id'], child); + const id = child.props && child.props.id; if (id) { if (typeof id === 'object') { const keys = Object.keys(id).sort(); @@ -134,7 +133,7 @@ export function appendPaths(newItems, startingPath, appendOffset, oldPaths) { newItems.forEach((child, i) => { crawlLayout(child, (c, itempath) => { - const id = path(['props', 'id'], c); + const id = c.props && c.props.id; if (!id) { return; } diff --git a/dash/dash-renderer/src/actions/utils.js b/dash/dash-renderer/src/actions/utils.js index eb9f382adc..69ef61a1ce 100644 --- a/dash/dash-renderer/src/actions/utils.js +++ b/dash/dash-renderer/src/actions/utils.js @@ -1,14 +1,4 @@ -import { - append, - concat, - has, - path, - pathOr, - type, - findIndex, - includes, - slice -} from 'ramda'; +import {concat, has, path, type, findIndex, includes, slice} from 'ramda'; /* * requests_pathname_prefix is the new config parameter introduced in @@ -86,23 +76,25 @@ export const crawlLayout = ( ); } } else { - crawlLayout(child, func, append(i, currentPath)); + crawlLayout(child, func, currentPath.concat(i)); } }); } else if (type(object) === 'Object') { func(object, currentPath); - const children = path(propsChildren, object); + // Hot path: every component hits this on every crawl (path recompute, + // callback gather). Direct access avoids ramda's curried `path`/ + // `pathOr` dispatch (and the `_isPlaceholder` checks they do) per node. + const children = object.props && object.props.children; if (children) { - const newPath = concat(currentPath, propsChildren); + const newPath = currentPath.concat(propsChildren); crawlLayout(children, func, newPath); } - const childrenProps = pathOr( - [], - [object.namespace, object.type], - window.__dashprivate_childrenProps - ); + const cp = window.__dashprivate_childrenProps; + const childrenProps = + (cp && cp[object.namespace] && cp[object.namespace][object.type]) || + []; childrenProps.forEach(childrenProp => { if (childrenProp.includes('[]')) { let [frontPath, backPath] = childrenProp