mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-07 00:42:37 +02:00
test(bench): add Waveform/Image render benchmark across data paths
This commit is contained in:
@@ -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)})")
|
||||
Reference in New Issue
Block a user