Skip to content

9. Performance & profiling

What & why

Swift is fast by default — it compiles to native code with LLVM, has value types, and avoids garbage collection. But "fast by default" isn't "fast regardless": hidden copies, ARC traffic, existential boxing, and accidental allocation can cost 10–100×. This lesson is about measuring first, then fixing what the profiler shows.

Rule 0: don't optimise without a measurement showing where the time goes. Intuition about performance is wrong most of the time.

Always measure in a release build

swift build -c release
swift run -c release mytool

Debug builds disable optimisation and keep extra checks; they can be 10–100× slower, and different things are slow. Never benchmark a debug build.

Useful optimisation flags (set per target with swiftSettings: [.unsafeFlags([...])], though unsafeFlags make a package non-publishable as a dependency):

Flag Effect
-O (release default) optimise for speed
-Osize optimise for code size
-Ounchecked removes overflow and some precondition checks — only if you've proven they're needed
-wmo / whole-module-optimization optimise across files (default in release)
-cross-module-optimization lets the optimizer look into dependencies' bodies

Measuring

Simple timing with the monotonic clock:

let clock = ContinuousClock()
let elapsed = clock.measure {
    for _ in 0..<1_000 { _ = (1...10_000).reduce(0, +) }
}
print(elapsed)                       // e.g. 0.123 seconds

Pitfalls of micro-benchmarks:

  • The optimizer may delete code whose result is unused. Consume the result: blackHole(result) or print it.
  • Warm up: the first run includes page faults and cache misses. Run several times and take the median/min.
  • Measure realistic data sizes. An algorithm that wins at n=100 can lose at n=10⁶.
  • Noise: other processes, CPU frequency scaling, thermal throttling. Repeat and look at the spread.

For serious benchmarking use the package-benchmark library, which reports wall time, CPU time, allocations and retain/release counts with statistical rigour, and can fail CI on regressions.

Profilers

Platform Tool Use for
macOS Instruments: Time Profiler, Allocations, Leaks, Swift Concurrency CPU hot spots, allocation counts, task timelines
macOS/Linux sample, perf (+ perf report, flame graphs) sampling CPU profiles
Linux Valgrind callgrind/massif, heaptrack, perf precise call counts, heap growth
Windows Windows Performance Analyzer / Visual Studio profiler CPU sampling
Any swift build -c release -Xswiftc -g keep symbols so profiles show function names

Method: record a sampling profile of a realistic run, sort by self time, and look at the top handful of functions. Fix the top item, re-measure, repeat. Look for unexpected frames like swift_retain, swift_release, swift_allocObject, swift_bridgeObjectRetain, or _swift_stdlib_... — they signal ARC and allocation overhead.

The usual suspects

1. Copy-on-write surprises

Arrays, strings, dictionaries, and sets share storage until mutated. Mutating a non-uniquely-referenced buffer copies the whole thing.

func slow(_ input: [Int]) -> [Int] {
    var copy = input                 // shares storage with `input` ...
    copy[0] = 1                      // ... so this triggers a full O(n) copy (fine, once)
    return copy
}

var buffer = [Int]()
let snapshot = buffer                // another reference
for i in 0..<100_000 {
    buffer.append(i)                 // first append copies (shared); later ones are unique and fast
}

The classic accident: holding an extra reference (a captured closure, a stored copy, an ArraySlice, a let copy used later) while mutating in a loop. If each iteration copies, you've got O(n²).

Check uniqueness with isKnownUniquelyReferenced for your own CoW types; for arrays, restructure code so the mutation site has the only reference.

2. Reserve capacity

var result = [Int]()
result.reserveCapacity(input.count)       // one allocation instead of ~log₂(n) reallocations
for x in input { result.append(x * 2) }

The same applies to String, Dictionary, and Set (reserveCapacity).

3. Prefer map/reduce(into:) and avoid quadratic patterns

// O(n²): creates a new array each iteration
var acc = [Int]()
for x in xs { acc = acc + [x] }

// O(n)
var acc2 = [Int]()
for x in xs { acc2.append(x) }

// `reduce(into:)` mutates in place; plain `reduce` makes a new value each time
let counts = words.reduce(into: [String: Int]()) { $0[$1, default: 0] += 1 }

4. Existentials and dynamic dispatch

[any Shape] boxes each value (large values go on the heap) and calls through a witness table. In hot loops, use generics ([S] where S: Shape) or an enum of the possible types. final classes and private/fileprivate members can be devirtualised; whole-module optimisation finds more of them.

5. ARC traffic

Every strong reference copy in a hot loop is an atomic increment/decrement. Reduce it by:

  • using value types (no refcounting) for small, frequently-copied data,
  • marking classes final,
  • passing borrowing / consuming parameters for large copyable values (lesson 8),
  • avoiding capturing self in tiny closures inside loops,
  • using unowned/Unmanaged only after the profiler proves retain/release dominates (and understanding the danger).

