perf(refresh): memoize the two steps that dominated every pass - #6
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
GitCorrelator.buildRangesGitCorrelator.swift:99Scanner.init→ plist decodeScanner.swift:119CodexScanner.scanLog 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 freshScannerevery 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 eachscan()— passes are already serialized byUsageStore's coalescing guard, so it is uncontended and only makes overlapping passes wait instead of race.GitCorrelator —
buildRangesmemoized 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 ofgit log --numstatper 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):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.
PerfCountersexposes the two counters so--self-testcan 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 assertionsgit today +607 −56 (10 commits)reflects commits made minutes earlier🤖 Generated with Claude Code