Skip to content

perf(ui): chart sections keep their per-publication series and window maxima between samples, so a frame between samples rebuilds only x (#843) - #2937

Merged
mgradwohl merged 2 commits into
mainfrom
perf/843-chart-series-cache
Oct 25, 2026
Merged

mgradwohl merged 2 commits into
mainfrom
perf/843-chart-series-cache

Conversation

@mgradwohl

Copy link
Copy Markdown
Owner

Description

This is the "Per-frame chart data/vector churn" item of #843, Phase 3.

What was already done. The plan's text is from 2025-09, so I checked the current code first. buildTimeAxis no longer exists: every chart takes its axis from frameTimeAxis(), whose TimeAxisPool reuses its buffers between frames (#1018). The sections read the published history through spans, with no per-frame copies (#1018). Their scratch vectors and labels already live in caches kept across frames (#1171), and reduced points are cached per generation (ReducedPointsCache, #1139). So, measured in Release, no chart-data path allocated per frame before this PR. What still ran every frame was work on data that hadn't changed, plus one allocating formatter.

What this changes. Between samples, each chart now redraws a publication's data from caches; only x is recomputed, since it moves with now:

What ran every frame Now Where
The window maximum behind each axis: maxOfSeriesSince() scanned every visible sample of every series windowSeriesMax(): a VisibleMaxCache per axis (ImGui-ID scoped, so per-disk/per-GPU axes keep their own), keyed on the data generation, the series (address + length), the axis length and the index of the window's first sample. A frame between samples does one binary search. It rescans only for a new generation or when a sample crosses the left edge Network, Disk I/O (aggregate and per disk), Memory peak, GPU clock reference, Overview Power and Resources, every Process Details axis (CPU, Memory, Virtual, Threads/Faults/Handles, Power, I/O, Network, GPU and NPU memory)
The stacked CPU charts' band edges and lines (User/System tops, I/O Wait/busy tops, Total, System) were rebuilt from the kept points Gathered once per choice of points (ReducedValuesStamp, keyed on the ReducedPointsCache rebuild), with the held-to-now value appended (appendHeldValues()). Each frame rebuilds x (gatherReducedTimes()) and appends x = 0 only when the last sample holds (holdTimeToNow()). Drawing x.size() points then draws exactly what holdLastValuesToNow() did Overview CPU (SystemMetricsPanel), Process Details CPU (ProcessDetailsCharts), and the geometry harness's scene that mirrors the Overview
GPU clock, temperature and power were normalised to percent (normalizeToPercent(), O(history) × 3 per GPU) into scratch buffers shared by all GPUs PercentSeriesCache, one per GPU per line, rebuilt for a new generation, history or scale GpuSection
Every time-axis tick label allocated: formatDuration() used std::format, which in MSVC's library reserves past the short-string buffer even for "5m" Format::Detail::formatShort() formats into a 32-byte local buffer, so the string fits SSO UI/Format.h (all callers of formatDuration())

Already fine, left unchanged: CpuCoresSection's per-core data is drawn through plotLineWithFill(), which caches its own reduction. Its per-frame work is the shared pooled axis and the static grid/name/kind caches. StorageSection's per-disk frames already reuse static buffers (#1171).

No behaviour change. Every cache returns exactly what the per-frame computation returned: the same values, reduced points, hover and tooltips. The cached window max is a pure function of its key. The stack's y values depend only on the points and data that the ReducedPointsCache key names. The percent series depend on their key. The tests below pin that equivalence.

Part of #843

PR Size

Large: 21 files, +1,039 / −193, of which about 500 lines are tests. The source diff is mostly the single-line call-site swaps (maxOfSeriesSince → windowSeriesMax). It also gives each Process Details axis helper a "from the window's max" overload; the span form delegates to it, and the existing tests still use the span form.

Type of Change

  • Bug fix (non-breaking change that fixes an issue). This is a performance fix with no behaviour change, the template's closest box.
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Build/CI improvement

Checklist

  • I have read the CONTRIBUTING guidelines
  • My code follows the project's coding standards
  • I have run clang-tidy and addressed any warnings
  • I have run clang-format on my changes
  • I have run pre-commit run --all-files or installed pre-commit hooks (pre-commit run --files <touched>: all passed)
  • I have added tests that prove my fix/feature works
  • All new and existing tests pass
  • I have updated documentation as needed (header docs; the frame-keyed cache list in ChartWidgets.h names visibleMaxCache())

Testing

cmake --build --preset win-debug -j 2
ctest --preset win-debug -j 2          # 100% of 688 passed
.\tools\clang-tidy.ps1 -Files <the 6 touched panel TUs + 6 test TUs>
pre-commit run --files <touched>

New tests. Each cache gets a "a second frame with the same version doesn't rebuild, a new version does" test, and each one-to-one replacement gets an equivalence test:

  • test_RateAxis.cpp, VisibleMaxCacheTest:
    • the same window replays the max, and a new generation, a sample leaving the window, or other series under the same generation rescan;
    • generation 0 is never kept;
    • across 1,200 sliding frames the cached max equals maxOfSeriesSince() every frame, with exactly five scans (the first, then one per left-edge crossing).
  • test_ChartFrameCaches.cpp (new), in headless ImGui frames:
    • windowSeriesMax() scans once per generation;
    • axes in different PushID scopes keep their own max;
    • the Overview stacked scene gathers its band edges once per generation, and its reduction and gather counts stay put across frames between samples.
  • test_ChartWidgets.cpp:
    • ReducedValuesStamp is stale only after a points rebuild (new generation or budget), and every frame at generation 0;
    • appendHeldValues() + holdTimeToNow() draw exactly what holdLastValuesToNow() did, for held, too-old and at-now last samples, NaN gaps included;
    • gatherReducedTimes() is covered;
    • the buildUserSystemStack tests run on the split API.
  • test_GpuSeriesScale.cpp, GpuPercentSeriesTest: no rebuild for the same history and scale; rebuilds for a new generation, scale or history; generation 0 rebuilds every call; values equal normalizeToPercent(), gaps included.
  • test_GpuSectionRender.cpp: three frames of one GPU scale clock, temperature and power once each, and a new generation scales them once more.
  • test_Format.cpp: formatShort() matches std::format and cuts at its buffer; the longest duration (the 1e15 s cap) comes out whole.

One launch (TASKSMACK_SYNTHETIC, a scratch TASKSMACK_CONFIG_DIR, TASKSMACK_SCREENSHOT=...;after=65; it quit by itself, exit 0). In the Overview, the CPU bands and lines reach "now", and every axis and its bars agree.

Measurements

#2926 (TASKSMACK_RENDER_METRICS) is still open, so I measured with a headless harness instead of the overlay CSV. It's a scratch program, not committed. It renders the real sections through their real render functions:

  • Network and I/O tab: Total + eth0 lines and a 4-disk grid;
  • GPU tab: one GPU, every sensor;
  • Overview Memory;
  • Process Details Overview;
  • Overview stacked CPU, via ChartGeometryScenes.h, which this PR keeps in step with the panel.

It runs 60 publications × 60 frames (a 60 s run at 1 s refresh, 60 fps) after a warm-up publication. A global operator new hook counts allocations, and each frame is timed whole (NewFrame → Render). Two history sizes: 60 samples (a 60 s window at 1 s), and 1,800 (a 30-minute window at 1 s). The build is win-release (clang 23, -O3, ThinLTO), base 012b6f2 against this branch. The runs are interleaved, 4 each, and the table gives medians.

Debug numbers aren't used: there, every container construction allocates an MSVC debug-iterator proxy.

Scene Samples Allocations / frame (before → after) Bytes / frame (before → after) CPU µs / frame, median (before → after)
Network + 4-disk grid 60 5.08 → 0.08 166 → 6 55.3 → 60.2 (noise, see below)
Network + 4-disk grid 1,800 30.08 → 0.08 966 → 6 149.6 → 135.9
GPU 60 4.08 → 2.08 132 → 68 29.4 → 30.5
GPU 1,800 14.08 → 2.08 452 → 68 77.5 → 79.6
Memory 60 3.00 → 2.00 96 → 64 13.4 → 13.0
Memory 1,800 8.00 → 2.00 256 → 64 33.7 → 31.2
Process Details Overview 60 5.00 → 2.00 160 → 64 48.2 → 46.8
Process Details Overview 1,800 20.00 → 2.00 640 → 64 107.4 → 84.8
Overview stacked CPU 60 0 → 0 0 → 0 13.9 → 14.4
Overview stacked CPU 1,800 5.00 → 0 192 → 0 33.8 → 28.8
  • Allocations are deterministic. The 0.08 left in Network is the publication frames' own allocations (5 per 60 frames).
  • CPU timings are noisy. The host is shared, and these runs had other builds and a clangd at 11 GB alongside them, so per-run spreads are ±10–20 %. The 60-sample rows are within that noise both ways. At 1,800 samples Process Details Overview (−21 %), Network (−9 %) and the stacked CPU (−15 %) improve consistently.
  • Earlier, quieter pair of runs (same harness; the code already had the caches but not the formatDuration() change): Process Details Overview 88.2 → 72.9 µs, Network 128.9 → 116.7 µs, every allocation count unchanged. So the CPU win comes from the caches, and the allocation win from the tick labels.

clang-tidy

.\tools\clang-tidy.ps1 -Files ... on host LLVM 23. It found three issues in this change, now fixed:

  • misc-include-cleaner: UI/RateAxis.h in MemorySection.cpp is no longer used directly;
  • an unused <cstdint> in test_GpuSeriesScale.cpp;
  • a missing <cstddef> in test_RateAxis.cpp.

What's left is LLVM-23-only or in code this PR doesn't touch: readability-trailing-comma, misc-const-correctness on lambdas, readability-redundant-lambda-parameter-list, bugprone-signed-bitwise on ImGui flag enums, and existing test-file findings.

Additional Notes

Found but not changed:

  • The remaining 2 allocations per frame in GPU, Memory and Process Details are NowBar/value-strip texts longer than the short-string buffer, such as "118.6 GiB / 184.0 GiB (64%)". They're display text that changes with the smoothed values, not chart data. UI::InlineText ([PERF] Remaining per-frame heap allocations on chart and panel paths #1171) would remove them.
  • m_ChartDataGeneration in SystemMetricsPanel is one generation for every source (system, storage, GPU, process histories). A storage or GPU publication therefore invalidates the CPU and memory charts' caches too, about 3–4 times a second at 1 s refresh instead of once. That is still far from every frame. A generation per source would cut it further.
  • plotLineWithFill() still gathers y from its kept points every frame, at most the plot's point budget per series. Keeping the gathered y in ReducedPointsCache would remove that too, but it's the generic hot path for every series, so I left it for a measured change of its own.
  • holdLastValuesToNow() has no production caller left. I kept it as the tested reference semantics for appendHeldValues() + holdTimeToNow().

🤖 Generated with Claude Code

… maxima between samples (#843)

Between samples every history chart redraws the same publication each
frame; only x moves with now. What still ran every frame for data that
had not changed:

- The window maxima behind every rate/percent axis (maxOfSeriesSince(),
  a scan of every visible sample of every series): now
  windowSeriesMax(), a VisibleMaxCache per axis keyed on the data
  generation, the series, the axis length and the window's first
  sample, so a frame between samples pays a binary search. Network,
  Disk I/O (aggregate and per disk), Memory peak, GPU clock reference,
  Overview power/resources and every Process Details axis.
- The stacked CPU charts' band edges and lines (Overview, Process
  Details): gathered once per choice of points (ReducedValuesStamp),
  with the held-to-now value appended; each frame rebuilds only x.
- The GPU clock/temperature/power lines scaled to percent: a
  PercentSeriesCache per GPU, rebuilt for a new generation or scale.
- The time axis's tick labels: formatDuration() built them with
  std::format, which in MSVC's library reserves past the short-string
  buffer, so every tick of every chart allocated every frame. It now
  formats into a local buffer.

Values, reduced points, hover and tooltips are unchanged: each cache
returns exactly what the per-frame computation did, and tests pin that.

Part of #843.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mgradwohl mgradwohl added this to the v0.10.0 milestone Oct 25, 2026
@mgradwohl mgradwohl added performance ui UI / rendering issues agent:Windows Owned by the Windows Claude session (bug-fix conveyor) labels Oct 25, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

…charts' values came from, and their draw count never passes a series' end (#843)

Review follow-up on the stacked CPU charts' kept values.

ReducedValuesStamp compared the ReducedPointsCache's rebuild count, which
starts over at 0 when the cache is reset or replaced (or a frame-keyed
entry is pruned and made again). After as many rebuilds the count
matched, so the old values passed for the new points' while x was
rebuilt from them, and drawing x.size() points could read past the end
of a band edge. Each rebuild now takes a process-unique pointsId(),
which the stamp compares. As a second guard, the Overview, Process
Details and harness stacks draw drawablePointCount() points: x's length,
or a shorter series' length should one ever be shorter.

Tests: a cache reset between frames regathers (unit and in the stacked
scene); drawablePointCount() never passes the shortest series. Also a
render test pinning every Process Details axis (Overview, Network and
I/O, GPU, NPU) to the bound its helper's series form gives, run once per
history in which each otherwise dominated series (User, System, Shared,
Private, Swap, GDI Objects, I/O Read, Net Sent, NPU Dedicated) is the
largest, so a series dropped from an axis fails it.

Part of #843.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mgradwohl
mgradwohl enabled auto-merge (squash) October 25, 2026 15:37
@mgradwohl
mgradwohl merged commit 87835be into main Oct 25, 2026
24 checks passed
@mgradwohl
mgradwohl deleted the perf/843-chart-series-cache branch October 25, 2026 16:12
mgradwohl added a commit that referenced this pull request Oct 27, 2026
…Widgets.h (#3116) (#3134)

reduceSeriesKeepingGaps, reduceSeriesMinMax, reduceAlignedSeries and
holdLastValuesToNow, and writeReducedPoint behind the first two, had no
caller under src/, benchmarks/ or the fuzzers: only tests used them. They
move unchanged to tests/UI/ChartReferenceHelpers.h, in
UI::Widgets::TestReference, so test_ChartWidgets.cpp keeps its peak, gap,
alignment and hold-to-now tests and #2937's check of appendHeldValues +
holdTimeToNow against holdLastValuesToNow.

The two test_ProcessDetailsHistory.cpp tests that held a series to now
now use the charts' own appendHeldValues + holdTimeToNow, with the same
assertions. The descriptions the removed wrappers carried move to the
production primitives (forEachStrideReducedPoint,
forEachAlignedReducedPoint, holdTimeToNow, appendHeldValues), and the
comments that pointed at the removed names point at those. No include
of ChartWidgets.h was used only by the moved helpers. No production
behaviour changes.

Fixes #3116

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent:Windows Owned by the Windows Claude session (bug-fix conveyor) performance testing ui UI / rendering issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant