Understanding @inlinable in Swift

Understanding @inlinable in Swift

The @inlinable attribute is one of Swift's lesser-known attributes. Like others of its kind, its purpose is to enable a specific set of micro-optimizations that you can use to increase the performance of your application. Let's take a look at how this one works.

Inline expansion in Swift with @inline

Perhaps the most important thing to note is that although @inlinable is related to code inlining, it's not the same as the @inline attribute we already covered here at SwiftRocks. But to avoid you from having to read two articles, we'll go through the concepts again before introducing @inlinable.

In programming, inline expansion, also called inlining, is a compiler optimization technique that replaces a method call with the body of the said method.

The action of calling a method is hardly free. As we covered back in SwiftRocks's article about memory allocation, a lot of orchestration is done to transmit, store and mutate the state of your application when it desires to push a new stack trace to a thread. While for one side having a stack trace makes your debugging life easier, you might wonder if it's necessary to do this every time. If a method is too simple, the overhead of calling it might be seen as something not only unnecessary but also harmful for the overall performance of the app:

func printPlusOne(_ num: Int) {
    print("My number: \(num + 1)")
}

print("I'm going to print some numbers!")
printPlusOne(5)
printPlusOne(6)
printPlusOne(7)
print("Done!")

A method like printPlusOne is too simple to justify a complete definition in the application's binary. We define it in code for clarity reasons, but when pushing this app for release it would arguably be better to get rid of it and replace everyone that is calling it with the full implementation, like this:

print("I'm going to print some number!")
print("My number: \(5 + 1)")
print("My number: \(6 + 1)")
print("My number: \(7 + 1)")
print("Done!")

This removed method-calling overhead may increase the performance of the app with the trade-off of slightly increasing the overall binary size, depending on how large the inlined methods are. This process is done automatically by the Swift compiler, with variable degrees of aggressiveness depending on which optimization level you are building for. As we covered in The Forbidden @inline Attribute in Swift, the @inline attribute can be used to ignore the optimization level and force the compiler to follow a particular direction, and that the act of inlining can also be useful for obfuscation purposes.

What is the purpose of @inlinable?

One important aspect of most optimizations like inlining is that they are mostly done internally. While you can be confident that a module you're developing will be properly optimized, things are a lot more complicated when we're dealing with calls made from other modules.

Compiler optimizations happen because the compiler has the full picture of what's being compiled, but when you're building a framework the compiler cannot possibly know how the importers are going to it. As a result, while the internal code of the framework will be optimized, the public interface will most likely end up intact.

A first thought may be "well, we could tell the compiler what sources composed that framework so that it could regain the full picture and apply more optimizations to the call sites", but this gets complicated when you realize that the importer of the said framework is linking something that is already compiled. All this information on source files is gone, and you may not even have them in the first place if this is a third-party framework!

However, this is not an impossible problem. In fact, while compilers have different solutions for this problem, they mostly follow this same idea of supercharging a module's public interface to contain additional information that the compiler can use at link time to further optimize the pieces of code in where a framework is being used, which includes, but is not exclusive to, inlining.

In practice though, you may notice that this could seriously get out of hand. If we start adding information from every single method to the public interface, not only the framework would massively jump in size but most of that would probably go to waste! As we don't know how the framework is going to be used, doing so in a discriminatory manner would be a terrible mistake.

Instead of gambling, Swift instead lets you make this decision for yourself. Introduced in Swift 4.2, the @inlinable attribute allows you to enable cross-module inlining for a particular method. When this is done, the implementation of the method will be exposed as part of the module's public interface, allowing the compiler to further optimize calls made from different modules.

@inlinable func printPlusOne(_ num: Int) {
    print("My number: \(num + 1)")
}

If the inlinable method happens to be calling other methods internally, the compiler will ask you to also expose these methods. This can be done either by also making them @inlinable or by marking them with @usableFromInline, which indicates that although the method is being used from an inlined method, it itself is not really supposed to be inlined.

@inlinable func myMethod() {
    myMethodHelper()
}

// Used within an inlinable method, but not inlinable itself
@usableFromInline internal func myMethodHelper() {
    // ...
}

The largest benefit of @inlinable lies in how much overhead certain methods might have. Even though the overhead of most methods can be negligible, some methods can be quite expensive, specifically if they contain generics and closures:

@inlinable public func allEqual<T>(_ seq: T) -> Bool where T : Sequence, T.Element : Equatable {
 // ...
}

The compiler already applies multiple optimizations to prevent generics from being a problem in general, but as we mentioned, these don't apply when the code is being called from a separate module. For cases like that, using @inlinable can give you interesting performance boosts at the cost of an increased framework binary size.

On the other hand, @inlinable can be a massive problem depending on what you're building. If the implementation of an @inlinable method changes, the modules that import it will not be able to make use of the modifications unless they are recompiled. Normally you would be able to update a framework by simply replacing the binary, but because the implementation of some methods got inlined, the app will keep running the old behavior even if you're linking to the new version. Because of this, apps with the library evolution setting enabled may find themselves unable to play around with @inlinable as this can break the ABI stability of your framework.

Should I use @inlinable, or even @inline?

Unless you're building a framework with ABI/API stability, these attributes should be perfectly safe to use. Still, I would strongly suggest for you to not use them unless you know what you're doing. They are built to be used in very special cases that most applications will never experience, so unless you really have to, it may be better for you to see these as nothing more than a piece of cool Swift trivia you read at SwiftRocks.

References and Good Reads

SE-0193: Cross-module inlining and specialization