-
Notifications
You must be signed in to change notification settings - Fork 2
perf(action-items): de-quadratic the parse, guard the linkifier, LRU the markdown cache - #100
Open
jordanrburger wants to merge 1 commit into
Open
perf(action-items): de-quadratic the parse, guard the linkifier, LRU the markdown cache #100jordanrburger wants to merge 1 commit into
jordanrburger wants to merge 1 commit into
Conversation
...the md cache Measured first paint on a real 1.8 MB day (683 live + 124 archived tasks, Debug): 2.9 s total — 11 ms file read, 830 ms parse, ~2.0 s SwiftUI. This takes ~535 ms off the non-SwiftUI half. The SwiftUI 71% is untouched and is the next piece of work. 1. `splitSubjectBody` was quadratic — 830 ms parse → 369 ms. `firstSeparatorOutsideTokens` walked `String.Index` and called `text.distance(from:to:)` — itself O(n) — three times per character, so cost grew with the square of the line length. Task lines run to 9,685 chars. Synthetic scaling confirmed it: doubling the input quadrupled the time (8k chars 100 ms, 16k 410 ms), and it was optimization-immune because the cost is stdlib grapheme-breaking, not unoptimized Swift. Rewritten over a `[Character]` array with integer indices. The function itself goes 562 ms → 122 ms over all 807 real task lines. Behavior must not move: `parser-corpus.json` is byte-identical across three repos but carries only 6 entries with a body, so the safety net is a differential test holding the original implementation verbatim and agreeing with it across ~2,900 generated inputs covering every scanner branch. 2. `GitHubRefLinkifier.linkify` had no fast path — 129 ms → 74 ms. It ran three protected-range regexes plus two repo-inference regexes over every string before checking whether a ref could exist. `KBTag.linkify` already had the guard; this didn't. Guarding on a bare `#` is not enough here, and measuring said so: Scout vaults are dense with `[#TAG]` mnemonics and `#channel` names, so 1,089 of 2,307 rendered strings contain a `#`. Both branches of `refRe` need a `#` immediately followed by a digit, so the guard tests for that instead — which also correctly admits digit-leading tags like `[#5864M]`, since `#5` clears it (they're then left alone by the regex, as before). 3. The markdown cache evicted arbitrarily — warm re-render 320 ms → 24 ms. At the 2000 cap it dropped `cache.keys.prefix(1000)`, i.e. arbitrary dictionary order, so the strings currently on screen were as likely to go as any other. Past the cap that became a re-parse treadmill: a warm pass over a 2,398-string working set measured ~320 ms against 12 ms uncapped, a 27x cliff hit by expanding cards, searching and switching days. Now a true LRU keyed on a logical clock, evicting the coldest quarter, so crossing the cap degrades gradually instead of falling off an edge. Cap raised 2000 → 6000 (first paint alone touches ~935 uniques). Co-Authored-By: Claude Opus 5 <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.
Three measured, independently-verifiable wins on the Action Items hot paths. All numbers are Debug, against a real 1.8 MB day (683 live + 124 archived tasks).
Where the time was
ActionItemsParser.parseThis PR takes ~535 ms off the non-SwiftUI half. The 71% SwiftUI share is deliberately untouched — that's a separate, higher-risk effort (and emphatically not by reintroducing
LazyVStack; #83's non-convergent height estimation is real and documented).1.
splitSubjectBodywas quadratic — parse 830 → 369 msfirstSeparatorOutsideTokenswalkedString.Indexand calledtext.distance(from:to:)— itself O(n) — three times per character. Task lines run to 9,685 chars.Synthetic scaling confirmed the shape: doubling the input quadrupled the time (8k chars → 100 ms, 16k → 410 ms). It was optimization-immune (
-Onone≈-O) because the cost is stdlib grapheme-breaking, not unoptimized Swift.Rewritten over a
[Character]array with integer indices. The function itself: 562 → 122 ms across all 807 real task lines.On not breaking it:
parser-corpus.jsonis byte-identical across three repos and is the contract gate, but it holds only 6 entries with a body — too thin to protect this. SoSplitSubjectBodyTestskeeps the original implementation verbatim as a differential reference and asserts the rewrite agrees with it across ~2,900 generated inputs covering every scanner branch (each token kind closed and unclosed, every separator, separators inside and outside tokens). The reference copy is marked do-not-refactor.2.
GitHubRefLinkifier.linkifyhad no fast path — 129 → 74 msIt ran three protected-range regexes plus two repo-inference regexes over every string before checking whether a ref could exist.
KBTag.linkifyalready had this guard; this didn't.Guarding on a bare
#— the obvious fix — measured badly, so I tightened it. Scout vaults are dense with[#TAG]mnemonics and#channelnames: 1,089 of 2,307 rendered strings contain a#, so a bare-#guard only skipped 53% and bought ~30 ms. Both branches ofrefRerequire a#immediately followed by a digit, so the guard tests for that instead.Note it correctly admits digit-leading tags like
[#5864M]—#5clears the guard — which are then left alone by the regex exactly as before. There's a test for that specific case.3. The markdown cache evicted arbitrarily — warm re-render 320 → 24 ms
At the 2000-entry cap it dropped
cache.keys.prefix(1000)— arbitrary dictionary order — so strings currently on screen were as likely to be evicted as anything else. Past the cap that became a re-parse treadmill: a warm pass over a 2,398-string working set measured ~320 ms against 12 ms uncapped, a ×ばつ cliff, hit by expanding cards, searching, and switching days.Replaced with a true LRU on a logical clock, evicting the coldest quarter so the sort amortizes. Cap raised 2000 → 6000 (first paint alone touches ~935 uniques, ~443 KB of source text).
Correction to an earlier reading: the cap was not the cause of slow first load — the first-paint working set fits under the old cap. It's a latency cliff on interaction, not on load.
Testing
ParserContractTests(three-repo corpus untouched).SplitSubjectBodyTests(curated + differential + cost-curve),InlineMarkdownCacheTests(LRU retention, boundedness, coldest-first eviction, hit==miss), and hash-digit fast-path tests.Caveats
@testableneeds testability, and forcing it on Release fails becauseRun.makeis#if DEBUG). The parse figures were cross-checked standalone at-Oand are Release-credible.DebouncedFileEventsTests.coalescesBurstIntoFewTrailingEventsfailed once during this work and passed on 4 subsequent runs. It's a timing/debounce test and nothing here touches file events — flagging it as observed pre-existing flakiness, not investigated.🤖 Generated with Claude Code