From 505cd77d38d8fbda63123313b124a91bedc9c60a Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 7 Oct 2026 12:10:10 -0400 Subject: [PATCH 01/11] Add streaming load test and a public benchmark site benchmarks/streaming/ measures how many browsers each server setup can stream to (Flask/gunicorn, FastAPI and Quart on uvicorn, 1 or 4 workers), with simulated browsers that follow the renderer's StreamClient. benchmarks/publish.py builds a static site from the renderer timings and the streaming sweep (embeddable chart pages, raw data with history, shields badges), and benchmarks-publish.yml pushes it to gh-pages from dev. --- .ai/PERFORMANCE.md | 24 ++ .github/workflows/benchmarks-publish.yml | 159 +++++++ benchmarks/.gitignore | 2 + benchmarks/README.md | 34 ++ benchmarks/publish.py | 509 +++++++++++++++++++++++ benchmarks/streaming/__init__.py | 0 benchmarks/streaming/client.py | 247 +++++++++++ benchmarks/streaming/load.py | 477 +++++++++++++++++++++ benchmarks/streaming/load_app.py | 46 ++ benchmarks/web/charts.js | 169 ++++++++ benchmarks/web/style.css | 97 +++++ tests/unit/test_benchmarks_publish.py | 158 +++++++ 12 files changed, 1922 insertions(+) create mode 100644 .github/workflows/benchmarks-publish.yml create mode 100644 benchmarks/publish.py create mode 100644 benchmarks/streaming/__init__.py create mode 100644 benchmarks/streaming/client.py create mode 100644 benchmarks/streaming/load.py create mode 100644 benchmarks/streaming/load_app.py create mode 100644 benchmarks/web/charts.js create mode 100644 benchmarks/web/style.css create mode 100644 tests/unit/test_benchmarks_publish.py diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md index 47d91f8321..a3a81ad546 100644 --- a/.ai/PERFORMANCE.md +++ b/.ai/PERFORMANCE.md @@ -95,6 +95,30 @@ production bundle, runs the harness against `baseline.json`, and: 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. +## Streaming load test and the public site + +`benchmarks/streaming/` measures streaming-callback capacity per server setup +(see `benchmarks/README.md` for how to run it). Simulated browsers follow the +renderer's `StreamClient`: one downlink per browser, a long NDJSON response on +ASGI and polls with the same backoff on WSGI. Frames carry the server's +`time.time()`, so latency is measured end to end on one clock. + +Pitfalls the harness guards against: + +- One Python client process tops out at a few hundred browsers and then + reports its own lag as server latency. Clients run 100 browsers per process, + on CPUs separate from the server, and a point is flagged `client_bound` when + a client hits 85% CPU. +- Client output goes to temp files, not pipes: a full pipe blocks the client + forever because the runner only reads after the clients exit. +- Multi-worker setups need shared storage (Redis); with a per-process secret + the workers also reject each other's stream tokens, so the runner sets + a shared secret (`LOAD_SECRET`). The server imports dash from the checkout + under test (`PYTHONPATH`), not whatever is installed. + +`benchmarks/publish.py` turns results into the site on `gh-pages` +(`.github/workflows/benchmarks-publish.yml`). + ## Profiling a slow scenario The runner can capture a **Chrome DevTools CPU profile** of a scenario and print diff --git a/.github/workflows/benchmarks-publish.yml b/.github/workflows/benchmarks-publish.yml new file mode 100644 index 0000000000..80b85f20c1 --- /dev/null +++ b/.github/workflows/benchmarks-publish.yml @@ -0,0 +1,159 @@ +name: Publish Benchmarks + +# Measures the benchmarks on the dev branch and publishes them to GitHub +# Pages (the gh-pages branch): every chart as an embeddable page, the raw JSON +# and its history. Renderer timings run on every push to dev; the streaming +# load sweep takes ~30 minutes, so it runs weekly and on demand. + +on: + push: + branches: [dev] + paths: + - 'dash/**' + - 'benchmarks/**' + - 'components/**' + - '@plotly/**' + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + inputs: + streaming: + description: Run the streaming load sweep too + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: benchmarks-publish + cancel-in-progress: false + +jobs: + renderer: + name: Renderer benchmarks + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - name: Install Dash + run: | + python -m pip install --upgrade pip + python -m pip install "setuptools<80.0.0" + python -m pip install -e .[ci,dev,testing] + - run: npm run build + - 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 + - run: python -m benchmarks.run --out benchmarks/results.json + - uses: actions/upload-artifact@v4 + with: + name: renderer-results + path: benchmarks/results.json + + streaming: + name: Streaming load sweep + if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.streaming) + runs-on: ubuntu-latest + timeout-minutes: 120 + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - name: Install Dash + run: | + python -m pip install --upgrade pip + python -m pip install "setuptools<80.0.0" + python -m pip install -e .[ci,dev,testing,async,fastapi,quart,redis] + python -m pip install gunicorn uvicorn aiohttp psutil + - run: npm run build + - name: Run the sweep + env: + BENCH_RUNNER: GitHub Actions ubuntu-latest + run: | + ulimit -n 65536 + python -m benchmarks.streaming.load \ + --redis-url redis://127.0.0.1:6379/0 \ + --out benchmarks/streaming/results.json + - uses: actions/upload-artifact@v4 + with: + name: streaming-results + path: benchmarks/streaming/results.json + + publish: + name: Publish to GitHub Pages + needs: [renderer, streaming] + # Only dev's numbers go public, even when a dispatch runs elsewhere. + if: >- + always() && github.ref == 'refs/heads/dev' && + (needs.renderer.result == 'success' || needs.streaming.result == 'success') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Check out gh-pages + run: | + if git ls-remote --exit-code --heads origin gh-pages; then + git fetch origin gh-pages + git worktree add site origin/gh-pages + git -C site switch -c gh-pages + else + git worktree add --detach site + git -C site switch --orphan gh-pages + fi + - uses: actions/download-artifact@v4 + with: + path: results + - name: Build the site + env: + BENCH_RUNNER: GitHub Actions ubuntu-latest + run: | + args="" + [ -f results/renderer-results/results.json ] && args="$args --renderer results/renderer-results/results.json" + [ -f results/streaming-results/results.json ] && args="$args --streaming results/streaming-results/results.json" + python -m pip install -e . + python -m benchmarks.publish --site site --commit "${GITHUB_SHA::9}" \ + --base-url "https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}" $args + - name: Push + run: | + cd site + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --cached --quiet; then + echo "Nothing changed." + exit 0 + fi + git commit -m "Publish benchmarks for ${GITHUB_SHA::9}" + git push origin gh-pages diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore index 60e8dc325d..34a0da2e62 100644 --- a/benchmarks/.gitignore +++ b/benchmarks/.gitignore @@ -3,3 +3,5 @@ results.json summary.md *.cpuprofile __pycache__/ +streaming/results.json +site/ diff --git a/benchmarks/README.md b/benchmarks/README.md index 2e5888567c..a14d4979fd 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -39,3 +39,37 @@ scenario( `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`. + +## Streaming load test (`streaming/`) + +How many browsers a server setup can stream to. Each setup (Flask on +gunicorn, FastAPI and Quart on uvicorn, 1 or 4 workers) serves +`streaming/load_app.py`, and simulated browsers (`streaming/client.py`) stream +from it while the count climbs until p95 frame latency passes 1 s or errors +pass 1%. + +```bash +ulimit -n 65536 +python -m benchmarks.streaming.load --redis-url redis://127.0.0.1:6379/0 +python -m benchmarks.streaming.load --setup fastapi-uvicorn-w1 --browsers 100 1000 +``` + +Multi-worker setups share frames through Redis and are skipped without +`--redis-url`. The server and the clients get separate CPUs (`--server-cpus`, +half the machine by default); a point where a client process hit 85% CPU is +flagged `client_bound` and ends that setup's sweep, since past that the numbers +measure the load generator. + +## Publishing (`publish.py`) + +`.github/workflows/benchmarks-publish.yml` measures on `dev` and pushes a static +site to the `gh-pages` branch: renderer timings on every push, the streaming +sweep weekly and on demand. Each chart has its own page under `embed/` to drop +into an `' + ) + snippet = ( + f'
Embed this chart' + f"
{html.escape(code)}
" + ) + title = "h1" if embed else "h3" + home = ( + ' · Dash benchmarks' + if embed + else "" + ) + return f"""
+<{title} class="chart-title">{html.escape(chart['title'])} +

{html.escape(chart['subtitle'])}

+
+

{html.escape(chart['source'])}{home}

+ +{snippet} +
""" + + +def build(site, base_url, streaming, renderer, renderer_history): + charts = [] + if streaming: + charts += streaming_charts(streaming) + if renderer: + charts += renderer_charts(renderer, renderer_history) + + for name in ("style.css", "charts.js"): + shutil.copy(os.path.join(STATIC, name), os.path.join(site, name)) + os.makedirs(os.path.join(site, "embed"), exist_ok=True) + for chart in charts: + page = _page( + chart["title"], + _figure(chart, embed=True), + depth=1, + embed=True, + ) + with open( + os.path.join(site, "embed", f"{chart['id']}.html"), "w", encoding="utf-8" + ) as f: + f.write(page) + + def embed_url(chart): + return f"{base_url.rstrip('/')}/embed/{chart['id']}.html" + + sections = [] + if streaming: + sections.append( + "

Streaming callbacks under load

" + "

Simulated browsers stream from one app, one setup at a time, with the" + " count raised until frames arrive late or fail. Each browser behaves like the" + " renderer: one shared downlink, a long NDJSON response on ASGI and short polls" + " on WSGI. Server and clients run on separate cores of one machine.

" + + "".join( + _figure(c, embed_url(c)) + for c in charts + if c["id"].startswith("streaming") + ) + + "

All measurements

" + + _table(*streaming_table(streaming)) + + "
" + ) + if renderer: + sections.append( + "

Renderer

" + "

Each scenario runs as a real app in headless Chrome. Lower is better." + " Growth compares late to early operations: near 1x is flat, higher means the" + " cost grows with the page.

" + + "".join( + _figure(c, embed_url(c)) + for c in charts + if c["id"].startswith("renderer") + ) + + "

All measurements

" + + _table(*renderer_table(renderer)) + + "
" + ) + body = ( + "

Dash benchmarks

" + '

Performance numbers for Dash,' + ' measured by the harness in ' + "benchmarks/ and published from CI. Every chart can be embedded. Raw numbers:" + ' streaming' + ' (history),' + ' renderer' + ' (history).

' + + "".join(sections) + ) + with open(os.path.join(site, "index.html"), "w", encoding="utf-8") as f: + f.write(_page("Dash benchmarks", body)) + # Keep GitHub Pages from running the site through Jekyll. + open(os.path.join(site, ".nojekyll"), "w", encoding="utf-8").close() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--site", required=True) + parser.add_argument("--renderer", help="results.json from benchmarks.run") + parser.add_argument( + "--streaming", help="results.json from benchmarks.streaming.load" + ) + parser.add_argument("--commit", default=os.environ.get("GITHUB_SHA", "")[:9]) + parser.add_argument("--date") + parser.add_argument( + "--runner", default=os.environ.get("BENCH_RUNNER", "local machine") + ) + parser.add_argument( + "--base-url", + default="https://plotly.github.io/dash", + help="where the site is served, for the embed snippets", + ) + args = parser.parse_args(argv) + + site = args.site + data = os.path.join(site, "data") + os.makedirs(data, exist_ok=True) + + streaming = _load(args.streaming, None) + if streaming: + _dump(os.path.join(data, "streaming-latest.json"), streaming) + history = _load(os.path.join(data, "streaming-history.json"), []) + summary = { + "date": streaming["meta"]["date"], + "commit": streaming["meta"]["commit"], + "runner": streaming["meta"]["runner"], + "capacity": {s["name"]: capacity(s)[0] for s in streaming["setups"]}, + } + _dump( + os.path.join(data, "streaming-history.json"), + _append(history, summary, "date"), + ) + for name, badge in streaming_badges(streaming).items(): + _dump(os.path.join(site, "badges", f"{name}.json"), badge) + else: + streaming = _load(os.path.join(data, "streaming-latest.json"), None) + + renderer_history = _load(os.path.join(data, "renderer-history.json"), []) + results = _load(args.renderer, None) + if results: + import time # pylint: disable=import-outside-toplevel + + entry = { + "date": args.date or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "commit": args.commit, + "runner": args.runner, + "results": results, + } + renderer_history = _append(renderer_history, entry, "date") + _dump(os.path.join(data, "renderer-latest.json"), entry) + _dump(os.path.join(data, "renderer-history.json"), renderer_history) + renderer = _load(os.path.join(data, "renderer-latest.json"), None) + + build(site, args.base_url, streaming, renderer, renderer_history) + print(f"site written to {site}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/streaming/__init__.py b/benchmarks/streaming/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/benchmarks/streaming/client.py b/benchmarks/streaming/client.py new file mode 100644 index 0000000000..10bd238365 --- /dev/null +++ b/benchmarks/streaming/client.py @@ -0,0 +1,247 @@ +"""Simulated browsers for the streaming load test. + +Run by ``benchmarks.streaming.load``, several processes at once: one Python +process saturates its own CPU at a few hundred browsers and then reports its +own slowness as server latency. + +Each browser loads the page once (for its signed ``end_id`` and the server's +stream mode), then loops: click (uplink POST), read its frames off the +downlink until the ``done`` frame, think, click again. The downlink follows +the renderer's ``StreamClient``: one long NDJSON response in ``stream`` mode +(ASGI), or polling with the same pacing and backoff in ``poll`` mode (WSGI). + +Prints one JSON line with the measurements of every stream clicked inside the +measure window, however late it finished: counting by finish time would drop +exactly the slow streams a struggling server produces. +""" +import argparse +import asyncio +import json +import random +import re +import sys +import time + +import aiohttp + +CONFIG_RE = re.compile(r'', re.S) +STAMP_RE = re.compile(r'"(\d{10}\.\d{6})"') +UPDATE = "/_dash-update-component" +# Matches streamClient.ts. +EMPTY_POLLS_BEFORE_BACKOFF = 2 +MAX_BACKOFF_FACTOR = 5 + +CLICK = { + "output": "out.children", + "outputs": {"id": "out", "property": "children"}, + "inputs": [{"id": "btn", "property": "n_clicks", "value": 1}], + "changedPropIds": ["btn.n_clicks"], + "state": [], +} + + +class StreamReset(Exception): + pass + + +class Stats: + def __init__(self, measure_from, measure_to): + self.measure_from = measure_from + self.measure_to = measure_to + self.latency_ms = [] + self.first_frame_ms = [] + self.streams = 0 + self.frames = 0 + self.errors = 0 + self.incomplete = 0 + self.failed_browsers = 0 + + def in_window(self, t): + return self.measure_from <= t <= self.measure_to + + +async def _lines(resp): + buf = b"" + async for chunk in resp.content.iter_any(): + buf += chunk + *lines, buf = buf.split(b"\n") + for line in lines: + if line.strip(): + yield json.loads(line) + if buf.strip(): + yield json.loads(buf) + + +class Browser: + def __init__(self, base, stats, frames, think): + self.base = base + self.stats = stats + self.expected = frames + self.think = think + self.cursor = 0 + # Set from the page config by load_page(). + self.end_id = "" + self.mode = "stream" + self.poll_interval = 0.1 + # Browsers cap connections per host at six. + self.session = aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=6), + timeout=aiohttp.ClientTimeout(total=None, sock_connect=30), + ) + + async def load_page(self): + async with self.session.get(self.base + "/") as resp: + text = await resp.text() + config = json.loads(CONFIG_RE.search(text).group(1)) + stream = config.get("stream") or {} + self.end_id = config["end_id"] + self.mode = stream.get("mode", "stream") + self.poll_interval = stream.get("poll_interval", 100) / 1000 + + def _url(self): + return f"{self.base}{UPDATE}?endId={self.end_id}" + + def _frame(self, frame, clicked, state): + now = time.time() + if frame.get("done"): + return True + match = STAMP_RE.search(json.dumps(frame)) + if match: + counted = self.stats.in_window(clicked) + if state["frames"] == 0 and counted: + self.stats.first_frame_ms.append((now - clicked) * 1000) + state["frames"] += 1 + if counted: + self.stats.latency_ms.append((now - float(match.group(1))) * 1000) + self.stats.frames += 1 + return False + + def _envelopes(self, envelope, rid, clicked, state): + if envelope.get("reset"): + # The renderer settles every pending stream on a reset. + self.cursor = 0 + raise StreamReset() + if isinstance(envelope.get("seq"), int): + self.cursor = envelope["seq"] + if envelope.get("rid") != rid: + return False + return self._frame(envelope.get("frame") or {}, clicked, state) + + async def _downlink_stream(self, rid, clicked, state): + # Like the renderer, reconnect from the cursor if the downlink closes + # before the done frame, backing off when it closed with nothing. + while True: + got = False + async with self.session.post( + self._url(), json={"streamDownlink": {"from": self.cursor}} + ) as resp: + resp.raise_for_status() + async for envelope in _lines(resp): + got = True + if self._envelopes(envelope, rid, clicked, state): + return + if not got: + await asyncio.sleep(1) + + async def _downlink_poll(self, rid, clicked, state): + empty = 0 + while True: + async with self.session.post( + self._url(), json={"streamDownlink": {"from": self.cursor}} + ) as resp: + resp.raise_for_status() + envelopes = [e async for e in _lines(resp)] + for envelope in envelopes: + if self._envelopes(envelope, rid, clicked, state): + return + empty = 0 if envelopes else empty + 1 + factor = 1 + if empty > EMPTY_POLLS_BEFORE_BACKOFF: + factor = min( + 2 ** (empty - EMPTY_POLLS_BEFORE_BACKOFF), MAX_BACKOFF_FACTOR + ) + await asyncio.sleep(self.poll_interval * factor) + + async def one_stream(self, n): + rid = f"{id(self)}-{n}" + clicked = time.time() + async with self.session.post( + self._url(), json={**CLICK, "streamConnection": {"requestId": rid}} + ) as resp: + resp.raise_for_status() + await resp.read() + state = {"frames": 0} + if self.mode == "poll": + await self._downlink_poll(rid, clicked, state) + else: + await self._downlink_stream(rid, clicked, state) + if self.stats.in_window(clicked): + self.stats.streams += 1 + if state["frames"] < self.expected: + self.stats.incomplete += 1 + + async def run(self, start_at, stop_at): + await asyncio.sleep(max(0.0, start_at - time.time())) + n = 0 + try: + await asyncio.wait_for(self.load_page(), timeout=30) + while time.time() < stop_at: + n += 1 + clicked = time.time() + try: + await asyncio.wait_for(self.one_stream(n), timeout=60) + except ( + aiohttp.ClientError, + asyncio.TimeoutError, + ValueError, + StreamReset, + ): + if self.stats.in_window(clicked): + self.stats.errors += 1 + await asyncio.sleep(1) + await asyncio.sleep(random.uniform(0, 2 * self.think)) + except (aiohttp.ClientError, asyncio.TimeoutError, AttributeError, ValueError): + # The page never loaded: this browser never got to stream at all. + self.stats.errors += 1 + self.stats.failed_browsers += 1 + finally: + await self.session.close() + + +async def main(args): + start = args.start + ramp_end = start + args.ramp + stats = Stats(ramp_end, ramp_end + args.duration) + browsers = [ + Browser(args.url, stats, args.frames, args.think) for _ in range(args.browsers) + ] + await asyncio.gather( + *( + b.run(start + args.ramp * i / max(1, args.browsers), stats.measure_to) + for i, b in enumerate(browsers) + ) + ) + json.dump( + { + "latency_ms": [round(x, 2) for x in stats.latency_ms], + "first_frame_ms": [round(x, 2) for x in stats.first_frame_ms], + "streams": stats.streams, + "frames": stats.frames, + "errors": stats.errors, + "incomplete": stats.incomplete, + "failed_browsers": stats.failed_browsers, + }, + sys.stdout, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--url", required=True) + parser.add_argument("--browsers", type=int, required=True) + parser.add_argument("--frames", type=int, required=True) + parser.add_argument("--think", type=float, default=1.0) + parser.add_argument("--start", type=float, required=True) + parser.add_argument("--ramp", type=float, default=5.0) + parser.add_argument("--duration", type=float, default=20.0) + asyncio.run(main(parser.parse_args())) diff --git a/benchmarks/streaming/load.py b/benchmarks/streaming/load.py new file mode 100644 index 0000000000..b38a9e2f80 --- /dev/null +++ b/benchmarks/streaming/load.py @@ -0,0 +1,477 @@ +"""Streaming-callback load test: how many browsers each backend serves. + +For each server setup (backend x server x workers) and each browser count, +start the server, run simulated browsers against it (``client.py``, spread +over several processes) and record frame latency, time to first frame, +errors and CPU. A setup stops climbing once it saturates (p95 latency over +``--max-p95`` ms or errors over 1%), since higher counts only add noise. + + python -m benchmarks.streaming.load --out benchmarks/streaming/results.json + python -m benchmarks.streaming.load --setup fastapi-uvicorn-w1 --browsers 100 500 + +Multi-worker setups need shared storage across processes; pass +``--redis-url`` (they are skipped without it). The server and the clients +are pinned to separate CPUs (``--server-cpus``) so the clients' own load +does not show up as server latency, and each point records the clients' +peak CPU so a client-bound point can be spotted. +""" +from __future__ import annotations + +import argparse +import contextlib +import json +import math +import os +import platform +import resource +import socket +import statistics +import subprocess +import sys +import tempfile +import time +from dataclasses import asdict, dataclass + +import psutil +import requests + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.dirname(os.path.dirname(HERE)) +BROWSERS_PER_CLIENT = 100 + + +@dataclass +class Setup: + name: str + backend: str + server: str + workers: int + label: str + + @property + def needs_redis(self): + return self.workers > 1 + + +SETUPS = [ + Setup( + "flask-gunicorn-w1", + "flask", + "gunicorn", + 1, + "Flask, gunicorn 1 worker x 8 threads", + ), + Setup( + "flask-gunicorn-w4", + "flask", + "gunicorn", + 4, + "Flask, gunicorn 4 workers x 8 threads", + ), + Setup("fastapi-uvicorn-w1", "fastapi", "uvicorn", 1, "FastAPI, uvicorn 1 worker"), + Setup("fastapi-uvicorn-w4", "fastapi", "uvicorn", 4, "FastAPI, uvicorn 4 workers"), + Setup("quart-uvicorn-w1", "quart", "uvicorn", 1, "Quart, uvicorn 1 worker"), + Setup("quart-uvicorn-w4", "quart", "uvicorn", 4, "Quart, uvicorn 4 workers"), +] +SETUPS_BY_NAME = {s.name: s for s in SETUPS} + + +def _free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _cpus(spec): + cpus = set() + for part in spec.split(","): + lo, _, hi = part.partition("-") + cpus.update(range(int(lo), int(hi or lo) + 1)) + return sorted(cpus) + + +def _server_cmd(setup, port): + py = sys.executable + if setup.server == "gunicorn": + return [ + py, + "-m", + "gunicorn", + "load_app:server", + "--bind", + f"127.0.0.1:{port}", + "--workers", + str(setup.workers), + "--worker-class", + "gthread", + "--threads", + "8", + "--timeout", + "120", + "--backlog", + "4096", + ] + return [ + py, + "-m", + "uvicorn", + "load_app:server", + "--host", + "127.0.0.1", + "--port", + str(port), + "--workers", + str(setup.workers), + "--log-level", + "warning", + "--backlog", + "4096", + ] + + +@contextlib.contextmanager +def serve(setup, args): + port = _free_port() + env = dict(os.environ) + env.update( + LOAD_BACKEND=setup.backend, + LOAD_FRAMES=str(args.frames), + LOAD_INTERVAL=str(args.interval), + LOAD_SECRET="load-test", + LOAD_REDIS_URL=args.redis_url if setup.needs_redis else "", + # The server runs from this directory: without this it would import + # whichever dash is installed, not the checkout under test. + PYTHONPATH=os.pathsep.join( + p for p in (REPO_ROOT, os.environ.get("PYTHONPATH")) if p + ), + ) + proc = subprocess.Popen( # pylint: disable=consider-using-with + _server_cmd(setup, port), + cwd=HERE, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + with contextlib.suppress(AttributeError): + psutil.Process(proc.pid).cpu_affinity(args.server_cpus) + url = f"http://127.0.0.1:{port}" + try: + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if proc.poll() is not None: + raise RuntimeError(f"{setup.name} exited with {proc.returncode}") + with contextlib.suppress(requests.RequestException): + if requests.get(url, timeout=1).ok: + break + time.sleep(0.3) + else: + raise RuntimeError(f"{setup.name} never came up") + # The first worker answering does not mean the others are up yet; + # wait for all of them so CPU, memory and pinning cover every worker. + # uvicorn runs a single worker in the main process, without forking. + forks = setup.workers if setup.server == "gunicorn" or setup.workers > 1 else 0 + while ( + len(psutil.Process(proc.pid).children(recursive=True)) < forks + and time.monotonic() < deadline + ): + time.sleep(0.3) + time.sleep(1) + for child in psutil.Process(proc.pid).children(recursive=True): + with contextlib.suppress(psutil.Error, AttributeError): + child.cpu_affinity(args.server_cpus) + yield url, proc + finally: + with contextlib.suppress(ProcessLookupError): + os.killpg(proc.pid, 15) + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + with contextlib.suppress(ProcessLookupError): + os.killpg(proc.pid, 9) + proc.wait() + + +def _tree(pid): + proc = psutil.Process(pid) + return [proc] + proc.children(recursive=True) + + +def _pct(values, q): + if not values: + return None + values = sorted(values) + return round(values[min(len(values) - 1, math.ceil(q * len(values)) - 1)], 1) + + +def _start_clients(url, browsers, start, args): + n_clients = max(1, math.ceil(browsers / BROWSERS_PER_CLIENT)) + clients = [] + for i in range(n_clients): + share = browsers // n_clients + (1 if i < browsers % n_clients else 0) + # Files, not pipes: nothing reads a pipe until the clients exit, and a + # full pipe would block them forever. + out = tempfile.TemporaryFile() # pylint: disable=consider-using-with + err = tempfile.TemporaryFile() # pylint: disable=consider-using-with + proc = subprocess.Popen( # pylint: disable=consider-using-with + [ + sys.executable, + "-m", + "benchmarks.streaming.client", + "--url", + url, + "--browsers", + str(share), + "--frames", + str(args.frames), + "--think", + str(args.think), + "--start", + str(start), + "--ramp", + str(args.ramp), + "--duration", + str(args.duration), + ], + cwd=REPO_ROOT, + stdout=out, + stderr=err, + ) + with contextlib.suppress(psutil.Error, AttributeError): + psutil.Process(proc.pid).cpu_affinity(args.client_cpus) + clients.append((proc, share, out, err)) + return clients + + +def _sample(server_procs, client_procs, n_client_cpus): + server, mem = 0.0, 0 + for p in server_procs: + with contextlib.suppress(psutil.Error): + server += p.cpu_percent(None) + mem += p.memory_info().rss + peak, total = 0.0, 0.0 + for p in client_procs: + with contextlib.suppress(psutil.Error): + pct = p.cpu_percent(None) + peak = max(peak, pct) + total += pct + # Many client processes sharing few CPUs each look idle on their own; + # what matters is how busy the client CPUs are as a whole. + return server / 100, mem, max(peak, total / n_client_cpus) + + +def _collect(clients): + merged = {"latency_ms": [], "first_frame_ms": []} + counts = { + "streams": 0, + "frames": 0, + "errors": 0, + "incomplete": 0, + "failed_browsers": 0, + } + for proc, share, out, err in clients: + out.seek(0) + try: + result = json.loads(out.read()) + except ValueError: + result = None + if proc.returncode != 0 or result is None: + # A crashed client's browsers all count as failed, so the point + # can't look healthy with part of its load missing. + err.seek(0) + tail = err.read().decode(errors="replace").strip().splitlines()[-1:] + print(f" client exited {proc.returncode}: {' '.join(tail)}", flush=True) + counts["errors"] += share + counts["failed_browsers"] += share + else: + for key in merged: + merged[key] += result.get(key, []) + for key in counts: + counts[key] += result.get(key, 0) + out.close() + err.close() + return merged, counts + + +def run_point(setup, browsers, args): + with serve(setup, args) as (url, server): + start = time.time() + 2 + measure_from = start + args.ramp + measure_to = measure_from + args.duration + clients = _start_clients(url, browsers, start, args) + try: + server_procs = _tree(server.pid) + client_procs = [psutil.Process(c[0].pid) for c in clients] + for p in server_procs + client_procs: + with contextlib.suppress(psutil.Error): + p.cpu_percent(None) + server_cpu, client_cpu, rss = [], [], [] + # Streams started in the window may run up to the client's 60 s + # stream timeout past it; anything later is hung. + hard_deadline = measure_to + 90 + while any(c[0].poll() is None for c in clients): + if time.time() > hard_deadline: + raise RuntimeError( + f"clients still running {hard_deadline - measure_to:.0f}s after the window" + ) + time.sleep(1) + if not measure_from <= time.time() <= measure_to: + continue + cores, mem, client = _sample( + server_procs, client_procs, len(args.client_cpus) + ) + server_cpu.append(cores) + rss.append(mem) + client_cpu.append(client) + finally: + for proc, *_ in clients: + if proc.poll() is None: + proc.kill() + proc.wait() + + merged, counts = _collect(clients) + attempts = counts["streams"] + counts["errors"] + error_rate = ( + (counts["errors"] + counts["incomplete"]) / attempts if attempts else 1.0 + ) + lat = merged["latency_ms"] + point = { + "browsers": browsers, + "latency_p50_ms": _pct(lat, 0.5), + "latency_p95_ms": _pct(lat, 0.95), + "latency_p99_ms": _pct(lat, 0.99), + "first_frame_p50_ms": _pct(merged["first_frame_ms"], 0.5), + "first_frame_p95_ms": _pct(merged["first_frame_ms"], 0.95), + "frames_per_s": round(counts["frames"] / args.duration, 1), + "streams_per_s": round(counts["streams"] / args.duration, 2), + **counts, + "error_rate": round(error_rate, 4), + "server_cpu_cores": round(statistics.mean(server_cpu), 2) + if server_cpu + else None, + "server_rss_mb": round(max(rss) / 2**20) if rss else None, + "client_cpu_peak_pct": round(max(client_cpu)) if client_cpu else None, + } + point["saturated"] = bool( + error_rate > 0.01 or (point["latency_p95_ms"] or math.inf) > args.max_p95 + ) + # Clients near 100% CPU were the bottleneck, not the server. + point["client_bound"] = (point["client_cpu_peak_pct"] or 0) > 85 + return point + + +def machine_info(args): + cpu = platform.processor() + with contextlib.suppress(OSError): + with open("/proc/cpuinfo", encoding="utf-8") as f: + for line in f: + if line.startswith("model name"): + cpu = line.split(":", 1)[1].strip() + break + sha = subprocess.run( + ["git", "rev-parse", "--short", "HEAD"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ).stdout.strip() + import dash # pylint: disable=import-outside-toplevel + + return { + "date": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "commit": sha, + "dash_version": dash.__version__, + "python": platform.python_version(), + "cpu": cpu, + "cores": psutil.cpu_count(), + "memory_gb": round(psutil.virtual_memory().total / 2**30), + "server_cpus": len(args.server_cpus), + "client_cpus": len(args.client_cpus), + "runner": os.environ.get("BENCH_RUNNER", "local"), + } + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--setup", nargs="*", choices=list(SETUPS_BY_NAME)) + parser.add_argument( + "--browsers", + nargs="*", + type=int, + default=[25, 50, 100, 250, 500, 1000, 2000, 4000], + ) + parser.add_argument("--frames", type=int, default=20) + parser.add_argument( + "--interval", type=float, default=0.1, help="seconds between frames" + ) + parser.add_argument( + "--think", type=float, default=1.0, help="mean seconds between streams" + ) + parser.add_argument("--ramp", type=float, default=10.0) + parser.add_argument("--duration", type=float, default=20.0) + parser.add_argument("--max-p95", type=float, default=1000.0) + parser.add_argument("--redis-url") + parser.add_argument("--server-cpus", help="e.g. 0-3 (default: half the machine)") + parser.add_argument("--out", default=os.path.join(HERE, "results.json")) + args = parser.parse_args(argv) + + # Every browser holds sockets in both the server and its client process. + _soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard)) + + cores = psutil.cpu_count() + args.server_cpus = ( + _cpus(args.server_cpus) if args.server_cpus else list(range(cores // 2)) + ) + args.client_cpus = [ + c for c in range(cores) if c not in args.server_cpus + ] or args.server_cpus + + setups = [SETUPS_BY_NAME[n] for n in args.setup] if args.setup else SETUPS + results = { + "kind": "streaming_load", + "meta": machine_info(args), + "params": { + k: getattr(args, k) + for k in ("frames", "interval", "think", "ramp", "duration", "max_p95") + }, + "setups": [], + } + for setup in setups: + if setup.needs_redis and not args.redis_url: + print(f"skip {setup.name}: multi-worker needs --redis-url", flush=True) + continue + entry = { + **asdict(setup), + "storage": "redis" if setup.needs_redis else "local", + "points": [], + } + results["setups"].append(entry) + for browsers in sorted(args.browsers): + try: + point = run_point(setup, browsers, args) + except RuntimeError as err: + print(f"{setup.name} @ {browsers}: {err}", flush=True) + break + entry["points"].append(point) + print( + f"{setup.name:20} {browsers:5} browsers p50 {point['latency_p50_ms']}ms" + f" p95 {point['latency_p95_ms']}ms first {point['first_frame_p50_ms']}ms" + f" err {point['error_rate']:.2%} server {point['server_cpu_cores']} cores" + f" client peak {point['client_cpu_peak_pct']}%" + + (" SATURATED" if point["saturated"] else "") + + (" CLIENT-BOUND" if point["client_bound"] else ""), + flush=True, + ) + with open(args.out, "w", encoding="utf-8") as f: + json.dump(results, f, indent=1) + # Past either limit the next counts measure nothing useful: a + # saturated server only gets worse, and client-bound numbers are + # the load generator's, not the server's. + if point["saturated"] or point["client_bound"]: + break + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/streaming/load_app.py b/benchmarks/streaming/load_app.py new file mode 100644 index 0000000000..e039d590a1 --- /dev/null +++ b/benchmarks/streaming/load_app.py @@ -0,0 +1,46 @@ +"""The app the streaming load test serves. + +One streaming callback that yields ``FRAMES`` frames, ``INTERVAL`` seconds +apart. Each frame carries the server's ``time.time()`` so the client can +measure delivery latency (clients run on the same machine, so the clocks +agree). + +Configured from the environment so gunicorn, uvicorn and hypercorn can all +import it: ``LOAD_BACKEND`` (flask, fastapi, quart), ``LOAD_FRAMES``, +``LOAD_INTERVAL``. ``LOAD_REDIS_URL`` switches shared storage to Redis (needed +across workers), and ``LOAD_SECRET`` gives every worker the same signing secret +so they verify each other's stream tokens. +""" +import asyncio +import os +import time + +from dash import Dash, Input, Output, LocalSharedStorage, RedisSharedStorage, html + +BACKEND = os.environ.get("LOAD_BACKEND", "flask") +FRAMES = int(os.environ.get("LOAD_FRAMES", "20")) +INTERVAL = float(os.environ.get("LOAD_INTERVAL", "0.1")) + +REDIS_URL = os.environ.get("LOAD_REDIS_URL") + +app = Dash( + __name__, + backend=BACKEND, + shared_storage=RedisSharedStorage(url=REDIS_URL) + if REDIS_URL + else LocalSharedStorage, +) +app.server.secret_key = os.environ.get("LOAD_SECRET", "load-test") +app.layout = html.Div([html.Button("go", id="btn", n_clicks=0), html.Div(id="out")]) + + +@app.callback(Output("out", "children"), Input("btn", "n_clicks")) +async def stream(n): + if not n: + return + for _ in range(FRAMES): + yield f"{time.time():.6f}" + await asyncio.sleep(INTERVAL) + + +server = app.server diff --git a/benchmarks/web/charts.js b/benchmarks/web/charts.js new file mode 100644 index 0000000000..17ba047cf2 --- /dev/null +++ b/benchmarks/web/charts.js @@ -0,0 +1,169 @@ +/* Renders the chart specs benchmarks/publish.py writes into each page. */ +(function () { + const params = new URLSearchParams(location.search); + const forced = params.get('theme'); + if (forced === 'light' || forced === 'dark') { + document.documentElement.setAttribute('data-theme', forced); + } + + const css = name => + getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + + const charts = []; + + function axis(spec, extra) { + const log = spec && spec.type === 'log'; + return Object.assign( + { + title: {text: (spec && spec.title) || '', font: {size: 12, color: css('--ink-2')}}, + type: (spec && spec.type) || 'linear', + rangemode: (spec && spec.rangemode) || 'normal', + gridcolor: css('--grid'), + linecolor: css('--axis'), + zeroline: false, + tickfont: {size: 11, color: css('--muted')}, + automargin: true, + // Log axes: label the decades only (1, 10, 100, ...). + dtick: log ? 1 : undefined + }, + extra || {} + ); + } + + function baseLayout(spec) { + return { + paper_bgcolor: 'rgba(0,0,0,0)', + plot_bgcolor: 'rgba(0,0,0,0)', + font: {family: 'system-ui, -apple-system, "Segoe UI", sans-serif', color: css('--ink')}, + margin: {l: 8, r: 16, t: 8, b: 8}, + hoverlabel: { + bgcolor: css('--surface'), + bordercolor: css('--border'), + font: {color: css('--ink'), size: 12} + }, + xaxis: axis(spec.xaxis), + yaxis: axis(spec.yaxis), + showlegend: false + }; + } + + function lines(spec) { + const traces = spec.traces.map(t => ({ + type: 'scatter', + mode: 'lines+markers', + name: t.name, + x: t.x, + y: t.y, + line: {color: css('--series-' + t.hue), width: 2, dash: t.dash}, + marker: {size: 8, color: css('--series-' + t.hue), line: {width: 2, color: css('--surface')}}, + hovertemplate: `%{x:,} browsers
%{y:,.1f} ${t.unit}${t.name}` + })); + const layout = baseLayout(spec); + // Label the x axis at the measured counts rather than log minor ticks. + const xs = [...new Set(spec.traces.flatMap(t => t.x))].sort((a, b) => a - b); + if (xs.length) { + layout.xaxis.tickvals = xs; + layout.xaxis.ticktext = xs.map(x => x.toLocaleString('en-US')); + layout.xaxis.dtick = undefined; + } + layout.showlegend = true; + layout.legend = { + orientation: 'h', x: 0, y: 1.02, xanchor: 'left', yanchor: 'bottom', + font: {size: 12, color: css('--ink-2')} + }; + layout.hovermode = 'closest'; + if (spec.threshold) { + layout.shapes = [{ + type: 'line', xref: 'paper', x0: 0, x1: 1, y0: spec.threshold, y1: spec.threshold, + line: {color: css('--muted'), width: 1} + }]; + layout.annotations = [{ + xref: 'paper', x: 0, y: Math.log10(spec.threshold), yref: 'y', xanchor: 'left', + yanchor: 'bottom', showarrow: false, text: `${spec.threshold} ms`, + font: {size: 11, color: css('--muted')} + }]; + } + return {traces, layout}; + } + + const wrap = label => label.replace(', ', ',
').replace(' (', '
('); + + function hbar(spec, el) { + const bars = spec.bars; + const traces = [{ + type: 'bar', + orientation: 'h', + // Wrap long labels on narrow screens so they don't clip. + y: bars.map(b => (el.clientWidth < 600 ? wrap(b.label) : b.label)), + x: bars.map(b => b.value), + text: bars.map(b => b.text), + textposition: 'outside', + cliponaxis: false, + textfont: {size: 12, color: css('--ink-2')}, + marker: {color: css('--series-1')}, + hovertext: bars.map(b => b.hover || `${b.label}: ${b.text}`), + hoverinfo: 'text' + }]; + const layout = baseLayout(spec); + layout.bargap = 0.35; + layout.yaxis = axis({type: 'category'}, {gridcolor: 'rgba(0,0,0,0)', tickfont: {size: 12, color: css('--ink-2')}}); + layout.margin.r = 64; + return {traces, layout}; + } + + function picker(spec, el, state) { + const names = Object.keys(spec.series); + if (!state.select) { + const select = document.createElement('select'); + select.className = 'picker'; + select.setAttribute('aria-label', 'Scenario'); + names.forEach(n => select.add(new Option(n, n))); + select.addEventListener('change', () => draw(el, spec, state)); + el.parentNode.insertBefore(select, el); + state.select = select; + } + const s = spec.series[state.select.value || names[0]] || {x: [], y: [], commit: []}; + const traces = [{ + type: 'scatter', + mode: 'lines+markers', + x: s.x, + y: s.y, + customdata: s.commit, + line: {color: css('--series-1'), width: 2}, + marker: {size: 8, color: css('--series-1'), line: {width: 2, color: css('--surface')}}, + hovertemplate: '%{x|%Y-%m-%d}
%{y:,.1f} ms
commit %{customdata}' + }]; + const layout = baseLayout(spec); + layout.xaxis.type = 'date'; + layout.xaxis.tickformat = '%b %d'; + if (s.x.length === 1) { + // One run so far: show a week around it, not a millisecond. + const t = new Date(s.x[0]).getTime(); + const day = 86400000; + layout.xaxis.range = [new Date(t - 3 * day), new Date(t + 3 * day)]; + } + return {traces, layout}; + } + + function draw(el, spec, state) { + const kind = {lines, hbar, picker}[spec.kind]; + const {traces, layout} = kind(spec, el, state); + Plotly.react(el, traces, layout, {displayModeBar: false, responsive: true}); + } + + window.renderChart = function (el, spec) { + const state = {}; + charts.push(() => draw(el, spec, state)); + draw(el, spec, state); + }; + + const redraw = () => charts.forEach(fn => fn()); + let resizeTimer; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(redraw, 150); + }); + if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', redraw); + } +})(); diff --git a/benchmarks/web/style.css b/benchmarks/web/style.css new file mode 100644 index 0000000000..fa9adaf3b4 --- /dev/null +++ b/benchmarks/web/style.css @@ -0,0 +1,97 @@ +:root { + color-scheme: light; + --page: #f9f9f7; + --surface: #fcfcfb; + --ink: #0b0b0b; + --ink-2: #52514e; + --muted: #898781; + --grid: #e1e0d9; + --axis: #c3c2b7; + --border: rgba(11, 11, 11, 0.1); + --link: #1c5cab; + --series-1: #2a78d6; + --series-2: #eb6834; + --series-3: #1baf7a; + --critical: #d03b3b; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --page: #0d0d0d; + --surface: #1a1a19; + --ink: #ffffff; + --ink-2: #c3c2b7; + --muted: #898781; + --grid: #2c2c2a; + --axis: #383835; + --border: rgba(255, 255, 255, 0.1); + --link: #86b6ef; + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; + } +} +:root[data-theme="dark"] { + color-scheme: dark; + --page: #0d0d0d; + --surface: #1a1a19; + --ink: #ffffff; + --ink-2: #c3c2b7; + --muted: #898781; + --grid: #2c2c2a; + --axis: #383835; + --border: rgba(255, 255, 255, 0.1); + --link: #86b6ef; + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; +} + +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--page); + color: var(--ink); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; +} +a { color: var(--link); } +body.full { max-width: 1040px; margin: 0 auto; padding: 32px 16px 64px; } +body.full header p { color: var(--ink-2); max-width: 70ch; } +h1 { font-size: 28px; margin: 0 0 8px; } +h2 { font-size: 21px; margin: 48px 0 8px; } +h3 { font-size: 16px; margin: 32px 0 8px; } +section > p { color: var(--ink-2); max-width: 70ch; } + +.chart { + margin: 24px 0 0; + padding: 20px 20px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} +.chart-title { font-size: 16px; margin: 0; } +.chart-sub { margin: 4px 0 8px; color: var(--ink-2); font-size: 13px; } +.chart-source { margin: 4px 0 0; color: var(--muted); font-size: 12px; } +.plot { width: 100%; height: 380px; } +.picker { font: inherit; font-size: 13px; color: var(--ink); background: var(--surface); + border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; max-width: 100%; } + +.embed-code { margin-top: 8px; font-size: 13px; color: var(--ink-2); } +.embed-code summary { cursor: pointer; } +.embed-code pre { white-space: pre-wrap; word-break: break-all; background: var(--page); + border: 1px solid var(--border); border-radius: 6px; padding: 8px; } + +.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; + background: var(--surface); } +table { border-collapse: collapse; width: 100%; font-size: 13px; } +th, td { padding: 6px 10px; text-align: right; white-space: nowrap; + border-bottom: 1px solid var(--grid); font-variant-numeric: tabular-nums; } +th { color: var(--ink-2); font-weight: 600; } +th:first-child, td:first-child, td:last-child { text-align: left; } +tr:last-child td { border-bottom: 0; } + +body.embed { padding: 0; background: var(--surface); } +body.embed .chart { margin: 0; border: 0; border-radius: 0; padding: 12px 16px 8px; + height: 100vh; display: flex; flex-direction: column; } +body.embed .chart-title { font-size: 15px; } +body.embed .plot { flex: 1; height: auto; min-height: 220px; } diff --git a/tests/unit/test_benchmarks_publish.py b/tests/unit/test_benchmarks_publish.py new file mode 100644 index 0000000000..16c4e38cf6 --- /dev/null +++ b/tests/unit/test_benchmarks_publish.py @@ -0,0 +1,158 @@ +import json + +from benchmarks import publish + + +def _point(browsers, p95, errors=0.0, client_bound=False): + return { + "browsers": browsers, + "latency_p50_ms": p95 / 2, + "latency_p95_ms": p95, + "latency_p99_ms": p95, + "first_frame_p50_ms": 3.0, + "first_frame_p95_ms": 5.0, + "frames_per_s": 10.0, + "streams_per_s": 1.0, + "streams": 10, + "frames": 200, + "errors": 0, + "incomplete": 0, + "error_rate": errors, + "server_cpu_cores": 0.5, + "server_rss_mb": 100, + "client_cpu_peak_pct": 99 if client_bound else 20, + "saturated": p95 > 1000 or errors > 0.01, + "client_bound": client_bound, + } + + +def _setup(name, points): + return { + "name": name, + "backend": "fastapi", + "server": "uvicorn", + "workers": 1, + "label": name, + "storage": "local", + "points": points, + } + + +def _run(setups): + return { + "kind": "streaming_load", + "meta": { + "date": "2026-10-06T00:00:00Z", + "commit": "abc", + "dash_version": "4.5.0", + "python": "3.12", + "cpu": "test cpu", + "cores": 8, + "memory_gb": 32, + "server_cpus": 4, + "client_cpus": 4, + "runner": "test", + }, + "params": { + "frames": 20, + "interval": 0.1, + "think": 1.0, + "ramp": 1, + "duration": 1, + "max_p95": 1000, + }, + "setups": setups, + } + + +def test_capacity_stops_at_first_slow_or_failing_point(): + setup = _setup("a", [_point(100, 10), _point(500, 400), _point(1000, 10)]) + assert publish.capacity(setup) == (100, False) + assert publish.capacity( + _setup("b", [_point(100, 10), _point(500, 10, errors=0.05)]) + ) == (100, False) + + +def test_capacity_marks_sweeps_that_never_saturated(): + assert publish.capacity(_setup("a", [_point(100, 10), _point(500, 20)])) == ( + 500, + True, + ) + + +def test_capacity_is_a_lower_bound_when_clients_ran_out_of_cpu(): + setup = _setup("a", [_point(100, 10), _point(500, 20, client_bound=True)]) + assert publish.capacity(setup) == (100, True) + + +def test_inline_chart_data_cannot_close_its_script(): + chart = { + "id": "x", + "title": "t", + "subtitle": "s", + "source": "src", + "kind": "hbar", + "bars": [{"label": " +No published run yet. + + +Charts, the full numbers and how they are measured: [benchmarks](https://github.com/plotly/dash/tree/dev/benchmarks). + ### Dash OSS & Dash Enterprise With Dash Open Source, Dash apps run on your local laptop or workstation, but cannot be easily accessed by others in your organization. diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore index 07fb7c9457..c25b4bd15b 100644 --- a/benchmarks/.gitignore +++ b/benchmarks/.gitignore @@ -1,8 +1,7 @@ -# Generated benchmark artifacts (baseline.json is committed on purpose) +# Generated benchmark artifacts (baseline.json and results/ are committed on purpose) results.json summary.md *.cpuprofile __pycache__/ streaming/results.json -site/ callbacks/results.json diff --git a/benchmarks/README.md b/benchmarks/README.md index 560d15ab61..dca9c64826 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -134,15 +134,15 @@ python -m benchmarks.swarm.aggregate reports/*.json --out swarm.json ## Publishing (`publish.py`) -`.github/workflows/benchmarks-publish.yml` measures on `dev` and pushes a static -site to the `gh-pages` branch: renderer timings on every push, the streaming -and callback sweeps weekly and on demand. Each chart has its own page under `embed/` to drop -into an `' - ) - snippet = ( - f'
Embed this chart' - f"
{html.escape(code)}
" +def _marker(hue): + return {"size": 8, "color": SERIES[hue], "line": {"width": 2, "color": "#fff"}} + + +def _lines(spec): + data = [ + { + "type": "scatter", + "mode": "lines+markers", + "name": t["name"], + "x": t["x"], + "y": t["y"], + "line": {"color": SERIES[t["hue"]], "width": 2, "dash": t["dash"]}, + "marker": _marker(t["hue"]), + "hovertemplate": f"%{{x:,}} browsers
%{{y:,.1f}} {t['unit']}" + f"{t['name']}", + } + for t in spec["traces"] + ] + layout = _layout(spec) + # Label the x axis at the measured counts rather than log minor ticks. + xs = sorted({x for t in spec["traces"] for x in t["x"]}) + if xs: + layout["xaxis"].pop("dtick", None) + layout["xaxis"]["tickvals"] = xs + layout["xaxis"]["ticktext"] = [f"{x:,}" for x in xs] + layout.update(showlegend=True, legend=LEGEND, hovermode="closest") + threshold = spec.get("threshold") + if threshold: + log = spec.get("yaxis", {}).get("type") == "log" + layout["shapes"] = [ + { + "type": "line", + "xref": "paper", + "x0": 0, + "x1": 1, + "y0": threshold, + "y1": threshold, + "line": {"color": MUTED, "width": 1}, + } + ] + layout["annotations"] = [ + { + "xref": "paper", + "x": 0, + "yref": "y", + # Annotations on a log axis are placed in log units. + "y": math.log10(threshold) if log else threshold, + "xanchor": "left", + "yanchor": "bottom", + "showarrow": False, + "text": f"{threshold} ms", + "font": {"size": 11, "color": MUTED}, + } + ] + return {"data": data, "layout": layout} + + +def _hbar(spec): + bars = spec["bars"] + names = {item["hue"]: item["label"] for item in spec.get("legend", [])} + data = [] + # One trace per colour so a legend can name them; overlaid, each bar keeps + # its full width on its own row. + for hue in sorted({b.get("hue", 1) for b in bars}): + group = [b for b in bars if b.get("hue", 1) == hue] + data.append( + { + "type": "bar", + "orientation": "h", + "name": names.get(hue, ""), + "y": [b["label"] for b in group], + "x": [b["value"] for b in group], + "text": [b["text"] for b in group], + "textposition": "outside", + "cliponaxis": False, + "textfont": {"size": 12, "color": INK_2}, + "marker": {"color": SERIES[hue]}, + "hovertext": [ + b.get("hover", f"{b['label']}: {b['text']}") for b in group + ], + "hoverinfo": "text", + } ) - title = "h1" if embed else "h3" - home = ( - ' · Dash benchmarks' - if embed - else "" + layout = _layout(spec) + layout["yaxis"] = _axis( + {"type": "category"}, + gridcolor="rgba(0,0,0,0)", + tickfont={"size": 12, "color": INK_2}, + categoryorder="array", + categoryarray=[b["label"] for b in bars], ) - return f"""
-<{title} class="chart-title">{html.escape(chart['title'])} -

{html.escape(chart['subtitle'])}

-
-

{html.escape(chart['source'])}{home}

- -{snippet} -
""" - - -def build(site, base_url, streaming, renderer, renderer_history, callbacks=None): - charts = [] - if streaming: - charts += streaming_charts(streaming) - if callbacks: - charts += callbacks_charts(callbacks) - if renderer: - charts += renderer_charts(renderer, renderer_history) - - for name in ("style.css", "charts.js"): - shutil.copy(os.path.join(STATIC, name), os.path.join(site, name)) - os.makedirs(os.path.join(site, "embed"), exist_ok=True) - for chart in charts: - page = _page( - chart["title"], - _figure(chart, embed=True), - depth=1, - embed=True, + layout.update(bargap=0.35, barmode="overlay") + layout["margin"]["r"] = 64 + if spec.get("legend"): + layout.update(showlegend=True, legend=LEGEND) + return {"data": data, "layout": layout} + + +def _picker(spec): + """One trace per series and a dropdown that shows one at a time.""" + names = list(spec["series"]) + data = [] + for i, name in enumerate(names): + s = spec["series"][name] + data.append( + { + "type": "scatter", + "mode": "lines+markers", + "name": name, + "x": s["x"], + "y": s["y"], + "customdata": s["commit"], + "visible": i == 0, + "line": {"color": SERIES[1], "width": 2}, + "marker": _marker(1), + "hovertemplate": "%{x|%Y-%m-%d}
%{y:,.1f} ms
commit %{customdata}" + "", + } ) - with open( - os.path.join(site, "embed", f"{chart['id']}.html"), "w", encoding="utf-8" - ) as f: - f.write(page) - def embed_url(chart): - return f"{base_url.rstrip('/')}/embed/{chart['id']}.html" + def x_range(series): + if len(series["x"]) != 1: + return {"xaxis.autorange": True} + # One run so far: show a week around it, not a millisecond. + day = datetime.fromisoformat(series["x"][0].replace("Z", "+00:00")) + return { + "xaxis.range": [ + (day - timedelta(days=3)).isoformat(), + (day + timedelta(days=3)).isoformat(), + ] + } + + layout = _layout(spec) + layout["xaxis"].update(type="date", tickformat="%b %d") + if names: + first = spec["series"][names[0]] + if len(first["x"]) == 1: + layout["xaxis"]["range"] = x_range(first)["xaxis.range"] + layout["margin"]["t"] = 48 + layout["updatemenus"] = [ + { + "x": 0, + "y": 1.02, + "xanchor": "left", + "yanchor": "bottom", + "buttons": [ + { + "label": name, + "method": "update", + "args": [ + {"visible": [n == name for n in names]}, + x_range(spec["series"][name]), + ], + } + for name in names + ], + } + ] + return {"data": data, "layout": layout} + + +def figure(spec): + """The chart spec as a Plotly figure (``data`` and ``layout``).""" + return {"lines": _lines, "hbar": _hbar, "picker": _picker}[spec["kind"]](spec) + + +def _section(sid, title, intro, charts, table): + header, rows = table + return { + "id": sid, + "title": title, + "intro": intro, + "charts": [ + { + "id": c["id"], + "title": c["title"], + "subtitle": c["subtitle"], + "source": c["source"], + "figure": figure(c), + } + for c in charts + ], + "table": {"header": header, "rows": rows}, + } - def section(title, intro, prefix, table): - figures = "".join( - _figure(c, embed_url(c)) for c in charts if c["id"].startswith(prefix) - ) - return ( - f"

{title}

{intro}

{figures}" - f"

All measurements

{_table(*table)}
" - ) +def report(streaming, renderer, renderer_history, callbacks=None): + """Every chart as a Plotly figure and the numbers as tables, in sections: + what the docs page renders.""" sections = [] if streaming: sections.append( - section( + _section( + "streaming", "Streaming callbacks under load", "Simulated browsers stream from one app, one setup at a time, with the" " count raised until frames arrive late or fail. Each browser behaves" " like the renderer: one shared downlink, a long NDJSON response on ASGI" " and short polls on WSGI. Server and clients run on separate cores of" " one machine.", - "streaming", + streaming_charts(streaming), streaming_table(streaming), ) ) if callbacks: sections.append( - section( + _section( + "callbacks", "Callbacks under load: HTTP vs websocket", "The same callback served two ways: a POST per call, or messages on" " one websocket per browser (FastAPI and Quart; Flask has no" " websocket). Simulated browsers speak each protocol as the renderer" " does, and the count is raised until responses come back late or" " fail. Server and clients run on separate cores of one machine.", - "callbacks", + callbacks_charts(callbacks), callbacks_table(callbacks), ) ) if renderer: sections.append( - section( + _section( + "renderer", "Renderer", "Each scenario runs as a real app in headless Chrome. Lower is better." " Growth compares late to early operations: near 1x is flat, higher" " means the cost grows with the page.", - "renderer", + renderer_charts(renderer, renderer_history), renderer_table(renderer), ) ) - body = ( - "

Dash benchmarks

" - '

Performance numbers for Dash,' - ' measured by the harness in ' - "benchmarks/ and published from CI. Every chart can be embedded. Raw numbers:" - ' streaming' - ' (history),' - ' callbacks' - ' (history),' - ' renderer' - ' (history).

' - + "".join(sections) + return {"sections": sections} + + +# --- README ------------------------------------------------------------------------ + +README_START = "" +README_END = "" +NOT_MEASURED = "-" + + +def _server(setup): + return (setup["backend"], setup["server"], setup["workers"]) + + +def readme_table(streaming, callbacks): + """Browsers each server setup keeps interactive, as a Markdown table.""" + labels, cells = {}, {} + for s in (callbacks or {}).get("setups", []): + key = _server(s) + labels.setdefault(key, s["label"].rsplit(", ", 1)[0]) + cap, capped = capacity(s, "rtt_p95_ms") + cells[key, s["transport"]] = f"{cap:,}{'+' if capped else ''}" + for s in (streaming or {}).get("setups", []): + key = _server(s) + labels.setdefault(key, s["label"]) + cap, capped = capacity(s) + cells[key, "stream"] = f"{cap:,}{'+' if capped else ''}" + if not labels: + return "No published run yet." + backends = list(BACKEND_HUE) + order = sorted( + labels, + key=lambda k: ( + backends.index(k[0]) if k[0] in backends else len(backends), + k[0], + k[2], + ), ) - with open(os.path.join(site, "index.html"), "w", encoding="utf-8") as f: - f.write(_page("Dash benchmarks", body)) - # Keep GitHub Pages from running the site through Jekyll. - open(os.path.join(site, ".nojekyll"), "w", encoding="utf-8").close() + lines = [ + "| Server | Callbacks over HTTP | Callbacks over websocket | Streaming callbacks |", + "|---|--:|--:|--:|", + ] + for key in order: + row = [cells.get((key, col), NOT_MEASURED) for col in ("http", "ws", "stream")] + lines.append(f"| {labels[key]} | " + " | ".join(row) + " |") + runs = [r for r in (callbacks, streaming) if r] + where = sorted( + { + f"{r['meta']['date'][:10]}: server on {r['meta']['server_cpus']} cores" + f" ({r['meta']['runner']}), Dash {r['meta']['dash_version']}" + for r in runs + } + ) + lines += [ + "", + f"Concurrent browsers with p95 response under {CAPACITY_P95_MS} ms and under" + " 1% errors; + means the sweep stopped before the server fell behind." + " Flask has no websocket. Measured " + "; ".join(where) + ".", + ] + return "\n".join(lines) + + +def update_readme(path, streaming, callbacks): + with open(path, encoding="utf-8") as f: + text = f.read() + head, start, rest = text.partition(README_START) + _, end, tail = rest.partition(README_END) + if not start or not end: + raise SystemExit(f"{path} has no {README_START} ... {README_END} section") + table = readme_table(streaming, callbacks) + with open(path, "w", encoding="utf-8") as f: + f.write(f"{head}{start}\n{table}\n{end}{tail}") + + +def _publish_load(run, out, name, metric): + """Commit a load run: its numbers, and a capacity summary in its history.""" + _dump(os.path.join(out, f"{name}-latest.json"), run) + history = _load(os.path.join(out, f"{name}-history.json"), []) + summary = { + "date": run["meta"]["date"], + "commit": run["meta"]["commit"], + "runner": run["meta"]["runner"], + "capacity": {s["name"]: capacity(s, metric)[0] for s in run["setups"]}, + } + _dump(os.path.join(out, f"{name}-history.json"), _append(history, summary, "date")) def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) - parser.add_argument("--site", required=True) + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", maxsplit=1)[0]) + parser.add_argument("--dir", default=os.path.join(HERE, "results")) + parser.add_argument("--readme", default=os.path.join(REPO_ROOT, "README.md")) parser.add_argument("--renderer", help="results.json from benchmarks.run") parser.add_argument( "--streaming", help="results.json from benchmarks.streaming.load" @@ -638,75 +834,51 @@ def main(argv=None): parser.add_argument( "--runner", default=os.environ.get("BENCH_RUNNER", "local machine") ) - parser.add_argument( - "--base-url", - default="https://plotly.github.io/dash", - help="where the site is served, for the embed snippets", - ) args = parser.parse_args(argv) - - site = args.site - data = os.path.join(site, "data") - os.makedirs(data, exist_ok=True) - - streaming = _load(args.streaming, None) - if streaming: - _dump(os.path.join(data, "streaming-latest.json"), streaming) - history = _load(os.path.join(data, "streaming-history.json"), []) - summary = { - "date": streaming["meta"]["date"], - "commit": streaming["meta"]["commit"], - "runner": streaming["meta"]["runner"], - "capacity": {s["name"]: capacity(s)[0] for s in streaming["setups"]}, - } - _dump( - os.path.join(data, "streaming-history.json"), - _append(history, summary, "date"), - ) - for name, badge in streaming_badges(streaming).items(): - _dump(os.path.join(site, "badges", f"{name}.json"), badge) - else: - streaming = _load(os.path.join(data, "streaming-latest.json"), None) - - callbacks = _load(args.callbacks, None) - if callbacks: - _dump(os.path.join(data, "callbacks-latest.json"), callbacks) - history = _load(os.path.join(data, "callbacks-history.json"), []) - summary = { - "date": callbacks["meta"]["date"], - "commit": callbacks["meta"]["commit"], - "runner": callbacks["meta"]["runner"], - "capacity": { - s["name"]: capacity(s, "rtt_p95_ms")[0] for s in callbacks["setups"] - }, - } - _dump( - os.path.join(data, "callbacks-history.json"), - _append(history, summary, "date"), - ) - for name, badge in callbacks_badges(callbacks).items(): - _dump(os.path.join(site, "badges", f"{name}.json"), badge) - else: - callbacks = _load(os.path.join(data, "callbacks-latest.json"), None) - - renderer_history = _load(os.path.join(data, "renderer-history.json"), []) - results = _load(args.renderer, None) + out = args.dir + os.makedirs(out, exist_ok=True) + + for name, path, metric in ( + ("streaming", args.streaming, "latency_p95_ms"), + ("callbacks", args.callbacks, "rtt_p95_ms"), + ): + run = _read(path) + if run: + _publish_load(run, out, name, metric) + + results = _read(args.renderer) if results: - import time # pylint: disable=import-outside-toplevel - entry = { "date": args.date or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "commit": args.commit, "runner": args.runner, "results": results, } - renderer_history = _append(renderer_history, entry, "date") - _dump(os.path.join(data, "renderer-latest.json"), entry) - _dump(os.path.join(data, "renderer-history.json"), renderer_history) - renderer = _load(os.path.join(data, "renderer-latest.json"), None) + # The trend only charts medians; full stats stay in renderer-latest. + trend = { + **entry, + "results": { + scenario: {m: {"median": st["median"]} for m, st in metrics.items()} + for scenario, metrics in results.items() + }, + } + history = _load(os.path.join(out, "renderer-history.json"), []) + _dump(os.path.join(out, "renderer-latest.json"), entry) + _dump( + os.path.join(out, "renderer-history.json"), + _append(history, trend, "date"), + ) - build(site, args.base_url, streaming, renderer, renderer_history, callbacks) - print(f"site written to {site}") + streaming = _load(os.path.join(out, "streaming-latest.json"), None) + callbacks = _load(os.path.join(out, "callbacks-latest.json"), None) + renderer = _load(os.path.join(out, "renderer-latest.json"), None) + renderer_history = _load(os.path.join(out, "renderer-history.json"), []) + _dump( + os.path.join(out, "report.json"), + report(streaming, renderer, renderer_history, callbacks), + ) + update_readme(args.readme, streaming, callbacks) + print(f"results written to {out}, README table to {args.readme}") return 0 diff --git a/benchmarks/web/charts.js b/benchmarks/web/charts.js deleted file mode 100644 index 321b5b29e1..0000000000 --- a/benchmarks/web/charts.js +++ /dev/null @@ -1,192 +0,0 @@ -/* Renders the chart specs benchmarks/publish.py writes into each page. */ -(function () { - const params = new URLSearchParams(location.search); - const forced = params.get('theme'); - if (forced === 'light' || forced === 'dark') { - document.documentElement.setAttribute('data-theme', forced); - } - - const css = name => - getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - - const charts = []; - - function axis(spec, extra) { - const log = spec && spec.type === 'log'; - return Object.assign( - { - title: {text: (spec && spec.title) || '', font: {size: 12, color: css('--ink-2')}}, - type: (spec && spec.type) || 'linear', - rangemode: (spec && spec.rangemode) || 'normal', - gridcolor: css('--grid'), - linecolor: css('--axis'), - zeroline: false, - tickfont: {size: 11, color: css('--muted')}, - automargin: true, - // Log axes: label the decades only (1, 10, 100, ...). - dtick: log ? 1 : undefined - }, - extra || {} - ); - } - - function baseLayout(spec) { - return { - paper_bgcolor: 'rgba(0,0,0,0)', - plot_bgcolor: 'rgba(0,0,0,0)', - font: {family: 'system-ui, -apple-system, "Segoe UI", sans-serif', color: css('--ink')}, - margin: {l: 8, r: 16, t: 8, b: 8}, - hoverlabel: { - bgcolor: css('--surface'), - bordercolor: css('--border'), - font: {color: css('--ink'), size: 12} - }, - xaxis: axis(spec.xaxis), - yaxis: axis(spec.yaxis), - showlegend: false - }; - } - - function lines(spec) { - const traces = spec.traces.map(t => ({ - type: 'scatter', - mode: 'lines+markers', - name: t.name, - x: t.x, - y: t.y, - line: {color: css('--series-' + t.hue), width: 2, dash: t.dash}, - marker: {size: 8, color: css('--series-' + t.hue), line: {width: 2, color: css('--surface')}}, - hovertemplate: `%{x:,} browsers
%{y:,.1f} ${t.unit}${t.name}` - })); - const layout = baseLayout(spec); - // Label the x axis at the measured counts rather than log minor ticks. - const xs = [...new Set(spec.traces.flatMap(t => t.x))].sort((a, b) => a - b); - if (xs.length) { - layout.xaxis.tickvals = xs; - layout.xaxis.ticktext = xs.map(x => x.toLocaleString('en-US')); - layout.xaxis.dtick = undefined; - } - layout.showlegend = true; - layout.legend = { - orientation: 'h', x: 0, y: 1.02, xanchor: 'left', yanchor: 'bottom', - font: {size: 12, color: css('--ink-2')} - }; - layout.hovermode = 'closest'; - if (spec.threshold) { - layout.shapes = [{ - type: 'line', xref: 'paper', x0: 0, x1: 1, y0: spec.threshold, y1: spec.threshold, - line: {color: css('--muted'), width: 1} - }]; - layout.annotations = [{ - xref: 'paper', x: 0, y: Math.log10(spec.threshold), yref: 'y', xanchor: 'left', - yanchor: 'bottom', showarrow: false, text: `${spec.threshold} ms`, - font: {size: 11, color: css('--muted')} - }]; - } - return {traces, layout}; - } - - const wrap = label => label.replace(', ', ',
').replace(' (', '
('); - - function hbar(spec, el) { - const bars = spec.bars; - // Wrap long labels on narrow screens so they don't clip. - const label = b => (el.clientWidth < 600 ? wrap(b.label) : b.label); - const names = {}; - (spec.legend || []).forEach(l => { names[l.hue] = l.label; }); - // One trace per colour, so a legend can name them; overlaid, each bar - // keeps its full width on its own row. - const hues = [...new Set(bars.map(b => b.hue || 1))].sort((a, b) => a - b); - const traces = hues.map(hue => { - const group = bars.filter(b => (b.hue || 1) === hue); - return { - type: 'bar', - orientation: 'h', - name: names[hue] || '', - y: group.map(label), - x: group.map(b => b.value), - text: group.map(b => b.text), - textposition: 'outside', - cliponaxis: false, - textfont: {size: 12, color: css('--ink-2')}, - marker: {color: css('--series-' + hue)}, - hovertext: group.map(b => b.hover || `${b.label}: ${b.text}`), - hoverinfo: 'text' - }; - }); - const layout = baseLayout(spec); - layout.bargap = 0.35; - layout.barmode = 'overlay'; - layout.yaxis = axis({type: 'category'}, { - gridcolor: 'rgba(0,0,0,0)', - tickfont: {size: 12, color: css('--ink-2')}, - categoryorder: 'array', - categoryarray: bars.map(label) - }); - layout.margin.r = 64; - if (spec.legend) { - layout.showlegend = true; - layout.legend = { - orientation: 'h', x: 0, y: 1.02, xanchor: 'left', yanchor: 'bottom', - font: {size: 12, color: css('--ink-2')} - }; - } - return {traces, layout}; - } - - function picker(spec, el, state) { - const names = Object.keys(spec.series); - if (!state.select) { - const select = document.createElement('select'); - select.className = 'picker'; - select.setAttribute('aria-label', 'Scenario'); - names.forEach(n => select.add(new Option(n, n))); - select.addEventListener('change', () => draw(el, spec, state)); - el.parentNode.insertBefore(select, el); - state.select = select; - } - const s = spec.series[state.select.value || names[0]] || {x: [], y: [], commit: []}; - const traces = [{ - type: 'scatter', - mode: 'lines+markers', - x: s.x, - y: s.y, - customdata: s.commit, - line: {color: css('--series-1'), width: 2}, - marker: {size: 8, color: css('--series-1'), line: {width: 2, color: css('--surface')}}, - hovertemplate: '%{x|%Y-%m-%d}
%{y:,.1f} ms
commit %{customdata}' - }]; - const layout = baseLayout(spec); - layout.xaxis.type = 'date'; - layout.xaxis.tickformat = '%b %d'; - if (s.x.length === 1) { - // One run so far: show a week around it, not a millisecond. - const t = new Date(s.x[0]).getTime(); - const day = 86400000; - layout.xaxis.range = [new Date(t - 3 * day), new Date(t + 3 * day)]; - } - return {traces, layout}; - } - - function draw(el, spec, state) { - const kind = {lines, hbar, picker}[spec.kind]; - const {traces, layout} = kind(spec, el, state); - Plotly.react(el, traces, layout, {displayModeBar: false, responsive: true}); - } - - window.renderChart = function (el, spec) { - const state = {}; - charts.push(() => draw(el, spec, state)); - draw(el, spec, state); - }; - - const redraw = () => charts.forEach(fn => fn()); - let resizeTimer; - window.addEventListener('resize', () => { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(redraw, 150); - }); - if (window.matchMedia) { - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', redraw); - } -})(); diff --git a/benchmarks/web/style.css b/benchmarks/web/style.css deleted file mode 100644 index fa9adaf3b4..0000000000 --- a/benchmarks/web/style.css +++ /dev/null @@ -1,97 +0,0 @@ -:root { - color-scheme: light; - --page: #f9f9f7; - --surface: #fcfcfb; - --ink: #0b0b0b; - --ink-2: #52514e; - --muted: #898781; - --grid: #e1e0d9; - --axis: #c3c2b7; - --border: rgba(11, 11, 11, 0.1); - --link: #1c5cab; - --series-1: #2a78d6; - --series-2: #eb6834; - --series-3: #1baf7a; - --critical: #d03b3b; -} -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - color-scheme: dark; - --page: #0d0d0d; - --surface: #1a1a19; - --ink: #ffffff; - --ink-2: #c3c2b7; - --muted: #898781; - --grid: #2c2c2a; - --axis: #383835; - --border: rgba(255, 255, 255, 0.1); - --link: #86b6ef; - --series-1: #3987e5; - --series-2: #d95926; - --series-3: #199e70; - } -} -:root[data-theme="dark"] { - color-scheme: dark; - --page: #0d0d0d; - --surface: #1a1a19; - --ink: #ffffff; - --ink-2: #c3c2b7; - --muted: #898781; - --grid: #2c2c2a; - --axis: #383835; - --border: rgba(255, 255, 255, 0.1); - --link: #86b6ef; - --series-1: #3987e5; - --series-2: #d95926; - --series-3: #199e70; -} - -* { box-sizing: border-box; } -body { - margin: 0; - background: var(--page); - color: var(--ink); - font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; -} -a { color: var(--link); } -body.full { max-width: 1040px; margin: 0 auto; padding: 32px 16px 64px; } -body.full header p { color: var(--ink-2); max-width: 70ch; } -h1 { font-size: 28px; margin: 0 0 8px; } -h2 { font-size: 21px; margin: 48px 0 8px; } -h3 { font-size: 16px; margin: 32px 0 8px; } -section > p { color: var(--ink-2); max-width: 70ch; } - -.chart { - margin: 24px 0 0; - padding: 20px 20px 12px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; -} -.chart-title { font-size: 16px; margin: 0; } -.chart-sub { margin: 4px 0 8px; color: var(--ink-2); font-size: 13px; } -.chart-source { margin: 4px 0 0; color: var(--muted); font-size: 12px; } -.plot { width: 100%; height: 380px; } -.picker { font: inherit; font-size: 13px; color: var(--ink); background: var(--surface); - border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; max-width: 100%; } - -.embed-code { margin-top: 8px; font-size: 13px; color: var(--ink-2); } -.embed-code summary { cursor: pointer; } -.embed-code pre { white-space: pre-wrap; word-break: break-all; background: var(--page); - border: 1px solid var(--border); border-radius: 6px; padding: 8px; } - -.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; - background: var(--surface); } -table { border-collapse: collapse; width: 100%; font-size: 13px; } -th, td { padding: 6px 10px; text-align: right; white-space: nowrap; - border-bottom: 1px solid var(--grid); font-variant-numeric: tabular-nums; } -th { color: var(--ink-2); font-weight: 600; } -th:first-child, td:first-child, td:last-child { text-align: left; } -tr:last-child td { border-bottom: 0; } - -body.embed { padding: 0; background: var(--surface); } -body.embed .chart { margin: 0; border: 0; border-radius: 0; padding: 12px 16px 8px; - height: 100vh; display: flex; flex-direction: column; } -body.embed .chart-title { font-size: 15px; } -body.embed .plot { flex: 1; height: auto; min-height: 220px; } diff --git a/tests/unit/test_benchmarks_publish.py b/tests/unit/test_benchmarks_publish.py index 3477153767..5e66208d4d 100644 --- a/tests/unit/test_benchmarks_publish.py +++ b/tests/unit/test_benchmarks_publish.py @@ -1,4 +1,7 @@ import json +import math + +import pytest from benchmarks import publish @@ -85,26 +88,27 @@ def test_capacity_is_a_lower_bound_when_clients_ran_out_of_cpu(): assert publish.capacity(setup) == (100, True) -def test_inline_chart_data_cannot_close_its_script(): - chart = { - "id": "x", - "title": "t", - "subtitle": "s", - "source": "src", - "kind": "hbar", - "bars": [{"label": " +No published run yet. + + +### Next +""" + + +def _readme(tmp_path): + path = tmp_path / "README.md" + path.write_text(README) + return path + + +def _renderer_results(tmp_path): + path = tmp_path / "renderer.json" + path.write_text( json.dumps( { "callback_fanout": { @@ -120,42 +124,130 @@ def test_site_has_embeds_data_badges_and_history(tmp_path): } ) ) - site = tmp_path / "site" + return path + + +def test_publish_commits_numbers_history_report_and_readme(tmp_path): + streaming = tmp_path / "streaming.json" + streaming.write_text( + json.dumps(_run([_setup("fastapi-uvicorn-w1", [_point(100, 10)])])) + ) + renderer = _renderer_results(tmp_path) + readme = _readme(tmp_path) + out = tmp_path / "results" for date in ("2026-10-01T00:00:00Z", "2026-10-02T00:00:00Z"): publish.main( [ - "--site", - str(site), + "--dir", + str(out), + "--readme", + str(readme), "--renderer", str(renderer), "--streaming", str(streaming), "--date", date, - "--base-url", - "https://example.test/dash", ] ) - for chart in ( + history = json.loads((out / "renderer-history.json").read_text()) + assert [h["date"][:10] for h in history] == ["2026-10-01", "2026-10-02"] + assert history[0]["results"] == {"callback_fanout": {"fanout_ms": {"median": 80.0}}} + latest = json.loads((out / "renderer-latest.json").read_text()) + assert latest["results"]["callback_fanout"]["fanout_ms"]["p90"] == 90.0 + assert json.loads((out / "streaming-latest.json").read_text())["setups"] + sections = json.loads((out / "report.json").read_text())["sections"] + assert [s["id"] for s in sections] == ["streaming", "renderer"] + charts = [c["id"] for s in sections for c in s["charts"]] + assert charts == [ "streaming-capacity", "streaming-latency", + "streaming-first-frame", + "streaming-cpu", "renderer-latest", "renderer-trend", - ): - page = (site / "embed" / f"{chart}.html").read_text() - assert "renderChart(" in page - assert "../charts.js" in page - index = (site / "index.html").read_text() - assert "https://example.test/dash/embed/streaming-capacity.html" in index - badge = json.loads( - (site / "badges" / "streaming-fastapi-uvicorn-w1.json").read_text() + ] + for s in sections: + assert s["table"]["rows"] + for c in s["charts"]: + assert c["figure"]["data"] and c["figure"]["layout"] + text = readme.read_text() + assert "| fastapi-uvicorn-w1 | - | - | 100+ |" in text + assert "No published run yet." not in text + assert text.endswith("\n\n### Next\n") + + +def test_lines_figure_marks_the_threshold_in_log_units(): + chart = publish.streaming_charts( + _run([_setup("a", [_point(100, 10), _point(500, 20)])]) + )[1] + fig = publish.figure(chart) + assert [t["x"] for t in fig["data"]] == [[100, 500]] + assert fig["layout"]["xaxis"]["ticktext"] == ["100", "500"] + assert fig["layout"]["shapes"][0]["y0"] == publish.CAPACITY_P95_MS + assert fig["layout"]["annotations"][0]["y"] == pytest.approx( + math.log10(publish.CAPACITY_P95_MS) ) - assert badge["message"] == "100+ browsers" - history = json.loads((site / "data" / "renderer-history.json").read_text()) - assert [h["date"][:10] for h in history] == ["2026-10-01", "2026-10-02"] - assert (site / ".nojekyll").exists() + + +def test_trend_figure_shows_one_scenario_at_a_time(): + entry = { + "date": "2026-10-01T00:00:00Z", + "commit": "abc", + "runner": "test", + "results": { + "a": {"m": {"median": 10.0, "p90": 12.0, "max": 13.0}}, + "b": {"m": {"median": 20.0, "p90": 22.0, "max": 23.0}}, + }, + } + trend = publish.renderer_charts(entry, [entry])[1] + fig = publish.figure(trend) + # Slowest scenario first. + assert [(t["name"], t["visible"]) for t in fig["data"]] == [ + ("b (m)", True), + ("a (m)", False), + ] + buttons = fig["layout"]["updatemenus"][0]["buttons"] + assert [b["args"][0]["visible"] for b in buttons] == [[True, False], [False, True]] + # A single run gets a week of axis, not a millisecond. + assert "xaxis.range" in buttons[0]["args"][1] + + +def test_named_input_must_exist(tmp_path): + with pytest.raises(FileNotFoundError): + publish.main( + [ + "--dir", + str(tmp_path / "results"), + "--readme", + str(_readme(tmp_path)), + "--streaming", + str(tmp_path / "typo.json"), + ] + ) + + +def test_readme_names_each_run_and_sorts_unknown_backends_last(): + callbacks = _cb_run() + streaming = _run( + [{**_setup("starlette-uvicorn-w1", [_point(100, 10)]), "backend": "starlette"}] + ) + streaming["meta"] = {**streaming["meta"], "date": "2026-10-07T00:00:00Z"} + lines = publish.readme_table(streaming, callbacks).splitlines() + assert lines[-3].startswith("| starlette-uvicorn-w1 |") + assert lines[-1].endswith( + "Measured 2026-10-06: server on 4 cores (test), Dash 4.5.0;" + " 2026-10-07: server on 4 cores (test), Dash 4.5.0." + ) + + +def test_readme_needs_its_markers(tmp_path): + readme = tmp_path / "README.md" + readme.write_text("# Dash\n") + with pytest.raises(SystemExit): + publish.update_readme(str(readme), None, None) def _cb_point(browsers, p95, calls_per_s=100.0, cores=0.2, client_bound=False): @@ -239,16 +331,43 @@ def test_callback_charts_split_by_transport_and_workers(): assert cpu == {"fastapi-w1-ws": 0.3, "fastapi-w1-http": 1.0} -def test_site_keeps_published_callbacks_when_not_given(tmp_path): +def test_readme_table_has_one_row_per_server(): + streaming = _run( + [ + { + **_setup("fastapi-uvicorn-w1", [_point(100, 10), _point(500, 400)]), + "label": "FastAPI, uvicorn 1 worker", + } + ] + ) + callbacks = _cb_run() + for s in callbacks["setups"]: + s["label"] = { + "fastapi-w1-http": "FastAPI, uvicorn 1 worker, HTTP", + "fastapi-w1-ws": "FastAPI, uvicorn 1 worker, websocket", + "quart-w4-ws": "Quart, uvicorn 4 workers, websocket", + }[s["name"]] + lines = publish.readme_table(streaming, callbacks).splitlines() + assert lines[2:4] == [ + "| FastAPI, uvicorn 1 worker | 100 | 1,000+ | 100 |", + "| Quart, uvicorn 4 workers | - | 100+ | - |", + ] + assert lines[-1].endswith( + "Measured 2026-10-06: server on 4 cores (test), Dash 4.5.0." + ) + + +def test_publish_keeps_committed_callbacks_when_not_given(tmp_path): callbacks = tmp_path / "callbacks.json" callbacks.write_text(json.dumps(_cb_run())) - site = tmp_path / "site" - publish.main(["--site", str(site), "--callbacks", str(callbacks)]) - publish.main(["--site", str(site)]) - index = (site / "index.html").read_text() - assert "HTTP vs websocket" in index - assert (site / "embed" / "callbacks-capacity.html").exists() - badge = json.loads((site / "badges" / "callbacks-fastapi-w1-ws.json").read_text()) - assert badge["message"] == "1,000+ browsers" - history = json.loads((site / "data" / "callbacks-history.json").read_text()) + out = tmp_path / "results" + readme = _readme(tmp_path) + args = ["--dir", str(out), "--readme", str(readme)] + publish.main([*args, "--callbacks", str(callbacks)]) + publish.main(args) + sections = json.loads((out / "report.json").read_text())["sections"] + assert [s["title"] for s in sections] == ["Callbacks under load: HTTP vs websocket"] + assert "| fastapi-w1-http | 100 | 1,000+ | - |" in readme.read_text() + history = json.loads((out / "callbacks-history.json").read_text()) + assert len(history) == 1 assert history[0]["capacity"]["fastapi-w1-http"] == 100 From 7768d5b25d0b7932a853ae87cbc360f90e0fd58f Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 13 Oct 2026 11:24:35 -0400 Subject: [PATCH 09/11] Open a benchmark results PR only when the numbers move The weekly run commits a load test only when a setup's capacity changes step, and the renderer only when a median moves by over 25% and 5 ms. The pull request lists what moved. --- .ai/PERFORMANCE.md | 5 +- .github/workflows/benchmarks-publish.yml | 26 +++-- benchmarks/README.md | 6 +- benchmarks/publish.py | 130 +++++++++++++++++++---- tests/unit/test_benchmarks_publish.py | 60 +++++++++++ 5 files changed, 194 insertions(+), 33 deletions(-) diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md index 35144b6427..b16539c2d7 100644 --- a/.ai/PERFORMANCE.md +++ b/.ai/PERFORMANCE.md @@ -133,8 +133,9 @@ deployed app, on as many machines as needed. Pitfalls it guards against: confirm a click went over the websocket, check that it made no HTTP POST. `benchmarks/publish.py` commits results to `benchmarks/results/` (read by the -docs) and the table in `README.md`, through a weekly pull request from -`.github/workflows/benchmarks-publish.yml`. +docs) and the table in `README.md`. `.github/workflows/benchmarks-publish.yml` +runs weekly and opens a pull request only when the numbers moved +significantly (`--only-significant`). ## Profiling a slow scenario diff --git a/.github/workflows/benchmarks-publish.yml b/.github/workflows/benchmarks-publish.yml index c2a9b45b4e..81d3a35adc 100644 --- a/.github/workflows/benchmarks-publish.yml +++ b/.github/workflows/benchmarks-publish.yml @@ -1,9 +1,10 @@ name: Publish Benchmarks -# Measures the benchmarks on the dev branch and opens a pull request that -# commits the numbers (benchmarks/results/, read by the docs) and the table in -# README.md. The streaming and callback load sweeps take 30-60 minutes each, -# so everything runs weekly and on demand. +# Measures the benchmarks on the dev branch weekly (and on demand). Only when +# the numbers moved enough to matter (a capacity step in the load tests, or a +# renderer median by over 25% and 5 ms) does it open a pull request that +# commits them to benchmarks/results/ (read by the docs) and the README table. +# Per-PR renderer regressions are benchmarks.yml's job, not this one. # # Needs "Allow GitHub Actions to create and approve pull requests" in the # repo settings. A pull request opened with GITHUB_TOKEN starts no other @@ -173,16 +174,24 @@ jobs: [ -f results/streaming-results/results.json ] && args="$args --streaming results/streaming-results/results.json" [ -f results/callbacks-results/results.json ] && args="$args --callbacks results/callbacks-results/results.json" python -m pip install -e . - python -m benchmarks.publish --commit "${GITHUB_SHA::9}" $args + python -m benchmarks.publish --commit "${GITHUB_SHA::9}" \ + --only-significant --changes "$RUNNER_TEMP/changes.md" $args - name: Open or update the pull request env: GH_TOKEN: ${{ github.token }} run: | git add benchmarks/results README.md if git diff --cached --quiet; then - echo "Nothing changed." + echo "No significant change." exit 0 fi + { + echo "Benchmark numbers from ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} moved enough to publish:" + echo + cat "$RUNNER_TEMP/changes.md" + echo + echo "Merge to update the README table and the docs." + } > "$RUNNER_TEMP/body.md" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git switch -c "$BRANCH" @@ -191,6 +200,7 @@ jobs: git push --force origin "$BRANCH" if [ -z "$(gh pr list --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then gh pr create --base dev --head "$BRANCH" \ - --title "Update benchmark results" \ - --body "Weekly benchmark numbers from ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}. Merge to update the README table and the docs." + --title "Update benchmark results" --body-file "$RUNNER_TEMP/body.md" + else + gh pr edit "$BRANCH" --body-file "$RUNNER_TEMP/body.md" fi diff --git a/benchmarks/README.md b/benchmarks/README.md index dca9c64826..47cadd3bcc 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -135,8 +135,10 @@ python -m benchmarks.swarm.aggregate reports/*.json --out swarm.json ## Publishing (`publish.py`) `.github/workflows/benchmarks-publish.yml` measures on `dev` weekly (and on -demand) and opens a pull request that commits the numbers to `results/` and -the table in the repo's `README.md`. `results/` holds the raw numbers +demand). Only when the numbers moved enough to matter (a load test's capacity +changes step, or a renderer median moves by over 25% and 5 ms) does it open a +pull request that commits them to `results/` and the table in the repo's +`README.md`; the pull request lists what moved. `results/` holds the raw numbers (`*-latest.json`), one summary per run (`*-history.json`), and `report.json`: every chart as a Plotly figure and the numbers as tables, which the docs page renders. To update them locally: diff --git a/benchmarks/publish.py b/benchmarks/publish.py index 33f6d47cfa..fa555b3a1b 100644 --- a/benchmarks/publish.py +++ b/benchmarks/publish.py @@ -14,6 +14,9 @@ and rewrites the table between the ``benchmarks`` markers in ``README.md`` (``--readme``). Any input may be omitted; the committed numbers for it stay. +With ``--only-significant`` (CI), an input is committed only if it moves the +published numbers (see ``load_changes`` and ``renderer_changes``), and nothing +is written when none does. """ from __future__ import annotations @@ -805,6 +808,62 @@ def update_readme(path, streaming, callbacks): f.write(f"{head}{start}\n{table}\n{end}{tail}") +# --- significance --------------------------------------------------------------- + +# With --only-significant, a run is committed only when it moves the numbers +# people read. Load capacity is a step on the sweep's browser counts, so any +# step counts; a renderer median must move by both of these. +RENDERER_CHANGE = 0.25 +RENDERER_CHANGE_MS = 5.0 + + +def _cap_text(cap): + if cap is None: + return "not measured" + return f"{cap[0]:,}{'+' if cap[1] else ''}" + + +def load_changes(name, old, new, metric): + """What a load run changes in the capacity table, one line per setup.""" + if not old: + return [f"{name}: first published run"] + labels = {s["name"]: s["label"] for s in old["setups"] + new["setups"]} + before = {s["name"]: capacity(s, metric) for s in old["setups"]} + after = {s["name"]: capacity(s, metric) for s in new["setups"]} + changes = [] + for key in labels: + was, now = before.get(key), after.get(key) + if was != now: + changes.append( + f"{name} {labels[key]}: {_cap_text(was)} -> {_cap_text(now)} browsers" + ) + return changes + + +def renderer_changes(old, new): + """Renderer medians that moved by more than the noise allows.""" + if not old: + return ["renderer: first published run"] + changes = [] + for scenario, metrics in new.items(): + for metric, stats in metrics.items(): + was = old["results"].get(scenario, {}).get(metric) + now = stats["median"] + if was is None: + changes.append(f"renderer {scenario} ({metric}): new, {now:,.1f} ms") + continue + delta = abs(now - was["median"]) + if delta >= RENDERER_CHANGE_MS and delta > RENDERER_CHANGE * was["median"]: + changes.append( + f"renderer {scenario} ({metric}):" + f" {was['median']:,.1f} -> {now:,.1f} ms" + ) + return changes + + +# --- main --------------------------------------------------------------------------- + + def _publish_load(run, out, name, metric): """Commit a load run: its numbers, and a capacity summary in its history.""" _dump(os.path.join(out, f"{name}-latest.json"), run) @@ -818,6 +877,20 @@ def _publish_load(run, out, name, metric): _dump(os.path.join(out, f"{name}-history.json"), _append(history, summary, "date")) +def _publish_renderer(entry, out): + # The trend only charts medians; full stats stay in renderer-latest. + trend = { + **entry, + "results": { + scenario: {m: {"median": st["median"]} for m, st in metrics.items()} + for scenario, metrics in entry["results"].items() + }, + } + history = _load(os.path.join(out, "renderer-history.json"), []) + _dump(os.path.join(out, "renderer-latest.json"), entry) + _dump(os.path.join(out, "renderer-history.json"), _append(history, trend, "date")) + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.split("\n\n", maxsplit=1)[0]) parser.add_argument("--dir", default=os.path.join(HERE, "results")) @@ -834,40 +907,53 @@ def main(argv=None): parser.add_argument( "--runner", default=os.environ.get("BENCH_RUNNER", "local machine") ) + parser.add_argument( + "--only-significant", + action="store_true", + help="commit a run only if it changes the published numbers enough", + ) + parser.add_argument("--changes", help="write the list of changes here (Markdown)") args = parser.parse_args(argv) out = args.dir os.makedirs(out, exist_ok=True) + changes = [] for name, path, metric in ( ("streaming", args.streaming, "latency_p95_ms"), ("callbacks", args.callbacks, "rtt_p95_ms"), ): run = _read(path) - if run: + if not run: + continue + old = _load(os.path.join(out, f"{name}-latest.json"), None) + moved = load_changes(name, old, run, metric) + if moved or not args.only_significant: + changes += moved _publish_load(run, out, name, metric) results = _read(args.renderer) if results: - entry = { - "date": args.date or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "commit": args.commit, - "runner": args.runner, - "results": results, - } - # The trend only charts medians; full stats stay in renderer-latest. - trend = { - **entry, - "results": { - scenario: {m: {"median": st["median"]} for m, st in metrics.items()} - for scenario, metrics in results.items() - }, - } - history = _load(os.path.join(out, "renderer-history.json"), []) - _dump(os.path.join(out, "renderer-latest.json"), entry) - _dump( - os.path.join(out, "renderer-history.json"), - _append(history, trend, "date"), - ) + old = _load(os.path.join(out, "renderer-latest.json"), None) + moved = renderer_changes(old, results) + if moved or not args.only_significant: + changes += moved + _publish_renderer( + { + "date": args.date + or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "commit": args.commit, + "runner": args.runner, + "results": results, + }, + out, + ) + + if args.changes: + with open(args.changes, "w", encoding="utf-8") as f: + f.write("".join(f"- {c}\n" for c in changes)) + if args.only_significant and not changes: + print("no significant change; nothing written") + return 0 streaming = _load(os.path.join(out, "streaming-latest.json"), None) callbacks = _load(os.path.join(out, "callbacks-latest.json"), None) @@ -879,6 +965,8 @@ def main(argv=None): ) update_readme(args.readme, streaming, callbacks) print(f"results written to {out}, README table to {args.readme}") + for change in changes: + print(f" {change}") return 0 diff --git a/tests/unit/test_benchmarks_publish.py b/tests/unit/test_benchmarks_publish.py index 5e66208d4d..aac5a91d92 100644 --- a/tests/unit/test_benchmarks_publish.py +++ b/tests/unit/test_benchmarks_publish.py @@ -371,3 +371,63 @@ def test_publish_keeps_committed_callbacks_when_not_given(tmp_path): history = json.loads((out / "callbacks-history.json").read_text()) assert len(history) == 1 assert history[0]["capacity"]["fastapi-w1-http"] == 100 + + +def _stats(median): + return {"n": 3, "min": 1, "median": median, "p90": median, "max": median} + + +def test_renderer_change_must_beat_both_noise_floors(): + old = {"results": {"a": {"m": _stats(100.0)}, "b": {"m": _stats(4.0)}}} + # +20 ms but only 20%: noise. + assert publish.renderer_changes(old, {"a": {"m": _stats(120.0)}}) == [] + # +100% but only 4 ms: noise. + assert publish.renderer_changes(old, {"b": {"m": _stats(8.0)}}) == [] + assert publish.renderer_changes(old, {"a": {"m": _stats(130.0)}}) == [ + "renderer a (m): 100.0 -> 130.0 ms" + ] + assert publish.renderer_changes(old, {"c": {"m": _stats(7.0)}}) == [ + "renderer c (m): new, 7.0 ms" + ] + + +def test_load_change_is_a_capacity_step(): + old = _run([_setup("a", [_point(100, 10), _point(500, 400)])]) + same = _run([_setup("a", [_point(100, 50), _point(500, 900)])]) + assert publish.load_changes("streaming", old, same, "latency_p95_ms") == [] + better = _run([_setup("a", [_point(100, 10), _point(500, 20)])]) + assert publish.load_changes("streaming", old, better, "latency_p95_ms") == [ + "streaming a: 100 -> 500+ browsers" + ] + assert publish.load_changes("streaming", None, old, "latency_p95_ms") == [ + "streaming: first published run" + ] + + +def test_only_significant_runs_are_committed(tmp_path): + out = tmp_path / "results" + readme = _readme(tmp_path) + run = tmp_path / "streaming.json" + changes = tmp_path / "changes.md" + args = ["--dir", str(out), "--readme", str(readme), "--only-significant"] + + run.write_text(json.dumps(_run([_setup("a", [_point(100, 10)])]))) + publish.main([*args, "--streaming", str(run), "--changes", str(changes)]) + assert changes.read_text() == "- streaming: first published run\n" + first = readme.read_text() + + # Slower but still inside the same capacity step: nothing is written. + later = _run([_setup("a", [_point(100, 60)])]) + later["meta"]["date"] = "2026-10-13T00:00:00Z" + run.write_text(json.dumps(later)) + publish.main([*args, "--streaming", str(run), "--changes", str(changes)]) + assert changes.read_text() == "" + assert readme.read_text() == first + assert len(json.loads((out / "streaming-history.json").read_text())) == 1 + + later["setups"][0]["points"].append(_point(500, 20)) + run.write_text(json.dumps(later)) + publish.main([*args, "--streaming", str(run), "--changes", str(changes)]) + assert changes.read_text() == "- streaming a: 100+ -> 500+ browsers\n" + assert "| a | - | - | 500+ |" in readme.read_text() + assert len(json.loads((out / "streaming-history.json").read_text())) == 2 From a9493e38d48a6319074c6d04a83de83727fa7516 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 14 Oct 2026 12:43:21 -0400 Subject: [PATCH 10/11] Benchmark callback response serialization with and without orjson Times dash._utils.to_json on a large figure alone and next to one value orjson cannot serialize (issue #4065), each engine in its own process. Published weekly with the other benchmarks. --- .ai/PERFORMANCE.md | 41 +++ .github/workflows/benchmarks-publish.yml | 38 ++- benchmarks/README.md | 19 +- benchmarks/publish.py | 289 ++++++++++++++++---- benchmarks/serialization/__init__.py | 0 benchmarks/serialization/bench.py | 231 ++++++++++++++++ tests/unit/test_benchmarks_publish.py | 102 ++++++- tests/unit/test_benchmarks_serialization.py | 34 +++ 8 files changed, 689 insertions(+), 65 deletions(-) create mode 100644 benchmarks/serialization/__init__.py create mode 100644 benchmarks/serialization/bench.py create mode 100644 tests/unit/test_benchmarks_serialization.py diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md index b16539c2d7..48962cffb9 100644 --- a/.ai/PERFORMANCE.md +++ b/.ai/PERFORMANCE.md @@ -132,6 +132,12 @@ deployed app, on as many machines as needed. Pitfalls it guards against: - `page.on("websocket")` does not see sockets opened by a SharedWorker; to confirm a click went over the websocket, check that it made no HTTP POST. +`benchmarks/serialization/` times `dash._utils.to_json` (plotly's +`to_json_plotly`) on one large figure, alone and next to a small value orjson +cannot serialize, plus the figure inside a layout. Each engine runs in its own +process; the stdlib one blocks the orjson import, so it is a real install +without orjson, not just a config switch. See finding 6. + `benchmarks/publish.py` commits results to `benchmarks/results/` (read by the docs) and the table in `README.md`. `.github/workflows/benchmarks-publish.yml` runs weekly and opens a pull request only when the numbers moved @@ -240,3 +246,38 @@ exists for growing containers; it is kept as a contrast with loose thresholds. 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. + +### 6. One non-native value sends the whole response down plotly's slow path (#4065) + +With orjson, `to_json_plotly` tries `orjson.dumps` on the whole response. If +any value is not orjson-native (a component, a `go.Figure` object, a +`pd.Timestamp`), it discards the attempt, walks everything in Python with +`clean_to_json_compatible`, then dumps again. A `go.Figure` also pays a +`deepcopy` of its data in `to_dict()`. Layouts always take this path, since +they are made of components. The cost scales with the number of Python-level +values in the response: numpy arrays pass through the walk whole, lists of +floats or datetimes are visited one value at a time. 10 traces x 20k points, +Python 3.13, plotly 6.7: + +| figure data | alone | + html.Div | as go.Figure | in a layout | no orjson, alone | +|--|--:|--:|--:|--:|--:| +| numpy array | 3 ms | 3 ms | 5 ms | 5 ms | 10 ms | +| list of float | 6 ms | 40 ms | 75 ms | 75 ms | 52 ms | +| pd.DatetimeIndex | 14 ms | 50 ms | 42 ms | 42 ms | 75 ms | +| list of datetime | 8 ms | 171 ms | 375 ms | 373 ms | 354 ms | + +The fix belongs in plotly.py, not Dash: + +- pass `default=` to `orjson.dumps` so only the non-native values are + converted, keeping the old path as a fallback on `TypeError`; +- give figures and traces a `_to_json_dict()` that skips the `deepcopy` and + copies only the containers the base64 conversion changes; +- in `PlotlyJSONEncoder.default`, return `isoformat()` first for exact + `datetime`/`date` (it now tries five encoders that raise before that one). + +A local patch to plotly 6.7 doing this brought every orjson "+ value" case to +the figure-alone time, `go.Figure` and layouts to 16-27 ms, and the stdlib list +of datetime from 354 to 106 ms. Its JSON was identical except numpy +`datetime64`, which then always uses orjson's short form +(`2024-01-01T00:00:00`), as the fast path already did. Lists of floats without +orjson stay at ~50 ms: that is the stdlib encoder itself. diff --git a/.github/workflows/benchmarks-publish.yml b/.github/workflows/benchmarks-publish.yml index 81d3a35adc..9ac2e3a63d 100644 --- a/.github/workflows/benchmarks-publish.yml +++ b/.github/workflows/benchmarks-publish.yml @@ -1,8 +1,9 @@ name: Publish Benchmarks # Measures the benchmarks on the dev branch weekly (and on demand). Only when -# the numbers moved enough to matter (a capacity step in the load tests, or a -# renderer median by over 25% and 5 ms) does it open a pull request that +# the numbers moved enough to matter (a capacity step in the load tests, a +# renderer median by over 25% and 5 ms, or a serialization median by over 25% +# and 5 ms) does it open a pull request that # commits them to benchmarks/results/ (read by the docs) and the README table. # Per-PR renderer regressions are benchmarks.yml's job, not this one. # @@ -143,14 +144,42 @@ jobs: name: callbacks-results path: benchmarks/callbacks/results.json + serialization: + name: Serialization benchmarks + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - name: Install Dash + run: | + python -m pip install --upgrade pip + python -m pip install "setuptools<80.0.0" + python -m pip install -e .[ci,dev,testing] + - run: npm run build + # Runs with orjson and again with its import blocked. + - run: python -m benchmarks.serialization.bench --out benchmarks/serialization/results.json + - uses: actions/upload-artifact@v4 + with: + name: serialization-results + path: benchmarks/serialization/results.json + publish: name: Open the results pull request - needs: [renderer, streaming, callbacks] + needs: [renderer, streaming, callbacks, serialization] # Only dev's numbers are committed, even when a dispatch runs elsewhere. if: >- always() && github.ref == 'refs/heads/dev' && (needs.renderer.result == 'success' || needs.streaming.result == 'success' || - needs.callbacks.result == 'success') + needs.callbacks.result == 'success' || needs.serialization.result == 'success') runs-on: ubuntu-latest permissions: contents: write @@ -173,6 +202,7 @@ jobs: [ -f results/renderer-results/results.json ] && args="$args --renderer results/renderer-results/results.json" [ -f results/streaming-results/results.json ] && args="$args --streaming results/streaming-results/results.json" [ -f results/callbacks-results/results.json ] && args="$args --callbacks results/callbacks-results/results.json" + [ -f results/serialization-results/results.json ] && args="$args --serialization results/serialization-results/results.json" python -m pip install -e . python -m benchmarks.publish --commit "${GITHUB_SHA::9}" \ --only-significant --changes "$RUNNER_TEMP/changes.md" $args diff --git a/benchmarks/README.md b/benchmarks/README.md index 47cadd3bcc..00d20616e6 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -132,11 +132,25 @@ Reports from any number of agents merge exactly (`swarm/aggregate.py`): python -m benchmarks.swarm.aggregate reports/*.json --out swarm.json ``` +## Serialization (`serialization/`) + +How long Dash takes to turn a callback response or a layout into JSON, on the +server, for one large figure: alone, next to a small value orjson cannot +serialize (`html.Div`, `pd.Timestamp`), returned as a `go.Figure` object, and +inside a layout. Runs once with orjson and once with its import blocked (issue +#4065). + +```bash +python -m benchmarks.serialization.bench +python -m benchmarks.serialization.bench --case list_float+div --engine json --points 100000 +``` + ## Publishing (`publish.py`) `.github/workflows/benchmarks-publish.yml` measures on `dev` weekly (and on demand). Only when the numbers moved enough to matter (a load test's capacity -changes step, or a renderer median moves by over 25% and 5 ms) does it open a +changes step, a renderer median moves by over 25% and 5 ms, or a serialization +median by over 25% and 5 ms) does it open a pull request that commits them to `results/` and the table in the repo's `README.md`; the pull request lists what moved. `results/` holds the raw numbers (`*-latest.json`), one summary per run (`*-history.json`), and `report.json`: @@ -146,5 +160,6 @@ renders. To update them locally: ```bash python -m benchmarks.publish \ --renderer benchmarks/results.json --streaming benchmarks/streaming/results.json \ - --callbacks benchmarks/callbacks/results.json + --callbacks benchmarks/callbacks/results.json \ + --serialization benchmarks/serialization/results.json ``` diff --git a/benchmarks/publish.py b/benchmarks/publish.py index fa555b3a1b..bfe28cdf07 100644 --- a/benchmarks/publish.py +++ b/benchmarks/publish.py @@ -3,7 +3,8 @@ python -m benchmarks.publish \\ --renderer benchmarks/results.json \\ --streaming benchmarks/streaming/results.json \\ - --callbacks benchmarks/callbacks/results.json + --callbacks benchmarks/callbacks/results.json \\ + --serialization benchmarks/serialization/results.json Writes to ``benchmarks/results/`` (``--dir``), which is committed: @@ -15,7 +16,7 @@ and rewrites the table between the ``benchmarks`` markers in ``README.md`` (``--readme``). Any input may be omitted; the committed numbers for it stay. With ``--only-significant`` (CI), an input is committed only if it moves the -published numbers (see ``load_changes`` and ``renderer_changes``), and nothing +published numbers (see ``load_changes`` and ``median_changes``), and nothing is written when none does. """ from __future__ import annotations @@ -367,6 +368,21 @@ def _descriptions(): return {} +def _median_series(history, name): + """One median trend per (outer, inner) key of each run's results.""" + series = {} + for h in history: + for outer, inner in h["results"].items(): + for key, stats in inner.items(): + s = series.setdefault( + name(outer, key), {"x": [], "y": [], "commit": []} + ) + s["x"].append(h["date"]) + s["y"].append(stats["median"]) + s["commit"].append(h["commit"]) + return series + + def renderer_charts(entry, history): desc = _descriptions() rows = [] @@ -380,15 +396,7 @@ def renderer_charts(entry, history): f"Headless Chrome, production bundle, {entry['runner']}; commit" f" {entry['commit']}, measured {entry['date'][:10]}" ) - series = {} - for h in history: - for scenario, metrics in h["results"].items(): - for metric, stats in metrics.items(): - key = f"{scenario} ({metric})" - s = series.setdefault(key, {"x": [], "y": [], "commit": []}) - s["x"].append(h["date"]) - s["y"].append(stats["median"]) - s["commit"].append(h["commit"]) + series = _median_series(history, lambda scenario, metric: f"{scenario} ({metric})") order = [r[0] for r in reversed(rows)] return [ { @@ -452,6 +460,110 @@ def renderer_table(entry): ], rows +# --- serialization -------------------------------------------------------------- + +ENGINE_NAME = {"orjson": "with orjson", "json": "without orjson (stdlib json)"} + + +def _figure_only(case): + return case.split("+")[0] + "+none" + + +def _serialization_where(entry): + v = entry["versions"] + return ( + f"{entry['traces']} traces x {entry['points']:,} points, median of" + f" {entry['repeat']}; Dash {v['dash']}, plotly {v['plotly']}, orjson" + f" {v['orjson']}, Python {v['python']}, {entry['runner']}; commit" + f" {entry['commit']}, measured {entry['date'][:10]}" + ) + + +def serialization_charts(entry, history): + labels = entry["labels"] + where = _serialization_where(entry) + charts = [] + for engine, name in ENGINE_NAME.items(): + cases = [c for c, by in entry["results"].items() if engine in by] + if not cases: + continue + bars = [] + for case in reversed(cases): + stats = entry["results"][case][engine] + ref = entry["results"].get(_figure_only(case), {}).get(engine) + ratio = ( + f", {stats['median'] / ref['median']:.1f}x the figure alone" + if ref + else "" + ) + bars.append( + { + "label": labels.get(case, case), + "value": stats["median"], + "text": f"{stats['median']:,.0f} ms", + "hover": f"{labels.get(case, case)}
median {stats['median']:,.1f} ms," + f" min {stats['min']:,.1f} ms{ratio}", + "hue": 1 if case.endswith("+none") else 2, + } + ) + charts.append( + { + "id": f"serialization-{engine}", + "title": f"Serializing a callback response, {name}", + "subtitle": ( + "The same figure alone, then with one small value orjson cannot" + " take as is. Shorter is better." + ), + "source": where, + "kind": "hbar", + "bars": bars, + "legend": [ + {"hue": 1, "label": "figure alone"}, + {"hue": 2, "label": "same figure, plus one non-native value"}, + ], + "xaxis": {"title": "median ms (log)", "type": "log"}, + } + ) + series = _median_series( + history, lambda case, engine: f"{labels.get(case, case)}, {ENGINE_NAME[engine]}" + ) + charts.append( + { + "id": "serialization-trend", + "title": "Serialization: median over time", + "subtitle": "One point per published run on the dev branch. Pick a case.", + "source": where, + "kind": "picker", + "series": series, + "yaxis": {"title": "median ms", "rangemode": "tozero"}, + } + ) + return charts + + +def serialization_table(entry): + rows = [] + for case, by_engine in entry["results"].items(): + row = [entry["labels"].get(case, case)] + for engine in ENGINE_NAME: + stats = by_engine.get(engine) + ref = entry["results"].get(_figure_only(case), {}).get(engine) + row.append(_fmt(stats["median"]) if stats else "-") + row.append( + f"{stats['median'] / ref['median']:.1f}x" if stats and ref else "-" + ) + row.append(_fmt(next(iter(by_engine.values()))["bytes"])) + rows.append(row) + return [ + "case", + "orjson ms", + "orjson vs figure alone", + "stdlib json ms", + "stdlib json vs figure alone", + "bytes", + ], rows + + # --- report ----------------------------------------------------------------------- # A light theme; backgrounds stay transparent so the figures sit on any page. @@ -690,7 +802,14 @@ def _section(sid, title, intro, charts, table): } -def report(streaming, renderer, renderer_history, callbacks=None): +def report( + streaming, + renderer, + renderer_history, + callbacks=None, + serialization=None, + serialization_history=(), +): """Every chart as a Plotly figure and the numbers as tables, in sections: what the docs page renders.""" sections = [] @@ -734,6 +853,21 @@ def report(streaming, renderer, renderer_history, callbacks=None): renderer_table(renderer), ) ) + if serialization: + sections.append( + _section( + "serialization", + "Serializing callback responses", + "Time for Dash to turn a callback response or a layout into JSON, on" + " the server. orjson serializes plain data in one fast pass, but one" + " value it cannot take (a component, a go.Figure object, a" + " pd.Timestamp) sends the whole response through a slow Python walk" + " (issue #4065). Without orjson, Python's json module does all the" + " work.", + serialization_charts(serialization, serialization_history), + serialization_table(serialization), + ) + ) return {"sections": sections} @@ -812,9 +946,10 @@ def update_readme(path, streaming, callbacks): # With --only-significant, a run is committed only when it moves the numbers # people read. Load capacity is a step on the sweep's browser counts, so any -# step counts; a renderer median must move by both of these. -RENDERER_CHANGE = 0.25 +# step counts; a median must move by both the ratio and its floor. +MEDIAN_CHANGE = 0.25 RENDERER_CHANGE_MS = 5.0 +SERIALIZATION_CHANGE_MS = 5.0 def _cap_text(cap): @@ -840,27 +975,43 @@ def load_changes(name, old, new, metric): return changes -def renderer_changes(old, new): - """Renderer medians that moved by more than the noise allows.""" +def median_changes(name, old, new, floor_ms): + """Medians that moved by more than the noise allows.""" if not old: - return ["renderer: first published run"] + return [f"{name}: first published run"] changes = [] - for scenario, metrics in new.items(): - for metric, stats in metrics.items(): - was = old["results"].get(scenario, {}).get(metric) + for outer, inner in new.items(): + for key, stats in inner.items(): + was = old["results"].get(outer, {}).get(key) now = stats["median"] if was is None: - changes.append(f"renderer {scenario} ({metric}): new, {now:,.1f} ms") + changes.append(f"{name} {outer} ({key}): new, {now:,.1f} ms") continue delta = abs(now - was["median"]) - if delta >= RENDERER_CHANGE_MS and delta > RENDERER_CHANGE * was["median"]: + if delta >= floor_ms and delta > MEDIAN_CHANGE * was["median"]: changes.append( - f"renderer {scenario} ({metric}):" - f" {was['median']:,.1f} -> {now:,.1f} ms" + f"{name} {outer} ({key}):" f" {was['median']:,.1f} -> {now:,.1f} ms" ) return changes +SERIALIZATION_SETUP = ("traces", "points", "python", "plotly", "orjson") + + +def serialization_setup_changes(old, meta): + """What else changed between the runs, so a move is not read as Dash's.""" + + def setup(entry): + return {k: entry.get(k, entry["versions"].get(k)) for k in SERIALIZATION_SETUP} + + was, now = setup(old), setup(meta) + return [ + f"serialization measured with {k} {now[k]} (was {was[k]})" + for k in SERIALIZATION_SETUP + if was[k] != now[k] + ] + + # --- main --------------------------------------------------------------------------- @@ -877,18 +1028,60 @@ def _publish_load(run, out, name, metric): _dump(os.path.join(out, f"{name}-history.json"), _append(history, summary, "date")) -def _publish_renderer(entry, out): - # The trend only charts medians; full stats stay in renderer-latest. +def _publish_medians(name, entry, out): + # The trend only charts medians; full stats stay in -latest. trend = { - **entry, + "date": entry["date"], + "commit": entry["commit"], + "runner": entry["runner"], "results": { - scenario: {m: {"median": st["median"]} for m, st in metrics.items()} - for scenario, metrics in entry["results"].items() + outer: {k: {"median": st["median"]} for k, st in inner.items()} + for outer, inner in entry["results"].items() }, } - history = _load(os.path.join(out, "renderer-history.json"), []) - _dump(os.path.join(out, "renderer-latest.json"), entry) - _dump(os.path.join(out, "renderer-history.json"), _append(history, trend, "date")) + history = _load(os.path.join(out, f"{name}-history.json"), []) + _dump(os.path.join(out, f"{name}-latest.json"), entry) + _dump(os.path.join(out, f"{name}-history.json"), _append(history, trend, "date")) + + +def _publish_serialization(run, out, stamp, only_significant): + if not run: + return [] + old = _load(os.path.join(out, "serialization-latest.json"), None) + moved = median_changes( + "serialization", old, run["results"], SERIALIZATION_CHANGE_MS + ) + if moved and old: + moved += serialization_setup_changes(old, run["meta"]) + if moved or not only_significant: + _publish_medians( + "serialization", {**stamp, **run["meta"], "results": run["results"]}, out + ) + return moved + + +def _write_outputs(out, readme): + """report.json and the README table, from everything committed in ``out``.""" + + def latest(name): + return _load(os.path.join(out, f"{name}-latest.json"), None) + + def history(name): + return _load(os.path.join(out, f"{name}-history.json"), []) + + streaming, callbacks = latest("streaming"), latest("callbacks") + _dump( + os.path.join(out, "report.json"), + report( + streaming, + latest("renderer"), + history("renderer"), + callbacks, + latest("serialization"), + history("serialization"), + ), + ) + update_readme(readme, streaming, callbacks) def main(argv=None): @@ -902,6 +1095,9 @@ def main(argv=None): parser.add_argument( "--callbacks", help="results.json from benchmarks.callbacks.load" ) + parser.add_argument( + "--serialization", help="results.json from benchmarks.serialization.bench" + ) parser.add_argument("--commit", default=os.environ.get("GITHUB_SHA", "")[:9]) parser.add_argument("--date") parser.add_argument( @@ -931,22 +1127,19 @@ def main(argv=None): changes += moved _publish_load(run, out, name, metric) + date = args.date or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + stamp = {"date": date, "commit": args.commit, "runner": args.runner} results = _read(args.renderer) if results: old = _load(os.path.join(out, "renderer-latest.json"), None) - moved = renderer_changes(old, results) + moved = median_changes("renderer", old, results, RENDERER_CHANGE_MS) if moved or not args.only_significant: changes += moved - _publish_renderer( - { - "date": args.date - or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "commit": args.commit, - "runner": args.runner, - "results": results, - }, - out, - ) + _publish_medians("renderer", {**stamp, "results": results}, out) + + changes += _publish_serialization( + _read(args.serialization), out, stamp, args.only_significant + ) if args.changes: with open(args.changes, "w", encoding="utf-8") as f: @@ -955,15 +1148,7 @@ def main(argv=None): print("no significant change; nothing written") return 0 - streaming = _load(os.path.join(out, "streaming-latest.json"), None) - callbacks = _load(os.path.join(out, "callbacks-latest.json"), None) - renderer = _load(os.path.join(out, "renderer-latest.json"), None) - renderer_history = _load(os.path.join(out, "renderer-history.json"), []) - _dump( - os.path.join(out, "report.json"), - report(streaming, renderer, renderer_history, callbacks), - ) - update_readme(args.readme, streaming, callbacks) + _write_outputs(out, args.readme) print(f"results written to {out}, README table to {args.readme}") for change in changes: print(f" {change}") diff --git a/benchmarks/serialization/__init__.py b/benchmarks/serialization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/benchmarks/serialization/bench.py b/benchmarks/serialization/bench.py new file mode 100644 index 0000000000..fe5d5a36ef --- /dev/null +++ b/benchmarks/serialization/bench.py @@ -0,0 +1,231 @@ +"""Time Dash's JSON serialization of callback responses and layouts. + + python -m benchmarks.serialization.bench --out benchmarks/serialization/results.json + python -m benchmarks.serialization.bench --case list_float+div --engine json + +Every case is one large figure (``--traces`` x ``--points``) serialized with +``dash._utils.to_json``, the way a callback response or the initial layout is. +The figure alone is the reference; the other cases add something small that +orjson cannot serialize natively (a component, a ``go.Figure`` object, a +``pd.Timestamp``) or wrap the figure in a layout. See issue #4065. + +Each engine runs in its own process. ``json`` blocks the orjson import, so +plotly takes its stdlib fallback exactly as on an install without orjson. +""" +from __future__ import annotations + +import argparse +import gc +import json +import os +import platform +import statistics +import subprocess +import sys +import time + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +ENGINES = ("orjson", "json") +DATA = { + "numpy": "numpy array", + "list_float": "list of float", + "datetimeindex": "pd.DatetimeIndex", + "list_datetime": "list of datetime", +} +EXTRAS = { + "none": "figure alone", + "div": "+ html.Div", + "figure": "as go.Figure", + "timestamp": "+ pd.Timestamp", + "layout": "in a layout", +} +CASES = [f"{d}+{e}" for d in DATA for e in EXTRAS] + + +def label(case): + data, extra = case.split("+") + return f"{DATA[data]}, {EXTRAS[extra]}" + + +def _figure(data, traces, points): + # pylint: disable=import-outside-toplevel + import datetime + + import numpy as np + import pandas as pd + import plotly.graph_objects as go + + rng = np.random.default_rng(0) + + def trace(): + y = rng.standard_normal(points) + if data == "numpy": + return go.Scatter(y=y) + if data == "list_float": + return go.Scatter(y=y.tolist()) + if data == "datetimeindex": + return go.Scatter(x=pd.date_range("2024", periods=points, freq="min"), y=y) + start = datetime.datetime(2024, 1, 1) + x = [start + datetime.timedelta(minutes=i) for i in range(points)] + return go.Scatter(x=x, y=y) + + return go.Figure([trace() for _ in range(traces)]) + + +def payload(case, traces, points): + """What Dash hands to ``to_json`` for this case.""" + # pylint: disable=import-outside-toplevel + import pandas as pd + from dash import dcc, html + + data, extra = case.split("+") + fig = _figure(data, traces, points) + if extra == "layout": + return html.Div([html.H1("Report"), dcc.Graph(id="graph", figure=fig)]) + response = {"graph": {"figure": fig if extra == "figure" else fig.to_plotly_json()}} + if extra == "div": + response["status"] = {"children": html.Div("ok")} + elif extra == "timestamp": + response["status"] = {"value": pd.Timestamp("2024-01-01")} + return {"multi": True, "response": response} + + +def _versions(): + # pylint: disable=import-outside-toplevel + from importlib.metadata import PackageNotFoundError, version + + out = {"python": platform.python_version()} + for pkg in ("dash", "plotly", "orjson", "numpy", "pandas"): + try: + out[pkg] = version(pkg) + except PackageNotFoundError: + out[pkg] = None + return out + + +def _require_orjson(): + try: + import orjson # noqa: F401 pylint: disable=import-outside-toplevel,unused-import + except ImportError as err: + raise SystemExit("the orjson engine needs orjson installed") from err + + +def worker(engine, cases, traces, points, repeat): + if engine == "json": + sys.modules["orjson"] = None # type: ignore[assignment] + else: + _require_orjson() + # pylint: disable-next=import-outside-toplevel + from dash._utils import to_json + + results = {} + for case in cases: + value = payload(case, traces, points) + size = len(to_json(value)) + times = [] + for _ in range(repeat): + gc.collect() + start = time.perf_counter() + to_json(value) + times.append((time.perf_counter() - start) * 1000) + results[case] = { + "median": statistics.median(times), + "min": min(times), + "max": max(times), + "bytes": size, + } + print( + f" {engine:6} {label(case):40} {results[case]['median']:8.1f} ms", + file=sys.stderr, + ) + # pylint: disable-next=import-outside-toplevel + from _plotly_utils.optional_imports import get_module + + loaded = get_module("orjson", should_load=True) is not None + return {"versions": _versions(), "orjson_loaded": loaded, "results": results} + + +def run(engines, cases, traces, points, repeat): + env = { + **os.environ, + "PYTHONPATH": os.pathsep.join( + p for p in (REPO_ROOT, os.environ.get("PYTHONPATH")) if p + ), + } + by_engine = {} + for engine in engines: + out = subprocess.run( + [ + sys.executable, + "-m", + "benchmarks.serialization.bench", + "--worker", + engine, + "--traces", + str(traces), + "--points", + str(points), + "--repeat", + str(repeat), + "--case", + *cases, + ], + cwd=REPO_ROOT, + env=env, + check=True, + stdout=subprocess.PIPE, + text=True, + ) + by_engine[engine] = json.loads(out.stdout) + versions = by_engine[engines[0]]["versions"] + results = { + case: {engine: run_["results"][case] for engine, run_ in by_engine.items()} + for case in cases + } + return { + "meta": { + "traces": traces, + "points": points, + "repeat": repeat, + "versions": versions, + "orjson_loaded": {e: r["orjson_loaded"] for e, r in by_engine.items()}, + "labels": {case: label(case) for case in cases}, + }, + "results": results, + } + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n", maxsplit=1)[0]) + parser.add_argument("--engine", nargs="+", choices=ENGINES, default=list(ENGINES)) + parser.add_argument("--case", nargs="+", choices=CASES, default=CASES) + parser.add_argument("--traces", type=int, default=10) + parser.add_argument("--points", type=int, default=20_000) + parser.add_argument("--repeat", type=int, default=7) + parser.add_argument("--out", help="write results here (JSON)") + parser.add_argument("--worker", choices=ENGINES, help=argparse.SUPPRESS) + args = parser.parse_args(argv) + + if args.worker: + json.dump( + worker(args.worker, args.case, args.traces, args.points, args.repeat), + sys.stdout, + ) + return 0 + + data = run(args.engine, args.case, args.traces, args.points, args.repeat) + header = f"{'case':42}" + "".join(f"{e:>10}" for e in args.engine) + print(header) + for case, by_engine in data["results"].items(): + print( + f"{label(case):42}" + + "".join(f"{by_engine[e]['median']:8.1f}ms" for e in args.engine) + ) + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_benchmarks_publish.py b/tests/unit/test_benchmarks_publish.py index aac5a91d92..a6c96e8163 100644 --- a/tests/unit/test_benchmarks_publish.py +++ b/tests/unit/test_benchmarks_publish.py @@ -377,18 +377,106 @@ def _stats(median): return {"n": 3, "min": 1, "median": median, "p90": median, "max": median} -def test_renderer_change_must_beat_both_noise_floors(): +def test_median_change_must_beat_both_noise_floors(): old = {"results": {"a": {"m": _stats(100.0)}, "b": {"m": _stats(4.0)}}} + + def changes(new): + return publish.median_changes("renderer", old, new, 5.0) + # +20 ms but only 20%: noise. - assert publish.renderer_changes(old, {"a": {"m": _stats(120.0)}}) == [] + assert changes({"a": {"m": _stats(120.0)}}) == [] # +100% but only 4 ms: noise. - assert publish.renderer_changes(old, {"b": {"m": _stats(8.0)}}) == [] - assert publish.renderer_changes(old, {"a": {"m": _stats(130.0)}}) == [ - "renderer a (m): 100.0 -> 130.0 ms" + assert changes({"b": {"m": _stats(8.0)}}) == [] + assert changes({"a": {"m": _stats(130.0)}}) == ["renderer a (m): 100.0 -> 130.0 ms"] + assert changes({"c": {"m": _stats(7.0)}}) == ["renderer c (m): new, 7.0 ms"] + + +def _serialization_run(div_ms): + def stats(median): + return {"median": median, "min": median, "max": median, "bytes": 1000} + + return { + "meta": { + "traces": 10, + "points": 20000, + "repeat": 7, + "versions": { + "python": "3.12.0", + "dash": "4.5.0", + "plotly": "6.7.0", + "orjson": "3.11.0", + }, + "labels": { + "list_float+none": "list of float, figure alone", + "list_float+div": "list of float, + html.Div", + }, + }, + "results": { + "list_float+none": {"orjson": stats(6.0), "json": stats(50.0)}, + "list_float+div": {"orjson": stats(div_ms), "json": stats(51.0)}, + }, + } + + +def test_serialization_section_compares_each_case_to_the_figure_alone(tmp_path): + out = tmp_path / "results" + run = tmp_path / "serialization.json" + changes = tmp_path / "changes.md" + args = ["--dir", str(out), "--readme", str(_readme(tmp_path)), "--only-significant"] + + run.write_text(json.dumps(_serialization_run(40.0))) + publish.main([*args, "--serialization", str(run), "--date", "2026-10-01T00:00:00Z"]) + sections = json.loads((out / "report.json").read_text())["sections"] + assert [s["id"] for s in sections] == ["serialization"] + section = sections[0] + assert [c["id"] for c in section["charts"]] == [ + "serialization-orjson", + "serialization-json", + "serialization-trend", + ] + orjson_bars = section["charts"][0]["figure"]["data"] + assert [t["name"] for t in orjson_bars] == [ + "figure alone", + "same figure, plus one non-native value", ] - assert publish.renderer_changes(old, {"c": {"m": _stats(7.0)}}) == [ - "renderer c (m): new, 7.0 ms" + assert orjson_bars[1]["x"] == [40.0] + assert section["table"]["rows"][1][:3] == [ + "list of float, + html.Div", + "40.0", + "6.7x", ] + latest = json.loads((out / "serialization-latest.json").read_text()) + assert latest["versions"]["plotly"] == "6.7.0" + assert latest["date"] == "2026-10-01T00:00:00Z" + + # A plotly fix that drops the + html.Div case to the figure-alone time. + fixed = _serialization_run(6.5) + fixed["meta"]["versions"]["plotly"] = "6.8.0" + run.write_text(json.dumps(fixed)) + publish.main( + [ + *args, + "--serialization", + str(run), + "--date", + "2026-10-08T00:00:00Z", + "--changes", + str(changes), + ] + ) + assert changes.read_text() == ( + "- serialization list_float+div (orjson): 40.0 -> 6.5 ms\n" + "- serialization measured with plotly 6.8.0 (was 6.7.0)\n" + ) + history = json.loads((out / "serialization-history.json").read_text()) + assert [h["results"]["list_float+div"]["orjson"] for h in history] == [ + {"median": 40.0}, + {"median": 6.5}, + ] + trend = json.loads((out / "report.json").read_text())["sections"][0]["charts"][2] + assert ( + trend["figure"]["data"][0]["name"] == "list of float, figure alone, with orjson" + ) def test_load_change_is_a_capacity_step(): diff --git a/tests/unit/test_benchmarks_serialization.py b/tests/unit/test_benchmarks_serialization.py new file mode 100644 index 0000000000..3537cd4330 --- /dev/null +++ b/tests/unit/test_benchmarks_serialization.py @@ -0,0 +1,34 @@ +import json + +from benchmarks.serialization import bench + + +def test_each_engine_runs_every_case_and_json_never_loads_orjson(tmp_path): + out = tmp_path / "results.json" + cases = [ + "list_float+none", + "list_float+div", + "list_datetime+figure", + "numpy+layout", + ] + bench.main( + [ + "--traces", + "1", + "--points", + "50", + "--repeat", + "1", + "--out", + str(out), + "--case", + *cases, + ] + ) + run = json.loads(out.read_text()) + assert run["meta"]["orjson_loaded"] == {"orjson": True, "json": False} + assert list(run["results"]) == cases + for by_engine in run["results"].values(): + assert set(by_engine) == {"orjson", "json"} + assert by_engine["orjson"]["median"] > 0 + assert run["meta"]["labels"]["list_float+div"] == "list of float, + html.Div" From f1e9fe7e829c01f11bc7c5633e2f54822e32439b Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 14 Oct 2026 13:06:15 -0400 Subject: [PATCH 11/11] Note what is left of serialization cost without orjson --- .ai/PERFORMANCE.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.ai/PERFORMANCE.md b/.ai/PERFORMANCE.md index 48962cffb9..58d5d0ac1f 100644 --- a/.ai/PERFORMANCE.md +++ b/.ai/PERFORMANCE.md @@ -271,13 +271,23 @@ The fix belongs in plotly.py, not Dash: - pass `default=` to `orjson.dumps` so only the non-native values are converted, keeping the old path as a fallback on `TypeError`; - give figures and traces a `_to_json_dict()` that skips the `deepcopy` and - copies only the containers the base64 conversion changes; + copies only the containers the base64 conversion changes; skip lists of + scalars with one `set(map(type, lst))` check instead of a Python loop; - in `PlotlyJSONEncoder.default`, return `isoformat()` first for exact `datetime`/`date` (it now tries five encoders that raise before that one). -A local patch to plotly 6.7 doing this brought every orjson "+ value" case to -the figure-alone time, `go.Figure` and layouts to 16-27 ms, and the stdlib list -of datetime from 354 to 106 ms. Its JSON was identical except numpy -`datetime64`, which then always uses orjson's short form -(`2024-01-01T00:00:00`), as the fast path already did. Lists of floats without -orjson stay at ~50 ms: that is the stdlib encoder itself. +A local patch to plotly 6.7 doing this brought every orjson case within ~3 ms +of the figure alone (list of datetime as `go.Figure` or in a layout: 375 -> +11 ms), and the stdlib list of datetime from 354 to 109 ms. Its JSON was +identical except numpy `datetime64`, which then always uses orjson's short +form (`2024-01-01T00:00:00`), as the fast path already did. + +Without orjson, what is left is the stdlib encoder: 200k floats take ~50 ms, +and 200k datetimes ~70 ms just to make their `isoformat()` strings, before +encoding. Bulk conversion does not help: numpy, pandas and pyarrow all pay +per object to read a list of datetimes (77-149 ms), and pyarrow casts a +timestamp column to strings in 5 ms but has no JSON writer for a nested +response, so the strings come back out as Python objects at the same cost. +Native encoders write bytes without Python objects: orjson and msgspec do +200k floats in ~5 ms and 200k datetimes in 2-4 ms. Making orjson a Dash +dependency is the change that helps every case.