天天看點

一行代碼,為何使 24 核伺服器比筆記本還慢

作者:CSDN

【編者按】想象一下,你編寫了一個處理并行問題的程式,每個線程都獨立執行其被配置設定的任務,除了在最後彙總結果外, 線程之間不需要協同。顯然,你會認為如果将該程式在更多核心上運作,運作速度會更快。你首先在筆記本電腦上進行基準測試,發現它幾乎能完美地利用所有的 4 個可用核心。然後你在更多核伺服器上運作該程式,期待有更好的性能表現,卻發現實際上比筆記本運作的還慢。太不可思議了!

原文連結:https://pkolaczk.github.io/server-slower-than-a-laptop/

未經允許,禁止轉載!

作者 | pkolaczk 譯者 | 明明如月 責編 | 夏萌

出品 | CSDN(ID:CSDNnews)

我最近一直在改進一款 Cassandra 基準測試工具 Latte ,這可能是你能找到的 CPU 使用和記憶體使用都最高效的 Cassandra 基準測試工具。設計思路非常簡單:編寫一小部分代碼生成資料,并且執行一系列異步的 CQL語句向 Cassandra 發起請求。Latte 在循環中調用這段代碼,并記錄每次疊代花費的時間。最後,進行統計分析,并通過各種形式展示結果。

基準測試非常适合并行化。隻要被測試的代碼是無狀态的,就很容易使用多個線程調用。我已經在《Benchmarking Apache Cassandra with Rust》和《Scalable Benchmarking with Rust Streams》中讨論過如何在 Rust 中實作此功能。

然而,當我寫這些早期的部落格文章時,Latte 幾乎不支援定義工作負載,或者說它的能力非常有限。它隻内置兩個預設的工作負載,一個用于讀取資料,另一個用于寫入資料。你隻能調整一些參數,比如列的數量和大小,沒有什麼進階的特性。它不支援二級索引,也無法自定義過濾條件。對于 CQL(Cassandra Query Language)文本的控制也受到限制。總而言之,它幾乎沒有任何過人之處。是以,在那個時候,Latte 更像是一個用于驗證概念的工具,而不是一個真正可用于實際工作的通用工具。當然,你可以 fork Latte 的源代碼,并使用 Rust 編寫新的工作負載,然後重新編譯。但誰想浪費時間去學習一個小衆基準測試工具的内部實作呢?

一行代碼,為何使 24 核伺服器比筆記本還慢

Rune 腳本

去年,為了能夠測量 Cassandra 使用存儲索引的性能,我決定将 Latte 與一個腳本引擎進行內建,這個引擎可以讓我輕松地定義工作負載,而無需重新編譯整個程式。在嘗試将 CQL 語句嵌入 TOML 配置檔案(效果非常不理想)後,我也嘗試過在 Rust 中嵌入 Lua (在 C 語言中可能很好用,但在與 Rust 配合使用時,并不如我預期的那樣順暢,盡管勉強能用)。最終,我選擇了一個類似于 sysbench 的設計,但使用了嵌入式的 Rune 解釋器代替 Lua。

說服我采用 Rune 的主要優勢是和 Rust 無縫內建以及支援異步代碼。由于支援異步,使用者可以直接在工作負載腳本中執行 CQL 語句,利用 Cassandra 驅動程式的異步性。此外,Rune 團隊極其樂于助人,短時間内幫我掃清了所有障礙。

以下是一個完整的工作負載示例,用于測量通過随機鍵選擇行時的性能:

const ROW_COUNT = latte::param!("rows", 100000);                  const KEYSPACE = "latte";              const TABLE = "basic";                  pub async fn 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 async fn erase(ctx) {              ctx.execute(`TRUNCATE TABLE ${KEYSPACE}.${TABLE}`).await?;              }                  pub async fn 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 async fn load(ctx, i) {              ctx.execute_prepared("insert", [i]).await?;              }                  pub async fn run(ctx, i) {              ctx.execute_prepared("select", [latte::hash(i) % ROW_COUNT]).await?;              }           

