編寫高性能的 Swift 代碼
來自: https://github.com/iThreeKing/gold-miner/blob/master/TODO/OptimizationTips.rst
- 原文鏈接: Optimization Tips
- 原文作者 : apple
- 譯文出自 : 掘金翻譯計劃
- 譯者 : iThreeKing
- 校對者: nathanwhy walkingway
- 狀態 : 翻譯完成
編寫高性能的 Swift 代碼
這篇文章整合了許多編寫高性能的 Swift 代碼的提示與技巧。文章的受眾是編譯器和標準庫的開發者。
這篇文章中的一些技巧可以幫助提高你的 Swift 程序質量,并且可以減少代碼中的容易出現的錯誤,使代碼更具可讀性。顯式地標記出最終類和類的協議是兩個顯而易見的例子。然而,文章中描述的一些技巧是不符合規定的,扭曲的,僅僅解決由于編譯器或者語言暫時限制的問題。文章中的建議來自多方面的權衡,例如程序運行時,二進制大小,代碼可讀性等等。
啟用優化
每個人應該做的第一件事是啟用優化。 Swift 提供了三種不同的優化級別:
-Onone: 這是為正常開發準備,它執行最少的優化并保留所有調試的信息。-O: 是為大多數生產代碼準備,編譯器執行積極的優化,可以很大程度上改變提交代碼的類型和數量。調試信息同樣會被輸出,但是有損耗。-Ounchecked: 這個是特定的優化模式,為了特定的庫或應用程序,舍棄安全性來提高性能。編譯器將移除所有溢出檢查以及一些隱式類型檢查。由于這樣會導致未被發現的存儲安全問題和整數溢出,所以一般情況下并不會使用這種模式。僅使用于你已經仔細審查了自己的代碼對于整數溢出和類型轉換是友好的情況下。
在 Xcode UI 中,人們可以按照下面修改當前優化級別:
...
優化整個組件
默認情況下 Swift 單獨編譯每個文件。這使得 Xcode 可以非常快速的并行編譯多個文件。然而,分開編譯每個文件可以阻止某些編譯器優化。 Swift 也可以把整個程序看做一個文件來編譯,并把程序當成單個編譯單元來優化。這個模式可以使用命令行``-whole-module-optimization``來啟用。在這種模式下程序需要花費更長的時間來編譯,但運行起來卻更快。
這個模式可以通過 Xcode 構建設置中的'Whole Module Optimization'來啟用。
降低動態調度 (Reducing Dynamic Dispatch)
在默認情況下, 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 = ...
}</pre>
在 Swift 中,動態調度默認通過一個 vtable[1](虛函數表)間接調用。_.如果使用``dynamic``關鍵字聲明, Swift 的調用方式將變為:『通過 Objective-C 的消息傳遞機制 』。在上面這兩種情況下,后者『通過 Objective-C 的消息傳遞』要比直接進行函數調用慢,因為他阻止了編譯器的很多優化[2],除了自身的間接調用開銷。_在性能優先的代碼中,人們常常想限制這種動態行為。
建議:當你在聲明時知道不會被重寫時使用 'final'
``final``關鍵字是類、方法或屬性聲明中的限制,從而讓聲明不被重寫。這就意味著編譯器可以使用直接函數調用代替間接函數調用。例如下面的``C.array1``和``D.array1``將會被直接訪問[3]。與之相反,``D.array2``將通過一個虛函數表訪問:
final class C {
// 類'C'中沒有聲明可以被重寫
var array1: [Int]
func doSomething() { ... }
}
class D {
final var array1 [Int] //'array1'不可以被計算屬性重寫
var array2: [Int] //'array2'可以被計算屬性重寫
}
func usingC(c: C) {
c.array1[i] = ... //可以直接使用C.array而不用通過動態調用
c.doSomething() = ... //可以直接調用C.doSomething而不用通過虛函數表訪問
}
func usingD(d: D) {
d.array1[i] = ... //可以直接使用D.array1而不用通過動態調用
d.array2[i] = ... //將通過動態調用使用D.array2
}</pre>
建議:當聲明不需要被文件外部訪問到的時候,使用'private'
在聲明中使用``private``關鍵字,會限制對其聲明文件的可見性。這會讓編譯器能查出所有其它潛在的重寫聲明。因此,由于沒有了這樣的聲明,編譯器就可以自動推斷出``final``關鍵字,并移除間接的方法調用和域訪問。例如下面,假設在同一文件中 E, ``F``并沒有任何重寫聲明,那么``e.doSomething()``和``f.myPrivateVar``將可以被直接訪問:
private class E {
func doSomething() { ... }
}
class F {
private var myPrivateVar : Int
}
func usingE(e: E) {
e.doSomething() // 文件中沒有替代類來聲明這個類
// 編譯器可以移除 doSomething() 的虛擬調用
// 并直接調用類 E 的 doSomething 方法
}
func usingF(f: F) -> Int {
return f.myPrivateVar
}</pre>
高效地使用容器類型
通用的容器 Array 和 Dictionary 是 Swift 標準庫提供的一個重要特性。本節將解釋如何用高性能方式使用這些類型。
建議:在數組中使用值類型
在 Swift 中,類型可以分為不同的兩類:值類型(結構體,枚舉,元組)和引用類型(類)。一個關鍵的差別就是 NSArray 中不能含有值類型。因此當使用值類型時,優化器就不需要去處理對 NSArray 的支持,從而可以在數組上省去大部分的消耗。
此外,相比引用類型,如果值類型遞歸地包含引用類型,那么值類型僅需要引用計數器。使用不含引用類型的值類型,就可以避免額外的開銷(數組內的元素執行 retain、release 操作所產生的通訊量)。
// 這里不要使用類
struct PhonebookEntry {
var name : String
var number : [Int]
}
var a : [PhonebookEntry]</pre>
牢記在使用大的值類型和引用類型之間要做好權衡。在某些情況下,拷貝和移動大的值類型消耗要大于移除橋接和保留/釋放的消耗。
建議:當 NSArray 橋接不必要時,使用 ContiguousArray 存儲引用類型
如果你需要一個引用類型的數組,并且數組不需要被橋接到 NSArray ,使用 ContiguousArray 代替 Array 。
class C { ... }
var a: ContiguousArray<C> = [C(...), C(...), ..., C(...)]
建議:使用就地轉變而不是對象的再分配
在 Swift 中,所有的標準庫容器都是值類型,使用 COW(copy-on-write)[4]機制執行拷貝以代替直接拷貝。在很多情況下,通過保持容器的引用而不是執行深度拷貝能夠讓編譯器節省不必要的拷貝。如果容器的引用計數大于1并且容器發生轉變,這將只通過拷貝底層容器實現。例如下面的情況,當``d``被分配給``c``時不進行拷貝,但當``d``通過結構的改變附加到``2``,那么``d`` 就會被拷貝,然后``2``就會被附加到``d``:
var c: [Int] = [ ... ]
var d = c //這里沒有拷貝
d.append(2) //這里*有*拷貝
如果用戶不小心,有時 COW 機制會引起額外的拷貝。例如,在函數中,試圖通過對象的再分配執行修改操作。在 Swift 中,所有的參數傳遞時都會被拷貝,例如,參數在調用之前會保留,然后在調用結束時會釋放。也就是像下面的函數:
func append_one(a: [Int]) -> [Int] {
a.append(1)
return a
}
var a = [1, 2, 3]
a = append_one(a)</pre>
盡管``a``(一開始未執行 append 操作)在``append_one``之后也沒有使用,但仍然可能會被拷貝。[5]。這可以通過使用參數``inout``來避免:
func append_one_in_place(inout a: [Int]) {
a.append(1)
}
var a = [1, 2, 3]
append_one_in_place(&a)</pre>
未檢查操作
在執行普通的整數運算時,Swift 會檢查運算結果是否溢出,從而消除 bug。然而在已知沒有內存安全問題發生的高性能代碼中,這樣的檢查是不合適的。
建議:如果你知道不會發生溢出時,使用未檢查整型計算
在性能優先的代碼中,如果你知道代碼是安全的,那么你可以忽略溢出檢查。
a : [Int]
b : [Int]
c : [Int]
//前提:對于所有的 a[i], b[i],a[i] + b[i]都不會溢出!
for i in 0 ... n {
c[i] = a[i] &+ b[i]
}</pre>
泛型
Swift通過使用泛型類型,提供了一種十分強大的抽象機制。 Swift 編譯器發出一個具體的代碼塊,從而可以對任何 T``執行``MySwiftFunc<T>。生成的代碼需要一個函數指針表和一個包含``T``的封裝作為額外參數。通過傳遞不同的函數指針表及封裝提供的抽象大小,從而來說明``MySwiftFunc<Int>``和``MySwiftFunc<String>``之間的不同行為。一個泛型的例子:
class MySwiftFunc<T> { ... }
MySwiftFunc<Int> X // 將通過 Int 類型傳遞代碼
MySwiftFunc<String> Y // 此處為 String 類型</pre>
當啟用優化時, Swift 編譯器查看每段調用的代碼,并試著查明其中具體使用的類型(例如:非泛型類型)。如果泛型函數定義對優化器可見,并且具體類型已知,那么 Swift 編譯器將產生一個具有特殊類型的特殊泛型函數。這一過程被稱作*特殊化*,從而可以避免與泛型關聯的消耗。一些泛型的例子:
class MyStack<T> {
func push(element: T) { ... }
func pop() -> T { ... }
}
func myAlgorithm(a: [T], length: Int) { ... }
//編譯器可以特殊化 MyStack[Int] 的代碼
var stackOfInts: MyStack[Int]
//使用整型類型的棧
for i in ... {
stack.push(...)
stack.pop(...)
}
var arrayOfInts: [Int]
//編譯器可以為目標為 [Int] 的 myAlgorithm 函數執行一個特殊化版本
myAlgorithm(arrayOfInts, arrayOfInts.length)</pre>
建議:將泛型聲明放在使用它的文件中
只有泛型聲明在當前模塊可見,優化器才能進行特殊化。這樣只發生在使用泛型和聲明泛型在同一個文件中的情況下。*注意*標準庫是一個例外。在標準庫中聲明泛型,可以對所有模塊可見且進行特殊化。
建議:允許編譯器進行泛型特殊化
只有調用和被調用函數位于同一編譯單元,編譯器才能夠對泛型代碼進行特殊化。我們可以使用一個技巧讓編譯器對被調用函數進行優化,就是在被調用函數的編譯單元中執行類型檢查代碼。進行類型檢查的代碼會被重新發送來調用泛型函數---但是這樣做會包含類型信息。在下面的代碼中,我們在函數"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 ) {
//這個檢查允許優化器對泛型函數'play'進行特殊化
if let z = game as? Game<Int> {
z.play()
} else {
game.play()
}
}
/// -------------- >8
// Application.swift:
play_a_game(Game(10))
Swift 中大的值類型的開銷</pre>
在 Swift 中,值保留有一份獨有的數據拷貝。使用值類型有很多優點,比如能保證值具有獨立的狀態。當我們拷貝值時(等同于分配,初始化和參數傳遞),程序將會創建一份新的拷貝。對于一些大的值類型,這樣的拷貝是相當耗時的,也可能會影響到程序的性能。
考慮下面的代碼,代碼中使用'值'類型的節點定義了一棵樹。樹的節點包括其它使用協議的節點。計算機圖形場景通常由不同的實體和變形體構成,而他們都能表示為值的形式,所以這個例子很有實際意義。
protocol P {}
struct Node : P {
var left, right : P?
}
struct Tree {
var node : P?
init() { ... }
}</pre>
當樹進行拷貝(傳遞參數,初始化或者賦值操作),整棵樹都要被拷貝。這是一個花銷很大的操作,需要調用很多 malloc/free (分配/釋放)以及大量引用計數操作。
然而,我們并不是真的關心值是否被拷貝,只要這些值還保留在內存中。
建議:對大的值類型使用 copy-on-write 機制
減少拷貝大的值類型的開銷,可以采用 copy-on-write 的方法。實現 copy-on-write 機制最簡單的辦法就是采用已經存在的 copy-on-write 的數據結構,比如數組。 Swift 的數組是值類型,因為它具有 copy-on-write 的特性,所以當數組作為參數被傳遞時,并不需要每次都進行拷貝。
在我們'樹'的例子中,通過將樹中的內容封裝到數組中,從而減少拷貝帶來的開銷。通過這一簡單的改變就能極大地提示我們樹的數據結構性能,數組作為參數傳遞的開銷從 O(n) 降到了 O(1) 。
struct Tree : P {
var node : [P?]
init() {
node = [ thing ]
}
}
使用數組來實現 COW 機制有兩個明顯的缺點。第一個問題就是數組中類似"append"和"count"的方法,它們在值封裝中沒有任何作用。這些方法讓引用封裝變得很不方便。我們可以通過創建一個隱藏未用到的 API 的封裝結構來解決這個問題,并且優化器會移除它的開銷,但是這樣的封裝并不能解決第二個問題。第二個問題就是數組內存在保證程序安全性和與 Objective-C 進行交互的代碼, Swift 會檢查索引訪問是否在數組邊界內,以及保存值時會判斷數組存儲時否需要擴展存儲空間。這些操作運行時都會降低程序速度。
一個替代方法就是實現一個 copy-on-write 機制的數據結構來代替數組作為值封裝。下面的例子就是介紹如何構建一個這樣的數據結構:
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
}
}
}</pre>
``Box``類型可以代替上個例子中的數組。
不安全的代碼
Swift 中類總是采用引用計數。 Swift 編譯器會在每次對象被訪問時插入增加引用計數的代碼。例如,考慮一個通過使用類實現遍歷鏈表的例子。遍歷鏈表是通過從一個節點到下一個節點移動引用實現:
elem = elem.next。每次我們移動這個引用, Swift 將會增加``next``對象的引用計數,并且減少前一個對象的引用計數。這樣的引用計數方法成本很高,但只要我們使用 Swift 的類就無法避免。
final class Node {
var next: Node?
var data: Int
...
}
建議:使用非托管的引用來避免引用計數帶來的開銷
在性能優先代碼中,你可以選擇使用未托管的引用。其中``Unmanaged<T>``結構體就允許開發者關閉對于特殊引用的自動引用計數 (ARC) 功能。
var Ref : Unmanaged<Node> = Unmanaged.passUnretained(Head)
while let Next = Ref.takeUnretainedValue().next {
...
Ref = Unmanaged.passUnretained(Next)
}</pre>
協議
建議:標記只能由類實現的協議為類協議
Swift 可以限定協議只能通過類實現。標記協議只能由類實現的一個優點就是,編譯器可以基于只有類實現協議這一事實來優化程序。例如,如果 ARC 內存管理系統知道正在處理類對象,那么就能夠簡單的保留(增加對象的引用計數)它。如果編譯器不知道這一事實,它就不得不假設結構體也可以實現協議,那么就需要準備保留或者釋放不可忽視的結構體,這樣做的代價很高。
如果限定只能由類實現某個協議,那么就需要標記類實現的協議為類協議,以便獲得更好的運行性能。
protocol Pingable : class { func ping() -> Int }
腳注
| [1] | 虛擬方法表或者'vtable'是一種被包含類型方法地址實例引用的類型特定表。動態分發執行時,首先要從對象中查找這張表,然后在表中查找方法。 |
| [2] | 這是因為編譯器不知道具體哪個函數被調用。 |
| [3] | 例如,直接加載類域或者直接調用函數。 |
| [4] | 解釋 COW 是什么。 |
| [5] | 在某些情況下,優化器能夠通過直接插入和 ARC 優化,來移除保持的引用、這種釋放確保拷貝不會發生。 |