低延遲場(chǎng)景下的性能優(yōu)化實(shí)踐
編者按:本文摘錄自「全球C++及系統(tǒng)軟件技術(shù)大會(huì)」Boolan資深咨詢師冉昕老師的專題演講。
Scott Meyers 曾說(shuō)到過(guò),如果你不在乎性能,為什么要在C++這里,而不去隔壁的 Python room 呢?
今天我們就從“低延遲的概述”、“低延遲系統(tǒng)調(diào)整”、“低延遲系統(tǒng)編譯選項(xiàng)”、“低延遲軟件設(shè)計(jì)與編碼”四個(gè)部分來(lái)聊聊低延遲場(chǎng)景下的性能優(yōu)化實(shí)踐。
1.低延遲概述
低延遲場(chǎng)景
很多系統(tǒng)都會(huì)關(guān)注延遲,比如:電信系統(tǒng)、游戲行業(yè)、音視頻解碼,或者一些金融系統(tǒng)。這里我們就以金融場(chǎng)景為例。
在程序化交易系統(tǒng)下,為什么需要關(guān)注低延遲?

程序化交易系統(tǒng)是接收市場(chǎng)的行情再去進(jìn)行運(yùn)算,然后發(fā)出交易信號(hào)。發(fā)出交易信號(hào)越早,就越可能掙到錢,如果晚了,錢都被別人掙了,自己可能就會(huì)虧錢。所以在這種場(chǎng)景下,低延遲是第一需求,不會(huì)追求吞吐量。交易所都有流速權(quán),即每秒的報(bào)單速度是有限的,不允許做很大的吞吐,所以金融對(duì)低延遲的要求是比較高的,也不在意資源利用率。因?yàn)槲覀兊?CPU 會(huì)進(jìn)行綁核,綁核會(huì)讓 CPU 處于 busy looping,讓它的占有率達(dá)到100%,那么資源利用率就沒(méi)有任何參考價(jià)值。
當(dāng)然,程序化交易系統(tǒng)資源都是超配的,比如內(nèi)存、硬盤,雖然 CPU 沒(méi)有超配這一說(shuō),但盡可能配最好的。
低延遲優(yōu)化特點(diǎn)

常用的性能優(yōu)化就是做一些壓力測(cè)試、關(guān)注一下QPS、看看系統(tǒng)負(fù)載需不需要內(nèi)存、使用率怎么樣,用 perf 工具去找出程序的熱點(diǎn)。“不成熟的優(yōu)化是萬(wàn)惡之源?!盤rofile 就是一個(gè)非常好的優(yōu)化工具。
但對(duì)低延遲性能優(yōu)化來(lái)說(shuō),Profile 可能就不是特別關(guān)鍵了。低延遲系統(tǒng)有追求延遲的線程,也有不追求延遲的、沒(méi)那么 critical 的線程。critical 線程在我們系統(tǒng)整個(gè)代碼量中并不是特別大,這種情況下用 Profile 的數(shù)據(jù)是不準(zhǔn)的,Profile 工具是采樣的,延遲很低就更難采到。所以在系統(tǒng)、設(shè)計(jì)、編碼的層次上需要提前考慮低延遲,也會(huì)提前規(guī)劃好哪些代碼要走 critical path 并對(duì)它進(jìn)行優(yōu)化。還要測(cè)試各單元的延遲,這個(gè)延遲可能是一個(gè) tick-to-trade,即從行情開(kāi)始到最后交易完成的整個(gè)系統(tǒng)的延遲,也可能是各個(gè)模塊、各個(gè) function、各個(gè)語(yǔ)句塊、甚至各條語(yǔ)句的延遲,最后再去優(yōu)化 critical path。
常見(jiàn)操作時(shí)延
我們來(lái)看一組以前的操作數(shù)據(jù)。

從最底下開(kāi)始看,Disk read 一旦涉及到磁盤就和延遲無(wú)關(guān)了,這個(gè)結(jié)果顯然是不允許的。Context switch 是系統(tǒng)調(diào)用,在內(nèi)核中會(huì)做很多操作,線程被調(diào)度出去再被調(diào)度回來(lái),本身切換過(guò)程的耗時(shí)就非常大,再加上運(yùn)行其他的線程,cache 可能都已經(jīng)冷了,這里的其他開(kāi)銷可能就更難衡量。假如是 10K 的 CPU cycle,即便是10GHz 的超頻服務(wù)器耗時(shí)也需要一個(gè)微秒,這在低延遲系統(tǒng)里已經(jīng)是非常大的開(kāi)銷。這里的異常拋出和 cache 處理占的時(shí)間也比較長(zhǎng),如果代碼進(jìn)了內(nèi)核態(tài)再切換回來(lái),這個(gè)延遲也是非??捎^的。
Allocation deallocation pair,這個(gè)延遲是指用 malloc/free?或 delete,申請(qǐng)內(nèi)存的過(guò)程中會(huì)有內(nèi)存管理器這一層,比如 Glibc ptmalloc,大多數(shù)情況下是不會(huì)系統(tǒng)調(diào)用,但它本身開(kāi)銷也很可觀。如果你申請(qǐng)的內(nèi)存本身core比較大,直接調(diào)用 mindmap,或者 Glibc 的緩存里沒(méi)有 free 內(nèi)存去分配,就會(huì)走到 kernel 再回來(lái),這個(gè)時(shí)間開(kāi)銷就更大了。
內(nèi)存讀取包括主內(nèi)存讀取、NUMA 去讀取另外一個(gè)節(jié)點(diǎn)的數(shù)據(jù),性能開(kāi)銷都是很大的。Mutex 在低延遲代碼里也基本不會(huì)用。至于函數(shù)調(diào)用,不可能一個(gè)都不用,但可以用 inline 來(lái)減少函數(shù)調(diào)用。除了普通的函數(shù)調(diào)用,還有多態(tài)調(diào)用,即vptr、vtable。Div操作是 CPU 不喜歡的。
CPU都是流水線執(zhí)行的,"wrong" branch of "if"和“right" branch of "if",就是 CPU 執(zhí)行到一個(gè) if-else 時(shí)會(huì)自己去猜,如果猜對(duì)了,就幾乎可以忽略,如果猜錯(cuò)了,代價(jià)就比較慘。
2.低延遲系統(tǒng)調(diào)整
硬件&系統(tǒng)
首先既對(duì)處理器的核數(shù)有要求,同時(shí)也對(duì)單核的頻率有要求,但這兩點(diǎn)是矛盾的。想要一個(gè)核數(shù)又多、頻率又高的,就要用到超頻服務(wù)器,執(zhí)行效率越高越好,不需要虛擬化功能。內(nèi)存也要充足。

超線程一般是關(guān)閉的,同一個(gè)程序在開(kāi)超線程和不開(kāi)超線程的機(jī)器跑的話,肯定是不開(kāi)超線程的更快。另外,如果進(jìn)行內(nèi)核綁定,Critical thread 會(huì)獨(dú)占一個(gè)核,如果綁定一個(gè)開(kāi)了超線程的核就相當(dāng)于綁定了同一個(gè)核,或者是一個(gè)核不用扔在那兒,這是沒(méi)有意義的。
操作系統(tǒng)是64位的 Linux,一般是?CentOS 或是?RHEL,最小化安裝,toolchain 升級(jí),因?yàn)槟J(rèn)自帶的可能是比較老的 GCC,我一般都習(xí)慣升級(jí)到9或10。
最小化安裝還有一個(gè)比較有意思的點(diǎn),因?yàn)槲覀€(gè)人是堅(jiān)定的?Emacs黨,不喜歡 vim,但 Emacs 會(huì)默認(rèn)安裝一些圖形化插件,所以要在你的生產(chǎn)機(jī)器上裝 Emacs 的話就要裝 Emacs-nw 版本。Rtkernel 看起來(lái)好像和低延遲實(shí)施有關(guān)系,但實(shí)際上它是保證一種硬搶占的內(nèi)核 patch,這個(gè)對(duì)我們來(lái)說(shuō)是完全不需要的。RHEL 一般都可以照著 Tuning guide 去對(duì)系統(tǒng)進(jìn)行調(diào)整,如果是買服務(wù)器或超頻服務(wù)器,vendor 也會(huì)有 guide,可以斟酌一下要不要打開(kāi)。
CPU相關(guān)優(yōu)化

CPU 優(yōu)化最核心的就是要讓 Critical 線程獨(dú)占 CPU,不能被打斷,要求極致的低延遲,而普通線程就無(wú)所謂。我們要做的就是先把一定數(shù)量的核 isolate,這樣操作系統(tǒng)就不會(huì)把任何的用戶態(tài)線程再調(diào)度到這個(gè)核上,然后再做 thread bonding,把 Critical thread綁定到這些 isolate 的核里去,這樣就保證了 thread 可以獨(dú)占這個(gè)核。也可以設(shè)置?scheduler,對(duì)于 Critical thread 我們一般都是設(shè)置 FIFO 這種實(shí)時(shí)的優(yōu)先級(jí)調(diào)度策略,對(duì)于普通的線程用 default(CFS)?就可以。
中斷

當(dāng)遇到 kernel 中斷、時(shí)鐘中斷或 workqueue 等情況時(shí)還是可能會(huì)侵占 CPU 時(shí)間,可以把中斷的 balance 關(guān)掉,設(shè)置中斷 affinity 到非 isolate 核心,這樣可以讓中斷對(duì)你的影響盡可能地小。這里要提一下,時(shí)鐘中斷是不可能完全關(guān)閉的,除非改內(nèi)核。

內(nèi)存優(yōu)化也要避免進(jìn)入內(nèi)核態(tài),一方面是分配的時(shí)候可能進(jìn)入, 另一方面是觸發(fā) fault 的時(shí)候。

fault,對(duì)于Linux操作系統(tǒng)來(lái)說(shuō),在內(nèi)核層面上是不區(qū)分線程和進(jìn)程的,都是用 task_struct 來(lái)表示線程。進(jìn)程和線程唯一的區(qū)別就是進(jìn)程的 tid 和 pid 是相等的,因?yàn)橐粋€(gè)進(jìn)程的內(nèi)存是共享的,所以每一個(gè) task_struct 里其實(shí)都有一個(gè) mm_struct 指向同一個(gè)內(nèi)存 object,這個(gè)內(nèi)存的 object 分各個(gè) area,每個(gè) area 都標(biāo)識(shí)了這塊內(nèi)存的虛擬地址是否合法。
我們平時(shí)寫代碼的時(shí)候,不考慮 Glibc 有緩存內(nèi)存,假如malloc 或 new 一塊內(nèi)存的請(qǐng)求到了操作系統(tǒng),那么操作系統(tǒng)做的一件事就是在剛才所說(shuō)的 mm_struct 里的?vm_area 里劃分并標(biāo)識(shí)一塊合法的區(qū)域,這些操作都是在虛擬地址層面上,并沒(méi)有真實(shí)的物理地址層面,然后做完這個(gè)操作以后它就返回了。但實(shí)際上虛擬地址和物理地址之間需要有一個(gè)映射,即虛擬頁(yè)面。假如說(shuō)是一個(gè)4K頁(yè),和一個(gè)物理4K頁(yè)之間的映射關(guān)系沒(méi)有建立,那什么時(shí)候建立呢?當(dāng)CPU訪問(wèn)這塊內(nèi)存的時(shí)候就會(huì)觸發(fā)一個(gè) fault,因?yàn)?CPU 在 MMU 單元通過(guò)虛擬地址去找這塊物理地址找不到,這個(gè) fault 交到操作系統(tǒng),操作系統(tǒng)再進(jìn)行處理,這相當(dāng)于是一種操作系統(tǒng) lazy 處理的模式。但這些過(guò)程都是需要內(nèi)核深度參與的,一旦出現(xiàn)要在內(nèi)核態(tài)做這么多事情的情況,和低延遲就差得很遠(yuǎn)了。
major fault 是指當(dāng)內(nèi)存不夠時(shí),內(nèi)存可能被交換到磁盤,再用到這塊內(nèi)存時(shí)再?gòu)拇疟P交換回去 。major fault 比較好解決,一種是禁用 swap 分區(qū) ,而且內(nèi)存比較充足的話一般也不會(huì)觸發(fā),我們?cè)谙到y(tǒng)里還有一個(gè) mlock 調(diào)用,mlock 調(diào)用以后就可以阻止你這個(gè)進(jìn)程的內(nèi)存被 swap 到硬盤上。
minor fault 就比較難搞了,這個(gè)過(guò)程中可能有多個(gè)手段,但也不保證能百分百把它消除掉。一種是用?huge page。因?yàn)?fault 是以頁(yè)面為單位的,huge page 可以把一個(gè)頁(yè)從4K變成2M,這樣的好處是頁(yè)面 fault 的幾率就明顯會(huì)小很多。另外,虛擬地址頁(yè)面去找物理地址頁(yè)面需要 CPU 的 MMU 單元去找,它會(huì)優(yōu)先去找TLB。TLB相當(dāng)于映射的緩存,你可以認(rèn)為它是一個(gè)哈希,如果找不到就會(huì)到頁(yè)表里面一級(jí)一級(jí)去找,可能是兩級(jí),可能是三級(jí),TLB 可以大大的提升這個(gè)這個(gè)尋找的時(shí)間。用了 huge page 以后,頁(yè)表總體更少了,TLB miss 幾率也就更低了。

對(duì)于 NUMA 來(lái)說(shuō),盡可能要它訪問(wèn)自己的線程,不要跨 slot 訪問(wèn)。NUMA 有多個(gè)內(nèi)存的分配策略,一般默認(rèn)的就是?localalloc,讓這個(gè)槽的線程分配的內(nèi)存在 local 分配,不要到 remote 分配。還有一種是 Interleave,即平均在幾個(gè) slot 里面分配,這種是我們不想要的。
prefault 是很大的一個(gè)話題,就是可以分配內(nèi)存,但是分配了之后要想辦法在真正使用之前先觸發(fā)它的 minor fault。這有多個(gè)層面去解決,一是可以 hack 內(nèi)存管理器,可以自己寫,也可以優(yōu)化 ptmalloc,當(dāng)然如果有第三方的內(nèi)存管理器可能會(huì)更好地解決這個(gè)問(wèn)題。
網(wǎng)絡(luò)

現(xiàn)在?TCP 延遲較高基本是業(yè)界共識(shí),大家都在想怎么去解決這個(gè)問(wèn)題,現(xiàn)在有趨勢(shì)就是交易這方面也往 UDP 轉(zhuǎn),尤其是行情部分會(huì)越來(lái)越多地轉(zhuǎn)到 UDP。無(wú)論怎么優(yōu)化,你的緩沖區(qū)也好,中斷也好,還是會(huì)有硬件的中斷觸發(fā),陷入內(nèi)核態(tài),只要你的協(xié)議棧在內(nèi)核態(tài),性能就不會(huì)很好,所以這種情況下就要用用戶態(tài)的協(xié)議棧。還有一些?FPGA 解決方案的,一般是券商或期貨公司在用。
3.低延遲系統(tǒng)編譯選項(xiàng)

我們用的更多的還是 GCC,GCC 現(xiàn)在討論最多的就是-O2 和-O3。這個(gè)在選擇上沒(méi)有標(biāo)準(zhǔn)答案,我們就來(lái)看看 -O3 比 -O2多了什么吧。
首先,-finline-functions 除了代碼里寫了 inline,或者用 GCC 的擴(kuò)展 always_inline,GCC-O2還會(huì)默認(rèn)開(kāi)一個(gè) inline call_once?function,還有一個(gè) inline_function 我個(gè)人覺(jué)得是很有用的。GCC 10 開(kāi)始就已經(jīng) include -O2了,也會(huì)針對(duì)它不同的優(yōu)化,不斷地把 -O2 move到 -O3。但-O3?不一定整個(gè)項(xiàng)目都能用,可以只針對(duì)某個(gè) function 或某個(gè) file 來(lái)打開(kāi)。
-floop-unroll-and-jam 是指如果有多層循環(huán)的話會(huì)把外層循環(huán)展開(kāi)。
-fipa-cp-clone 是指如果有多個(gè)參數(shù),其中一個(gè)傳了常數(shù)的話,它有可能把這個(gè) function clone兩份,其中一份會(huì)去做一些常量展開(kāi)、常量傳播,這個(gè)有時(shí)能用得上。也許你會(huì)說(shuō)“我代碼寫得比較好,我用?(const expression)?之類也能達(dá)到相似效果”,但是你不能保證所有人寫代碼或第三方庫(kù)都能做到這一點(diǎn)。

這張圖中上面兩段代碼都不是 cache friendly 的代碼,都是比較低效的內(nèi)存訪問(wèn)模式,但如果開(kāi)了?-floop-interchange ,編譯器就幫我們優(yōu)化到我們想要的樣子,cache friendly就沒(méi)有問(wèn)題了。

可能有的編碼規(guī)范上說(shuō)不要在 foo 里面加 branch,但這段代碼中看起來(lái)每個(gè) foo 里都加了 branch,其實(shí)如果開(kāi)了編譯選項(xiàng)以后,GCC 會(huì)自動(dòng)把 if 放到 foo 外面,如果這個(gè) foo 里面有一條賦值語(yǔ)句且和 foo 無(wú)關(guān)的話,也會(huì)被移到外面。

