技術記事

DelphiでOOMを起こさないPDFページの並列レンダリング

HotPDFは2つのエントリポイントを通じて多数のPDFページを並行してレンダリングする:ビットマップの配列を返すRenderLoadedPagesParallelと、各ページを準備でき次第、入力順にコールバックへ配信するRenderLoadedPagesParallelOrderedである。スケールするのは順序付きの形式であり、要求したページ数に関係なく、メモリ上に保持されるビットマップ数が常に上限付きに保たれるためである

この違いこそがこの記事の主題である。10,000ページを300 DPIで配列にレンダリングすることは、10,000個の生存ビットマップを意味し、これはスループットの問題ではなくメモリ不足クラッシュである。キュー深さ2の順序付きコールバックを通じてレンダリングすれば、生存ビットマップは2個で済み、ドキュメントがどれだけ長くても固定のメモリ使用量でジョブが実行される

並列レンダリングになぜ2段階が必要なのか

PDFページのレンダリングは、1つの名前をまとった2つの異なる仕事である。まずコンテンツストリームがトークン化され、ディスプレイリストへコンパイルされる。これはCPUバウンドで共有キャッシュに触れる処理である。次にディスプレイリストがビットマップへ再生される。これもCPUバウンドだが大量のアロケーションを行う。これらを1つの不可分な単位として扱うと、キャッシュに触れる半分を直列化するか、作業を重複させるかの選択を強いられる

HotPDFはこれらを分割する。ディスプレイリストキャッシュのロックは頻度カウント、LRU順序、使用カウント、採用判定のみを保護し、コンパイル自体はロックの外で実行される。コンパイル後、ワーカーはキャッシュを再照会する:もし別のワーカーが同じページをその間に公開していれば、自分のコピーを解放し、公開済みのエントリの使用カウントを取得する。これにより重複作業は上限付きに保たれ、同じページに対して2つのエントリが存在することも防がれる。しかもページをまたいでコンパイルを直列化することは一切ない

32ページの複雑なフィクスチャで測定された効果は覚えておく価値がある。Win64での合計時間は、ワーカー1個の1,565ミリ秒から、ワーカー4個の696ミリ秒へと、およそ2.25倍に短縮された。最初の結果が出るまでの時間は1,255ミリ秒から63ミリ秒へ、約95パーセント短縮された。これは、他のワーカーがまだコンパイル中であっても、完了したディスプレイリストの再生をワーカーが開始でき、コンパイルの完全な障壁で待たされることがないためである

Delphi向けHotPDFの2段階並列レンダリングパイプラインを示す図。コンパイル段階が共有ディスプレイリストキャッシュへ供給し、その後にビットマップ再生が行われる
HotPDFは各ページをキャッシュ済みディスプレイリストへコンパイルし、空いている任意のワーカーで再生する。そのため再生はコンパイルと重なり合い、障壁の後ろで待たされることがない

順序付き配信と予約スロット

順序付きAPIは、ワーカーが完了する順序がどうであれ、コールバックがリクエストした順序でページを見ることを保証する。完了したビットマップは入力位置でインデックス付けされた出力スロットへ公開され、コールバックは呼び出し元のスレッド上で直列に実行される

これを単なる順序付けではなく上限付きにする規則が2つある。出力キューのカウントには現在コールバックが使用中のビットマップも含まれるため、遅いコールバックがプロデューサーをキュー深さの先へ滑り込ませることはできない。そして、後続のページは最大でqueueDepth - 1個のスロットしか占有できない。なぜなら1つのスロットは配信順が次であるページ用に恒久的に予約されているからである。この予約がなければ、遅い先頭ページが、完了した後続ページによってキューが埋められることでロックアウトされてしまい、すべてのワーカーがアイドルのままパイプラインが行列の先頭でデッドロックする

HotPDFにおける順序付き配信を示す図。順序通りでないワーカーの結果が出力スロットを埋め、予約された1つのスロットが次に配信すべきページを滞りなく流し続ける
完了したページは入力位置でアドレス指定されたスロットに収まり、直列の借用ビットマップコールバックを通じて排出される。1つのスロットが予約されているため、先頭のページが決してロックアウトされることはない
uses
  HPDFDoc;

