低延遲場景下的性能優化實踐

編者按:本文摘錄自「全球 C++ 及系統軟件技術大會」Boolan 資深諮詢師冉昕老師的專題演講。

Scott Meyers 曾說到過,如果你不在乎性能,爲什麼要在 C++ 這裏,而不去隔壁的 Python room 呢?

今天我們就從 “低延遲的概述”、“低延遲系統調整”、“低延遲系統編譯選項”、“低延遲軟件設計與編碼” 四個部分來聊聊低延遲場景下的性能優化實踐。

低延遲概述

低延遲場景

很多系統都會關注延遲,比如:電信系統、遊戲行業、音視頻解碼,或者一些金融系統。這裏我們就以金融場景爲例。

在程序化交易系統下,爲什麼需要關注低延遲?

程序化交易系統是接收市場的行情再去進行運算,然後發出交易信號。發出交易信號越早,就越可能掙到錢,如果晚了,錢都被別人掙了,自己可能就會虧錢。所以在這種場景下,低延遲是第一需求,**不會追求吞吐量。交易所都有流速權,即每秒的報單速度是有限的,不允許做很大的吞吐,所以金融對低延遲的要求是比較高的,也不在意資源利用率**。因爲我們的 CPU 會進行綁核,綁核會讓 CPU 處於 busy looping,讓它的佔有率達到 100%,那麼資源利用率就沒有任何參考價值。

當然,程序化交易系統資源都是超配的,比如內存、硬盤,雖然 CPU 沒有超配這一說,但儘可能配最好的。

低延遲優化特點

常用的性能優化就是做一些壓力測試、關注一下 QPS、看看系統負載需不需要內存、使用率怎麼樣,用 perf 工具去找出程序的熱點。“不成熟的優化是萬惡之源。”Profile 就是一個非常好的優化工具。

但對低延遲性能優化來說,Profile 可能就不是特別關鍵了。低延遲系統有追求延遲的線程,也有不追求延遲的、沒那麼 critical 的線程。critical 線程在我們系統整個代碼量中並不是特別大,這種情況下用 Profile 的數據是不準的,Profile 工具是採樣的,延遲很低就更難採到。所以在系統、設計、編碼的層次上需要提前考慮低延遲,也會提前規劃好哪些代碼要走 critical path 並對它進行優化。還要測試各單元的延遲,這個延遲可能是一個 tick-to-trade,即從行情開始到最後交易完成的整個系統的延遲,也可能是各個模塊、各個 function、各個語句塊、甚至各條語句的延遲,最後再去優化 critical path。

常見操作時延

我們來看一組以前的操作數據。

從最底下開始看,Disk read 一旦涉及到磁盤就和延遲無關了,這個結果顯然是不允許的。Context switch 是系統調用,在內核中會做很多操作,線程被調度出去再被調度回來,本身切換過程的耗時就非常大,再加上運行其他的線程,cache 可能都已經冷了,這裏的其他開銷可能就更難衡量。假如是 10K 的 CPU cycle,即便是 10GHz 的超頻服務器耗時也需要一個微秒,這在低延遲系統裏已經是非常大的開銷。這裏的異常拋出和 cache 處理佔的時間也比較長,如果代碼進了內核態再切換回來,這個延遲也是非常可觀的。

Allocation deallocation pair,這個延遲是指用 malloc/free 或 delete,申請內存的過程中會有內存管理器這一層,比如 Glibc ptmalloc,大多數情況下是不會系統調用,但它本身開銷也很可觀。如果你申請的內存本身 core 比較大,直接調用 mindmap,或者 Glibc 的緩存裏沒有 free 內存去分配,就會走到 kernel 再回來,這個時間開銷就更大了。

內存讀取包括主內存讀取、NUMA 去讀取另外一個節點的數據,性能開銷都是很大的。Mutex 在低延遲代碼裏也基本不會用。至於函數調用,不可能一個都不用,但可以用 inline 來減少函數調用。除了普通的函數調用,還有多態調用,即 vptr、vtable。Div 操作是 CPU 不喜歡的。

CPU 都是流水線執行的,"wrong" branch of "if" 和 “right"branch of"if",就是 CPU 執行到一個 if-else 時會自己去猜,如果猜對了,就幾乎可以忽略,如果猜錯了,代價就比較慘。

低延遲系統調整

硬件 & 系統

首先既對處理器的核數有要求,同時也對單核的頻率有要求,但這兩點是矛盾的。想要一個核數又多、頻率又高的,就要用到超頻服務器,執行效率越高越好,不需要虛擬化功能。內存也要充足。

超線程一般是關閉的,同一個程序在開超線程和不開超線程的機器跑的話,肯定是不開超線程的更快。另外,如果進行內核綁定,Critical thread 會獨佔一個核,如果綁定一個開了超線程的核就相當於綁定了同一個核,或者是一個核不用扔在那兒,這是沒有意義的。

操作系統是 64 位的 Linux,一般是 CentOS 或是 RHEL,最小化安裝,toolchain 升級,因爲默認自帶的可能是比較老的 GCC,我一般都習慣升級到 9 或 10。

最小化安裝還有一個比較有意思的點,因爲我個人是堅定的 Emacs 黨,不喜歡 vim,但 Emacs 會默認安裝一些圖形化插件,所以要在你的生產機器上裝 Emacs 的話就要裝 Emacs-nw 版本。Rtkernel 看起來好像和低延遲實施有關係,但實際上它是保證一種硬搶佔的內核 patch,這個對我們來說是完全不需要的。RHEL 一般都可以照着 Tuning guide 去對系統進行調整,如果是買服務器或超頻服務器,vendor 也會有 guide,可以斟酌一下要不要打開。

CPU 相關優化

CPU 優化最核心的就是要讓 Critical 線程獨佔 CPU,不能被打斷,要求極致的低延遲,而普通線程就無所謂。我們要做的就是先把一定數量的核 isolate,這樣操作系統就不會把任何的用戶態線程再調度到這個核上,然後再做 thread bonding,把 Critical thread 綁定到這些 isolate 的核裏去,這樣就保證了 thread 可以獨佔這個核。也可以設置 scheduler,對於 Critical thread 我們一般都是設置 FIFO 這種實時的優先級調度策略,對於普通的線程用 default(CFS) 就可以。

中斷

當遇到 kernel 中斷、時鐘中斷或 workqueue 等情況時還是可能會侵佔 CPU 時間,可以把中斷的 balance 關掉,設置中斷 affinity 到非 isolate 核心,這樣可以讓中斷對你的影響儘可能地小。這裏要提一下,時鐘中斷是不可能完全關閉的,除非改內核。

網卡要綁定到相應的 slot,一般和 Critical 線程綁定的 slot 是同一個。

內存優化也要避免進入內核態,一方面是分配的時候可能進入, 另一方面是觸發 fault 的時候。

fault,對於 Linux 操作系統來說,在內核層面上是不區分線程和進程的,都是用 task_struct 來表示線程。進程和線程唯一的區別就是進程的 tid 和 pid 是相等的,因爲一個進程的內存是共享的,所以每一個 task_struct 裏其實都有一個 mm_struct 指向同一個內存 object,這個內存的 object 分各個 area,每個 area 都標識了這塊內存的虛擬地址是否合法。

我們平時寫代碼的時候,不考慮 Glibc 有緩存內存,假如 malloc 或 new 一塊內存的請求到了操作系統,那麼操作系統做的一件事就是在剛纔所說的 mm_struct 裏的 vm_area 裏劃分並標識一塊合法的區域,這些操作都是在虛擬地址層面上,並沒有真實的物理地址層面,然後做完這個操作以後它就返回了。但實際上虛擬地址和物理地址之間需要有一個映射,即虛擬頁面。假如說是一個 4K 頁,和一個物理 4K 頁之間的映射關係沒有建立,那什麼時候建立呢?當 CPU 訪問這塊內存的時候就會觸發一個 fault,因爲 CPU 在 MMU 單元通過虛擬地址去找這塊物理地址找不到,這個 fault 交到操作系統,操作系統再進行處理,這相當於是一種操作系統 lazy 處理的模式。但這些過程都是需要內核深度參與的,一旦出現要在內核態做這麼多事情的情況,和低延遲就差得很遠了。

major fault 是指當內存不夠時,內存可能被交換到磁盤,再用到這塊內存時再從磁盤交換回去 。major fault 比較好解決,一種是禁用 swap 分區 ,而且內存比較充足的話一般也不會觸發,我們在系統裏還有一個 mlock 調用,mlock 調用以後就可以阻止你這個進程的內存被 swap 到硬盤上。

minor fault 就比較難搞了,這個過程中可能有多個手段,但也不保證能百分百把它消除掉。一種是用 huge page。因爲 fault 是以頁面爲單位的,huge page 可以把一個頁從 4K 變成 2M,這樣的好處是頁面 fault 的幾率就明顯會小很多。另外,虛擬地址頁面去找物理地址頁面需要 CPU 的 MMU 單元去找,它會優先去找 TLB。TLB 相當於映射的緩存,你可以認爲它是一個哈希,如果找不到就會到頁表裏面一級一級去找,可能是兩級,可能是三級,TLB 可以大大的提升這個這個尋找的時間。用了 huge page 以後,頁表總體更少了,TLB miss 幾率也就更低了。

對於 NUMA 來說,儘可能要它訪問自己的線程,不要跨 slot 訪問。NUMA 有多個內存的分配策略,一般默認的就是 localalloc,讓這個槽的線程分配的內存在 local 分配,不要到 remote 分配。還有一種是 Interleave,即平均在幾個 slot 裏面分配,這種是我們不想要的。

prefault 是很大的一個話題,就是可以分配內存,但是分配了之後要想辦法在真正使用之前先觸發它的 minor fault。這有多個層面去解決,一是可以 hack 內存管理器,可以自己寫,也可以優化 ptmalloc,當然如果有第三方的內存管理器可能會更好地解決這個問題。

網絡

現在 TCP 延遲較高基本是業界共識,大家都在想怎麼去解決這個問題,現在有趨勢就是交易這方面也往 UDP 轉,尤其是行情部分會越來越多地轉到 UDP。無論怎麼優化,你的緩衝區也好,中斷也好,還是會有硬件的中斷觸發,陷入內核態,只要你的協議棧在內核態,性能就不會很好,所以這種情況下就要用用戶態的協議棧。還有一些 FPGA 解決方案的,一般是券商或期貨公司在用。

低延遲系統編譯選項

先說一下編譯器選擇。Linux 主流的編譯器無非就是 GCC、Clang、ICC。ICC 一直作爲性能標杆的存在,但 ICC 在 C++ 的標準支持是比較落後的。ICC 做的比較好的是 CPU patch,它會針對不同的 CPU 的指令集生成很多份代碼,運行的時候會根據具體的指令去選擇最優的 function。

我們用的更多的還是 GCC,GCC 現在討論最多的就是 - O2 和 - O3。這個在選擇上沒有標準答案,我們就來看看 -O3 比 -O2 多了什麼吧。

首先,-finline-functions 除了代碼裏寫了 inline,或者用 GCC 的擴展 always_inline,GCC-O2 還會默認開一個 inline call_once function,還有一個 inline_function 我個人覺得是很有用的。GCC 10 開始就已經 include -O2 了,也會針對它不同的優化,不斷地把 -O2 move 到 -O3。但 -O3 不一定整個項目都能用,可以只針對某個 function 或某個 file 來打開。

-floop-unroll-and-jam 是指如果有多層循環的話會把外層循環展開。

-fipa-cp-clone 是指如果有多個參數,其中一個傳了常數的話,它有可能把這個 function clone 兩份,其中一份會去做一些常量展開、常量傳播,這個有時能用得上。也許你會說 “我代碼寫得比較好,我用 (const expression) 之類也能達到相似效果”,但是你不能保證所有人寫代碼或第三方庫都能做到這一點。

這張圖中上面兩段代碼都不是 cache friendly 的代碼,都是比較低效的內存訪問模式,但如果開了 -floop-interchange ,編譯器就幫我們優化到我們想要的樣子,cache friendly 就沒有問題了。

可能有的編碼規範上說不要在 foo 裏面加 branch,但這段代碼中看起來每個 foo 裏都加了 branch,其實如果開了編譯選項以後,GCC 會自動把 if 放到 foo 外面,如果這個 foo 裏面有一條賦值語句且和 foo 無關的話,也會被移到外面。