其他情況比如 a[i] = b[i],但對(duì) b[i] + 1 有一些依賴,那對(duì)流水線是不友好的,這種情況也有可能拆開(kāi)。
當(dāng)然還有?loop fusion 這種相反的情況,本來(lái)寫的兩個(gè)循環(huán),它發(fā)現(xiàn)合并了以后更有利于 cache friendly,可能就會(huì)做合并,但在 GCC 里沒(méi)有做合并這個(gè)選項(xiàng),我們自己寫代碼的時(shí)候需要注意一下。

loop-vectorize 是我認(rèn)為最關(guān)鍵的一個(gè),這里源自 Stack Overflow 的一個(gè)問(wèn)題:在執(zhí)行過(guò)程中有沒(méi)有 sort,性能差異是巨大的,為什么?
有了 sort 以后,CPU Branch Prediction 更好了,成功率很高,性能就很高。GCC-O3 比 -O2 更慢,核心原因是最初 cmov 指令在老的處理器架構(gòu)上比較慢,而現(xiàn)代新的編譯器都用 cmov 做優(yōu)化,不再用條件 jump 語(yǔ)句了,執(zhí)行效率非常高。有了cmov 以后就沒(méi)有分支了,也就不存在 sort 和 unsort 的區(qū)別了,也不存在 -O3 比 -O2 更慢的情況。
如果我們把?sum += data[c] 改成 sum += data[c] + data[c],那么無(wú)論 GCC 還是 Clang 都不會(huì)再用 cmov,而是用傳統(tǒng)條件跳轉(zhuǎn)的方式,這種情況下性能就又有差別了,loop-vectorize 就可以起作用了。
如果啟用了 loop vectorize,它就會(huì)用 SSE指令集 去優(yōu)化這個(gè)循環(huán)的過(guò)程,也就是說(shuō),這個(gè)性能和 cmov 版本相比不會(huì)差,甚至是更好,所以說(shuō) loop vectorize 很多時(shí)候是非常有用的。如果是針對(duì) -march=native,讓編譯器針對(duì)當(dāng)前的處理器架構(gòu)做一些優(yōu)化的話,如果你的處理器支持 AVX 2 或是更高的 AVX-512 指令集,那它可以給你做更進(jìn)一步的優(yōu)化,性能提升得更大。

O3 與 Ofast 的主要區(qū)別在于?-ffast-math 是針對(duì)浮點(diǎn)數(shù)進(jìn)行運(yùn)算的。
Profile-Guided Optimisations 和 Profile 有點(diǎn)像,對(duì)于金融來(lái)說(shuō)是測(cè)不準(zhǔn)的。
-funroll-loops 在 Clang-O2 就有這個(gè)優(yōu)化,但基于 GCC 只有開(kāi)了 Profile Guided 優(yōu)化才會(huì)把循環(huán)展開(kāi),這種情況下如果希望強(qiáng)制展開(kāi),可以用 #pragma。
-march 要么=native,要么等于目標(biāo)架構(gòu)。
-flto 一般也是打開(kāi)的,可以減少 binary size,跨文件單元進(jìn)行優(yōu)化。
irace 是一個(gè)開(kāi)源工具,是把各個(gè)編譯選項(xiàng)排列組合,你提供一個(gè)測(cè)試程序,看一看哪個(gè)性能最高。

loop-vectorize 對(duì)于 int 是能提升性的,但如果把 type 改成 double,由于 floating 運(yùn)算不支持結(jié)合律,loop vectorize 就做不了。那要如何進(jìn)行優(yōu)化呢?
如果你對(duì)浮點(diǎn)數(shù)的要求的精度運(yùn)算沒(méi)有那么高,可以打開(kāi)?-ffast-math 下 -funsafe-math-optimizations 三個(gè)選項(xiàng):
-fassociative-math
-fno-signed-zeros
-fno-trapping-math
但要注意打開(kāi) -ffast-math 有可能產(chǎn)生一些精度問(wèn)題,一定要對(duì)你的程序進(jìn)行一些精確的測(cè)量,否則會(huì)出現(xiàn)一些莫名其妙的運(yùn)算錯(cuò)誤,對(duì)交易來(lái)說(shuō),這個(gè)運(yùn)算錯(cuò)誤肯定是非常致命的。
總結(jié)來(lái)說(shuō),開(kāi)哪個(gè)編譯并沒(méi)有標(biāo)準(zhǔn)答案,我個(gè)人會(huì)開(kāi) Ofast 也會(huì)開(kāi)?-march=native,需要結(jié)合你的具體項(xiàng)目需要。
4.低延遲軟件設(shè)計(jì)與編碼

