技術文章

在 Delphi 中的平行 XLSX 解析:記憶體管理員瓶頸

HotXLS 這個適用於 Delphi 與 C++Builder 的原生 Excel 程式庫,透過三個階段的載入程序在多個執行緒上解析 XLSX 工作表:工作表 XML 會序列化地 (serially) 解壓縮、平行解析,最後再序列化地讀取小型部分。這項功能的初次發布僅獲得 12–25% 的效能提升,因為 Delphi 預設記憶體管理員的鎖 (lock) 將工作執行緒給序列化了。將堆積配置 (heap allocations) 從每個儲存格大約 20 次削減到 9.1 次,讓 8 個執行緒的平行加速提升到了 ×1.90。本文將帶您走過這些測量、走錯的路,以及兩個真正奏效的修復方法

HotXLS 如何平行解析 XLSX 工作表?

HotXLS 將 Open 拆分為三個階段,且只有中間階段會在工作執行緒 (worker threads) 上執行。原因在於 zip 容器:一個 zip 封存檔是一個共用的輸入串流 (input stream),帶有一個解壓縮 (inflate) 狀態機,而這個狀態機無法同時被兩個執行緒讀取。用一個鎖來包裝它是沒有意義的,因為對每個條目進行解壓縮本質上就是序列化的,所以一個鎖只會以額外的負擔來重現序列化執行。因此,階段 A 在維持單執行緒的情況下,將每個工作表的 XML 解壓縮到它專屬的 TMemoryStream 中;在我們的基準測試檔案中,8 個工作表部分大約花費了 4 毫秒,所以它完全不是瓶頸所在。階段 B 在一個工作集區 (worker pool) 上為每個工作表執行 ParseWorksheetXml,這是幾乎所有載入時間的所在。階段 C 回到序列化地讀取 zip 檔案以處理小型部分:註解、繪圖、圖表以及表格

工作集區本身刻意保持簡單。工作執行緒使用 InterlockedIncrement 從一個共用計數器中取出工作索引,所以大小不一的工作表可以自然地平衡,不需要任何排程器。執行緒數量為 min(工作表數量, CPU 核心數),第一個工作執行緒的例外狀況會以 AcquireExceptionObject 擷取,並在主執行緒進行 join 之後重新擲出,當工作數量為零或一時,分派器會降級為單純的序列迴圈。TXLSXWorkbook 上的兩個屬性控制著這個功能:ParallelParse 會開啟集區,而 ParallelParseThreads 會設定執行緒數量的上限,0 表示自動。多工作表的活頁簿是能從中獲益的形狀,包含您透過複製範本工作表數十次所產生的那種活頁簿

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // 啟用平行工作集區
    Book.ParallelParseThreads := 0;  // 0 = 自動:min(工作表數, CPU 核心數)
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('open failed');
    // ... 像平常一樣讀取儲存格;活頁簿已完全具現化 ...
  finally
    Book.Free;
  end;
end;

為何在 Delphi 中增加執行緒反而讓 XLSX 解析變慢?

因為 Delphi 的預設記憶體管理員以一個全域鎖來保護它的堆積,而工作表解析是密集配置的:數以百萬計的儲存格、Variants 以及 WideStrings。每個碰到堆積的工作執行緒都會在那個鎖上排隊,所以原始碼中看起來獨立的執行緒,在實際上幾乎是一次執行一個。我們的第一次基準測試痛苦地證實了這一點。在一個包含 8 個工作表,每個工作表 5,000 列乘以 4 欄的活頁簿上,在 i5-11600K(6 核心,12 執行緒)與 Win64 環境下進行測量,平行的 Open 僅改善了 12–25%,而計畫估計至少應有 40%。跨 2、3、4、6 與 8 個執行緒的執行緒數量掃描產生了一條平坦的曲線,而在後來有加入檢測 (instrumented) 的執行中,2 執行緒的設定實際上比序列化慢了 26%,這是兩個執行緒在競爭鎖時互相拖累的經典特徵

三個測量結果確立了診斷,且每一個都推翻了先前的直覺。首先,一個微小檔案(8 個工作表,每表 1 列)在 1.2 毫秒內開啟,證明解析實際上佔了 Open 時間的 100%,並沒有隱藏的固定成本可以怪罪。第二,一個純粹測試配置波動 (allocation churn) 的微基準測試顯示 Delphi 記憶體管理員呈現負向擴展:同樣是 200 萬次物件與 AnsiString 配置的總量,在 8 個執行緒上執行比在單一執行緒上慢了 60%,而同樣的波動在 WideString 堆積(這是 COM BSTR 配置器,而不是 Delphi 記憶體管理員)上,則擴展到了 ×3.7。HotXLS 整個過程中都使用 WideString,這證明了是歷史的巧合對我們有利。第三,GetProcessTimes 顯示在一次平行的 Open 期間,CPU 時間大致等於實際時鐘時間 (wall time):八個名義上的執行緒只消耗了約 1.3 個執行緒的 CPU 資源。工作執行緒並沒有在忙碌等待 (spinning);它們在記憶體管理員的競爭路徑中睡著了,被阻擋而不是在忙碌中

這個務實的教訓超越了試算表的範疇。如果一個 Delphi 工作負載有繁重的配置,提高執行緒數量在配置率下降之前是沒有任何作用的,而且它很容易讓情況變得更糟。在這個修復之前,我們對正在調整 ParallelParseThreads 的使用者說了實話:在受限於配置 (allocation-bound) 的檔案上,更多的執行緒幾乎買不到任何東西

每個儲存格 20 次堆積配置是從哪裡來的?

透過 SetMemoryManager 安裝的計數包裝器精確地回答了這個問題:每個儲存格大約有 20 次 Delphi 記憶體管理員配置,其中 287 萬次在 32 個位元組或以下。罪魁禍首根本不是儲存格物件。TXMLScaner.GetTokenValue 在每次呼叫時都會具現化一個新的 AnsiString,而且它在每個儲存格大約被呼叫 15–20 次:元素名稱、屬性名稱、屬性值以及文字內容各一次。最重要的是,RTL 的 UTF8ToWideString 路徑會為每一次轉換製造一個暫時的 UnicodeString 中間產物。儲存格物件僅佔了 16 萬次配置,約為總數的 8%,這當場扼殺了我們原本的計畫:我們曾打算建置一個儲存格物件集區,但數據表示它永遠無法回本

var
  OldMM, NewMM: TMemoryManagerEx;
  AllocCount, TinyCount: Int64;

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // 我們關心的小型物件波動
  Result := OldMM.GetMem(Size);
end;

// 在 Open 之前安裝,之後還原
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

這個十分鐘的診斷方法值得被偷去用於任何 Delphi 的效能調查。依據大小區間來計算配置的建置成本幾乎為零,而且會告訴您記憶體管理員的壓力實際從何而來,在我們的案例中,是 XML 掃描器內部的兩個 RTL 層級習慣,而不是物件模型中的任何東西。分析器 (profilers) 不斷指向整個解析器;包裝器則指向了特定的兩行

修復:權杖內部化 (token interning) 與零中間產物的 UTF-8 解碼器

XML 讀取器中兩項具針對性的變更,移除了超過一半的每個儲存格配置,而不需要更動解析器的結構。第一個是元素名稱內部化 (interning)。工作表 XML 無止盡地重複一小群詞彙:rowcvrts,以及少數的屬性名稱。InternTokenName 保持著一個擁有 64 個插槽的先前見過名稱的快取,並透過 TokenEqualsAnsi 將掃描器的建構緩衝區與快取條目進行比較,這是一個直接的位元組比較,不會配置任何東西。命中時,它會傳回快取的 AnsiString,而在這裡型別的選擇很重要:AnsiString 是有參考計數的,所以傳回一個快取的實例只需要遞增一次參考計數,且記憶體堆積流量為零。WideString 沒有參考計數,每一次指派都會經過 SysAllocString,所以內部化 WideStrings 節省不了任何東西。內部化只值得在有參考計數的字串型別上執行

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // 只有 refcount++,無記憶體配置
  else
  begin
    Result := GetTokenValue;        // 具現化一次,然後快取
    FInternNames[Slot] := Result;
  end;
