去年十一月,我開始在 Intercom 新工作,其中一個首要專案是與新同事一起改善 Intercom 單體應用的 CI 性能。

有趣的是,我從未在此部落格談過 CI,儘管我認為這是我的專長之一。CI 性能與使用者體驗的關鍵在於 Ruby 程式啟動並準備執行測試的速度。

當測試套件非常龐大時,平行執行測試變得必須。理論上,若測試套件執行需一小時,使用四個工作者可縮短至 15 分鐘,十個工作者約 6 分鐘,六十個工作者約 1 分鐘。

但實務上,CI 測試執行有兩階段:首先是所有工作者必須經歷的設定階段,包括取得原始碼、準備資料庫等後端服務,以及啟動應用程式。設定完成後,工作者才開始執行測試。

以同樣一小時的測試套件為例,若設定階段需 1 分鐘,使用四個工作者總時間約 16 分鐘,使用六十個工作者約 2 分鐘。這不僅使用者體驗較差,也代表一半的運算資源未用於實際工作,可能增加成本。

因此,平行化測試套件的效益會因設定工作者的成本而遞減。設定時間如同固定成本,減少它能提升體驗並降低成本。

Intercom 單體應用 CI 預設使用 1350 個平行工作者,優化設定時間一秒,影響力相當於優化單一測試一秒的 1350 倍,每次建置可節省超過 20 分鐘運算時間。

團隊同時加速慢測試與工廠,但我個人專注於縮短設定時間,盡可能削減每一秒甚至分秒。

為此,我研究加快應用啟動時間。Ruby 開發者大概都知道 Bootsnap。

Bootsnap 已是 Rails 預設套件近十年,且在非 Rails 專案也很受歡迎,但我與線上及會議上的人交流後,發現許多人不完全理解它的運作。這裡說明其中一項優化。

當你 require 一個檔案(Ruby 內部稱為 "feature"),Ruby 會在載入路徑中執行昂貴的線性搜尋,類似以下流程:

這種載入機制雖簡單,但擴展性差。

一個乾淨的 Ruby 程式啟動時,載入路徑約有 8 個路徑,require 相對便宜,最壞情況下會查詢檔案系統 16 次。

但每加入一個 gem,$LOAD_PATH 就多一個條目,且通常會呼叫更多次 require。啟動 Ruby 程式的成本非線性,而是約為 O(N*M),N 為 $LOAD_PATH 大小,M 為 $LOADED_FEATURES 大小。

換言之,擁有 400 個 gem 的應用啟動速度可能比 200 個 gem 慢超過兩倍。

這是 Aaron Patterson 在 2015 年 GORUCO 演講中提到的問題,該演講啟發我寫了 bootscale,曾在 Shopify 單體應用成功使用,但因脆弱性高,未公開。

後來前同事 Burke Libbey 重新實作此概念,做得更穩健且乾淨,誕生了 bootsnap,促進社群採用。

回到主題,Bootsnap 的主要功能是載入路徑快取。

其概念是:不斷檢查檔案是否存在很慢,Bootsnap 會主動掃描 $LOAD_PATH 下所有目錄,建立一個龐大映射表,讓檔案查找變成 O(1) 的雜湊查詢。

簡化來說,Bootsnap 的快取就是一個大雜湊表:

接著它裝飾 Kernel.require,快速將相對路徑轉成絕對路徑,完全避開 Ruby 緩慢的搜尋機制。

當然 Bootsnap 需處理許多細節以準確模擬 Ruby 行為,但概念簡單且可靠。

有了快取後,每次 require 不用掃描和檢查多達 2*N 個檔案,只需支付一次性成本,且能快速攤銷。

但快取有個問題:何時失效?快取失效是程式設計中最難的問題之一。

Bootsnap 無法跨 CI 建置持久化快取,因為任何新增或刪除檔案都必須使快取失效。

它透過記錄掃描目錄的修改時間(mtime)來判斷。新增或刪除檔案會更新該目錄 mtime,但父目錄 mtime 不變。

若 mtime 遞迴更新,對 Bootsnap 等工具非常有用,但可能因效能考量未如此設計。

因此,Bootsnap 每次需重新驗證快取時,必須遞迴檢查所有載入路徑目錄的 mtime。這比重建快取便宜,但仍可能有數千次 stat(2) 系統呼叫,成本不低。

更重要的是,CI 常用 git 取代碼,而 git 不會更新 mtime,導致快取難以跨建置或機器重用,必須每次重建,掃描效能因此重要。

在 Intercom 單體應用中,掃描所有載入路徑接近一秒,對我來說即使是分秒也重要,因為屬於設定時間的一部分,我積極尋找改進方法。

為理解問題,簡化 Bootsnap 掃描器實作如下:

對每個目錄條目,若是目錄則加入清單並遞迴,否則若副檔名符合(.rb、.so、.bundle 等)則加入可 require 檔案清單。

你可能想問為何不用 Dir["**/*.{rb,so}"],但 Bootsnap 需精確模擬 Ruby 行為,否則可能改變程式行為。假設有目錄名為 something.rb 或 somethingelse.so 是錯誤的。

還有其他細節,如需保留所有目錄清單(即使尚無可 require 檔案),以便重新驗證快取。

實際版本更複雜,但效能上等同。

問題是此掃描器本質上是系統程式設計的 N+1 問題,類似網頁程式的 N+1 查詢,但是系統呼叫。

系統呼叫比資料庫查詢快,但仍希望避免,因為涉及核心態切換。

系統呼叫成本依系統不同而異,Linux 通常比 macOS 快,且部分呼叫不需切換。macOS 某些呼叫如 open(2) 因安全機制開銷大。

此案例中,每個目錄條目會呼叫 File.directory?,導致 stat(2) 系統呼叫。這 N+1 系統呼叫問題早在早期 UNIX C 程式就被發現,Linux 和 BSD 的 readdir(3) API 有 d_type 成員,可判斷條目類型,避免 stat(2) 呼叫。

遺憾的是,Ruby 雖內部用 d_type 加速 Dir[],但 Dir.foreach 卻未暴露此資訊。

我早在 2020 年就知道此問題,當時已嘗試優化 Bootsnap 和 Zeitwerk(同樣問題),並提出 Dir.scan 功能請求,但未獲回應。

這次我決定重新嘗試,並附上原型實作。我沒有新增方法,而是擴充現有 Dir.foreach,使其多傳一個參數表示檔案類型符號。

初版原型使遞迴掃描目錄速度提升約 2 倍。不久後,Nobuyoshi Nakada(nobu)注意到我的 PR,實作另一版本,yield File::Stat 物件,我認為更優雅,遂提出新功能請求。

但我知道即使通過開發者會議,也要等到 Ruby 4.1 才會實裝。

等一年改善 Bootsnap 不理想,因 Bootsnap 已有 C 擴充,我決定直接在 Bootsnap 實作此 API,立即獲得效能提升。

在 Intercom 單體應用(僅 repo,不含依賴)測試,速度提升約 2 倍:

Bootsnap 現可在 230 毫秒掃描約 3.2 萬檔案,舊版需 500 毫秒。

後來我的功能請求在開發者會議討論,因改變現有方法簽名可能破壞相容性,最後決定新增 Dir.scan 方法。

此功能將於 Ruby 4.1.0 推出。

雖然 N+1 問題是主要瓶頸,且 2 倍提升令人滿意,但我認為效能優化如同採蘑菇,找到一處代表該區域尚未被優化。

另一個 Bootsnap 常呼叫的 Ruby 方法是 File.join,雖非主要瓶頸,但在啟動分析中可見,值得研究。

如何判斷程式碼是否過慢?通常是因為處理邊界案例。比較基準是只考慮正常路徑的簡單實作。File.join 最常用即是字串串接。

將簡單實作與真實 File.join 比較,發現真實版本慢約 4 倍,令人懷疑。

我對呼叫 1000 萬次的 File.join 進行剖析,發現超過一半時間花在編碼相關函式,尤其是 rb_enc_mbclen。

