test(bench): add Waveform/Image render benchmark across data paths

This commit is contained in:
2026-08-14 12:44:47 +02:00
parent 5a180957f8
commit 18dc005f58
4 changed files with 339 additions and 0 deletions
@@ -0,0 +1 @@
{'files': ['feedback_dialog.py']}
@@ -0,0 +1,57 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from qtpy.QtWidgets import QWidget
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog import FeedbackDialog
DOM_XML = """
<ui language='c++'>
<widget class='FeedbackDialog' name='feedback_dialog'>
</widget>
</ui>
"""
class FeedbackDialogPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
if parent is None:
return QWidget()
t = FeedbackDialog(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(FeedbackDialog.ICON_NAME)
def includeFile(self):
return "feedback_dialog"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "FeedbackDialog"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()
@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.utility.feedback_dialog.feedback_dialog_plugin import FeedbackDialogPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(FeedbackDialogPlugin())
if __name__ == "__main__": # pragma: no cover
main()
@@ -0,0 +1,266 @@
"""End-to-end render benchmark for Waveform and Image, with and without OpenGL.
Drives each widget through the *real* data-entry point of whichever branch it is
run on, so the numbers include the widget-side data handling, not just pyqtgraph:
- ``data_api`` branch: a ``SubscriptionUpdate`` is handed to ``_on_data_update``,
which includes the columnar alignment work.
- ``main``: ``update_sync_curves`` pulls a prepared scan-data dict (the fetch from
the scan item is stubbed, since that needs a live BEC), and the image widget is
fed through ``on_image_update_2d``.
The network/Redis hop is outside the measured region on both branches -- what is
compared is "a data snapshot is available -> pixels on screen".
Run explicitly (it is excluded from the CI suite by ``--ignore``)::
QT_QPA_PLATFORM=offscreen pytest tests/unit_tests/benchmarks/test_gpu_widget_benchmark.py -s
Environment knobs: ``BENCH_FRAMES`` (default 40), ``BENCH_POINTS`` (default 50000),
``BENCH_IMAGE`` (image edge length, default 2048).
"""
import os
import time
import numpy as np
import pyqtgraph as pg
import pytest
from bec_widgets.utils.gpu_acceleration import opengl_available, set_view_opengl
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import mocked_client
from tests.unit_tests.conftest import create_widget
try:
from bec_lib.data_api.models import SourceData, SubscriptionUpdate
HAS_DATA_API = True
except ImportError: # main branch
HAS_DATA_API = False
FRAMES = int(os.environ.get("BENCH_FRAMES", 40))
POINTS = int(os.environ.get("BENCH_POINTS", 50_000))
IMAGE_EDGE = int(os.environ.get("BENCH_IMAGE", 2048))
N_PRESET = 8
DEVICE, ENTRY = "bpm4i", "bpm4i"
_results: list[tuple[str, str, float | None]] = []
_data_path: dict[str, list[float]] = {}
def _make_update(values: np.ndarray, ordinals: tuple[int, ...], kind: str = "monitored"):
"""Build a single-source SubscriptionUpdate carrying `values`."""
source = SourceData(
device=DEVICE,
entry=ENTRY,
kind=kind,
ordinals=ordinals,
values=tuple(values),
timestamps=tuple(float(o) for o in ordinals),
complete=True,
)
return SubscriptionUpdate(
scan_id="bench-scan",
reason="live",
sources={(DEVICE, ENTRY): source},
aligned_ordinals=ordinals,
complete=True,
)
def _time_frames(widget, inject, count_paint_on, qtbot) -> float | None:
"""Run FRAMES inject-and-render cycles; None if the frames never rendered.
Each frame waits for the item to actually paint before starting the next.
``repaint()`` is not usable here: on a QOpenGLWidget it defers, so a
synchronous count sees zero. An unexposed window also makes painting a no-op
for the raster viewport while OpenGL still renders, which would silently
flatter raster -- hence the explicit per-frame confirmation.
"""
paints = 0
original = count_paint_on.paint
def counting_paint(self, *args, **kwargs):
nonlocal paints
paints += 1
return original(self, *args, **kwargs)
count_paint_on.paint = counting_paint
rendered = 0
try:
# The first paint after the window appears needs a generous stretch of
# event loop; 1 ms slices never get there. Warm up until one lands, then
# the per-frame polling below keeps up on its own.
warmup_deadline = time.perf_counter() + 10.0
while paints == 0 and time.perf_counter() < warmup_deadline:
inject(0)
widget.plot_widget.viewport().update()
try:
qtbot.waitUntil(lambda: paints > 0, timeout=1000)
except Exception:
pass
if paints == 0:
return None
start = time.perf_counter()
for i in range(FRAMES):
before = paints
inject(i)
widget.plot_widget.viewport().update()
# waitUntil spins pytest-qt's own event loop; hand-rolled
# processEvents()/wait() polling does not deliver these paint events.
try:
qtbot.waitUntil(lambda: paints > before, timeout=5000)
rendered += 1
except Exception:
pass
elapsed = time.perf_counter() - start
finally:
count_paint_on.paint = original
if rendered < FRAMES:
print(
f" [diag] rendered={rendered}/{FRAMES} paints={paints} "
f"viewport={type(widget.plot_widget.viewport()).__name__} "
f"visible={widget.isVisible()} items={len(widget.plot_widget.scene().items())}"
)
return None
return FRAMES / elapsed
def _time_data_path(inject, iterations: int) -> float:
"""Time the widget-side data handling alone, in ms per update.
This is the part that differs between branches (DataAPI columnar alignment
vs. the scan-item dict walk) and it needs no window, so unlike the render
timing it is reliable regardless of whether the session composites.
"""
inject(0) # warm caches
start = time.perf_counter()
for i in range(iterations):
inject(i)
return (time.perf_counter() - start) * 1000 / iterations
def _report(label: str, mode: str, fps: float | None) -> None:
_results.append((label, mode, fps))
if fps is None:
print(f" {label:<26} {mode:<8} INVALID (frames did not render)")
else:
print(f" {label:<26} {mode:<8} {fps:8.1f} fps ({1000 / fps:7.2f} ms/frame)")
def _setup_waveform(qtbot, mocked_client, use_opengl: bool):
wf = create_widget(qtbot, Waveform, client=mocked_client)
set_view_opengl(wf.plot_widget, use_opengl)
wf.plot(arg1=DEVICE)
wf.resize(1200, 800)
wf.show()
qtbot.waitExposed(wf)
return wf
def _waveform_injector(wf, x, datasets):
"""Return a per-frame injector using this branch's real data path."""
if HAS_DATA_API:
ordinals = tuple(range(POINTS))
updates = [_make_update(d, ordinals) for d in datasets]
return lambda i: wf._on_data_update(updates[i % N_PRESET])
# main: update_sync_curves() reads the scan data dict it fetches
payloads = [{DEVICE: {ENTRY: {"val": d}}} for d in datasets]
state = {"i": 0}
def fetch():
return payloads[state["i"] % N_PRESET], "val"
wf._fetch_scan_data_and_access = fetch
wf._get_x_data = lambda *a, **k: x
def inject(i):
state["i"] = i
wf.update_sync_curves()
return inject
def _setup_image(qtbot, mocked_client, use_opengl: bool):
img = create_widget(qtbot, Image, client=mocked_client)
set_view_opengl(img.plot_widget, use_opengl)
img.image(DEVICE, ENTRY)
img.resize(1200, 800)
img.show()
qtbot.waitExposed(img)
return img
def _image_injector(img, frames):
if HAS_DATA_API:
img._source_key = (DEVICE, ENTRY)
img.subscriptions["main"].monitor_type = "2d"
# a plain list keeps each frame a float32 ndarray; np.array([f], dtype=object)
# yields an object-dtype array and the downstream isnan() blows up
updates = [_make_update([f], (0,), kind="async") for f in frames]
return lambda i: img._on_data_update(updates[i % N_PRESET])
img.async_update = False
return lambda i: img.on_image_update_2d({"data": frames[i % N_PRESET]}, {})
@pytest.mark.parametrize("use_opengl", [False, True], ids=["raster", "opengl"])
def test_waveform_render_benchmark(qtbot, mocked_client, use_opengl):
if use_opengl and not opengl_available(True):
pytest.skip("no hardware OpenGL available")
rng = np.random.default_rng(0)
x = np.arange(POINTS, dtype=np.float64)
datasets = [np.sin(x * 0.001 + k) + rng.normal(0, 0.1, POINTS) for k in range(N_PRESET)]
wf = _setup_waveform(qtbot, mocked_client, use_opengl)
inject = _waveform_injector(wf, x, datasets)
inject(0) # warm up the curve/GL state before timing
qtbot.wait(200)
ms = _time_data_path(inject, FRAMES)
fps = _time_frames(wf, inject, pg.PlotCurveItem, qtbot)
_report(f"Waveform {POINTS:,}pts", "opengl" if use_opengl else "raster", fps)
_data_path.setdefault(f"Waveform {POINTS:,}pts", []).append(ms)
@pytest.mark.parametrize("use_opengl", [False, True], ids=["raster", "opengl"])
def test_image_render_benchmark(qtbot, mocked_client, use_opengl):
if use_opengl and not opengl_available(True):
pytest.skip("no hardware OpenGL available")
rng = np.random.default_rng(0)
frames = [rng.normal(size=(IMAGE_EDGE, IMAGE_EDGE)).astype(np.float32) for _ in range(N_PRESET)]
img = _setup_image(qtbot, mocked_client, use_opengl)
inject = _image_injector(img, frames)
inject(0)
qtbot.wait(200)
ms = _time_data_path(inject, FRAMES)
fps = _time_frames(img, inject, pg.ImageItem, qtbot)
_report(f"Image {IMAGE_EDGE}x{IMAGE_EDGE}", "opengl" if use_opengl else "raster", fps)
_data_path.setdefault(f"Image {IMAGE_EDGE}x{IMAGE_EDGE}", []).append(ms)
def teardown_module(module):
branch = "data_api" if HAS_DATA_API else "main"
print(f"\n==== summary ({branch} data path, {FRAMES} frames) ====")
by_label: dict[str, dict[str, float | None]] = {}
for label, mode, fps in _results:
by_label.setdefault(label, {})[mode] = fps
for label, modes in by_label.items():
raster, opengl = modes.get("raster"), modes.get("opengl")
if raster and opengl:
print(f" {label:<26} render speedup {opengl / raster:5.2f}x")
else:
print(f" {label:<26} render speedup n/a (a run did not render)")
print(f"---- widget-side data path ({branch}) ----")
for label, samples in _data_path.items():
best = min(samples)
print(f" {label:<26} {best:8.3f} ms/update (best of {len(samples)})")