Files
bec_widgets/GPU_ACCELERATION.md
T

7.0 KiB
Raw Blame History

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:

  • PlotCurveItempaintGL at graphicsItems/PlotCurveItem.py:1013
  • PColorMeshItempaintGL at graphicsItems/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 ~510k 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.

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, honours BEC_WIDGETS_OPENGL=auto|1|0, and provides grab_widget() for OpenGL-safe screenshots.
  • plot_base.pyPlotBase.USE_OPENGL class flag (default False); the viewport is swapped after construction because GraphicsLayoutWidget.__init__ forwards no viewport argument to GraphicsView.
  • waveform.py, multi_waveform.pyUSE_OPENGL = True.
  • bec_widget.py — the three self.grab() screenshot sites now call grab_widget(self).
  • tests/unit_tests/test_gpu_acceleration.py — 10 tests covering the renderer gate, the env var, and that a capture over an OpenGL viewport is non-blank.

Opt-in per widget rather than a global pg.setConfigOption("useOpenGL", True), because a global switch would put Image and Heatmap on a GL viewport for zero gain while still paying the blank-grab and software-renderer costs.

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:72 returns True, None unconditionally when sys.platform == "darwin", so the assertion can only hold on Linux.
  • 7 × test_plugin_creator.py::TestAddWidgetVariants — environmental. The copier template task runs pyside6-uic, which exits 127 (not found) in the cloned bec_312_pg-gpu env.

Targeted runs: 10/10 new tests, 167 passed across waveform/multi-waveform/plot_base/lifecycle/ scatter, 322 passed in a plot/image/heatmap/crosshair/roi/export sweep. An AttributeError traceback logged during test_waveform.py is pre-existing — it also appears with BEC_WIDGETS_OPENGL=0.

Not addressed

  • Antialiasing/appearance differences. The GL path renders lines through its own shader; hairlines and antialias=True will 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. Their ScatterPlotItem has no GL path, so accelerating them needs upstream work.
  • The useOpenGL viewport is still flagged experimental by Qt for QGraphicsView. 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.