HotPDF 透過兩個進入點同時渲染多個 PDF 頁面:RenderLoadedPagesParallel 會傳回一個點陣圖陣列,RenderLoadedPagesParallelOrdered 則會在每個頁面就緒後立即依輸入順序交付給回呼函式。具有可延展性的是有序版本,因為無論你要求渲染多少頁,它在記憶體中保留的點陣圖數量永遠有上限
這個差異就是本文的重點所在。將 10,000 頁以 300 DPI 渲染進一個陣列,代表同時存在 10,000 個點陣圖,這不是輸出量問題,而是記憶體不足導致的當機。透過佇列深度為 2 的有序回呼渲染,代表同時只存在兩個點陣圖,無論文件多長,整個工作都以固定用量執行
為何平行渲染需要兩個階段?
渲染一個 PDF 頁面其實是共用一個名稱的兩份不同工作。首先內容串流會被權杖化並編譯成顯示清單,這一步受 CPU 限制,且會存取共用快取。接著顯示清單會被播放至點陣圖,這一步同樣受 CPU 限制,但會大量配置記憶體。若把兩者視為不可分割的單一單元,就必須在序列化存取快取的那一半,與重複執行工作之間做出取捨
HotPDF 把兩者拆開。顯示清單快取鎖只保護頻率統計、LRU 順序、使用計數與允入決策;編譯本身則在鎖之外執行。編譯完成後,工作執行緒會重新查詢快取:若同一時間另一個工作執行緒已發佈同一頁面,它就會釋放自己那份副本,改為在已發佈的項目上取得一次使用計數。這讓重複工作維持在有限範圍內,並防止同一頁面出現兩個項目,同時完全不需要跨頁面序列化編譯過程
在一份 32 頁的複雜測試文件上量測到的效果,是最值得記住的部分。在 Win64 上,總耗時從單一工作執行緒的 1,565 毫秒降到四個工作執行緒的 696 毫秒,約為 2.25 倍。取得第一個結果的時間則從 1,255 毫秒降到 63 毫秒,降幅約 95%,因為部分工作執行緒開始播放已完成的顯示清單時,其他執行緒仍在編譯,不必在完整編譯這道關卡前等待
有序交付與保留插槽
有序 API 保證你的回呼函式會依你要求的順序看到各頁面,即使工作執行緒完成的順序並不一致。完成的點陣圖會發佈到以輸入位置為索引的輸出插槽中,回呼函式則在呼叫執行緒上依序執行
有兩條規則讓它維持「有上限」而不只是「有順序」。輸出佇列計數包含回呼函式目前正在使用的點陣圖,因此執行緩慢的回呼無法讓生產者的速度超越佇列深度上限。而較後面的頁面最多只能佔用 queueDepth - 1 個插槽,因為有一個插槽永遠保留給下一個待交付的頁面。若沒有這項保留機制,緩慢的第一頁可能被已完成、填滿佇列的後續頁面卡住,導致管線在最前端死結,所有工作執行緒都閒置
uses
HPDFDoc;
procedure TExportJob.PageReady(Sender: TObject; InputIndex,
PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
// 此 bitmap 是借用的:只在這次呼叫有效,請在此消費它
// (寫入磁碟、編碼、雜湊)且不要保留參照
Bitmap.SaveToFile(Format('page-%.4d.bmp', [InputIndex + 1]));
Cancel := FUserCancelled;
end;
procedure TExportJob.Run;
var
Pdf: THotPDF;
Pages: array of Integer;
Info: THPDFParallelRenderPipelineInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('manual.pdf') <= 0 then
Exit;
SetLength(Pages, Pdf.LoadedPageCount);
for I := 0 to High(Pages) do
Pages[I] := I;
Pdf.RenderLoadedPagesParallelOrdered(Pages, 200, 4, 2,
PageReady, Info);
Writeln(Format('workers=%d peak queued=%d peak bytes=%d',
[Info.WorkerCount, Info.PeakQueuedPageCount, Info.PeakQueuedBytes]));
Writeln(Format('first result=%d ms total=%d ms backpressure waits=%d',
[Info.FirstResultMilliseconds, Info.TotalMilliseconds,
Info.BackpressureWaitCount]));
finally
Pdf.Free;
end;
end;
用量之所以能維持平穩,關鍵在於「借用點陣圖」這項約定。函式庫會在你的回呼函式返回後立即釋放該點陣圖,因此一份 10,000 頁、佇列深度為 2 的測試文件,在 Win32 與 Win64 上都恰好只會有兩個點陣圖同時存在達到尖峰。若你需要在回呼結束後仍保留該點陣圖,請自行複製一份,屆時記憶體與計數就由你自己負責
記憶體額度如何決定工作執行緒數量
工作執行緒愈多不代表愈快,往往反而是致命的。六個工作執行緒以 600 DPI 渲染 A3 頁面,無論有多少核心可用,都需要數百 MB 的即時點陣圖記憶體,而擁有八核心的機器未必有那麼多可用位址空間
因此 HotPDF 會根據請求大小、頁數與一個硬性上限,正規化所要求的工作執行緒數量,再將目前可用的全域額度除以請求中最大的保守單頁估計值。至少會保留一個工作執行緒存活,因此即使頁面過大,仍會以獨佔方式完成渲染。在參考測量中,一份六頁、600 DPI、額度為 512 MiB 的工作,實際以三個而非六個工作執行緒執行,保留量尖峰為 504,583,296 位元組
每個工作執行緒會在取得頁面之後、開始編譯之前先進行記憶體保留,且保留量涵蓋編譯與播放兩個階段。若只針對點陣化階段進行保留,會漏算顯示清單與內容串流的工作集,而在圖形密集的頁面上,這部分往往佔比更大。兩種 API 在這裡有所不同:陣列版本會在點陣圖完成後立即釋放保留量,因為結果的所有權已轉交呼叫端,排程器不再能對其計量;有序版本則會將保留量連同點陣圖一起轉移到輸出插槽中,直到回呼函式返回才釋放。這也是輸出集龐大時應優先選用有序 API 的另一個原因
取消時不會死結
在回呼函式中設定 Cancel 會停止新工作,並喚醒任何因反壓而等待中的工作執行緒。已經在渲染器內部的頁面會完成處理並正常清理,而不是被直接丟棄,且只會保留第一個失敗診斷,並在所有工作執行緒都會合後才拋出
這裡的順序至關重要。取消狀態會先被設定,接著釋放尚未發佈的輸出保留量,最後才會合所有工作執行緒。若把最後兩個步驟顛倒過來,就會形成一個封閉迴圈:某個工作執行緒等待進入一個要等消費端完成才會釋放的額度,而消費端卻在等那個工作執行緒結束
互動式應用程式還應該了解前景搶佔機制。頁面預先擷取使用自己的一組取消權杖,前景渲染呼叫會先取消並會合預先擷取的工作執行緒,再開始自己的工作,因此背景吞吐量絕不會為使用者正在等待的頁面增加延遲。取消狀態的檢查間隔以 4 KiB 位元組掃描或 256 個權杖為上限,不過原子性的編解碼器呼叫或作業系統呼叫無法在執行中被中斷——這項回應性保證涵蓋函式庫自有的迴圈,不涵蓋第三方解碼器。以佇列為基礎的互動式渲染做法,說明於以請求佇列進行背景渲染一文
解讀管線統計資訊
THPDFParallelRenderPipelineInfo 把容易混淆的數字分開呈現。比較 RequestedWorkerCount 與 WorkerCount、MemoryBudgetWorkerLimit,可得知額度是否降低了你的並行度。比較 CompiledPageCount 與 CacheHitPageCount,可得知顯示清單快取節省了多少工作量。CompiledPagesAtFirstResult 則顯示管線是否真的讓編譯與播放重疊執行,還是退化成一道關卡
就調校而言,最有用的兩個欄位是 BackpressureWaitCount 與 MemorySchedulerWaitMilliseconds。反壓等待次數偏高,代表瓶頸在你的回呼函式,該讓它更快,或在記憶體允許的情況下提高佇列深度。排程器等待時間偏高,代表瓶頸在額度,該降低 DPI、降低並行度,或提高額度。無論哪一種情況,提高工作執行緒數量都只會讓吞吐量變差,這正是違反直覺的地方
批次匯出的實務起點:並行度設為實體核心數減一,佇列深度設為二或三,DPI 則依實際輸出需求選擇,而非憑習慣。接著從實際文件讀取統計資訊,一次性調整,而不是連猜兩次。若文件本身大到無法整份放進記憶體,可將此方法與大型 PDF 工作流程的直接檔案 API一文所述的串流存取模型結合使用
平行渲染、預先擷取與顯示清單快取,都屬於 Delphi 與 C++Builder 同一套渲染堆疊的一部分;完整功能清單請見HotPDF Delphi PDF 元件頁面