Last updated 7 min read

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:

/content/images/2024/07/Screenshot-2024-07-27-at-13.40.54.png
Screenshot 2024-07-27 at 13.40.54.png

Value representation is the memory layout of it in memory:

/content/images/2024/07/Screenshot-2024-07-27-at-13.41.03.png
Screenshot 2024-07-27 at 13.41.03.png
/content/images/2024/07/Screenshot-2024-07-27-at-13.45.08.png
Screenshot 2024-07-27 at 13.45.08.png

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
/content/images/2024/07/Screenshot-2024-07-27-at-14.04.27.png
Screenshot 2024-07-27 at 14.04.27.png
/content/images/2024/07/Screenshot-2024-07-27-at-14.04.47.png
Screenshot 2024-07-27 at 14.04.47.png

However, if the generic parameter has a limitation to be class, the size of the generic struct will be determined at compile time:

/content/images/2024/07/Screenshot-2024-07-27-at-14.04.57.png
Screenshot 2024-07-27 at 14.04.57.png

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:

/content/images/2024/07/Screenshot-2024-07-27-at-15.40.18.png
Screenshot 2024-07-27 at 15.40.18.png

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.

/content/images/2024/07/Screenshot-2024-07-27-at-16.30.00.png
Screenshot 2024-07-27 at 16.30.00.png

Call frames should have a constant size. For dynamically-sized types, they will reserve a place for a pointer:

/content/images/2024/07/Screenshot-2024-07-27-at-16.31.51.png
Screenshot 2024-07-27 at 16.31.51.png

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.

/content/images/2024/07/Screenshot-2024-07-27-at-16.35.40.png
Screenshot 2024-07-27 at 16.35.40.png

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
/content/images/2024/07/Screenshot-2024-07-27-at-16.44.28.png
Screenshot 2024-07-27 at 16.44.28.png

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:

/content/images/2024/07/Screenshot-2024-07-27-at-16.45.11.png
Screenshot 2024-07-27 at 16.45.11.png

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:

/content/images/2024/07/Screenshot-2024-07-27-at-16.56.35.png
Screenshot 2024-07-27 at 16.56.35.png

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:

/content/images/2024/07/Screenshot-2024-07-27-at-16.58.43.png
Screenshot 2024-07-27 at 16.58.43.png

For escaping closures context has to be heap-allocated to capture variables. Because we don't know how much time it will be needed.

/content/images/2024/07/Screenshot-2024-07-27-at-17.03.39.png
Screenshot 2024-07-27 at 17.03.39.png

Generic Types

/content/images/2024/07/Screenshot-2024-07-27-at-17.12.23.png
Screenshot 2024-07-27 at 17.12.23.png

Swift protocols are represented in runtime by a table of function pointers:

/content/images/2024/07/Screenshot-2024-07-27-at-17.09.32.png
Screenshot 2024-07-27 at 17.09.32.png

Every time we have a protocol constraint we also pass a pointer to the appropriate table as hidden parameters:

/content/images/2024/07/Screenshot-2024-07-27-at-17.13.50.png
Screenshot 2024-07-27 at 17.13.50.png

When we deal with protocol types it's different:

/content/images/2024/07/Screenshot-2024-07-27-at-17.17.52.png
Screenshot 2024-07-27 at 17.17.52.png

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.

/content/images/2024/07/Screenshot-2024-07-27-at-17.25.16.png
Screenshot 2024-07-27 at 17.25.16.png

That's the under-the-hood difference between these guys. A homogeneous array of data models vs a heterogeneous array of data models

/content/images/2024/07/Screenshot-2024-07-27-at-17.29.33.png
Screenshot 2024-07-27 at 17.29.33.png

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.

/content/images/2024/07/Screenshot-2024-07-27-at-17.32.01.png
Screenshot 2024-07-27 at 17.32.01.png

References