# 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` — `paintGL` at `graphicsItems/PlotCurveItem.py:1013` - `PColorMeshItem` — `paintGL` 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 ~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, honours `BEC_WIDGETS_OPENGL=auto|1|0`, and provides `grab_widget()` for OpenGL-safe screenshots. - **`plot_base.py`** — `use_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 ```bash 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.