真實世界中的 Swift 性能優化
那麽有什麽因素會導致代碼運行緩慢呢?當您在編寫代碼並選擇架構的時候,深刻認識到這些架構所帶來的影響是非常重要的。我將首先談一談:如何理解內聯、動態調度與靜態調度之間的權衡,以及相關結構是如何分配內存的,還有怎樣選擇最適合的架構。
內存分配 (1:02)
對象的內存分配 (allocation) 和內存釋放 (deallocation) 是代碼中最大的開銷之一,同時通常也是不可避免的。Swift 會自行分配和釋放內存,此外它存在兩種類型的分配方式。
第一個是基於棧 (stack-based) 的內存分配。Swift 會盡可能選擇在棧上分配內存。棧是一種非常簡單的數據結構;數據從棧的底部推入 (push),從棧的頂部彈出 (pop)。由於我們只能夠修改棧的末端,因此我們可以通過維護一個指向棧末端的指針來實現這種數據結構,並且在其中進行內存的分配和釋放只需要重新分配該整數即可。
第二個是基於堆 (heap-based) 的內存分配。這使得內存分配將具備更加動態的生命周期,但是這需要更為復雜的數據結構。要在堆上進行內存分配的話,您需要鎖定堆當中的一個空閑塊 (free block),其大小能夠容納您的對象。因此,我們需要找到未使用的塊,然後在其中分配內存。當我們需要釋放內存的時候,我們就必須搜索何處能夠重新插入該內存塊。這個操作很緩慢。主要是為了線程安全,我們必須要對這些東西進行鎖定和同步。
引用計數 (2:30)
我們還有引用計數 (reference counting) 的概念,這個操作相對不怎麽耗費性能,但是由於使用次數很多,因此它帶來的性能影響仍然是很大的。引用計數是 Objective-C 和 Swift 中用於確定何時該釋放對象的安全機制。目前,Swift 當中的引用計數是強制自動管理的,這意味著它很容易被開發者們所忽略。然而,當您打開 Instrument 查看何處影響了代碼運行的速度的時候,您會發現 20,000 多次的 Swift 持有 (retain) 和釋放 (release),這些操作占用了 90% 的代碼運行時間!
Receive news and updates from Realm straight to your inbox
func perform(with object: Object) {
object.doAThing()
}
這是因為如果有這樣一個函數接收了一個對象作為參數,並且執行了這個對象的 doAThing()
方法,編譯器會自動插入對象持有和釋放操作,以確保在這個方法的生命周期當中,這個對象不會被回收掉。
func perform(with object: Object) { __swift_retain(object) object.doAThing() __swift_release(object) }
這些對象持有和釋放操作是原子操作 (atomic operations),所以它們運轉緩慢就很正常了。或者,是因為我們不知道如何讓它們能夠運行得更快一些。
調度與對象 (3:28)
此外還有調度 (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
結構體 (4:57)
很多人都會說:要編寫性能優異的 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
如果其中包含了類的話,那麽性能耗費會更大。
抽象類型 (6:59)
正如我們此前所述,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
將會被存放在堆上,從而最後將線繪制出來。
https://academy.realm.io/cn/posts/real-world-swift-performance/
真實世界中的 Swift 性能優化