# Swift Performance Benchmarks: Structs vs Classes, Arrays, Strings, and Loops (Swift 6.4, -O)

Source: https://devdocs10.com/articles/swift/swift-performance-benchmarks  
By Murat Bo · Published 2026-10-17 · DevDocs10 (https://devdocs10.com)

How much faster are structs than classes, does `reserveCapacity` matter, is `forEach` slower than `for-in`, and when does a `Dictionary` beat a plain array search? This article answers with numbers measured on one machine on 2026-10-17 with `swiftc -O`, explains why each result comes out the way it does, and includes the complete benchmark source so you can reproduce it.

> **Quick answer:** With `-O`, `for-in`, `forEach`, `reduce` and an index loop all summed an array at the same speed (0.21 ns per element); switching `+` to the wrapping `&+` made the sum 4.0× faster because it lets the compiler vectorize. Creating a million `final class` instances took about 8 ns each, roughly 16× the cost of structs in most runs (4–16× across all runs), copying an array of class references was 6.2× slower than copying structs, `reserveCapacity` made building many small arrays 2.1× faster, `+=` and `append` are the same operation, and a linear array search beat `Dictionary` up to about 16 `Int` keys but only up to about 8 `String` keys.

> **Difficulty:** Intermediate to Advanced
>
> **You'll understand this better if you know:** [structs](/articles/swift/swift-structs) and classes, [value vs reference semantics](/articles/swift/swift-value-vs-reference-semantics), [copy-on-write](/articles/swift/swift-copy-on-write), and [ARC](/articles/swift/swift-arc).

## 1. Test Setup

| Item | Value |
| --- | --- |
| Machine | Apple M6, 24 GB RAM (12 cores; `sysctl` reports 2 &ldquo;Super&rdquo;, 4 &ldquo;Performance&rdquo; and 6 &ldquo;Efficiency&rdquo; cores) |
| Operating system | macOS 27.0.1 (build 26A434) |
| Compiler | swift-driver version: 1.168.6 Apple Swift version 6.4 (swiftlang-6.4.0.34.1 clang-2100.3.34.1), target `arm64-apple-macosx27.0.0` (from `swift --version`) |
| Build | `swiftc -O SwiftBench.swift` (single file, default Swift 5 language mode; the file also compiles with `-swift-version 6`). The same file was also built with `-Onone` for section 7. |
| Timer | `ContinuousClock`. Readings advance in steps of about 41.7 ns. An empty timed repetition (two clock reads) measured 23 ns and 43 ns in the two full runs and between 15 and 69 ns in extra standalone runs; it varies from process to process. |
| Date measured | 2026-10-17 |

### Methodology

- **Every repetition is timed on its own.** The clock is read before and after each repetition. Because a clock read is an opaque call, the optimizer cannot hoist work out of a timed region or merge repetitions together.
- **3 warm-up runs, then 10 measured runs per case.** A run is one or more repetitions; tables report the **median** time per element or operation (ns/op), the min–max range of the 10 runs, and speed relative to the fastest variant in the same table.
- **No dead code.** Every result is folded into a global `checksum` that is printed at the end, and inputs are mixed with a value the compiler cannot know (`CommandLine.arguments.count`), so nothing can be constant-folded or deleted. Most functions under test are marked `@inline(never)`, so they are compiled as standalone functions rather than folded into the timing code.
- **What is timed.** Results are freed after the clock stops, except in the &ldquo;destroy&rdquo; rows, which time exactly that.
- **Repeated twice.** The full suite ran twice back to back; the *Re-run* column is the second run&rsquo;s median and the *-Onone* column comes from the unoptimized build. The machine was idle apart from normal background desktop processes (1-minute load average 4.25 before the first suite, partly from the author&rsquo;s own test runs just before, and 1.71 before the second).

That timer overhead is included in every repetition. It is negligible except for the shortest repetitions (the 10,000-element sums with `&+`, about 0.5 µs), where it adds roughly 3–13%. Across all 101 cases, the median min–max spread within a run was 4% and the median difference between the two runs was 2%.

## 2. Structs vs Classes: Creating, Copying, Reading, Freeing

The value type is `struct PointS { var x, y, z: Double }` and the reference type is a `final class PointC` with the same three stored properties. Each test works with 1,000,000 instances held in an array; ns/op is per instance.

### Creating 1,000,000 instances (appending to a reserved array)

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `struct (array of PointS)` | 1.11 | 0.97 – 1.39 | 1.00× | 2.34 | 58.1 |
| `final class (array of PointC)` | 8.28 | 7.97 – 8.63 | 7.44× | 8.61 | 65.2 |

> **Note:** Struct creation was the one unstable measurement in the suite. It took 1.11 ms per repetition in the first full run and 2.34 ms in the second, but 7 of 8 additional standalone runs of just these two cases measured 504.88 µs–516.90 µs (the other measured 1.47 ms). Class creation stayed between 8.17 ms and 8.61 ms. A check with `getrusage` found no page faults after the first repetition, so the slow mode is not explained by faulting in fresh memory; its cause was not identified.

### Copying the array (assignment, then one mutation)

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `struct PointS (values copied)` | 0.34 | 0.33 – 0.34 | 1.00× | 0.34 | 0.36 |
| `class PointC (references copied + retained)` | 2.10 | 1.89 – 2.20 | 6.23× | 1.97 | 1.34 |
| `struct Tagged {Int, String} (String retained)` | 2.07 | 1.92 – 2.19 | 6.16× | 2.11 | 1.65 |
| `class PointC deep copy (new instances)` | 13.0 | 12.9 – 13.5 | 38.65× | 13.6 | 46.9 |

### Reading: summing `.x` over all elements

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `struct PointS` | 0.51 | 0.50 – 0.52 | 1.00× | 0.52 | 34.4 |
| `final class PointC` | 1.53 | 1.47 – 1.55 | 2.98× | 1.53 | 40.4 |

### Freeing the array

| Variant | Median time to free 1,000,000 instances | Min – max | Re-run | -Onone |
| --- | --- | --- | --- | --- |
| `struct PointS` | 84 ns | 41 ns – 542 ns | 83 ns | 458 ns |
| `final class PointC` | 8.41 ms | 8.03 ms – 8.76 ms | 8.80 ms | 9.30 ms |

Why the gaps are this large:

- **Allocation.** `MemoryLayout<PointS>.stride` is 24 bytes, and the structs sit inline in one array buffer, so creating them is a sequence of stores. Every `PointC` is a separate heap allocation: `malloc_size` reported 48 bytes per instance (the object header with its metadata pointer and reference count, plus the 24 bytes of fields, rounded up by the allocator), and the array holds an 8-byte reference to each. That is why creating classes cost 8.28 ns per instance against 1.11 ns.
- **Freeing is the mirror image.** Releasing the struct array is one deallocation (under 1 µs in total, at the limit of the timer&rsquo;s resolution); releasing the class array has to release and free each object (8.41 ms).
- **Copying.** Assigning an array is O(1); the copy happens at the first mutation ([copy-on-write](/articles/swift/swift-copy-on-write)). For `PointS` that copy is a plain memory copy. For `PointC` only references are copied, but every one must be retained, so the shallow copy cost 6.2× as much, and a real deep copy (new instances) cost 38.7× as much.
- **Structs are not automatically cheap to copy.** `Tagged` holds a `String` longer than 15 bytes, which lives in heap storage, so copying an array of `Tagged` also retains every element (2.07 ns per element). Swift&rsquo;s [optimization tips](https://github.com/swiftlang/swift/blob/main/docs/OptimizationTips.rst) make the same point: value types need reference counting only if they contain references.
- **Reading.** Summing a field over the class array was 3.0× slower: each element is a reference that has to be followed to an object elsewhere in memory, and the data touched grows from 24 MB to about 56 MB.

## 3. Building Arrays: append With and Without reserveCapacity

Each variant builds an `[Int]` of *n* elements. For n = 1,000, one repetition builds 1,000 arrays; ns/op is per element.

### n = 1,000 (many small arrays)

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `append, no reserveCapacity` | 0.61 | 0.59 – 0.62 | 6.38× | 0.61 | 59.3 |
| `append after reserveCapacity(n)` | 0.28 | 0.28 – 0.29 | 2.98× | 0.30 | 58.9 |
| `Array(repeating:count:) + a[i] =` | 0.14 | 0.14 – 0.15 | 1.49× | 0.15 | 56.4 |
| `(0..<n).map { }` | 0.36 | 0.35 – 0.36 | 3.73× | 0.36 | 47.9 |
| `Array(unsafeUninitializedCapacity:)` | 0.10 | 0.10 – 0.10 | 1.00× | 0.10 | 50.6 |

### n = 1,000,000 (one large array)

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `append, no reserveCapacity` | 0.36 | 0.34 – 0.38 | 6.66× | 0.36 | 56.8 |
| `append after reserveCapacity(n)` | 0.32 | 0.32 – 0.32 | 5.84× | 0.33 | 56.2 |
| `Array(repeating:count:) + a[i] =` | 0.08 | 0.08 – 0.08 | 1.50× | 0.08 | 55.1 |
| `(0..<n).map { }` | 0.42 | 0.42 – 0.43 | 7.81× | 0.42 | 47.2 |
| `Array(unsafeUninitializedCapacity:)` | 0.05 | 0.05 – 0.05 | 1.00× | 0.05 | 50.8 |

- **`reserveCapacity` matters most for small arrays.** Apple&rsquo;s [Array documentation](https://developer.apple.com/documentation/swift/array) describes the growth strategy: when capacity runs out, the array allocates a larger buffer (a multiple of the old size) and copies its elements, so appends are constant time on average. Reserving up front removed those intermediate reallocations: 2.1× faster for 1,000-element arrays, but only 14% faster for one 1,000,000-element array, where the reallocations turned out to be a small part of the total.
- **Index assignment beat `append`.** `Array(repeating: 0, count: n)` followed by `a[i] = ...` was 3.9× faster than reserved appends at n = 1,000,000. In the optimized assembly, the Array(repeating:count:) version zero-fills the buffer with bzero and then runs a vectorized loop that stores eight Ints per iteration, while the reserved-append version is a scalar loop that still checks capacity on every iteration.
- **`Array(unsafeUninitializedCapacity:initializingWith:)`** was fastest of all because it skips both the zero-fill and the per-element checks, but you must initialize every element and set the count correctly yourself.
- **`map`** over a range cost 0.36 ns per element for small arrays (between the two append variants) and 0.42 ns at n = 1,000,000 (the slowest option at that size). It is still the most readable choice when the values come from a transformation.
- **Do not call `reserveCapacity` repeatedly with small increments.** Apple&rsquo;s documentation warns that reserving a little more space on every pass replaces exponential growth with linear growth, turning appends into quadratic work.

## 4. String Building: += vs append vs joined()

Each variant builds one string from 100,000 short pieces like `item123;` (about 1 MB). In the first table the pieces are created before timing; in the second each piece is created inside the loop.

### Concatenating 100,000 pre-built pieces

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `s += piece` | 8.26 | 8.23 – 8.39 | 1.02× | 8.26 | 42.6 |
| `s.append(piece)` | 8.13 | 7.88 – 8.34 | 1.01× | 8.09 | 42.2 |
| `reserveCapacity + s.append(piece)` | 8.07 | 8.05 – 8.10 | 1.00× | 8.07 | 42.7 |
| `pieces.joined()` | 8.20 | 8.10 – 8.46 | 1.02× | 8.52 | 8.29 |
| `pieces.reduce(into: "") { $0 += $1 }` | 9.36 | 9.33 – 9.41 | 1.16× | 9.11 | 41.9 |

### Building each piece inside the loop

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `s += "item\(i);"` | 24.1 | 23.3 – 24.4 | 1.00× | 23.7 | 79.7 |
| `s += "item" + String(i) + ";"` | 24.7 | 23.7 – 25.5 | 1.03× | 25.0 | 75.7 |
| `s.append("item"); s.append(String(i)); ...` | 31.0 | 30.2 – 31.9 | 1.28× | 31.3 | 82.7 |

### reduce("", +) vs reduce(into:)

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `reduce("", +), 5000 pieces` | 212 | 211 – 220 | 23.70× | 215 | 262 |
| `reduce("", +), 10000 pieces` | 544 | 538 – 549 | 121.28× | 545 | 590 |
| `reduce("", +), 20000 pieces` | 1,208 | 1,194 – 1,231 | 538.81× | 1,218 | 1,162 |
| `reduce(into:), 5000 pieces` | 8.97 | 8.96 – 9.02 | 1.00× | 8.79 | 42.7 |
| `reduce(into:), 10000 pieces` | 8.53 | 8.53 – 8.83 | 1.90× | 8.74 | 39.6 |
| `reduce(into:), 20000 pieces` | 9.00 | 8.61 – 9.15 | 4.01× | 9.22 | 42.1 |

- **`+=` and `append` are the same operation.** In the standard library, `static func += (lhs: inout String, rhs: String)` is implemented as `lhs.append(rhs)` ([String.swift](https://github.com/swiftlang/swift/blob/main/stdlib/public/core/String.swift)), and the measurements match (8.26 vs 8.13 ns per piece).
- **`reserveCapacity` did not help** (8.07 ns): reallocations were not a measurable part of the cost here.
- **`joined()`** matched the loop within a few percent (8.20 ns), and `reduce(into:)` was about 13% slower than the plain loop (9.36 ns).
- **`reduce("", +)` is quadratic.** String&rsquo;s `+` is `var result = lhs; result.append(rhs); return result`, and inside `reduce(_:_:)` the accumulator still references the old buffer, so every step copies the whole string built so far. Doubling the pieces from 10,000 to 20,000 multiplied the time by 4.4, and at 20,000 pieces it was 134× slower than `reduce(into:)`. The standard library&rsquo;s own documentation says `reduce(into:_:)` is preferred for efficiency when the result is a copy-on-write type ([SequenceAlgorithms.swift](https://github.com/swiftlang/swift/blob/main/stdlib/public/core/SequenceAlgorithms.swift)).
- **Creating the pieces costs more than joining them.** Interpolation (`"item\(i);"`) and `"item" + String(i) + ";"` cost the same (about 24.1 ns per piece); three separate `append` calls were 1.3× slower.

## 5. Loops: for-in vs forEach vs reduce

Each variant sums an `[Int]` of values below 1,000. The 10,000-element array (80 KB) stays in cache and is summed 1,000 times per run; the 10,000,000-element array (80 MB) is summed once per run.

### 10,000 Ints

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `for x in a { s += x }` | 0.21 | 0.21 – 0.21 | 4.03× | 0.21 | 32.4 |
| `for i in 0..<a.count { s += a[i] }` | 0.21 | 0.21 – 0.21 | 4.03× | 0.21 | 52.7 |
| `a.forEach { s += $0 }` | 0.21 | 0.21 – 0.21 | 4.03× | 0.21 | 33.0 |
| `a.reduce(0, +)` | 0.21 | 0.21 – 0.21 | 4.03× | 0.21 | 33.2 |
| `for x in a { s &+= x }` | 0.05 | 0.05 – 0.05 | 1.00× | 0.05 | 31.8 |
| `a.reduce(0, &+)` | 0.05 | 0.05 – 0.05 | 1.00× | 0.05 | 32.8 |

### 10,000,000 Ints

| Variant | Median (ns/op) | Min – max | Relative | Re-run | -Onone |
| --- | --- | --- | --- | --- | --- |
| `for x in a { s += x }` | 0.22 | 0.22 – 0.22 | 2.85× | 0.22 | 32.0 |
| `for i in 0..<a.count { s += a[i] }` | 0.22 | 0.21 – 0.22 | 2.86× | 0.22 | 52.7 |
| `a.forEach { s += $0 }` | 0.22 | 0.21 – 0.22 | 2.86× | 0.22 | 33.2 |
| `a.reduce(0, +)` | 0.22 | 0.22 – 0.22 | 2.86× | 0.22 | 33.0 |
| `for x in a { s &+= x }` | 0.08 | 0.08 – 0.08 | 1.02× | 0.07 | 31.8 |
| `a.reduce(0, &+)` | 0.08 | 0.07 – 0.08 | 1.00× | 0.07 | 32.9 |

- **Loop style made no difference at `-O`.** `forEach` and `reduce` are `@inlinable` generic functions in the standard library, so the compiler specializes them for `[Int]`, inlines the closure, and ends up with the same loop as `for-in`. The index loop compiled to the very same machine code (next point), so its bounds checks were optimized away.
- **Overflow checking was the real cost.** Swift&rsquo;s `+` traps on overflow, while `&+` wraps ([The Swift Programming Language: Advanced Operators](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/advancedoperators/)). With `&+` the in-cache sum was 4.0× faster and the 80 MB sum 2.8× faster. In the optimized binary, sumForIn and sumIndices share one machine-code body, and sumForEach and sumReduce share another with the same instructions: a scalar loop that does one checked add per element (an adds instruction followed by a branch to a trap on overflow). The &+ versions compile to a NEON loop that adds eight Ints per iteration in four 128-bit registers. Use wrapping arithmetic only where overflow is impossible or wrapping is the intended result, as the Swift optimization tips advise.
- **Memory bandwidth caps the large case.** The wrapping sum over 80 MB ran at 0.08 ns per element, about 104 GB/s of reads, so it is limited by memory rather than arithmetic.

## 6. Dictionary vs Array Lookup: Where Is the Crossover?

For each size *n*, the test performs 200,000 lookups of keys chosen at random from the *n* keys (every lookup hits). The array version stores keys and values in parallel arrays and uses `keys.firstIndex(of:)`; the dictionary version uses `dict[key]!`. Probe keys are built separately from the stored keys, so long strings are equal but not shared buffers. Values are ns per lookup; the faster option in each pair is bold.

| n | Int: Array | Int: Dictionary | short String: Array | short String: Dictionary | long String: Array | long String: Dictionary |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | **0.21** | 3.82 | **0.62** | 8.78 | **3.18** | 17.5 |
| 2 | **1.72** | 4.15 | **3.36** | 15.0 | **8.60** | 18.3 |
| 4 | **3.10** | 8.66 | **6.06** | 12.7 | **13.7** | 22.8 |
| 8 | 4.43 | **4.14** | **8.42** | 10.2 | **20.8** | 26.7 |
| 16 | **5.89** | 6.02 | **13.1** | 13.2 | 31.7 | **23.3** |
| 32 | 7.82 | **5.67** | 22.0 | **13.5** | 55.0 | **25.2** |
| 64 | 11.7 | **6.55** | 40.0 | **14.0** | 99.4 | **26.9** |
| 128 | 19.7 | **6.27** | 75.4 | **14.4** | 177 | **25.6** |
| 256 | 34.2 | **7.38** | 148 | **15.8** | 357 | **26.9** |

- **Crossover points on this machine:** across the two full runs, `Dictionary` won at every size from n = 32 with `Int` keys, from n = 16–32 with short strings, and from n = 16 with long strings. Below that the linear search won, with one exception where the hash layout happened to favor the dictionary (Int keys at n = 8 in the first run). Past the crossover `Dictionary` pulled away quickly (at n = 256 it was 4.6× faster with `Int` keys and 13.3× with long strings).
- **Why small n favors the array.** A dictionary lookup always hashes the key with `Hasher` (SipHash-1-3 according to [SE-0206](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0206-hashable-enhancements.md), which calls the choice an implementation detail) and then probes the table, about 3.82 ns for an `Int` even with one key. A linear search does about n/2 comparisons on average, and comparing two `Int` values is a single instruction.
- **Why the key type moves the crossover.** Strings of up to 15 UTF-8 code units are stored inline on 64-bit platforms ([Swift blog: UTF-8 String](https://www.swift.org/blog/utf8-string/)), so short keys compare cheaply. Longer keys live in separately allocated buffers: hashing them costs more (17.5 ns at n = 1), but every comparison in a linear scan costs more too, so the scan loses much sooner.
- **n = 1 is a special case:** the search always stops at the first element, so the loop exit is perfectly predictable. From n = 2 the exit depends on the random key, and the array cost jumps.
- **Dictionary timings vary from process to process.** `Hasher` is seeded randomly for each execution (SE-0206), so each run gets different collision patterns. With `Int` keys and n = 8, three normally seeded processes measured 9.0, 5.3, 7.1 ns per lookup; with `SWIFT_DETERMINISTIC_HASHING=1`, three processes measured 8.8–9.0 ns every time. That is why the dictionary column moves between runs and is not strictly increasing with n.

## 7. -O vs -Onone

The same source built with `-Onone` (the default for Debug builds in Xcode) gave very different numbers:

| Case | -O (ns/op) | -Onone (ns/op) | -Onone / -O |
| --- | --- | --- | --- |
| Sum 10k Ints: for-in | 0.21 | 32.4 | 154.1× |
| Sum 10k Ints: index loop | 0.21 | 52.7 | 250.3× |
| Sum 10k Ints: forEach | 0.21 | 33.0 | 156.9× |
| Sum 10k Ints: reduce | 0.21 | 33.2 | 157.6× |
| Create 1M structs | 1.11 | 58.1 | 52.2× |
| Create 1M class instances | 8.28 | 65.2 | 7.9× |
| Copy array of 1M structs | 0.34 | 0.36 | 1.1× |
| Free 1M class instances | 8.41 | 9.30 | 1.1× |
| Append 1M Ints (reserved) | 0.32 | 56.2 | 177.8× |
| String: s += piece | 8.26 | 42.6 | 5.2× |
| String: joined() | 8.20 | 8.29 | 1.0× |
| Int keys, n = 8: Array search | 4.43 | 214 | 48.3× |
| Int keys, n = 8: Dictionary | 4.14 | 56.0 | 13.5× |

- **Your own loops slow down the most**, around 154× for the sums, because nothing is inlined or specialized. The index loop was slower than `for-in` at `-Onone` (52.7 vs 32.4 ns).
- **Work done inside the precompiled runtime and standard library barely changes**: copying a struct array, freeing objects and `joined()` cost about the same in both builds.
- **The Dictionary crossover disappears.** At `-Onone` `Dictionary` was faster at every size tested, because the compiler only specializes generic code such as `firstIndex(of:)` when optimizations are enabled ([OptimizationTips](https://github.com/swiftlang/swift/blob/main/docs/OptimizationTips.rst)), so each comparison in the scan goes through generic dispatch.
- **One oddity:** copying an array of class references was faster in the `-Onone` build (1.34 vs 2.10 ns per element), and so was copying the `Tagged` array. Both builds have to retain every element; this benchmark does not show why the optimized copy is slower.
- **Structs vs classes looks closer than it is** at `-Onone`: creating structs (58.1 ns) was barely cheaper than classes (65.2 ns) because unoptimized overhead dominates both.

> **Note:** Never draw performance conclusions from a Debug build. Measure with the optimization level you ship, which for most apps is `-O` or `-Osize`.

## 8. What This Means for Your Code

- **Write loops however reads best.** At `-O`, `for-in`, `forEach` and `reduce` compiled to the same speed.
- **Prefer structs for plain data**, especially when you create, copy or free many values. The class costs here were per object, so they scale with how many objects you allocate.
- **Remember what is inside your structs.** A struct holding strings longer than 15 bytes, arrays or class references needs reference counting when copied.
- **Call `reserveCapacity` once when you know the final size**, especially when you build many small arrays. Do not call it repeatedly in a loop.
- **Use `+=`, `append`, `joined()` or `reduce(into:)` to build strings**, and never `reduce("", +)` on a large input.
- **Use wrapping operators only with a proof.** `&+` made a hot sum several times faster, but it turns an overflow trap into silently wrong results if your assumption is false.
- **Do not replace small dictionaries by reflex.** A linear search wins only for a handful of keys, and the margin is a few nanoseconds; a `Dictionary` stays fast as the data grows.

**When none of this matters:** most differences here are nanoseconds per element. Creating 1,000 class instances instead of structs costs about 7.17 µs extra, which is invisible next to a network request or a frame of UI layout. Measure first with Instruments or [performance tests](/articles/swift/swift-performance-testing), then optimize the code a profile points to.

## 9. Caveats

- **One machine, one compiler version.** Optimizer behavior changes between Swift releases, and other CPUs have different caches, memory bandwidth and branch predictors. Re-measure on your target hardware.
- **Single file, whole-module view.** The benchmark is one file, so the optimizer sees all code. Across module boundaries, non-`@inlinable` generic code cannot be specialized for your types.
- **Micro-benchmarks run in isolation.** All cases share one process, so the allocator&rsquo;s state carries over between cases, and in a real app other work competes for caches.
- **Timer resolution and overhead.** Readings advance in steps of about 41.7 ns and two clock reads cost 15–69 ns, which matters only for the shortest repetitions (about 0.5 µs).
- **Hashing is randomized.** Dictionary numbers can shift slightly from run to run.

## 10. Noise and Reproducibility

The optimized build was very stable: the median min–max spread within a run was 4% and the median change between the two full runs was 2% (for `-Onone`: 2% and 2%). Cases with a within-run spread of 15% or more: `destroy (release the array) / struct PointS` (13.22×), `n = 4 / Array linear search (firstIndex(of:))` (1.54×), `create / struct (array of PointS)` (1.43×), `n = 2 / Dictionary subscript` (1.25×), `n = 64 / Dictionary subscript` (1.24×), `n = 1 / Array linear search (firstIndex(of:))` (1.22×). Cases whose median moved by 10% or more between runs: `Int keys/n = 8 / Dictionary subscript` (+142%), `1M instances/create / struct (array of PointS)` (+110%), `calibration / empty repetition (clock overhead)` (+85%), `short String keys ("key17", stored inline)/n = 8 / Dictionary subscript` (+50%), `short String keys ("key17", stored inline)/n = 4 / Dictionary subscript` (+39%), `Int keys/n = 4 / Dictionary subscript` (-23%), `Int keys/n = 128 / Dictionary subscript` (+16%), `Int keys/n = 64 / Dictionary subscript` (+16%). Most of that movement is in Dictionary lookups (random hash seeds, section 6), in struct creation (section 2), and in sub-microsecond measurements that sit at the timer&rsquo;s resolution (freeing the struct array, the empty calibration repetition).

## 11. Run the Benchmarks Yourself

Save the source below as `SwiftBench.swift`. It uses only the standard library (no Foundation) and needs Swift 5.10 or later for `nonisolated(unsafe)` ([SE-0412](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0412-strict-concurrency-for-global-variables.md)) and macOS 13 or later for `ContinuousClock`. The optimized suite takes a few seconds; the `-Onone` build takes a few minutes.

```bash
mkdir swift-bench && cd swift-bench
# save the source below as SwiftBench.swift, then:
swiftc -O SwiftBench.swift -o swift-bench
./swift-bench                  # every case, printed as tables plus raw RAW lines
./swift-bench dict-vs-array    # only cases whose group/name contains the filter
swiftc -Onone SwiftBench.swift -o swift-bench-onone && ./swift-bench-onone loop
```

### Complete benchmark source (SwiftBench.swift)

```swift
// SwiftBench.swift — Swift micro-benchmarks (structs/classes, arrays, strings, loops, lookups)
//
//   swiftc -O SwiftBench.swift -o swift-bench
//   ./swift-bench              run everything
//   ./swift-bench dict         run only cases whose "group/name" contains "dict"
//
// Method: every repetition is timed on its own with ContinuousClock, so no work can be
// moved out of, or merged across, timed regions. Each case does WARMUP_RUNS untimed runs,
// then MEASURED_RUNS timed runs; a run is `reps` repetitions and reports ns per repetition.
// Every result feeds `checksum`, which is printed at the end, so nothing is dead code.

let WARMUP_RUNS = 3
let MEASURED_RUNS = 10

nonisolated(unsafe) var checksum = 0
let filter = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : ""
// A value the compiler cannot know, mixed into inputs so nothing is constant-folded.
let seed = CommandLine.arguments.count &+ 6

let clock = ContinuousClock()

func nanoseconds(_ d: Duration) -> Double {
    let (s, atto) = d.components
    return Double(s) * 1e9 + Double(atto) * 1e-9
}

// ---------- reporting ----------
func fmt(_ x: Double, _ digits: Int = 2) -> String {
    var scale = 1.0
    for _ in 0..<digits { scale *= 10 }
    let r = (x * scale).rounded() / scale
    var s = String(r)
    if !s.contains(".") { s += "." }
    let decimals = s.split(separator: ".", omittingEmptySubsequences: false)[1].count
    if decimals < digits { s += String(repeating: "0", count: digits - decimals) }
    return s
}
func fmtTime(_ ns: Double) -> String {
    if ns >= 1e6 { return fmt(ns / 1e6) + " ms" }
    if ns >= 1e3 { return fmt(ns / 1e3) + " µs" }
    return fmt(ns, 1) + " ns"
}
extension String {
    func padded(_ n: Int, left: Bool = false) -> String {
        let pad = String(repeating: " ", count: max(0, n - count))
        return left ? pad + self : self + pad
    }
}

struct CaseResult { let name: String; let ops: Int; let runs: [Double] }
nonisolated(unsafe) var currentGroup = ""
nonisolated(unsafe) var groupResults: [CaseResult] = []

func median(_ xs: [Double]) -> Double {
    let s = xs.sorted()
    return s.count % 2 == 1 ? s[s.count / 2] : (s[s.count / 2 - 1] + s[s.count / 2]) / 2
}

func flushGroup() {
    guard !groupResults.isEmpty else { return }
    let best = groupResults.map { median($0.runs) }.min()!
    for r in groupResults {
        let m = median(r.runs)
        print("  " + r.name.padded(44)
              + " median " + fmtTime(m).padded(10, left: true)
              + "  min " + fmtTime(r.runs.min()!).padded(10, left: true)
              + "  max " + fmtTime(r.runs.max()!).padded(10, left: true)
              + "  " + fmt(m / Double(r.ops)).padded(9, left: true) + " ns/op  "
              + fmt(m / best) + "x")
    }
    groupResults = []
}

func record(_ group: String, _ name: String, ops: Int, runs: [Double]) {
    if group != currentGroup {
        flushGroup()
        currentGroup = group
        print("\n" + group)
    }
    groupResults.append(CaseResult(name: name, ops: ops, runs: runs))
    // raw line: every measured run in ns per repetition
    rawLines.append("RAW\t\(group)\t\(name)\t\(ops)\t" + runs.map { fmt($0, 1) }.joined(separator: ","))
}
nonisolated(unsafe) var rawLines: [String] = []

func selected(_ group: String, _ name: String) -> Bool {
    filter.isEmpty || (group + "/" + name).contains(filter)
}

// ---------- harness ----------
/// Times `body` (`reps` timed repetitions per run). `check` turns the result into an Int
/// for the checksum; it runs after the clock stops, and the result is freed after that.
@inline(never)
func bench<T>(_ group: String, _ name: String, ops: Int, reps: Int = 1,
              _ body: () -> T, check: (T) -> Int) {
    guard selected(group, name) else { return }
    func oneRun() -> Double {
        var total = 0.0
        for _ in 0..<reps {
            let t0 = clock.now
            let r = body()
            let t1 = clock.now
            checksum &+= check(r)
            total += nanoseconds(t1 - t0)
        }
        return total / Double(reps)
    }
    for _ in 0..<WARMUP_RUNS { _ = oneRun() }
    var runs: [Double] = []
    for _ in 0..<MEASURED_RUNS { runs.append(oneRun()) }
    record(group, name, ops: ops, runs: runs)
}

/// Like `bench`, but `prepare` builds a fresh input before every repetition (not timed).
@inline(never)
func benchPrepared<S, T>(_ group: String, _ name: String, ops: Int, reps: Int = 1,
                         prepare: () -> S, _ body: (S) -> T, check: (T) -> Int) {
    guard selected(group, name) else { return }
    func oneRun() -> Double {
        var total = 0.0
        for _ in 0..<reps {
            let input = prepare()
            let t0 = clock.now
            let r = body(input)
            let t1 = clock.now
            checksum &+= check(r)
            total += nanoseconds(t1 - t0)
        }
        return total / Double(reps)
    }
    for _ in 0..<WARMUP_RUNS { _ = oneRun() }
    var runs: [Double] = []
    for _ in 0..<MEASURED_RUNS { runs.append(oneRun()) }
    record(group, name, ops: ops, runs: runs)
}

// Deterministic pseudo-random numbers (LCG)
struct LCG {
    var state: UInt64
    mutating func next() -> Int {
        state = state &* 6364136223846793005 &+ 1442695040888963407
        return Int(truncatingIfNeeded: state >> 33)
    }
}

// Calibration: what an empty timed repetition costs (two clock reads)
bench("calibration", "empty repetition (clock overhead)", ops: 1, reps: 1_000, { 0 }, check: { $0 })

// =====================================================================
// 1. Structs vs classes
// =====================================================================
struct PointS { var x: Double; var y: Double; var z: Double }
final class PointC {
    var x: Double; var y: Double; var z: Double
    init(x: Double, y: Double, z: Double) { self.x = x; self.y = y; self.z = z }
}
struct Tagged { var id: Int; var name: String }   // a struct that holds a reference (String storage)
final class Box<T> { var value: T; init(_ v: T) { value = v } }

@inline(never) func makeStructs(_ n: Int) -> [PointS] {
    var a: [PointS] = []
    a.reserveCapacity(n)
    for i in 0..<n {
        let d = Double(i &+ seed)
        a.append(PointS(x: d, y: d + 1, z: d + 2))
    }
    return a
}
@inline(never) func makeClasses(_ n: Int) -> [PointC] {
    var a: [PointC] = []
    a.reserveCapacity(n)
    for i in 0..<n {
        let d = Double(i &+ seed)
        a.append(PointC(x: d, y: d + 1, z: d + 2))
    }
    return a
}

do {
    let n = 1_000_000
    let g = "struct-vs-class/1M instances"
    bench(g + "/create", "struct (array of PointS)", ops: n, { makeStructs(n) }, check: { Int($0[$0.count / 2].x) })
    bench(g + "/create", "final class (array of PointC)", ops: n, { makeClasses(n) }, check: { Int($0[$0.count / 2].x) })

    let structs = makeStructs(n)
    let classes = makeClasses(n)
    let tagged = (0..<n).map { Tagged(id: $0, name: "a-heap-allocated-name-\($0 &+ seed)") }

    // Copying an array: assigning is O(1); the first mutation copies the whole buffer.
    bench(g + "/copy array (first mutation triggers copy-on-write)", "struct PointS (values copied)", ops: n, {
        var b = structs; b[0].x += 1; return b
    }, check: { Int($0[0].x) })
    bench(g + "/copy array (first mutation triggers copy-on-write)", "class PointC (references copied + retained)", ops: n, {
        var b = classes; b[0] = b[1]; return b
    }, check: { Int($0[0].x) })
    bench(g + "/copy array (first mutation triggers copy-on-write)", "struct Tagged {Int, String} (String retained)", ops: n, {
        var b = tagged; b[0].id += 1; return b
    }, check: { $0[0].id })
    bench(g + "/copy array (first mutation triggers copy-on-write)", "class PointC deep copy (new instances)", ops: n, {
        classes.map { PointC(x: $0.x, y: $0.y, z: $0.z) }
    }, check: { Int($0[0].x) })

    bench(g + "/read: sum of .x over all elements", "struct PointS", ops: n, {
        var s = 0.0
        for p in structs { s += p.x }
        return s
    }, check: { Int($0) })
    bench(g + "/read: sum of .x over all elements", "final class PointC", ops: n, {
        var s = 0.0
        for p in classes { s += p.x }
        return s
    }, check: { Int($0) })

    // Freeing: an array of structs is one deallocation; an array of objects frees each object.
    benchPrepared(g + "/destroy (release the array)", "struct PointS", ops: n,
                  prepare: { Box(makeStructs(n)) }, { box in box.value = []; return box }, check: { $0.value.count })
    benchPrepared(g + "/destroy (release the array)", "final class PointC", ops: n,
                  prepare: { Box(makeClasses(n)) }, { box in box.value = []; return box }, check: { $0.value.count })
}

// =====================================================================
// 2. Building arrays: append with and without reserveCapacity
// =====================================================================
@inline(never) func buildAppend(_ n: Int) -> [Int] {
    var a: [Int] = []
    for i in 0..<n { a.append(i &* 2 &+ seed) }
    return a
}
@inline(never) func buildReserved(_ n: Int) -> [Int] {
    var a: [Int] = []
    a.reserveCapacity(n)
    for i in 0..<n { a.append(i &* 2 &+ seed) }
    return a
}
@inline(never) func buildRepeating(_ n: Int) -> [Int] {
    var a = [Int](repeating: 0, count: n)
    for i in 0..<n { a[i] = i &* 2 &+ seed }
    return a
}
@inline(never) func buildMap(_ n: Int) -> [Int] {
    (0..<n).map { $0 &* 2 &+ seed }
}
@inline(never) func buildUnsafe(_ n: Int) -> [Int] {
    [Int](unsafeUninitializedCapacity: n) { buf, count in
        for i in 0..<n { buf[i] = i &* 2 &+ seed }
        count = n
    }
}

do {
    let builders: [(String, (Int) -> [Int])] = [
        ("append, no reserveCapacity", buildAppend),
        ("append after reserveCapacity(n)", buildReserved),
        ("Array(repeating:count:) + a[i] =", buildRepeating),
        ("(0..<n).map { }", buildMap),
        ("Array(unsafeUninitializedCapacity:)", buildUnsafe),
    ]
    // n = 1,000: one repetition builds 1,000 arrays
    for (name, build) in builders {
        bench("array-build/n = 1,000 (x1,000 arrays per repetition)", name, ops: 1_000_000, {
            var s = 0
            for k in 0..<1_000 { let a = build(1_000); s &+= a[k] }
            return s
        }, check: { $0 })
    }
    for (name, build) in builders {
        bench("array-build/n = 1,000,000", name, ops: 1_000_000, reps: 3, { build(1_000_000) }, check: { $0[$0.count / 2] })
    }
}

// =====================================================================
// 3. Building a String from 100,000 pieces
// =====================================================================
do {
    let n = 100_000
    let pieces = (0..<n).map { "item\($0 &+ seed);" }       // short strings, built before timing
    let total = pieces.reduce(0) { $0 + $1.utf8.count }
    let g = "string-build/100k pieces (pre-built [String])"
    let check: (String) -> Int = { $0.utf8.count &+ Int($0.utf8.last!) }
    bench(g, "s += piece", ops: n, { var s = ""; for p in pieces { s += p }; return s }, check: check)
    bench(g, "s.append(piece)", ops: n, { var s = ""; for p in pieces { s.append(p) }; return s }, check: check)
    bench(g, "reserveCapacity + s.append(piece)", ops: n, {
        var s = ""; s.reserveCapacity(total); for p in pieces { s.append(p) }; return s
    }, check: check)
    bench(g, "pieces.joined()", ops: n, { pieces.joined() }, check: check)
    bench(g, "pieces.reduce(into: \"\") { $0 += $1 }", ops: n, { pieces.reduce(into: "") { $0 += $1 } }, check: check)

    let g2 = "string-build/100k pieces (built on the fly)"
    bench(g2, "s += \"item\\(i);\"", ops: n, {
        var s = ""; for i in 0..<n { s += "item\(i &+ seed);" }; return s
    }, check: check)
    bench(g2, "s += \"item\" + String(i) + \";\"", ops: n, {
        var s = ""; for i in 0..<n { s += "item" + String(i &+ seed) + ";" }; return s
    }, check: check)
    bench(g2, "s.append(\"item\"); s.append(String(i)); ...", ops: n, {
        var s = ""; for i in 0..<n { s.append("item"); s.append(String(i &+ seed)); s.append(";") }; return s
    }, check: check)

    // reduce("", +) copies the accumulated string on every step; measure how it scales
    for m in [5_000, 10_000, 20_000] {
        let sub = Array(pieces.prefix(m))
        bench("string-build/reduce(\"\", +) vs reduce(into:) scaling", "reduce(\"\", +), \(m) pieces", ops: m, { sub.reduce("", +) }, check: check)
        bench("string-build/reduce(\"\", +) vs reduce(into:) scaling", "reduce(into:), \(m) pieces", ops: m, { sub.reduce(into: "") { $0 += $1 } }, check: check)
    }
}

// =====================================================================
// 4. Loops: for-in vs forEach vs reduce (sum of [Int])
// =====================================================================
@inline(never) func sumForIn(_ a: [Int]) -> Int { var s = 0; for x in a { s += x }; return s }
@inline(never) func sumIndices(_ a: [Int]) -> Int { var s = 0; for i in 0..<a.count { s += a[i] }; return s }
@inline(never) func sumForEach(_ a: [Int]) -> Int { var s = 0; a.forEach { s += $0 }; return s }
@inline(never) func sumReduce(_ a: [Int]) -> Int { a.reduce(0, +) }
@inline(never) func sumForInWrapping(_ a: [Int]) -> Int { var s = 0; for x in a { s &+= x }; return s }
@inline(never) func sumReduceWrapping(_ a: [Int]) -> Int { a.reduce(0, &+) }

do {
    var rng = LCG(state: UInt64(seed))
    for (label, n, reps) in [("10k Ints (fits in cache)", 10_000, 1_000), ("10M Ints (80 MB)", 10_000_000, 1)] {
        let a = (0..<n).map { _ in rng.next() % 1_000 }
        let g = "loop/sum of \(label)"
        let fns: [(String, ([Int]) -> Int)] = [
            ("for x in a { s += x }", sumForIn),
            ("for i in 0..<a.count { s += a[i] }", sumIndices),
            ("a.forEach { s += $0 }", sumForEach),
            ("a.reduce(0, +)", sumReduce),
            ("for x in a { s &+= x }  (wrapping add)", sumForInWrapping),
            ("a.reduce(0, &+)  (wrapping add)", sumReduceWrapping),
        ]
        for (name, f) in fns {
            bench(g, name, ops: n, reps: reps, { f(a) }, check: { $0 })
        }
    }
}

// =====================================================================
// 5. Dictionary vs Array (linear search) lookup for small n
// =====================================================================
@inline(never) func lookupLinear<K: Equatable>(_ keys: [K], _ values: [Int], _ probes: [K]) -> Int {
    var s = 0
    for p in probes { s &+= values[keys.firstIndex(of: p)!] }
    return s
}
@inline(never) func lookupDict<K: Hashable>(_ dict: [K: Int], _ probes: [K]) -> Int {
    var s = 0
    for p in probes { s &+= dict[p]! }
    return s
}

func crossover<K: Hashable>(_ label: String, _ makeKey: (Int) -> K) {
    let m = 200_000   // lookups per repetition, keys chosen at random from the n keys
    for n in [1, 2, 4, 8, 16, 32, 64, 128, 256] {
        let keys = (0..<n).map(makeKey)
        let values = Array(0..<n)
        let dict = Dictionary(uniqueKeysWithValues: zip(keys, values))
        var rng = LCG(state: UInt64(seed &* 31 &+ n))
        let probes = (0..<m).map { _ in makeKey(rng.next() % n) }   // equal values, separately built
        let g = "dict-vs-array/\(label)/n = \(n)"
        bench(g, "Array linear search (firstIndex(of:))", ops: m, { lookupLinear(keys, values, probes) }, check: { $0 })
        bench(g, "Dictionary subscript", ops: m, { lookupDict(dict, probes) }, check: { $0 })
    }
}

crossover("Int keys") { $0 &* 7919 &+ seed }
crossover("short String keys (\"key17\", stored inline)") { "key\($0)" }
crossover("long String keys (\"user-profile-setting-17\", heap)") { "user-profile-setting-\($0)" }

flushGroup()
print("")
for line in rawLines { print(line) }
print("checksum \(checksum)")
```

## 12. Key Points

- At `-O`, `for-in`, `forEach`, `reduce` and an index loop all summed an array at 0.21 ns per element.
- Overflow checks, not loop style, decided the speed: `&+` was 4.0× faster in cache.
- Creating 1M class instances cost 4–16× more than structs (about 16× in most runs), freeing them took 8.41 ms, and reading through them was 3.0× slower.
- Copying an array of class references, or of structs that hold heap-backed strings, retains every element.
- `reserveCapacity` made building 1,000-element arrays 2.1× faster and a single 1M-element array only slightly faster.
- `+=` is `append`; `reduce("", +)` is quadratic and should be `reduce(into:)`.
- A linear search beat `Dictionary` up to about 16 `Int` keys and about 8 `String` keys; past that, `Dictionary` wins by a growing margin.
- `-Onone` made code you write 5–250× slower and says nothing about release performance.

## 13. Final Summary

With `swiftc -O` on Swift 6.4, the optimizer erases most stylistic differences: closures passed to `forEach` and `reduce` are inlined, `+=` and `append` are literally the same code, and loop syntax does not change the generated loop. The costs that remain are structural: one heap allocation, reference count and deallocation per class instance, a retain per element when copying references, reallocation when arrays grow without reserved capacity, quadratic copying in `reduce("", +)`, overflow checks that block vectorization, and hashing that only pays off once a collection has more than a handful of keys. These numbers come from one machine on one day; run the source on your own hardware and toolchain, and let a profiler tell you which of them matters for your app.
