Writing Swift That Actually Gets Out of the Compiler’s Way

Swift’s compiler can be a strong partner—if you feed it code that matches its optimization model. Getting to fast, lean machine instructions isn’t a puzzle to outsmart the optimizer. It’s about stripping away ambiguity and leaning into the guarantees the language already gives you. This piece walks through concrete, no-nonsense ways to help the Swift compiler spit out better code, from value semantics to generics specialization.

Seeing Your Code Through the Compiler’s Eyes

Before you tweak anything, it helps to understand what the compiler actually stares at. Swift’s optimizer works mostly on SIL—Swift Intermediate Language—a stopover between your source and LLVM IR. How many abstractions get melted away depends on how much info is sitting there at compile time. Whole-module optimization (WMO) is a big lever: it lets the compiler chew on your entire module at once, opening doors to cross-function inlining, devirtualization, and generics specialization. Flip it on in your build settings under Swift Compiler – Code Generation by setting Compilation Mode to wholemodule for Release builds. Without WMO, the optimizer works file by file and misses chances that cross file boundaries.

Close-up of Swift code on a monitor in a dim workspace

Bet on Value Types, Not Reference Types

Structs and enums are value types. Every instance gets its own independent copy. On paper, that sounds like a performance hit, but the compiler is remarkably good at erasing those copies through copy elision. Pass a struct to a function or assign it to a variable, and the compiler often skips the copy entirely—especially if the code gets inlined. The bigger win, though: value types dodge the reference counting and heap allocation overhead that classes lug around. A local struct sits on the stack; its memory vanishes when the scope exits, with zero ARC noise. For data models, coordinates, or config objects, reach for a struct first. Save classes for when you truly need shared mutable state or identity.

Getting the Most from Value Types

To keep the optimizer happy, keep structs small and filled with other value types when you can. Stuffing a class inside a struct usually drags reference counting into a type that should be lightweight. Mark methods as mutating only when they actually change stored properties—this hands the compiler a clear signal about side effects. A common stumble: making every property a let, then swapping the whole struct when a tiny change is needed. Instead, use var for properties that change and let the compiler handle in-place mutation. That cuts down on temporary copies and leaves the optimizer with less mess to untangle.

Two developers reviewing Swift code on a large screen

Functions the Compiler Loves to Inline

Inlining swaps a function call for the function’s body, wiping out call overhead and exposing more context for downstream optimizations. The Swift compiler runs heuristics to decide what to inline, but you can tilt things in your favor. Keep functions short and single-minded. A function that nails one task in a handful of lines gets inlined way more often than a 50-line beast. Dodge dynamic dispatch inside small functions: call methods on concrete types, not protocols, when you know the type at compile time. Use @inlinable sparingly—it flings the function body across module boundaries, which lets clients inline but also freezes your implementation for ABI stability. For internal functions, lean on WMO instead; it gives the optimizer full sight without widening your module’s surface.

Cutting Protocol Witness Table Friction

Protocols pull in dynamic dispatch through protocol witness tables. The compiler has to chase those unless it can devirtualize the call. Devirtualization kicks in when the compiler knows the concrete type at the call site. Push it along by using generics over existential types (protocols as types). For example, func foo<T: MyProtocol>(value: T) lets the compiler stamp out a version of foo for each concrete T, baking the protocol requirements right in. Meanwhile, func foo(value: MyProtocol) boxes the value and dispatches through the witness table every time. When you’re stuck with an existential, tack on AnyObject if the conforming types are classes; that sidesteps the value-boxing hit. Fire up Instruments to measure—the specialized generic version often runs an order of magnitude faster in a tight loop.

Generics for Compile-Time Polymorphism

Swift generics are a zero-cost abstraction when the compiler can specialize them. Specialization churns out a unique copy of the generic function for each concrete type used, melting away generic overhead and unlocking type-specific tricks like SIMD vectorization. To keep specialization on the table, avoid type erasure. Wrapping a generic type in AnyView or AnyHashable strips the type info the compiler craves. Instead, keep the generic parameter visible up the call stack. For beefy generics that bloat your binary, try generic thunking: write a private, non-generic core that works on unsafe pointers or raw bytes, then expose a thin generic wrapper that calls it. That balances binary size against runtime speed.

A MacBook with Swift playground open and a coffee cup beside it

Memory Access That Plays Nice with the Cache

The CPU cache is your bottleneck more often than raw instruction count. Swift’s optimizer can shuffle memory accesses around, but it’s handcuffed by the program’s semantics. Reach for contiguous storage like Array instead of linked lists or trees when you iterate a lot. Array parks elements in one solid block of memory, which makes the CPU’s prefetcher purr. When your collection holds value types, Array also dodges the pointer indirection you’d get with an array of class references. For dictionaries and sets, lean on value-type keys to dial down retain/release churn during hashing and comparison. Don’t wrap a small value in a class just to tuck it into a collection—use a struct instead.

Keeping Reference Counting Quiet

ARC (Automatic Reference Counting) sprinkles retain and release calls around class instances. One or two are no big deal, but they pile up fast in a loop. You can lighten the ARC load by using unowned or weak references only when the object graph truly demands it; otherwise, strong references are faster because they skip the side-table checks that weak references need. For local variables, the compiler often shortens a strong reference’s lifetime and drops the retain/release pair entirely. Help it along by scoping class instances as tightly as possible: declare them inside the innermost block, and don’t stash them in longer-lived properties if you only need them briefly. When you hammer a class property inside a loop, yank it into a local variable first—this tells the compiler the object won’t budge, letting it clear out redundant reference counting.

Lean on Final, Private, and Whole-Module Smarts

Slap final on classes and methods when you don’t plan to subclass or override. That tells the compiler dynamic dispatch isn’t needed, paving the way for direct calls and inlining. Likewise, private and fileprivate shrink a declaration’s scope, giving the optimizer confidence that nothing outside can override or spy on it. In a whole-module-optimized build, the compiler can sniff out finality for internal declarations on its own, but explicit markers still boost readability and lock in the optimization even without WMO. For computed properties that hold a fixed value, mark them lazy only if the initial value is pricey and not always needed; a stored property with a default value usually lets the compiler fold the constant at compile time.

Staring at the Assembly Output

The bluntest way to check the optimizer’s work is to read the generated assembly. In Xcode, set a breakpoint and pick Debug > Debug Workflow > Always Show Disassembly, or compile with swiftc -O -emit-assembly. Hunt for call instructions to swift_retain, swift_release, or witness table functions. If you spot them in a hot loop, trace back to the source and apply the fixes above. Also keep an eye out for vector instructions like movdqa or addps—those mean the compiler auto-vectorized your loop over an array of floats, a solid sign your memory layout and loop shape are optimizer-friendly. Don’t guess; measure. Lean on XCTest performance tests or Instruments’ time profiler to put a number on each change.

FAQ

Does using structs always make things faster?

Nope. Fat structs passed by value over and over can trigger a copy storm if the compiler can’t elide them. Profile your code. If you hit a bottleneck, try passing the struct inout or wrap it in a class only after you’ve confirmed the copy overhead is the real culprit.

When should I sidestep protocol existentials?

Skip them in performance-sensitive spots where the concrete type is known at compile time. Existentials force boxing and dynamic dispatch. Use generics or concrete types for hot paths, and save existentials for mixed collections or when the abstraction genuinely cleans up your architecture without a measurable hit.

Is whole-module optimization always worth it?

It jacks up compile time because the compiler chews on the whole module at once. For small projects or during development, the default incremental mode feels snappier. Flip on WMO for your Release setup, where the extra build time pays off in runtime speed.

How do I know if the compiler specialized a generic function?

Peek at the assembly: if you see a function label with the concrete type name mangled in (like $s3App4fooyS2iF for an Int specialization), specialization happened. If you see calls to a generic stub with type metadata parameters, it didn’t. The swiftc -O -emit-sil output also shows specialization passes explicitly.