HotPDF 會把已載入 PDF 的記憶體概況,以可查詢的圖形結構呈現:BuildLoadedObjectDependencyGraph 會為每一個間接物件回傳一個節點,內含估計的淺層大小、以支配者演算法算出的保留大小,以及一個標記該物件是否仍可從文件 Catalog 到達的旗標。這讓「這份檔案用了 800 MB」變成「4173 號物件,一個影像 XObject,獨占保留了 612 MB」,一個你可以據以行動的事實
這兩句話之間的差異正是重點所在。淺層大小告訴你一個物件本身有多大。保留大小告訴你如果這個物件消失了,實際上會釋放多少記憶體,而這個數字才是決定某個修正方案是否有效的關鍵
為什麼總記憶體用量不是一個可以據以行動的事實?
因為在一份 PDF 中,幾乎沒有東西是恰好只被一件事物擁有的。單一個內嵌 CID 字型,可能被使用它的每一頁的資源字典所參照。一份 ICC 描述檔支撐著十份不同內容串流共用的色彩空間。一個用作圖章的 Form XObject 可能出現在全部 400 頁上。如果你天真地把每一頁的物件大小逐頁加總,就會把那個字型算了 400 次,結論會是每一頁都巨大無比;如果你把它除以 400,結論又會變成沒有東西特別昂貴,記憶體不知從何而來
支配者分析是唯一能在真實文件面前站得住腳的方式,用來消解這種模糊性。物件 X 會被歸屬給最接近的、能獨占保留它的物件,意思是從 Catalog 到 X 的每一條參照路徑,都必然經過那個支配者。若一個字型被所有頁面共用,它不會被歸屬給任何一頁;它會被歸屬給所有這些路徑共同經過的最近節點,通常就是 Catalog 本身。若一個字型只被一頁使用,它就會被歸屬給那一頁。結果是 EstimatedRetainedBytes 能正確加總,不會重複計算,清單最上方的物件也就真的是移除後能實質釋放記憶體的物件
這張圖實際包含什麼
每一個 THPDFObjectDependencyNode 都帶有以 ObjectNumber 與 GenerationNumber 表示的物件識別、ObjectType、LifecycleState、EstimatedShallowBytes、EstimatedRetainedBytes、IncomingReferenceCount、OutgoingReferenceCount、其直接支配者的識別,以及 ReachableFromCatalog。每一個 THPDFObjectDependencyEdge 則記錄來源、目標、找到該參照所在的字典 Path,以及它是否 Resolved
Path 欄位是最常被低估的一個。知道 91 號物件指向 4173 號物件,跟知道它是透過 /Resources/XObject/Im3 指向的,兩者差別很大:後者能立刻告訴你,你看到的究竟是頁面內容、註解外觀串流,還是一個從沒被渲染過的選擇性內容群組
var
Pdf: THotPDF;
Nodes: THPDFObjectDependencyNodeArray;
Edges: THPDFObjectDependencyEdgeArray;
Info: THPDFObjectDependencyGraphInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('report-800mb.pdf') <> 1 then Exit;
if not Pdf.BuildLoadedObjectDependencyGraph(Nodes, Edges, Info) then Exit;
if Info.LimitExceeded then
Log('Graph truncated: raise MaxObjects / MaxEdges');
Log(Format('%d objects, %d edges, %d reachable, catalog retains %d bytes',
[Info.ObjectCount, Info.EdgeCount, Info.ReachableObjectCount,
Info.CatalogRetainedBytes]));
SortByRetainedDescending(Nodes);
for I := 0 to Min(9, High(Nodes)) do
Log(Format('%d %d obj: shallow %d, retained %d, dominator %d',
[Nodes[I].ObjectNumber, Nodes[I].GenerationNumber,
Nodes[I].EstimatedShallowBytes, Nodes[I].EstimatedRetainedBytes,
Nodes[I].ImmediateDominatorObjectNumber]));
finally
Pdf.Free;
end;
end;
兩個邊界值都是明確的參數,預設分別為 250,000 個物件與 2,000,000 條邊。當文件超出任一上限時,LimitExceeded 會被設定,回傳的圖形是一個被截斷的前段結果,而不是一個謊言:這是帶有標記的部分結果,而不是靜默錯誤的總計。如果要做鑑識分析,可以刻意提高這些上限,但要記得這項分析會走訪整個物件圖,所以它屬於診斷路徑,不該放進渲染迴圈裡
一個不可到達的物件說明了什麼?
ReachableFromCatalog 為 False 的物件,是文件攜帶著、但檢視器永遠不會顯示出來的記憶體。實務上,這種情況通常來自三種原因:增量更新取代了某個物件的舊版本、卻把原版本遺留了下來;產生器寫入了物件卻沒能把它連結起來;或是檔案損壞,重建後的交互參照表撿回了沒有任何東西參照的定義
第一種情況是正常且預期中的,這正是 增量更新與物件串流 設計要達成的效果。第二與第三種情況則值得深入調查。當大部分保留位元組落在不可到達節點上時,你就找到了一個具體的理由,可以主張重寫這份檔案而不是繼續在上面附加內容,也有位元組數可以用來向任何質疑的人證明多花這段處理時間是值得的
兩個值得優先查看的配置器計數器
在斷定一份文件本質上就是龐大之前,先檢查看看是不是剖析器本身造成的成本。HotPDF 提供兩個計數器,描述的是載入過程的行為,而不是文件的內容
GetLastParserArenaStatistics 回報用於短生命週期剖析器標記與緩衝區的保留區塊 arena:RetainedBytes、PeakUsedBytes、AllocationCount、ReusedAllocationCount、TokenCount 與 TemporaryObjectElisionCount。最後這個欄位計算的是有多少字典鍵是直接剖析進 arena、而不是透過一個暫時的名稱物件,這正是過去在剖析字典密集型檔案時最常見的主要配置行為
GetDocumentStringInternStatistics 回報文件範圍內的字串暫存池,用來去除重複的 PDF 名稱、內容串流運算子與長度不超過 64 位元組的不可變字串。它提供 RequestCount、HitCount、MissCount、BypassCount、RetainedBytes 與 ReusedBytes,以及目前生效的上限。這個池被刻意限制在 65,536 筆項目與 4 MiB,因此一份惡意產生大量獨特名稱的文件,不會把一項記憶體最佳化手段變成記憶體放大器;一旦達到上限,後續字串就會繞過這個池,BypassCount 就會隨之上升
var
Arena: THPDFParserArenaStatistics;
Intern: THPDFDocumentStringInternStatistics;
begin
if Pdf.GetLastParserArenaStatistics(Arena) then
Log(Format('arena: peak %d, reused %d of %d allocations, %d tokens',
[Arena.PeakUsedBytes, Arena.ReusedAllocationCount,
Arena.AllocationCount, Arena.TokenCount]));
if Pdf.GetDocumentStringInternStatistics(Intern) then
Log(Format('intern: %d/%d hits, %d bypassed, %d bytes reused',
[Intern.HitCount, Intern.RequestCount, Intern.BypassCount,
Intern.ReusedBytes]));
end;
ReusedBytes 偏高、BypassCount 偏低,代表這份文件擁有多數真實 PDF 都會有的重複性詞彙,暫存池正在發揮效用。BypassCount 接近 RequestCount,則代表出現了不尋常的情況:可能是一份真正龐大的文件,也可能是刻意產生獨特名稱,這在不受信任的接收路徑上,是值得記錄下來的一個輕微訊號
一套行得通的分診順序
從圖形資訊中的 CatalogRetainedBytes 開始看。如果這個數字接近你的處理程序記憶體成長量,記憶體就在文件裡,這張圖會告訴你在哪裡。如果它遠低於實際成長量,記憶體就在你自己的快取、已渲染的點陣圖或剖析器內,此時 arena 計數器會告訴你是哪一個
接著,取出依 EstimatedRetainedBytes 排序的前十個節點,看看它們的 ObjectType。影像 XObject 排在最前面,代表這份檔案以掃描內容為主,降低取樣率就是修正方案。字型描述元排在最前面,代表原本用子集就夠的地方內嵌了完整字型。內容串流排在最前面,通常代表產生的是向量圖形,常見於地圖或 CAD 匯出檔。只有做完這一步之後,才值得去看不可到達物件與配置器行為。依這個順序處理,通常前兩步就能找到答案;對於非常大型的文件,直接檔案 API 工作流程 中描述的串流式方法,往往才是結構性的解方,而不是任何逐物件的最佳化
以上所有診斷都是純粹的 Pascal 呼叫,回傳的也是純粹的記錄,因此可以直接接入現有的記錄或遙測路徑。HotPDF 是一套原生 VCL PDF 元件,適用於 Delphi 與 C++Builder,並提供完整原始碼;API 參考文件與試用版請參見 HotPDF Delphi PDF 元件頁面