9.9 KiB
GPU acceleration for BEC Widgets plots (pyqtgraph 0.14)
Branch pg-gpu, worktree bec_widgets_pg-gpu, env bec_312_pg-gpu.
Summary
The migration is already done. pyproject.toml pins pyqtgraph==0.14.0 and that is what
bec_312 has installed. There is no 0.13 → 0.14 port to carry out.
Enabling GPU acceleration is small — one viewport swap at a single choke point — but it is not free, and it does not help the widgets people usually assume it will.
| Migration effort | none (already on 0.14.0) |
| Enablement effort | ~1 day incl. the screenshot fix and tests |
| Widgets that benefit | Waveform, MultiWaveform |
| Widgets that gain nothing | Image, Heatmap, ScatterWaveform, MotorMap |
| Main hazard | screenshots come back blank; software GL on remote consoles |
What pyqtgraph 0.14 actually accelerates
0.14 rewrote the OpenGL path as a self-contained shader program (no PyOpenGL needed) and dropped
the enableExperimental gate. It applies to exactly two items:
PlotCurveItem—paintGLatgraphicsItems/PlotCurveItem.py:1013PColorMeshItem—paintGLatgraphicsItems/PColorMeshItem.py:469
Everything else — ImageItem, ScatterPlotItem, TextItem, InfiniteLine, the ROIs, the axes —
has no paintGL and still goes through QPainter. There is no GPU path for image display in the
QGraphicsView stack at all.
This matters because ImageItem is by far the most-used pyqtgraph item in this repo (27 call
sites vs. 9 for PlotDataItem). The Image and Heatmap widgets get no benefit from this work.
Measured
Apple M1 Pro, PySide6 6.11.1, swapInterval=0, data pre-generated so only render cost is timed
(gpu_bench.py, 5 curves):
| points/curve | raster | opengl | speedup |
|---|---|---|---|
| 1,000 | 170.1 fps | 319.1 fps | 1.9× |
| 5,000 | 74.3 fps | 292.3 fps | 3.9× |
| 20,000 | 29.5 fps | 248.1 fps | 8.4× |
| 100,000 | 6.7 fps | 155.5 fps | 23.2× |
| 500,000 | 1.4 fps | 51.4 fps | 36.5× |
Raster cost scales with sample count; the OpenGL path stays roughly flat. Note the practical
threshold, though: with vsync on, a real app is capped at the display refresh anyway, so below
~5–10k points per curve both paths are already "fast enough" and the win is invisible. The change
pays off for long line scans and for MultiWaveform.
For comparison, a 2048×2048 ImageItem measured 39.0 fps raster vs. 42.6 fps OpenGL — 1.09×,
i.e. noise. That is the expected result given there is no GL path for images.
Symbols dominate everything above (fixed)
Profiling the Waveform update path turned up a cost far larger than anything the viewport choice
buys. Curves default to symbol="o", and pyqtgraph draws symbols through ScatterPlotItem, whose
SymbolAtlas._keys builds a style tuple per point in Python — 2 getId calls per point, linear
in sample count.
At 50,000 points, curve.setData() measured 92.1 ms with a symbol and 0.49 ms without; the full
_on_data_update() went from 108.7 ms to 7.2 ms. The scaling makes the mechanism plain — the symbol
cost is per point, the rest is flat:
| points | symbols on | symbols off |
|---|---|---|
| 1,000 | 1.16 ms | 0.05 ms |
| 10,000 | 18.36 ms | 0.06 ms |
| 50,000 | 89.56 ms | 0.09 ms |
Curve.setData now hides the symbol above CurveConfig.symbol_point_limit (default 1000) and
restores it when the data shrinks. The suppression is a display-level setSymbol(None); config.symbol
keeps the user's choice, so a custom symbol comes back rather than being reset to "o". Setting
symbol_point_limit = None opts out.
This lives in Curve.setData rather than in the Waveform update slots so every data path is covered
— sync, async, history, DAP, and the data_api branch's _render_* helpers. _auto_adjust_async_curve_settings
previously did this for async curves only, and reset the symbol to a hardcoded "o"; its symbol
handling was removed in favour of the shared path (it still manages pen width and downsampling).
Note this was never a data_api regression: the default is identical on main. Pen width is left
alone — thick pens are also costly, but width 1 is a visual regression nobody asked for.
The two real hazards
1. Screenshots come back blank (fixed here)
BECWidget captures via self.grab() in three places — screenshot, screenshot_bytes and
screenshot_to_scilog. QWidget.grab() renders the widget tree through QPainter and never
reaches a QOpenGLWidget, so the plot area is empty. Measured directly: 6.5% non-background
pixels on the raster viewport, 0.0% on the OpenGL viewport.
Left unfixed this silently uploads blank plots to SciLog.
QOpenGLWidget.grabFramebuffer() is not a usable substitute — it also returned an empty image on
macOS, before and after a forced repaint(), because the framebuffer is not retained after
compositing. Swapping the viewport back to raster for the duration of the grab is worse: pyqtgraph
parents its OpenGLState to the GL viewport widget (PlotCurveItem.py:49), so destroying that
widget leaves PlotCurveItem.glstate dangling.
The fix in bec_widgets/utils/gpu_acceleration.py re-renders the affected GraphicsView's
scene through QPainter into the grabbed pixmap. No OpenGL state is touched and the output
matches the raster path. One subtlety: GraphicsView.render forwards to QGraphicsView.render,
not QWidget.render, so it stretches the scene across the whole painter unless an explicit target
and source rect are passed — without that the capture comes out zoomed.
2. Software OpenGL on remote consoles
The renderer string decides this. An X-forwarded or VNC session typically lands on Mesa
llvmpipe, where the OpenGL path is slower than raster. Given how BEC GUIs are deployed on
beamline nodes this is the common case, not the exotic one, so acceleration is gated on the
renderer not being software.
What was implemented
bec_widgets/utils/gpu_acceleration.py(new) — caches an offscreen-context probe of the GL renderer, refuses software rasterisers, honoursBEC_WIDGETS_OPENGL=auto|1|0, and providesgrab_widget()for OpenGL-safe screenshots.plot_base.py—use_openglSafeProperty(bool), defaultTrueviaPlotBase.USE_OPENGL, switchable at runtime and exposed over RPC. The viewport is swapped after construction becauseGraphicsLayoutWidget.__init__forwards no viewport argument toGraphicsView.bec_widget.py— the threeself.grab()screenshot sites now callgrab_widget(self).tests/unit_tests/— 13 tests intest_gpu_acceleration.pyplus 3 intest_plot_base_next_gen.py, covering the renderer gate, the env var, non-blank captures over an OpenGL viewport, and the runtime toggle.
Set per widget rather than via a global pg.setConfigOption("useOpenGL", True), so a single plot can
be dropped back to raster without disturbing the rest of the application.
Runtime switching
The viewport can be swapped after construction, but not naively. pyqtgraph parents each item's
OpenGLState to the viewport widget, so useOpenGL() deletes it on the C++ side while the item
keeps a stale Python reference. The next paintGL then raises
RuntimeError: Signal source has been deleted — and because PlotCurveItem.paint is wrapped in
@debug.warnOnException, the exception is swallowed: the curve silently stops rendering instead of
crashing.
Measured over 4 toggle cycles: 29 swallowed GL paint exceptions without a reset, 0 when the
stale state is released first. set_view_opengl() therefore disconnects sigPlotChanged and clears
glstate on every affected item before swapping, so the item rebuilds against the new context.
use_opengl reflects the live viewport rather than the requested value — setting it True on a
software renderer leaves it False.
Test status
Full unit suite on this machine: 2051 passed, 3 skipped, 1 failed, 7 errors (9m29s). None of the failures are attributable to this change:
test_client_utils.py::test_check_gui_display_available_reports_missing_display_for_ssh_session— pre-existing, macOS-only.client_utils.py:72returnsTrue, Noneunconditionally whensys.platform == "darwin", so the assertion can only hold on Linux.- 7 ×
test_plugin_creator.py::TestAddWidgetVariants— environmental. The copier template task runspyside6-uic, which exits 127 (not found) in the clonedbec_312_pg-gpuenv.
Targeted runs after adding the property: 16/16 new tests, 183 passed across
waveform/multi-waveform/plot_base/lifecycle/scatter, and 402 passed in a
plot/image/heatmap/crosshair/roi/export/rpc/client sweep (same single pre-existing failure). An
AttributeError traceback logged during test_waveform.py is also pre-existing — it appears with
BEC_WIDGETS_OPENGL=0 too.
bw-generate-cli --target bec_widgets was re-run; the only change to the generated
bec_widgets/cli/client.py is the new use_opengl accessor on the plot classes.
Not addressed
- Antialiasing/appearance differences. The GL path renders lines through its own shader;
hairlines and
antialias=Truewill not be pixel-identical to raster. No reference-image tests cover the plot canvas, so nothing failed, but it is worth an eyeball before deploying. ScatterWaveform/MotorMap. Left on raster. TheirScatterPlotItemhas no GL path, so accelerating them needs upstream work.- The
useOpenGLviewport is still flagged experimental by Qt forQGraphicsView. That is Qt's wording, not a specific known bug. - Not verified on Linux/NVIDIA or on a real beamline console — only on Apple M1 Pro. The software-renderer gate is unit-tested with a faked renderer string, not against real llvmpipe.
Try it
conda activate bec_312_pg-gpu && cd /Users/janwyzula/PSI/bec_widgets_pg-gpu && python gpu_bench.py 100000 5
gpu_risks.py prints the grab-blankness comparison and the renderer identity for the current
session.