由于我們相當(dāng)于是一個(gè)client,不會(huì)用多進(jìn)程,線程也都是提前創(chuàng)建的,因?yàn)閯?chuàng)建線程肯定是要進(jìn)內(nèi)核態(tài),而且內(nèi)核態(tài)開(kāi)銷比較大,線程池也不太用,靜態(tài)鏈接會(huì)比動(dòng)態(tài)鏈接有百分之幾的性能提升,建議用靜態(tài)鏈接。數(shù)據(jù)拷貝和數(shù)據(jù)共享也都盡可能避免。?

因?yàn)槲覀冇袛?shù)很多數(shù)據(jù),即使是 critical 線程,critical path,系統(tǒng)剛啟動(dòng)時(shí)你總有一定的時(shí)間可以進(jìn)行一些比較耗時(shí)的操作,你可以把這些計(jì)算量比較大的東西先算好,然后每來(lái)一些新的數(shù)據(jù)就可以用一些增量的方法來(lái)更新。最簡(jiǎn)單的,比如算一些均線、布林線、MACD指標(biāo)等都可以考慮怎樣增量計(jì)算,畢竟預(yù)算量越小、指令越少,性能就越高,讓代碼盡可能減少間接層,慎用第三方庫(kù)。

運(yùn)行時(shí)多態(tài),通過(guò) vptr 去找 vtable 會(huì)有一定的性能開(kāi)銷,另外如果是虛函數(shù)調(diào)用就沒(méi)法 inline,這個(gè)帶來(lái)的性能損失可能更大。這里有一些模板去解決這個(gè)問(wèn)題,比如 CRTP、Policy based class design 之類。如果集成 path 上虛函數(shù)和具體函數(shù)只有一個(gè)實(shí)現(xiàn)的話,這個(gè)編譯選項(xiàng)有可能會(huì)把虛函數(shù)間接的調(diào)用優(yōu)化掉,還會(huì)有一個(gè) vtable 的比較,我覺(jué)得這個(gè)可有可無(wú),性能開(kāi)銷也不會(huì)很大,僅有一個(gè)實(shí)現(xiàn)的話還是可以考慮的。

這個(gè)案例中上面是基類,下面是派生類。基類里有一些 virtual function,這里用了一個(gè) Strategy 設(shè)計(jì)模式,可能也是一個(gè)抽象類,運(yùn)行時(shí)指向幾個(gè)具體的類,通常寫代碼時(shí)這樣寫肯定是沒(méi)有問(wèn)題的,但如果這里面的觸發(fā)至少有兩次的虛函數(shù)都用開(kāi)銷,并且也不能 inline ,我們一般都會(huì)用 CRTP 這種模板的方式去解決。首先,基類 OnTick 會(huì)調(diào)用派生類的 OnTickImpl,這個(gè) OMType* _om 也不再作為 Strategy 設(shè)計(jì)模式,直接繼承下來(lái)并作為一個(gè)模板參數(shù),在編譯時(shí)就把它決議掉,然后在具體的類再去實(shí)現(xiàn) OnTickImpl,這樣就沒(méi)有虛函數(shù)的開(kāi)銷,也可以 inline。

我們做這個(gè)模板的時(shí)候有很多類型信息可能拿不到,所以就用一些 traits 的方式,比如各個(gè)接口之間用這個(gè) concept 定一個(gè)traits,每個(gè)接口都把這個(gè) traits 實(shí)現(xiàn)好,基類就可以去根據(jù)這個(gè) traits 去取派生類的一些信息。這里 if constexpr 也相當(dāng)于一個(gè)編譯態(tài)的 if,和 enable_if 很像,通過(guò)這個(gè)也可以針對(duì)類型做一些分支處理,這些在運(yùn)行時(shí)都是沒(méi)有任何開(kāi)銷的。enable_if 這有一個(gè)參數(shù),來(lái)判斷它是不是 bool Critical 線程,如果是,就直接用 write(msg) , 因?yàn)橛辛硗庖粋€(gè)線程會(huì) busy loop 去做這件事情,可能就需要 _cv.notify。

