百度 C++ 工程师的那些极限优化(内存篇)
c/c++ linux服務(wù)器開發(fā)相關(guān)視頻解析:
c/c++程序員必知的內(nèi)存泄漏解決方案與原理實現(xiàn)
90分鐘了解Linux內(nèi)存架構(gòu),numa的優(yōu)勢,slab的實現(xiàn),vmalloc的原理
c/c++ linux服務(wù)器開發(fā)免費(fèi)學(xué)習(xí)地址:c/c++ linux后臺服務(wù)器高級架構(gòu)師
1 背景
在百度看似簡簡單單的界面后面,是遍布全國的各個數(shù)據(jù)中心里,運(yùn)轉(zhuǎn)著的海量 C++服務(wù)。對 C++的重度應(yīng)用是百度的一把雙刃劍,學(xué)習(xí)成本陡峭,指針類錯誤定位難、擴(kuò)散性廣另很多開發(fā)者望而卻步。然而在另一方面,語言層引入的額外開銷低,對底層能力可操作性強(qiáng),又能夠為追求極致性能提供優(yōu)異的實踐環(huán)境。
因此,對百度的 C++工程師來說,掌握底層特性并加以利用來指導(dǎo)應(yīng)用的性能優(yōu)化,就成了一門必要而且必須的技能。久而久之,百度工程師就將這種追求極致的性能優(yōu)化,逐漸沉淀成了習(xí)慣,甚至形成了對技術(shù)的信仰。下面我們就來盤點和分享一些,在性能優(yōu)化的征途上,百度 C++工程師積累下來的理論和實踐,以及那些為了追求極致,所發(fā)掘的『奇技淫巧』。
2 重新認(rèn)識性能優(yōu)化
作為程序員,大家或多或少都會和性能打交道,尤其是以 C++為主的后端服務(wù)工程師,但是每個工程師對性能優(yōu)化概念的理解在細(xì)節(jié)上又是千差萬別的。下面先從幾個優(yōu)化案例入手,建立一個性能優(yōu)化相關(guān)的感性認(rèn)識,之后再從原理角度,描述一下本文所講的性能優(yōu)化的切入角度和方法依據(jù)。
2.1 從字符串處理開始
2.1.1 string as a buffer
為了調(diào)用底層接口和集成一些第三方庫能力,在調(diào)用界面層,會存在對 C++字符串和 C 風(fēng)格字符串的交互場景,典型是這樣的:
size_t some_c_style_api(char* buffer, size_t size); void some_cxx_style_function(std::string& result) {// 首先擴(kuò)展到充足大小result.resize(estimate_size);// 從c++17開始,string類型支持通過data取得非常量指針auto acture_size = some_c_style_api(result.data(), result.size());// 最終調(diào)整到實際大小result.resize(acture_size); }這個方法存在一個問題,就是在首次 resize 時,string 對 estimate_size 內(nèi)的存儲區(qū)域全部進(jìn)行了 0 初始化。但是這個場景中,實際的有效數(shù)據(jù)其實是在 some_c_style_api 內(nèi)部被寫入的,所以 resize 時的初始化動作其實是冗余的。在交互 buffer 的 size 較大的場景,例如典型的編碼轉(zhuǎn)換和壓縮等操作,這次冗余的初始化引入的開銷還是相當(dāng)可觀的。
可以這樣編碼:
2.1.2 split string
實際業(yè)務(wù)中,有一個典型場景是一些輕 schema 數(shù)據(jù)的解析,比如一些標(biāo)準(zhǔn)分隔符,典型是’_‘或者’\t’,簡單分割的分列數(shù)據(jù)(這在日志等信息的粗加工處理中格外常見)。由于場景極其單純,可能的算法層面優(yōu)化空間一般認(rèn)為較小,而實際實現(xiàn)中,這樣的代碼是廣為存在的:
std::vector<std::string> tokens; // boost::split boost::split(token, str, [] (char c) {return c == '\t';}); // absl::StrSplit for (std::string_view sv : absl::StrSplit(str, '\t')) {tokens.emplace_back(sv); } // absl::StrSplit no copy for (std::string_view sv : absl::StrSplit(str, '\t')) {direct_work_on_segment(sv); }boost 版本廣泛出現(xiàn)在新工程師的代碼中,接口靈活,流傳度高,但是實際業(yè)務(wù)中效率其實并不優(yōu)秀,例如和 google 優(yōu)化過的 absl 相比,其實有倍數(shù)級的差距。尤其如果工程師沒有注意進(jìn)行單字符優(yōu)化的時候(直接使用了官方例子中的 is_any_of),甚至達(dá)到了數(shù)量級的差距。進(jìn)一步地,如果聯(lián)動思考業(yè)務(wù)形態(tài),一般典型的分割后處理是可以做到零拷貝的,這也可以進(jìn)一步降低冗余拷貝和大量臨時對象的創(chuàng)建開銷。
最后,再考慮到百度當(dāng)前的內(nèi)部硬件環(huán)境有多代不同型號的 CPU,進(jìn)一步改造 spilt 顯式使用 SIMD 優(yōu)化,并自適應(yīng)多代向量指令集,可以取得進(jìn)一步的性能提升。尤其是 bmi 指令加速后,對于一個 SIMD 步長內(nèi)的連續(xù)分隔符探測,比如密集短串場景,甚至可以取得數(shù)量級的性能提升。
最終在百度,我們可以這樣編碼實現(xiàn):
2.1.3 magic of protobuf
隨著 brpc 在百度內(nèi)部的廣泛應(yīng)用,protobuf 成為了百度內(nèi)部數(shù)據(jù)交換的主流方式,解析、改寫、組裝 protobuf 的代碼在每個服務(wù)中幾乎都會有一定的占比。尤其是近幾年,進(jìn)一步疊加了微服務(wù)化的發(fā)展趨勢之后,這層數(shù)據(jù)交換邊界就變得更加顯著起來。
在有些場景下,例如傳遞并增加一個字段,或者從多個后端存儲獲取分列表達(dá)的數(shù)據(jù)合并后返回,利用標(biāo)準(zhǔn)的 C++API 進(jìn)行反序列化、修改、再序列化的成本,相對于實際要執(zhí)行的業(yè)務(wù)來說,額外帶來的性能開銷會顯著體現(xiàn)出來。
舉例來說,比如我們定義了這樣的 message:
我們設(shè)想一個場景,一個邏輯的 record 分散于多個子系統(tǒng),那么我們需要引入一個 proxy 層,完成多個 partial record 的 merge 操作,常規(guī)意義上,這個 merge 動作一般是這樣的:
one_sub_service.query(&one_controller, &request, &one_sub_response, nullptr); another_sub_service.query(&another_controller, &request, &another_sub_response, nullptr); ... for (size_t i = 0; i < record_size; ++i) {final_response.mutable_record(i).MergeFrom(one_sub_response.record(i));final_response.mutable_record(i).MergeFrom(another_sub_response.record(i));... }對于一個輕量級 proxy 來說,這一層反復(fù)對后端的解析、合并、再序列化引入的成本,就會相對凸現(xiàn)出來了。進(jìn)一步的消除,就要先從 protobuf 的本質(zhì)入手。
protobuf 的根基先是一套公開標(biāo)準(zhǔn)的 wire format,其上才是支持多語言構(gòu)造和解析的 SDK,因此嘗試降低對解析和合并等操作的進(jìn)一步優(yōu)化,繞過 c++api,深入 wire format 層來嘗試是一種可行且有效的手段。那么我們先來看一下一些 wire format 層的特性。
即 message 的構(gòu)成直接由內(nèi)部包含的 field 的序列化結(jié)果堆砌而成,field 之間不存在分割點,在整個 message 外部,也不存在框架信息。基于這個特性,一些合并和修改操作可以在序列化的 bytes 結(jié)果上被低成本且安全地操作。而另一方面,message field 的格式和 string 又是完全一致的,也就是定義一個 message field,或者定義一個 string field 而把對應(yīng) message 序列化后存入,結(jié)果是等價的(而且這兩個特性是被官方承諾的)。
結(jié)合這些特性,之前的合并操作在實現(xiàn)上我們改造為:
在微服務(wù)搭的環(huán)境下,類似的操作可以很好地控制額外成本的增加。
【文章福利】需要C/C++ Linux服務(wù)器架構(gòu)師學(xué)習(xí)資料加群812855908(資料包括C/C++,Linux,golang技術(shù),Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協(xié)程,DPDK,ffmpeg等)
2.2 回頭來再看看性能優(yōu)化
一般來講,一個程序的性能構(gòu)成要件大概有三個,即算法復(fù)雜度、IO 開銷和并發(fā)能力。
首要的影響因素是大家都熟悉的算法復(fù)雜度。一次核心算法優(yōu)化和調(diào)整,可以對應(yīng)用性能產(chǎn)生的影響甚至是代差級別的。例如 LSM Tree 對 No-SQL 吞吐的提升,又例如事件觸發(fā)對 epoll 大并發(fā)能力的提升。然而正因為關(guān)注度高,在實際工程實現(xiàn)層面,無論是犯錯幾率,還是留下的優(yōu)化空間,反而會大為下降。甚至極端情況下,可能作為非科研主導(dǎo)的工程師,在進(jìn)行性能優(yōu)化時鮮少遇到改良算法的場景,分析問題選擇合適算法會有一定占比,但是可能范圍也有限。
更多情況下需要工程師解決的性能問題,借用一句算法競賽用語,用『卡常數(shù)』來形容可能更貼切。也就是采用了正確的合適的算法,但是因為沒有采用體系結(jié)構(gòu)下更優(yōu)的實現(xiàn)方案,導(dǎo)致在 O(X)上附加了過大的常數(shù)項,進(jìn)而造成的整體性能不足。雖然在算法競賽中,卡常數(shù)和常數(shù)優(yōu)化是出題人和解題人都不愿意大量出現(xiàn)的干擾項(因為畢竟是以核心算法本身為目標(biāo)),但是轉(zhuǎn)換到實際項目背景下,常數(shù)優(yōu)化卻往往是性能優(yōu)化領(lǐng)域的重要手段。
現(xiàn)在我們再來回顧一下上面引出問題的三個優(yōu)化案例??梢钥吹?#xff0c;其中都不包含算法邏輯本身的改進(jìn),但是通過深入利用底層(比如依賴庫或指令集)特性,依然能夠取得倍數(shù)甚至數(shù)量級的優(yōu)化效果。這和近些年體系結(jié)構(gòu)變得越發(fā)復(fù)雜有很大關(guān)聯(lián),而這些變化,典型的體現(xiàn)場景就是 IO 和并發(fā)。并發(fā)的優(yōu)化,對于工程經(jīng)驗比較豐富的同學(xué)應(yīng)該已經(jīng)不陌生了,但是關(guān)于 IO,尤其是『內(nèi)存 IO』可能需要特別說明一下。
與代碼中顯示寫出的 read/write/socket 等設(shè)備 IO 操作不同,存儲系統(tǒng)的 IO 很容易被忽略,因為這些 IO 透明地發(fā)生在普通 CPU 指令的背后。先列舉 2009 年 Jeff Dean 的一個經(jīng)典講座中的一頁數(shù)字。
雖然已經(jīng)是十多年前的數(shù)據(jù),但是依然可以看出,最快速的 L1 cache 命中和 Main memory 訪問之間,已經(jīng)拉開了多個數(shù)量級的差距。這些操作并不是在代碼中被顯式控制的,而是 CPU 幫助我們透明完成的,在簡化編程難度的同時,卻也引入了問題。也就是,如果不能良好地適應(yīng)體系結(jié)構(gòu)的特性,那么看似同樣的算法,在常數(shù)項上就可能產(chǎn)生數(shù)量級的差異。而這種差異因為隱蔽性,恰恰是最容易被新工程師所忽略的。下面,我們就圍繞內(nèi)存訪問這個話題,來盤點一下百度 C++工程師的一些『常數(shù)優(yōu)化』。
3 從內(nèi)存分配開始
要使用內(nèi)存,首先就要進(jìn)行內(nèi)存分配。進(jìn)入了 c++時代后,隨著生命周期管理的便捷化,以及基于 class 封裝的各種便捷容器封裝的誕生,運(yùn)行時的內(nèi)存申請和釋放變得越來越頻繁。但是因為地址空間是整個進(jìn)程所共享的一種資源,在多核心系統(tǒng)中就不得不考慮競爭問題。有相關(guān)經(jīng)驗的工程師應(yīng)該會很快聯(lián)想到兩個著名的內(nèi)存分配器,tcmalloc 和 jemalloc,分別來自 google 和 facebook。下面先來對比一下兩者的大致原理。
3.1 先看看 tcmalloc 和 jemalloc
針對多線程競爭的角度,tcmalloc 和 jemalloc 共同的思路是引入了線程緩存機(jī)制。通過一次從后端獲取大塊內(nèi)存,放入緩存供線程多次申請,降低對后端的實際競爭強(qiáng)度。而典型的不同點是,當(dāng)線程緩存被擊穿后,tcmalloc 采用了單一的 page heap(簡化了中間的 transfer cache 和 central cache,他們也是全局唯一的)來承載,而 jemalloc 采用了多個 arena(默認(rèn)甚至超過了服務(wù)器核心數(shù))來承載。因此和網(wǎng)上流傳的主流評測推導(dǎo)原理一致,在線程數(shù)較少,或釋放強(qiáng)度較低的情況下,較為簡潔的 tcmalloc 性能稍勝過 jemalloc。而在核心數(shù)較多、申請釋放強(qiáng)度較高的情況下,jemalloc 因為鎖競爭強(qiáng)度遠(yuǎn)小于 tcmalloc,會表現(xiàn)出較強(qiáng)的性能優(yōu)勢。
一般的評測到這里大致就結(jié)束了,不過我們可以再想一步,如果我們愿意付出更多的內(nèi)存到 cache 層,將后端競爭壓力降下來,那么是否 tcmalloc 依然可以回到更優(yōu)的狀態(tài)呢?如果從上面的分析看,應(yīng)該是可以有這樣的推論的,而且近代服務(wù)端程序的瓶頸也往往并不在內(nèi)存占用上,似乎是一個可行的方案。
不過實際調(diào)整過后,工程師就會發(fā)現(xiàn),大多數(shù)情況下,可能并不能達(dá)到預(yù)期效果。甚至明明從 perf 分析表現(xiàn)上看已經(jīng)觀測到競爭開銷和申請釋放動作占比很小了,但是整個程序表現(xiàn)依然不盡如人意。
這實際上是內(nèi)存分配連續(xù)性的對性能影響的體現(xiàn),即線程緩存核心的加速點在于將申請批量化,而非單純的降低后端壓力。緩存過大后,就會導(dǎo)致持續(xù)反復(fù)的申請和釋放都由緩存承擔(dān),結(jié)果是緩存中存放的內(nèi)存塊地址空間分布越來越零散,呈現(xiàn)一種洗牌效果。
體系結(jié)構(gòu)的緩存優(yōu)化,一般都是以局部性為標(biāo)準(zhǔn),也就是說,程序近期訪問的內(nèi)存附近,大概率是后續(xù)可能被訪問的熱點。因此,如果程序連續(xù)申請和訪問的內(nèi)存呈跳躍變化,那么底層就很難正確進(jìn)行緩存優(yōu)化。體現(xiàn)到程序性能上,就會發(fā)現(xiàn),雖然分配和釋放動作都變得開銷很低了,但是程序整體性能卻并未優(yōu)化(因為真正運(yùn)行的算法的訪存操作常數(shù)項增大)。
3.2 那么理想的 malloc 模型是什么?
通過前面的分析,我們大概得到了兩條關(guān)于 malloc 的核心要素,也就是競爭性和連續(xù)性。那么是否 jemalloc 是做到極致了呢?要回答這個問題,還是要先從實際的內(nèi)存使用模型分析開始。
這是一個很典型的程序,核心是一組持續(xù)運(yùn)行的線程池,當(dāng)任務(wù)到來后,每個線程各司其職,完成一個個的任務(wù)。在 malloc 看來,就是多個長生命周期的線程,隨機(jī)的在各個時點發(fā)射 malloc 和 free 請求。如果只是基于這樣的視圖,其實 malloc 并沒有辦法做其他假定了,只能也按照基礎(chǔ)局部性原理,給一個線程臨近的多次 malloc,盡量分配連續(xù)的地址空間出來。同時利用線程這一概念,將內(nèi)存分區(qū)隔離,減少競爭。這也就是 tcmalloc 和 jemalloc 在做的事情了。
但是內(nèi)存分配這件事和程序的邊界就只能在這里了嗎?沒有更多的業(yè)務(wù)層輸入,可以讓 malloc 做的更好了嗎?那么我們再從業(yè)務(wù)視角來看一下內(nèi)存分配。
微服務(wù)、流式計算、緩存,這幾種業(yè)務(wù)模型幾乎涵蓋了所有主流的后端服務(wù)場景。而這幾種業(yè)務(wù)對內(nèi)存的應(yīng)用有一個重要的特征,就是擁有邊界明確的生命周期。回退到早期的程序設(shè)計年代,其實 server 設(shè)計中每個請求單獨一個啟動線程處理,處理完整體銷毀是一個典型的方案。即使是使用線程池,一個請求接受后從頭到尾一個線程跟進(jìn)完成也是持續(xù)了相當(dāng)長時間的成熟設(shè)計。
而針對這種早期的業(yè)務(wù)模型,其實 malloc 是可以利用到更多業(yè)務(wù)信息的,例如線程動態(tài)申請的內(nèi)存,大概率后續(xù)某個時點會全部歸還,從 tcmalloc 的線程緩存調(diào)整算法中也可以看出對這樣那個的額外信息其實是專門優(yōu)化過的。
但是隨著新型的子任務(wù)級線程池并發(fā)技術(shù)的廣泛應(yīng)用,即請求細(xì)分為多個子任務(wù)充分利用多核并發(fā)來提升計算性能,到 malloc 可見界面,業(yè)務(wù)特性幾乎已經(jīng)不復(fù)存在。只能看到持續(xù)運(yùn)行的線程在隨機(jī) malloc 和 free,以及大量內(nèi)存的 malloc 和 free 漂移在多個線程之間。
那么在這樣 job 化的背景下,怎樣的內(nèi)存分配和釋放策略能夠在競爭性和局部性角度工作的更好呢?下面我們列舉兩個方案。
3.2.1 job arena
第一種是基礎(chǔ)的 job arena 方案,也就是每個 job 有一個獨立的內(nèi)存分配器,job 中使用的動態(tài)內(nèi)存注冊到 job 的 arena 中。因為 job 生命周期明確,中途釋放的動態(tài)內(nèi)存被認(rèn)為無需立即回收,也不會顯著增大內(nèi)存占用。在無需考慮回收的情況下,內(nèi)存分配不用再考慮分塊對齊,每個線程內(nèi)可以完全連續(xù)。最終 job 結(jié)束后,整塊內(nèi)存直接全部釋放掉,大幅減少實際的競爭發(fā)生。
顯而易見,因為需要感知業(yè)務(wù)生命周期,malloc 接口是無法獲得這些信息并進(jìn)行支持的,因此實際會依賴運(yùn)行時使用的容器能夠單獨暴露內(nèi)存分配接口出來。幸運(yùn)的是,在 STL 的帶動下,現(xiàn)實的主流容器庫一般都實現(xiàn)了 allocator 的概念,盡管細(xì)節(jié)并不統(tǒng)一。
例如重度使用的容器之一 protobuf,從 protobuf 3.x 開始引入了 Arena 的概念,可以允許 Message 將內(nèi)存結(jié)構(gòu)分配通過 Arena 完成??上е钡阶钚碌?3.15 版本,string field 的 arena 分配依然沒有被官方支持。https://github.com/protocolbuffers/protobuf/issues/4327
但是,因為 string/bytes 是業(yè)務(wù)廣為使用的類型,如果脫離 Arena 的話,實際對連續(xù)性的提升就會大打折扣。因此在百度,我們內(nèi)部維護(hù)了一個 ArenaString 的 patch,重現(xiàn)了 issue 和注釋中的表達(dá),也就是在 Arena 上分配一個『看起來像』string 的結(jié)構(gòu)。對于讀接口,因為和 string 的內(nèi)存表達(dá)一致,可以直接通過 const string&呈現(xiàn)。對于 mutable 接口,會返回一個替代的 ArenaString 包裝類型,在使用了 auto 技術(shù)的情況下,幾乎可以保持無縫切換。
另外一個重度使用的容器就是 STL 系列了,包括 STL 自身實現(xiàn)的容器,以及 boost/tbb/absl 中按照同類接口實現(xiàn)的高級容器。從 C++17 開始,STL 嘗試將之前混合在 allocator 中的內(nèi)存分配和實例構(gòu)造兩大功能進(jìn)行拆分,結(jié)果就是產(chǎn)生了 PMR(Polymorphic Memory Resouce)的概念。在解耦了構(gòu)造器和分配器之后,程序就不再需要通過修改模板參數(shù)中的類型,來適應(yīng)自己的內(nèi)存分配方法了。其實 PMR 自身也給出了一種連續(xù)申請,整體釋放的分配器實現(xiàn),即 monotonic_buffer_resource,但是這個實現(xiàn)是非線程安全的。
結(jié)合上面兩個內(nèi)存分配器的概念,在實際應(yīng)用中,我們利用線程緩存和無鎖循環(huán)隊列(降低競爭),整頁獲取零散供給(提升連續(xù))實現(xiàn)了一個 SwissMemoryResource,通過接口適配統(tǒng)一支持 STL 和 protobuf 的分配器接口。最終通過 protocol 插件集成到 brpc 中,在百度,我們可以如下使用:
3.2.2 job reserve
更復(fù)雜一些的是 job reserve 方案,在 job arena 的基礎(chǔ)上,結(jié)合了 job 結(jié)束后不析構(gòu)中間結(jié)構(gòu),也不釋放內(nèi)存,轉(zhuǎn)而定期進(jìn)行緊湊重整。這就進(jìn)一步要求了中間結(jié)構(gòu)是可以在保留內(nèi)存的情況下完成重置動作的,并且能夠進(jìn)行容量提取,以及帶容量重新構(gòu)建的功能。這里用 vector為例解釋一下:
和典型的 vector 處理主要不同點是,在 clear 或者 pop_back 等操作縮減大小之后,內(nèi)容對象并沒有實際析構(gòu),只是清空重置。
因此,再一次用到這個槽位的時候,可以直接拿到已經(jīng)構(gòu)造好的元素,而且其 capacity 之內(nèi)的內(nèi)存也依然持有??梢钥吹椒磸?fù)使用同一個實例,容器內(nèi)存和每個元素自身的 capacity 都會逐漸趨向于飽和值,反復(fù)的分配和構(gòu)造需求都被減少了。了解過 protobuf 實現(xiàn)原理的工程師可以對照參考,這種保留實例的 clear 動作,也是 protobuf 的 message 鎖采用的方法。
不過關(guān)注到之前提過局部性的工程師可能會發(fā)現(xiàn),盡管分配需求降低了,但是最終飽和態(tài)的內(nèi)存分布在連續(xù)性上仍不理想,畢竟中途的動態(tài)分配是按需進(jìn)行,而未能參考局部性了。因此容器還需要支持一個動作,也就是重建。
也就是,當(dāng)重復(fù)利用多次,發(fā)生了較多臨時申請之后,需要能夠提取出當(dāng)前的容量 schema,在新的連續(xù)空間中做一次原樣重建,讓內(nèi)存塊重新回歸連續(xù)。
3.3 總結(jié)一下內(nèi)存分配
通過分析 malloc 的性能原理,引入這兩種細(xì)粒度的內(nèi)存分配和管理方案,可以在更小的競爭下,得到更好的內(nèi)存連續(xù)性。
在實測中,簡單應(yīng)用做到 job arena 一般就可以取得大部分性能收益,一般能夠達(dá)到倍數(shù)級提升,在整體服務(wù)角度也能夠產(chǎn)生可觀測的性能節(jié)省。而 job reserve,雖然可以獲得進(jìn)一步地性能提升,但一方面是因為如果涉及非 protobuf 容器,需要實現(xiàn)自定義的 schema 提取和重建接口,另一方面趨于飽和的 capacity 也會讓內(nèi)存使用增大一些。引入成本會提高不少,因此實際只會在性能更為緊要的環(huán)節(jié)進(jìn)行使用。
4 再來看看內(nèi)存訪問
內(nèi)存分配完成后,就到了實際進(jìn)行內(nèi)存訪問的階段了。一般我們可以將訪存需求拆解到兩個維度,一個是單線程的連續(xù)訪問,另一個是多個線程的共享訪問。下面就分拆到兩個部分來分別談?wù)劯鱾€維度的性能優(yōu)化方法。
4.1 順序訪存優(yōu)化
一般來說,當(dāng)我們要執(zhí)行大段訪存操作時,如果訪問地址連續(xù),那么實際效率可以獲得提升。典型例如對于容器遍歷訪問操作,數(shù)組組織的數(shù)據(jù),相比于比鏈表組織的數(shù)據(jù),一般會有顯著的性能優(yōu)勢。其實在內(nèi)存分配的環(huán)節(jié),我們引入的讓連續(xù)分配(基本也會是連續(xù)訪問)的空間地址連續(xù)性更強(qiáng),也是出于這一目的。
那么下面我們先來看一看,連續(xù)性的訪問產(chǎn)生性能差異的原理是什么。
這里以 Intel CPU 為例來簡要描述一下預(yù)取過程。詳見:https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf當(dāng)硬件監(jiān)測到連續(xù)地址訪問模式出現(xiàn)時,會激活多層預(yù)取器開始執(zhí)行,參考當(dāng)前負(fù)載等因素,將預(yù)測將要訪問的數(shù)據(jù)加載到合適的緩存層級當(dāng)中。這樣,當(dāng)后續(xù)訪問真實到來的時候,能夠從更近的緩存層級中獲取到數(shù)據(jù),從而加速訪問速度。因為 L1 容量有限,L1 的硬件預(yù)取步長較短,加速目標(biāo)主要為了提升 L2 到 L1,而 L2 和 LLC 的預(yù)取步長相對較長,用于將主存提升到 cache。
在這里局部性概念其實充當(dāng)了軟硬件交互的某種約定,因為程序天然的訪問模式總有一些局部性,硬件廠商就通過預(yù)測程序設(shè)計的局部性,來盡力加速這種模式的訪問請求,力求做到通用提升性能。而軟件設(shè)計師,則通過盡力讓設(shè)計呈現(xiàn)更多的局部性,來激活硬件廠商設(shè)計的優(yōu)化路徑,使具體程序性能得到進(jìn)一步優(yōu)化。某種意義上講,z 不失為一個相生相伴的循環(huán)促進(jìn)。
這里通過一個樣例來驗證體現(xiàn)一下如何尊重局部性,以及局部性對程序的影響。
這是一個推薦/搜索系統(tǒng)中常見的內(nèi)積打分場景,即通過向量計算來進(jìn)行大規(guī)模打分。同樣的代碼,依據(jù) shuffle 和 prefetch 存在與否,產(chǎn)生類似如下的表現(xiàn):
shuffle & no prefetch:44ms shuffle & prefetch:36ms shuffle & no prefetch:13ms shuffle & prefetch:12ms從 1 和 3 的區(qū)別可以看出,完全相同的指令,在不同的訪存順序下存在的性能差距可以達(dá)到倍數(shù)級。而從 1 和 2 的區(qū)別可以看出,手動添加預(yù)取操作后,對性能有一定改善,預(yù)期更精細(xì)地指導(dǎo)預(yù)取步長和以及 L1 和 L2 的分布還有改善空間。不過指令執(zhí)行周期和硬件效率很難完備匹配,手動預(yù)取一般用在無法改造成物理連續(xù)的場景,但調(diào)參往往是一門玄學(xué)。最后 3 和 4 可以看出,即使連續(xù)訪存下,預(yù)取依然有一些有限的收益,推測和硬件預(yù)取無法跨越頁邊界造成的多次預(yù)測冷啟動有關(guān),但是影響已經(jīng)比較微弱了。
最具備指導(dǎo)意義的可能就是類似這個內(nèi)積打分的場景,有時為了節(jié)省空間,工程師會將程序設(shè)計為,從零散的空間取到向量指針,并組成一個數(shù)組或鏈表系統(tǒng)來管理。天然來講,這樣節(jié)省了內(nèi)存的冗余,都引用向一份數(shù)據(jù)。但是如果引入一些冗余,將所需要的向量數(shù)據(jù)一同拷貝構(gòu)成連續(xù)空間,對于檢索時的遍歷計算會帶來明顯的性能提升。
4.2 并發(fā)訪問優(yōu)化
提到并發(fā)訪問,可能要先從一個概念,緩存行(cache line)說起。
為了避免頻繁的主存交互,其實緩存體系采用了類似 malloc 的方法,即劃分一個最小單元,叫做緩存行(主流 CPU 上一般 64B),所有內(nèi)存到緩存的操作,以緩存行為單位整塊完成。
例如對于連續(xù)訪問來說第一個 B 的訪問就會觸發(fā)全部 64B 數(shù)據(jù)都進(jìn)入 L1,后續(xù)的 63B 訪問就可以直接由 L1 提供服務(wù)了。所以并發(fā)訪問中的第一個問題就是要考慮緩存行隔離,也就是一般可以認(rèn)為,位于不同的兩個緩存行的數(shù)據(jù),是可以被真正獨立加載/淘汰和轉(zhuǎn)移的(因為 cache 間流轉(zhuǎn)的最小單位是一個 cache line)。
典型的問題一般叫做 false share 現(xiàn)象,也就是不慎將兩個本無競爭的數(shù)據(jù),放置在一個緩存行內(nèi),導(dǎo)致因為體系結(jié)構(gòu)的原因,引入了『本不存在的競爭』。這點在網(wǎng)上資料比較充足,例如 brpc 和 disruptor 的設(shè)計文檔都比較詳細(xì)地講解了這一點,因此這里就不再做進(jìn)一步的展開了。
4.3 那先來聊聊緩存一致性
排除了 false share 現(xiàn)象之后,其余就是真正的共享問題了,也就是確實需要位于同一個緩存行內(nèi)的數(shù)據(jù)(往往就是同一個數(shù)據(jù)),多個核心都要修改的場景。由于在多核心系統(tǒng)中 cache 存在多份,因此就需要考慮這多個副本間一致性的問題。這個一致性一般由一套狀態(tài)機(jī)協(xié)議保證(MESI 及其變體)。
大體是,當(dāng)競爭寫入發(fā)生時,需要競爭所有權(quán),未獲得所有權(quán)的核心,只能等待同步到修改的最新結(jié)果之后,才能繼續(xù)自己的修改。這里要提一下的是有個流傳甚廣的說法是,因為緩存系統(tǒng)的引入,帶來了不一致,所以引發(fā)了各種多線程可見性問題。
這么說其實有失偏頗,MESI 本質(zhì)上是一個『一致性』協(xié)議,也就是遵守協(xié)議的緩存系統(tǒng),其實對上層 CPU 多個核心做到了順序一致性。比如對比一下就能發(fā)現(xiàn),緩存在競爭時表現(xiàn)出來的處理動作,其實和只有主存時是一致的。
只是阻塞點從競爭一個物理主存單元的寫入,轉(zhuǎn)移到了雖然是多個緩存物理單元,但是通過協(xié)議競爭獨占上。不過正因為競爭阻塞情況并沒有緩解,所以 cache 的引入其實搭配了另一個部件也就是寫緩沖(store buffer)。
注:寫緩存本身引入其實同時收到亂序執(zhí)行的驅(qū)動。
寫緩沖的引入,真正開始帶來的可見性問題。
以 x86 為例,當(dāng)多核發(fā)生寫競爭時,未取得所有權(quán)的寫操作雖然無法生效到緩存層,但是可以在改為等待在寫緩沖中。而 CPU 在一般情況下可以避免等待而先開始后續(xù)指令的執(zhí)行,也就是雖然 CPU 看來是先進(jìn)行了寫指令,后進(jìn)行讀指令,但是對緩存而言,先進(jìn)行的是讀指令,而寫指令被阻塞到緩存重新同步之后才能進(jìn)行。要注意,如果聚焦到緩存交互界面,整體依然是保證了順序一致,但是在指令交互界面,順序發(fā)生了顛倒。這就是典型的 StoreLoad 亂序成了 LoadStore,也是 x86 上唯一的一個亂序場景。而針對典型的 RISC 系統(tǒng)來說(arm/power),為了流水線并行度更高,一般不承諾寫緩沖 FIFO,當(dāng)一個寫操作卡在寫緩沖之后,后續(xù)的寫操作也可能被先處理,進(jìn)一步造成 StoreStore 亂序。
寫緩沖的引入,讓競爭出現(xiàn)后不會立即阻塞指令流,可以容忍直到緩沖寫滿。但因為緩存寫入完成需要周知所有 L1 執(zhí)行作廢操作完成,隨著核心增多,會出現(xiàn)部分 L1 作廢長尾阻塞寫緩沖的情況。因此一些 RISC 系統(tǒng)引入了進(jìn)一步的緩沖機(jī)制。
進(jìn)一步的緩沖機(jī)制一般叫做失效隊列,也就是當(dāng)一個寫操作只要將失效消息投遞到每個 L1 的失效隊列即視為完成,失效操作長尾不再影響寫入。這一步改動甚至確實地部分破壞了緩存一致性,也就是除非一個核心等待當(dāng)前失效消息排空,否則可能讀取到過期數(shù)據(jù)。
到這里已經(jīng)可以感受到,為了對大量常規(guī)操作進(jìn)行優(yōu)化,近代體系結(jié)構(gòu)設(shè)計中引入了多個影響一致性的機(jī)制。但是為了能夠構(gòu)建正確的跨線程同步,某些關(guān)鍵節(jié)點上的一致性又是必不可少的。
因此,配套的功能指令應(yīng)運(yùn)而生,例如 x86 下 mfence 用于指導(dǎo)后續(xù) load 等待寫緩沖全部生效,armv8 的 lda 用于確保后續(xù) load 等待 invalid 生效完成等。這一層因為和機(jī)型與指令設(shè)計強(qiáng)相關(guān),而且指令的配套使用又能帶來多種不同的內(nèi)存可見性效果。這就大幅增加了工程師編寫正確一致性程序的成本,而且難以保證跨平臺可移植。于是就到了標(biāo)準(zhǔn)化發(fā)揮作用的時候了,這個關(guān)于內(nèi)存一致性領(lǐng)域的標(biāo)準(zhǔn)化規(guī)范,就是內(nèi)存序(memory order)。
4.4 再談一談 memory order
作為一種協(xié)議機(jī)制,內(nèi)存序和其他協(xié)議類似,主要承擔(dān)了明確定義接口層功能的作用。體系結(jié)構(gòu)專家從物理層面的優(yōu)化手段中,抽象總結(jié)出了多個不同層級的邏輯一致性等級來進(jìn)行刻畫表達(dá)。這種抽象成為了公用邊界標(biāo)準(zhǔn)之后,硬件和軟件研發(fā)者就可以獨立開展各自的優(yōu)化工作,而最終形成跨平臺通用解決方案。
對于硬件研發(fā)者來說,只要能夠最終設(shè)計一些特定的指令或指令組合,支持能夠?qū)崿F(xiàn)這些內(nèi)存序規(guī)范的功能,那么任意的設(shè)計擴(kuò)展原理上都是可行的,不用考慮有軟件兼容性風(fēng)險。同樣,對于軟件研發(fā)者來說,只要按照標(biāo)準(zhǔn)的邏輯層來理解一致性,并使用正確的內(nèi)存序,就可以不用關(guān)注底層平臺細(xì)節(jié),寫出跨平臺兼容的多線程程序。
內(nèi)存序在官方定義里,是洋洋灑灑一大篇內(nèi)容,為了便于理解,下面從開發(fā)程序須知角度,抽出一些簡潔精煉的概念(雖不是理論完備的)來輔助記憶和理解。
首先來看看,內(nèi)存序背后到底發(fā)生了啥。
在這個樣例中可以看到,在編譯層,默認(rèn)對于無關(guān)指令,會進(jìn)行一定程度的順序調(diào)整(不影響正確性的前提下)。另一方面,編譯器默認(rèn)可以假定不受其他線程影響,因此同一個數(shù)據(jù)連續(xù)的多次內(nèi)存訪問可以省略。
下面看一下最基礎(chǔ)的內(nèi)存序等級,relaxed。
在使用了基礎(chǔ)的內(nèi)存序等級 relaxed 之后,編譯器不再假設(shè)不受其他線程影響,每個循環(huán)都會重新加載 flag。另外可以觀測到 flag 和 payload 的亂序被恢復(fù)了,不過原理上 relaxed 并不保證順序,也就是這個順序并不是一個編譯器的保證承諾??傮w來說,relaxed 等級和普通的讀寫操作區(qū)別不大,只是保證了對應(yīng)的內(nèi)存訪問不可省略。
更進(jìn)一步的內(nèi)存序等級是 consume-release,不過當(dāng)前沒有對應(yīng)的實現(xiàn)案例,一般都被默認(rèn)提升到了下一個等級,也就是第一個真實有意義的內(nèi)存序等級 acquire-release。先從原理上講,一般可以按照滿足條件/給出承諾的方式來簡化理解,即:
要求:對同一變量 M 分別進(jìn)行寫(release)A 和讀(acquire)B,B 讀到了 A 寫入的值。承諾:A 之前的所有其他寫操作,對 B 之后的讀操作可見。實際影響:涉及到的操作不會發(fā)生穿越 A/B 操作的重排;X86:無額外指令;ARMv8:A 之前排空 store buffer,B 之后排空 invalid queue,A/B 保序;ARMv7&Power:A 之前全屏障,B 之后全屏障。
由于 x86 默認(rèn)內(nèi)存序不低于 acquire-release,這里用 ARMv8 匯編來演示效果??梢钥闯鰧?yīng)指令發(fā)生了替換,從 st/ld 變更到了 stl/lda,從而利用 armv8 的體系結(jié)構(gòu)實現(xiàn)了相應(yīng)的內(nèi)存序語義。
再進(jìn)一步的內(nèi)存序,就是最強(qiáng)的一級 sequentially-consistent,其實就是恢復(fù)到了 MESI 的承諾等級,即順序一致。同樣可以按照滿足條件/給出承諾的方式來簡化理解,即:
- 要求:對兩個變量 M,N 的(Sequentially Consistent)寫操作Am,An。在任意線程中,通過(Sequentially Consistent)的讀操作觀測到 Am 先于 An。
- 承諾:在其他線程通過(Sequentially Consistent)的讀操作 B 也會觀測到 Am 先于 An。
- 實際影響:
X86:Am 和 An 之后清空 store buffer,讀操作 B 無額外指令;
ARMv8:Am 和 An 之前排空 store buffer, B 之后排空 invalid queue,A/B 保序;
ARMv7:Am 和 An 前后全屏障,B 之后全屏障;
POWER:Am 和 An 前全屏障,B 前后全屏障。
值得注意的是,ARMv8 開始,特意優(yōu)化了 sequentially-consistent 等級,省略了全屏障成本。推測是因為順序一致在 std::atomic 實現(xiàn)中作為默認(rèn)等級提供,為了通用意義上提升性能做了專門的優(yōu)化。
4.5 理解 memory order 如何幫助我們
先給出一個基本測試的結(jié)論,看一下一組對比數(shù)據(jù):
1、多線程競爭寫入近鄰地址 sequentially-consistent:0.71 單位時間
2、多線程競爭寫入近鄰地址 release:0.006 單位時間
3、多線程競爭寫入 cache line 隔離地址 sequentially-consistent:0.38 單位時間
4、多線程競爭寫入 cache line 隔離地址 release:0.02 單位時間
這里可以看出,做 cache line 隔離,對于 sequentially-consistent 內(nèi)存序下,有一定的收益,但是對 release 內(nèi)存序,反而有負(fù)效果。這是由于 release 內(nèi)存序下,因為沒有強(qiáng)內(nèi)存屏障,寫緩沖起到了競爭緩解的作用。而在充分緩解了競爭之后,因為 cache line 隔離引入了相同吞吐下更多 cache line 的傳輸交互,反而開銷變大。
在這個信息指導(dǎo)下,我們在實現(xiàn)無鎖隊列時,采用了循環(huán)數(shù)組 + 分槽位版本號的模式來實現(xiàn)。因為隊列操作只需要 acquire-release 等級,分槽位版本號間無需采用 cache line 隔離模式設(shè)計,整體達(dá)到了比較高的并發(fā)性能。
原文地址:百度C++工程師的那些極限優(yōu)化(內(nèi)存篇)
總結(jié)
以上是生活随笔為你收集整理的百度 C++ 工程师的那些极限优化(内存篇)的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 怎么给word文档注音_请教如何在WOR
- 下一篇: c++编写断点续传和多线程下载模块【转】