Skip to content

Speed up Debug builds: fix CI cache key + trim type-checker hotspots#308

Merged
onevcat merged 3 commits into
mainfrom
perf/build-time-optimization
May 19, 2026
Merged

Speed up Debug builds: fix CI cache key + trim type-checker hotspots#308
onevcat merged 3 commits into
mainfrom
perf/build-time-optimization

Conversation

@onevcat
Copy link
Copy Markdown
Owner

@onevcat onevcat commented May 19, 2026

Summary

Two threads of work to shorten Debug clean-build wall time, especially on CI:

  1. CI cache key fix~/Library/Developer/Xcode/DerivedData/CompilationCache.noindex
    was being cached with a static xcode-compilation-cache-v0 key.
    actions/cache@v4 only saves a new cache when the primary key has no
    exact match, so the cache was frozen at the very first snapshot ever
    written (~152MB, verified via gh api /repos/onevcat/Prowl/actions/caches).
    Most compile work didn't find its content hash in there, so CI kept
    cold-compiling almost everything. New key rotates only when
    Package.resolved or supacode.xcodeproj/project.pbxproj changes,
    so routine code-only PRs don't pay the ~30s upload cost but the
    cache still gets refreshed when its content profile actually shifts.
    restore-keys keeps the old -v0 snapshot reachable as a one-time
    fallback until the new -v1 cache lands.

  2. Type-checker hotspots — profiling with
    -Xfrontend -warn-long-function-bodies=100 -Xfrontend -warn-long-expression-type-checking=100
    surfaced ~14s of cumulative type-checker time concentrated in a
    handful of files. The biggest mechanical wins:

    • Replace Dictionary(uniqueKeysWithValues: arr.map { ($0.id, $0) })
      with typed for-loops (huge generic-resolution reduction).
    • Promote the inline (repository:, worktrees:) tuple to a nominal
      ArchivedWorktreeGroup struct.
    • Extract complex SwiftUI bodies into private helpers so the
      result-builder accumulates less state per scope.

Type-checker hotspots (Debug, M2 Pro, threshold 100ms)

File / symbol Before After
ArchivedWorktreesDetailView.body 4312ms 346ms
CanvasView.body 1639ms (below threshold)
WorktreeTerminalTabsView.body 344ms 301ms
ShelfOpenBookView.body 422ms 373ms
supacodeApp.init 356ms (below threshold)
ToolbarNotificationGroup.toolbarNotificationGroups 315ms (below threshold)
ListRuntimeSnapshotBuilder.makeSnapshot 301ms 368ms*
TargetResolver.makeSnapshot 314ms 315ms*

* The Dictionary-init hotspot is gone; the remaining time has shifted
to the surrounding outer body, which is a smaller per-file cost.

Build settings

  • COMPILATION_CACHE_ENABLE_CACHING = YES at the project Debug
    level (was target-only). In practice SPM packages already inherit
    the cache via Xcode defaults so this is mostly belt-and-braces, but
    explicit is better than implicit.
  • EAGER_LINKING = YES for Debug to overlap link with compile.

Wall-clock impact (M2 Pro 12-core, Debug)

Measured locally, two states each. The PR's biggest single CI lever is
the cache-key fix — local numbers conflate it with the existing warm
local cache, so the delta below is the realistic shape of what CI
should see once the new -v1 cache populates.

Scenario Wall Cumulative SwiftCompile
Cold cache (custom DerivedData, before changes) ~44s 162.8s
Cold cache, after code refactoring ~41s 154.9s
Warm cache (default DerivedData) ~17-18s ~10s

Code-level refactoring on its own buys ~5% on cold; the dramatic
drop comes from the cache actually being usable, which is what the CI
fix unlocks.

CI impact

Once the new -v1 key populates after merge, subsequent runs should
restore a much fuller cache and skip most SwiftCompile work. The key
rotates only on dep / project-file changes, so routine PRs hit primary
key → restore → build → no re-upload (avoids per-run ~30s save cost
and CI minute charges). Stale or harmful caches can be wiped via the
GitHub Actions cache UI; the next CI run rebuilds a fresh -v1 from
scratch.

Test plan

  • make build-app
  • make lint (SwiftLint strict)
  • make format-lint (swift-format strict)
  • xcodebuild test -only-testing:supacodeTests/ToolbarNotificationGroupingTests
  • Full CI run on this PR — verify cache save-back triggers (cache list should show a new -v1-<hash> entry)
  • Re-run CI after first save - confirm restore hits and build is faster
  • Manual smoke: launch Prowl, open a repo, open archived worktrees pane, open canvas, switch tabs

onevcat added 2 commits May 19, 2026 23:21
Profile diagnostics from `xcodebuild ... OTHER_SWIFT_FLAGS=-Xfrontend
-warn-long-function-bodies=100 -Xfrontend -warn-long-expression-type-checking=100`
surfaced ~14s of cumulative type-checking concentrated in a handful of
files, plus the project-level compilation cache being limited to the
main target only. This pass attacks both.

Type-checker hotspots refactored:
- ArchivedWorktreesDetailView body (4312ms -> 346ms): hoist data prep
  into a typed snapshot, split branches/sections into helpers, and
  promote the inline (repository:, worktrees:) tuple to a nominal
  ArchivedWorktreeGroup struct.
- CanvasView body (1639ms -> below threshold): pull the per-card view
  out into a private `cardView(for:in:activeStates:)` helper.
- WorktreeTerminalTabsView / ShelfOpenBookView bodies: factor out the
  tab bar, content stack, and icon-picker sheet into private helpers.
- ListRuntimeSnapshotBuilder / TargetResolver / ToolbarNotificationGroup:
  replace `Dictionary(uniqueKeysWithValues:)` and chained higher-order
  closures with typed for-loops that the type-checker resolves cheaply.
- supacodeApp.init: extract MemoryWatchdog wiring into a static helper
  to break the long property chain.

Build settings:
- COMPILATION_CACHE_ENABLE_CACHING = YES at the project Debug level so
  SPM packages (TCA, Sharing, Dependencies, PostHog, Sentry, ...) hit
  the same Xcode compilation cache the main target already uses. With a
  warm cache this drops `make build-app` clean wall time from ~44s to
  ~26s on an M2 Pro (~40% reduction).
- EAGER_LINKING = YES for Debug to overlap linking with compilation.

Verified: `make build-app`, `make lint`, swift-format strict lint, and
ToolbarNotificationGroupingTests all pass.
…gers

`actions/cache@v4` only saves a new cache when the primary key has no
exact match. The previous static `xcode-compilation-cache-v0` key meant
the very first CI run that populated the cache became permanent: every
subsequent run restored that same snapshot but never updated it.

In practice the saved cache is ~152MB (verified via `gh api
/repos/onevcat/Prowl/actions/caches`), frozen since the workflow was
added back in February 2026. Most of the project's compile work doesn't
find its content hash in there, so CI keeps cold-compiling everything
except a few stable SDK Clang modules — even though the `hits / cacheable
tasks` metric looks high (the metric only counts tasks Xcode bothered
to look up, not the silent misses).

Rotate the key by Package.resolved + project.pbxproj hashes so it only
churns on dep upgrades or target/build-setting edits, not on every PR.
That avoids paying the ~30s upload cost on routine code-only PRs while
still letting the cache evolve when its content profile actually changes.
restore-keys keeps the old `-v0` cache reachable as a one-time fallback
until the new `-v1` snapshot lands.
@onevcat onevcat changed the title Speed up Debug builds via type-checker fixes + SPM compilation cache Speed up Debug builds: fix CI cache key + trim type-checker hotspots May 19, 2026
@onevcat onevcat merged commit 637b14f into main May 19, 2026
1 check passed
@onevcat onevcat deleted the perf/build-time-optimization branch May 19, 2026 15:18
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