loop distribution 是目前的熱點話題,distribution 是把一個循環展開成兩個,但在這個例子中,展開成兩個看起來是反優化的:a[i] = b[i], b[i] = 0,在 cache 裏肯定是最快的,那爲什麼要拆開呢?loop distribution pattern 能發現這個代碼有一定的 pattern,上面用 memcpy() 搞定,下面用 memset() 搞定。

其他情況比如 a[i] = b[i],但對 b[i] + 1 有一些依賴,那對流水線是不友好的,這種情況也有可能拆開。

當然還有 loop fusion 這種相反的情況,本來寫的兩個循環,它發現合併了以後更有利於 cache friendly,可能就會做合併,但在 GCC 裏沒有做合併這個選項,我們自己寫代碼的時候需要注意一下。

loop-vectorize 是我認爲最關鍵的一個,這裏源自 Stack Overflow 的一個問題:在執行過程中有沒有 sort,性能差異是巨大的,爲什麼?

有了 sort 以後,CPU Branch Prediction 更好了,成功率很高,性能就很高。GCC-O3 比 -O2 更慢,核心原因是最初 cmov 指令在老的處理器架構上比較慢,而現代新的編譯器都用 cmov 做優化,不再用條件 jump 語句了,執行效率非常高。有了 cmov 以後就沒有分支了,也就不存在 sort 和 unsort 的區別了,也不存在 -O3 比 -O2 更慢的情況。

如果我們把 sum += data[c] 改成 sum += data[c] + data[c],那麼無論 GCC 還是 Clang 都不會再用 cmov,而是用傳統條件跳轉的方式,這種情況下性能就又有差別了,loop-vectorize 就可以起作用了。

如果啓用了 loop vectorize,它就會用 SSE 指令集 去優化這個循環的過程,也就是說,這個性能和 cmov 版本相比不會差,甚至是更好,所以說 loop vectorize 很多時候是非常有用的。如果是針對 -march=native,讓編譯器針對當前的處理器架構做一些優化的話,如果你的處理器支持 AVX 2 或是更高的 AVX-512 指令集,那它可以給你做更進一步的優化,性能提升得更大。

O3 與 Ofast 的主要區別在於 -ffast-math 是針對浮點數進行運算的。

Profile-Guided Optimisations 和 Profile 有點像,對於金融來說是測不準的。

-funroll-loops 在 Clang-O2 就有這個優化,但基於 GCC 只有開了 Profile Guided 優化纔會把循環展開,這種情況下如果希望強制展開,可以用 #pragma。

-march 要麼 = native,要麼等於目標架構。

-flto 一般也是打開的,可以減少 binary size,跨文件單元進行優化。

irace 是一個開源工具,是把各個編譯選項排列組合,你提供一個測試程序,看一看哪個性能最高。

loop-vectorize 對於 int 是能提升性的,但如果把 type 改成 double,由於 floating 運算不支持結合律,loop vectorize 就做不了。那要如何進行優化呢?

如果你對浮點數的要求的精度運算沒有那麼高,可以打開 -ffast-math 下 -funsafe-math-optimizations 三個選項:

但要注意打開 -ffast-math 有可能產生一些精度問題,一定要對你的程序進行一些精確的測量,否則會出現一些莫名其妙的運算錯誤,對交易來說,這個運算錯誤肯定是非常致命的。

總結來說,開哪個編譯並沒有標準答案,我個人會開 Ofast 也會開 -march=native,需要結合你的具體項目需要。

低延遲軟件設計與編碼

由於我們相當於是一個 client,不會用多進程,線程也都是提前創建的,因爲創建線程肯定是要進內核態,而且內核態開銷比較大,線程池也不太用,靜態鏈接會比動態鏈接有百分之幾的性能提升,建議用靜態鏈接。數據拷貝和數據共享也都儘可能避免。 

因爲我們有數很多數據,即使是 critical 線程,critical path,系統剛啓動時你總有一定的時間可以進行一些比較耗時的操作,你可以把這些計算量比較大的東西先算好,然後每來一些新的數據就可以用一些增量的方法來更新。最簡單的,比如算一些均線、布林線、MACD 指標等都可以考慮怎樣增量計算,畢竟預算量越小、指令越少,性能就越高,讓代碼儘可能減少間接層,慎用第三方庫。

