天天看點

真實世界中的 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/

轉載于:https://www.cnblogs.com/feng9exe/p/10309357.html

繼續閱讀