Swift 效能相關

Damonwong發表於2017-04-28

起初的疑問源自於「在 Swift 中的, Struct:Protocol 比 抽象類 好在哪裡?」。但是找來找去都是 Swift 效能相關的東西。整理了點筆記,供大家可以參考一下。

一些疑問

在正題開始之前,不知道你是否有如下的疑問:

  • 為什麼說 Swift 相比較於 Objective-C 會更加
  • 為什麼在編譯 Swift 的時候這麼
  • 如何更優雅的去寫 Swift ?

如果你也有類似疑問,希望這篇筆記能幫你解釋一下上面幾個問題的一些原因。(ps.上面幾個問題都很大,如果有不同的想法和了解,也希望你能分享出來,大家一起討論一下。)

Swift中的型別

首先,我們先統一一下關於型別的幾個概念。

  • 平凡型別

有些型別只需要按照位元組表示進行操作,而不需要額外工作,我們將這種型別叫做平凡型別 (trivial)。比如,Int 和 Float 就是平凡型別,那些只包含平凡值的 struct 或者 enum 也是平凡型別。

struct AStruct {
    var a: Int
}
struct BStruct {
    var a: AStruct
}
// AStruct & BStruct 都是平凡型別
複製程式碼
  • 引用型別

對於引用型別,值例項是一個對某個物件的引用。複製這個值例項意味著建立一個新的引用,這將使引用計數增加。銷燬這個值例項意味著銷燬一個引用,這會使引用計數減少。不斷減少引用計數,最後當然它會變成 0,並導致物件被銷燬。但是需要特別注意的是,我們這裡談到的複製和銷燬值,只是對引用計數的操作,而不是複製或者銷燬物件本身。

struct CStruct {
    var a: Int
}
class AClass {
    var a: CStruct
}
class BClass {
    var a: AClass
}
// AClass & BClass 都是引用型別
複製程式碼
  • 組合型別

類似 AClass 這類,引用型別包含平凡型別的,其實還是引用型別,但是對於平凡型別包含引用型別,我們暫且稱之為組合型別。

struct DStruct {
    var a: AClass
}
// DStruct 是組合型別
複製程式碼

影響效能的主要因素

主要原因在下面幾個方面:

  • 記憶體分配 (Allocation):主要在於 堆記憶體分配 還是 棧記憶體分配
  • 引用計數 (Reference counting):主要在於如何 權衡 引用計數。
  • 方法排程 (Method dispatch):主要在於 靜態排程動態排程 的問題。

記憶體分配(Allocation)

今天主要談一談 記憶體分割槽 中的

  • 堆(heap)

堆是用於存放程式執行中被動態分配的記憶體段,它的大小並不固定,可動態擴張或 縮減。當程式呼叫malloc等函式分配記憶體時,新分配的記憶體就被動態新增到堆上(堆被擴張); 當利用free等函式釋放記憶體時,被釋放的記憶體從堆中被剔除(堆被縮減)

  • 棧 (stack heap)

棧又稱堆疊, 是使用者存放程式臨時建立的區域性變數,也就是說我們函式括弧“{}” 中定義的變數(但不包括static宣告的變數,static意味著在資料段中存放變數)。除此以外, 在函式被呼叫時,其引數也會被壓入發起呼叫的程式棧中,並且待到呼叫結束後,函式的返回值 也會被存放回棧中。由於棧的後進先出特點,所以 棧特別方便用來儲存/恢復呼叫現場。從這個意義上講,我們可以把堆疊看成一個寄存、交換臨時資料的記憶體區。

在 Swift 中,對於 平凡型別 來說都是存在 中的,而 引用型別 則是存在於 中的,如下圖所示:

Swift 效能相關

