Swift 高级性能优化

写在前面

本文中会提到很多下划线开头的声明,这些声明并不是稳定的,且错误的使用可能导致未定义行为。在使用这些特性前,请确认你知道自己在做什么

另外,Swift 标准库以及 Apple Frameworks 中以下划线开头的声明不会出现在代码补全中,你需要自己输入完整的名称。

本文的知识大多来源于 Swift 的官方文档 UnderscoredAttributes.mdOptimizationTips.rst 并加上了我自己的理解和经验。阅读最新的官方原始文档可以帮助你更好地理解这些特性。

分支优化

if 语句是使用频率非常高的一个语句。在一些时候,我们能够预测到某一 if 语句进入一个分支的概率比另一个分支高,这时我们可以使用特殊语法告诉编译器如何对其进行优化。

这类优化只适用于进入一个分支的概率比进入另一个的概率高得多的情况。如果概率相差不大,这并不会有帮助。

_fastPath(_:)

使用 _fastPath(_:) 来说明进入 true 分支的可能性更高:

1
2
3
4
5
6
7
let result = Int.random(in: 0..<10)

if _fastPath(result < 9) {
// 90%
} else {
// 10%
}

_fastPath(_:) 函数本身接受一个 Bool 类型的参数,返回值类型也是 Bool。实际上,它的返回值与输入是一致的,这个函数只是起到一个标记的作用,告诉编译器如何对分支进行优化。

当然,如果没有 else 语句,_fastPath(_:) 也能够正常工作,语义是一样的。

_slowPath(_:)

用法与 _fastPath(_:) 一样,效果与其相反。使用 _slowPath(_:) 来说明进入 false 分支的可能性更高:

1
2
3
4
5
6
7
let result = Int.random(in: 0..<10)

if _slowPath(result == 9) {
// 10%
} else {
// 90%
}

_onFastPath()

标记所在的分支被运行的可能性更高:

1
2
3
4
5
6
let result = Int.random(in: 0..<10)

if result < 9 {
_onFastPath()
// Do really SIMPLE things.
}

_onFastPath() 并不是 _fastPath(_:) 的另一种写法,而是“更强”的版本。它会强制提高优化级别。错误的使用不仅会降低性能,还会导致二进制文件大小增加。

为声明添加特性

特性(attribute)用于修饰声明或类型,例如 @available(...) 就是一个特性。我们这里主要使用其修饰声明以对编译器提供额外的优化信息。

@_effects(...) 系列

@_effects(...) 告诉编译器函数的实现只会产生指定的副作用,以向编译器提供不易推断的额外优化信息。

如果函数的实现产生了指定副作用以外的副作用,即违反 @_effects(...) 特性提供的信息,则会导致未定义行为。因此请认真阅读各副作用定义的描述后再决定使用。

另外,函数中调用的其他函数所产生的副作用也属于此函数的副作用。如果你不清楚在此之中调用的其他函数的实现,请勿添加 @_effects(...) 特性。

@_effects(readnone)

标记函数不会产生任何可观测到的内存读写以及其他任何副作用。

此类函数并非完全不能读写内存,可以在这些函数内对函数内部的对象进行操作。例如:

1
2
3
4
5
@_effects(readnone)
func index(_ i: Int) -> String {
let array = ["A", "C", "E"]
return array[i]
}

函数被标记为 readnone 则代表它的两次传入相同参数的调用可以被简化为一次调用。例如:

1
2
3
let m = index(0)
// 执行任何操作
let n = index(0)

应当与下面的代码效果完全一致:

1
2
3
let m = index(0)
// 执行任何操作
let n = m

@_effects(readonly)

标记函数不会产生任何可观测到的内存写入以及除读取内存以外的其他任何副作用。

readnone 一样,函数也可以操作其内部的对象。

函数被标记为 readonly 代表如果它的返回值没有被使用,则可以不对此函数进行调用。

内联

通过对函数进行内联,可以消除处理函数调用时的开销。但如果函数体过大,内联会导致二进制大小增加,这是一个通过大小换取性能的操作。

@inlinable

将函数标记为 @inlinable,函数的实现将被作为模块公开接口的一部分被公开,这允许调用方使用函数的具体实现替换函数调用。也正因此,被标记为 @inlinable 的函数不能与带有 privatefileprivate 或是未标记为 @usableFromInlineinternal 符号交互。

@inline(__always)

这将强制函数总是内联,除非未开启优化(-Onone)或函数不能被内联(例如递归调用)。

@_transparent

这将在编译的早期阶段内联函数,即使是在未开启优化的构建中。优先考虑使用 @inline(__always) 而不是 @_transparent

写时复制

每次将值类型的实例作为参数传递或将其赋值到另一变量时,这个对象都会被完整复制一份。如果这个对象比较大,这就会是一个较大的开销。但在一些时候,即使不在内存中复制对象也不会影响程序运行,例如将值作为参数传递时,我们只读取值而不进行写入,这一份拷贝就是不必要的。使用写时复制(copy-on-write, 简称 COW)则可以解决此问题。

什么是写时复制

写时复制与其字面意思一样,只有在对象被写入时才真正地复制这个对象。例如下面的代码:

1
2
3
let a = [0, 7, 2]
var b = a
b.append(1)

在第二行,虽然我们定义了新的变量 b = a,但在此时数组并不会被复制。数组会在第三行,对新的变量进行写入时才会在内存中被复制。

Swift 标准库中的一些类型已经实现了写时复制,例如此处使用的 Array 类型。

自行实现写时复制

可以通过一个包装器来为自己的值类型实现写时复制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
final class Ref<T> {
var val: T
init(_ v: T) { val = v }
}

struct Box<T> {
var ref: Ref<T>
init(_ x: T) { ref = Ref(x) }

var value: T {
get { return ref.val }
set {
if !isKnownUniquelyReferenced(&ref) {
ref = Ref(newValue)
return
}
ref.val = newValue
}
}
}

使用:

1
2
3
4
5
6
struct Tree: P {
var node: Box<P?>
init() {
node = .init(things)
}
}