swift-performence

Swift 的性能优化

在写代码中, 有很多因素会使得你的应用在某些方面会变慢, 这里自底向上来探索应用的性能优化.

这里主要是指, 在 Swift 方面的一些特性上的优化, 因为从 OC 到Swift, 原来有很多的运行时特性都被减弱了. 甚至还有一些内存方面使用上的变化.

Node1: 选择架构的时候, 深刻认识架构带来的影响和局限性很重要.
先谈一谈: inline, dynamic dispatch, static dispatch 之间的权衡, 以及相关结构是如何分配内存的, 怎样选择最合适的框架.

内存分配

对象内存的 allocationdeallocation 是代码中最大的开销之一, 同时也是不可避免的. 而且 Swift 会自行分配和释放内存, 而且 Swift 的 ARC 是强制的, coder 也不能手动管理内存了, 这部分对性能的影响, 我们得需要了解到.
此外, 它还存在两种类型的分配方式.

  1. 基于栈(stack-based)的内存分配. Swift 会尽可能在栈上分配内存. 栈的结构很简单, 先进后出.
  2. 基于堆(heap-based)的内存分配.这使得内存分配将具备更动态的生命周期, 但是这需要更为复杂的数据结构. 在堆中分配的话, 需要在堆中找到一个大小能容纳这个对象的空闲块(free block), 因此, 找到这个块, 然后再其中分配内存. 当我们需要释放内存的时候, 我们就必须搜索何处能重新插入该内存块, 这个过程会很慢. 还有为了县城安全, 我们还必须要对这些东西进行锁定和同步.

引用技术

在 Apple 的框架中, 有个引用计数(reference counting)的机制, 控制着这个对象什么时候被销毁. 这个操作相对不那么耗时, 但是由于使用次数很多, 因此它带来的性能影响仍然是很大的. 目前, Swift 是强制 ARC 的, 这样开发者就很容易忽略. 然而, 打开 Instrument 查看哪里影响代码运行速度的时候, 会发现20,000多次的 Retain 和 release, 这些操作占用了90%的代码运行时间!

编译器会自动在一些地方插入 ARC 管理的代码:

1
2
3
4
5
6
7
8
9
10
11
//  你写出来的是这样
func perform(with object: Object) {
object.doAThing()
}

// 经过编译器后会这样
func perform(with object: Object) {
__swift_retain(object)
object.doAThing()
__swift_release(object)
}

而且这些东回乡的 retainrelease 是原子操作(atomic operations), 也就不奇怪他们运行会缓慢了.

调度与对象

调度: dispatch. Swift 有三种调度的方式

会尽可能使用 内联(inline)方式, 这个不会有额外性能开销. 可以直接调用.
其次是: 静态调度(static dispatch), 本质上是通过V-Table 进行的查找和跳转, 这个操作会耗时1纳秒.
最后是: 动态调度(dynamic dispatch ), 这个耗时大概5纳秒, 当使用这个方式调度的数量多了之后, 带来的性能消耗会很可观..

Swift 也有两种类型的对象

1
2
3
4
5
6
7
8
9

class Index {
let section: Int
let item: Int
}

let i = Index(section: 1, item: 1)

let i2 = i

这是一个类, 类当中的数据都会在堆中分配内存, 当我们创建以上Index类的时候, 堆上便穿件了一个指向此 Index 的指针, 因此堆上便存放的里面sectionitem 的数据和空间.

如果建立引用, 我们就会有两个指针指向同一个堆上的区域. 他们之间内存共享, 是同一个东西.

编译后 Swift 会自动插入 retain 的操作

1
2
3
4
5
6
7
8
9
class Index {
let section: Int
let item: Int
}

let i = Index(section: 1, item: 1)

__swift_retain(i)
let i2 = i

结构体

结构体是一个很好的结构, 他会存粗在栈上, 并且通常会使用static dispatch 或者 inline dispatch, 存储在栈上的结构体将占用3个字(即64位, 3个字节, 是 CPU 处理的一个区块单元)的大小. 吐过您的结构体当中的数据量低于三种的话, 那么结构体的值会自动在栈上内联.

