Skip to content

perf(refresh): memoize the two steps that dominated every pass - #6

Merged
Gnonymous merged 2 commits into
mainfrom
perf/refresh-pass
Aug 1, 2026
Merged

perf(refresh): memoize the two steps that dominated every pass#6
Gnonymous merged 2 commits into
mainfrom
perf/refresh-pass

Conversation

@Gnonymous

Copy link
Copy Markdown
Owner

Where the pass actually went

With the animation fixed in v1.1.4, the 30-second refresh became the only CPU event left. Profiling one pass (3179 main-thread samples, ~2 s):

Step Time Share Location
GitCorrelator.buildRanges 951 ms 30% GitCorrelator.swift:99
Scanner.init → plist decode 745 ms 23% Scanner.swift:119
CodexScanner.scan 93 ms 3%

Log parsing was not on the list. The mtime+size cache already skips unchanged files, so the incremental-parse idea this task started from would have optimised something that costs nothing. These two steps are the pass.

Fix

Scanner — cache state moves from per-instance to process-wide. Aggregator.run() builds a fresh Scanner every pass, and the on-disk cache is 10 MB of binary plist here, so it was decoded every 30 seconds to reconstruct a dictionary already sitting in memory. Now read once per process, still written back on change so a cold launch starts warm. A lock spans each scan() — passes are already serialized by UsageStore's coalescing guard, so it is uncontended and only makes overlapping passes wait instead of race.

GitCorrelatorbuildRanges memoized for 120 s, keyed on the candidate cwds and on the day being reported. The outputs bucket against midnight, so an entry computed yesterday would be wrong today even inside the TTL. Thirty days of git log --numstat per repo cannot meaningfully change between 30-second passes.

Trade-off: a new commit can take up to two minutes to appear in the panel. That is the only visible change.

Measured

Release build, same machine and workload, CPU-time delta (not ps pcpu, which is a decaying average and reads 0 between spikes):

CPU
v1.1.3 ~60% — animation, sustained
v1.1.4 18.7% — animation fixed, refresh pass exposed
v1.1.5 3.2%

60 s window: 1.94 s consumed. 120 s window: 3.85 s — same rate, so it holds across a git recompute.

Tests

Both wins are silent if they regress — the numbers stay correct, the app just burns a core again. PerfCounters exposes the two counters so --self-test can assert reuse (the CLT-only self-test cannot reach the internal types, and XCTest needs a full Xcode toolchain).

  • --self-test: 27/27 pass, including 3 new assertions
  • Red-green was actually run on all three: they fail with the counters added but the memoization absent, pass with it
  • XCTest additions cover the same ground with a real git repo (CI runs these; not runnable locally without Xcode)
  • Verified live: scan cache still rewrites within seconds of a log append, git today +607 −56 (10 commits) reflects commits made minutes earlier

🤖 Generated with Claude Code

Gnonymous and others added 2 commits August 2, 2026 01:47
With the menu-bar animation fixed in v1.1.4, the refresh pass itself became the
only CPU event — and profiling one showed it was mostly rebuilding state that
had not changed. Of a ~2 s pass (3179 main-thread samples):

  GitCorrelator.buildRanges     951 ms   30%   GitCorrelator.swift:99
  Scanner.init → plist decode   745 ms   23%   Scanner.swift:119
  CodexScanner.scan              93 ms    3%

Log parsing was not on the list. The mtime+size cache already skips unchanged
files, so the incremental-parse idea this started from would have optimised
something that costs nothing; these two are the actual pass.

Scanner: cache state is now process-wide instead of per-instance. Aggregator.run()
builds a fresh Scanner every pass, and the on-disk cache is 10 MB of binary plist
here, so it was decoded every 30 seconds to reconstruct a dictionary that was
already in memory. It is now read once per process and still written back on
change, so a fresh launch starts warm. A lock spans each scan(); passes are
already serialized by UsageStore's coalescing guard, so it is uncontended and
just makes overlapping passes wait rather than race.

GitCorrelator: buildRanges is memoized for 120 s, keyed on the candidate cwds and
on the day being reported — the outputs bucket against midnight, so an entry
computed yesterday is wrong today even inside the TTL. Thirty days of
`git log --numstat` per repo cannot meaningfully change between 30-second passes;
the worst case is a commit showing up in the panel up to two minutes late.

Measured, release build, same machine and workload:
  v1.1.3  ~60%  animation, sustained
  v1.1.4  18.7%  animation fixed, refresh pass exposed
  v1.1.5   3.2%  (60 s window 1.94 s; 120 s window 3.85 s — same rate)

Both wins are silent if they regress: the numbers stay correct, the app just
burns a core again. PerfCounters exposes the two counters so --self-test can
assert reuse, since the CLT-only self-test cannot reach the internal types and
XCTest needs a full Xcode toolchain. Red-green was run on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Gnonymous
Gnonymous merged commit 0ff2aa5 into main Aug 1, 2026
1 check passed
@Gnonymous
Gnonymous deleted the perf/refresh-pass branch August 1, 2026 17:49
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