要說寫代碼是每位程序員的使命,那么寫優秀的代碼則是每位程序員的底線。本文作者分享基于 Go 語言的代碼重構,使得性能提升 23 倍的快速方法。
以下為譯文:
幾周前,我讀了一篇名為“Go 語言中的好代碼與差代碼”(https://medium.com/@teivah/good-code-vs-bad-code-in-golang-84cb3c5da49d)的文章,作者一步步地向我們介紹了一個實際業務用例的重構。
文章的主旨是利用 Go 語言的特性將“差代碼”轉換成“好代碼”,即更加符合慣例和更易讀的代碼。但是它也堅持性能是項目重要的方面。這就引起了我探索的好奇心:讓我們深入看看!
這篇文章里的程序基本上就是讀取輸入文件,然后解析每一行并存儲到內存的對象中。
作者不僅在 Github 上發布了他的代碼(https://github.com/teivah/golang-good-code-bad-code),還寫了個性能測試程序。這真是個好主意,鼓勵大家調整代碼并用如下命令重現測量結果:
每次執行所需的微秒數(越小越好)
基于此,在我的機器上測出“好代碼”速度提升了 16%。那么我們可以進一步提高嗎?
以我的經驗看來,代碼質量和性能間的相互關系非常有趣。如果你成功地重構代碼,讓代碼更清晰,更進一步分離,那么最終代碼速度會加快,因為它不會像以前一樣徒勞無功地執行不相關的指令,而且一些可能的優化會凸顯出來,且易于實現。
另一方面,如果進一步追求性能,那就不得不放棄簡單性并訴諸黑科技。實際上你只減少了幾毫秒,但是代碼的質量會受到影響,會變得晦澀難懂、脆弱且缺乏靈活性。
簡單性先是上升,繼而下降
你需要權衡利弊:應該進行到什么程度?
為了正確地確定性能的優先級,最有價值的策略是找到瓶頸,然后集中精力改善??梢允褂梅治龉ぞ邅碜?!例如 Pprof(https://blog.golang.org/profiling-go-programs) 和 Trace(https://making.pusher.com/go-tool-trace/):
一個非常大CPU使用圖
彩虹追蹤:許多小任務
追蹤結果證明所有的 CPU 內核都得到了利用,乍一看似乎不錯。但是它顯示了幾千個很小的彩色計算片段,還有一些空白表示內核閑置。讓我們放大一點:
3毫秒的窗口
實際上,每個內核都有大量閑置的時間,并且在多個微型任務間不斷切換??雌饋砣蝿盏牧6炔⒉焕硐?,從而導致大量上下文切換,還有同步引起的資源爭搶。
我們用數據沖突檢測器檢查下同步是否正確(如果同步都不正確,那問題就不只是性能了):
很好!看起來沒問題,沒有遇到數據沖突。
“好代碼”中的并發策略是把輸入中的每一行交給單獨的 Go 例程,以便利用多核。這是合理的直覺,因為 Go 例程以輕量和廉價著稱。那么并發能帶來多少好處呢?讓我們比較一下使用單一 Go 例程順序執行的代碼(僅需在調用行解析函數的時候,刪掉關鍵字go)。
每次執行所需的微秒數(越小越好)
哎呀,實際上不用并行的代碼速度更快。這意味著啟動go例程的開銷超過了同時使用多核所節省的時間。
現在我們放棄并發,轉而使用順序執行,那么下一步自然是不要使用通道來傳遞結果,以節省開銷。我們用一個裸分片來代替。
每次執行所需的微秒數(越小越好)
僅僅通過簡化代碼,刪除并發,現在“好代碼”版本將速度提高了40%。
使用單個go例程的時候,一段時間內僅有1個CUP在工作
現在讓我們看看Pprof圖形都調用了哪個函數。
找到瓶頸
我們目前的版本的狀況是:86%的時間真正用在了解析消息上,這非常好。我們立刻注意到43%的時間用在了匹配正則表達式上:調用(*Regexp).FindAll。
雖然從原始文本中抽取數據時,正則表達式非常方便,而且很靈活,但是它們也有弊端,例如需要耗費內存和運行時間。正則表達式很強大,但是在很多情況下是殺雞用牛刀。
在我們的程序中,文本模式為:
主要是為了識別以“-”開頭的“命令”,而且一行可能有多個命令。我們可以用bytes.Split做一些略微的調整。讓我們用Split替換代碼中的正則表達式:
每次執行所需的微秒數(越小越好)
哇,這一改速度又提高了40%!
現在 CPU 的圖如下所示:
沒有正則表達式的巨大開銷了。5個不同的函數中的內存分配占用了40%的時間,還說得過去。很有意思的是現在21%的時間被bytes.Trim占據了。
這個函數調用讓我很感興趣:我們可以改善它嗎?
bytes.Trim需要一個“cutset string”作為參數(用于分隔符),但我們的分隔符只是一個空格而已。這就是個可以引入一些復雜性來提高性能的例子:實現自己定義的“trim”函數來代替標準庫。自定義的“trim”僅處理單個分隔符字節。
每次執行所需的微秒數(越小越好)
哈哈,又快了20%。目前的版本的速度是最初“差代碼”的4倍,雖然我們只用到了機器的一個CPU內核。相當可觀!
早些時候,我們在處理每行輸入的級別放棄了并發,但是我們仍然可以在更粗的力度上使用并發提高性能。 例如,如果每個文件在各自的go例程中進行處理,那么在我的工作站上處理6千個文件(6千個消息)的速度要比串行更快:
每次執行所需的微秒數(越小越好,紫色代表并發
速度提高了66%(也就是提到了3倍),看起來不錯,但是想到它使用了我所有12個CPU內核,那么這個結果“也沒有那么好”。這可能意味著,使用新的優化代碼,處理單個文件仍然是一項“小任務”,go例程和同步的開銷不可忽略。
有趣的是,如果將消息數量從6千增加到12萬,對于串行版本的性能沒有影響,而且還會降低“每個消息1個例程”版本的性能。這是因為啟動大量go例程是可能的,有時也很有用,但它確實給go的運行時間調度帶來了一些壓力。
我們可以通過僅創建幾個工作進程(例如12個持續運行的go例程)來進一步縮短執行時間(雖然達不到12倍,但還是會加快速度),每個go例程處理消息的一個子集:
每次執行所需的微秒數(越小越好,紫色代表并發)
與串行版本相比,針對大量消息進行改進后的并發減少了79%的執行時間。 請注意,只有在確實需要處理大量文件時,此策略才有意義。
最佳地利用所有CPU內核的代碼由幾個go例程組成,每個go例程負責處理一定量的數據,在處理完成之前不進行任何通信和同步。
一種常見的啟發式方法就是選擇與可用CPU核心數量相等的進程(go例程),但它并不總是最佳選擇,因為每個任務的情況都不一樣。 例如,如果任務是從文件系統讀取數據或發出網絡請求,那么從性能的角度來看,go例程多于CPU核心數量是完全正確的。
每次執行所需的微秒數(越小越好,紫色代表并發)
現在,解析代碼的效率很難再通過局部改進來提高了。執行時間中的主要部分是小對象的分配和垃圾回收(例如消息結構),這是合理的,因為我們知道內存管理操作相對較慢。 對分配策略的進一步優化......權當是留給高手們的一個練習吧。
使用完全不同的算法也會可以大幅提高速度。
這時,我從 Rob Pike 的《Lexical Scanning in Go》演講中獲得了靈感。構建自定義語法分析其和自定義解析器。 這只是一個原型(我沒有實現所有的極端情況),它不如原始算法直觀,并且正確實現錯誤處理可能會很棘手。 但是,它的速度比前一個版本提高了30%。
每次執行所需的微秒數(越小越好,紫色代表并發)
好了,與最初的代碼相比,速度提高了 23 倍。
今天就說這么多,我希望你們能喜歡這篇文章。下面是一些免責聲明和建議的關鍵點:
·在許多抽象的層次上都可以通過不同的技巧提高性能,以獲得性能的成倍增長。
·首先在最高抽象層次上調優:數據結構,算法,以及正確的解耦合。低層調優放在后面:輸入輸出,批處理,并發,標準庫的使用,內存管理等。
·算法復雜度分析十分重要,但并不是讓程序運行得更快的最佳工具。
·性能測試很難。通過分析工具和性能測試發現瓶頸,以獲得代碼的執行情況。時刻牢記性能測試不是最終用戶在生產環境中感受到的“真正”延遲,所以性能測試數據僅供參考。
·幸運的是,工具(Bench、Pprof、Trace、數據沖突檢測器、Cover)使得檢查性能變得十分容易,并且鼓舞人心。
·停下來問問自己,多快才算快。不要浪費時間去優化一次性的腳本。要記住,優化也是要付出成本的:工程時間、復雜度、bug,還有技術債務。
·混淆代碼之前一定要慎重!
·Ω(n2) 以及更高的算法通常都很昂貴。
·復雜度在O(n)或O(n log n)及以下的算法一般都沒問題。
·隱藏因素不能忽略!例如,本文中的所有改進都是針對隱藏因素的,而沒有改變算法的復雜度級別。
·輸入輸出通常都是瓶頸,如網絡請求、數據庫查詢、文件系統訪問等。
·正則表達式的代價通常會超過實際需要。
·內存分配比計算更昂貴。
·棧中的對象比堆中的對象代價更低。
·分片可以用來替代昂貴的內存重新分配。
·字符串在只讀的情況下很合適(包括重新分片),但對于其他一切操作,[]byte的效率更高。
·內存的局部性很重要(更適合CPU緩存)。
·并發和并行很有用,但很難用好。
·在深入到更底層時會遇到你不希望在Go語言中解決的“玻璃地板”。如果你開始使用匯編指令、intrinsic函數、SIMD指令……或許你應該考慮用Go語言做原型,然后換成低級語言來榨干硬件性能,節省每一納秒!
1024你懂的国产日韩欧美_亚洲欧美色一区二区三区_久久五月丁香合缴情网_99爱之精品网站
責任編輯:韓希宇
免責聲明:
中國電子銀行網發布的專欄、投稿以及征文相關文章,其文字、圖片、視頻均來源于作者投稿或轉載自相關作品方;如涉及未經許可使用作品的問題,請您優先聯系我們(聯系郵箱:cebnet@cfca.com.cn,電話:400-880-9888),我們會第一時間核實,謝謝配合。