1
2
3
4
5
6
7
struct Index {
let section: Int
let item: Int
}

let i = Index(section: 1, item: 1)
let i2 = i

当我们创建这个结构体的时候, 带有sectionitemIndex 结构体会直接放到栈中, 这个时候不会有额外的内存分配. 当将其复制给另一个变量i2时, 这会将我们存储在栈当中的值再次复制一遍, 这个时候并不会出现引用的情况, 这就是value copy: 值拷贝, 因为是一个新的东西新的内存了, 也就没有引用了.

当在结构体内放入引用类型的话

1
2
3
4
5
6
7
//  持有内联指针的结构体(String 是结构体, 其会拥有指针指向静态区中的字符串)
struct User {
let name: String
let id: String
}

let u = User(name: "Joe", id: "1234")

当我们将其复制给别的变量的时候, 我们就有了共享给两个结构体的相同指针. 因此必须对这两个指针 retain 操作, 而不是在对象上执行单独的持有操作.(注, 这句话可能看几次才看得懂…就是说, 字符串是静态区的, 即使结构体赋值给另一个变量, 但是里面的引用类型还是指向了同一个内存区域, 所以新的结构体中的引用类型变量也会指向这些区域, 所以会发生 retain 操作)

1
2
3
4
5
6
7
8
9
10
struct User {
let name: String
let id: String
}

let u = User(name: "Joe",
id: "1234")
__swift_retain(u.name._textStorage)
__swift_retain(u.id._textStorage)
let u2 = u

如果引用类型是类的话, 那么性能耗费会更大!

抽象类型

Swift 提供了很多不同的抽象类型(abstraction), 允许我们自行决定代码该如何运行, 以及代码的性能特性.
实际环境中的使用:

1
2
3
4
5
6
7
8
9
10
11
struct Circle {
let radius: Double
let center: Point
func draw() {}
}

var circles = (1..<100_000_000).map { _ in Circle(...) }

for circle in circles {
circle.draw()
}

这个 Circle 结构体, 将占用三个 word 的大小, 并存储在栈上. 折耳根循环一亿次的代码, 在发布模式下耗时0.3秒. 改变一下需求, 加个绘线的需求.

当我们使用协议抽象这个draw()到协议中时时, 然后将数组的引用类型变更为这个协议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protocol Drawable {
func draw()
}
// 遵守协议
struct Circle: Drawable {
let radius: Double
let center: Point
func draw() {}
}
// 改变数组额度引用类型为`Drawable`
let drawables: [Drawable] = (1..<100_000_000).map { _ in Circle(...) }

for drawable in drawables {
drawable.draw()
}

这个运行耗时4.0秒, 比之前满了1300%! 仅仅是将数组类型指向了协议, 结构体遵守协议! Why?

因为之前的代码可以被静态调度, 从而在没有任何堆应用建立的情况下仍能执行.

这是堆如何实现的.
在之前, 在 for 循环中, Swift 编译器就是前往 V-Table 进行查找, 或者直接讲draw 函数内联

当我们用协议代替的时候, 此时它不知道遵守协议的对象是结构体还是类. 因为 任何类型都可以遵守协议 .

1
2
//  这句中, 不知道这个`Drawable` 会是结构体, 还是一个类.
var drawables: [Drawable] = (1..<100_000_000).map { _ in return Circle(...) }

那么将如何去调度draw 函数? 答案就卫浴协议记录表(protocol witness table. 也称为虚函数表)当中, 里面存放了应用当中每个实现协议的对象名(这里应该是指类对象), 并且在底层实现当中, 这个表本质上充当了这些类型的别名.

在这里的代码中, 协议记录表是从这个既有容器(existential container)中获取, 这个容器目前有三个字大小的结构体, 并且存放在其内部的值缓冲区当中. 此外海鱼协议记录表建立了引用关系.

如果一个结构体中有需要使用超过3个字的存储空间时, 是怎么处理的? 而且对性能有影响.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

protocol Drawable {
func draw()
}

struct Line: Drawable {
let origin: Point
let end: Point
func draw() {}
}

let drawables: [Drawable] = (1..<100_000_000).map { _ in Line(...) }

for drawable in drawables {
drawable.draw()
}

这个Line结构体, 运行时间达到45秒!

额外支出的消耗主要在内存的分配上. 因为缓冲区大小只有3个字, 而里面需要超过4个字的空间, 因此这些结构需要在堆中分配. 此外也与协议有点关系, 由于既有容器只能存储三个字大小的结构体, 或者也可以使用对象建立引用关系, 但这样同样需要某种名为值记录表的东西(value witness table). 这就是我们用来处理任意值的东西.

因此这里, 编译器会创建一个值记录表, 对每个缓冲区, 内联结构体来说, 都有三个字大小的缓冲区, 它负责对值或者类进行内存分配/拷贝/销毁/内存释放等操作.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

func draw(drawable: Drawable) {
drawable.draw()
}

let value: Drawable = Line()
draw(local: value)

// Generates
func draw(value: ECTDrawable) {
var drawable: ECTDrawable = ECTDrawable()
let vwt = value.vwt
let pwt = value.pwt
// 会利用传进来的值的两个表, 去创建空间或者做其他什么事情
drawable.vwt = value.vwt
drawable.pwt = value.pwt
vwt.allocateBuffAndCopyValue(&drawable, value)
pwt.draw(vwt.projectBuffer(&drawable)
}

上面那个例子中的Generates 就是这个过程的中间产物, 我们将 Line()传递给draw 函数.

实际情况是, 它将这个Drawable 协议传递到既有容器当中, 然后在函数内部再次创建. 这回对值记录表和协议记录表进行复制, 然后分配一个新的缓冲区, 然后将其他结构,类或者类似对象的值拷贝进这个缓冲区当中. 然后就使用协议记录表当中的draw 函数, 把真实的Drable 对象传递给这个函数/

可以看到, 值记录表和协议记录表会放在栈上, 而 Line 将会放在堆上, 从而最后将线绘制出来.

结论

对我们数据建模的方式进行简单的更改将可能会对性能造成巨大的影响. 那么怎么避免?

泛型~

之前的协议会导致性能的下降, 那么我们为什么要用泛型? 答案在于: 泛型允许我们做什么(也就是泛型会在使用时做了一些限制, 让编译器更清楚的知道情况, 避免插入多余的代码造成性能消耗)

1
2
3
struct Stack<T: Type> {
...
}

假设我们这里有一个带有泛型 T 的 Stack 结构体,它受到一个协议类型的约束。编译器所要做的,就是将这个 T 提换成相应的协议,或者替换为我们传入的具体类型。这些操作会一直沿着函数链 (function chain) 执行,并且编译器会创建直接对此类型进行操作的专用版本。

这样我们就无需再使用值记录表或者协议记录表了,并且还移除了既有容器,这可能是一个非常好的解决方式,这使得我们仍然能够写出真正快速运行的泛型代码,并且还具备 Swift 所提供的良好多态性。这就是所谓的静态多态性 (static polymorphism)。

您还可以通过使用枚举来改进数据模型,而不是从服务器中获取大量的字符串。例如,假设您正在构建一个社交应用,您需要对账户建立状态的管理,以前您可能会使用字符串来进行控制。

1
2
3
enum AccountStatus: String, RawRepresentable {
case .banned, .verified, incomplete
}

如果我们改用枚举的话,那么我们就无需进行内存分配了,当我们传递这个类型的时候,我们只是将枚举值进行传递,这是一个加快代码运行速度的好方法,同时也可以为整个应用程序提供更安全、更可读的代码。

此外,使用 u-模型或者演示者 (Presenter) 或者不同的抽象类型的形式,来构建特定类型的模型也是非常有用的,这使得我们能够精简掉应用当中许多不必要的部分。

Ref: 真实世界中的 Swift 性能优化