mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-09-06 16:40:56 +02:00
267 lines
9.7 KiB
Python
267 lines
9.7 KiB
Python
"""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)})")
|