procedure TExportJob.PageReady(Sender: TObject; InputIndex,
  PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
  // bitmapはborrowed:このcall中だけ有効なのでここでconsume
  // (diskへwrite、encode、hash)してreferenceを保持しない
  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;

使用量が横ばいに保たれる理由は、この「借用ビットマップ」契約にある。ライブラリはコールバックが戻り次第すぐにビットマップを解放するため、キュー深さ2の10,000ページのフィクスチャは、Win32とWin64のどちらでもピーク時にちょうど2個のビットマップにとどまる。コールバック後もビットマップが必要な場合はコピーすればよく、その時点でメモリと会計は自分の所有物になる

メモリ予算がワーカー数をどう選ぶか

ワーカーを増やすことは常に速くなるとは限らず、しばしば致命的である。600 DPIでA3ページをレンダリングする6個のワーカーは、利用可能なコア数にかかわらず数百メガバイトの生存ビットマップを必要とし、8コアを持つマシンでもそれだけの空きアドレス空間があるとは限らない

そのためHotPDFは、要求されたワーカー数をリクエストサイズ、ページ数、そして固定の上限に対して正規化し、その上で現在利用可能なグローバル予算を、リクエスト内の最大かつ保守的な単一ページ推定量で割る。少なくとも1個のワーカーは常に生き残るため、規格外に大きなページであってもそれだけは専有でレンダリングされる。参照測定では、512 MiBの予算下での6ページの600 DPIジョブは6個ではなく3個のワーカーで実行され、予約のピークは504,583,296バイトであった

各ワーカーはページを取得した後、コンパイルの前にメモリ予約を取得し、その予約はコンパイルと再生の両方をカバーする。ラスタ段階の周辺だけを予約すると、グラフィックスの多いページでは大きい方の半分であるディスプレイリストとコンテンツストリームのワーキングセットを見落としてしまう。この点で2つのAPIは異なる挙動をする:配列形式はビットマップが完成した時点で予約を解放する。呼び出し元が結果を所有するため、スケジューラはもはやそれを会計できないからである。一方、順序付き形式は予約をビットマップとともに出力スロットへ引き継ぎ、コールバックが戻るまで保持する。出力セットが大きい場合に順序付きAPIを選ぶべきもう1つの理由がここにある

HotPDFのメモリ予算が512 MiBを最大のページ推定量で割り、6個ではなく3個のワーカーを許可する様子を示す図
予算は最大の単一ページ推定量に基づいてワーカーを許可し、各予約はコンパイルと再生の両方にまたがり、順序付きAPIはコールバックが戻るまで予約を維持し続ける

デッドロックのないキャンセル

コールバック内でCancelを設定すると、新規作業は停止し、バックプレッシャーで待機中のワーカーも起こされる。すでにレンダラー内にあるページは放棄されずに完了して片付けられ、最初の失敗診断のみが保持され、すべてのワーカーが合流した後に発生させられる

ここでの順序は無視できない重みを持つ。まずキャンセル状態が設定され、次に未公開の出力予約が解放され、その後で初めてワーカーが合流させられる。最後の2ステップの順序を逆にすると閉じたループができてしまう:あるワーカーは、消費者が終わるまで解放されない予算への割り当てを待ち、一方消費者はそのワーカーの終了を待つことになる

対話型アプリケーションはフォアグラウンドのプリエンプションについても知っておくべきである。ページのプリフェッチは独自のキャンセルトークン上で動作し、フォアグラウンドのレンダリング呼び出しは自身の作業を行う前にプリフェッチワーカーをキャンセルして合流させるため、バックグラウンドのスループットがユーザーが待っているページにレイテンシを加えることは決してない。キャンセルは4KiBのバイトスキャンまたは256トークンを上限とした間隔でチェックされるが、アトミックなコーデックやOS呼び出しは実行途中で中断できない——この応答性の保証はライブラリ所有のループをカバーするものであり、サードパーティのデコーダーはカバーしない。対話型レンダリングに対するキュー方式のアプローチはリクエストキューによるバックグラウンドレンダリングで説明されている

パイプライン統計の読み方

THPDFParallelRenderPipelineInfoは混同しやすい数値を分離する。RequestedWorkerCountとWorkerCountおよびMemoryBudgetWorkerLimitを照らし合わせれば、予算が並行度を減らしたかどうかがわかる。CompiledPageCountとCacheHitPageCountを照らし合わせれば、ディスプレイリストキャッシュがどれだけ節約したかがわかる。CompiledPagesAtFirstResultは、パイプラインが実際にコンパイルと再生を重ね合わせたのか、それとも障壁へと退化したのかを示す

チューニングにおいて最も有用な2つはBackpressureWaitCountとMemorySchedulerWaitMillisecondsである。バックプレッシャー待機が多いということはコールバックがボトルネックであることを意味するので、それを速くするか、メモリに余裕があればキュー深さを上げるとよい。スケジューラ待機が多いということは予算がボトルネックであることを意味するので、DPIを下げるか、並行度を下げるか、予算を上げるとよい。いずれの場合もワーカー数を増やすとスループットはむしろ悪化する。これが直感に反する部分である

バッチエクスポートの実用的な出発点は、並行度を物理コア数マイナス1、キュー深さを2または3、そしてDPIは習慣ではなく実際の出力要件から選ぶことである。そのうえで実際のドキュメントから統計を読み、二度勘で調整するのではなく一度だけ調整するとよい。ドキュメント自体がメモリに収まらないほど大きいジョブについては、これを大規模PDFワークフロー向けの直接ファイルAPIにあるストリーミングアクセスモデルと組み合わせるとよい

並列レンダリング、プリフェッチ、ディスプレイリストキャッシュはいずれも、DelphiおよびC++Builder向けの同じレンダリングスタックの一部である。機能一覧の全体はHotPDF Delphi PDFコンポーネントページにある