Go代碼重構:23倍的性能爆增

 

幾周前,我讀了一篇名爲「 Good Code vs Go Code中的錯誤代碼 」的文章,做者指導咱們逐步完成實際業務用例的重構。html

本文的重點是將「壞代碼」轉變爲「良好代碼」:更具慣用性,更易讀,利用go語言的細節。但它也堅持將性能做爲項目的一個重要方面。這引起了個人好奇心:讓咱們深刻挖掘!git


該程序基本上讀取一個輸入文件,並解析每一行以填充內存中的對象。github

 

 

 
 
做者不只 在Github上發佈 了源代碼,他還寫了一個慣用的基準。這是一個很是好的主意,就像邀請調整代碼並使用命令重現測量:
$ go test -bench =。

 

 
 
 
每次執行μs(越小越好)

所以,在個人機器上,「好代碼」的速度提升了16%。咱們能得到更多嗎?golang

根據個人經驗,代碼質量和性能之間存在有趣的關聯。當您成功地重構代碼以使其更清晰且更加分離時,您一般最終會使其更快,由於它不會使以前執行的無關指令變得混亂,而且還由於一些可能的優化變得明顯且易於實現。正則表達式

另外一方面,若是你進一步追求性能,你將不得不放棄簡單並訴諸於黑客。你確實會刮掉幾毫秒,但代碼質量會受到影響,由於它會變得更難以閱讀和推理,更脆弱,更不靈活。算法

 

 

攀登mount Simplicity,而後降序

這是一個權衡:你願意走多遠?數據庫

爲了正確肯定您的績效工做的優先順序,最有價值的策略是肯定您的瓶頸並專一於它們。要實現這一點,請使用分析工具!PprofTrace是你的朋友:canvas

$ go test -bench =。-cpuprofile cpu.prof 
$ go tool pprof -svg cpu.prof> cpu.svg
 

 

一個至關大的CPU使用率圖(點擊SVG)
$ go test -bench =。-trace trace.out 
$ go工具跟蹤trace.out
 

 

彩虹追蹤:許多小任務(點擊打開,僅限Chrome)

跟蹤證實使用了全部CPU內核(底線0,1等),這在一開始看起來是件好事。但它顯示了數千個小的彩色計算切片,以及一些空閒插槽,其中一些核心處於空閒狀態。咱們放大:緩存

 
一個3毫秒的窗口(點擊打開,僅限Chrome)

每一個核心實際上花費大量時間閒置,並在微任務之間保持切換。看起來任務的粒度不是最優的,致使許多上下文切換以及因爲同步而致使的爭用。網絡

讓咱們檢查一下競爭檢測器是否同步是正確的(若是沒有,那麼咱們的問題比性能更大):

$ go test -race 
PASS

是!!看起來是正確的,沒有遇到數據爭用狀況。測試函數和基準函數是不一樣的(參見文檔),但在這裏他們調用相同的函數ParseAdexpMessage,咱們可使用-race

「好」版本中的併發策略包括在其本身的goroutine中處理每行輸入,以利用多個核心。這是一種合法的直覺,由於goroutines的聲譽是輕量級和廉價的。咱們多少得益於併發性?讓咱們與單個順序goroutine中的相同代碼進行比較(只需刪除行解析函數調用以前的go關鍵字)

 
 
 

 

每次執行μs(越小越好)

哎呀,沒有任何並行性,它實際上更快。這意味着啓動goroutine的(非零)開銷超過了同時使用多個核心所節省的時間。

天然的下一步,由於咱們如今順序而不是同時處理行,是爲了不使用結果通道的(非零)開銷:讓咱們用裸片替換它。

 
 
每次執行μs(越小越好)

咱們如今從「好」版本得到了大約40%的加速,只是簡化了代碼,刪除了併發(差別)。

 

 

使用單個goroutine,在任何給定時間只有1個CPU內核正在工做。

如今讓咱們看一下Pprof圖中的熱函數調用:

 

 

發現瓶頸

咱們當前版本的基準(順序,帶切片)花費86%的時間實際解析消息,這很好。咱們很快注意到,總時間的43%用於將正則表達式與(* Regexp).FindAll匹配  。

雖然regexp是從原始文本中提取數據的一種方便靈活的方法,但它們存在缺陷,包括內存和運行時的成本。它們很強大,但對於許多用例來講可能有點過度。

在咱們的程序中,模式

patternSubfield =「 - 。[^  - ] *」

主要用於識別以短劃線「 - 」 開頭的「 命令 」,而且一行可能有多個命令。經過一些調整,可使用bytes.Split完成讓咱們調整代碼(commitcommit)以使用Split替換regexp:

 

 

每次執行μs(越小越好)

哇,這是40%的額外增益!

CPU圖如今看起來像這樣:

 
 
 

 

沒有更多正則表達式的巨大成本。從5個不一樣的功能中分配內存花費了至關多的時間(40%)。有趣的是,總時間的21%如今由字節.Trim  。

 

 

這個函數引發了個人興趣:咱們能夠作得更好嗎?

bytes.Trim指望一個「 cutset string」做爲參數(對於要在左側和右側刪除的字符),但咱們僅使用單個空格字節做爲cutset。這是一個例子,您能夠經過引入一些複雜性來得到性能:實現您本身的自定義「trim」函數來代替標準庫函數。自定義的「微調」的交易,只有一個割集字節。

 

 
 
