【編者按】想象一下,你編寫了一個(gè)處理并行問題的程序,每個(gè)線程都獨(dú)立執(zhí)行其被分配的任務(wù),除了在最后匯總結(jié)果外, 線程之間不需要協(xié)同。顯然,你會認(rèn)為如果將該程序在更多核心上運(yùn)行,運(yùn)行速度會更快。你首先在筆記本電腦上進(jìn)行基準(zhǔn)測試,發(fā)現(xiàn)它幾乎能完美地利用所有的 4 個(gè)可用核心。然后你在更多核服務(wù)器上運(yùn)行該程序,期待有更好的性能表現(xiàn),卻發(fā)現(xiàn)實(shí)際上比筆記本運(yùn)行的還慢。太不可思議了!
原文鏈接:https://pkolaczk.Github.io/server-slower-than-a-laptop/
作者 | pkolaczk
譯者 | 明明如月 責(zé)編 | 夏萌
出品 | CSDN(ID:CSDNnews)
我最近一直在改進(jìn)一款 Cassandra 基準(zhǔn)測試工具 Latte ,這可能是你能找到的 CPU 使用和內(nèi)存使用都最高效的 Cassandra 基準(zhǔn)測試工具。設(shè)計(jì)思路非常簡單:編寫一小部分代碼生成數(shù)據(jù),并且執(zhí)行一系列異步的 CQL語句向 Cassandra 發(fā)起請求。Latte 在循環(huán)中調(diào)用這段代碼,并記錄每次迭代花費(fèi)的時(shí)間。最后,進(jìn)行統(tǒng)計(jì)分析,并通過各種形式展示結(jié)果。
基準(zhǔn)測試非常適合并行化。只要被測試的代碼是無狀態(tài)的,就很容易使用多個(gè)線程調(diào)用。我已經(jīng)在《Benchmarking Apache Cassandra with Rust》 和《Scalable Benchmarking with Rust Streams》 中討論過如何在 Rust 中實(shí)現(xiàn)此功能。
然而,當(dāng)我寫這些早期的博客文章時(shí),Latte 幾乎不支持定義工作負(fù)載,或者說它的能力非常有限。它只內(nèi)置兩個(gè)預(yù)設(shè)的工作負(fù)載,一個(gè)用于讀取數(shù)據(jù),另一個(gè)用于寫入數(shù)據(jù)。你只能調(diào)整一些參數(shù),比如列的數(shù)量和大小,沒有什么高級的特性。它不支持二級索引,也無法自定義過濾條件。對于 CQL(Cassandra Query Language)文本的控制也受到限制。總而言之,它幾乎沒有任何過人之處。因此,在那個(gè)時(shí)候,Latte 更像是一個(gè)用于驗(yàn)證概念的工具,而不是一個(gè)真正可用于實(shí)際工作的通用工具。當(dāng)然,你可以 fork Latte 的源代碼,并使用 Rust 編寫新的工作負(fù)載,然后重新編譯。但誰想浪費(fèi)時(shí)間去學(xué)習(xí)一個(gè)小眾基準(zhǔn)測試工具的內(nèi)部實(shí)現(xiàn)呢?
Rune 腳本
去年,為了能夠測量 Cassandra 使用存儲索引的性能,我決定將 Latte 與一個(gè)腳本引擎進(jìn)行集成,這個(gè)引擎可以讓我輕松地定義工作負(fù)載,而無需重新編譯整個(gè)程序。在嘗試將 CQL 語句嵌入 TOML 配置文件(效果非常不理想)后,我也嘗試過在 Rust 中嵌入 Lua (在 C 語言中可能很好用,但在與 Rust 配合使用時(shí),并不如我預(yù)期的那樣順暢,盡管勉強(qiáng)能用)。最終,我選擇了一個(gè)類似于 sysbench 的設(shè)計(jì),但使用了嵌入式的 Rune 解釋器代替 Lua。
說服我采用 Rune 的主要優(yōu)勢是和 Rust 無縫集成以及支持異步代碼。由于支持異步,用戶可以直接在工作負(fù)載腳本中執(zhí)行 CQL 語句,利用 Cassandra 驅(qū)動程序的異步性。此外,Rune 團(tuán)隊(duì)極其樂于助人,短時(shí)間內(nèi)幫我掃清了所有障礙。
以下是一個(gè)完整的工作負(fù)載示例,用于測量通過隨機(jī)鍵選擇行時(shí)的性能:
constROW_COUNT = latte::param!( "rows", 100000);constKEYSPACE = "latte"; constTABLE = "basic";
pub asyncfn schema(ctx) { ctx.execute( `CREATE KEYSPACE IF NOT EXISTS ${KEYSPACE} WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }`).awAIt?; ctx.execute( `CREATE TABLE IF NOT EXISTS ${KEYSPACE}. ${TABLE}(id bigint PRIMARY KEY)` ).await?; }
pub asyncfn erase(ctx) { ctx.execute( `TRUNCATE TABLE ${KEYSPACE}. ${TABLE}` ).await?; }
pub asyncfn prepare(ctx) { ctx.load_cycle_count = ROW_COUNT;ctx.prepare( "insert", `INSERT INTO ${KEYSPACE}. ${TABLE}(id) VALUES (:id)` ).await?; ctx.prepare( "select", `SELECT * FROM ${KEYSPACE}. ${TABLE}WHERE id = :id` ).await?; }
pub asyncfn load(ctx, i) { ctx.execute_prepared( "insert", [i]).await?; }
pub asyncfn run(ctx, i) { ctx.execute_prepared( "select", [latte::hash(i) % ROW_COUNT]).await?; }
如果你想進(jìn)一步了解如何編寫該腳本可以參考:README。
對基準(zhǔn)測試程序進(jìn)行基準(zhǔn)測試
盡管腳本尚未編譯為本機(jī)代碼,但速度已可接受,而且由于它們通常包含的代碼量有限,所以在性能分析的頂部并不會顯示這些腳本。我通過實(shí)證發(fā)現(xiàn),Rust-Rune FFI 的開銷低于由 mlua 提供的Rust-Lua,這可能是由于mlua使用的安全檢查。
一開始, 為了評估基準(zhǔn)測試循環(huán)的性能,我創(chuàng)建了一個(gè)空的腳本:
pub async fn run(ctx, i) {}盡管函數(shù)體為空, 但基準(zhǔn)測試程序仍需要做一些工作來真正運(yùn)行它:
- 使用buffer_unordered調(diào)度 N 個(gè)并行的異步調(diào)用
- 為 Rune VM 設(shè)置新的本地狀態(tài)(例如,棧)
- 從 Rust 一側(cè)傳入?yún)?shù)調(diào)用 Rune 函數(shù)
- 衡量每一個(gè)返回的future完成所花費(fèi)的時(shí)間
- 收集日志,更新 HDR 直方圖并計(jì)算其他統(tǒng)計(jì)數(shù)據(jù)
- 使用 Tokio 線程調(diào)度器在 M 個(gè)線程上運(yùn)行代碼
我老舊的 4 核 Intel Xeon E3-1505M v6鎖定在3GHz上,結(jié)果看起來還不錯(cuò):
因?yàn)橛?4 個(gè)核心,所以直到 4 個(gè)線程,吞吐量隨著線程數(shù)的增加線性增長。然后,由于超線程技術(shù)使每個(gè)核心中可以再擠出一點(diǎn)性能,所以在 8 個(gè)線程時(shí),吞吐量略有增加。顯然,在 8 個(gè)線程之后,性能沒有任何提升,因?yàn)榇藭r(shí)所有的 CPU 資源都已經(jīng)飽和。
我對獲取的絕對數(shù)值感到滿意。幾百萬個(gè)空調(diào)用在筆記本上每秒聽起來像基準(zhǔn)測試循環(huán)足夠輕量,不會在真實(shí)測量中造成重大開銷。同一筆記本上,如果請求足夠簡單且所有數(shù)據(jù)都在內(nèi)存中,本地 Cassandra 服務(wù)器在全負(fù)載情況下每秒只能做大約 2 萬個(gè)請求。當(dāng)我在函數(shù)體中添加了一些實(shí)際的數(shù)據(jù)生成代碼,但沒有對數(shù)據(jù)庫進(jìn)行調(diào)用時(shí),一如預(yù)期性能變慢,但不超過 2 倍,仍在 "百萬 OPS"范圍。
我本可以在這里停下來,宣布勝利。然而,我很好奇,如果在一臺擁有更多核心的大型服務(wù)器上運(yùn)行,它能跑多快。
在 24核上運(yùn)行空循環(huán)
一臺配備兩個(gè) Intel Xeon CPU E5-2650L v3 處理器的服務(wù)器,每個(gè)處理器有 12 個(gè)運(yùn)行在 1.8GHz 的內(nèi)核,顯然應(yīng)該比一臺舊的 4 核筆記本電腦快得多,對吧?可能單線程會慢一些,因?yàn)?CPU 主頻更低(3 GHz vs 1.8 GHz),但是它應(yīng)該可以通過更多的核心來彌補(bǔ)這一點(diǎn)。
用數(shù)字說話:
你肯定也發(fā)現(xiàn)了這里不太對勁。兩個(gè)只是線程比一個(gè)線程好一些而已,隨著線程的增加吞吐量增加有限,甚至開始降低。我無法獲得比每秒約 200 萬次調(diào)用更高的吞吐量,這比我在筆記本上得到的吞吐量差了近 4 倍。要么這臺服務(wù)器有問題,要么我的程序有嚴(yán)重的可擴(kuò)展性問題。
查問題
當(dāng)你遇到性能問題時(shí),最常見的調(diào)查方法是在分析器下運(yùn)行代碼。在 Rust 中,使用cargo flamegraph生成火焰圖非常容易。讓我們比較在 1 個(gè)線程和 12 個(gè)線程下運(yùn)行基準(zhǔn)測試時(shí)收集的火焰圖:
我原本期望找到一個(gè)瓶頸,例如競爭激烈的互斥鎖或類似的東西,但令我驚訝的是,我沒有發(fā)現(xiàn)明顯的問題。甚至連一個(gè)瓶頸都沒有!Rune的VM::run代碼似乎占用了大約 1/3 的時(shí)間,但剩下的時(shí)間主要花在了輪詢 futures上,最有可能的罪魁禍?zhǔn)卓赡芤呀?jīng)被內(nèi)聯(lián)了,從而在分析中消失。
無論如何,由于VM::run和通往 Rune 的路徑rune::shared::assert_send::AssertSend,我決定禁用調(diào)用 Rune 函數(shù)的代碼,并且我只是在一個(gè)循環(huán)中運(yùn)行一個(gè)空的 future,重新進(jìn)行了實(shí)驗(yàn),盡管仍然啟用了計(jì)時(shí)和統(tǒng)計(jì)代碼:
// Executes a single iteration of a workload.// This should be idempotent –// the generated action should be a function of the iteration number.// Returns the end time of the query.pub async fn run(& self, iteration: i64) -> Result< Instant, LatteError> { letstart_time = Instant::now; letsession = SessionRef::new(& self.session); // let result = self// .program// .async_call(self.function, (session, iteration))// .await// .map(|_| ); // erase Value, because Value is !Sendletend_time = Instant::now; letmut state = self.state.try_lock.unwrap; state.fn_stats.operation_completed(end_time - start_time);// ... Ok(end_time) }在 48 個(gè)線程上,每秒超過 1 億次調(diào)用的擴(kuò)展表現(xiàn)良好!所以問題一定出現(xiàn)在Program::async_call函數(shù)下面的某個(gè)地方:
// Compiled workload programpub struct Program {sources: Sources,context: Arc<RuntimeContext>, unit: Arc< Unit>, }// Executes given async function with args.// If execution fails, emits diagnostic messages, e.g. stacktrace to standard error stream.// Also signals an error if the function execution succeeds, but the function returns// an error value. pub async fn async_call(&self,fun: FnRef, args: impl Args + Send,) -> Result<Value, LatteError> {let handle_err = |e: VmError| {let mut out= StandardStream::stderr(ColorChoice::Auto); let _ = e.emit(&mut out, &self.sources); LatteError::ExecError( fun.name, e) };let execution = self.vm.send_execute( fun.hash, args). map_err(handle_err)?; let result = execution.async_complete.await.map_err(handle_err)?;self.convert_error( fun.name, result) }
// Initializes a fresh virtual machine needed to execute this program.// This is extremely lightweight.fn vm(&self) -> Vm {Vm::new(self.context.clone, self.unit.clone)}
async_call函數(shù)做了幾件事:
- 它準(zhǔn)備了一個(gè)新的 Rune VM - 這應(yīng)當(dāng)是一個(gè)非常輕量級的操作,基本上是準(zhǔn)備一個(gè)新的堆棧;VM 并沒有在調(diào)用或線程之間共享,所以它們可以完全獨(dú)立地運(yùn)行
- 它通過傳入標(biāo)識符和參數(shù)來調(diào)用函數(shù)
- 最后,它接收結(jié)果并轉(zhuǎn)換一些錯(cuò)誤;我們可以安全地假定在一個(gè)空的基準(zhǔn)測試中,這是空操作 (no-op)
我的下一個(gè)想法是只移除 send_execute和 async_complete調(diào)用,只留下 VM 的準(zhǔn)備。所以我想對這行代碼進(jìn)行基準(zhǔn)測試:
代碼看起來相當(dāng)無辜。這里沒有鎖,沒有互斥鎖,沒有系統(tǒng)調(diào)用,也沒有共享的可變數(shù)據(jù)。有一些只讀的結(jié)構(gòu) context和 unit通過 Arc共享,但只讀共享應(yīng)該不會有問題。
VM::new也很簡單:
impl Vm {// Construct a new virtual machine.pub constfn new(context: Arc<RuntimeContext>, unit: Arc<Unit>) -> Self{ Self::with_stack(context, unit, Stack::new) }
// Construct a new virtual machine with a custom stack.pub constfn with_stack(context: Arc<RuntimeContext>, unit: Arc<Unit>, stack: Stack) -> Self{ Self{ context,unit,ip: 0, stack,call_frames: vec::Vec::new,}}
然而,無論代碼看起來多么無辜,我都喜歡對我的假設(shè)進(jìn)行雙重檢查。我使用不同數(shù)量的線程運(yùn)行了那段代碼,盡管現(xiàn)在比以前更快了,但它依然沒有任何擴(kuò)展性 - 它達(dá)到了大約每秒 400 萬次調(diào)用的吞吐量上限!
問題
雖然從上述代碼中看不出有任何可變的數(shù)據(jù)共享,但實(shí)際上有一些稍微隱蔽的東西被共享和修改了:即 Arc引用計(jì)數(shù)器本身。那些計(jì)數(shù)器是所有調(diào)用共享的,它們來自多線程,正是它們造成了阻塞。
一些人會說,在多線程下原子的增加或減少共享的原子計(jì)數(shù)器不應(yīng)該有問題,因?yàn)檫@些是"無鎖"的操作。它們甚至可以翻譯為單條匯編指令(如 lock xadd)! 如果某事物是一個(gè)單條匯編指令,它不是很慢嗎?不幸的是這個(gè)推理有問題。
問題的根源其實(shí)不在于計(jì)算本身,而在于維護(hù)共享狀態(tài)的代價(jià)。
讀取或?qū)懭霐?shù)據(jù)需要的時(shí)間主要受 CPU 核心和需要訪問數(shù)據(jù)的遠(yuǎn)近影響。根據(jù) 這個(gè)網(wǎng)站,Intel Haswell Xeon CPUs 的標(biāo)準(zhǔn)延遲如下:
- L1緩存:4個(gè)周期
- L2緩存:12個(gè)周期
- L3緩存:43個(gè)周期
- RAM:62個(gè)周期 + 100 ns
L1 和 L2 緩存通常屬于一個(gè)核心(L2 可能由兩個(gè)核心共享)。L3 緩存由一個(gè) CPU 的所有核心共享。主板上不同處理器的 L3 緩存之間還有直接的互連,用于管理 L3 緩存的一致性,所以 L3 在邏輯上是被所有處理器共享的。
只要你不更新緩存行并且只從多個(gè)線程中讀取該行,多個(gè)核心會加載該行并標(biāo)記為共享。頻繁訪問這樣的數(shù)據(jù)可能來自 L1 緩存, 非常快。所以只讀共享數(shù)據(jù)完全沒問題,并具有很好的擴(kuò)展性。即使只使用原子操作也足夠快。
然而,一旦我們對共享緩存行進(jìn)行更新,事情就開始變得復(fù)雜。x86-amd64 架構(gòu)有一致性的數(shù)據(jù)緩存。這基本上意味著,你在一個(gè)核心上寫入的內(nèi)容,你可以在另一個(gè)核心上讀回。多個(gè)核心存儲有沖突數(shù)據(jù)的緩存行是不可能的。一旦一個(gè)線程決定更新一個(gè)共享的緩存行,那么在所有其他核心上的該行就會失效,因此那些核心上的后續(xù)加載將不得不從至少L3中獲取數(shù)據(jù)。這顯然要慢得多,而且如果主板上有多個(gè)處理器則更慢。
我們的引用計(jì)數(shù)器是原子的,這讓事情變得更加復(fù)雜。盡管使用原子指令常常被稱為“無鎖編程”,但這有點(diǎn)誤導(dǎo)性——實(shí)際上,原子操作需要在硬件級別進(jìn)行一些鎖定。只要沒有阻塞這個(gè)鎖非常細(xì)粒度且廉價(jià),但與鎖定一樣, 如果很多事物同時(shí)爭奪同一個(gè)鎖,性能就會下降。如果需要爭奪同一個(gè)鎖的不僅僅是相鄰的單個(gè)核心,而是涉及到整個(gè)CPU,通信和同步的開銷更大,而且可能存在更多的競爭條件,情況會更加糟糕。
解決方法
解決方案是避免 共享 引用計(jì)數(shù)器。Latte 有一個(gè)非常簡單的分層生命周期結(jié)構(gòu),所以所有的 Arc更新讓我覺得有些多余,它們可以用更簡單的引用和 Rust 生命周期來代替。然而,說起來容易做起來難。不幸的是,Rune 需要將對 Unit和 RuntimeContext的引用包裝在 Arc中來管理生命周期(可能在更復(fù)雜的場景中),并且它還在這些結(jié)構(gòu)的一部分中使用一些Arc包裝的值。僅僅為了我的小用例來重寫 Rune 是不切實(shí)際的。
因此,Arc必須保留。我們不使用單個(gè) Arc值,而是每個(gè)線程使用一個(gè) Arc。這也需要分離Unit和 RuntimeContext的值,這樣每個(gè)線程都會得到它們自己的。作為一個(gè)副作用,這確保了完全沒有任何共享,所以即使 Rune 克隆了一個(gè)作為那些值的一部分內(nèi)部存儲的Arc,這個(gè)問題也會解決。這種解決方案的缺點(diǎn)是內(nèi)存使用更高。幸運(yùn)的是,Latte 的工作負(fù)載腳本通常很小,所以內(nèi)存使用增加可能不是一個(gè)大問題。
為了能夠使用獨(dú)立的Unit和RuntimeContext,我提交了一個(gè) 補(bǔ)丁 給 Rune,使它們可Clone。然后,在 Latte 這邊,整個(gè)修復(fù)實(shí)際上是引入了一個(gè)新的函數(shù)用于 "深度" 克隆Program結(jié)構(gòu),然后確保每個(gè)線程都獲取它自己的副本:
// Makes a deep copy of context and unit.// Calling this method instead of `clone` ensures that Rune runtime structures// are separate and can be moved to different CPU cores efficiently without accidental// sharing of Arc references.fn unshare(& self) -> Program { Program {sources: self.sources. clone, context: Arc::new( self.context.as_ref. clone), // clones the value under Arc and wraps it in a new counterunit: Arc::new( self.unit.as_ref. clone), // clones the value under Arc and wraps it in a new counter}}順便說一下:sources 字段在執(zhí)行過程中除了用于發(fā)出診斷信息并未被使用,所以它可以保持共享。
注意,我最初發(fā)現(xiàn)性能下降的那一行代碼并不需要任何改動!
Vm::new( self.context. clone, self.unit. clone)
這是因?yàn)?nbsp;self.context和 self.unit不再在線程之間共享。幸運(yùn)的是頻繁更新非共享計(jì)數(shù)器通常很快。
最終結(jié)果
現(xiàn)在吞吐量按符合預(yù)期,從 1 到 24 個(gè)線程吞吐量線性增大: