perf(spectrum): per-row waterfall-history frequency frames (no per-pan reproject)#3578
Conversation
Horizontal frequency scrolling was choppy on wide displays because the full waterfall *history* image was reprojected on the CPU on every pan step. At ultrawide widths that image is ~0.5 GB (up to 24k rows), so the drawImage cost was ~108 ms/step (measured) vs ~3 ms for the visible waterfall — blowing the 16 ms frame budget ~7x. The GPU render path was never the bottleneck (renderP95 6-8 ms throughout). The history image is only consumed by time-scrollback (rebuildWaterfallViewport), never by the live view. So instead of keeping the whole image aligned to the current frequency frame on every pan, each history row now records the center/bandwidth it was captured at (parallel to m_wfHistoryTimestamps). The visible waterfall is still reprojected immediately (cheap); the history image is never globally reprojected. rebuildWaterfallViewport remaps only the ~700 visible rows from their own frame to the current one, on time-scrollback. Measured (ultrawide 5144x1440, aether.perf telemetry): - frequency-scroll uiLagMax 110-164 ms -> ~32 ms across ALL windows including short-scroll/stop windows (no hitch); pan/wf frame-age p95 120-155 -> 24-30 ms. - GPU renderP95 unchanged 6-7 ms; time-scrollback verified frequency-correct. Platform-independent (plain Qt QImage path) — benefits Windows/macOS/Linux. Fixes aethersdr#3575 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for this, @svabi79 — well-diagnosed and cleanly executed. The instrumentation-first approach (Principle XI) and the before/after aether.perf table make the root cause and fix easy to trust, and the per-row-frame design is genuinely more correct than the old global-reproject-on-every-pan, not just faster.
I traced the central claim through the code and it holds:
- Cost is off the pan hot path. The pan drag (
SpectrumWidget.cpp:4485) and zoom drag (:4435) call onlyreprojectWaterfall, which now touches just the visible waterfall. The new per-rowremapHistoryRowIntoruns exclusively insiderebuildWaterfallViewport, reached from scrollback/resize/live-toggle paths — never from a frequency pan. - History pixels are consumed for display only in
rebuildWaterfallViewport(the otherm_waterfallHistoryreads are index/timestamp math), so dropping the global history reprojection is safe. - Fast path preserved on the common case. When you scroll back in time without having panned, every row's frame equals the current frame, so
remapHistoryRowIntofalls into thememcpybranch — the resample only engages when a row was genuinely captured at a different center/bandwidth. Nice. - Vectors stay consistent. The width-change
scaled()branch inensureWaterfallHistoryleaves the per-row vectors intact (height unchanged, frames are frequency-domain so still valid), and thehaveFramessize check plus therowBwMhz <= 0.0guard cover the degenerate/uninitialized cases.
Conventions and portability are fine — no QSettings/flat-key issues, RAII intact, and the extracted free functions only use QPainter/QImage/qRgb/std::memcpy already present in the TU, so no Qt-version or transitive-include risk on Linux CI. All six checks are green.
One non-blocking note: remapHistoryRowInto uses nearest-neighbour gather, consistent with the visible reproject's SmoothPixmapTransform(false), so on zoom-out scrollback older history loses some columns — but that's an inherent, acceptable trade for the perf win and matches the live path's behaviour, so no change requested.
Looks good to me. 👍
🤖 aethersdr-agent · cost: $2.6849 · model: claude-opus-4-8
NF0T
left a comment
There was a problem hiding this comment.
Well-diagnosed and cleanly executed. The instrumented root-cause split (histMs avg 108 ms vs visMs 3.3 ms) makes the fix easy to trust, and the per-row-frame design is more correct than the old global-reproject-on-every-pan, not just faster — rows captured at different centers during a fast scroll are each placed by their own frame.
Traced the implementation through:
- Fast path is correct.
remapHistoryRowInto()short-circuits tomemcpywhenrowBwMhz ≤ 0.0(unwritten/cleared rows) or when both frame components match the current frame (bit-identical doubles from the same member fields — valid here). If any FP epsilon difference sneaks in, the cost is one extra nearest-neighbour remap, not a wrong result. - Remap path is correct. Nearest-neighbour gather, consistent with
SmoothPixmapTransform(false)inreprojectWaterfallImage(). Out-of-bounds columns black-filled;srcX < 0.0guard covers the negative edge before truncation. haveFramesguard inrebuildWaterfallViewport()is good defensive programming — if the parallel arrays ever don't matchm_waterfallHistory.height(), the fallback passes current frame to both sides ofremapHistoryRowInto(), triggering memcpy rather than a bad remap.- Lifecycle coverage is complete. Arrays allocated alongside
m_wfHistoryTimestampsinensureWaterfallHistory(), stamped inappendHistoryRow(), cleared inclearDisplay()— all three sites consistent. reprojectWaterfallImage()extraction is a faithful refactor of the oldreprojectImagelambda; logic is identical, theimage.isNull()guard is promoted to the function top, andreprojectWaterfall()correctly leavesm_waterfallHistoryuntouched with a comment explaining why.
Two non-blocking notes for awareness:
newStartMhzis computed early inreprojectWaterfallImage()butoverlapStartMhzre-derives it inline asnewCenterMhz - newBandwidthMhz / 2.0rather than using the named variable —newStartMhzis then used correctly on thedstLeft/dstRightlines below. Cosmetically inconsistent, not a bug.static_cast<int>(sizeof(QRgb))in thememcpycall is harmless (sizeofis never negative) but slightly unusual; pre-existing style carried from the old implementation.
6/6 CI green. Approved.
## Summary Continuous edge auto-pan while dragging a slice across the panadapter, so you can sweep the whole band in one gesture without zooming in first. Fixes #3580. Replaces the edge-follow *position* controller (`revealFrequencyIfNeeded`) — whose nudge is bounded by the cursor's self-limiting overshoot at the frame border (~0.1× span/s, the "rubber band" creep) — with a *velocity* controller: while the cursor sits in the edge zone a ~30 Hz timer pans at a speed that scales with edge depth and ramps with hold time (~1.2× span/s at full depth+ramp), parking the slice just inside the leading edge so it stays visible while the band scrolls under it. Pan+tune go through a new `edgePanTuneRequested` signal that bypasses pan-follow so the two controllers can't fight; reveal is removed from the drag path entirely (in-window moves tune-only). This also removes a one-sided stutter/jump-back caused by the flag-extended trigger (#2761) firing asymmetrically just inside the edge on the flag side. ##⚠️ Maintainer decision needed (UX) This is a UX behaviour change — per AGENTS.md not an autonomous change. The **velocity defaults** (top speed / ramp / interval) are a feel decision and need your sign-off. They're exposed as env overrides so you can tune them live without rebuilding: - `AETHER_DRAG_EDGEPAN_VMAX` — top speed, % of span per second (default 120) - `AETHER_DRAG_EDGEPAN_RAMP` — ms to ramp to top speed (default 600) - `AETHER_DRAG_EDGEPAN_INTERVAL` — timer interval ms (default 33) - `AETHER_NO_DRAG_EDGEPAN=1` — restore legacy reveal-only behaviour (A/B) ## Test plan / evidence - Built on Windows (Qt 6.8.3, GPU QRhi path). Validated by drag-to-edge in both directions on a live FLEX. - `aether.perf` `SliceDrag` telemetry: legacy ≈ 0.0027 MHz/tick regardless of how hard you push (~0.1× span/s); fixed = 0.00182 MHz/tick at full depth+ramp (1.2× span/s), center monotonic, no jump-back, symmetric L/R, slice parked at the 5% boundary. - `AETHER_NO_DRAG_EDGEPAN=1` reproduces the old behaviour on the same build for a direct A/B comparison. ## Scope Independent of #3578 (waterfall perf) and deliberately kept separate from #3444 (mouseMoveEvent overload). Principle XI. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng (#3700) (#3701) Closes #3700. #3668 introduced an unconditional native+kiwi double reproject on every pan step (no KiwiSDR guard), whose stream-state save/restore left m_waterfall COW-shared — forcing a full waterfall-image deep-copy per pan step (60-80 ms UI stall on wide/high-DPI displays), re-introducing the cost #3578 had removed. Reproject only the active stream; remap the other stream to the current frame when it next becomes visible (exact — each history row carries its own capture frame). AETHER_WF_KIWI_ALWAYS=1 restores the old double pass for A/B. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
Fixes #3575. Horizontal frequency scrolling was choppy on wide displays because
reprojectWaterfall()reprojected the full waterfall history image (up to ~0.5 GB / 24k rows at ultrawide widths) on the CPU on every pan step — ~108 ms each (measured) vs ~3 ms for the visible waterfall, blowing the 16 ms frame budget ~7×. The GPU render path was never the bottleneck (renderP956–8 ms throughout).The history image is only consumed by time-scrollback (
rebuildWaterfallViewport), never by the live view. So instead of keeping the whole image aligned to the current frequency frame on every pan, each history row now records the center/bandwidth it was captured at (parallel array tom_wfHistoryTimestamps). The visible waterfall is still reprojected immediately (cheap); the history image is never globally reprojected.rebuildWaterfallViewportremaps only the ~700 visible rows from their own frame to the current one, on time-scrollback.Constitution principle honored
Principle XI — Fixes Are Demonstrated. Root cause and fix were both confirmed by instrumentation and the built-in
aether.perftelemetry (before/after below), not by code-reading alone.Measured (ultrawide 5144×1440,
aether.perf)uiLagMaxMs— continuous scrolluiLagMaxMs— short-scroll / stop windowspanAgeP95/wfAgeP95renderP95(GPU)Instrumented split confirmed the cause:
histMsavg 108 ms/step vsvisMs3.3 ms. Time-scrollback verified frequency-correct (a row captured at center A renders at the correct position after tuning to center B).Test plan
QT_LOGGING_RULES=aether.perf.debug=true(table above), two runs each.History / supersedes
This supersedes #3576 (a defer-and-coalesce intermediate that left a ~108 ms gesture-end hitch — see #3577) and resolves #3577 in the same change: the per-row approach removes the need for the debounce/append-pause/flush machinery entirely (net deletes that code) and is strictly more correct — rows captured at different centers during a fast scroll are each placed by their own frame. #3576 was never merged, so this lands the final solution directly.
Notes
Platform-independent (
reprojectWaterfall/rebuildWaterfallViewportare plain QtQImage) — benefits Windows/macOS/Linux. Touches the render hot path insrc/gui/SpectrumWidget.*(not a maintainer-only CODEOWNERS path); flagging for review regardless.Checklist
AppSettingscalls (N/A)MeterSmoother(N/A)