编写高性能的 Swift 代码

jopen 8年前

编写高性能的 Swift 代码

下面这篇文档收集了一系列编写高性能 Swift 代码的要诀和技巧。文档的目标读者是编译器和标准库开发人员。

文档中的一些技巧可以帮助提升您的 Swift 程序质量,使您的代码不容易出错且可读性更好。显式地标记最终类和类协议是两个显而易见的例子。 然而文档中还有一些技巧是不符合规矩的,扭曲的,仅仅解决一些比编译器或语言的特殊的临时性需求。文档中的很多建议来自于多方面的权衡,例如:运行时、字节大小、代码可读性等等。

启用优化

第一个应该做的事情就是启用优化。Swift 提供了三种不同的优化级别:

  • -Onone: 这意味着正常的开发。它执行最小优化和保存所有调试信息。

  • -O: 这意味着对于大多数生产代码。编译器执行积极地优化,可以大大改变提交代码的类型和数量。调试信息将被省略但还是会有损害的。

  • -Ounchecked: 这是一个特殊的优化模式,它意味着特定的库或应用程序,这是以安全性来交换的。编译器将删除所有溢出检查以及一些隐式类型检查。这不是在通常情况下使用的,因为它可能会导致内存安全问题和整数溢出。如果你仔细审查你的代码,那么对整数溢出和类型转换来说是安全的。

在 Xcode UI 中,可以修改的当前优化级别如下: 
...

整个组件优化

默认情况下 Swift 单独编译每个文件。这使得 Xcode 可以非常快速的并行编译多个文件。然而,分开编译每个文件可以预防某些编译器优化。Swift 也可以犹如它是一个文件一样编译整个程序,犹如就好像它是一个单一的编译单元一样优化这个程序。这个模式可以使用命令行 flag-whole-module-optimization 来激活。在这种模式下编译的程序将最最有可能需要更长时间来编译,单可以运行得更快。

这个模式可以通过 XCode 构建设置中的“Whole Module Optimization”来激活。

降低动态调度

Swift 在默认情况下是一个类似 Objective-C 的非常动态的语言。与 Objective-C 不同的是,Swift 给了程序员通过消除和减少这种特性来提供运行时性能的能力。本节提供几个可被用于这样的操作的语言结构的例子。

动态调度

类使用动态调度的方法和默认的属性访问。因此在下面的代码片段中,a.aProperty、a.doSomething() 和 a.doSomethingElse() 都将通过动态调度来调用:

class A {    var aProperty: [Int]    func doSomething() { ... }    dynamic doSomethingElse() { ... }  }    class B : A {    override var aProperty {      get { ... }      set { ... }    }      override func doSomething() { ... }  }    func usingAnA(a: A) {    a.doSomething()    a.aProperty = ...  }

在 Swift 中,动态调度默认通过一个 vtable[1](虚函数表)间接调用。如果使用一个 dynamic 关键字来声明,Swift 将会通过调用 Objective-C 通知来发送呼叫代替。这两种情况中,这种情况会比直接的函数调用较慢,因为它防止了对间接呼叫本身之外程序开销的许多编译器优化[2]。在性能关键的代码中,人们常常会想限制这种动态行为。

建议:当你知道声明不需要被重写时使用“final”。

final 关键字是一个类、一个方法、或一个属性声明中的一个限制,使得这样的声明不得被重写。这意味着编译器可以呼叫直接的函数调用代替间接调用。例如下面的 C.array1 和 D.array1 将会被直接[3]访问。与之相反,D.array2 将通过一个虚函数表访问:

final class C {    // No declarations in class 'C' can be overridden.    var array1: [Int]    func doSomething() { ... }  }    class D {    final var array1 [Int] // 'array1' cannot be overridden by a computed property.    var array2: [Int]      // 'array2' *can* be overridden by a computed property.  }    func usingC(c: C) {     c.array1[i] = ... // Can directly access C.array without going through dynamic dispatch.     c.doSomething() = ... // Can directly call C.doSomething without going through virtual dispatch.  }    func usingD(d: D) {     d.array1[i] = ... // Can directly access D.array1 without going through dynamic dispatch.     d.array2[i] = ... // Will access D.array2 through dynamic dispatch.  }

建议:当声明的东西不需要被文件外部被访问到的时候,就用“private”

将 private 关键词用在一个声明上,会限制对其进行了声明的文件的可见性。这会让编辑器有能力甄别出所有其它潜在的覆盖声明。如此,由于没有了任何这样的声明,使得编译器可以自动地推断出 final 关键词,并据此去掉对方面的间接调用和属性的访问。例如在如下的 e.doSomething()  和 f.myPrivateVar 中,就将可以被直接访问,假定在同一个文件中,E, F 并没有任何覆盖的声明:

private class E {    func doSomething() { ... }  }    class F {    private var myPrivateVar : Int  }    func usingE(e: E) {    e.doSomething() // There is no sub class in the file that declares this class.                    // The compiler can remove virtual calls to doSomething()                    // and directly call A’s doSomething method.  }    func usingF(f: F) -> Int {    return f.myPrivateVar  }

高效的使用容器类型

通用的容器 Array 和 Dictionary 是有 Swift 标准库提供的一个重要的功能特性。本节将介绍如何用一种高性能的方式使用这些类型。

建议:在数组中使用值类型

在 Swift 中,类型可以分为不同的两类:值类型(结构体,枚举,元组)和引用类型(类)。一个关键的区分是 NSArray 不能含有值类型。因此当使用值类型时,优化器就不需要去处理对 NSArray 的支持,从而可以在数组上省去大部分消耗。

此外,相比引用类型,如果值类型递归地含有引用类型,那么值类型仅仅需要引用计数器。而如果使用没有引用类型的值类型,就可以避免额外的开销,从而释放数组内的流量。

// Don't use a class here.  struct PhonebookEntry {    var name : String    var number : [Int]  }    var a : [PhonebookEntry]

记住要在使用大值类型和使用引用类型之间做好权衡。在某些情况下,拷贝和移动大值类型数据的消耗要大于移除桥接和持有/释放的消耗。

建议:当 NSArray 桥接不必要时,使用 ContiguousArray 存储引用类型。如果你需要一个引用类型的数组,而且数组不需要桥接到 NSArray 时,使用 ContiguousArray 替代 Array:

class C { ... }  var a: ContiguousArray<C> = [C(...), C(...), ..., C(...)]

建议:使用适当的改变而不是对象分配。

在 Swift 中所有的标准库容器都使用 COW(copy-on-write) 执行拷贝代替即时拷贝。在很多情况下,这可以让编译器通过持有容器而不是深度拷贝,从而省掉不必要的拷贝。如果容器的引用计数大于 1 并容器时被改变时,就会拷贝底层容器。例如:在下面这种情况:当 d 被分配给 c 时不拷贝,但是当 d 经历了结构性的改变追加 2,那么 d 将会被拷贝,然后 2 被追加到 b:

var c: [Int] = [ ... ]  var d = c        // No copy will occur here.  d.append(2)      // A copy *does* occur here.

如果用户不小心时,有时 COW 会引起额外的拷贝。例如,在函数中,试图通过对象分配执行修改。在 Swift 中,所有的参数传递时都会被拷贝一份,例如,参数在调用点之前持有一份,然后在调用的函数结束时释放。也就是说,像下面这样的函数:

func append_one(a: [Int]) -> [Int] {    a.append(1)    return a  }    var a = [1, 2, 3]  a = append_one(a)

尽管由于分配,a 的版本没有任何改变 ,在 append_one 后也没有使用 ,  但 a 也许会被拷贝。这可以通过使用 inout 参数来避免这个问题:

func append_one_in_place(inout a: [Int]) {    a.append(1)  }    var a = [1, 2, 3]  append_one_in_place(&a)

未检查操作

Swift 通过在执行普通计算时检查溢出的方法解决了整数溢出的 bug。这些检查在已确定没有内存安全问题会发生的高效的代码中,是不合适的。

建议:当你确切的知道不会发生溢出时使用未检查整型计算。

在对性能要求高的代码中,如果你知道你的代码是安全的,那么你可以忽略溢出检查。

a : [Int]  b : [Int]  c : [Int]    // Precondition: for all a[i], b[i]: a[i] + b[i] does not overflow!  for i in 0 ... n {    c[i] = a[i] &+ b[i]  }

泛型

Swift 通过泛型类型的使用,提供了一个非常强大的抽象机制 。Swift 编译器发出一个可以对任何 T 执行 MySwiftFunc<T> 的具体的代码块。生成的代码需要一个函数指针表和一个包含 T 的盒子作为额外的参数。MySwiftFunc<Int> 和 MySwiftFunc<String> 之间的不同的行为通过传递不同的函数指针表和通过盒子提供的抽象大小来说明。一个泛型的例子:

class MySwiftFunc<T> { ... }    MySwiftFunc<Int> X    // Will emit code that works with Int...  MySwiftFunc<String> Y // ... as well as String.

当优化器启用时,Swift 编译器寻找这段代码的调用,并试着确认在调用中具体使用的类型(例如:非泛型类型)。如果泛型函数的定义对优化器来说是可见的,并知道具体类型,Swift 编译器将生成一个有特殊类型的特殊泛型函数。那么调用这个特殊函数的这个过程就可以避免关联泛型的消耗。一些泛型的例子:

class MyStack<T> {    func push(element: T) { ... }    func pop() -> T { ... }  }    func myAlgorithm(a: [T], length: Int) { ... }    // The compiler can specialize code of MyStack[Int]  var stackOfInts: MyStack[Int]  // Use stack of ints.  for i in ... {    stack.push(...)    stack.pop(...)  }    var arrayOfInts: [Int]  // The compiler can emit a specialized version of 'myAlgorithm' targeted for  // [Int]' types.  myAlgorithm(arrayOfInts, arrayOfInts.length)

建议:将泛型的声明放在使用它的文件中

只有在泛型声明在当前模块可见的情况下优化器才能执行特殊化。这只有在使用泛型的代码和声明泛型的代码在同一个文件中才能发生。注意标准库是一个例外。在标准库中声明的泛型对所有模块可见并可以进行特殊化。

建议:允许编译器进行特殊化

只有当调用位置和被调函数位于同一个编译单元的时候编译器才能对泛型代码进行特殊化。我们可以使用一个技巧让编译器对被调函数进行优化,这个技巧就是在被调函数所在的编译单元中执行类型检查。执行类型检查的代码会重新分发这个调用到泛型函数---可是这一次它携带了类型信息。在下面的代码中,我们在函数 play_a_game 中插入了类型检查,使得代码的速度提高了几百倍。

//Framework.swift:    protocol Pingable { func ping() -> Self }  protocol Playable { func play() }    extension Int : Pingable {    func ping() -> Int { return self + 1 }  }    class Game<T : Pingable> : Playable {    var t : T      init (_ v : T) {t = v}      func play() {      for _ in 0...100_000_000 { t = t.ping() }    }  }    func play_a_game(game : Playable ) {    // This check allows the optimizer to specialize the    // generic call 'play'    if let z = game as? Game<Int> {      z.play()    } else {      game.play()    }  }    /// -------------- >8    // Application.swift:    play_a_game(Game(10))

大的值对象的开销

在 swift 语言中,值类型保存它们数据独有的一份拷贝。使用值类型有很多优点,比如值类型具有独立的状态。当我们拷贝值类型时(相当于复制,初始化参数传递等操作),程序会创建值类型的一个拷贝。对于大的值类型,这种拷贝时很耗费时间的,可能会影响到程序的性能。

让我们看一下下面这段代码。这段代码使用值类型的节点定义了一个树,树的节点包含了协议类型的其他节点,计算机图形场景经常由可以使用值类型表示的实体以及形态变化,因此这个例子很有实践意义

protocol P {}  struct Node : P {    var left, right : P?  }    struct Tree {    var node : P?    init() { ... }  }

当树进行拷贝时(参数传递,初始化或者赋值)整个树都需要被复制.这是一项花销很大的操作,需要很多的 malloc/free 调用以及以及大量的引用计数操作

然而,我们并不关系值是否被拷贝,只要在这些值还在内存中存在就可以。

对大的值类型使用 COW(copy-on-write,写时复制和数组有点类似)

减少复制大的值类型数据开销的办法时采用写时复制行为(当对象改变时才进行实际的复制工作)。最简单的实现写时复制的方案时使用已经存在的写时复制的数据结构,比如数组。Swift 的数据是值类型,但是当数组作为参数被传递时并不每次都进行复制,因为它具有写时复制的特性。

在我们的 Tree 的例子中我们通过将 tree 的内容包装成一个数组来减少复制的代价。这个简单的改变对我们 tree 数据结构的性能影响时巨大的,作为参数传递数组的代价从 O(n) 变为 O(1)。

struct tree : P {    var node : [P?]    init() {      node = [ thing ]    }  }

但是使用数组实现 COW 机制有两个明显的不足,第一个问题是数组暴露的诸如 append 以及 count 之类的方法在值包装的上下文中没有任何作用,这些方法使得引用类型的封装变得棘手。也许我们可以通过创建一个封装的结构体并隐藏这些不用的 API 来解决这个问题,但是却无法解决第二个问题。第二个问题就是数组内部存在保证程序安全性的代码以及和 OC 交互的代码。Swift 要检查给出的下表是否搂在数组的边界内,当保存值的时候需要检查是否需要扩充存储空间。这些运行时检查会降低速度。

一个替代的方案是实现一个专门的使用 COW 机制的数据结构代替采用数组作为值的封装。构建这样一个数据结构的示例如下所示:

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 (!isUniquelyReferencedNonObjC(&ref)) {              ref = Ref(newValue)              return            }            ref.val = newValue          }      }  }

类型 Box 可以代替上个例子中的数组

不安全的代码

Swift 语言的类都是采用引用计数进行内存管理的。Swift 编译器会在每次对象被访问的时候插入增加引用计数的代码。例如,考虑一个遍历使用类实现的一个链表的例子。遍历链表是通过移动引用到链表的下一个节点来完成的:elem = elem.next,每次移动这个引用,Swift 都要增加 next 对象的引用计数并减少前一个对象的引用计数,这种引用计数代价昂贵但是只要使用 swift 类就无法避免

final class Node {   var next: Node?   var data: Int   ...  }

建议:使用未托管的引用避免引用计数的负荷

在效率至上的代码中你可以选择使用未托管的引用。Unmanaged<T>结构体允许开发者对特别的引用关闭引用计数

var Ref : Unmanaged<Node> = Unmanaged.passUnretained(Head)    while let Next = Ref.takeUnretainedValue().next {    ...    Ref = Unmanaged.passUnretained(Next)  }

协议

建议:将只有类实现的协议标记为类协议

Swift 可以指定协议只能由类实现。标记协议只能由类实现的一个好处是编译器可以基于这一点对程序进行优化。例如,ARC 内存管理系统能够容易的持有(增加该对象的引用计数)如果它知道它正在处理一个类对象。如果编译器不知道这一点,它就必须假设结构体也可以实现协议,那么它就必须准备好持有或者释放不同的数据结构,而这代价将会十分昂贵。

如果限制只能由类实现某协议那么就标记该协议为类协议以获得更好的性能

protocol Pingable : class { func ping() -> Int }

脚注

【1】虚拟方法表或者 vtable 是被一个实例引用的一种包含类型方法地址的类型约束表。进行动态分发时,首先从对象中查找这张表然后查找表中的方法
【2】这是因为编译器并不知道那个具体的方法要被调用
【3】例如,直接加载一个类的字段或者直接调用一个方法
【4】解释 COW 是什么
【5】在特定情况下优化器能够通过内联和 ARC 优化技术移除 retain,release 因为没有引起复制