File.join 和其他路徑處理方法會拒絕非 ASCII 相容編碼的路徑。

我推測這是舊時遺留,可優化,於是查 git 歷史,發現多字節編碼支援由 nobu 於 2012 年初加入,拒絕非 ASCII 相容編碼則於同年十月加入。

兩次提交訊息皆不明確,也無相關錯誤票,但看來當時 nobu 嘗試解決多字節路徑問題,最後決定只接受 ASCII 相容編碼。

我不輕易認為 nobu 犯錯,於是詢問他,幾小時後得到回覆:

確實不是錯誤,而是涉及日文 Shift JIS 編碼的特殊案例。

這類問題我曾多次遇到,且 Wikipedia 上有相關頁面「Japanese language and computers」可參考。

簡述問題:Ruby 支援百餘種字串編碼,其中部分定義為「ASCII 相容」,但此概念不明確。Ruby 認為 UTF-8 和 Shift JIS 都是 ASCII 相容。

這不錯,因為兩者都是 ASCII 超集,ASCII 字元在兩者皆有效。

但 UTF-8 的優勢是多字節字元完全使用 ASCII 範圍外的位元組(大於 127),使 ASCII 操作可簡化為固定長度。

換言之,UTF-8 中看到 0x5c 就是反斜線,但 Shift JIS 中 0x5c 可能是反斜線,也可能是多字節字元的續位元組,例如「構」編碼為 0x8d 0x5c。

因此無法有效將 Shift JIS 當作 ASCII 處理,必須用查表法檢查字元寬度,這是 rb_enc_mbclen 的工作,成本高昂。

不過大多數傳給 File.join 的路徑應是 UTF-8 或純 ASCII,因此我為這些編碼實作快速路徑,其他則用複雜演算法。

Ruby 早已提供檢查此類編碼的輔助函式,我利用它為 File.join 實作快速路徑,使用單字節比較。

優化過程中,我發現多字節檢查非唯一瓶頸,File.join 及其他路徑方法還有其他可優化處。

File.join 在串接每個路徑段後會呼叫 chompdirsep,判斷是否有尾端路徑分隔符,避免重複分隔符。

但其實現有問題:

函式接收字串起迄指標,應回傳最後有效分隔符位置,讓 File.join 去除多餘尾端分隔符。

合理實作應從字串尾部往前尋找,但此處卻掃描整個字串,導致長路徑串接速度不成比例地慢。

我猜原因是多字節感知的 Inc 巨集易用,實作 Dec 巨集較複雜,但技術上可行。

我只優化快速路徑,內聯單字節版本,從字串尾部尋找重複分隔符。

同時我繼續尋找其他優化機會。

剖析顯示 6.7% 時間花在 rb_string_value_cstr,修正多字節編碼後此問題更明顯。

該函式確保 Ruby 字串為有效 C 字串(以 NULL 結尾),但 File.join 並不需 NULL 結尾,僅串接字串,未傳給需 NULL 結尾的 C API。

因此我改用 rb_str_null_check,只檢查字串內容,避免不必要的處理。

另一瓶頸是 10% 時間花在 rb_ary_new_from_values,該函式建立新陣列。

File.join 參數彈性大,為簡化實作,原先將所有參數放入 args 陣列。

我改為不建立陣列,改用堆疊指標與參數數量,避免簡單情況下額外分配。

綜合這些改進,File.join 常見用法速度提升超過 7 倍。

在 Ruby 4.1.0dev 中,使用 File.join 串接兩個簡單路徑比字串插值還快。

有興趣者可參考完整 Pull Request。

發現 File.join 低垂果實後,我推測其他路徑處理方法也有類似問題,並對以下方法做了相似優化:

這些方法雖非重大瓶頸,但優化無害。

除非使用如 git-restore-mtime 等外掛,會帶來額外效能負擔。

總結,透過深入分析 Ruby 路徑處理機制,特別是 Bootsnap 的載入路徑快取與 File.join 的多項優化,成功大幅縮短 CI 設定時間與提升啟動速度,對大型專案效益顯著。