Performance
Dispatch, allocation, and measuring instead of guessing
Every chapter so far ended in a cost. This one is about seeing those costs instead of guessing at them.
Three kinds of dispatch
How a method call is resolved decides whether it can be inlined, and inlining is where most of the speed is.
- Static — the compiler knows the exact function. Structs,
finalclasses,privatemethods, protocol extension methods that are not requirements. - Table — one indirection through a vtable (classes) or witness table (protocol requirements). Cheap, but opaque: the optimizer cannot see through it.
- Message — Objective-C
objc_msgSend, used by@objc dynamicmembers and anything inherited fromNSObject. The most flexible and by far the slowest.
You can move a call from table to static with three annotations that cost nothing:
final class Renderer { // no subclasses → devirtualise
private func prepare() {} // not visible outside → devirtualise
}
and by building with whole-module optimization, which lets the compiler prove that an internal
class has no subclasses in the module. It is the default for release builds; the reason a debug build
can be ten times slower is largely this.
Allocation
Heap allocation is the other big cost, and it is easier to spot than dispatch because it follows type structure:
- A
classinstance is always heap-allocated. - A
structlives inline — in a register, on the stack, or inside whatever contains it — unless it is captured by an escaping closure or boxed into an existential larger than three words. Array,String,Dictionary,Setallocate a buffer as soon as they hold anything.- An existential (
any P) larger than three words allocates a box.
protocol Shape { var area: Double { get } }
struct Circle: Shape { // 8 bytes — fits inline in an existential
var radius: Double
var area: Double { .pi * radius * radius }
}
struct Rect: Shape { // 32 bytes — boxed when stored as `any Shape`
var x, y, width, height: Double
var area: Double { width * height }
}
Two similar-looking types, one of which quietly allocates every time it is stored in a
[any Shape]. If that matters, use an enum instead of a protocol — an enum with associated values is
one flat value, sized to its largest case, with no allocation and no dynamic dispatch.
Reserve capacity
Appending to an array grows the buffer geometrically, copying each time:
var result: [Transform] = []
result.reserveCapacity(items.count)
for item in items { result.append(transform(item)) }
One allocation instead of a handful of allocate-copy-free rounds. Worth doing whenever the final size is known, and free to write.
Measure, and measure the right build
The single most common performance mistake in Swift is profiling a debug build. Without optimization, generics are not specialized, nothing is inlined, ARC traffic is at its worst, and bounds checks are everywhere. Numbers from that build tell you nothing about the shipped one.
import Testing
@Test func decodingStaysFast() async throws {
let payload = try fixture(named: "large-feed")
let clock = ContinuousClock()
let elapsed = clock.measure {
for _ in 0..<100 { _ = try? JSONDecoder().decode(Feed.self, from: payload) }
}
#expect(elapsed < .milliseconds(500))
}
A test like this pins the number so a regression shows up in CI rather than in a review comment. For
finding where the time goes, Instruments’ Time Profiler is still the tool; the flags worth knowing
are -Xswiftc -O for the build and Signposts (OSSignposter) for marking your own intervals.
Tip
Order of operations: make it correct, then measure, then fix the top item, then measure again. Every optimization in this chapter costs readability, and most programs spend their time in one or two places that nobody predicted.
A short checklist
When something is slower than it should be, in the order these usually pay off:
- Are you profiling a release build?
- Is the work happening more times than you thought? (Log the call count first — algorithmic wins dwarf everything else here.)
- Is a hot type a
classthat could be astruct? - Are hot classes
final? - Is a hot protocol call an
anythat could be a generic or an enum? - Are arrays reserving capacity?
- Is a
lazychain, or its absence, allocating intermediate arrays you never read?