6. Strings

Swift String is Unicode-correct: count and integer-like indexing are O(n). For ASCII/bytes processing, drop to utf8:

let text = "Hello, world"
var spaces = 0
for byte in text.utf8 where byte == UInt8(ascii: " ") {      // much faster than iterating Characters
    spaces += 1
}

withContiguousStorageIfAvailable and String.UTF8View give you direct access to bytes. Avoid repeatedly creating Substring→String conversions in inner loops.

7. Allocation in loops

Creating small class instances, closures that escape, and arrays inside tight loops each cost an allocation. Hoist reusable buffers out of loops, or use ContiguousArray, withUnsafeTemporaryAllocation, or stack-promoted values.

8. Choosing data structures

Need Prefer Avoid
Append/iterate Array / ContiguousArray Array.insert(at: 0) in loops
Membership Set array.contains in a loop
Queue/deque ring buffer or swift-collections Deque removeFirst() on Array (O(n))
Ordered set / dictionary OrderedSet, OrderedDictionary sorting each time
Priority queue Heap (swift-collections) re-sorting

The swift-collections and swift-algorithms packages are well worth knowing.

9. Lazy sequences

lazy avoids intermediate arrays but adds closure-call overhead; it wins for short-circuiting or huge pipelines, not for small ones.

let firstMatch = (1...1_000_000).lazy.map(expensive).first { $0 > 100 }    // computes only until the first hit
func expensive(_ x: Int) -> Int { x &* 3 }

10. Generics across module boundaries

An unspecialised generic function is slower. Same-module generics are specialised automatically. For library code that's performance sensitive, mark small generic functions @inlinable (and the types they use @usableFromInline/@frozen) — with the source-compat commitments described in lesson 11.

Concurrency performance

  • More tasks ≠ faster. Cap concurrency near the core count for CPU-bound work.
  • Chunk work: one task per file or per few-thousand items, not per byte.
  • Avoid actor hops in inner loops — each hop is a scheduling event. Batch calls.
  • Contention on one actor or mutex serialises everything; shard state or merge results at the end (as the capstone does).
  • Use Instruments' Swift Concurrency template (macOS) to see task creation and suspension counts.

Memory

  • MemoryLayout<T>.size, .stride, .alignment tell you the real layout. Reorder struct fields (large to small) to reduce padding.
  • Enums with payloads take the size of the largest case; mark big payloads indirect.
  • Beware boxed closure contexts: capturing many variables allocates a context object.
  • Leaks (cycles, lesson 7 of Intermediate) show as steady growth in massif/Instruments Allocations.
struct A { var a: Bool; var b: Int64; var c: Bool }     // stride 24
struct B { var b: Int64; var a: Bool; var c: Bool }     // stride 16
print(MemoryLayout<A>.stride, MemoryLayout<B>.stride)

A tuning workflow

  1. Write a benchmark that reflects real usage and record the baseline.
  2. Profile (sampling) a release build with symbols.
  3. Form one hypothesis (e.g. "ARC traffic on Node dominates").
  4. Change one thing. Re-measure. Keep it only if the numbers improved.
  5. Add a regression benchmark to CI if the improvement matters.
  6. Stop when the target is met. Readability has value too.

Common mistakes

  • Optimising before profiling, or profiling a debug build.
  • Micro-benchmarks the optimizer deleted.
  • Quadratic algorithms hiding behind innocent code (array.contains in a loop, string concatenation with + in a loop on arrays).
  • Accidental CoW copies from holding an extra reference.
  • Using -Ounchecked or unowned(unsafe) globally and losing safety for a few percent.
  • unsafeFlags in published packages — SwiftPM refuses to resolve them for dependents.
  • Over-parallelising: thread-pool overhead exceeds the work.

Exercises

  1. Write a benchmark for summing 10 million Ints via a for loop, reduce, and lazy pipeline; run it in debug and release and compare.
  2. Make a function that does acc = acc + [x] in a loop; profile it and fix the quadratic behaviour.
  3. Compare [any Shape] vs [Circle] vs an enum-based approach for summing areas of 1M shapes.
  4. Count spaces in a 50 MB string by iterating Characters, then by utf8; compare.
  5. Use MemoryLayout to find a struct with wasteful padding and reorder its fields.
  6. Profile the capstone indexer with --jobs 1 and --jobs <cores>; find what scales and what doesn't.

Recap

  • Measure release builds, with realistic data, using a profiler — not guesses.
  • Watch for CoW copies, quadratic loops, ARC traffic, existential boxing, and allocations in hot paths.
  • reserveCapacity, final, generics, utf8, and better data structures solve most problems.
  • Concurrency is a tool, not a speedup button: chunk work and avoid contention.

Next: Unsafe Swift & C interoperability →