rdtsc 是最方便的取時(shí)間的方式,而 clock 是真正的系統(tǒng)調(diào)用,它就不在 vdso 里,性能開(kāi)銷非常大。
打日志也是要非常謹(jǐn)慎,我們很多地方注意了低延遲,但其實(shí)打日志的延遲是非常大的,它開(kāi)銷主要是兩部分:一部分是 format 開(kāi)銷,一部分是獲取時(shí)間的開(kāi)銷 。format 開(kāi)銷有一種方式是編譯的時(shí)候生成一些信息,運(yùn)行時(shí)把這些 format 延遲推后,然后通過(guò)一些離線的工具,在生成 log 的時(shí)候再去做這個(gè) format,比如說(shuō)一些低開(kāi)銷的日志庫(kù)就能做這些事情。

動(dòng)態(tài)內(nèi)存分配有可能觸發(fā)系統(tǒng)調(diào)用,并且會(huì)引發(fā)配置 fault,所以要盡量避免。避免的方式有很多,你可以用 placement new、memory pool。但 STL 及第三方庫(kù)帶來(lái)的內(nèi)存分配很難避免,要避免的話,一是要提前分配內(nèi)存,用 ring buffer 之類,map/set?數(shù)量少的話可以用 sorted array 替代。pool allocator 也可以實(shí)現(xiàn)內(nèi)存池。

減少內(nèi)存分配就是讓 Glibc 的內(nèi)存分配了以后盡可能不回收,一旦回收給操作系統(tǒng),下次再申請(qǐng)就比較麻煩。
這里有一個(gè)例子。

vector 和 string 一樣是可以提前分配內(nèi)存的,你可以先 alloc 一塊memory,目的就是 presort,每隔4096都寫一下,sort 完之后 clear,如果系統(tǒng)調(diào)整好了,即使是默認(rèn)的,Glibc 也不會(huì)回收這塊內(nèi)存。然后下次你再一個(gè)個(gè) push_back,也不會(huì)觸發(fā) page fault。你可以通過(guò)工具,包括這個(gè) perf 來(lái)看?minor-faults?有多少來(lái)驗(yàn)證。這僅限于 Glibc,對(duì)于谷歌 tcmalloc 和 Facebook jemalloc,因?yàn)槭腔ヂ?lián)網(wǎng)環(huán)境,它們對(duì)內(nèi)存回收都比較積極,所以這個(gè)方法對(duì)于它們都不適用。

string 的開(kāi)銷其實(shí)也是比較大的,但好處是它引起的開(kāi)銷就是堆上開(kāi)銷。它在堆上分配內(nèi)存,在堆上分配一個(gè)差的數(shù)組,如果 std::string 比較小,就會(huì)在棧上分配內(nèi)存,那速度就是比較快的。

hashmap 也很關(guān)鍵,因?yàn)槔锩娴臄?shù)據(jù)也會(huì)觸發(fā)堆內(nèi)存分配,這個(gè)也是不可以接受的。因此不能用那種鏈?zhǔn)降?,都是用線性搜索。如果一塊連續(xù)的地址填滿了一個(gè) hashmap,就會(huì)到緊鄰的位置去找。

消息傳遞會(huì)用一些 lockfree queue 無(wú)鎖編程,其實(shí)它內(nèi)部也是有 lock 指令的,也是有開(kāi)銷的,所以如非必要也不建議用。如果極端情況下確實(shí)需要lock的話,用spinlock,不要用mutex/semaphore,因?yàn)閟pinlock是一種懵懂的狀態(tài),它不會(huì)進(jìn)內(nèi)核態(tài)等你的內(nèi)核喚醒,所以它的開(kāi)銷還是比較小的。對(duì)于 mutex,現(xiàn)在是用 futex 優(yōu)化,如果沒(méi)有發(fā)生鎖沖突,它也不會(huì)進(jìn)內(nèi)核態(tài),但即使這樣,mutex 的實(shí)現(xiàn)也比較復(fù)雜,開(kāi)銷也是比較大的。

這張圖中我們可以看到 spinlock 開(kāi)銷還是比較低的,atomic 的操作也不高,當(dāng)然原生的肯定是最高的。對(duì)于 mutax,我這里測(cè)的都是在沒(méi)有資源競(jìng)爭(zhēng)的情況下,這個(gè)數(shù)據(jù)已經(jīng)非常不好了。

寫代碼的時(shí)候,為了節(jié)省時(shí)間資源,往往都傾向于鼓勵(lì)提前退出,都會(huì)把比較高的 if 放到前面。但對(duì)于我們的情況可能正好相反。