運行時多態,通過 vptr 去找 vtable 會有一定的性能開銷,另外如果是虛函數調用就沒法 inline,這個帶來的性能損失可能更大。這裏有一些模板去解決這個問題,比如 CRTP、Policy based class design 之類。如果集成 path 上虛函數和具體函數只有一個實現的話,這個編譯選項有可能會把虛函數間接的調用優化掉,還會有一個 vtable 的比較,我覺得這個可有可無,性能開銷也不會很大,僅有一個實現的話還是可以考慮的。

這個案例中上面是基類,下面是派生類。基類裏有一些 virtual function,這裏用了一個 Strategy 設計模式,可能也是一個抽象類,運行時指向幾個具體的類,通常寫代碼時這樣寫肯定是沒有問題的,但如果這裏面的觸發至少有兩次的虛函數都用開銷,並且也不能 inline ,我們一般都會用 CRTP 這種模板的方式去解決。首先,基類 OnTick 會調用派生類的 OnTickImpl,這個 OMType* _om 也不再作爲 Strategy 設計模式,直接繼承下來並作爲一個模板參數,在編譯時就把它決議掉,然後在具體的類再去實現 OnTickImpl,這樣就沒有虛函數的開銷,也可以 inline。

我們做這個模板的時候有很多類型信息可能拿不到,所以就用一些 traits 的方式,比如各個接口之間用這個 concept 定一個 traits,每個接口都把這個 traits 實現好,基類就可以去根據這個 traits 去取派生類的一些信息。這裏 if constexpr 也相當於一個編譯態的 if,和 enable_if 很像,通過這個也可以針對類型做一些分支處理,這些在運行時都是沒有任何開銷的。enable_if 這有一個參數,來判斷它是不是 bool Critical 線程,如果是,就直接用 write(msg) , 因爲有另外一個線程會 busy loop 去做這件事情,可能就需要 _cv.notify。

系統調用要儘可能避免,除非就是 vdso 支持的可以考慮派出。我們知道 vdso 就相當於是內核開闢了一塊空間,方便系統調用拿數據。vdso 只支持幾個時間相關的以及 getcpu 有限的幾個。

rdtsc 是最方便的取時間的方式,而 clock 是真正的系統調用,它就不在 vdso 裏,性能開銷非常大。

打日誌也是要非常謹慎,我們很多地方注意了低延遲,但其實打日誌的延遲是非常大的,它開銷主要是兩部分:一部分是 format 開銷,一部分是獲取時間的開銷 。format 開銷有一種方式是編譯的時候生成一些信息,運行時把這些 format 延遲推後,然後通過一些離線的工具,在生成 log 的時候再去做這個 format,比如說一些低開銷的日誌庫就能做這些事情。

動態內存分配有可能觸發系統調用,並且會引發配置 fault,所以要儘量避免。避免的方式有很多,你可以用 placement new、memory pool。但 STL 及第三方庫帶來的內存分配很難避免,要避免的話,一是要提前分配內存,用 ring buffer 之類,map/set 數量少的話可以用 sorted array 替代。pool allocator 也可以實現內存池。

減少內存分配就是讓 Glibc 的內存分配了以後儘可能不回收,一旦回收給操作系統,下次再申請就比較麻煩。

這裏有一個例子。

vector 和 string 一樣是可以提前分配內存的,你可以先 alloc 一塊 memory,目的就是 presort,每隔 4096 都寫一下,sort 完之後 clear,如果系統調整好了,即使是默認的,Glibc 也不會回收這塊內存。然後下次你再一個個 push_back,也不會觸發 page fault。你可以通過工具,包括這個 perf 來看 minor-faults 有多少來驗證。這僅限於 Glibc,對於谷歌 tcmalloc 和 Facebook jemalloc,因爲是互聯網環境,它們對內存回收都比較積極,所以這個方法對於它們都不適用。

string 的開銷其實也是比較大的,但好處是它引起的開銷就是堆上開銷。它在堆上分配內存,在堆上分配一個差的數組,如果 std::string 比較小,就會在棧上分配內存,那速度就是比較快的。

