Explore Swift Performance – WWDC24.
#Swift, #Software Engineering, #iOS App Development
Function calls
Function dispatch can be static vs dynamic.
Static dispatch is used when the compiler knows exactly what function is going to be called. The call goes directly to the function definition.
In that case, the compiler can inline or specialize that call.
Dynamic dispatch is used when the call can go to a different function definition. The compiler has to be conservative with his assumptions about the call.
Dynamic Dispatch
- Call to opaque function values
- overridable class methods
- calls to protocol requirements
- call to Obj-C or virtual C++ methods
Static Dispatch
- anything else
Memory Management
There are 3 types of Memory:
- Global
- Stack
- Heap
Global is allocated when a program is run. Used for global variables and types' static properties. It's cheap but never gets freed.
Stack memory is a place to put functions' parameters and function local variables.
Heap memory is used for reference objects: classes and actors. Memory allocation on the heap is expensive.
Value Representation
Value is the actual content:
Value representation is the memory layout of it in memory:
Value Context
Every value in Swift is logically contained in some context.
- A local scope (e.g. local variables, intermediate results of expressions)
- An instance context (e.g. non-static stored properties)
- A global context (e.g. global variables, static stored properties)
- A dynamic context (e.g. buffers managed by Array and Dictionary)
Copying
Value can be:
- Consumed
- Mutated
- Borrowed
Value is implicitly consumed by the variable when you assign variables.
var array = [1.0, 2.0]
var array2 = array
//or explicitly:
var array = [1.0, 2.0]
var array2 = consume array
//in this way, the swift compiler won't allow to use the array var anymore
During mutation, the ownership of the value is transferred to the mutating function and then back to the variable:
var array = [1.0, 2.0]
//Ownership of the current value in the array is transferred to the append method
array.append(3.0)
//After the call, ownership of the new value is transferred back to the array
BTW, here is more about ownership
Borrowing takes place in the following scenarios:
- Calling a normal method on a value or class reference
- Passing a value to a normal or borrowing parameter
- Reading the value of a property
Borrowing asserts that nothing else has ownership of the value.
func makeArray() {
//compiler must prove that there is no
// simultaneous mutations of the array
var array = [1.0, 2.0]
print(array)
//should just borrow the current value of the array
}
func makeArray(object: MyClass) {
//compiler struggles to reason about references to the object
//may have to make a defensive copy
var object.array = [1.0, 2.0]
print(object.array)
}
Mechanics of Copy
Copying a value actually means copying the inline representation.
Inline vs out-of-line storage
For types using out-of-line storage (e.g. classes):
- Copies (retains) the object reference
For types using inline storage (e.g. structs):
- Recursively copies the inline representation of all stored properties
Inline Storage
- Avoids heaps allocation
- great for small types
- The more properties you have the more expensive it is to copy
Out-of-line Storage
Out-of-line mutable storage naturally has reference semantics
- Mutations to one value are visible in copies of it
- Challenging in multi-threaded environments
You can still get value semantics using copy-on-write
- Wrap a class reference with a struct
- In mutations, copy the object if the reference isn't unique
Dynamically Sized Value Types
For some of the value types, the compiler does not know their exact size at compile time.
- Nonfrozen value types
- Types with generic parameters
However, if the generic parameter has a limitation to be class, the size of the generic struct will be determined at compile time:
For dynamic-sized properties Swift will determine its size at runtime the first time the program needs the layout for the type.
As soon as it knows its size it will work as if Swift compiler would have known it statically:
Dynamically-sized types in fixed-size containers
Some containers must have a constant size:
- global memory
- call frames
Memory for value must be allocated separately The container stores a pointer to the allocation
For global var of dynamic size, it will allocate the memory on the heap lazily on the first time you access it.
Call frames should have a constant size. For dynamically-sized types, they will reserve a place for a pointer:
When the variable comes into scope the memory will be allocated dynamically and freed when the var is out of scope.
Because local variables are scoped this can be done on the stack.
Async Functions
Async functions are designed to be able to abandon a C thread when they need to suspend
- Keep local state on a special stack
- Split functions into partial functions that run between suspensions
Async functions allocate memory on stack pretty much like sync functions. But they do it on a special stack.
This stack is related to Task. If there is not enough memory for the function call frame Task will allocate another slab with malloc:
Since it's used for a single task and uses stack discipline. It's significantly faster than just malloc.
Closures
Function values are always a pair of function pointer and a context pointer:
For non-espacing closures, we know that the closure will be used only for the duration of the call.
Here is what the context will look like:
For escaping closures context has to be heap-allocated to capture variables. Because we don't know how much time it will be needed.
Generic Types
Swift protocols are represented in runtime by a table of function pointers:
Every time we have a protocol constraint we also pass a pointer to the appropriate table as hidden parameters:
When we deal with protocol types it's different:
Every instance of the model in the array conforms to DataModel, but can be a different type.
The inline representation of AnyDataModel
will have a field to store the value type and conformance that we know it has.
AnyDataModel should have a constant-size type regardless of the value type.
Swift uses an arbitrary buffer size of 3 pointers.
It's chosen heuristically to fit the most common types.
If the value stored in the protocol type can fit into the buffer Swift will put it there inline. Otherwise, it allocates space for the value on the heap and stores that pointer in the buffer.
That's the under-the-hood difference between these guys. A homogeneous array of data models vs a heterogeneous array of data models
In the 1st case, the function can be specialized when the caller knows the type it's being called with.
The compiler can easily inline the call or produce a specialized version of the function. This will remove any abstraction cost associated with the usage of generic type.
Comments