Files
bec_widgets/GPU_ACCELERATION.md

9.9 KiB
Raw Permalink 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.

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, honours BEC_WIDGETS_OPENGL=auto|1|0, and provides grab_widget() for OpenGL-safe screenshots.
  • plot_base.pyuse_opengl SafeProperty(bool), default True via PlotBase.USE_OPENGL, switchable at runtime and exposed over RPC. The viewport is swapped after construction because GraphicsLayoutWidget.__init__ forwards no viewport argument to GraphicsView.
  • bec_widget.py — the three self.grab() screenshot sites now call grab_widget(self).
  • tests/unit_tests/ — 13 tests in test_gpu_acceleration.py plus 3 in test_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: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 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=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.