hashmap 也很關鍵,因爲裏面的數據也會觸發堆內存分配,這個也是不可以接受的。因此不能用那種鏈式的,都是用線性搜索。如果一塊連續的地址填滿了一個 hashmap,就會到緊鄰的位置去找。

消息傳遞會用一些 lockfree queue 無鎖編程,其實它內部也是有 lock 指令的,也是有開銷的,所以如非必要也不建議用。如果極端情況下確實需要 locK

的話,用 spinlock,不要用 mutex/semaphore,因爲 spinlock 是一種懵懂的狀態,它不會進內核態等你的內核喚醒,所以它的開銷還是比較小的。對於 mutex,現在是用 futex 優化,如果沒有發生鎖衝突,它也不會進內核態,但即使這樣,mutex 的實現也比較複雜,開銷也是比較大的。

這張圖中我們可以看到 spinlock 開銷還是比較低的,atomic 的操作也不高,當然原生的肯定是最高的。對於 mutax,我這裏測的都是在沒有資源競爭的情況下,這個數據已經非常不好了。

代碼儘可能少用 branch,比如這裏是一個 lookup table,我們在 table 裏寫的這些 function 就可以減少一些 switch case 或者 else 的分支判斷。

寫代碼的時候,爲了節省時間資源,往往都傾向於鼓勵提前退出,都會把比較高的 if 放到前面。但對於我們的情況可能正好相反。

在低延遲場景下,大多數時候我們最終的信號是不會發出執行的,這種情況下如果讓它過早退出,那這段很大的代碼,包括數據,中間的 cache 都是冷的,那下次真正執行的效率就會比較低,這種是我們不想要的。

我們會在不 crash 的情況下,儘可能多執行代碼,讓它把一個完整的流程走完,不在意 CPU 時間浪費。在這裏我們不是用或操作符,而是用按位操作符。用這種操作來換取 branch 只會有一個分支,就不會有三個分支了。

分支預測基本上是 [[likely]], [[unlikely]],相當於是給編譯器的一個參考。但實際上它只是決定靜態的分支預測,到實際 CPU 運行的時候會按照實際的 branch 是否 take 來決定下一次怎麼預測。

這和剛纔那個例子一樣,可能 99.9% 的情況下我們這個交易信號是觸發不了的,那我永遠走的是不觸發的那個 branch,這樣 CPU 也記住了,每次都會走到不觸發的 branch。當我真正想要觸發時是最需要低延遲的時候,這個 branch 就給我預測錯,並且所有 cache 都是冷的。這種情況有一些技巧,比如,可以用一些假的程序儘可能地往下執行,到最後一步停。還有最簡單的辦法就是我這個訂單真的發到櫃檯,只不過我把精度擴大一位,比如說有效價格是兩位,我給它設三位,那這個單子發出去了也會被櫃檯拒絕,但我所有的 branch 都走到了。但這樣做可能券商或期貨公司不喜歡,因爲會有大量的廢單進來。

異常如果不觸發,對性能基本上沒有什麼影響,大家就沒有什麼心理負擔。寫代碼的時候,作用域儘可能小,儘可能用 const ,連接性也儘可能低。這樣目的就是讓編譯器知道更多,編譯器知道更多的信息,就可能幫你做更多的優化。

智能指針 unique_ptr 的開銷基本可以忽略,開銷本身是動態分配內存的開銷,shared_ptr 裏面有兩個 atomic 變量,當然它底層不是用 atomic,而是用的更原始的操作去做的,但這個也會有性能開銷,傳參的時候也不要覺得它是智能指針可以自動加一減一就直接傳了,還是按照引用的方式傳比較好。

最後,C++ 20 的一些新特性對低延遲有一些幫助。atomic shared_ptr 目前內部還是用鎖實現的,也是暫時不能用,希望以後可能有更優化的實現。

關於我們

CPP 與系統軟件聯盟是 Boolan 旗下連接 30 萬 + 中高端 C++ 與系統軟件研發骨幹、技術經理、總監、CTO 和國內外技術專家的高端技術交流平臺。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/2drIFihDSBLkhklPuSmZkQ