Writing Swift the Compiler Actually Wants to Optimize

Getting Swift to run fast is less about clever algorithms and more about how you present your code to the compiler. The Swift compiler is hungry for predictable input — patterns it can recognize, tear apart, and rebuild into something lean. I’m Yuki Tanaka, and I’ve spent a career chasing performance at the OS layer. This guide walks through the specific moves that let the compiler do its best work: value types, generics specialization, ARC traffic, dispatch control, and a handful of collection tricks. Concrete examples included, no hand-waving.

Swift code on a laptop screen with a cup of coffee
Photo by Pexels

Let Value Types Carry the Weight

Structs and enums in Swift are a straight shot to better compiler output. The compiler sees exactly how a struct’s memory is arranged — no hidden indirection, no class hierarchy to resolve. Method calls on structs default to static dispatch, which means inlining becomes trivial. The data sits on the stack, so retain and release traffic simply vanishes.

A tiny example:

struct Point {
    var x: Double
    var y: Double
    func distance(to other: Point) -> Double {
        let dx = x - other.x
        let dy = y - other.y
        return (dx * dx + dy * dy).squareRoot()
    }
}

Because Point is a struct, distance(to:) gets inlined at the call site. No heap allocation, no ARC chatter. Swap that for a class, and suddenly every instance lives on the heap with reference counting baked into each access. For plain data carriers, structs aren’t just good style — they’re a direct optimization hint.

One catch: a giant struct passed around by value can generate a pile of copies. If profiling shows a bottleneck there, reach for inout or consider wrapping in a class. The standard library’s copy-on-write types, like Array, already handle this dance, but custom types need a light touch.

Make Generics Specialize, Not Generalize

Generics are an abstraction tax that you can often get refunded at compile time. The compiler will create a concrete, type-specific version of a generic function — that’s specialization. When it works, you get inlining, devirtualization, and the kind of tight code you’d write by hand. The trick is to keep the concrete type visible to the compiler.

Problems start when you slip into existential containers without realizing it. An any Drawable parameter, for instance, forces the value into a box and dispatches through a witness table. Compare the two styles:

protocol Drawable {
    func draw()
}

struct Renderer {
    func render<T: Drawable>(_ item: T) {
        item.draw()
    }
    // Forces existential dispatch:
    func render(_ item: any Drawable) {
        item.draw()
    }
}

That first render gets a specialized copy per concrete type. The second pays dynamic dispatch on every call. Swift 5.7’s any syntax makes the distinction explicit, so you can eyeball where you’re opting into overhead.

Lean on generic parameters over existentials. If you want specialization across module boundaries, mark the function @inlinable. The caller’s module then sees the body and can optimize as if it owned the code.

Cut ARC Overhead Where It Hurts

Automatic reference counting is cheap per operation, but loops have a way of multiplying that cost. The compiler can remove retain/release pairs when it can prove an object’s lifetime, but certain patterns get in the way.

Repeated property access on a class instance inside a loop is a classic case. The compiler may not be able to guarantee the instance survives across iterations without an extra retain. Hoist the reference:

class Container {
    var items: [Int]
    init(items: [Int]) { self.items = items }
}

func process(container: Container) {
    // Each access potentially retains and releases
    for i in 0..<container.items.count {
        print(container.items[i])
    }
    // Pull it out once
    let items = container.items
    for i in 0..<items.count {
        print(items[i])
    }
}

The local items variable gives the compiler a stable reference to optimize against. unowned and weak can also skip ARC traffic when breaking cycles, though unowned is a sharp tool — if the object goes away, you crash.

Close-up of Swift code on a monitor with debugging tools
Photo by Pexels

Lock Down Classes with Final and Private

Swift methods on classes default to dynamic dispatch so subclasses can override them. That flexibility blocks inlining and a bunch of interprocedural optimizations. If a class or method won’t be subclassed, say so explicitly.

A final class shuts the door on subclassing and converts all method dispatch to static or vtable-based calls the compiler can chew on. Individual methods marked private or final get the same treatment. Example:

final class Math {
    func square(_ x: Double) -> Double {
        return x * x
    }
}

class Base {
    private func helper() -> Int { return 42 }
    func compute() -> Int { return helper() }
}

Math.square can be inlined anywhere because the class is final. In Base, the compiler sees every call to helper() within the file and inlines it without ceremony. These keywords aren’t just architecture notes; they’re levers on the optimizer’s control panel.

Protocol extensions offer a similar edge: a default implementation called on a concrete type uses static dispatch right out of the gate, so long as the method isn’t a protocol requirement.

Clean Up Strings and Collections

Swift’s String is a feature-packed type — Unicode-correct, copy-on-write — and that complexity can surface as surprise allocations. Building a string piece by piece often triggers multiple heap expansions. String(reservingCapacity:) or a StringIO pattern gives the compiler a single allocation to work with. Same idea for Array: call reserveCapacity(_:) when you know the final count.

How you iterate matters, too. Index-based loops force bounds checks the compiler sometimes can’t eliminate. A simple for-in loop gives the compiler full knowledge of the range and often strips those checks:

// Cleaner, and the compiler knows the bounds
for item in array {
    process(item)
}
// Manual indexing invites bounds checks
for i in 0..<array.count {
    process(array[i])
}

Lazy chains (array.lazy.map { ... }) avoid intermediate arrays when you chain operations, but they defer work. Eager map and filter create new arrays immediately. Short pipelines are often fine — profile before you contort the code for a maybe-win.

Flip On Whole-Module Optimization

By default, Swift compiles files independently. Whole-module optimization bundles all the module’s files into a single compilation unit, giving the compiler a full view of types, methods, and call sites. Suddenly, private methods get more aggressive elimination, generics specialize across files, and dynamic dispatch can be devirtualized when no overrides exist.

Set SWIFT_COMPILATION_MODE = wholemodule in your release build settings. The trade-off is compile time — it can climb noticeably. Keep incremental builds for daily work and reserve WMO for the configuration you actually ship.

Swift code on a tablet with a coffee shop background
Photo by Pexels

Measure First, Then Tweak

Compiler optimizations are powerful, but blind application will waste your time. Instruments shows you retain counts, heap allocations, and where your CPU cycles actually go. XCTest performance tests with measure blocks let you track whether a change helped or hurt.

When something looks off, dump the Swift Intermediate Language with -emit-sil. The output is dense, but you can spot existential boxes, missed specializations, and dynamic dispatch that shouldn’t be there. The toolchain changes with every Swift release, so always benchmark on the version you deploy. And don’t let optimization turn readable code into a puzzle — clarity matters until the profiler says otherwise.

FAQ

What’s the single biggest performance mistake in Swift?

Reaching for a class when a struct would do. Value types give the compiler a clear memory picture and skip heap allocation and ARC overhead. Unless you need identity or shared mutable state, start with a struct.

How do I know if generics are being specialized?

Check the SIL output with swiftc -emit-sil or fire up Instruments. Calls through witness tables signal existential dispatch; direct function calls mean specialization kicked in. Using generic parameters instead of any types usually seals the deal.

Does whole-module optimization affect app size?

It can, because more inlining and specialization land in the binary. Usually the speed boost justifies the extra kilobytes. If code size is tight, apply @inlinable sparingly rather than turning on WMO everywhere.

When should I prefer a class over a struct for performance?

When shared mutable state is the point, or when copying a huge struct costs more than the reference counting overhead. Profile both paths; the break-even point depends on data size and how often you copy.