perf(gui): per-pixel GPU FFT trace, present coalescing, 60 fps panadapters + get panstats bridge verb#3958
Conversation
…g + panstats verb 2D panadapter render-path optimization, profiled and proven live via the agent automation bridge: - panscope.frag: the FFT trace (fill + feather + core stroke, heat-map and solid modes) is now evaluated per pixel from a width×1 R32F column texture (R16F fallback), replacing the per-frame CPU vertex bake (two AA line strips + fill strip, ~1.4 MB VBO upload per frame at a 2140 px pan). buildFftDisplayTrace's spatial smooth + Catmull-Rom stays, now sampled at one point per device pixel. Frame prep: 601 -> 118 µs; trace upload: 41 MB/s -> 0.5 MB/s at 30 fps. - Present coalescing in leanCappedUpdate(): FFT frames and waterfall rows arrive as separate UDP events; one present per 16 ms slot (trailing update keeps the last frame) caps a narrow pan at ~60 flushes/s instead of ~83+, which starved the window swapchain drawable pool (aethersdr#3938 class). - FFT FPS slider ceiling 30 -> 60: the FLEX-8400M happily delivers ~57 frames/s; with the flat per-pixel path the 30 fps cap was purely client-side. 57 fps costs +5.8 whole-process CPU points vs 30 fps. - get panstats bridge verb: per-SpectrumWidget frame-cost counters (gpuFrameMsPerSec headline, fft build/upload, overlay rebuilds with dirty-cause attribution, waterfall upload volume) for profiler-free before/after proofs. Documented in docs/automation-bridge.md. - AETHER_PAN_NO_NATIVE_WINDOW=1 diagnostic to probe the composited (non-native) QRhiWidget path on macOS — still fails to obtain a QRhi on Qt 6.9 (aethersdr#714 behavior), so the native view stays the default. Live proofs on FLEX-8400M: visual parity grabs (2D trace/fill/heat, band plan, scales), TX key/unkey on ANT2 dummy load with instant RX recovery, two-pan render + close, mode 2D/3D toggle unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ten9876
left a comment
There was a problem hiding this comment.
Reviewed with a multi-angle pass (line-by-line, removed-behavior, cross-file, reuse, efficiency, altitude, conventions, and GPU/QRHI + shader correctness), then verified each candidate against source. Solid, well-built PR — the per-pixel fragment-shader trace (replacing the ~1.4 MB/frame VBO bake) and the always-on RAII panstats are clean wins, the GPU-off #ifdef guards are correct, and the new get panstats verb is documented.
Several scary-looking candidates were checked and refuted, worth recording:
- Present coalescing is correct:
elapsed()is integer-ms soslotMs - sinceMs >= 1(never a 0-timeout), it's single-threaded (nom_presentPendingrace), and the trailing update presents the latest data (no dropped frames). - Empty-spectrum trace doesn't freeze:
fftTracePointCountis a per-frame local reset to 0, the draw is gated on> 0, andbeginPassclears the target each frame. - panstats is race-free: AutomationServer runs on the GUI thread (widget calls use
DirectConnection; only the audio engine usesBlockingQueuedConnection), so the snapshot read can't overlaprenderGpuFrame's write.
Two minor/optional inline nits below. One extra non-blocking note: the paintEvent #else // !AETHER_GPU_SPECTRUM branch (~line 9369) wraps a block in a nested #ifdef AETHER_GPU_SPECTRUM that is unreachable in both build configs — looks pre-existing/copy-paste; worth deleting when convenient.
Nice work — mergeable as-is.
Co-authored-by: Jeremy [KK7GWY] <105957118+ten9876@users.noreply.github.com> Signed-off-by: jensenpat <84298137+jensenpat@users.noreply.github.com>
There was a problem hiding this comment.
Reviewed the full diff and traced the changed paths against main. This is a well-executed, well-measured rendering-path rewrite — the profile-first/prove-after discipline shows, and it lands cleanly. All 6 CI checks (build, check-windows, check-macos, CodeQL, analyze cpp, a11y) are green. A few notes below; nothing I'd consider blocking.
What looks solid
- Per-pixel column path is clean. The old per-frame feather/core/fill vertex bake (three dynamic VBOs, ~1.4 MB/frame) is replaced by a single width×1 column texture + one full-viewport quad.
releaseResources()is updated for every new resource (m_fftScopePipeline/Srb/Ubo/ColTex/ColSampler), no leaks, andm_fftColTexWis reset. The UBO buffer alloc (5*4*sizeof(float)) matches thefloat ubo[20]writer and the shader's 5×vec4Ublock. - R32F→R16F fallback is correct (
qfloat16staging,isTextureFormatSupportedgate), and them_fftColScratchQByteArray is reused per frame and stays alive untilcb->resourceUpdate(batch). nativeWindowPreferred()gating is consistent — bothsetAttribute(WA_NativeWindow)sites (constructor +PanadapterStack::refreshAfterReparent) are gated; the env probe caches via a function-localstatic, so it's read once. Good.- panstats follows the existing
pan/pansconvention: GUI-thread widget walk viafindWidgetsByClass, meta-called throughQ_INVOKABLEsocore/needs no GUI header, pointer-dedup for floated containers. RAII scope timers are tidy and exception-safe. No QSettings / no new flatAppSettingskeys.
Copilot findings
- Half-texel offset in
panscope.frag(ten9876): already addressed — the committed shader (line 68) contains exactly the suggested remap:texX = (v_uv.x * (plot.z - 1.0) + 0.5) / plot.z, so the trace samples texel centers and lines up with the dBm/frequency grid. Resolved; nice catch by the tool and good that it was folded in. - Per-frame
darker(300)+ full UBO re-upload (ten9876): valid, minor.m_fftFillColor.darker(300)(HSV round-trip + QColor alloc) and the 20-floatupdateDynamicBufferrun every frame on the 60 fps hot path even though their inputs only change on a UI edit. Negligible on one pan, measurable across a multi-pan wall (which this PR explicitly targets). Optional follow-up: cache the dark color insetFftFillColor()and gate the UBO update behind a small dirty flag — the per-frame column upload has to stay, but the config block doesn't.
Minor observations (non-blocking)
get panstats … resetbuilds the snapshot twice — once to readpanIndexfor filtering, then again withreset=true(discarded). The comment explains why (the index filter needs the snapshot's ownpanIndex), and it only happens on an explicit reset, so cost is irrelevant; just flagging that a single-pass structure (snapshot once, reset the counters after) would be slightly cleaner if you touch this again.- Present-coalescing trailing update reads correctly: normal mode never drops a frame (a
singleShotbound tothisfires the trailing present at the slot edge), lean mode keeps its hard ~30 Hz cap, and interactive paths still callupdate()directly, so input latency is unaffected. ThesingleShotcapturingthisis safe against widget teardown.
Scope is tight — every file maps to a bullet in the description, and the AETHER_PAN_NO_NATIVE_WINDOW probe is small, documented, and off by default. Thanks for the thorough measurements and the honest "parity-to-slightly-better at matched fps, but width/fps-flat" reading — that framing is exactly right for a structural win. Nice work, @jensenpat.
🤖 aethersdr-agent · cost: $5.7297 · model: claude-opus-4-8
ten9876
left a comment
There was a problem hiding this comment.
Reviewed at high effort — multi-angle (panscope shader + GPU column-texture path, present coalescing, removed-behavior audit of the ~277 deleted vertex-bake lines, and the get panstats verb).
Strong PR, no correctness/safety blockers:
- Removed-behavior audit clean: 3D-skip, software QPainter path, lean, line-width-0, heat-map/solid fill, buildFftDisplayTrace (unchanged consumer), overlay z-order, member cleanup, and the SrcAlpha->One premultiplied blend are all preserved/correct.
- Shader math bounded and correct: texel-center UV mapping (no edge off-by-one), R32F/R16F fallback (no NaN/inf), std140 UBO matches, slope-corrected stroke is capped (the near-vertical 'fill' is the correct stroke, matching the old perpendicular-offset geometry).
- Coalescing correct for its design: trailing update guaranteed (no dropped frames), interactive paths immediate, multi-pan independent.
- Thread-safe: AutomationServer/renderGpuFrame/panstatsSnapshot all run on the GUI thread (bridge isn't moveToThread'd), so no race on the panstats counters.
Refuted several finder candidates on verification (shader singularity, fbDpr/dpr resize race, panstats thread race, lean-cap-on-drag).
Three LOW get-panstats polish items filed as a consolidated follow-up (invalid-selector returns empty instead of erroring; verb missing from the start() banner; lenient 'reset' parsing) — none blocking.
LGTM. Nice, measured optimization.
#3961) ## Release documentation prep for **v26.7.1** (2026-07-02) 29 commits since v26.6.5. Docs-only + version bump — no code behavior change. ### Version bump 26.6.5 → 26.7.1 The three canonical locations (per `AGENTS.md`): - `CMakeLists.txt:2` — `project(AetherSDR VERSION 26.7.1)` → `AETHERSDR_VERSION` (the app's version string) - `README.md` — Current version line - `AGENTS.md` — Current version line ### `CHANGELOG.md` Promoted `[Unreleased]` → **`## [v26.7.1] — 2026-07-02`** in house style (v-prefix, em-dash, "N commits since…" opener + headline), authored from all merged PRs and categorized: - **Added:** 3D stacked-trace spectrum (#3899), 3D dBm scale (#3937), in-process NVIDIA BNR + AFX download-on-demand (#3902), TX meter mouse-over readouts (#3936), SWR manual sweep range (#3885), CW sidetone capture (#3895), KiwiSDR metadata scaffolding (#3898), bridge verbs (#3920) - **Changed:** BNR container/NIM backend removed, 60 fps + per-pixel GPU FFT trace (#3958), FlexLib-sourced model capabilities (#3954/#2177), RX speaker-latency cut (#3897), Display pane sections (#3935) - **Fixed:** #3922, #3926, #3941, #3940, #3942, #3921, #3903, #3892, #3924, #3891, #3890, #3900, #3947 - Fresh empty `[Unreleased]` restored above the release block. Internal/CI/docs/self-fix PRs (#3931/#3916/#3934/#3929) omitted per house style. ### `ROADMAP.md` - Cycle header → "post-v26.7.1" - NVIDIA BNR removed from **In flight** (shipped this cycle) - Five v26.7.1 highlights prepended to **Recently shipped** ### `README.md` - Highlights: GPU spectrum bullet now notes the **per-pixel FFT trace at up to 60 fps** and the **3D stacked-trace** spectrum mode ### Reviewer notes - Touches protected paths (`*.md` + `AGENTS.md` Tier-1 + `CMakeLists.txt`) — needs `@aethersdr/infrastructure` sign-off. - Tagging `v26.7.1` and cutting the release build are **not** part of this PR (docs prep only). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…rry 2D trace on old D3D11 GPUs (#3967). Principle II. Regression from #3958 (per-pixel GPU FFT trace). initSpectrumPipeline preferred R32F whenever isTextureFormatSupported(R32F) returned true, but that predicate only reports create/sample support — NOT linear filterability. On older Windows GPUs (Intel iGPUs, D3D11 feature level <= 10.0) R32_FLOAT is sample-supported but not linearly filterable, so the Linear column sampler silently degrades to point sampling. panscope.frag then slope-corrects stroke width from dFdx(yTrace)/fwidth() of the sampled trace; point-sampled columns step between texels, dFdx spikes on every step, and the perpendicular-distance stroke fattens into the blurred band the reporter sees. Fix: use R16F unconditionally — the only float format QRhi guarantees is linear-filterable on every backend. The stored value is a normalized 0..1 amplitude, for which half-float has ample precision, so this removes the unreliable hardware-parity fork with no shader change and no visible quality loss on hardware where R32F was previously filterable. Also drops the now-dead R32F upload branch in renderGpuFrame. Honors Principle II (radio is authoritative on live state): the panadapter must faithfully mirror the FFT the radio reports across all client hardware, not a GPU-degraded approximation of it. Blast radius: risk_score=0.272, 19 high-risk affected (top: MainWindow::MainWindow, MainWindow::wirePanadapter, MainWindow::buildMenuBar). All are file-level SpectrumWidget callers that construct/wire the widget and are agnostic to the FFT column texture's internal pixel format; the change is confined to the GPU render path and reaches none of them behaviorally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…filt guard The pan fps ceiling was raised to 60 on main (perf(gui) aethersdr#3958), but every constant in the adaptive pipeline is counted in frames and was calibrated at ~25-30 fps. At 60 fps every dwell/hold/confidence window halves in wall-clock time and — worse — the frame-counted send throttle (kSendIntervalFrames=4) allows ~15 filt/s, violating RFC aethersdr#3878 cond. 2 (<= ~8/s). Frames arriving faster than 25 ms apart are now not accepted (native ~30 fps passes untouched; 60 fps is paced to 30 — the calibration rate — and the measurement cost halves as a bonus). The gate sits before any state update, so rejected frames are invisible to every frame counter. glideToward additionally enforces >= 125 ms between filt sends in wall-clock, making the RFC bound independent of any fps assumption. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…u-gated smoothing — Principle XI. (#3975) ## Summary Fixes #3967. Also fixes #3932. Root-caused with per-commit builds (pre-#3836, post-#3836, 26.7.1-main, this fix) captured minutes apart against the same live AM broadcast band on a FLEX-8400M:  ## The regression chain (two releases, two causes) **26.6.5 — #3836's unconditional spatial smoothing ("out of focus", #3932).** `buildFftDisplayTrace`'s 5-tap binomial smooth at a fixed 0.75 blend exists to melt the radio's RBW stair-steps when zoomed in. Applied to every frame at every span, it rounds narrow carriers (visible height loss on AM carriers, panel 2) and defocuses the noise floor. **Fix:** gate the blend on the measured plateau fraction (adjacent equal-pixel bins). Zoomed-in plateau runs (frac ≳0.65) keep the full blend — #3836's stair-step fix is preserved; busy wide spans (frac ≲0.35) get zero — the pre-#3836 crispness returns; the ramp between prevents a visible mode flip while zooming. **26.7.1 — #3958's per-pixel stroke ("spiky above and below the line", the rest of #3967).** The stroke distance was approximated as vertical distance scaled by a `dFdx`-derived slope — i.e. distance to the segment's **infinite line**. On steep segments that line passes near the entire pixel column, so the stroke drew full-height needles above and below the trace (panel 3), and the fixed ±0.75 px smoothstep band read as a soft fat stroke at 1× DPI. **Fix:** true endpoint-clamped distance to the three adjacent polyline segments (4 column samples — matches the coverage the old geometry quads had), plus falloff parity with the old `spectrum.frag`: solid to `halfWidth−1`, 1 px fade, 1 px feather annulus at α 0.22. Also folds in #3968's R16F-unconditional column format as hardening (float32 linear filtering is optional on GLES and `isTextureFormatSupported()` can't detect filterability). Note it could **not** have caused #3967: the reporter's own About screenshot shows `D3D11; Intel(R) HD Graphics 520` — feature level 12.1 hardware where R32F linear filtering is mandatory. With this folded in, #3968 can be closed. Not implicated (left untouched, documented for the record): #3836's `decodeFFT` over-range renormalization only fires on radio/client `y_pixels` mismatch (transient), and the DPR-scaled `x_pixels`/`y_pixels` requests are orthogonal to trace sharpness. ## Why our platforms missed it The #3958 parity checks ran on 2× Retina, where the extra AA softness is half the logical size and the spike needles read as band activity in static grabs. The 4-way same-band comparison above is exactly the test that should have been run: panel 4 (this fix) restores panel 1 (pre-#3836). ## Verification - 4-way live comparison above (same band, same radio pan, same display settings; three grabs per build). - `get panstats 0`: frame prep unchanged at ~121 µs/frame, 58 presents/s, `fftBuildMsPerSec` 0.28 (plateau gate skips the smoothing copy on wide spans). - 3D stacked-trace mode, lean mode, line-width 0, heat/solid fill: unaffected paths re-checked by inspection; the software (non-QRhi-build) QPainter renderer shares `buildFftDisplayTrace` and picks up the plateau gate automatically. ## Also on the #3967 thread (separate follow-ups, not in this PR) - "3D option no longer exists": the 2D/3D combo is the **last row of the Display panel**, below Background — on short screens (768p laptops) it clips off the bottom of the panadapter. Needs a scrollable/compacted Display panel; the option itself ships fine in 26.7.1. - The 60 fps FFT slider ceiling from #3958 is unrelated to this report but worth an explicit release-notes mention. 💻 Generated with Claude Code (claude-fable-5 7/2/26) with architecture by @jensenpat 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
2D panadapter rendering-path optimization, profiled first and proven after with the agent automation bridge (live FLEX-8400M session, Mac Studio M2 Ultra, Retina 2×, 2140×651 pan).
panscope.frag— per-pixel FFT trace. The spectrum trace (gradient/heat-map fill + feather + core stroke) is now evaluated per pixel from a width×1 R32F column texture (R16F fallback where float32 isn't filterable), drawn as one quad. This replaces the per-frame CPU vertex bake — two antialiased triangle-strip line passes plus a 2N fill strip, rebuilt and re-uploaded every frame (~1.4 MB/frame at a 2140 px pan).buildFftDisplayTrace's spatial smoothing + Catmull-Rom interpolation is unchanged, now sampled at exactly one point per device pixel column; stroke width is slope-corrected in the shader via screen-space derivatives so steep peak flanks keep their width, matching the old perpendicular-offset geometry. Cross-platform viaqt_add_shaders(SPIR-V / GLSL / HLSL / MSL).CAMetalLayer nextDrawable(the Detached Waveform making panadapters choppy #3938 mechanism). Data-driven repaints now coalesce into one present per 16 ms slot with a trailing update (no frame is dropped); interactive paths still repaint immediately. Lean mode keeps its ~30 Hz cap.get panstatsbridge verb (get panstats [<panIndex>|<objectName>] [reset]): per-SpectrumWidget frame-cost counters —gpuFrameMsPerSec(headline main-thread budget), FFT build/upload volume, overlay rebuilds with first-cause attribution (smartMtr/detect/other), waterfall upload volume — for profiler-free before/after proofs. Documented indocs/automation-bridge.md.AETHER_PAN_NO_NATIVE_WINDOW=1diagnostic to probe the composited (non-native) QRhiWidget path on macOS. Result: still fails to obtain a QRhi on Qt 6.9 (Fix GPU rendering on macOS: Metal API + WA_NativeWindow for QRhiWidge… #714 behavior — parent surface is raster), so the native NSView stays the default; the probe documents the finding for a future Qt.Measurements
get panstats 0 reset→ 40 s → read; identical 2140×651 pan, 2D, 30 fps, idle RX, interleaved same hour:gpuFrameMsPerSec)fftBuildMsPerSec)Whole-process CPU (cputime delta / 40 s, same session, big window 2400×1300):
Honest reading: at matched 30 fps, whole-process CPU is at parity-to-slightly-better — the pan's own prep was ~18 ms/s of a ~450 ms/s main thread, and the freed time is partly reabsorbed by other animated widgets that were previously starved behind drawable waits (main-thread
sampleshowsrenderGpuFramedropping 243 → 30 samples/10 s while WAVE/S-meter paints rise). The structural wins are: the pan's cost is now width- and fps-flat, ~2× the frame rate costs ~6 CPU points, and two pans at 57 fps present at ~41–44 frames/s each with <6 ms/s prep each. The remaining window-level costs (rasterflushSubWindowblends from the flag/menu widgets over the native pan view, WAVE's QPainter paints) are outside this path — WAVE is addressed by #3955, and the native-window blend tax is documented via the new probe.Screenshots
Per-pixel trace, 2D + heat-map fill, band plan/scales/markers intact (visual parity with the vertex path):
Running at 57 delivered FFT frames/s (slider at 60):
RX recovery immediately after an ANT2 dummy-load TX key/unkey cycle:
TX / regression proofs (agent automation bridge, live radio)
transmitting:true, pan keeps rendering; unkey → instant RX trace + waterfall recovery (grabs above).pan create→ both pans on the GPU path (~41–44 presents/s each, <6 ms/s prep each; coalescer merges 57 FFT + 26 WF events/s correctly) →pan close 1clean.kFftLineFeatherPx, core α 0.90, feather α 0.22).New automation verbs that helped / follow-ups
get panstats(this PR) made every number above reproducible without a profiler.stats windowverb exposing per-window flush/drawable-wait counters (would make the Detached Waveform making panadapters choppy #3938 contention measurable directly), and awavestats-style counter forSMeterWidget/VfoWidgetpaint rates.💻 Generated with Claude Code (claude-fable-5 7/1/26) with architecture by @jensenpat
🤖 Generated with Claude Code