Skip to content

Commit 2994a41

Browse files
committed
Add benchmarks for JS rendering and Python serialization performance
1 parent ea7082c commit 2994a41

6 files changed

Lines changed: 973 additions & 12 deletions

File tree

anyplotlib/figure.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,13 @@ class Figure(anywidget.AnyWidget):
6767
subplots : Recommended factory for creating Figure and Axes grid.
6868
"""
6969

70-
layout_json = traitlets.Unicode("{}").tag(sync=True)
71-
fig_width = traitlets.Int(640).tag(sync=True)
72-
fig_height = traitlets.Int(480).tag(sync=True)
70+
layout_json = traitlets.Unicode("{}").tag(sync=True)
71+
fig_width = traitlets.Int(640).tag(sync=True)
72+
fig_height = traitlets.Int(480).tag(sync=True)
7373
# Bidirectional JS event bus: JS writes interaction events here, Python reads them.
74-
event_json = traitlets.Unicode("{}").tag(sync=True)
74+
event_json = traitlets.Unicode("{}").tag(sync=True)
75+
# When True the JS renderer shows a per-panel FPS / frame-time overlay.
76+
display_stats = traitlets.Bool(False).tag(sync=True)
7577
_esm = _ESM_SOURCE
7678
# Static CSS injected by anywidget alongside _esm.
7779
# .apl-scale-wrap — outer container; width:100% means it always fills
@@ -108,7 +110,8 @@ class Figure(anywidget.AnyWidget):
108110

109111
def __init__(self, nrows=1, ncols=1, figsize=(640, 480),
110112
width_ratios=None, height_ratios=None,
111-
sharex=False, sharey=False, **kwargs):
113+
sharex=False, sharey=False,
114+
display_stats=False, **kwargs):
112115
super().__init__(**kwargs)
113116
self._nrows = nrows
114117
self._ncols = ncols
@@ -119,8 +122,9 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480),
119122
self._axes_map: dict = {}
120123
self._plots_map: dict = {}
121124
with self.hold_trait_notifications():
122-
self.fig_width = figsize[0]
123-
self.fig_height = figsize[1]
125+
self.fig_width = figsize[0]
126+
self.fig_height = figsize[1]
127+
self.display_stats = display_stats
124128
self._push_layout()
125129

126130
# ── subplot creation ──────────────────────────────────────────────────────
@@ -342,7 +346,8 @@ def subplots(nrows=1, ncols=1, *,
342346
figsize=(640, 480),
343347
width_ratios=None,
344348
height_ratios=None,
345-
gridspec_kw=None):
349+
gridspec_kw=None,
350+
display_stats=False):
346351
"""Create a :class:`Figure` and a grid of :class:`~anyplotlib.figure_plots.Axes`.
347352
348353
Mirrors :func:`matplotlib.pyplot.subplots`.
@@ -392,6 +397,7 @@ def subplots(nrows=1, ncols=1, *,
392397
nrows=nrows, ncols=ncols, figsize=figsize,
393398
width_ratios=width_ratios, height_ratios=height_ratios,
394399
sharex=sharex, sharey=sharey,
400+
display_stats=display_stats,
395401
)
396402
# Build the GridSpec from the Figure's own stored ratios so there is
397403
# exactly one source of truth.

anyplotlib/figure_esm.js

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,51 @@ function render({ model, el }) {
8282
return arr[lo]+t*(arr[lo+1]-arr[lo]);
8383
}
8484

85-
// ── outer DOM ────────────────────────────────────────────────────────────
85+
// ── per-panel frame timing ────────────────────────────────────────────────
86+
// Called at the entry of every draw function (draw2d / draw1d / draw3d /
87+
// drawBar). Records a high-resolution timestamp in a 60-entry rolling
88+
// buffer on the panel object, then:
89+
// • updates window._aplTiming[p.id] — always, for Playwright readback
90+
// • updates p.statsDiv text — only when display_stats is true
91+
//
92+
// Placing the call at the *start* of each draw function means we measure
93+
// the inter-trigger interval: how often the CPU initiates a render, which
94+
// is the right metric for both interactive (pan/zoom) and data-push paths.
95+
const _FRAME_BUF = 60;
96+
97+
function _recordFrame(p) {
98+
const now = performance.now();
99+
p.frameTimes.push(now);
100+
if (p.frameTimes.length > _FRAME_BUF) p.frameTimes.shift();
101+
102+
const n = p.frameTimes.length;
103+
104+
// Always keep the global timing dict fresh so Playwright can read it back
105+
// at any point via window._aplTiming[panelId].
106+
if (!window._aplTiming) window._aplTiming = {};
107+
108+
if (n >= 2) {
109+
let sum = 0, minDt = Infinity, maxDt = -Infinity;
110+
for (let i = 1; i < n; i++) {
111+
const dt = p.frameTimes[i] - p.frameTimes[i - 1];
112+
sum += dt; if (dt < minDt) minDt = dt; if (dt > maxDt) maxDt = dt;
113+
}
114+
const mean_ms = sum / (n - 1);
115+
const fps = 1000 * (n - 1) / (now - p.frameTimes[0]);
116+
window._aplTiming[p.id] = {
117+
count: n, fps, mean_ms, min_ms: minDt, max_ms: maxDt,
118+
};
119+
120+
if (p.statsDiv && model.get('display_stats')) {
121+
p.statsDiv.style.display = 'block';
122+
p.statsDiv.textContent =
123+
`FPS ${fps.toFixed(1)}\n` +
124+
` dt ${mean_ms.toFixed(1)} ms\n` +
125+
`min ${minDt.toFixed(1)} ms\n` +
126+
`max ${maxDt.toFixed(1)} ms`;
127+
}
128+
}
129+
}
86130
// Static layout styles live in the _css traitlet (.apl-scale-wrap /
87131
// .apl-outer). Only the two dynamic properties — transform and
88132
// marginBottom — are ever written here at runtime.
@@ -207,6 +251,7 @@ function render({ model, el }) {
207251
let plotCanvas, overlayCanvas, markersCanvas, statusBar;
208252
let xAxisCanvas=null, yAxisCanvas=null, scaleBar=null;
209253
let _p2d = null; // extra 2D DOM refs, null for 1D panels
254+
let _wrapNode = null; // container to which statsDiv is appended
210255

211256
if (kind === '2d') {
212257
// ── 2D branch ──────────────────────────────────────────────────────────
@@ -270,6 +315,7 @@ function render({ model, el }) {
270315

271316
const cbCtx = cbCanvas.getContext('2d');
272317
_p2d = { cbCanvas, cbCtx, plotWrap };
318+
_wrapNode = plotWrap;
273319

274320
} else if (kind === '3d') {
275321
// ── 3D branch: one full-panel plotCanvas + overlayCanvas on top ───────
@@ -293,8 +339,7 @@ function render({ model, el }) {
293339
statusBar.style.cssText =
294340
'position:absolute;bottom:4px;right:4px;padding:2px 6px;display:none;';
295341
wrap3.appendChild(statusBar);
296-
297-
} else {
342+
_wrapNode = wrap3;
298343
plotCanvas = document.createElement('canvas');
299344
plotCanvas.tabIndex = 1;
300345
plotCanvas.style.cssText = 'outline:none;cursor:crosshair;display:block;border-radius:2px;';
@@ -319,6 +364,7 @@ function render({ model, el }) {
319364
'background:rgba(0,0,0,0.55);color:white;font-size:10px;font-family:monospace;' +
320365
'border-radius:4px;pointer-events:none;white-space:nowrap;display:none;z-index:9;';
321366
wrap.appendChild(statusBar);
367+
_wrapNode = wrap;
322368
}
323369

324370
const plotCtx = plotCanvas.getContext('2d');
@@ -329,12 +375,25 @@ function render({ model, el }) {
329375

330376
const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 };
331377

378+
// ── stats overlay (top-left of panel) ────────────────────────────────
379+
// Positioned absolutely inside the panel's wrap container so it floats
380+
// over the plot area. Visibility is toggled by the display_stats traitlet.
381+
const statsDiv = document.createElement('div');
382+
statsDiv.style.cssText =
383+
'position:absolute;top:4px;left:4px;padding:4px 7px;' +
384+
'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' +
385+
'font-family:monospace;border-radius:4px;pointer-events:none;' +
386+
'white-space:pre;line-height:1.5;z-index:20;display:none;';
387+
if (_wrapNode) _wrapNode.appendChild(statsDiv);
388+
332389
const p = {
333390
id, kind, cell, pw, ph,
334391
plotCanvas, overlayCanvas, markersCanvas,
335392
plotCtx, ovCtx, mkCtx,
336393
xAxisCanvas, yAxisCanvas, xCtx, yCtx,
337394
scaleBar, statusBar,
395+
statsDiv, // ← per-panel FPS overlay element
396+
frameTimes: [], // ← rolling 60-entry timestamp buffer (performance.now())
338397
blitCache,
339398
ovDrag: null,
340399
isPanning: false, panStart: {},
@@ -578,6 +637,7 @@ function render({ model, el }) {
578637
function draw2d(p) {
579638
const st=p.state;
580639
if(!st) return;
640+
_recordFrame(p);
581641
// Re-sync axis/histogram canvas visibility whenever state changes
582642
_resizePanelDOM(p.id, p.pw, p.ph);
583643
const {pw,ph,plotCtx:ctx,blitCache} = p;
@@ -1030,6 +1090,7 @@ function render({ model, el }) {
10301090

10311091
function draw3d(p) {
10321092
const st = p.state; if (!st) return;
1093+
_recordFrame(p);
10331094
const { pw, ph, plotCtx: ctx } = p;
10341095

10351096
ctx.clearRect(0, 0, pw, ph);
@@ -1317,6 +1378,7 @@ function render({ model, el }) {
13171378

13181379
function draw1d(p) {
13191380
const st=p.state; if(!st) return;
1381+
_recordFrame(p);
13201382
const {pw,ph,plotCtx:ctx} = p;
13211383
const r=_plotRect1d(pw,ph);
13221384
const xArr=st.x_axis||[], x0=st.view_x0||0, x1=st.view_x1||1;
@@ -2696,6 +2758,7 @@ function render({ model, el }) {
26962758

26972759
function drawBar(p) {
26982760
const st = p.state; if (!st) return;
2761+
_recordFrame(p);
26992762
const { pw, ph, plotCtx: ctx } = p;
27002763
const r = _plotRect1d(pw, ph);
27012764

@@ -3100,6 +3163,20 @@ function render({ model, el }) {
31003163
model.on('change:layout_json', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); });
31013164
model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); });
31023165

3166+
// Toggle the per-panel stats overlay when display_stats changes.
3167+
// Hiding is immediate; showing waits for the next natural redraw to
3168+
// populate the overlay text — but we also call redrawAll() here so the
3169+
// stats appear instantly without having to interact with the figure first.
3170+
model.on('change:display_stats', () => {
3171+
const show = model.get('display_stats');
3172+
for (const p of panels.values()) {
3173+
if (!show && p.statsDiv) {
3174+
p.statsDiv.style.display = 'none';
3175+
}
3176+
}
3177+
if (show) redrawAll();
3178+
});
3179+
31033180
// Python→JS targeted widget update (source:"python" in event_json).
31043181
// Applies changed fields directly to the widget in overlay_widgets and
31053182
// redraws the panel — no image re-decode, no Python echo.

tests/benchmarks/baselines.json

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
{
2+
"_meta": {
3+
"note": "Run `uv run pytest tests/test_benchmarks.py tests/test_benchmarks_py.py --update-benchmarks` to regenerate on this machine. Headless-Chrome timings are machine-specific; regenerate when switching hardware or CI runners.",
4+
"regression_threshold_js": 1.5,
5+
"regression_threshold_py": 1.3,
6+
"updated_at": "2026-04-03T15:14:50.260443Z",
7+
"host": "Carters-MacBook-Air.local"
8+
},
9+
"py_normalize_64x64": {
10+
"min_ms": 0.013,
11+
"mean_ms": 0.03,
12+
"max_ms": 0.094,
13+
"n": 15,
14+
"updated_at": "2026-04-03T15:14:49.086721Z"
15+
},
16+
"py_encode_64x64": {
17+
"min_ms": 0.006,
18+
"mean_ms": 0.007,
19+
"max_ms": 0.01,
20+
"n": 15,
21+
"updated_at": "2026-04-03T15:14:49.088606Z"
22+
},
23+
"py_serialize_2d_64x64": {
24+
"min_ms": 0.08,
25+
"mean_ms": 0.118,
26+
"max_ms": 0.173,
27+
"n": 15,
28+
"updated_at": "2026-04-03T15:14:49.295005Z"
29+
},
30+
"py_serialize_1d_100pts": {
31+
"min_ms": 0.051,
32+
"mean_ms": 0.061,
33+
"max_ms": 0.087,
34+
"n": 15,
35+
"updated_at": "2026-04-03T15:14:49.298385Z"
36+
},
37+
"py_serialize_1d_1000pts": {
38+
"min_ms": 0.479,
39+
"mean_ms": 0.495,
40+
"max_ms": 0.514,
41+
"n": 15,
42+
"updated_at": "2026-04-03T15:14:49.308353Z"
43+
},
44+
"py_serialize_1d_10000pts": {
45+
"min_ms": 5.173,
46+
"mean_ms": 6.195,
47+
"max_ms": 7.04,
48+
"n": 15,
49+
"updated_at": "2026-04-03T15:14:49.409289Z"
50+
},
51+
"py_serialize_1d_100000pts": {
52+
"min_ms": 49.916,
53+
"mean_ms": 51.439,
54+
"max_ms": 53.381,
55+
"n": 15,
56+
"updated_at": "2026-04-03T15:14:50.252123Z"
57+
},
58+
"py_update_2d_64x64": {
59+
"min_ms": 0.205,
60+
"mean_ms": 0.231,
61+
"max_ms": 0.299,
62+
"n": 15,
63+
"updated_at": "2026-04-03T15:14:50.260435Z"
64+
}
65+
}

0 commit comments

Comments
 (0)