Before your code is spat out as machine code, several optimisations take place. A technique that was introduced with Swift and has since been adopted by some other languages is the Swift Intermediary Language (SIL). This is code that’s sort of like Swift, but optimisations can be performed on it.
For example, one of these optimisations is function inlining. In Objective-C, calling a method on a target, such as [dog bark], after compilation actually looks like objc_msgSend(dog, @selector(bark)). Objective-C is a dynamic language, meaning there’s weak binding between the method and the object. Function inlining is an optimisation that can be performed on Swift code, as Swift is a static language, so the closure can be copy/pasted to the calling site. Since a function call in Objective-C is just calling objc_msgSend, the method might not be there or swizzled, so inlining can’t be done. Since Swift uses static binding, it uses a Virtual Method Table for storing methods. Vtable is an array of function pointers, meaning that a class has a list of its methods with the memory address of that method implementation. This means the binding is very strong and can be done at compile time. More optimisations can be done if the method isn’t overridden, whereby the function lookup in the vtable can be skipped and the method can just be inlined.
Another trick is constants vs variables. Which do you think would be faster – using a single variable and mutating it, or creating a new constant each time you want a new value? Turns out they’re either equal or the new constant is slightly faster. This is again due to a performance optimisation, meaning using constants for code readability is generally preferred to variables which change, unless it makes sense.
Global functions in Swift provide us with the best performance, but using too many makes code difficult to follow. So you might instead use static methods, which can’t be overridden. These have the same performance as a global function, but with a namespace for clarity. Class methods can be overridden and could lead to a performance loss and should be used when you need class inheritance, otherwise static methods are preferred. Make methods final when you don’t need to override them. This tells the compiler that optimisation and performance could be increased because of that.
The Swift compiler can remove code that is useless. For example:
when compiled will just be:
Swift actually goes one step further, since the compiler understands that the object instance isn’t used, it can actually remove the instantiation line too, meaning all three lines are removed. Swift will also remove empty loops. Again, because Objective-C has a dynamic runtime, the method could be changed to do some work at runtime, and so the empty method calls can’t be removed.
A danger to watch out for here is print and debugPrint function calls. If you have a loop that only prints the current index or value, for example, the compiler treats print and debugPrint as a valid and important instruction that can’t be skipped. So if you have a huge loop and all it does is print, it won’t be optimised out.
To fix this, we can use configuration flags and our own printd(_ items: Any…) function that only actually calls print #if DEBUG. This way, the call to printd will be removed if we’re not in DEBUG.
Another thing to note is that all the standard frameworks, such as Foundation and UIKit, are written in Objective-C, and the types – UIView, UITableView, use the Objective-C runtime. This means they don’t perform as fast as Swift types, and there’s no way to remove the Objective-C runtime dispatch from the Objective-C types in Swift, so it’s a good idea to know how to use them.
Value v Reference Types
You may have heard that value types are faster to use than reference types, and the reason why can be seen in the assembly code. Let’s say we have a class and a struct, both of which are initialised with an Int.
We’ll do the same thing to both of these – assign a variable to an instance of one, and then change the instance of the same variable.
In SIL, the NumberValue result will look like this:
You can see that when reassigning the y variable to a new instance of NumberValue, the code output is the same (apart from the new Int passed in).
Now if we check the assembly code for the class reference type, we can see that initialising the NumberReference class the first time is very similar to the struct:
But then when it comes to reassigning that variable to a new instance, we get a few bits of extra stuff on top:
This is ARC releasing the previous reference since we no longer need it. This is where the main performance benefit of value types come in. When used in a loop (each time in the loop you create a new instance of either NumberValue or NumberReference), for example, the value type will have a huge performance gain over the reference type, especially if you’re looping thousands of times – up to four times faster.
Compiling – SIL
Now let’s take a look at the compilation process for Swift code. First let’s look at a brief overview of the Swift compilation pipeline. Let’s imagine we have a swift file and we compile it. This goes through multiple stages before spitting an output out that we can use:
Firstly, the swift code you’ve written goes through a parser, which just figures out what methods you’re calling, parameters you’re passing in, etc. That then goes through Abstract Syntax Tree (AST) compilation, which is really just a tree representation of the parser unit. The AST will then go through the semantic analysis stage, which makes sure that if you’re calling a method that takes an string, that you are passing it a string and not anything else. That spits out another AST with decorated values on top. The decorated AST then goes through a process called SILGen.
SILGen is a concept brought forward in Swift, which is starting to be used more and more by languages which use LLVM, which builds a swift-based intermediary language. The reason this is important is that it allows all these optimisations we’ve talked about thus far to take place. Those optimisations would be much harder to do further down the pipeline, so creating Swift Intermediate Language and optimising code there makes it much easier. As well as all the optimisations we’ve discussed so far, this stage also takes into account things like whole module optimisation, which if turned on, will mark classes which aren’t overridden as final for you.
The output from SILGen then goes through IRGen which spits out a bunch of LLVM IR code, which can then finally be translated by LLVM into .o files or .dylib files.
Going back to the SILGen output, let’s quickly take a look at how some things are represented here. If you have a function that throws, SIL represents this with a tuple, like: let (ok, fail) = throwingFunction(). Fail will be non-nil if the function threw an error. The way SIL checks the fail is how you use the try keyword. By doing try?, SIL checks the fail and if it’s nil, returns nil, else carries on with ok. If you do try!, it will ignore fail and try and use ok, and subsequently crash if it’s nil. And if you wrap try in a throwing function, it just passes the fail up to the caller.
Default arguments work by calling .defaultArgument1(), .defaultArgument2(), etc, on the function and assigning the results to variables, then calling the function and passing the variables as the arguments to satisfy the function parameters.
So, this is getting sort of long so I’ll wrap it up, but I thought it’d be interesting to take a look under the hood of Swift compilation. Hopefully you enjoyed this trip with me! See you next time!