Key Takeaways
Low-level cost comes from calls, allocation, layout, and copying
Inline vs out-of-line storage sets copy cost and ARC traffic
Abstractions have a cost, but it is often worth paying
Presenters
John McCall, Swift Team
Before we start, what is performance?:
Performance is multidimensional and situational
Macroscopic: power consumption, latency, memory limits
Usually fixed top-down with algorithmic improvements (e.g. Instruments)
Microscopic: when that’s not enough, low-level performance is dominated by 4 factors
Swift’s optimizer removes many issues before you ever see them, but it has limits
How you write code affects how much it can do → monitor performance to catch regressions
Low-level principles
1. Function calls
Every call has costs:
Set up arguments (calling convention)
Resolve the function’s address (dispatch)
Allocate space for the function’s local state (call frame)

Argument passing: values placed in registers per the calling convention
Usually near-free (hidden by register renaming)
Passing ownership of a value can add extra
retain/releasework
Dispatch: depends on where the method is declared
Static dispatch: target known at compile time → cheaper, enables inlining & generic specialization
Dynamic dispatch: target resolved at runtime → needed for polymorphism, but blocks compile-time optimization
func updateAll(models: [any DataModel], from source: DataSource) {
for model in models {
// kind of call depends on where the method is declared
model.update(from: source)
}
}
// declared in main body of the protocol (protocol required)
// - call uses dynamic dispatch
protocol DataModel {
func update(from source: DataSource, quickly: Bool)
}
// declared in protocol extension
// - call uses static dispatch
extension DataModel {
func update(from source: DataSource) {
self.update(from: source, quickly: true)
}
}Call frame: holds a function’s local state, laid out on the C stack
Allocated at entry by just subtracting from the stack pointer (
sub sp, sp, #N), restored at exit → reason putting things in theCallFrameis idealSize doesn’t affect cost (unlike heap allocation) → local memory in the call frame is essentially free
2. Memory allocation
Three kinds of memory, all from the same pool of RAM: Global / Stack / Heap
Global memory: allocated & initialized at program load
Almost free
Only for fixed amounts of memory that live for the whole program (global variables,
staticmembers)
Stack memory: allocated via the call frame (subtract from the stack pointer)
Very cheap
Memory must be scoped (local
let/var, parameters, temporaries)
Heap memory: allocated/freed at arbitrary times (
malloc/free)Most flexible, but substantially more expensive → must find free space, stay thread-safe, handle fragmentation
Used for
class/actorinstances, escaping closures, anytime a scope restriction can’t be provenOften has shared ownership
Swift manages its lifetime with reference counting (
retain/release, freed when count hits 0)
3. Memory layout
Terminology:
Value: the high-level information content, regardless of how it’s stored
Representation: how that value is actually arranged in memory
Inline representation: the portion of the representation without following pointers
e.g.
Array’s inline representation is a single buffer reference →MemoryLayout.size(ofValue: array) == 8


Value contexts — every value in Swift is logically contained in some context
Local scope: local variables, intermediate results of expressions
Instance context: non-static stored properties
Global context: global variables, static stored properties
Dynamic context: buffers managed by
ArrayandDictionary
Every value has a static type → the type’s rules dictate the value’s representation, while the context’s rules provide the memory that holds the inline representation
A type’s representation is built from the representations of its stored properties
struct/enum/tuple: inline storage → everything laid out inline in the container (usually in declaration order)class/actor: out-of-line storage → fields live in a heap object, container just holds a pointer
Some types keep part of their representation out-of-line on the heap (e.g.
Array,Stringstore elements in a buffer)Inline vs. out-of-line is a tradeoff:
Inline: no heap allocation (great for small types), but copying large types duplicates all fields → costly
Out-of-line: needs a heap allocation, but copies can share the same object → cheaper when copied a lot

4. Value copying
Ownership = responsibility for a value’s representation (balancing each
retainwith arelease):Consume: transfer ownership elsewhere. Copying from an existing variable retains the buffer; if it’s the last use the compiler transfers with no copy, which you can force with
consumeMutate: temporarily take ownership, then hand it back when done → requires exclusive access
Borrow: assert nobody else can consume/mutate it → ideal for read-only use (e.g. passing to
print); if Swift can’t prove exclusivity it inserts a defensive copy (common forclassproperties)
func makeArray() {
var array = [1.0, 2.0]
var array2 = array // copies: retains the buffer (unless this is the last use)
var array3 = consume array // explicitly transfers ownership, no copy — using `array` after is an error
}Copying = duplicating the inline representation to get a new one with independent ownership
Copying a class value just copies the reference’s ownership → a single
retainof the object it points toCopying a struct value recursively copies all its stored properties → every reference-typed field must be retained
Copying value types is often not “just copying bits”: e.g. a
Personstruct with three reference-typed fields does 3 retains per copy, whereas making it a class would do only 1
High-level features in Swift
Dynamically-sized types
Unlike C structs (always constant-size), a Swift type’s size can be determined at runtime:
Resilient SDK value types that reserve the right to add/change stored properties in a future OS (e.g. Foundation’s
URL) → their whole layout is unknown at compile timeA generic type’s type parameter, which can be replaced by any type with any representation → its layout is also unknown
Exception: constraining the parameter to a class (
where T: AnyObject) means it’s always a pointer → more efficient code even without generic specialization

Most containers: Swift lays the type out at runtime → the compiler knows the static layout up to the first dynamically-sized property, and the runtime fills in the rest on first use (loading sizes/offsets dynamically instead of using constants)
Some containers must have constant size (global variables, call frames) → the value is allocated separately from the container:
Global variable: the compiler makes a pointer-typed global and lazily heap-allocates the storage on first access
Local variable: the call frame just holds a pointer; since locals are scoped, the storage can still be allocated on the C stack (another subtraction from the stack pointer when it enters scope, reset when it leaves)
Async functions
C threads are a precious resource → blocking one just to wait wastes it, so async functions are implemented in two special ways:
They keep local state on a separate stack from the C stack
They’re split into multiple partial functions at runtime, one per gap between potential suspension points (
await)
Async stack: instead of one large contiguous stack, an async task holds one or more slabs of memory
Allocating asks the task for space in the current slab
if it doesn’t fit, the task
mallocs a new slab; deallocating just hands the memory back
Used by a single task with a stack discipline
typically much faster than
malloc, so the profile is similar to sync functions, just with slightly higher per-call overhead




Function splitting: a function with one
awaitbecomes two partial functions (before/after the suspension point) → local state that must cross a suspension point can’t live on the C stackAt most one partial function is on the C stack at a time: each runs like an ordinary C function up to the next suspension point, then tail-calls the next one (its call frame disappears, the next is allocated)
If a task actually suspends, it returns normally on the C stack → control goes to the concurrency runtime so the thread can be reused immediately

Closures
Closures are passed around as function-type values
A function value is a pair of a function pointer + context pointer
Calling it just calls the function pointer, passing the context pointer as an implicit extra argument
Non-escaping closure:
Value won’t be used after the call returns
No memory management needed, so the context can be a simple struct allocated on the stack (scoped allocation)

Escaping closure:
Used beyond the call
The context object must be heap-allocated and managed with retain/release (it behaves like an instance of an anonymous class)

Captured local
vars are captured by reference (changes are visible in both the closure and the original scope):Captured only by non-escaping closures → variable’s lifetime is unchanged, so the closure just captures a pointer to its allocation
Captured by an escaping closure → the variable’s lifetime can extend as long as the closure lives → it must also be heap-allocated, with the context holding a reference to it
Generics
A generic type’s layout is statically unknown (handled by the containers described earlier); how are its protocol constraints executed?
A protocol is represented at runtime by a witness table — a table of function pointers, one per protocol requirement
Wherever there’s a protocol constraint, Swift passes around a pointer to the appropriate witness table
Generic function (default): the type metadata and witness tables become hidden extra parameters → one shared version of the function works for any conforming type
The array is homogeneous → elements are packed efficiently, and the type info is passed once as top-level arguments
func updateAll<Model: DataModel>(models: [Model], from source: DataSource) { for model in models { model.update(from: source) } }Values of protocol type (
any DataModel): more flexible (each element can be a different type), but different characteristicsThe existential has a fixed-size inline buffer (3 pointers) plus the value’s type and conformances → values that fit go inline, larger ones are heap-allocated with just a pointer stored
The array is heterogeneous → each element carries its own dynamic type and isn’t densely packed
func updateAll(models: [any DataModel], from source: DataSource) { for model in models { model.update(from: source) } }Specialization: when the optimizer can see the concrete type at the call site, it can inline the call or generate a specialized version → removes the generic abstraction cost, calling the requirement directly
Needs the optimizer to see both caller and callee (same module,
@inlinable, or whole-module optimization); across protocol-type values it’s much harder, so you get less help from the compiler there
These are just costs, and abstraction is often worth paying for. Don’t avoid protocol types — build an intuition for when a cost actually matters.
