A primer on how (and why) Swift implements all 4 types of method dispatch, what the compiler does to your methods in secret, how the Swift runtime calls into your functions, and how to make your code runfaster.
If you’re like me, you’ve worked with a grizzled staff engineer that generously doles out helpful nitpicks to improve the performance of your code.
Perhaps, like me, you’ve also been that grizzled engineer.
You know the sort.
Nitpicks like “this method should be declared fileprivate”, or “keep your generics in the same file where they’re used”, and the all-time classic “mark this function as final” to help out the compiler and speed up your code.
In modern Swift, you might as well spend your time writing branch prediction hints for the compiler. That is, the compiler and runtime do a ton of optimisation under the hood that makes these tips redundant.
These optimisations are mostly around Method Dispatch.
That is, how Swift executes function calls. This knowledge is crucial for understanding the low-level performance characteristics of your code.
Today we’re going to learn:
How (and why) Swift implements all 4 types of dispatch
In broad Computer Science terms, “method dispatch” is the process for telling the CPU where to find executable code in memory for a function call.
Method dispatch can be static or dynamic, but these break down into 4 sub-types. As you move down, method dispatch becomes more flexible, but slower: The types create a bidirectional hierarchy of speed & flexibility.
This hierarchy is defined by indirection. That is, the number of times the CPU has to jump between pointers to find the machine code for a function.
Inlining (fastest, not flexible) This optimisation unrolls a method’s contents directly onto the code path.
Static dispatch (fast, not flexible) The function’s memory address is known, so the CPU jumps straight to it.
Table dispatch (slow, flexible) The CPU jumps twice: once to a table of pointers (to potential implementations), then once to the correct implementation.
Message dispatch (slowest, very flexible) There might be multiple jumps as the runtime traverses class and superclass metadata to locate method implementations. This dynamic approach implements caching that can speed it up past table dispatch once it’s ‘warmed up’.
Most languages pick just one or two approaches:
C only uses static dispatch since it is designed around predictable low-level performance.
Java only implements table dispatch because of its focus on portability, meaning the JVM must always be able to load classes dynamically.
Objective-C uses message dispatch because it is deliberately built for runtime dynamism, with language features like swizzling and method forwarding.
Swift implements all 4.
This is due to the scope of the language.
Swift is (designed to be) as performant as C when using value types like structs and enums, which use static dispatch.
Swift also supports classes, protocols and generics, which rely on the more flexible table dispatch to locate relevant implementations.
Message dispatch, honestly, is a legacy Objective-C holdover. Swift had to be fully compatible with Obj-C and UIKit in order to achieve adoption from the aforementioned grizzled staff engineers.
This is a double-edged sword: Swift gives engineers fine-grained control over the performance characteristics of their code; but this scope also introduces many of the gotchas and misunderstandings that trip up less experienced Swifties*
(I’m pretty sure this is what they call us).
Inlining
This is the fastest approach, and is actually not dispatch at all. Inlining is a compiler optimisation which replaces the call to a function with the actual code from inside that function.
We don’t usually control this*. The Swift compiler makes the call about inlining function calls during its SIL optimisation stages.
*but you can influence it by selecting size vs speed compiler optimisations.
Let’s say you had the following code:
If the compiler decides to inline this, the compiled Swift might be equivalent to this:
Here, the call to the addOne function is just replaced with the operation num + 1, where num is replaced with our argument, 2.
Pre-computing
The compiler can go further.
Since these numbers are hardcoded, the compiler actually has all the information it needs to calculate this result at compile-time. It can, literally, pre-compute the result we need here, giving us a simple number.
Subscribe for free to continue reading
Enter your email and get every post from Jacob’s Tech Tavern.