我們都知道,Swift建議我們多用 平凡型別,那麼 平凡型別引用型別 好在哪呢?換句話說「在 中的資料和 中的資料相比有什麼優勢?」

  • 資料結構
    • 存放在棧中的資料結構較為簡單,只有一些值相關的東西
    • 存放在堆中的資料較為複雜,如上圖所示,會有type、retainCount等。
  • 資料的分配與讀取
    • 存放在棧中的資料從棧區底部推入 (push),從棧區頂部彈出 (pop),類似一個資料結構中的棧。由於我們只能夠修改棧的末端,因此我們可以通過維護一個指向棧末端的指標來實現這種資料結構,並且在其中進行記憶體的分配和釋放只需要重新分配該整數即可。所以棧上分配和釋放記憶體的代價是很小。
    • 存放在堆中的資料並不是直接 push/pop,類似資料結構中的連結串列,需要通過一定的演算法找出最優的未使用的記憶體塊,再存放資料。同時銷燬記憶體時也需要重新插值。
  • 多執行緒處理
    • 棧是執行緒獨有的,因此不需要考慮執行緒安全問題。
    • 堆中的資料是多執行緒共享的,所以為了防止執行緒不安全,需同步鎖來解決這個問題題。

綜上幾點,在記憶體分配的時候,儘可能選擇 而不是 會讓程式執行起來更加快。

引用計數(Reference counting)

首先 引用計數 是一種 記憶體管理技術,不需要程式設計師直接去操作指標來管理記憶體。

而採用 引用計數記憶體管理技術,會帶來一些效能上的影響。主要以下兩個方面:

  • 需要通過大量的 release/retain 程式碼去維護一個物件生命週期。
  • 存放在 堆區 的是多執行緒共享的,所以對於 retainCount 的每一次修改都需要通過同步鎖等來保證執行緒安全。

對於 自動引用計數 來說, 在新增 release/retain 的時候採用的是一個寧可多寫也不漏寫的原則,所以 release/retain 有一定的冗餘。這個冗餘量大概在 10% 的左右(如下圖,圖片來自於iOS可執行檔案瘦身方法)。

Swift 效能相關

而這也是為什麼雖然 ARC 底層對於記憶體管理的演算法進行了優化,在速度上也並沒有比 MRC 寫出來的快的原因。這篇文章 詳細描述了 ARC 和 MRC 在速度上的比較。

綜上,雖然因為自動引用計數的引入,大大減少了記憶體管理相關的事情,但是對於引用計數來說,過多或者冗餘的引用計數是會減慢程式的執行的。

而對於引用計數來說,還有一個權衡問題,具體如何權衡會再後文解釋。

方法排程 (Method dispatch)

在 Swift 中, 方法的排程主要分為兩種:

  • 靜態排程: 可以進行inline和其他編譯期優化,在執行的時候,會直接跳到方法的實現。
struct Point {
    var x, y: Double
    func draw() {
        // Point.draw implementation
    } 
}
func drawAPoint(_ param: Point) {
    param.draw()
}
let point = Point(x: 0, y: 0)
drawAPoint(point)
// 1.編譯後變為下面的inline方式
point.draw()
// 2.執行時,直接跳到實現 Point.draw implementation
複製程式碼
  • 動態排程: 在執行的時候,會根據執行時,採用 V-Table 的方式,找到方法的執行體,然後執行。無法進行編譯期優化。V-Table 不同於 OC 的排程,在 OC 中,是先在執行時的時候先在子類中尋找方法,如果找不到,再去父類尋找方法。而對於 V-Table 來說,它的排程過程如下圖:

Swift 效能相關

因此,在效能上「靜態排程 > 動態排程」並且「Swift中的V-Table > Objective-C 的動態排程」。

協議型別 (Protocol types)

在 Swift 引入了一個 協議型別 的概念,示例如下:

protocol Drawable {
    func draw()
}
struct Point : Drawable {
    var x, y: Double
    func draw() { ... }
}
struct Line : Drawable {
    var x1, y1, x2, y2: Double
    func draw() { ... }
}
var drawables: [Drawable]
// Drawable 就稱為協議型別
for d in drawables {
    d.draw()
}
複製程式碼

在上述程式碼中,Drawable 就稱為協議型別,由於 平凡型別 沒有繼承,所以實現多型上出現了一些棘手的問題,但是 Swift 引入了 協議型別 很好的解決了 平凡型別 多型的問題,但是在設計 協議型別 的時候有兩個最主要的問題:

  • 對於類似 Drawable 的協議型別來說,如何去排程一個方法?
  • 對於不同的型別,具有不同的size,當儲存到 drawables 陣列時,如何保證記憶體對齊?

對於第一個問題,如何去排程一個方法?因為對於 平凡型別 來說,並沒有什麼虛擬函式指標,所以在 Swift 中並沒有 V-Table 的方式,但是還是用到了一個叫做 The Protocol Witness Table (PWT) 的函式表,如下圖所示:

Swift 效能相關

對於每一個 Struct:Protocol 都會生成一個 StructProtocol 的 PWT

對於第二個問題,如何保證記憶體對齊問題?

Swift 效能相關

有一個簡單粗暴的方式就是,取最大的Size作為陣列的記憶體對齊的標準,但是這樣一來不但會造成記憶體浪費的問題,還會有一個更棘手的問題,如何去尋找最大的Size。所以為了解決這個問題,Swift 引入一個叫做 Existential Container 的資料結構。

Swift 效能相關

  • Existential Container

Swift 效能相關

這是一個最普通的 Existential Container。

  • 前三個word:Value buffer。用來儲存Inline的值,如果word數大於3,則採用指標的方式,在堆上分配對應需要大小的記憶體
  • 第四個word:Value Witness Table(VWT)。每個型別都對應這樣一個表,用來儲存值的建立,釋放,拷貝等操作函式。(管理 Existential Container 生命週期)
  • 第五個word:Protocol Witness Table(PWT),用來儲存協議的函式。

用虛擬碼表示如下:

// Swift 虛擬碼
struct ExistContDrawable {
    var valueBuffer: (Int, Int, Int)
    var vwt: ValueWitnessTable
    var pwt: DrawableProtocolWitnessTable
}
複製程式碼

所以,對於上文程式碼中的 Point 和 Line 最後的資料結構大致如下:

Swift 效能相關

這裡需要注意的幾個點:

  • 在 ABI 穩定之前 value buffer 的 size 可能會變,對於是不是 3個 word 還在 Swift 團隊還在權衡.
  • Existential Container 的 size 不是隻有 5 個 word。示例如下:

Swift 效能相關

對於這個大小差異最主要在於這個 PWT 指標,對於 Any 來說,沒有具體的函式實現,所以不需要 PWT 這個指標,但是對於 ProtocolOne&ProtocolTwo 的組合協議,是需要兩個 PWT 指標來表示的。

OK,由於 Existential Container 的引入,我們可以將協議作為型別來解決 平凡型別 沒有繼承的問題,所以 Struct:Protocol 和 抽象類就越來越像了。

回到我們最初的疑問,「在 Swift 中的, Struct:Protocol 比 抽象類 好在哪裡?」

  • 由於 Swift 只能是單繼承,所以 抽象類 很容易造成 「上帝類」,而Protocol可以是一個多這多個則沒有這個問題
  • 在記憶體分配上上,Struct是在棧中的,而抽象類是在堆中的,所以簡單資料的Struct:Protocol會再效能上比抽象類更加好
  • (寫起來更加有逼格算不算?)

但是,雖然表面上協議型別確實比抽象類更加的**“好”**,但是我還是想說,不要隨隨便便把協議當做型別來使用。

為什麼這麼說?先來看一段程式碼:

struct Pair {
    init(_ f: Drawable, _ s: Drawable) {
        first = f ; second = s
    }
    var first: Drawable
    var second: Drawable
}
複製程式碼

首先,我們把 Drawable 協議當做一個型別,作為 Pair 的屬性,由於協議型別的 value buffer 只有三個 word,所以如果一個 struct(比如上文的Line) 超過三個 word,那麼會將值儲存到堆中,因此會造成下圖的現象:

Swift 效能相關

一個簡單的複製,導致屬性的copy,從而引起 大量的堆記憶體分配

所以,不要隨隨便便把協議當做型別來使用。上面的情況發生於無形之中,你卻沒有發現。

當然,如果你非要將協議當做型別也是可以解決的,首先需要把Line改為class而不是struct,目的就是引入引用計數。所以,將Line改為class之後,就變成了如下圖所示:

Swift 效能相關

