Skip to content

Navigation Menu

Sign in
Sign up

perf(action-items): de-quadratic the parse, guard the linkifier, LRU the markdown cache - #100

Open
jordanrburger wants to merge 1 commit into
main from
claude/action-items-perf-hot-paths
Open

perf(action-items): de-quadratic the parse, guard the linkifier, LRU the markdown cache #100
jordanrburger wants to merge 1 commit into
main from
claude/action-items-perf-hot-paths

Conversation

@jordanrburger

@jordanrburger jordanrburger commented Sep 3, 2026

Copy link
×ばつ 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 - 530 tests in 97 suites pass, including `ParserContractTests` (three-repo corpus untouched). - New: `SplitSubjectBodyTests` (curated + differential + cost-curve), `InlineMarkdownCacheTests` (LRU retention, boundedness, coldest-first eviction, hit==miss), and hash-digit fast-path tests. - The scaling tests assert the **cost curve**, not a wall-clock budget — a hard millisecond bound would be flaky, so they check that doubling the input doesn't quadruple the time. They fail on the old implementation (ratio 3.26; 16k line at 197 ms) and pass on the new one. ## Caveats - All figures are **Debug**. Release couldn't be measured in-app (`@testable` needs testability, and forcing it on Release fails because `Run.make` is `#if DEBUG`). The parse figures were cross-checked standalone at `-O` and are Release-credible. - `DebouncedFileEventsTests.coalescesBurstIntoFewTrailingEvents` failed 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](https://claude.com/claude-code)" data-view-component="true"> Copy Markdown
Collaborator

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

stage before share
file read + decode + split 11 ms 0.4%
ActionItemsParser.parse 830 ms 29%
SwiftUI build + layout of ~475 cards ~2.0 s 71%
first paint ≈ 2.9 s

This 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. splitSubjectBody was quadratic — parse 830 → 369 ms

firstSeparatorOutsideTokens walked String.Index and called text.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.json is byte-identical across three repos and is the contract gate, but it holds only 6 entries with a body — too thin to protect this. So SplitSubjectBodyTests keeps 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.linkify had no fast path — 129 → 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 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 #channel names: 1,089 of 2,307 rendered strings contain a #, so a bare-# guard only skipped 53% and bought ~30 ms. Both branches of refRe require a # immediately followed by a digit, so the guard tests for that instead.

Note it correctly admits digit-leading tags like [#5864M]#5 clears 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

  • 530 tests in 97 suites pass, including ParserContractTests (three-repo corpus untouched).
  • New: SplitSubjectBodyTests (curated + differential + cost-curve), InlineMarkdownCacheTests (LRU retention, boundedness, coldest-first eviction, hit==miss), and hash-digit fast-path tests.
  • The scaling tests assert the cost curve, not a wall-clock budget — a hard millisecond bound would be flaky, so they check that doubling the input doesn't quadruple the time. They fail on the old implementation (ratio 3.26; 16k line at 197 ms) and pass on the new one.

Caveats

  • All figures are Debug. Release couldn't be measured in-app (@testable needs testability, and forcing it on Release fails because Run.make is #if DEBUG). The parse figures were cross-checked standalone at -O and are Release-credible.
  • DebouncedFileEventsTests.coalescesBurstIntoFewTrailingEvents failed 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

...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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

1 participant

AltStyle によって変換されたページ (->オリジナル) /