From 91a937d46aa81c311b36a14af3c143616d0c2188 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 16 Jun 2026 09:46:20 -0500 Subject: [PATCH] Speed up Pyodide interaction dispatch ~50x (pre-compiled proxy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs ⚡ bridge dispatched every interaction event with pyodide.runPythonAsync(code-string), which parses + compiles a fresh Python code string per event — ~1.2 ms/event in WASM, the dominant per-frame cost of the Pyodide interaction path on a drag (30-60 events/sec). That's why 3D orbit / plane-drag felt sluggish in the docs vs a live Jupyter kernel. Define a pre-compiled `_awi_dispatch(fig_id, data)` function once at boot and call its PyProxy directly from the message handler (~0.02 ms/event, ~50x faster, measured end-to-end in Pyodide). The proxy is fetched lazily and cached (robust to boot-step ordering) with a runPythonAsync fallback. The Python->JS return path was already efficient (a compiled observer closure calling js.window._anywidgetPush), so no change needed there. All 12 bridge-boot tests pass. --- .../static/anywidget_bridge.js | 44 +++++++++++++------ upcoming_changes/+pyodide_dispatch.bugfix.rst | 7 +++ 2 files changed, 37 insertions(+), 14 deletions(-) create mode 100644 upcoming_changes/+pyodide_dispatch.bugfix.rst diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js index 76b3afa..fbf2c96 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -186,6 +186,16 @@ class _AnyWidgetMod: sys.modules['anywidget'] = _AnyWidgetMod() _AWI_REGISTRY = {} # fig_id → widget instance + +# Pre-compiled interaction-event dispatcher. The JS message handler calls +# this proxy DIRECTLY per frame instead of pyodide.runPythonAsync(code-string) +# — recompiling a code string every event costs ~1.2 ms in WASM (vs ~0.01 ms +# to call a ready function), which is the dominant per-frame cost of the +# Pyodide interaction path on a drag (30-60 events/sec). +def _awi_dispatch(fig_id, data): + _w = _AWI_REGISTRY.get(fig_id) + if _w is not None and hasattr(_w, '_dispatch_event'): + _w._dispatch_event(data) `)); console.info('[sphinx_anywidget] anywidget stub installed'); @@ -991,23 +1001,29 @@ if _exec_error: } } - // 10. Route awi_event messages from iframes → Pyodide callbacks - window.addEventListener('message', async (e) => { + // 10. Route awi_event messages from iframes → Pyodide callbacks. + // Call the pre-compiled _awi_dispatch proxy DIRECTLY (no runPythonAsync + // code-string recompile per frame — that was ~1.2 ms/event in WASM, the + // dominant per-frame cost of the Pyodide interaction path; the proxy is + // ~50x faster). Synchronous call: _dispatch_event itself is sync, and + // skipping the async wrapper removes a microtask hop per event. + // The proxy is fetched lazily + cached (robust to any boot-step ordering), + // with a one-shot runPythonAsync fallback if it isn't available. + let _awiDispatch = null; + window.addEventListener('message', (e) => { if (!e.data || e.data.type !== 'awi_event') return; const { figId, data } = e.data; - console.debug('[sphinx_anywidget] awi_event', figId, - JSON.parse(data || '{}').event_type); - const _figIdRepr = JSON.stringify(figId); - const _dataRepr = JSON.stringify(data); try { - await pyodide.runPythonAsync(` -_AWI_FIG_ID = ${_figIdRepr} -_widget = _AWI_REGISTRY.get(_AWI_FIG_ID) -if _widget is not None and hasattr(_widget, '_dispatch_event'): - _widget._dispatch_event(${_dataRepr}) -elif _widget is None: - print("[sphinx_anywidget] no widget for figId=" + repr(_AWI_FIG_ID)) -`); + if (!_awiDispatch) { + try { _awiDispatch = pyodide.globals.get('_awi_dispatch'); } catch (_) {} + } + if (_awiDispatch) { + _awiDispatch(figId, data); + } else { + // Fallback (should not happen): recompiled dispatch. + pyodide.runPythonAsync( + `_awi_dispatch(${JSON.stringify(figId)}, ${JSON.stringify(data)})`); + } } catch (err) { console.warn('[sphinx_anywidget] event dispatch error:', err); } diff --git a/upcoming_changes/+pyodide_dispatch.bugfix.rst b/upcoming_changes/+pyodide_dispatch.bugfix.rst new file mode 100644 index 0000000..fef1730 --- /dev/null +++ b/upcoming_changes/+pyodide_dispatch.bugfix.rst @@ -0,0 +1,7 @@ +Interactive (⚡) documentation figures are much smoother under Pyodide. Each +user interaction event was dispatched with ``pyodide.runPythonAsync`` on a +freshly-built code string, which recompiles Python source every frame +(~1.2 ms/event in WASM — the dominant per-frame cost on a drag). The bridge +now calls a pre-compiled dispatcher proxy directly (~50× faster, ~0.02 ms), +so panning, orbiting, and dragging widgets / slice planes in the docs keep up +with the gesture.