end;

第二個變更針對的是儲存格文字。舊的路徑會建置一個 AnsiString 權杖,把它交給 UTF8ToWideString,這會建置一個 UnicodeString 中間產物,最後才轉換為儲存格所儲存的 WideString:在真正的字串出現之前,每個文字權杖會有兩次 Delphi 記憶體管理員的配置。替代方案 XmlUtf8ToWide(TokenPtr, TokenLen),是一個直接從掃描緩衝區讀取的二階段純 Pascal UTF-8 解碼器:階段一測量 UTF-16 長度,階段二解碼到配置一次的 WideString 中。每個文字權杖的淨成本:一次 COM 配置,零次 Delphi 記憶體管理員配置。給謹慎者的語意提醒:遇到格式錯誤的 UTF-8 序列時,新的解碼器會直接讓位元組通過,而不是像 RTL 那樣替換為取代字元,這只會影響損毀檔案如何降級處理;對於有效的輸入,輸出會是位元組一致的。XML 字元實體永遠不會到達解碼器,因為掃描器已經在權杖緩衝區中將它們解析為 UTF-8 了

它買到了什麼,以及平行解析仍然無用武之地的地方

這兩個修復將每個儲存格的配置從大約 20 次削減到 9.1 次,而平行的數據也如理論所說的那樣移動了。在同一個 8 個工作表、5,000 列的基準測試檔案與同一台 6C12T 機器上,8 執行緒的改進從 14% 提升到了 47.4%,比序列化快了 ×1.90 倍。2 執行緒的情況從慢了 26% 翻轉為快了 23.6%,測量到的 CPU 使用率也從 ×1.0 上升到了 ×2.2。序列化路徑也獲得了約 3% 的額外加速,因為較少的配置對單一執行緒也有幫助。剩餘的約 9 次每次儲存格配置,大約一半是儲存格物件,一半是攤提 (amortized) 的容器增長;我們對它們進行了測量,判斷報酬率正在遞減,所以停了下來,而記憶體管理員包裝器已經準備好,如果在未來的負載證明合理的話,可以再依據呼叫點 (call site) 重新取樣

這項成果的邊界,值得和勝利一樣清楚地說明。HotXLS 是以工作表為粒度進行平行化的,所以如果活頁簿只是一個巨大的工作表,無論 ParallelParseThreads 說什麼,它都只會在一個執行緒上解析;對於那種形狀,串流直接讀取器是更好的工具,因為它完全避免了具現化活頁簿。如果檔案的時間都花在階段 C 的部分,像是繪圖、圖表與註解,能獲得的益處就會比較少,因為這個階段依設計會維持序列化。小型檔案根本不值得開執行緒,這就是為什麼分派器遇到少量工作時會安靜地執行序列化。而且記憶體管理員的上限並沒有消失,只是退後了:在每個儲存格 9.1 次配置的情況下,全域鎖依然會對工作執行緒造成負擔,這就是為什麼八個執行緒產生的是 ×1.90 而不是 ×4 的原因。如需有關縮減載入和存檔時間的更廣泛工具組,包含樣式、集區和批次列回呼 (bulk row callbacks),請參閱我們在 Delphi 中的大型活頁簿效能的指南

平行 XLSX 解析、ParallelParseParallelParseThreads 屬性,以及這裡描述配置精簡的 XML 讀取器,皆作為 HotXLS Delphi Excel 元件 的標準部分隨附,它可以從 Delphi 與 C++Builder 中原生讀取與寫入 XLS、XLSX 以及 ODS,而無需涉及 Excel 自動化 (automation)