如果你想進一步了解如何編寫該腳本可以參考:README。

一行代碼,為何使 24 核伺服器比筆記本還慢

對基準測試程式進行基準測試

盡管腳本尚未編譯為本機代碼,但速度已可接受,而且由于它們通常包含的代碼量有限,是以在性能分析的頂部并不會顯示這些腳本。我通過實證發現,Rust-Rune FFI 的開銷低于由 mlua 提供的Rust-Lua,這可能是由于mlua使用的安全檢查。

一開始, 為了評估基準測試循環的性能,我建立了一個空的腳本:

pub async fn run(ctx, i) {              }           

盡管函數體為空, 但基準測試程式仍需要做一些工作來真正運作它:

  • 使用buffer_unordered排程 N 個并行的異步調用
  • 為 Rune VM 設定新的本地狀态(例如,棧)
  • 從 Rust 一側傳入參數調用 Rune 函數
  • 衡量每一個傳回的future完成所花費的時間
  • 收集日志,更新 HDR 直方圖并計算其他統計資料
  • 使用 Tokio 線程排程器在 M 個線程上運作代碼

我老舊的 4 核 Intel Xeon E3-1505M v6鎖定在3GHz上,結果看起來還不錯:

一行代碼,為何使 24 核伺服器比筆記本還慢

因為有 4 個核心,是以直到 4 個線程,吞吐量随着線程數的增加線性增長。然後,由于超線程技術使每個核心中可以再擠出一點性能,是以在 8 個線程時,吞吐量略有增加。顯然,在 8 個線程之後,性能沒有任何提升,因為此時所有的 CPU 資源都已經飽和。

我對擷取的絕對數值感到滿意。幾百萬個空調用在筆記本上每秒聽起來像基準測試循環足夠輕量,不會在真實測量中造成重大開銷。同一筆記本上,如果請求足夠簡單且所有資料都在記憶體中,本地 Cassandra 伺服器在全負載情況下每秒隻能做大約 2 萬個請求。當我在函數體中添加了一些實際的資料生成代碼,但沒有對資料庫進行調用時,一如預期性能變慢,但不超過 2 倍,仍在 "百萬 OPS"範圍。

我本可以在這裡停下來,宣布勝利。然而,我很好奇,如果在一台擁有更多核心的大型伺服器上運作,它能跑多快。

一行代碼,為何使 24 核伺服器比筆記本還慢

在 24核上運作空循環

一台配備兩個 Intel Xeon CPU E5-2650L v3 處理器的伺服器,每個處理器有 12 個運作在 1.8GHz 的核心,顯然應該比一台舊的 4 核筆記本電腦快得多,對吧?可能單線程會慢一些,因為 CPU 主頻更低(3 GHz vs 1.8 GHz),但是它應該可以通過更多的核心來彌補這一點。

用數字說話:

一行代碼,為何使 24 核伺服器比筆記本還慢

你肯定也發現了這裡不太對勁。兩個隻是線程比一個線程好一些而已,随着線程的增加吞吐量增加有限,甚至開始降低。我無法獲得比每秒約 200 萬次調用更高的吞吐量,這比我在筆記本上得到的吞吐量差了近 4 倍。要麼這台伺服器有問題,要麼我的程式有嚴重的可擴充性問題。

一行代碼,為何使 24 核伺服器比筆記本還慢

查問題

當你遇到性能問題時,最常見的調查方法是在分析器下運作代碼。在 Rust 中,使用cargo flamegraph生成火焰圖非常容易。讓我們比較在 1 個線程和 12 個線程下運作基準測試時收集的火焰圖:

我原本期望找到一個瓶頸,例如競争激烈的互斥鎖或類似的東西,但令我驚訝的是,我沒有發現明顯的問題。甚至連一個瓶頸都沒有!Rune的VM::run代碼似乎占用了大約 1/3 的時間,但剩下的時間主要花在了輪詢 futures上,最有可能的罪魁禍首可能已經被内聯了,進而在分析中消失。

