Folks have asked for ways to better control the performance of compiled code, so we're currently experimenting with an approach that we think might work well. This isn't ready to be a formal proposal yet, but we'd love to get more input and ideas from interested people so that we can eventually make this into an official part of the language.
In some usage domains, like system-level programming, it is very undesirable that the compiler implicitly generates calls to allocating or locking runtime functions.
We’d like to introduce a set of function annotations which help the developer to avoid unwanted runtime calls in certain parts of the code.
Motivation
Swift does not make any guarantees on which code patterns can call what runtime functions, which makes developing performance aware code very hard. For example, following language features can implicitly perform memory allocations:
- escaping closures allocate storage for captured values
- metadata instantiation, e.g. when calling a generic function or when doing a dynamic type cast
- using copy-on-write types, like Array
- creating existential types which exceed the size of three words
Following language features can implicitly perform a lock:
- static and global variables, because they are lazily initialized
- protocol conformance lookup
Without a deep compiler knowledge it’s difficult to write Swift code which avoids memory allocation or locks.
Proposed Solution
We propose new annotations that developers can use to mark functions that should not have any implicit compiler-inserted allocation or lock operations. If an annotated function contains some code which requires unwanted runtime operations, the compilation will fail with an error explaining the reason.
Detailed Design
Two function annotations can be used to enforce performance characteristics:
@noAllocation
func foo() { ... }
@noLocks
func foo() { ... }
The compiler analyses the runtime impact of an annotated function, i.e. checks if a certain code pattern requires allocations or locks. The analysis is conservative. That means, if the compiler is not sure about if a certain code pattern, it assumes the worst case, i.e. that it allocates or locks. For example, it will consider any type metadata runtime functions as potentially allocating (which is not true in general).
Functions, which are annotated with @noAllocation
are assumed to not allocate any heap memory. If this cannot be proved by the analysis, the compiler issues an error which describes the reason why a function does or could allocate.
Functions, which are annotated with @noLocks
are assumed to not do any locks. Again, if this cannot be proved by the analysis, the compiler issues an error which describes the reason why a function does or could lock.
The attribute @noLocks
implies noAllocation
because allocating heap memory also performs a lock.
Global and static variables
In Swift all global and static variables are lazily initialized, which involves a lock.
But the compiler can rewrite globals of trivial types (e.g. Int
) to be initialized in the data section. Therefore using such global variables don’t need a lock and using them in noLocks
functions is permitted.
Generic Functions
It is not be possible to put performance annotations on generic functions. In most cases the performance characteristics depends on the actual types for which a generic function is called. This cannot be expressed with simple annotations like @noLocks
or @noAllocation
.
But generic functions are a fundamental and important language feature. Therefore it is possible that performance annotated code calls generic functions. The compiler eagerly specializes generic functions which are called from performance annotated code. This avoids creating metadata for the generic parameters.
Escape hatch
To silence the performance diagnostics in a certain code area, the developer can use a compiler-known unsafePerformance
function. This is useful if either the developer knows that the callee is not allocating/locking or if it’s okay that the called function allocates or locks.
@noAllocations
func foo() -> Int {
return unsafePerformance { doesAllocate() } // no diagnostic
}
The unsafePerformance
function takes a closure. For all code within the closure, no performance diagnostics are issued.
Transitivity
In case of a function being called from a performance annotated caller, the compiler will also issue a diagnostic if the called function does or could allocate or lock.
For calls to functions in the same compilation unit, the compiler can derive this information either by analyzing the called function (if the function body is available) or if the called function is annotated itself. For example:
func add(x: Int, y: Int) -> Int {
return x + y
}
@noAllocations
func somethingMoreComplex() { ... }
@noAllocations
func timeCriticalFunction() {
let a = add(x: 1, y: 2) // no diagnostic
somethingMoreComplex() // no diagnostic
}
For calls where the callee is not known, the compiler can only assume that it’s allocation or lock free if the declaration is annotated. For example:
- protocol methods
- class methods
Error Handing
It is assumed that errors are only thrown in an exceptional case where performance is not relevant anymore.
Therefore, code which is within a throw
or catch
path is excluded from performance diagnostics.
Effect on ABI stability
None.
Effect on API resilience
There is no effect on API resilience for existing, not annotated code.
Removing a performance annotation from a function in a new version of a library would break the performance assumptions of a client. Therefore, removing a performance annotation is an API break.
Future Directions
One of the most important features to add is a safe and allocation free alternative to Swift’s Array
. Currently the only option for an allocation free array-like data structure is to use unsafe APIs, like UnsafeMutableBuffer
.