Skip to content

perf(spectrum): per-row waterfall-history frequency frames (no per-pan reproject)#3578

Merged
NF0T merged 1 commit into
aethersdr:mainfrom
svabi79:perf/per-row-history-frames
Jun 14, 2026
Merged

perf(spectrum): per-row waterfall-history frequency frames (no per-pan reproject)#3578
NF0T merged 1 commit into
aethersdr:mainfrom
svabi79:perf/per-row-history-frames

Conversation

@svabi79

@svabi79 svabi79 commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

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 (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 array 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.

Constitution principle honored

Principle XI — Fixes Are Demonstrated. Root cause and fix were both confirmed by instrumentation and the built-in aether.perf telemetry (before/after below), not by code-reading alone.

Measured (ultrawide 5144×1440, aether.perf)

metric before after
uiLagMaxMs — continuous scroll 110–164 ms 31–42 ms
uiLagMaxMs — short-scroll / stop windows (108 ms per step) 20–33 ms (no hitch)
panAgeP95 / wfAgeP95 120–155 ms 24–30 ms
renderP95 (GPU) 6–8 ms 6–7 ms (unchanged)

Instrumented split confirmed the cause: histMs avg 108 ms/step vs visMs 3.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

  • Local build passes — Windows, Qt 6.8.3 msvc2022_64, VS 2022; GPU path enabled; no errors.
  • Before/after measured with QT_LOGGING_RULES=aether.perf.debug=true (table above), two runs each.
  • Time-scrollback manually verified frequency-correct and responsive.
  • Existing tests pass (CI) — pending.

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/rebuildWaterfallViewport are plain Qt QImage) — benefits Windows/macOS/Linux. Touches the render hot path in src/gui/SpectrumWidget.* (not a maintainer-only CODEOWNERS path); flagging for review regardless.

Checklist

  • Commits are signed (SSH, verified)
  • No new flat-key AppSettings calls (N/A)
  • All meter UI uses MeterSmoother (N/A)
  • Documentation updated if user-visible behavior changed (N/A — live view visually unchanged)
  • Security-sensitive changes reference a GHSA if applicable (N/A)

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>

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 only reprojectWaterfall, which now touches just the visible waterfall. The new per-row remapHistoryRowInto runs exclusively inside rebuildWaterfallViewport, reached from scrollback/resize/live-toggle paths — never from a frequency pan.
  • History pixels are consumed for display only in rebuildWaterfallViewport (the other m_waterfallHistory reads 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 remapHistoryRowInto falls into the memcpy branch — the resample only engages when a row was genuinely captured at a different center/bandwidth. Nice.
  • Vectors stay consistent. The width-change scaled() branch in ensureWaterfallHistory leaves the per-row vectors intact (height unchanged, frames are frequency-domain so still valid), and the haveFrames size check plus the rowBwMhz <= 0.0 guard 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 NF0T left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to memcpy when rowBwMhz ≤ 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) in reprojectWaterfallImage(). Out-of-bounds columns black-filled; srcX < 0.0 guard covers the negative edge before truncation.
  • haveFrames guard in rebuildWaterfallViewport() is good defensive programming — if the parallel arrays ever don't match m_waterfallHistory.height(), the fallback passes current frame to both sides of remapHistoryRowInto(), triggering memcpy rather than a bad remap.
  • Lifecycle coverage is complete. Arrays allocated alongside m_wfHistoryTimestamps in ensureWaterfallHistory(), stamped in appendHistoryRow(), cleared in clearDisplay() — all three sites consistent.
  • reprojectWaterfallImage() extraction is a faithful refactor of the old reprojectImage lambda; logic is identical, the image.isNull() guard is promoted to the function top, and reprojectWaterfall() correctly leaves m_waterfallHistory untouched with a comment explaining why.

Two non-blocking notes for awareness:

  • newStartMhz is computed early in reprojectWaterfallImage() but overlapStartMhz re-derives it inline as newCenterMhz - newBandwidthMhz / 2.0 rather than using the named variable — newStartMhz is then used correctly on the dstLeft/dstRight lines below. Cosmetically inconsistent, not a bug.
  • static_cast<int>(sizeof(QRgb)) in the memcpy call is harmless (sizeof is never negative) but slightly unusual; pre-existing style carried from the old implementation.

6/6 CI green. Approved.

@NF0T NF0T merged commit 1c489a8 into aethersdr:main Jun 14, 2026
6 checks passed
ten9876 pushed a commit that referenced this pull request Jun 19, 2026
## 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>
ten9876 pushed a commit that referenced this pull request Jun 21, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants