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.
How a method call is resolved decides whether it can be inlined, and inlining is where most of the speed is.
final classes, private methods,
protocol extension methods that are not requirements.objc_msgSend, used by @objc dynamic members and anything inherited
from NSObject. 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 optimisation, 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.
Heap allocation is the other big cost, and it is easier to spot than dispatch because it follows type structure:
class instance is always heap-allocated.struct lives 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, Set allocate a buffer as soon as they hold anything.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.
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.
The single most common performance mistake in Swift is profiling a debug build. Without optimisation, generics are not specialised, 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 optimisation in this chapter costs readability, and most programs spend their time in one or two places that nobody predicted.
When something is slower than it should be, in the order these usually pay off:
class that could be a struct?final?any that could be a generic or an enum?lazy chain, or its absence, allocating intermediate arrays you never read?