每次執行μs(越小越好)

是的,另外20%被削減了。當前版本的速度是原始「壞」速度的4倍,而機器只使用1個CPU內核。至關實質!


以前咱們放棄了在線處理級別的併發性,可是經過併發更新仍然存在改進的空間,而且具備更粗略的粒度。例如,在每一個文件在其本身的goroutine中處理時,處理6,000個文件(6,000條消息)在個人工做站上更快:

 

 

 

 
每條消息μs(越小越好,紫色併發)

66%的勝利(即3倍的加速),這是好的但「不是那麼多」,由於它利用了我全部的12個CPU內核!這可能意味着使用新的優化代碼,處理整個文件仍然是一個「小任務」,goroutine和同步的開銷不可忽略不計。

有趣的是,將消息數量從6,000增長到120,000對順序版本的性能沒有影響,而且下降了「每一個消息的1個goroutine」版本的性能。這是由於啓動大量的goroutine是可能的,有時是有用的,但它確實給go運行時調度程序帶來了一些壓力

咱們能夠經過僅建立少數工做人員來減小執行時間(不是12倍因素,但仍然是這樣)例如12個長期運行的goroutine,每一個goroutine處理一部分消息:

 

 


 

每條消息μs(越小越好,紫色併發)

與順序版本相比,大批消息的調優併發性刪除了79%的執行時間。請注意,只有在確實要處理大量文件時,此策略纔有意義。

全部CPU核心的最佳利用包括幾個goroutine,每一個goroutine處理至關數量的數據,在完成以前沒有任何通訊和同步。

選擇與可用CPU核心數相等的多個進程(goroutine)是一種常見的啓發式方法,但並不老是最佳:您的里程可能會根據任務的性質而有所不一樣。例如,若是您的任務從文件系統讀取或發出網絡請求,那麼性能比CPU核心具備更多的goroutine是徹底合理的。

 

 

每條消息μs(越小越好,紫色併發)

咱們已經達到了這樣的程度,即經過本地化加強很難提升解析代碼的效率。如今,執行時間由小對象(例如Message結構)的分配和垃圾收集主導,這是有道理的,由於已知內存管理操做相對較慢。進一步優化分配策略......留給狡猾的讀者練習。


使用徹底不一樣的算法也能夠帶來很大的加速。

在這裏,我從此次演講中汲取靈感

 
Go - Rob Pike中的詞彙掃描

構建自定義Lexer()和自定義Parser()。這是一個概念證實(我沒有實現全部的角落狀況),它不像原始算法那麼直觀,而且錯誤處理可能很難正確實現。可是,它比之前的優化版本節儉快30%。

 

 

每條消息μs(越小越好,紫色併發)

是的,與初始代碼相比,這是一個23倍的加速因子。


這就是今天,我但願你喜歡這個旅程。如下是一些免責聲明和外賣:

  • 可使用不一樣的技術在多個抽象級別上提升性能,而且增益是乘法的。
  • 首先調整高級抽象:數據結構,算法,適當的解耦。稍後調整低級抽象:I / O,批處理,併發,stdlib使用,內存管理。
  • Big-O分析是基礎,但一般不是使給定程序運行得更快的相關工具。
  • 基準測試很難。使用分析和基準來發現瓶頸並深刻了解您的代碼。請記住,基準測試結果並非最終用戶在生產中遇到的「真實」延遲,而且將這些數字與鹽分相提並論。
  • 幸運的是,工具(BenchPprofTraceRace detectorCover)使性能探索變得平易近人且使人興奮。
  • 編寫好的相關測試並不是易事。但它們很是珍貴,能夠幫助「保持正常」,即重構,同時保留原始的正確性和語義。
  • 花一點時間問問本身「足夠快」的速度有多快。不要浪費時間過分優化一次性腳本。考慮到優化伴隨着成本:工程時間,複雜性,錯誤,技術債務。
  • 在模糊代碼以前要三思然後行。
  • Ω(n²)及以上的算法一般很昂貴。
  • O(n)或O(n log n)或更低的複雜度一般很好。
  • 隱性因素是不容忽視的!例如,文章中的全部改進都是經過下降這些因素來實現的,而不是經過改變算法的複雜性類來實現的。
  • I / O一般是一個瓶頸:網絡請求,數據庫查詢,文件系統。
  • 正則表達式每每是比實際須要更昂貴的解決方案。
  • 內存分配比計算更昂貴。
  • 堆棧中的對象比堆中的對象便宜。
  • 切片可用做替代昂貴的從新分配的替代方案。
  • 字符串對於只讀使用(包括從新設置)是有效的,但對於任何其餘操做,[]字節更有效。
  • 內存局部性很重要(CPU緩存友好性)。
  • 併發和並行是頗有用的,但要正確起來很棘手。
  • 當挖掘更深層次和更低層次時,有一個「玻璃地板」,你真的不想在其中突破。若是你渴望asm指令,內在函數,SIMD ...也許你應該考慮去作原型,而後切換到低級語言來充分利用硬件和每納秒!
相關文章
相關標籤/搜索