至於修改了 line 的 x1 導致所有 pair 下的 line 的 x1 的值都變了,我們可以引入 Copy On Write 來解決。

當我們 Line 使用平凡型別時,由於line佔用了4個word,當把協議作為型別時,無法將line存在 value buffer 中,導致了堆記憶體分配,同時每一次複製都會引發堆記憶體分配,所以我們採用了引用型別來替代平凡型別,增加了引用計數而降低了堆記憶體分配,這就是一個很好的引用計數權衡的問題。

泛型(Generic code)

首先,如果我們把協議當做型別來處理,我們稱之為 「動態多型」,程式碼如下:

protocol Drawable {
    func draw()
}
func drawACopy(local : Drawable) {
    local.draw()
}
let line = Line()
drawACopy(line)
// ...
let point = Point()
drawACopy(point)

複製程式碼

而如果我們使用泛型來改寫的話,我們稱之為 「靜態多型」,程式碼如下:

// Drawing a copy using a generic method
protocol Drawable {
    func draw()
}
func drawACopy<T: Drawable>(local : T) {
    local.draw()
}
let line = Line()
drawACopy(line)
// ...
let point = Point()
drawACopy(point)
複製程式碼

而這裡所謂的 動態靜態 的區別在哪裡呢?

在 Xcode 8 之前,唯一的區別就是由於使用了泛型,所以在排程方法是,我們已經可以根據上下文確定了這個 T 到底是什麼型別,所以並不需要 Existential Container,所以泛型沒有使用 Existential Container,但是因為還是多型,所以還是需要VWT和PWT作為隱形引數傳遞,對於臨時變數仍然按照ValueBuffer的邏輯儲存 - 分配3個word,如果儲存資料大小超過3個word,則在堆上開闢記憶體儲存。如圖所示:

Swift 效能相關

這樣的形式其實和把協議作為型別並沒有什麼區別。唯一的就是沒有 Existential Container 的中間層了。

但是,在 Xcode 8 之後,引入了 Whole-Module Optimization 使泛型的寫法更加靜態化。

首先,由於可以根據上下文知道確定的型別,所以編譯器會為每一個型別都生成一個drawACopy的方法,示例如下:

func drawACopy<T : Drawable>(local : T) {
    local.draw()
}
// 編譯後 
func drawACopyOfALine(local : Line) {
    local.draw()
}
func drawACopyOfAPoint(local : Point) {
    local.draw()
}

//比如:
drawACopy(local: Point(x: 1.0, y: 1.0))
//變為
drawACopyOfAPoint(local : Point(x: 1.0, y: 1.0))
複製程式碼

由於每個型別都生成了一個drawACopy的方法,drawACopyOfAPoint的呼叫就吧程式設計了一個靜態排程,再根據前文靜態排程的時候,編譯器會做 inline 處理,所以上面的程式碼經過編譯器處理之後程式碼如下:

drawACopy(local: Point(x: 1.0, y: 1.0))
//會變為
Point(x: 1.0, y: 1.0).draw()
複製程式碼

由於編譯器一步步的處理,再也不需要 vwt、pwt及value buffer了。所以對於泛型來做多型來說,就叫做靜態多型。

幾點總結

  • 為什麼在編譯 Swift 的時候這麼慢
    • 因為編譯做了很多事情,例如 靜態排程的inline處理,靜態多型的分析處理等
  • 為什麼說 Swift 相比較於 Objective-C 會更加快
    • 對於Swift來說,更多的靜態的,比如靜態排程、靜態多型等。
    • 更多的棧記憶體分配
    • 更少的引用計數
  • 如何更優雅的去寫 Swift
    • 不要把協議當做型別來處理
    • 如果需要把協議當做型別來處理的時候,需要注意 big Value 的複製就引起堆記憶體分配的問題。可以用 Indirect Storage + Copy On Write 來處理。
    • 對於一些抽象,可以採用 Struct:Protocol 來代替抽象類。至少不會有上帝類出現,而且處理的好的話效能是比抽象類更好的。

參考資料

更多

工作之餘,寫了點筆記,如果需要可以在我的 GitHub 看。

相關文章