無論如何,由于VM::run和通往 Rune 的路徑rune::shared::assert_send::AssertSend,我決定禁用調用 Rune 函數的代碼,并且我隻是在一個循環中運作一個空的 future,重新進行了實驗,盡管仍然啟用了計時和統計代碼:

// 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> {              let start_time = Instant::now();              let session = SessionRef::new(&self.session);              // let result = self              // .program              // .async_call(self.function, (session, iteration))              // .await              // .map(|_| ()); // erase Value, because Value is !Send              let end_time = Instant::now();              let mut state = self.state.try_lock().unwrap();              state.fn_stats.operation_completed(end_time - start_time);              // ...               Ok(end_time)               }           

在 48 個線程上,每秒超過 1 億次調用的擴充表現良好!是以問題一定出現在Program::async_call函數下面的某個地方:

// Compiled workload program              pub 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::ScriptExecError(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函數做了幾件事:

  • 它準備了一個新的 Rune VM - 這應當是一個非常輕量級的操作,基本上是準備一個新的堆棧;VM 并沒有在調用或線程之間共享,是以它們可以完全獨立地運作
  • 它通過傳入辨別符和參數來調用函數
  • 最後,它接收結果并轉換一些錯誤;我們可以安全地假定在一個空的基準測試中,這是空操作 (no-op)

我的下一個想法是隻移除 send_execute和 async_complete調用,隻留下 VM 的準備。是以我想對這行代碼進行基準測試:

Vm::new(self.context.clone(), self.unit.clone())           

代碼看起來相當無辜。這裡沒有鎖,沒有互斥鎖,沒有系統調用,也沒有共享的可變資料。有一些隻讀的結構 context和 unit通過 Arc共享,但隻讀共享應該不會有問題。

VM::new也很簡單:

impl Vm {                  // Construct a new virtual machine.              pub const fn 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 const fn with_stack(context: Arc<RuntimeContext>, unit: Arc<Unit>, stack: Stack) -> Self {              Self {              context,              unit,              ip: 0,              stack,              call_frames: vec::Vec::new(),              }              }           

然而,無論代碼看起來多麼無辜,我都喜歡對我的假設進行雙重檢查。我使用不同數量的線程運作了那段代碼,盡管現在比以前更快了,但它依然沒有任何擴充性 - 它達到了大約每秒 400 萬次調用的吞吐量上限!

一行代碼,為何使 24 核伺服器比筆記本還慢

問題

雖然從上述代碼中看不出有任何可變的資料共享,但實際上有一些稍微隐蔽的東西被共享和修改了:即 Arc引用計數器本身。那些計數器是所有調用共享的,它們來自多線程,正是它們造成了阻塞。

一些人會說,在多線程下原子的增加或減少共享的原子計數器不應該有問題,因為這些是"無鎖"的操作。它們甚至可以翻譯為單條彙編指令(如 lock xadd)! 如果某事物是一個單條彙編指令,它不是很慢嗎?不幸的是這個推理有問題。

問題的根源其實不在于計算本身,而在于維護共享狀态的代價。

讀取或寫入資料需要的時間主要受 CPU 核心和需要通路資料的遠近影響。根據 這個網站,Intel Haswell Xeon CPUs 的标準延遲如下:

