07 · Performance at Scale¶
Level 3's performance module covered per-call micro-optimizations
(reserveCapacity, Set vs Array). This module is about performance
at scale — the techniques that matter once you're processing thousands
to millions of items or serving concurrent requests: memoization, batching
(and when it doesn't help), and parallelizing independent work.
A timing helper¶
Reusing the small timer from Level 3's performance module:
import Foundation
func measure(_ label: String, _ block: () -> Void) {
let start = DispatchTime.now()
block()
let end = DispatchTime.now()
let ms = Double(end.uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000
print(String(format: "%@: %.2fms", label, ms))
}
Memoization: trading memory for repeated work¶
Naive recursive Fibonacci recomputes the same sub-results exponentially many times. Caching each result the first time it's computed collapses that into linear work:
final class Memoizer {
private var cache: [Int: Int] = [:]
private var calls = 0
func fib(_ n: Int) -> Int {
calls += 1
if let cached = cache[n] { return cached }
let result = n < 2 ? n : fib(n - 1) + fib(n - 2)
cache[n] = result
return result
}
var callCount: Int { calls }
}
let memo = Memoizer()
let result = memo.fib(30)
print("fib(30) =", result, "computed with", memo.callCount, "calls (memoized)")
Output:
59 calls total for fib(30) — compare to the naive version, which makes
roughly 2.7 million recursive calls for the same input, because it
recomputes fib(28), fib(27), etc. from scratch every time they're
needed again.
Measuring the difference¶
func fibNaive(_ n: Int) -> Int {
n < 2 ? n : fibNaive(n - 1) + fibNaive(n - 2)
}
measure("naive fib(28)") { _ = fibNaive(28) }
let memo2 = Memoizer()
measure("memoized fib(28)") { _ = memo2.fib(28) }
Output:
Two orders of magnitude faster at n = 28 — and the gap widens
exponentially as n grows, since the naive version's work grows
exponentially while the memoized version's stays linear.
Batching: helps when overhead is per-call, not per-item¶
A common claim is "batch your work for better performance" — but that's only true when there's a fixed cost per call that batching amortizes (a network round-trip, a database query, a lock acquisition). For pure in-memory CPU work with no such per-call overhead, batching can actually add cost from the extra bookkeeping:
func expensiveStep(_ x: Int) -> Int { x * x }
func processOneByOne(_ items: [Int]) -> Int {
var total = 0
for item in items { total += expensiveStep(item) }
return total
}
func processBatched(_ items: [Int], batchSize: Int) -> Int {
var total = 0
var index = 0
while index < items.count {
let end = min(index + batchSize, items.count)
let batch = items[index..<end]
total += batch.reduce(0) { $0 + expensiveStep($1) }
index = end
}
return total
}
let items = Array(0..<100_000)
measure("one by one") { _ = processOneByOne(items) }
measure("batched") { _ = processBatched(items, batchSize: 1000) }
Output on this machine:
The batched version is slower here — there's no per-call overhead to
amortize (expensiveStep is a trivial in-process function call), so the
extra array slicing and closure-based reduce per batch is pure added
cost. Batching earns its keep specifically when each unbatched operation
has a real fixed cost — a network request, a SELECT/INSERT
round-trip to a database, an actor hop — not for tight in-memory loops.
Measure before assuming; the "obviously correct" optimization here would
have made things worse.
Parallelizing independent chunks of work¶
Where batching didn't help, splitting genuinely independent CPU work across
concurrent tasks can — each withTaskGroup child can run on a different
CPU core:
func sumOfSquares(_ range: Range<Int>) -> Int {
range.reduce(0) { $0 + $1 * $1 }
}
func parallelSum(_ total: Int, chunks: Int) async -> Int {
let chunkSize = total / chunks
return await withTaskGroup(of: Int.self) { group -> Int in
for i in 0..<chunks {
let start = i * chunkSize
let end = (i == chunks - 1) ? total : start + chunkSize
group.addTask { sumOfSquares(start..<end) }
}
var sum = 0
for await partial in group { sum += partial }
return sum
}
}
let parallelResult = await parallelSum(1_000_000, chunks: 4)
let sequentialResult = sumOfSquares(0..<1_000_000)
print("Parallel result matches sequential:", parallelResult == sequentialResult)
Output:
Splitting 0..<1_000_000 into 4 non-overlapping ranges and summing each
independently, then combining, is correct here because summation is
associative — the chunks don't need to communicate. This pattern
(partition → process independently → combine) is the shape of most
"embarrassingly parallel" performance wins; work with dependencies between
chunks (like the Fibonacci memoization above) doesn't parallelize this
cleanly.
Swift-specific traps¶
- Don't assume batching, chunking, or parallelizing helps — measure. As shown above, the same-looking "obvious optimization" made things worse for pure CPU work with no per-call overhead.
withTaskGroupoverhead is real for very cheap per-chunk work — spinning up child tasks has its own cost, so parallelizing a microsecond-scale computation can lose to just doing it sequentially; the technique pays off once each chunk's work meaningfully exceeds task overhead.- Memoization caches grow unbounded by default — the
Memoizerabove never evicts anything; a production cache needs an eviction policy (LRU, a size cap, or a TTL) or it becomes its own memory leak under sustained load. - Recursive memoization with a class-based cache is not
concurrency-safe —
Memoizerabove uses a plainclass; called from multiple concurrent tasks, itscachedictionary would be a data race. Wrapping it as anactor(Level 3, Module 01) fixes that at the cost of requiringawaitat every call site.
Cheat sheet¶
| Technique | Helps when | Doesn't help when |
|---|---|---|
| Memoization | Overlapping/repeated sub-computations (recursion, shared queries) | Every input is unique — nothing to reuse |
| Batching | Per-call overhead is real (network, DB, actor hops) | Pure in-memory CPU work with no per-call cost |
withTaskGroup parallelism |
Chunks are independent and individually substantial | Chunks are tiny, or depend on each other's results |
How It Actually Works¶
- Horizontal scaling interacts with Swift's cooperative-thread-pool concurrency model per-instance, not cluster-wide — each process instance gets its own fixed-size cooperative pool sized to its container's visible CPU count; over-provisioning CPU limits below what the pool detects (a common container/cgroup misconfiguration) starves the pool and causes tasks to queue even though "the pool" appears fine, which is a frequent, hard-to-spot source of latency in containerized Swift services.
- Connection pool sizing must be tuned relative to the cooperative pool, not just to the database's max-connections limit — because request-handling tasks suspend (not block a thread) while waiting on a pooled database connection, you can safely serve far more concurrent in-flight requests than you have OS threads, but only up to however many database connections the pool actually holds; beyond that, additional requests queue for a database connection to free up.
- Caching layers (in-memory
NSCache/custom LRU vs. a shared cache like Redis) differ mechanically in cross-instance consistency: an in-process cache is only visible to that one process's cooperative pool and is invalidated by nothing but its own eviction policy or process restart, while a shared external cache adds a network round trip per lookup but stays consistent across every horizontally-scaled instance — the right choice depends on whether staleness within a single instance is acceptable for your data. - Backpressure: a
TaskGrouporAsyncStreamhandling an unbounded influx of work will happily keep queuing child tasks/buffered values if you don't explicitly bound concurrency (e.g. capping how many tasks are added to a group at once) — since tasks are cheap to create but not free, an unbounded fan-out under real production load is a common way to exhaust memory or database connections well before CPU becomes the bottleneck.
Exercise¶
Take the processOneByOne/processBatched comparison above and change
expensiveStep to simulate a real per-call cost — for example, try?
await Task.sleep(nanoseconds: 100_000) inside an async version reached
one item at a time versus fetched via a single batched
withTaskGroup-based fan-out. Measure both with the measure helper
(adapted for async code) and confirm that this time, batching (or
concurrent fan-out) wins — because now there's real per-call overhead to
amortize, unlike the pure-CPU case in this module.