在低延遲場(chǎng)景下,大多數(shù)時(shí)候我們最終的信號(hào)是不會(huì)發(fā)出執(zhí)行的,這種情況下如果讓它過(guò)早退出,那這段很大的代碼,包括數(shù)據(jù),中間的 cache 都是冷的,那下次真正執(zhí)行的效率就會(huì)比較低,這種是我們不想要的。
我們會(huì)在不crash的情況下,盡可能多執(zhí)行代碼,讓它把一個(gè)完整的流程走完,不在意 CPU 時(shí)間浪費(fèi)。在這里我們不是用或操作符,而是用按位操作符。用這種操作來(lái)?yè)Q取 branch 只會(huì)有一個(gè)分支,就不會(huì)有三個(gè)分支了。

分支預(yù)測(cè)基本上是?[[likely]], [[unlikely]],相當(dāng)于是給編譯器的一個(gè)參考。但實(shí)際上它只是決定靜態(tài)的分支預(yù)測(cè),到實(shí)際 CPU 運(yùn)行的時(shí)候會(huì)按照實(shí)際的 branch 是否 take 來(lái)決定下一次怎么預(yù)測(cè)。
這和剛才那個(gè)例子一樣,可能99.9%的情況下我們這個(gè)交易信號(hào)是觸發(fā)不了的,那我永遠(yuǎn)走的是不觸發(fā)的那個(gè) branch,這樣CPU 也記住了,每次都會(huì)走到不觸發(fā)的 branch。當(dāng)我真正想要觸發(fā)時(shí)是最需要低延遲的時(shí)候,這個(gè) branch 就給我預(yù)測(cè)錯(cuò),并且所有 cache 都是冷的。這種情況有一些技巧,比如,可以用一些假的程序盡可能地往下執(zhí)行,到最后一步停。還有最簡(jiǎn)單的辦法就是我這個(gè)訂單真的發(fā)到柜臺(tái),只不過(guò)我把精度擴(kuò)大一位,比如說(shuō)有效價(jià)格是兩位,我給它設(shè)三位,那這個(gè)單子發(fā)出去了也會(huì)被柜臺(tái)拒絕,但我所有的 branch 都走到了。但這樣做可能券商或期貨公司不喜歡,因?yàn)闀?huì)有大量的廢單進(jìn)來(lái)。

異常如果不觸發(fā),對(duì)性能基本上沒(méi)有什么影響,大家就沒(méi)有什么心理負(fù)擔(dān)。寫代碼的時(shí)候,作用域盡可能小,盡可能用 const ,連接性也盡可能低。這樣目的就是讓編譯器知道更多,編譯器知道更多的信息,就可能幫你做更多的優(yōu)化。

智能指針?unique_ptr 的開(kāi)銷基本可以忽略,開(kāi)銷本身是動(dòng)態(tài)分配內(nèi)存的開(kāi)銷,shared_ptr 里面有兩個(gè) atomic 變量,當(dāng)然它底層不是用 atomic,而是用的更原始的操作去做的,但這個(gè)也會(huì)有性能開(kāi)銷,傳參的時(shí)候也不要覺(jué)得它是智能指針可以自動(dòng)加一減一就直接傳了,還是按照引用的方式傳比較好。
最后,C++ 20的一些新特性對(duì)低延遲有一些幫助。atomic shared_ptr目前內(nèi)部還是用鎖實(shí)現(xiàn)的,也是暫時(shí)不能用,希望以后可能有更優(yōu)化的實(shí)現(xiàn)。
關(guān)于我們
CPP與系統(tǒng)軟件聯(lián)盟是Boolan旗下連接30萬(wàn)+中高端C++與系統(tǒng)軟件研發(fā)骨干、技術(shù)經(jīng)理、總監(jiān)、CTO和國(guó)內(nèi)外技術(shù)專家的高端技術(shù)交流平臺(tái)。

2022年3月11-12日,「全球C++及系統(tǒng)軟件技術(shù)大會(huì)」將于上海隆重召開(kāi),“C++之父”Bjarne Stroustrup將發(fā)布主題演講。來(lái)自海內(nèi)外近40位專家將圍繞包括:現(xiàn)代C++、系統(tǒng)級(jí)軟件、架構(gòu)與設(shè)計(jì)演化、高性能與低時(shí)延、質(zhì)量與效能、工程與工具鏈、嵌入式開(kāi)發(fā)、分布式與網(wǎng)絡(luò)應(yīng)用 共8大主題,深度探討最佳工程實(shí)踐和前沿方法。大會(huì)官網(wǎng):www.cpp-summit.org