日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 编程语言 > c/c++ >内容正文

c/c++

百度 C++ 工程师的那些极限优化(内存篇)

發布時間:2023/12/20 c/c++ 34 豆豆
生活随笔 收集整理的這篇文章主要介紹了 百度 C++ 工程师的那些极限优化(内存篇) 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.

c/c++ linux服務器開發相關視頻解析:

c/c++程序員必知的內存泄漏解決方案與原理實現
90分鐘了解Linux內存架構,numa的優勢,slab的實現,vmalloc的原理

c/c++ linux服務器開發免費學習地址:c/c++ linux后臺服務器高級架構師

1 背景

在百度看似簡簡單單的界面后面,是遍布全國的各個數據中心里,運轉著的海量 C++服務。對 C++的重度應用是百度的一把雙刃劍,學習成本陡峭,指針類錯誤定位難、擴散性廣另很多開發者望而卻步。然而在另一方面,語言層引入的額外開銷低,對底層能力可操作性強,又能夠為追求極致性能提供優異的實踐環境。
因此,對百度的 C++工程師來說,掌握底層特性并加以利用來指導應用的性能優化,就成了一門必要而且必須的技能。久而久之,百度工程師就將這種追求極致的性能優化,逐漸沉淀成了習慣,甚至形成了對技術的信仰。下面我們就來盤點和分享一些,在性能優化的征途上,百度 C++工程師積累下來的理論和實踐,以及那些為了追求極致,所發掘的『奇技淫巧』。

2 重新認識性能優化

作為程序員,大家或多或少都會和性能打交道,尤其是以 C++為主的后端服務工程師,但是每個工程師對性能優化概念的理解在細節上又是千差萬別的。下面先從幾個優化案例入手,建立一個性能優化相關的感性認識,之后再從原理角度,描述一下本文所講的性能優化的切入角度和方法依據。

2.1 從字符串處理開始

2.1.1 string as a buffer

為了調用底層接口和集成一些第三方庫能力,在調用界面層,會存在對 C++字符串和 C 風格字符串的交互場景,典型是這樣的:

