百度C++工程師的那些極限優化(內存篇)

導讀:在百度看似簡簡單單的界面後面,是遍及全國的各個數據中內心,運轉着的海量C++服務。如何提高性能,下降延時和成本就成了百度C++工程師的必修功課。伴隨着優化的深刻攻堅,誕生並積累下來一系列的性能優化理論和方案,其中不乏一些冷門但精巧實用的經驗和技巧。本文從內存訪問角度,收集總結了一些具備通用意義的典型案例,分享出來和你們學習交流。html

1  背景

在百度看似簡簡單單的界面後面,是遍及全國的各個數據中內心,運轉着的海量C++服務。對C++的重度應用是百度的一把雙刃劍,學習成本陡峭,指針類錯誤定位難、擴散性廣另不少開發者望而卻步。然而在另外一方面,語言層引入的額外開銷低,對底層能力可操做性強,又可以爲追求極致性能提供優異的實踐環境。前端

所以,對百度的C++工程師來講,掌握底層特性並加以利用來指導應用的性能優化,就成了一門必要並且必須的技能。長此以往,百度工程師就將這種追求極致的性能優化,逐漸沉澱成了習慣,甚至造成了對技術的信仰。下面咱們就來盤點和分享一些,在性能優化的征途上,百度C++工程師積累下來的理論和實踐,以及那些爲了追求極致,所發掘的『奇技淫巧』。c++

2  從新認識性能優化

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

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較大的場景,例如典型的編碼轉換和壓縮等操做,此次冗餘的初始化引入的開銷仍是至關可觀的。github

爲了解決這個問題,大約從3年前開始,已經有人在持續嘗試推進標準改進。算法

http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p1072r7.html編程

注:在這個問題上使用clang + libc++的同窗有福,較新版本的libc++中已經非標實現了resize_default_init功能,能夠開始嚐鮮使用。小程序

在標準落地前,爲了可以在百度內部(目前普遍使用gcc8和gcc10編譯器)提早使用起來,咱們專門製做了適用於gcc的resize_uninitialized,相似於上面的功能,在百度,能夠這樣編碼:後端

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序列化後存入,結果是等價的(並且這兩個特性是被官方承諾的)。

https://developers.google.com/protocol-buffers/docs/encoding#optional

結合這些特性,以前的合併操做在實現上咱們改造爲:

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));
    ...
}

在微服務搭的環境下,相似的操做能夠很好地控制額外成本的增長。

2.2  回頭來再看看性能優化

通常來說,一個程序的性能構成要件大概有三個,即算法複雜度、IO開銷和併發能力。

圖片

首要的影響因素是你們都熟悉的算法複雜度。一次核心算法優化和調整,能夠對應用性能產生的影響甚至是代差級別的。例如LSM Tree對No-SQL吞吐的提高,又例如事件觸發對epoll大併發能力的提高。然而正由於關注度高,在實際工程實現層面,不管是犯錯概率,仍是留下的優化空間,反而會大爲降低。甚至極端狀況下,可能做爲非科研主導的工程師,在進行性能優化時鮮少遇到改良算法的場景,分析問題選擇合適算法會有必定佔比,可是可能範圍也有限。

更多狀況下須要工程師解決的性能問題,借用一句算法競賽用語,用『卡常數』來形容可能更貼切。也就是採用了正確的合適的算法,可是由於沒有采用體系結構下更優的實現方案,致使在O(X)上附加了過大的常數項,進而形成的總體性能不足。雖然在算法競賽中,卡常數和常數優化是出題人和解題人都不肯意大量出現的干擾項(由於畢竟是以核心算法自己爲目標),可是轉換到實際項目背景下,常數優化卻每每是性能優化領域的重要手段。

如今咱們再來回顧一下上面引出問題的三個優化案例。能夠看到,其中都不包含算法邏輯自己的改進,可是經過深刻利用底層(好比依賴庫或指令集)特性,依然可以取得倍數甚至數量級的優化效果。這和近些年體系結構變得愈加複雜有很大關聯,而這些變化,典型的體現場景就是IO和併發。併發的優化,對於工程經驗比較豐富的同窗應該已經不陌生了,可是關於IO,尤爲是『內存IO』可能須要特別說明一下。

與代碼中顯示寫出的read/write/socket等設備IO操做不一樣,存儲系統的IO很容易被忽略,由於這些IO透明地發生在普通CPU指令的背後。先列舉2009年Jeff Dean的一個經典講座中的一頁數字。

https://www.cs.cornell.edu/projects/ladis2009/talks/dean-keynote-ladis2009.pdf

圖片

雖然已是十多年前的數據,可是依然能夠看出,最快速的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);
        // 經過轉換到具體實現來取得MemoryResource
        auto\* 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<string>爲例解釋一下:

圖片

和典型的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存在與否,產生相似以下的表現:

  1. shuffle & no prefetch:44ms

  2. shuffle & prefetch:36ms

  3. shuffle & no prefetch:13ms

  4. 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以後的讀操做可見。

  • 實際影響:

  1. 涉及到的操做不會發生穿越A/B操做的重排;

  2. X86:無額外指令;

  3. ARMv8:A以前排空store buffer,B以後排空invalid queue,A/B保序;

  4. 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。

  • 實際影響:

  1. X86:Am和An以後清空store buffer,讀操做B無額外指令;

  2. ARMv8:Am和An以前排空store buffer, B以後排空invalid queue,A/B保序;

  3. ARMv7:Am和An先後全屏障,B以後全屏障;

  4. 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隔離模式設計,總體達到了比較高的併發性能。

圖片

閱讀原文 https://mp.weixin.qq.com/s/wF4M2pqlVq7KljaHAruRug

推薦閱讀

前端工程化之H5性能優化篇

趣談哈希表優化:從規避 Hash 衝突到利⽤ Hash 衝突

百度智能小程序框架性能優化實踐

----------  END  ----------

百度Geek說

百度官方技術公衆號上線啦!

技術乾貨 · 行業資訊 · 線上沙龍 · 行業大會

招聘信息 · 內推信息 · 技術書籍 · 百度周邊

歡迎各位同窗關注!

相關文章
相關標籤/搜索