真實世界中的 Swift 性能優化
有太多的因素會導致您的應用變得緩慢。在本次講演中,我們將自底向上地來探索應用的性能優化。來看一看在真實世界中進行數據解析、數據映射和數據存儲的時候,Swift 的特性(協議、泛型、結構體和類)是如何影響應用性能的,我們將確定影響性能提升的瓶頸所在,并體驗 Swift 帶來的「迅捷」體驗。
概述
今天我打算同大家談論 Swift 性能優化方面的內容。當我們構建軟件的時候,特別是移動軟件,由于人們的精力、時間有限,人們往往更喜歡有一個流暢的用戶體驗,而不是等著您的應用在那加載。如果應用運行速度過慢,并且不能給用戶帶來他們所想要的結果的話,那么這會讓人們感到不爽。因此,讓您的代碼能夠快速運行是無比重要的一件事。
那么有什么因素會導致代碼運行緩慢呢?當您在編寫代碼并選擇架構的時候,深刻認識到這些架構所帶來的影響是非常重要的。我將首先談一談:如何理解內聯、動態調度與靜態調度之間的權衡,以及相關結構是如何分配內存的,還有怎樣選擇最適合的架構。
內存分配
對象的內存分配 (allocation) 和內存釋放 (deallocation) 是代碼中最大的開銷之一,同時通常也是不可避免的。Swift 會自行分配和釋放內存,此外它存在兩種類型的分配方式。
第一個是 基于棧 (stack-based) 的內存分配 。Swift 會盡可能選擇在棧上分配內存。棧是一種非常簡單的數據結構;數據從棧的底部推入 (push),從棧的頂部彈出 (pop)。由于我們只能夠修改棧的末端,因此我們可以通過維護一個指向棧末端的指針來實現這種數據結構,并且在其中進行內存的分配和釋放只需要重新分配該整數即可。
第二個是 基于堆 (heap-based) 的內存分配 。這使得內存分配將具備更加動態的生命周期,但是這需要更為復雜的數據結構。要在堆上進行內存分配的話,您需要鎖定堆當中的一個空閑塊 (free block),其大小能夠容納您的對象。因此,我們需要找到未使用的塊,然后在其中分配內存。當我們需要釋放內存的時候,我們就必須搜索何處能夠重新插入該內存塊。這個操作很緩慢。主要是為了線程安全,我們必須要對這些東西進行鎖定和同步。
引用計數
我們還有引用計數 (reference counting) 的概念,這個操作相對不怎么耗費性能,但是由于使用次數很多,因此它帶來的性能影響仍然是很大的。引用計數是 Objective-C 和 Swift 中用于確定何時該釋放對象的安全機制。目前,Swift 當中的引用計數是強制自動管理的,這意味著它很容易被開發者們所忽略。然而,當您打開 Instrument 查看何處影響了代碼運行的速度的時候,您會發現 20,000 多次的 Swift 持有 (retain) 和釋放 (release),這些操作占用了 90% 的代碼運行時間!
func perform(with object: Object) {
object.doAThing()
}
這是因為如果有這樣一個函數接收了一個對象作為參數,并且執行了這個對象的 doAThing() 方法,編譯器會自動插入對象持有和釋放操作,以確保在這個方法的生命周期當中,這個對象不會被回收掉。
func perform(with object: Object) {
__swift_retain(object)
object.doAThing()
__swift_release(object)
}
這些對象持有和釋放操作是原子操作 (atomic operations),所以它們運轉緩慢就很正常了。或者,是因為我們不知道如何讓它們能夠運行得更快一些。
調度與對象
此外還有調度 (dispatch) 的概念。Swift 擁有三種類型的調度方式。Swift 會盡可能將函數 內聯 (inline) ,這樣的話使用這個函數將不會有額外的性能開銷。這個函數可以直接調用。 靜態調度 (static dispatch) 本質上是通過 V-table 進行的查找和跳轉,這個操作會花費一納秒的時間。然后 動態調度 (dynamic dispatch) 將會花費大概五納秒的時間,如果您只有幾個這樣的方法調用的話,這實際上并不會帶來多大的問題,問題是當您在一個嵌套循環或者執行上千次操作當中使用了動態調度的話,那么它所帶來的性能耗費將成百上千地累積起來,最終影響應用性能。
Swift 同樣也有兩種類型的對象。
class Index {
let section: Int
let item: Int
}
let i = Index(section: 1,
item: 1)
這是一個類,類當中的數據都會在堆上分配內存。您可以在此處看到,這里我們創建了一個名為 Index 的類。其中包含了兩個屬性,一個 section 和一個 item 。當我們創建了這個對象的時候,堆上便創建了一個指向此 Index 的指針,因此在堆上便存放了這個 section 和 item 的數據和空間。
如果我們對其建立引用,就會發現我們現在有兩個指向堆上相同區域的指針了,它們之間是共享內存的。
class Index {
let section: Int
let item: Int
}
let i = Index(section: 1,
item: 1)
let i2 = i
這個時候,Swift 會自動插入對象持有操作。
class Index {
let section: Int
let item: Int
}
let i = Index(section: 1,
item: 1)
__swift_retain(i)
let i2 = i
結構體
很多人都會說:要編寫性能優異的 Swift 代碼,最簡單的方式就是使用結構體了,結構體通常是一個很好的結構,因為結構體會存儲在棧上,并且通常會使用靜態調度或者內聯調度。
存儲在棧上的 Swift 結構體將占用三個 Word 大小。如果您的結構體當中的數據數量低于三種的話,那么結構體的值會自動在棧上內聯。 Word 是 CPU 當中內置整數的大小,它是 CPU 所工作的區塊。
struct Index {
let section: Int
let item: Int
}
let i = Index(section: 1, item: 1)
在這里您可以看到,當我們創建這個結構體的時候,帶有 section 和 item 值得 Index 結構體將會直接下放到棧當中,這個時候不會有額外的內存分配發生。那么如果我們在別處將其賦值到另一個變量的時候,會發生什么呢?
struct Index {
let section: Int
let item: Int
}
let i = Index(section: 1, item: 1)
let i2 = i
如果我們將 i 賦給 i2 ,這會將我們存儲在棧當中的值直接再次復制一遍,這個時候并不會出現引用的情況。這就是所謂的「值類型」。
那么如果結構體當中存放了引用類型的話又會怎樣呢?持有內聯指針的結構體。
struct User {
let name: String
let id: String
}
let u = User(name: "Joe", id: "1234")
當我們將其賦值給別的變量的時候,我們就持有了共享兩個結構體的相同指針,因此我們必須要對這兩個指針進行持有操作,而不是在對象上執行單獨的持有操作。
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),從而允許我們自行決定代碼該如何運行,以及決定代碼的性能特性。現在我們來看一看抽象類型是如何在實際環境當中使用的。這里有一段很簡單的代碼:
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()
}
這里有一個帶有 radius 和 center 屬性的 Circle 結構體。它將占用三個 Word 大小的空間,并存儲在棧上。我們創建了一億個 Circle ,然后我們遍歷這些 Circle 并調用這個函數。在我的電腦上,這段操作在發布模式下耗費了 0.3 秒的時間。那么當需求發生變更的時候,會發生什么事呢?
我們不僅需要繪圓,還需要能夠處理多種類型的形狀。讓我們假設我們還需要繪線。我非常喜歡面向協議編程,因為它允許我在不使用繼承的情況下實現多態性,并且它允許我們只需要考慮這個「抽象類型」即可。
protocol Drawable {
func draw()
}
struct Circle: Drawable {
let radius: Double
let center: Point
func draw() {}
}
let drawables: [Drawable] = (1..<100_000_000).map { _ in Circle(...) }
for drawable in drawables {
drawable.draw()
}
我們需要做的,就是將這個 draw 方法析取到協議當中,然后將數組的引用類型變更為這個協議,這樣做導致這段代碼花費了 4.0 秒的時間來運行。速率減慢了 1300%,這是為什么呢?
這是因為此前的代碼可以被靜態調度,從而在沒有任何堆應用建立的情況下仍能夠執行。這就是協議是如何實現的。
例如,如大家所見,這里是我們此前的 Circle 結構體。在這個 for 循環當中,Swift 編譯器所做的就是前往 V-table 進行查找,或者直接將 draw 函數內聯。
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()
}
當我們用協議來替代的時候,此時它并不知道這個對象是結構體還是類。因為這里可能是任何一個實現此協議的類型。
protocol Drawable {
func draw()
}
struct Circle: Drawable {
let radius: Double
let center: Point
func draw() {}
}
var drawables: [Drawable] = (1..<100_000_000).map { _ in return Circle(...) }
for drawable in drawables {
drawable.draw()
}
那么我們該如何去調度這個 draw 函數呢?答案就位于協議記錄表 (protocol witness table,也稱為虛函數表) 當中。它其中存放了您應用當中每個實現協議的對象名,并且在底層實現當中,這個表本質上充當了這些類型的別名。
protocol Drawable {
func draw()
}
struct Circle: Drawable {
let radius: Double
let center: Point
func draw() {}
}
var drawables: [Drawable] = (1..<100_000_000).map { _ in
return Circle(...)
}
for drawable in drawables {
drawable.draw()
}
在這里的代碼當中,我們該如何獲取協議記錄表呢?答案就是從這個既有容器 (existential container) 當中獲取,這個容器目前擁有一個三個字大小的結構體,并且存放在其內部的值緩沖區當中,此外還與協議記錄表建立了引用關系。
struct Circle: Drawable {
let radius: Double
let center: Point
func draw() {}
}
這里 Circle 類型存放在了三個字大小的緩沖區當中,并且不會被單獨引用。
struct Line: Drawable {
let origin: Point
let end: Point
func draw() {}
}
舉個例子,對于我們的 Line 類型來說,它其中包含了四個字的存儲空間,因為它擁有兩個點類型。這個 Line 結構體需要超過四個字以上的存儲空間。我們該如何處理它呢?這會對性能有影響么?好吧,它的確會:
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()
}
這需要花費 45 秒鐘 的時間來運行。為什么這里要花這么久的時間呢,發生了什么事呢?
絕大部分的時間都花費在對結構體進行內存分配上了,因為現在它們無法存放在只有三個字大小的緩沖區當中了。因此這些結構會在堆上進行內存分配,此外這也與協議有一點關系。由于既有容器只能夠存儲三個字大小的結構體,或者也可以與對象建立引用關系,我們同樣需要某種名為值記錄表 (value witness table)。這就是我們用來處理任意值的東西。
因此在這里,編譯器將創建一個值記錄表,對每個值緩沖區、內斂結構體來說,都有三個字大小的緩沖區,然后它將負責對值或者類進行內存分配、拷貝、銷毀和內存釋放等操作。
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)
}
這里是一個例子,這就是這個過程的中間產物。如果我們只有一個 draw 函數,那么它將會接受我們創建的 Line 作為參數,因此我們將它傳遞給這個 draw 函數即可。
實際情況時,它將這個 Drawable 協議傳遞到既有容器當中,然后在函數內部再次進行創建。這會對值和協議記錄表進行賦值,然后分配一個新的緩沖區,然后將其他結構、類或者類似對象的值拷貝進這個緩沖區當中。然后就使用協議記錄表當中的 draw 函數,把真實的 Drable 對象傳遞給這個函數。
您可以看到,值記錄表和協議記錄表將會存放在棧上,而 Line 將會被存放在堆上,從而最后將線繪制出來。
結論
對我們進行數據建模的方式進行簡單的更改將會對性能造成巨大的影響。讓我們來看一看該如何來避免這些情況的發生。
首先讓我們來說一說泛型。大家可能會說了, 我給各位展示了協議會導致性能的下降,那么我們為什么還要使用泛型呢? 答案在于:泛型允許我們做什么。
struct Stack<T: Type> {
...
}
假設我們這里有一個帶有泛型 T 的 Stack 結構體,它受到一個協議類型的約束。編譯器所要做的,就是將這個 T 提換成相應的協議,或者替換為我們傳入的具體類型。這些操作會一直沿著函數鏈 (function chain) 執行,并且編譯器會創建直接對此類型進行操作的專用版本。
這樣我們就無需再使用值記錄表或者協議記錄表了,并且還移除了既有容器,這可能是一個非常好的解決方式,這使得我們仍然能夠寫出真正快速運行的泛型代碼,并且還具備 Swift 所提供的良好多態性。這就是所謂的靜態多態性 (static polymorphism)。
您還可以通過使用枚舉來改進數據模型,而不是從服務器中獲取大量的字符串。例如,假設您正在構建一個社交應用,您需要對賬戶建立狀態的管理,以前您可能會使用字符串來進行控制。
enum AccountStatus: String, RawRepresentable {
case .banned, .verified, incomplete
}
如果我們改用枚舉的話,那么我們就無需進行內存分配了,當我們傳遞這個類型的時候,我們只是將枚舉值進行傳遞,這是一個加快代碼運行速度的好方法,同時也可以為整個應用程序提供更安全、更可讀的代碼。
此外,使用 u-模型或者演示者 (Presenter) 或者不同的抽象類型的形式,來構建特定類型的模型也是非常有用的,這使得我們能夠精簡掉應用當中許多不必要的部分。
來自:https://realm.io/cn/news/real-world-swift-performance/