Summary
The panadapter pegs the main/GUI thread at ~75–95% CPU even though the GPU spectrum render itself costs ~0.4 ms/frame. This investigation (profiles + GPU/Wayland/PRIME rule-outs + Qt-source deep-dive) establishes that the cost is Qt's CPU-side compositing of translucent child QWidgets over the QRhiWidget panadapter, not the spectrum render, not the repaint rate, and not the GPU. This issue captures the full evidence and a tiered fix plan so the work is targeted, not a scattered chase.
Verified diagnosis
The render is cheap; the composite is the cost.
PerfTelemetry (aether.perf) during steady-state panadapter:
| metric |
value |
meaning |
panFps |
~30 |
repaint rate is fine |
wfFps |
~25 |
waterfall rate is fine |
renderP95Ms |
0.4 ms |
the GPU render(QRhiCommandBuffer*) is near-free |
gpuWfFull / gpuWfRows |
0 / 25 |
waterfall upload is already incremental (per-row), not full-texture |
Yet the main thread sits at ~75–95%. So it is not repaint-rate-bound and not render-bound.
perf record (DWARF call-graph) self-time, GPU=ON build:
libQt6Gui — 44%
libc memcpy — 22%
libnvidia-glcore — 15%
- AetherSDR's own code — only 6%
Hot path: notifyInternal2 → QWidget::event(paint) → QWidgetRepaintManager::paintAndFlush → flush → rhiFlush (with QImage::copy ~14% + QRhi::endFrame ~13%). A CPU-fallback (GPU=OFF) build instead showed QWidgetPrivate::paintSiblingsRecursive at 42% self-time — the same compositing work, just on the CPU raster path.
Rule-outs (what it is NOT)
- Not the GPU / not the driver. Identical ~86% on the Intel iGPU vs the NVIDIA RTX 4090 Laptop dGPU. PRIME offload confirmed engaged (
libGLX_nvidia loaded), still ~86%. → GPU-/platform-independent.
- Not Wayland vs X. Same under native Wayland and under
QT_QPA_PLATFORM=xcb (XWayland).
- Not the repaint rate. Lean mode (caps repaints, throttles meters, hides WAVE scope) only moved the needle ~13%, and that gain came specifically from making
VfoWidget opaque — see below.
- Not the spectrum draw.
paintEvent() early-returns in the GPU build (if (m_rhiInitialized) { SPECTRUM_BASE_CLASS::paintEvent(ev); return; }), so the QPainter spectrum/gradient/waterfall path does not execute at all. Static overlays (grid/spots/markers/labels) are already rasterized once into m_ovGpuTex and are not a per-frame cost.
Mechanism (corrected by Qt-source deep-dive)
The initial hypothesis was "Qt re-uploads the full window backing store every frame." That is wrong. Reading qbackingstoredefaultcompositor.cpp, qrhiwidget.cpp, and the QRhiWidget/QOpenGLWidget docs:
- The backing-store texture is cached across frames and uploaded dirty-region-incrementally, not full-window.
- The real per-frame cost is (a)
QWidgetPrivate::paintSiblingsRecursive re-rasterizing overlapping translucent raster siblings every time the QRhiWidget dirties its region, plus (b) QImage::detach / convertToFormat inside QBackingStoreDefaultCompositor::toTexture() (the memcpy/QImage::copy in the profile).
- This is architectural by design: any texture-backed widget forces the whole top-level to be composited through the 3D API, and
QRhiWidget always renders into a backing texture. The Qt docs explicitly warn of the "potential effect on performance." No QTBUG fix or Qt version removes it (cf. QTBUG-43298).
Why it hurts us specifically: the panadapter is a QRhiWidget, and layered on top of it as translucent child QWidgets are:
SpectrumOverlayMenu — src/gui/SpectrumOverlayMenu.cpp:213 sets WA_NoSystemBackground but is not opaque (default-translucent); child of SpectrumWidget (src/gui/SpectrumWidget.cpp:586).
- One
VfoWidget per slice — src/gui/VfoWidget.cpp:268 sets WA_TranslucentBackground; child of SpectrumWidget (src/gui/SpectrumWidget.cpp:912). Several of its sub-rows are also translucent (VfoWidget.cpp:721,762,776).
- TNF / interlock / popup labels.
Every dirtied panadapter frame forces Qt to walk and re-raster each overlapping translucent sibling on the CPU, then re-toTexture the composited result. Lean mode's ~13% win came exactly from VfoWidget::setOpaqueMode() (src/gui/VfoWidget.cpp:2064) clearing WA_TranslucentBackground — confirming the lever — but it leaves SpectrumOverlayMenu and the popup labels translucent.
Fix plan (tiered)
Short-term (targeted, low-risk) — make overlapping overlay children opaque/static
Extend the proven Lean lever to all overlapping overlay siblings, unconditionally (not just in Lean):
- Give
SpectrumOverlayMenu an opaque-mode path mirroring VfoWidget::setOpaqueMode() (clear WA_TranslucentBackground, opaque background) and apply it.
- Audit
VfoWidget's translucent sub-rows (:721/:762/:776) — make opaque where they don't need alpha.
- Minimize per-frame overlay invalidation: ensure overlays only
update() when their content actually changes, so they don't get pulled into every panadapter frame's dirty region.
WA_StaticContents where applicable so unchanged overlay regions aren't re-rastered.
This should recover the bulk of the CPU without architectural change. Must be cross-platform (no platform #ifdef unless solving a platform-specific problem) and must not disable GPU rendering (flagship feature).
Flagship (architectural) — retained-mode GPU scene graph
Eliminate the CPU raster-sibling composite entirely by drawing the overlays on the GPU instead of as raster child widgets. Static overlays already go to m_ovGpuTex; extend that to the interactive overlays. Options:
- Single custom
QRhiWidget that draws spectrum + overlays itself (no child raster widgets over it).
- Qt Quick / QML scene graph, where static/retained overlays are "effectively free."
- Hybrid
QQuickRenderControl → QRhiTexture.
Native-window-per-overlay was ruled out (can't composite raster overlays on top of a native child window).
Acceptance target
Per project direction: 30 fps is the floor, at full 4K/8K native resolution, on a flagship GPU — "AAA games meet this on a 4090; we should be no different." GPU rendering stays on.
Open gap
Sources did not fully establish the exact role of each widget attribute (WA_OpaquePaintEvent vs WA_TranslucentBackground vs WA_StaticContents) in whether a sibling is pulled into paintSiblingsRecursive. The short-term fix should be validated empirically with PerfTelemetry + perf before/after.
Sources
- Qt:
src/widgets/kernel/qwidgetrepaintmanager.cpp, qwidget.cpp (paintSiblingsRecursive)
- Qt:
src/gui/painting/qbackingstoredefaultcompositor.cpp (toTexture)
- Qt:
src/widgets/widgets/qrhiwidget.cpp + QRhiWidget / QOpenGLWidget class docs ("potential effect on performance")
- Qt: scene-graph renderer docs (retained-mode "effectively free" overlays)
- QTBUG-43298
Evidence-backed from in-session profiling (perf DWARF call-graph, PerfTelemetry aether.perf) on the perf/panadapter-repaint branch + a deep-research Qt-source verification pass.
Summary
The panadapter pegs the main/GUI thread at ~75–95% CPU even though the GPU spectrum render itself costs ~0.4 ms/frame. This investigation (profiles + GPU/Wayland/PRIME rule-outs + Qt-source deep-dive) establishes that the cost is Qt's CPU-side compositing of translucent child
QWidgets over theQRhiWidgetpanadapter, not the spectrum render, not the repaint rate, and not the GPU. This issue captures the full evidence and a tiered fix plan so the work is targeted, not a scattered chase.Verified diagnosis
The render is cheap; the composite is the cost.
PerfTelemetry (
aether.perf) during steady-state panadapter:panFpswfFpsrenderP95Msrender(QRhiCommandBuffer*)is near-freegpuWfFull/gpuWfRowsYet the main thread sits at ~75–95%. So it is not repaint-rate-bound and not render-bound.
perf record(DWARF call-graph) self-time, GPU=ON build:libQt6Gui— 44%libcmemcpy— 22%libnvidia-glcore— 15%Hot path:
notifyInternal2→QWidget::event(paint)→QWidgetRepaintManager::paintAndFlush→flush→rhiFlush(withQImage::copy~14% +QRhi::endFrame~13%). A CPU-fallback (GPU=OFF) build instead showedQWidgetPrivate::paintSiblingsRecursiveat 42% self-time — the same compositing work, just on the CPU raster path.Rule-outs (what it is NOT)
libGLX_nvidialoaded), still ~86%. → GPU-/platform-independent.QT_QPA_PLATFORM=xcb(XWayland).VfoWidgetopaque — see below.paintEvent()early-returns in the GPU build (if (m_rhiInitialized) { SPECTRUM_BASE_CLASS::paintEvent(ev); return; }), so the QPainter spectrum/gradient/waterfall path does not execute at all. Static overlays (grid/spots/markers/labels) are already rasterized once intom_ovGpuTexand are not a per-frame cost.Mechanism (corrected by Qt-source deep-dive)
The initial hypothesis was "Qt re-uploads the full window backing store every frame." That is wrong. Reading
qbackingstoredefaultcompositor.cpp,qrhiwidget.cpp, and the QRhiWidget/QOpenGLWidget docs:QWidgetPrivate::paintSiblingsRecursivere-rasterizing overlapping translucent raster siblings every time theQRhiWidgetdirties its region, plus (b)QImage::detach/convertToFormatinsideQBackingStoreDefaultCompositor::toTexture()(thememcpy/QImage::copyin the profile).QRhiWidgetalways renders into a backing texture. The Qt docs explicitly warn of the "potential effect on performance." NoQTBUGfix or Qt version removes it (cf. QTBUG-43298).Why it hurts us specifically: the panadapter is a
QRhiWidget, and layered on top of it as translucent childQWidgets are:SpectrumOverlayMenu—src/gui/SpectrumOverlayMenu.cpp:213setsWA_NoSystemBackgroundbut is not opaque (default-translucent); child ofSpectrumWidget(src/gui/SpectrumWidget.cpp:586).VfoWidgetper slice —src/gui/VfoWidget.cpp:268setsWA_TranslucentBackground; child ofSpectrumWidget(src/gui/SpectrumWidget.cpp:912). Several of its sub-rows are also translucent (VfoWidget.cpp:721,762,776).Every dirtied panadapter frame forces Qt to walk and re-raster each overlapping translucent sibling on the CPU, then re-
toTexturethe composited result. Lean mode's ~13% win came exactly fromVfoWidget::setOpaqueMode()(src/gui/VfoWidget.cpp:2064) clearingWA_TranslucentBackground— confirming the lever — but it leavesSpectrumOverlayMenuand the popup labels translucent.Fix plan (tiered)
Short-term (targeted, low-risk) — make overlapping overlay children opaque/static
Extend the proven Lean lever to all overlapping overlay siblings, unconditionally (not just in Lean):
SpectrumOverlayMenuan opaque-mode path mirroringVfoWidget::setOpaqueMode()(clearWA_TranslucentBackground, opaque background) and apply it.VfoWidget's translucent sub-rows (:721/:762/:776) — make opaque where they don't need alpha.update()when their content actually changes, so they don't get pulled into every panadapter frame's dirty region.WA_StaticContentswhere applicable so unchanged overlay regions aren't re-rastered.This should recover the bulk of the CPU without architectural change. Must be cross-platform (no platform
#ifdefunless solving a platform-specific problem) and must not disable GPU rendering (flagship feature).Flagship (architectural) — retained-mode GPU scene graph
Eliminate the CPU raster-sibling composite entirely by drawing the overlays on the GPU instead of as raster child widgets. Static overlays already go to
m_ovGpuTex; extend that to the interactive overlays. Options:QRhiWidgetthat draws spectrum + overlays itself (no child raster widgets over it).QQuickRenderControl→QRhiTexture.Native-window-per-overlay was ruled out (can't composite raster overlays on top of a native child window).
Acceptance target
Per project direction: 30 fps is the floor, at full 4K/8K native resolution, on a flagship GPU — "AAA games meet this on a 4090; we should be no different." GPU rendering stays on.
Open gap
Sources did not fully establish the exact role of each widget attribute (
WA_OpaquePaintEventvsWA_TranslucentBackgroundvsWA_StaticContents) in whether a sibling is pulled intopaintSiblingsRecursive. The short-term fix should be validated empirically with PerfTelemetry +perfbefore/after.Sources
src/widgets/kernel/qwidgetrepaintmanager.cpp,qwidget.cpp(paintSiblingsRecursive)src/gui/painting/qbackingstoredefaultcompositor.cpp(toTexture)src/widgets/widgets/qrhiwidget.cpp+ QRhiWidget / QOpenGLWidget class docs ("potential effect on performance")Evidence-backed from in-session profiling (perf DWARF call-graph, PerfTelemetry
aether.perf) on theperf/panadapter-repaintbranch + a deep-research Qt-source verification pass.