Skip to content

perf(panadapter): main-thread CPU ~75–95% is Qt CPU-side compositing of translucent child widgets over the QRhiWidget (render is 0.4 ms) #3617

Description

@ten9876

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:

  • libQt6Gui44%
  • libc memcpy22%
  • libnvidia-glcore — 15%
  • AetherSDR's own code — only 6%

Hot path: notifyInternal2QWidget::event(paint)QWidgetRepaintManager::paintAndFlushflushrhiFlush (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:

  1. The backing-store texture is cached across frames and uploaded dirty-region-incrementally, not full-window.
  2. 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).
  3. 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:

  • SpectrumOverlayMenusrc/gui/SpectrumOverlayMenu.cpp:213 sets WA_NoSystemBackground but is not opaque (default-translucent); child of SpectrumWidget (src/gui/SpectrumWidget.cpp:586).
  • One VfoWidget per slicesrc/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:

  1. Single custom QRhiWidget that draws spectrum + overlays itself (no child raster widgets over it).
  2. Qt Quick / QML scene graph, where static/retained overlays are "effectively free."
  3. Hybrid QQuickRenderControlQRhiTexture.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    VITA-49VITA-49 UDP streaming: FFT, waterfall, audio, metersenhancementImprovement to existing featuremaintainer-reviewRequires maintainer review before any action is takenpriority: highHigh priorityspectrumPanadapter and waterfall

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions