Skip to content

perf: read each source file once, restore per-file parallelism, use Foundation .strings parser (~4× faster)#4

Open
memoto wants to merge 1 commit into
mainfrom
perf/read-once-parallel-tasks-foundation-parser
Open

perf: read each source file once, restore per-file parallelism, use Foundation .strings parser (~4× faster)#4
memoto wants to merge 1 commit into
mainfrom
perf/read-once-parallel-tasks-foundation-parser

Conversation

@memoto

@memoto memoto commented Jun 24, 2026

Copy link
Copy Markdown
Owner

Why

LocalizeChecker runs as a build-phase script on every Debug build of large iOS apps. Profiling showed several real, fixable inefficiencies in the implementation.

Benchmarked on Apple Silicon, corpus: 400 Swift files (~1.85 MB), 600 bundle keys, ×10 XCTest.measure runs:

avg min stddev
Before (0.1.13) 8.020 s 7.944 s 1.3 %
After 1.930 s 1.895 s 1.3 %
Speed-up ≈ 4.1×

What changed

1. Read each source file once

SourceFileChecker.start() previously called fastCheck(), which loaded and UTF-8-decoded the file just to substring-check for .localized, then re-loaded the same file for SwiftParser. Now the file is read once and the string is reused for both the early-exit check and the parse.

2. Replace the hand-rolled .strings parser

The old tokenizer split on ; and used per-entry String.init + two trimmingCharacters calls — slow, and mis-parsed values that contain ;. Replaced with Foundation's NSDictionary(contentsOf:error:) — faster and correct. The bug is covered by a new correctness test in the companion PR to the consuming repo.

3. Restore real parallelism in SourceFileBatchChecker

0.1.13 made this type an actor. Every group.addTask { try await self.processBatch(...) } closure hopped back onto the actor's serial executor, so chunking added overhead without delivering any parallelism. Reverted to final class & Sendable and switched from fixed-size chunks to one task per file — Swift Concurrency's cooperative pool then load-balances CPU-bound SwiftParser work across cores.

4. Smaller cleanups

  • Cache the nextToken(viewMode:) walk in LocalizeParser.visit (the original walked the syntax tree twice per node).
  • Use a Set for unused-key detection (was Array.contains(where:), O(N·M) on large bundles).
  • Drop debug print() calls from LocalizeBundle.init that fired on every build.
  • Stream SourceFilesTraversalTrait.parseSourceDirectory with a for-in loop instead of chaining lazy filters that allocate intermediate arrays.

Correctness

All existing public API shapes are preserved. The only breaking change is that SourceFileBatchChecker is no longer an actor — callers that await its methods still compile; the async qualifier is retained where the method itself spawns tasks.

🤖 Generated with Claude Code

…oundation .strings parser (~4× faster)

Four independent optimizations, each profiled separately:

1. Read each source file once
   SourceFileChecker.start() previously called fastCheck(), which loaded
   and decoded the file just to scan for the literal marker, then re-loaded
   the same file for SwiftParser. Now the file is read once and the string
   is reused for both the early-exit check and parse.

2. Replace the hand-rolled .strings parser
   The old tokenizer split on ';', which mis-parsed values containing
   semicolons and was slow due to repeated String allocations. Replaced
   with Foundation's NSDictionary(contentsOf:error:) — faster and correct.

3. Restore real parallelism in SourceFileBatchChecker
   The type was an actor in 0.1.13. Every group.addTask closure hopped back
   onto the actor's serial executor, so chunking added overhead without
   delivering any parallelism. Reverted to final class & Sendable and
   switched from fixed-size chunks to one task per file — Swift Concurrency's
   cooperative pool then load-balances SwiftParser work across cores.

4. Smaller cleanups
   - Cache the nextToken(viewMode:) walk in LocalizeParser.visit (the
     original walked twice in a row per node).
   - Use a Set for unused-key detection (was Array.contains(where:), O(N·M)).
   - Drop debug print() calls from LocalizeBundle.init.
   - Stream SourceFilesTraversalTrait.parseSourceDirectory with a for-in
     loop instead of chaining lazy filters that allocate intermediate arrays.

Benchmarked on Apple Silicon, corpus: 400 Swift files (~1.85 MB), 600 bundle keys.
Baseline avg: 8.020 s  →  Optimized avg: 1.930 s  (≈4.1× speed-up).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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