Skip to content

perf(chart): redraw per display frame and cache the plot - #117

Merged
mrcsin merged 1 commit into
masterfrom
chart-render-headroom
Oct 9, 2026
Merged

mrcsin merged 1 commit into
masterfrom
chart-render-headroom

Conversation

@mrcsin

@mrcsin mrcsin commented Oct 9, 2026

Copy link
Copy Markdown
Member

Problem

A drag repainted the chart at about 30 frames per second and late (plot frame spacing p50 33.4 ms, pan step to
the next frame p50 24.4 ms, 2026-10-08, 8 pens), a hover over a still plot re-rendered it about 52 times per
second, and every hidden pen axis regenerated its ticks twice per frame. No thread was saturated; the costs that
scale with the pen count left little headroom for a 50-pen catalogue.

Change

  • Frame-paced redraw. The view model raises RedrawRequested at once; the view coalesces requests into one
    TopLevel.RequestAnimationFrame, whose callback calls PlotControl.InvalidateVisual(). ChartRedrawSchedule
    and its 33 ms timer are deleted; RequestRedraw is UI-thread only, enforced by Avalonia.
  • Cached plot layer. PlotControl sits alone inside the PlotLayer decorator, which carries a
    BitmapCache; the overlay repaints over the cached raster. The cache is on the wrapper because Avalonia 12 never
    refreshes a BitmapCache set on a self-drawing visual such as AvaPlot (charting.md#hover-and-the-plot-cache).
  • Ticks for drawn axes only. LinearTickGenerator and LogTickGenerator generate nothing while hidden and keep
    their last two results (TickCache); ChartAxisBinder flips IsDrawn and IsVisible together under
    Plot.Sync, only on a change. With the active pen switched off the plot shows no horizontal gridlines.
  • Debouncer. Admission runs on the data scheduler after the Sample/Throttle gates; Dispose completes the
    admission subject instead of disposing it.
  • Data-area width is posted only when it changes.
  • scripts/perf/trace-shares.py reports frame spacing, pan latency, compositor rate and tick cost, with
    --skip-seconds; testing-strategy.md#frame-cost states the stand checks, including the follow capture that
    catches a plot that stops redrawing.

Verification

  • dotnet test SemiPlot.slnx: unit 1843, integration 147; dotnet format --verify-no-changes clean.
  • Demo stand, Release, 8 pens, 2026-10-09: drag frame spacing p50 17.8 ms; follow mode without input one render
    per data tick; hover over a still plot about one render per second; theme switch repaints without a resize.
Per-task commits before collapse
8019cae perf(chart): cache the plot through a wrapper layer
b9c7f20 fix(chart): drop the bitmap cache that froze the plot
4f742d0 docs(plans): record the render headroom run
1035a14 refactor(chart): trim comments the docs already hold
d036710 refactor(chart): address smells review findings
c62d777 fix(chart): address review findings
25e2b7f docs(plans): close the render headroom tasks
cd20c20 docs(plans): record the render headroom checks
bc6db78 perf(chart): report the data-area width on change
c970c68 perf(chart): admit history requests past the gates
3b13199 perf(chart): publish no ticks from a hidden axis
b4a0443 perf(chart): generate ticks for drawn axes only
39e16c7 perf(chart): bitmap-cache the plot visual
ce36c10 perf(chart): pace the redraw by the display frame
9c4e925 chore(perf): measure frame cadence in trace-shares
4f601b6 docs(plans): add chart render headroom plan

A drag repainted the chart at about 30 frames per second and late (plot
frame spacing p50 33.4 ms, pan step to the next frame p50 24.4 ms), a
hover over a still plot re-rendered it about 52 times per second, and
every hidden pen axis regenerated its ticks twice per frame. No thread
was saturated; the costs that scale with the pen count left little
headroom for a 50-pen catalogue.

The redraw is paced by the display: the view model raises its redraw
signal at once and the view coalesces requests into one
TopLevel.RequestAnimationFrame, whose callback invalidates the plot.
The 33 ms timer and ChartRedrawSchedule are gone; RequestRedraw is UI
thread only, which Avalonia enforces.

The plot sits alone inside a decorator that carries a BitmapCache, so a
pointer move repaints the overlay over a cached raster and ScottPlot
renders only when the plot changes. The cache is on the wrapper because
Avalonia 12 never refreshes a BitmapCache set on a self-drawing visual
such as AvaPlot.

LinearTickGenerator and LogTickGenerator generate nothing while their
axis is hidden and keep their last two results, since ScottPlot asks for
each axis at two lengths per frame; ChartAxisBinder flips IsDrawn and
IsVisible together under Plot.Sync, and only when they change. The
history debouncer admits a request on the data scheduler after the
Sample and Throttle gates, so a UI-thread request never waits on a
starting query, and its Dispose completes rather than disposes the
admission subject. The chart posts its data-area width only when it
changes. trace-shares.py reports frame spacing, pan latency, compositor
rate and tick cost, after an optional warm-up skip.

Measured on the demo stand at 8 pens: drag frame spacing p50 17.8 ms,
hover about one render per second, follow mode one render per data
tick.
@mrcsin
mrcsin marked this pull request as ready for review October 9, 2026 09:49
@mrcsin
mrcsin merged commit 53ee950 into master Oct 9, 2026
2 checks passed
@mrcsin
mrcsin deleted the chart-render-headroom branch October 9, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant