技術記事

PDFメモリの浪費元を発見:HotPDFのオブジェクト依存グラフ

HotPDFは、ロード済みPDFのメモリプロファイルを、クエリ可能なグラフとして公開します。BuildLoadedObjectDependencyGraphは、間接オブジェクトごとに1つのノードを返し、そこには推定シャロウサイズ、ドミネーターベースの保持サイズ、そのオブジェクトがドキュメントCatalogから今もなお到達可能かどうかを示すフラグが含まれます。これによって「このファイルは800MB使っている」という漠然とした事実が、「オブジェクト4173、画像XObjectが612MBを排他的に保持している」という、実際に対処できる事実に変わります

この2つの表現の違いこそが、すべての要点です。シャロウサイズは、1つのオブジェクトがどれだけ大きいかを教えてくれます。保持サイズは、そのオブジェクトが消えた場合に実際に解放されるメモリ量を教えてくれ、これが、その修正が効果を持つかどうかを決める数値です

総メモリ使用量が実行可能な事実にならないのはなぜか

なぜなら、PDFの中でただ1つのものだけに所有されているオブジェクトはほとんど存在しないからです。1つの埋め込みCIDフォントは、それを使うすべてのページのリソース辞書から参照されます。1つのICCプロファイルストリームは、10個の異なるコンテンツストリームが共有するカラースペースを支えています。スタンプとして使われる1つのForm XObjectは、400ページすべてに登場します。ページごとにオブジェクトサイズを単純に足し上げれば、そのフォントを400回数えてしまい、すべてのページが巨大だという結論になります。逆に400で割れば、何も高価なものはなく、メモリはどこか別のところから来ているという結論になります

ドミネーター解析は、実際のドキュメントを相手にしても成立する唯一の方法で、この曖昧さを解消します。オブジェクトXは、それを排他的に保持している最も近いオブジェクトに帰属させられます。つまり、CatalogからXへ至るすべての参照経路が、そのドミネーターを通過するということです。全ページで共有されているフォントは、どのページにも帰属しません。それらすべての経路が通過する最も近いノード、通常はCatalog自身に帰属します。ちょうど1ページだけで使われているフォントは、そのページに帰属します。この結果、EstimatedRetainedBytesは二重計上されることなく正しく合計され、リストの上位に来るオブジェクトは、実際に取り除けばメモリが解放されるオブジェクトになります

グラフに実際に含まれるもの

THPDFObjectDependencyNodeは、ObjectNumberGenerationNumberによるオブジェクトの識別情報に加えて、ObjectTypeLifecycleStateEstimatedShallowBytesEstimatedRetainedBytesIncomingReferenceCountOutgoingReferenceCount、直近のドミネーターの識別情報、そして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になっているオブジェクトは、ドキュメントが抱えているのにビューアが決して表示しないメモリです。実際には、次の3つのいずれかから生じます。オブジェクトの古いバージョンを置き換えて元のバージョンを取り残した増分更新、書き出したもののリンクに失敗したオブジェクトを持つ生成側、あるいはクロスリファレンステーブルが再構築された際に、何からも参照されない定義を拾い上げてしまった破損ファイルです

1つ目のケースは正常かつ想定内であり、増分更新とオブジェクトストリームがまさに実現しようとしていることそのものです。2つ目と3つ目は調査する価値があります。保持バイト数の大部分が到達不能なノードに存在している場合、それはファイルに追記するのではなく書き直すべきだという具体的な根拠になり、その追加処理時間を誰かに説明する必要が生じたときのために、その根拠となるバイト数もすでに手元にあります

最初に読むべき2つのアロケーターカウンター

ドキュメントが本質的に大きいと結論づける前に、コストの原因がパーサー自体にないかを確認してください。HotPDFは、ドキュメントの中身ではなく、ロード処理がどう振る舞ったかを表す2つのカウンターを公開しています

GetLastParserArenaStatisticsは、短命なパーサートークンやバッファに使われる保持ブロックアリーナについて、RetainedBytesPeakUsedBytesAllocationCountReusedAllocationCountTokenCountTemporaryObjectElisionCountを報告します。最後のフィールドは、一時的な名前オブジェクトを経由せずアリーナへ直接パースされた辞書キーの数を数えており、これはかつて辞書の多いファイルのパース処理を支配していた種類のアロケーションです

GetDocumentStringInternStatisticsは、繰り返し登場するPDF名、コンテンツストリーム演算子、64バイトまでの不変文字列を重複排除する、ドキュメントスコープのインターンプールを報告します。RequestCountHitCountMissCountBypassCountRetainedBytesReusedBytesと、有効な上限値が得られます。このプールは65,536エントリと4MiBに意図的に制限されているため、100万個の一意な名前を生成する悪意あるドキュメントであっても、メモリ最適化がメモリ増幅器に変わってしまうことはありません。上限に達すると、それ以降の文字列はプールを迂回し、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の多くが持つ反復的な語彙を持っており、プールがきちんと役目を果たしているということです。BypassCountRequestCountに近づいている場合は何か普通ではないことを意味します。本当に巨大なドキュメントであるか、意図的に一意な名前を生成しているかのどちらかであり、これは信頼できない取り込み経路においてログに残す価値のある軽度のシグナルです

実際に機能するトリアージの順序

まずグラフ情報のCatalogRetainedBytesから始めてください。この数値が自分のプロセスの増加量に近ければ、メモリはドキュメントの中にあり、グラフがどこにあるかを示してくれます。それより大幅に低ければ、メモリは自分自身のキャッシュ、レンダリング済みのビットマップ、あるいはパーサーの中にあり、アリーナカウンターがどれであるかを教えてくれます

次に、EstimatedRetainedBytesで上位10個のノードを取り出し、それぞれのObjectTypeを確認してください。上位に画像XObjectが並んでいれば、そのファイルはスキャン中心であり、ダウンサンプリングが解決策です。上位にフォントディスクリプタが並んでいれば、サブセットで済むはずのところにフルフェイスが埋め込まれています。上位にコンテンツストリームが並んでいれば、多くの場合、生成されたベクターグラフィックス、しばしば地図やCADエクスポートを意味します。到達不能オブジェクトやアロケーターの挙動を見るのは、それらの後で初めて価値を持ちます。この順序で進めれば、最初の2ステップで答えが見つかることがほとんどであり、非常に大きなドキュメントについては、直接ファイルAPIワークフローで説明されているストリーミング方式が、オブジェクト単位の最適化よりも構造的な解決策になることがよくあります

これらの診断機能はすべて、単純なレコードを返す単純なPascal呼び出しであるため、既存のログ経路やテレメトリ経路にそのまま組み込めます。HotPDFはDelphiとC++Builder向けのフルソース付きネイティブVCL PDFコンポーネントです。APIリファレンスと評価版はHotPDF Delphi PDFコンポーネントページにあります