技術記事

DelphiのHotPDFでフォントサブセットをディスクにキャッシュ

HotPDFはTrueTypeとOpenTypeのフォントサブセットをディスクに保持し、ドキュメントをまたぎ、プロセス実行をまたいで再利用できる。だから同じ3つのフォントで1万通の明細書をレンダリングするバッチは、1万回ではなく1回だけそれらのフォントをサブセット化する。キャッシュは2つのプロパティで設定し、1つのレコードで点検し、オンのまま安全に放置できる。キャッシュの失敗は通常のインメモリサブセット化へフォールバックし、ドキュメントの生成を止めることは決してない

サブセット化が高価なのには理由がある。サブセットを構築するとは、グリフクロージャを歩き、locaglyfを書き直し、cmaphmtxをリビルドし、PDFがアドレス指定できるCIDマッピングを出力することだ。1つのドキュメントでは、そのコストはノイズの中に消える。ループの中でドキュメントを生み出すレポートサーバーでは、しばしば実行の中で最大の単一CPUブロックになる

キャッシュヒットを可能にするもの

4つが一致しなければならない。フォントの内容、使用グリフの集合、サブセットモード、キャッシュスキーマ。1つでも欠ければHotPDFはゼロからサブセット化する。サブセットが再利用可能なのは、それがバイト同一になっていたはずの場合だけだからだ

グリフ集合が、人を驚かせる条件だ。1つの顧客名だけが異なる2つの請求書は、異なるグリフ集合を使い、したがって異なるサブセットと異なるキャッシュエントリを生む。キャッシュが報われるのは、ドキュメントがグリフのレパートリーを共有するときだ。固定テンプレートからの明細書、変数データが数値のフォーム、1つの製品データベースから引かれたカタログ。逆に、すべてのドキュメントが巨大なCJK書体の異なる一部分を描くような場合には、キャッシュは何も払わない。自分がどちらのケースにいるか、想定する前に測ること

var
  Pdf: THotPDF;
  Info: THPDFFontSubsetCacheInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.EnableFontSubsetting := True;
    Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
    Pdf.FontSubsetCacheMaxBytes := 64 * 1024 * 1024;   // 64 MiB, default is 256
    // ... generate the batch ...
    Info := Pdf.GetFontSubsetCacheInfo;
    LogFmt('subset cache: %d hits, %d misses, %d bytes in %d files',
      [Info.HitCount, Info.MissCount, Info.CurrentBytes, Info.FileCount]);
  finally
    Pdf.Free;
  end;
end;

キャッシュが何かしているとどうやって知るのか

GetFontSubsetCacheInfoは9つのカウンタを返し、最初の2つの比率が問いに直接答える。HitCountMissCountがヒット率を与える。WriteCountEvictionCountは、エントリが再利用されるほど長く生きているか、小さすぎる予算に押し出されているかを示す。CurrentBytesFileCountは、現時点でディスクにあるものを報告する

残る3つが、アラートを上げる価値のあるものだ。CorruptCountは検証に失敗して削除されたエントリを数える。クリーンでないシャットダウンの後の少数は正常だが、定常的な流れはストレージが信頼できないことを意味する。RejectedCountは使用前に拒否されたエントリを数える。WriteFailureCountはまったく書き込めなかったエントリを数えるが、これは通常フォントに関する何かではなくフォルダの権限問題を意味する。これら3つのどれもドキュメント生成を止めない。だからこそあなたが見なければならない。黙って一度も書かないキャッシュは、外から見ると動くキャッシュと同じに見える。CPUの請求書を除いて

追い出し、予算、そして予算を縮める瞬間

FontSubsetCacheMaxBytesはデフォルトで268435456バイト、つまり256 MiBであり、実行時に下げられる。下げると次の書き込みを待つ代わりに、即座に最も最近使われていないものから追い出す。だからディスク圧力に反応するサービスは、自分が制御できない後の某个の時点ではなく、決めた瞬間にスペースを解放できる

FontSubsetCacheFolderに空文字列を設定すると、すでに保存されたものを消去せず、フォント出力の1バイトも変えずに、ディスク階層を無効化する。トラブルシューティング中にキャッシュを隔離したいときに手を伸ばすべきプロパティだ。オフにして同じバッチを走らせ、生成されたPDFを比較する。キャッシュはポリシーではなく結果を保存するので、両者は同一のはずだ

エントリが壊れたときキャッシュはどうするか

それを削除し、通常どおりサブセット化する。奇形や切り詰められたエントリは、サブセットがPDFストリームに届く前に拒否される。これが設計の中で最も重要な部分だ。壊れたキャッシュエントリがドキュメントに入り込んだとすれば、壊れたフォントプログラムを持つPDFを生み、その失敗は原因から遠く離れた場所で表面化する。ビューアの中で、顧客のマシン上で、何週間もあとに

書き込みはアトミックであり、読み手が半分書かれたエントリを観察することはなく、書き込み途中のクラッシュはキャッシュを毒化するのではなく一貫した状態に保つ。コンパクトなサブセットエントリはPDF/Aフォント辞書が要求するCID再マッピングデータを保持するので、キャッシュされたサブセットはやはり適合サブセットであり、アーカイブ出力は有効性を保つためにキャッシュをバイパスする必要はない

// Reset the disk tier after a font upgrade or a schema change
Pdf.ClearFontSubsetCache;

// Or move it somewhere writable and let the budget apply immediately
Pdf.SetFontSubsetCacheFolder('D:\cache\fonts');

実際のデプロイでフォルダをどこに置くか

3つの性質がこれを決める。フォルダはサービスが走るアカウントから書き込み可能でなければならず、ネットワーク共有ではなくローカルストレージ上にあり、デプロイステップが消去するディレクトリの中にあってはならない。共有上のキャッシュは、すべてのミスをラウンドトリップに変え、すべてのヒットを2回のラウンドトリップに変える。インストーラーが再作成するアプリケーションフォルダの下のキャッシュは、毎回更新後にコールドスタートするキャッシュだ

マルチインスタンスサービスでは、ストレージが期待どおりに並行アトミック置換を扱うことを確認しない限り、各インスタンスに独自のフォルダを与えること。重複したエントリのコストは余分な1回のサブセット化パスだが、共有キャッシュの競合状態をデバッグするコストは午後1本だ

ほかのものに手を伸ばすべきとき

キャッシュは繰り返し作業を減らす。最初のドキュメントの作業は減らさないし、グリフ集合が決して繰り返さないワークロードも助けない。あなたの出力が、予測不能なテキストにまたがって使われる1つの巨大なCJK書体に支配されているなら、より効くレバーはサブセットクロージャ自体、つまりどのグリフが引き込まれ、なぜかである。これはフォントサブセットクロージャとシェーピンググリフのノートが扱う。バッチが遅い理由が結局フォントではなかったなら、フォントと画像を伴うレポート出力の解説が、他の時間が通常どこへ行くかを示す。そしてEndDocのフォントサブセット順序バグのケーススタディは、サブセット化の正確性と速さが別の問題であることを思い出させる

HotPDFはDelphiとC++Builder向けのネイティブVCL PDFコンポーネントであり、サブセットキャッシュはアドオンサービスではなくライブラリの一部だ。だからレポートサーバーは、1つのフォルダパスを設定するだけでこれを得る。完全なフォントとパフォーマンスの機能一覧はHotPDFコンポーネントページを参照のこと