  • L1緩存:4個周期
  • L2緩存:12個周期
  • L3緩存:43個周期
  • RAM:62個周期 + 100 ns

L1 和 L2 緩存通常屬于一個核心(L2 可能由兩個核心共享)。L3 緩存由一個 CPU 的所有核心共享。主機闆上不同處理器的 L3 緩存之間還有直接的互連,用于管理 L3 緩存的一緻性,是以 L3 在邏輯上是被所有處理器共享的。

隻要你不更新緩存行并且隻從多個線程中讀取該行,多個核心會加載該行并标記為共享。頻繁通路這樣的資料可能來自 L1 緩存, 非常快。是以隻讀共享資料完全沒問題,并具有很好的擴充性。即使隻使用原子操作也足夠快。

然而,一旦我們對共享緩存行進行更新,事情就開始變得複雜。x86-amd64 架構有一緻性的資料緩存。這基本上意味着,你在一個核心上寫入的内容,你可以在另一個核心上讀回。多個核心存儲有沖突資料的緩存行是不可能的。一旦一個線程決定更新一個共享的緩存行,那麼在所有其他核心上的該行就會失效,是以那些核心上的後續加載将不得不從至少L3中擷取資料。這顯然要慢得多,而且如果主機闆上有多個處理器則更慢。

我們的引用計數器是原子的,這讓事情變得更加複雜。盡管使用原子指令常常被稱為“無鎖程式設計”,但這有點誤導性——實際上,原子操作需要在硬體級别進行一些鎖定。隻要沒有阻塞這個鎖非常細粒度且廉價,但與鎖定一樣, 如果很多事物同時争奪同一個鎖,性能就會下降。如果需要争奪同一個鎖的不僅僅是相鄰的單個核心,而是涉及到整個CPU,通信和同步的開銷更大,而且可能存在更多的競争條件,情況會更加糟糕。

一行代碼,為何使 24 核伺服器比筆記本還慢

解決方法

解決方案是避免 共享 引用計數器。Latte 有一個非常簡單的分層生命周期結構,是以所有的 Arc 更新讓我覺得有些多餘,它們可以用更簡單的引用和 Rust 生命周期來代替。然而,說起來容易做起來難。不幸的是,Rune 需要将對 Unit 和 RuntimeContext 的引用包裝在 Arc 中來管理生命周期(可能在更複雜的場景中),并且它還在這些結構的一部分中使用一些 Arc 包裝的值。僅僅為了我的小用例來重寫 Rune 是不切實際的。

是以,Arc 必須保留。我們不使用單個 Arc 值,而是每個線程使用一個 Arc。這也需要分離 Unit 和 RuntimeContext 的值,這樣每個線程都會得到它們自己的。作為一個副作用,這確定了完全沒有任何共享,是以即使 Rune 克隆了一個作為那些值的一部分内部存儲的 Arc,這個問題也會解決。這種解決方案的缺點是記憶體使用更高。幸運的是,Latte 的工作負載腳本通常很小,是以記憶體使用增加可能不是一個大問題。

為了能夠使用獨立的 Unit 和 RuntimeContext,我送出了一個 更新檔 給 Rune,使它們可 Clone。然後,在 Latte 這邊,整個修複實際上是引入了一個新的函數用于 "深度" 克隆 Program 結構,然後確定每個線程都擷取它自己的副本:

// 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 counter              unit: Arc::new(self.unit.as_ref().clone()), // clones the value under Arc and wraps it in a new counter              }              }           

順便說一下:sources 字段在執行過程中除了用于發出診斷資訊并未被使用,是以它可以保持共享。

注意,我最初發現性能下降的那一行代碼并不需要任何改動!

Vm::new(self.context.clone(), self.unit.clone())           

這是因為 self.context 和 self.unit 不再線上程之間共享。幸運的是頻繁更新非共享計數器通常很快。

一行代碼,為何使 24 核伺服器比筆記本還慢

最終結果

現在吞吐量按符合預期,從 1 到 24 個線程吞吐量線性增大:

一行代碼,為何使 24 核伺服器比筆記本還慢
一行代碼,為何使 24 核伺服器比筆記本還慢

經驗總結

  • 在某些硬體配置上,如果在多個線程上頻繁更新一個共享的 Arc,其代價可能會高得離譜。
  • 不要假設單條彙編指令不可能造成性能問題。
  • 不要假設在單個 CPU 上表現良好的應用程式也會在多 CPU 機器上具有相同的性能表現和可擴充性。