編寫高性能的Swift代碼(譯)
這篇文章搜集了一些編寫高性能的swift代碼的一些方法和技巧。本文面向的讀者是編譯器和標準庫的開發者。
本文提到的一些技巧可以提高Swift程序的質量,可以減少錯誤的發生并提高可讀性。明確的標注final類以及面向協議(protocols)編程是兩個很明顯的例子。然而,本文中還有一些技巧是不正規的,扭曲的,只是為了解決編譯器或者語言的臨時限制而提出的。本文中的許多建議都是在權衡了程序運行時、二進制應用大小,代碼可讀性而提出的。
打開優化選項
首先需要打開優化選項,Swift提供了三種優化級別:
- -O none: 這是開發中最通用的優化選項,它提供了最少的優化并保留了所有的調試信息(debug info)
- -O: 這是大部分發布版本時的優化選項,編譯器會進行激進的優化,他會劇烈的更改類型以及發布的代碼,調試信息也會發布但是會刪減
- -O unchecked: 這是為特殊的類庫或者應用準備的,當人們基于安全性的考慮不想保留調試信息時。編譯器會刪除所有溢出檢查以及隱式的類型轉換檢查。通常情況下不要食用我這種優化選項,因為這樣無法檢查內存安全問題以及整型一出問題。只有在你已經確定你的代碼在整型溢出以及類型轉化方面沒有問題才使用這種優化選項。
使用方法如下:xcrun swiftc -Onone Assembly.swift -o none
優化整個模塊
默認情況下Swift獨立的編譯每個文件,這可以讓XCode并行的編譯多個文件,以達到提高編譯速度的效果。然而,獨立的編譯每個文件使得編譯器無法進行某些優化。Swift也可編譯整個項目,就好像它只是一個文件一樣。SWi ft還可以優化整個程序就好像它是一個編譯單元一樣。這種模式可以使用命令行標記(command linw flag) -whole-module-optimization。 在這種模式下編譯的程序需要花費更長的編譯時間,但是性能更高。
這種模式可以使用Xcode 編譯選項 Whole Module Optimization 打開
減少動態分發
和OC相似,Swift默認是一個十分動態的語言。但和OC不同的是,它可以讓程序猿移除或者減少這種動態性以提高性能。這一部分給出了幾個例子演示語言的這個特性。
動態分發
類默認情況下采用動態分發的方式調用方法以及訪問屬性,因此在下面的代碼片段中,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中,動態分發是通過查找虛擬方法表[1]進行迂回的調用。如果編程時在聲明前面附加了dynamic關鍵字,Swift將會通過OC發送消息的方式完成方法調用。這兩種方式都比直接調用方法速度慢,因為他阻止了很多的編譯器優化[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修飾符,但是編譯器可以推斷出final(編譯器認為和final一樣),所以編譯器會自動去除不直接的方法調用而采用直接調用字段的方式。例如,在下面的代碼片段中,e.doSomething()和f.myPrivateVar會采用直接調用的方式,因為編譯器假定在相同文件中不會重載這個聲明
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
} 高效的使用容器類型
Swift語言一個重要的特性是在標準庫中提供了通用的容器類,如數組和詞典(Array和Dictionary)。這部分怎樣高效的使用這些類型
建議:在數組中使用值類型
swift語言的類型可以分為兩大類:值類型(結構體,枚舉和元組)和引用類型(類)。這兩者之間的一個關鍵的區別是值類型不能存儲在NSArray中(NSArray只能添加引用類型的數據)。這樣在使用值類型時,優化器可以避免大部分數組可能要處理的像數組備份之類的繁重的工作。
另外,和引用類型相比,值類型只有在它包含引用類型的數據時才進行引用計數,使用不包含引用類型的值類型可以在數組中避免額外的持有、釋放操作。
// Don't use a class here.
struct PhonebookEntry {
var name : String
var number : [Int]
}
var a : [PhonebookEntry] 注意這里有一個在大的值類型以及引用類型之間的權衡問題。在某種情況下,拷貝和移動值類型所要話費的開銷要比對引用類型進行引用計數的開銷大。
建議:不需要橋接NSArray時,使用保存引用類型的ContiguousArray
如果你需要一個存儲引用類型的數組,并且這個數據不需要橋接至NSArray,請使用ContiguousArray
class C { ... }
var a: ContiguousArray<C> = [C(...), C(...), ..., C(...)] 使用適當的改變而不是對象分配
Swift中標準庫中所有的容器類型都是值類型,在復制時采用COW(copy-on-write 寫時復制)[4]而不是即時復制。在很多情況下,這可以讓編譯器通過持有容器而不是深度拷貝,從而省掉不必要的拷貝。如果容器的引用計數大于 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的版本不會因為添加1到數組而改變,執行完append_one之后也不再使用,但是由于賦值操作,a仍然可能被復制[5]。可以使用inout參數避免這種問題:
func append_one_in_place(inout a: [Int]) {
a.append(1)
}
var a = [1, 2, 3]
append_one_in_place(&a) 未檢查的操作
Swift通過在進行算術運算的時候對溢出進行檢查排除了整數溢出錯誤。這種檢查對于那些不可能引起內存安全問題并要求高效的代碼時沒有必要的。
建議:如果你能保證不會發生溢出,使用未檢查的整數算術運算
如果你能保證內存安全,在對性能要求很高的代碼中省略溢出檢查
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通過泛型(generic type)提供了一種非常強大的抽象機制。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對象的引用計數并減少previous對象的引用計數,這種引用計數代價昂貴但是只要使用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因為沒有引起復制
?