size_t some_c_style_api(char* buffer, size_t size); void some_cxx_style_function(std::string& result) {// 首先擴展到充足大小result.resize(estimate_size);// 從c++17開始,string類型支持通過data取得非常量指針auto acture_size = some_c_style_api(result.data(), result.size());// 最終調整到實際大小result.resize(acture_size); }

這個方法存在一個問題,就是在首次 resize 時,string 對 estimate_size 內的存儲區域全部進行了 0 初始化。但是這個場景中,實際的有效數據其實是在 some_c_style_api 內部被寫入的,所以 resize 時的初始化動作其實是冗余的。在交互 buffer 的 size 較大的場景,例如典型的編碼轉換和壓縮等操作,這次冗余的初始化引入的開銷還是相當可觀的。
可以這樣編碼:

size_t some_c_style_api(char* buffer, size_t size); void some_cxx_style_function(std::string& result) {auto* buffer = babylon::resize_uninitialized(result, estimate_size);auto acture_size = some_c_style_api(buffer, result.size());result.resize(acture_size); }

2.1.2 split string

實際業務中,有一個典型場景是一些輕 schema 數據的解析,比如一些標準分隔符,典型是’_‘或者’\t’,簡單分割的分列數據(這在日志等信息的粗加工處理中格外常見)。由于場景極其單純,可能的算法層面優化空間一般認為較小,而實際實現中,這樣的代碼是廣為存在的:

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 版本廣泛出現在新工程師的代碼中,接口靈活,流傳度高,但是實際業務中效率其實并不優秀,例如和 google 優化過的 absl 相比,其實有倍數級的差距。尤其如果工程師沒有注意進行單字符優化的時候(直接使用了官方例子中的 is_any_of),甚至達到了數量級的差距。進一步地,如果聯動思考業務形態,一般典型的分割后處理是可以做到零拷貝的,這也可以進一步降低冗余拷貝和大量臨時對象的創建開銷。

最后,再考慮到百度當前的內部硬件環境有多代不同型號的 CPU,進一步改造 spilt 顯式使用 SIMD 優化,并自適應多代向量指令集,可以取得進一步的性能提升。尤其是 bmi 指令加速后,對于一個 SIMD 步長內的連續分隔符探測,比如密集短串場景,甚至可以取得數量級的性能提升。
最終在百度,我們可以這樣編碼實現:

babylon::split([] (std::string_view sv) {direct_work_on_segment(sv); }, str, '\t'};

2.1.3 magic of protobuf

隨著 brpc 在百度內部的廣泛應用,protobuf 成為了百度內部數據交換的主流方式,解析、改寫、組裝 protobuf 的代碼在每個服務中幾乎都會有一定的占比。尤其是近幾年,進一步疊加了微服務化的發展趨勢之后,這層數據交換邊界就變得更加顯著起來。
在有些場景下,例如傳遞并增加一個字段,或者從多個后端存儲獲取分列表達的數據合并后返回,利用標準的 C++API 進行反序列化、修改、再序列化的成本,相對于實際要執行的業務來說,額外帶來的性能開銷會顯著體現出來。
舉例來說,比如我們定義了這樣的 message:

message Field {bytes column = 1;bytes value = 2; }; message Record {bytes key = 1;repeated Field field = 2; }; message Response {repeated Record record = 1;bytes error_message = 2; };

我們設想一個場景,一個邏輯的 record 分散于多個子系統,那么我們需要引入一個 proxy 層,完成多個 partial record 的 merge 操作,常規意義上,這個 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 來說,這一層反復對后端的解析、合并、再序列化引入的成本,就會相對凸現出來了。進一步的消除,就要先從 protobuf 的本質入手。

protobuf 的根基先是一套公開標準的 wire format,其上才是支持多語言構造和解析的 SDK,因此嘗試降低對解析和合并等操作的進一步優化,繞過 c++api,深入 wire format 層來嘗試是一種可行且有效的手段。那么我們先來看一下一些 wire format 層的特性。

即 message 的構成直接由內部包含的 field 的序列化結果堆砌而成,field 之間不存在分割點,在整個 message 外部,也不存在框架信息。基于這個特性,一些合并和修改操作可以在序列化的 bytes 結果上被低成本且安全地操作。而另一方面,message field 的格式和 string 又是完全一致的,也就是定義一個 message field,或者定義一個 string field 而把對應 message 序列化后存入,結果是等價的(而且這兩個特性是被官方承諾的)。
結合這些特性,之前的合并操作在實現上我們改造為:

message ProxyResponse {// 修改proxy側的message定義,做到不深層解析repeated string record = 1;bytes error_message = 2; };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) {// 利用string追加替換message操作final_response.mutable_record(i).append(one_sub_response.record(i));final_response.mutable_record(i).append(another_sub_response.record(i));... }

在微服務搭的環境下,類似的操作可以很好地控制額外成本的增加。

【文章福利】需要C/C++ Linux服務器架構師學習資料加群812855908(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等)

2.2 回頭來再看看性能優化

一般來講,一個程序的性能構成要件大概有三個,即算法復雜度、IO 開銷和并發能力。

首要的影響因素是大家都熟悉的算法復雜度。一次核心算法優化和調整,可以對應用性能產生的影響甚至是代差級別的。例如 LSM Tree 對 No-SQL 吞吐的提升,又例如事件觸發對 epoll 大并發能力的提升。然而正因為關注度高,在實際工程實現層面,無論是犯錯幾率,還是留下的優化空間,反而會大為下降。甚至極端情況下,可能作為非科研主導的工程師,在進行性能優化時鮮少遇到改良算法的場景,分析問題選擇合適算法會有一定占比,但是可能范圍也有限。
更多情況下需要工程師解決的性能問題,借用一句算法競賽用語,用『卡常數』來形容可能更貼切。也就是采用了正確的合適的算法,但是因為沒有采用體系結構下更優的實現方案,導致在 O(X)上附加了過大的常數項,進而造成的整體性能不足。雖然在算法競賽中,卡常數和常數優化是出題人和解題人都不愿意大量出現的干擾項(因為畢竟是以核心算法本身為目標),但是轉換到實際項目背景下,常數優化卻往往是性能優化領域的重要手段。
現在我們再來回顧一下上面引出問題的三個優化案例。可以看到,其中都不包含算法邏輯本身的改進,但是通過深入利用底層(比如依賴庫或指令集)特性,依然能夠取得倍數甚至數量級的優化效果。這和近些年體系結構變得越發復雜有很大關聯,而這些變化,典型的體現場景就是 IO 和并發。并發的優化,對于工程經驗比較豐富的同學應該已經不陌生了,但是關于 IO,尤其是『內存 IO』可能需要特別說明一下。
與代碼中顯示寫出的 read/write/socket 等設備 IO 操作不同,存儲系統的 IO 很容易被忽略,因為這些 IO 透明地發生在普通 CPU 指令的背后。先列舉 2009 年 Jeff Dean 的一個經典講座中的一頁數字。

雖然已經是十多年前的數據,但是依然可以看出,最快速的 L1 cache 命中和 Main memory 訪問之間,已經拉開了多個數量級的差距。這些操作并不是在代碼中被顯式控制的,而是 CPU 幫助我們透明完成的,在簡化編程難度的同時,卻也引入了問題。也就是,如果不能良好地適應體系結構的特性,那么看似同樣的算法,在常數項上就可能產生數量級的差異。而這種差異因為隱蔽性,恰恰是最容易被新工程師所忽略的。下面,我們就圍繞內存訪問這個話題,來盤點一下百度 C++工程師的一些『常數優化』。

3 從內存分配開始

要使用內存,首先就要進行內存分配。進入了 c++時代后,隨著生命周期管理的便捷化,以及基于 class 封裝的各種便捷容器封裝的誕生,運行時的內存申請和釋放變得越來越頻繁。但是因為地址空間是整個進程所共享的一種資源,在多核心系統中就不得不考慮競爭問題。有相關經驗的工程師應該會很快聯想到兩個著名的內存分配器,tcmalloc 和 jemalloc,分別來自 google 和 facebook。下面先來對比一下兩者的大致原理。

3.1 先看看 tcmalloc 和 jemalloc

針對多線程競爭的角度,tcmalloc 和 jemalloc 共同的思路是引入了線程緩存機制。通過一次從后端獲取大塊內存,放入緩存供線程多次申請,降低對后端的實際競爭強度。而典型的不同點是,當線程緩存被擊穿后,tcmalloc 采用了單一的 page heap(簡化了中間的 transfer cache 和 central cache,他們也是全局唯一的)來承載,而 jemalloc 采用了多個 arena(默認甚至超過了服務器核心數)來承載。因此和網上流傳的主流評測推導原理一致,在線程數較少,或釋放強度較低的情況下,較為簡潔的 tcmalloc 性能稍勝過 jemalloc。而在核心數較多、申請釋放強度較高的情況下,jemalloc 因為鎖競爭強度遠小于 tcmalloc,會表現出較強的性能優勢。
一般的評測到這里大致就結束了,不過我們可以再想一步,如果我們愿意付出更多的內存到 cache 層,將后端競爭壓力降下來,那么是否 tcmalloc 依然可以回到更優的狀態呢?如果從上面的分析看,應該是可以有這樣的推論的,而且近代服務端程序的瓶頸也往往并不在內存占用上,似乎是一個可行的方案。
不過實際調整過后,工程師就會發現,大多數情況下,可能并不能達到預期效果。甚至明明從 perf 分析表現上看已經觀測到競爭開銷和申請釋放動作占比很小了,但是整個程序表現依然不盡如人意。
這實際上是內存分配連續性的對性能影響的體現,即線程緩存核心的加速點在于將申請批量化,而非單純的降低后端壓力。緩存過大后,就會導致持續反復的申請和釋放都由緩存承擔,結果是緩存中存放的內存塊地址空間分布越來越零散,呈現一種洗牌效果。

體系結構的緩存優化,一般都是以局部性為標準,也就是說,程序近期訪問的內存附近,大概率是后續可能被訪問的熱點。因此,如果程序連續申請和訪問的內存呈跳躍變化,那么底層就很難正確進行緩存優化。體現到程序性能上,就會發現,雖然分配和釋放動作都變得開銷很低了,但是程序整體性能卻并未優化(因為真正運行的算法的訪存操作常數項增大)。

3.2 那么理想的 malloc 模型是什么?

通過前面的分析,我們大概得到了兩條關于 malloc 的核心要素,也就是競爭性和連續性。那么是否 jemalloc 是做到極致了呢?要回答這個問題,還是要先從實際的內存使用模型分析開始。

這是一個很典型的程序,核心是一組持續運行的線程池,當任務到來后,每個線程各司其職,完成一個個的任務。在 malloc 看來,就是多個長生命周期的線程,隨機的在各個時點發射 malloc 和 free 請求。如果只是基于這樣的視圖,其實 malloc 并沒有辦法做其他假定了,只能也按照基礎局部性原理,給一個線程臨近的多次 malloc,盡量分配連續的地址空間出來。同時利用線程這一概念,將內存分區隔離,減少競爭。這也就是 tcmalloc 和 jemalloc 在做的事情了。
但是內存分配這件事和程序的邊界就只能在這里了嗎?沒有更多的業務層輸入,可以讓 malloc 做的更好了嗎?那么我們再從業務視角來看一下內存分配。

微服務、流式計算、緩存,這幾種業務模型幾乎涵蓋了所有主流的后端服務場景。而這幾種業務對內存的應用有一個重要的特征,就是擁有邊界明確的生命周期。回退到早期的程序設計年代,其實 server 設計中每個請求單獨一個啟動線程處理,處理完整體銷毀是一個典型的方案。即使是使用線程池,一個請求接受后從頭到尾一個線程跟進完成也是持續了相當長時間的成熟設計。
而針對這種早期的業務模型,其實 malloc 是可以利用到更多業務信息的,例如線程動態申請的內存,大概率后續某個時點會全部歸還,從 tcmalloc 的線程緩存調整算法中也可以看出對這樣那個的額外信息其實是專門優化過的。
但是隨著新型的子任務級線程池并發技術的廣泛應用,即請求細分為多個子任務充分利用多核并發來提升計算性能,到 malloc 可見界面,業務特性幾乎已經不復存在。只能看到持續運行的線程在隨機 malloc 和 free,以及大量內存的 malloc 和 free 漂移在多個線程之間。
那么在這樣 job 化的背景下,怎樣的內存分配和釋放策略能夠在競爭性和局部性角度工作的更好呢?下面我們列舉兩個方案。

3.2.1 job arena

第一種是基礎的 job arena 方案,也就是每個 job 有一個獨立的內存分配器,job 中使用的動態內存注冊到 job 的 arena 中。因為 job 生命周期明確,中途釋放的動態內存被認為無需立即回收,也不會顯著增大內存占用。在無需考慮回收的情況下,內存分配不用再考慮分塊對齊,每個線程內可以完全連續。最終 job 結束后,整塊內存直接全部釋放掉,大幅減少實際的競爭發生。
顯而易見,因為需要感知業務生命周期,malloc 接口是無法獲得這些信息并進行支持的,因此實際會依賴運行時使用的容器能夠單獨暴露內存分配接口出來。幸運的是,在 STL 的帶動下,現實的主流容器庫一般都實現了 allocator 的概念,盡管細節并不統一。
例如重度使用的容器之一 protobuf,從 protobuf 3.x 開始引入了 Arena 的概念,可以允許 Message 將內存結構分配通過 Arena 完成。可惜直到最新的 3.15 版本,string field 的 arena 分配依然沒有被官方支持。https://github.com/protocolbuffers/protobuf/issues/4327
但是,因為 string/bytes 是業務廣為使用的類型,如果脫離 Arena 的話,實際對連續性的提升就會大打折扣。因此在百度,我們內部維護了一個 ArenaString 的 patch,重現了 issue 和注釋中的表達,也就是在 Arena 上分配一個『看起來像』string 的結構。對于讀接口,因為和 string 的內存表達一致,可以直接通過 const string&呈現。對于 mutable 接口,會返回一個替代的 ArenaString 包裝類型,在使用了 auto 技術的情況下,幾乎可以保持無縫切換。
另外一個重度使用的容器就是 STL 系列了,包括 STL 自身實現的容器,以及 boost/tbb/absl 中按照同類接口實現的高級容器。從 C++17 開始,STL 嘗試將之前混合在 allocator 中的內存分配和實例構造兩大功能進行拆分,結果就是產生了 PMR(Polymorphic Memory Resouce)的概念。在解耦了構造器和分配器之后,程序就不再需要通過修改模板參數中的類型,來適應自己的內存分配方法了。其實 PMR 自身也給出了一種連續申請,整體釋放的分配器實現,即 monotonic_buffer_resource,但是這個實現是非線程安全的。
結合上面兩個內存分配器的概念,在實際應用中,我們利用線程緩存和無鎖循環隊列(降低競爭),整頁獲取零散供給(提升連續)實現了一個 SwissMemoryResource,通過接口適配統一支持 STL 和 protobuf 的分配器接口。最終通過 protocol 插件集成到 brpc 中,在百度,我們可以如下使用:

babylon::ReusableRPCProtocol::register_protocol(); ::baidu::rpc::ServerOptions options; options.enabled_protocols = "baidu_std_reuse";class SomeServiceImpl : public SomeService { public:// request和response本身采用了請求級別的memory resource來分配virtual void some_method(google::protobuf::RpcController* controller,const SomeRequest* request,SomeResponse* response,google::protobuf::Closure* done) {baidu::rpc::ClosureGuard guard(done);// 通過轉換到具體實現來取得MemoryResourceauto* closure = static_cast<babylon::ReusableRPCProtocol::Closure*>(done);auto& resource = closure->memory_resource();// 做一些請求級別的動態容器std::pmr::vector<std::pmr::string> tmp_vector(&resource);google::protobuf::Arena::CreateMessage<SomeOtherMessage>(&(Arena&)resource);...// done->Run時請求級內存整體釋放} };

3.2.2 job reserve

更復雜一些的是 job reserve 方案,在 job arena 的基礎上,結合了 job 結束后不析構中間結構,也不釋放內存,轉而定期進行緊湊重整。這就進一步要求了中間結構是可以在保留內存的情況下完成重置動作的,并且能夠進行容量提取,以及帶容量重新構建的功能。這里用 vector為例解釋一下:

和典型的 vector 處理主要不同點是,在 clear 或者 pop_back 等操作縮減大小之后,內容對象并沒有實際析構,只是清空重置。
因此,再一次用到這個槽位的時候,可以直接拿到已經構造好的元素,而且其 capacity 之內的內存也依然持有。可以看到反復使用同一個實例,容器內存和每個元素自身的 capacity 都會逐漸趨向于飽和值,反復的分配和構造需求都被減少了。了解過 protobuf 實現原理的工程師可以對照參考,這種保留實例的 clear 動作,也是 protobuf 的 message 鎖采用的方法。
不過關注到之前提過局部性的工程師可能會發現,盡管分配需求降低了,但是最終飽和態的內存分布在連續性上仍不理想,畢竟中途的動態分配是按需進行,而未能參考局部性了。因此容器還需要支持一個動作,也就是重建。

也就是,當重復利用多次,發生了較多臨時申請之后,需要能夠提取出當前的容量 schema,在新的連續空間中做一次原樣重建,讓內存塊重新回歸連續。

3.3 總結一下內存分配

通過分析 malloc 的性能原理,引入這兩種細粒度的內存分配和管理方案,可以在更小的競爭下,得到更好的內存連續性。
在實測中,簡單應用做到 job arena 一般就可以取得大部分性能收益,一般能夠達到倍數級提升,在整體服務角度也能夠產生可觀測的性能節省。而 job reserve,雖然可以獲得進一步地性能提升,但一方面是因為如果涉及非 protobuf 容器,需要實現自定義的 schema 提取和重建接口,另一方面趨于飽和的 capacity 也會讓內存使用增大一些。引入成本會提高不少,因此實際只會在性能更為緊要的環節進行使用。

4 再來看看內存訪問

內存分配完成后,就到了實際進行內存訪問的階段了。一般我們可以將訪存需求拆解到兩個維度,一個是單線程的連續訪問,另一個是多個線程的共享訪問。下面就分拆到兩個部分來分別談談各個維度的性能優化方法。

4.1 順序訪存優化

一般來說,當我們要執行大段訪存操作時,如果訪問地址連續,那么實際效率可以獲得提升。典型例如對于容器遍歷訪問操作,數組組織的數據,相比于比鏈表組織的數據,一般會有顯著的性能優勢。其實在內存分配的環節,我們引入的讓連續分配(基本也會是連續訪問)的空間地址連續性更強,也是出于這一目的。
那么下面我們先來看一看,連續性的訪問產生性能差異的原理是什么。

這里以 Intel CPU 為例來簡要描述一下預取過程。詳見:https://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf當硬件監測到連續地址訪問模式出現時,會激活多層預取器開始執行,參考當前負載等因素,將預測將要訪問的數據加載到合適的緩存層級當中。這樣,當后續訪問真實到來的時候,能夠從更近的緩存層級中獲取到數據,從而加速訪問速度。因為 L1 容量有限,L1 的硬件預取步長較短,加速目標主要為了提升 L2 到 L1,而 L2 和 LLC 的預取步長相對較長,用于將主存提升到 cache。
在這里局部性概念其實充當了軟硬件交互的某種約定,因為程序天然的訪問模式總有一些局部性,硬件廠商就通過預測程序設計的局部性,來盡力加速這種模式的訪問請求,力求做到通用提升性能。而軟件設計師,則通過盡力讓設計呈現更多的局部性,來激活硬件廠商設計的優化路徑,使具體程序性能得到進一步優化。某種意義上講,z 不失為一個相生相伴的循環促進。
這里通過一個樣例來驗證體現一下如何尊重局部性,以及局部性對程序的影響。

// 設計一塊很大的存儲區域用于存儲固定維度的浮點向量 // vecs中存儲每個浮點向量的起始地址 std::vector<float> large_memory_buffer; std::vector<float*> vecs;std::shuffle(vecs.begin(), vecs.end(), random_engine);for (size_t i = 0; i < vecs.size(); ++i) {__builtin_prefetch(vecs[i + step]);dot_product(vecs[i], input); }

這是一個推薦/搜索系統中常見的內積打分場景,即通過向量計算來進行大規模打分。同樣的代碼,依據 shuffle 和 prefetch 存在與否,產生類似如下的表現:

shuffle & no prefetch:44ms shuffle & prefetch:36ms shuffle & no prefetch:13ms shuffle & prefetch:12ms

從 1 和 3 的區別可以看出,完全相同的指令,在不同的訪存順序下存在的性能差距可以達到倍數級。而從 1 和 2 的區別可以看出,手動添加預取操作后,對性能有一定改善,預期更精細地指導預取步長和以及 L1 和 L2 的分布還有改善空間。不過指令執行周期和硬件效率很難完備匹配,手動預取一般用在無法改造成物理連續的場景,但調參往往是一門玄學。最后 3 和 4 可以看出,即使連續訪存下,預取依然有一些有限的收益,推測和硬件預取無法跨越頁邊界造成的多次預測冷啟動有關,但是影響已經比較微弱了。
最具備指導意義的可能就是類似這個內積打分的場景,有時為了節省空間,工程師會將程序設計為,從零散的空間取到向量指針,并組成一個數組或鏈表系統來管理。天然來講,這樣節省了內存的冗余,都引用向一份數據。但是如果引入一些冗余,將所需要的向量數據一同拷貝構成連續空間,對于檢索時的遍歷計算會帶來明顯的性能提升。

4.2 并發訪問優化

提到并發訪問,可能要先從一個概念,緩存行(cache line)說起。
為了避免頻繁的主存交互,其實緩存體系采用了類似 malloc 的方法,即劃分一個最小單元,叫做緩存行(主流 CPU 上一般 64B),所有內存到緩存的操作,以緩存行為單位整塊完成。
例如對于連續訪問來說第一個 B 的訪問就會觸發全部 64B 數據都進入 L1,后續的 63B 訪問就可以直接由 L1 提供服務了。所以并發訪問中的第一個問題就是要考慮緩存行隔離,也就是一般可以認為,位于不同的兩個緩存行的數據,是可以被真正獨立加載/淘汰和轉移的(因為 cache 間流轉的最小單位是一個 cache line)。
典型的問題一般叫做 false share 現象,也就是不慎將兩個本無競爭的數據,放置在一個緩存行內,導致因為體系結構的原因,引入了『本不存在的競爭』。這點在網上資料比較充足,例如 brpc 和 disruptor 的設計文檔都比較詳細地講解了這一點,因此這里就不再做進一步的展開了。

4.3 那先來聊聊緩存一致性

排除了 false share 現象之后,其余就是真正的共享問題了,也就是確實需要位于同一個緩存行內的數據(往往就是同一個數據),多個核心都要修改的場景。由于在多核心系統中 cache 存在多份,因此就需要考慮這多個副本間一致性的問題。這個一致性一般由一套狀態機協議保證(MESI 及其變體)。

大體是,當競爭寫入發生時,需要競爭所有權,未獲得所有權的核心,只能等待同步到修改的最新結果之后,才能繼續自己的修改。這里要提一下的是有個流傳甚廣的說法是,因為緩存系統的引入,帶來了不一致,所以引發了各種多線程可見性問題。
這么說其實有失偏頗,MESI 本質上是一個『一致性』協議,也就是遵守協議的緩存系統,其實對上層 CPU 多個核心做到了順序一致性。比如對比一下就能發現,緩存在競爭時表現出來的處理動作,其實和只有主存時是一致的。

只是阻塞點從競爭一個物理主存單元的寫入,轉移到了雖然是多個緩存物理單元,但是通過協議競爭獨占上。不過正因為競爭阻塞情況并沒有緩解,所以 cache 的引入其實搭配了另一個部件也就是寫緩沖(store buffer)。
注:寫緩存本身引入其實同時收到亂序執行的驅動。

寫緩沖的引入,真正開始帶來的可見性問題。
以 x86 為例,當多核發生寫競爭時,未取得所有權的寫操作雖然無法生效到緩存層,但是可以在改為等待在寫緩沖中。而 CPU 在一般情況下可以避免等待而先開始后續指令的執行,也就是雖然 CPU 看來是先進行了寫指令,后進行讀指令,但是對緩存而言,先進行的是讀指令,而寫指令被阻塞到緩存重新同步之后才能進行。要注意,如果聚焦到緩存交互界面,整體依然是保證了順序一致,但是在指令交互界面,順序發生了顛倒。這就是典型的 StoreLoad 亂序成了 LoadStore,也是 x86 上唯一的一個亂序場景。而針對典型的 RISC 系統來說(arm/power),為了流水線并行度更高,一般不承諾寫緩沖 FIFO,當一個寫操作卡在寫緩沖之后,后續的寫操作也可能被先處理,進一步造成 StoreStore 亂序。
寫緩沖的引入,讓競爭出現后不會立即阻塞指令流,可以容忍直到緩沖寫滿。但因為緩存寫入完成需要周知所有 L1 執行作廢操作完成,隨著核心增多,會出現部分 L1 作廢長尾阻塞寫緩沖的情況。因此一些 RISC 系統引入了進一步的緩沖機制。

進一步的緩沖機制一般叫做失效隊列,也就是當一個寫操作只要將失效消息投遞到每個 L1 的失效隊列即視為完成,失效操作長尾不再影響寫入。這一步改動甚至確實地部分破壞了緩存一致性,也就是除非一個核心等待當前失效消息排空,否則可能讀取到過期數據。
到這里已經可以感受到,為了對大量常規操作進行優化,近代體系結構設計中引入了多個影響一致性的機制。但是為了能夠構建正確的跨線程同步,某些關鍵節點上的一致性又是必不可少的。
因此,配套的功能指令應運而生,例如 x86 下 mfence 用于指導后續 load 等待寫緩沖全部生效,armv8 的 lda 用于確保后續 load 等待 invalid 生效完成等。這一層因為和機型與指令設計強相關,而且指令的配套使用又能帶來多種不同的內存可見性效果。這就大幅增加了工程師編寫正確一致性程序的成本,而且難以保證跨平臺可移植。于是就到了標準化發揮作用的時候了,這個關于內存一致性領域的標準化規范,就是內存序(memory order)。

4.4 再談一談 memory order

作為一種協議機制,內存序和其他協議類似,主要承擔了明確定義接口層功能的作用。體系結構專家從物理層面的優化手段中,抽象總結出了多個不同層級的邏輯一致性等級來進行刻畫表達。這種抽象成為了公用邊界標準之后,硬件和軟件研發者就可以獨立開展各自的優化工作,而最終形成跨平臺通用解決方案。
對于硬件研發者來說,只要能夠最終設計一些特定的指令或指令組合,支持能夠實現這些內存序規范的功能,那么任意的設計擴展原理上都是可行的,不用考慮有軟件兼容性風險。同樣,對于軟件研發者來說,只要按照標準的邏輯層來理解一致性,并使用正確的內存序,就可以不用關注底層平臺細節,寫出跨平臺兼容的多線程程序。
內存序在官方定義里,是洋洋灑灑一大篇內容,為了便于理解,下面從開發程序須知角度,抽出一些簡潔精煉的概念(雖不是理論完備的)來輔助記憶和理解。
首先來看看,內存序背后到底發生了啥。

int payload = 0; int flag = 0; void normal_writer(int i) {payload = flag + i;flag = 1; } int normal_reader() {while (flag == 0) {}return payload; }

在這個樣例中可以看到,在編譯層,默認對于無關指令,會進行一定程度的順序調整(不影響正確性的前提下)。另一方面,編譯器默認可以假定不受其他線程影響,因此同一個數據連續的多次內存訪問可以省略。
下面看一下最基礎的內存序等級,relaxed。

int payload = 0; std::atomic<int> flag {0}; void relaxed_writer(int i) {payload = flag.load(std::memory_order_relaxed) + i;flag.store(1, std::memory_order_relaxed); } int relaxed_reader() {while (flag.load(std::memory_order_relaxed) == 0) {}return payload; }

在使用了基礎的內存序等級 relaxed 之后,編譯器不再假設不受其他線程影響,每個循環都會重新加載 flag。另外可以觀測到 flag 和 payload 的亂序被恢復了,不過原理上 relaxed 并不保證順序,也就是這個順序并不是一個編譯器的保證承諾。總體來說,relaxed 等級和普通的讀寫操作區別不大,只是保證了對應的內存訪問不可省略。
更進一步的內存序等級是 consume-release,不過當前沒有對應的實現案例,一般都被默認提升到了下一個等級,也就是第一個真實有意義的內存序等級 acquire-release。先從原理上講,一般可以按照滿足條件/給出承諾的方式來簡化理解,即:
要求:對同一變量 M 分別進行寫(release)A 和讀(acquire)B,B 讀到了 A 寫入的值。承諾:A 之前的所有其他寫操作,對 B 之后的讀操作可見。實際影響:涉及到的操作不會發生穿越 A/B 操作的重排;X86:無額外指令;ARMv8:A 之前排空 store buffer,B 之后排空 invalid queue,A/B 保序;ARMv7&Power:A 之前全屏障,B 之后全屏障。

int payload = 0; std::atomic<int> flag {0}; void release_writer(int i) {payload = flag.load(std::memory_order_relaxed) + i;flag.store(1, std::memory_order_release); } int acquire_reader() {while (flag.load(std::memory_order_acquire) == 0) {}return payload; }


由于 x86 默認內存序不低于 acquire-release,這里用 ARMv8 匯編來演示效果。可以看出對應指令發生了替換,從 st/ld 變更到了 stl/lda,從而利用 armv8 的體系結構實現了相應的內存序語義。
再進一步的內存序,就是最強的一級 sequentially-consistent,其實就是恢復到了 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 開始,特意優化了 sequentially-consistent 等級,省略了全屏障成本。推測是因為順序一致在 std::atomic 實現中作為默認等級提供,為了通用意義上提升性能做了專門的優化。

4.5 理解 memory order 如何幫助我們

先給出一個基本測試的結論,看一下一組對比數據:
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 內存序下,有一定的收益,但是對 release 內存序,反而有負效果。這是由于 release 內存序下,因為沒有強內存屏障,寫緩沖起到了競爭緩解的作用。而在充分緩解了競爭之后,因為 cache line 隔離引入了相同吞吐下更多 cache line 的傳輸交互,反而開銷變大。

在這個信息指導下,我們在實現無鎖隊列時,采用了循環數組 + 分槽位版本號的模式來實現。因為隊列操作只需要 acquire-release 等級,分槽位版本號間無需采用 cache line 隔離模式設計,整體達到了比較高的并發性能。

原文地址:百度C++工程師的那些極限優化(內存篇)

總結

以上是生活随笔為你收集整理的百度 C++ 工程师的那些极限优化(内存篇)的全部內容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。