技術記事

HotPDF RenderCacheFolder:Delphiのディスクページキャッシュ

HotPDF RenderCacheFolderは、HotPDF Delphiコンポーネントのメモリ内描画ページキャッシュを、永続化されるディスクページキャッシュへ拡張します。描画済みページはあなたが選んだフォルダーの下へPNGファイルとして書き出され、次に同じPDFソースを開いたとき、RenderLoadedPageToBitmapCachedが再ラスタライズの代わりにそれらを読み戻します。探索の順序は、メモリ、次にディスク、最後にレンダラーです

ディスク層はv2.416.0からAPIに存在しましたが、v2.770.140まで、普通のLoadFromFileやLoadFromStreamの呼び出しに対しては、実際には1ページも供給したことがありませんでした。この修正は、すべての永続キャッシュが答えねばならない問いを突きつけます。今日開いたファイルが、昨日描画したドキュメントだとどうやって知るのか。そうでないとき、キャッシュ済みページはどうなるのか。以下はHotPDFが落ち着かせた答えで、あえてキャッシュを拒否する場所も含みます

HotPDFのディスク描画キャッシュはどう動くか

HotPDFのディスク描画キャッシュは、メモリ内ラスターキャッシュの後ろに控える第2層で、RenderCacheFolderが空でないパスであるときにだけ参加します。RenderLoadedPageToBitmapCached(PageIndex, DPI)の呼び出しはまず、ページインデックス、DPI、描画設定バリアントをキーとするメモリ内エントリーを走査します。ミスならディスク層に問い合わせます。ディスクヒットはPNGをデコードし、メモリへ戻してプロモートし、呼び出し側所有のコピーを返します。両層ともミスのときだけ、ページはロード済みPDFページをTBitmapへ描画する記事で述べたコンテンツストリームインタープリターを通り、できたばかりのビットマップはディスクにも書き込まれます

RenderLoadedPageToBitmapCachedに対するHotPDFの描画キャッシュ探索を示す図。ページ、DPI、描画バリアントをキーとするメモリ内層が最初に検査され、次にアトミック置換付きのPNGファイルからなるRenderCacheFolderディスク層、最後にコンテンツストリームインタープリターが検査され、すべてのヒットは呼び出し側所有のコピーを返します
HotPDFはまずメモリを見て、次にディスクを見て、それでもなければラスタライズします。ディスクヒットはメモリへ戻されてプロモートされ、どの経路もあなたが所有し解放すべきコピーを手渡します

ディスク上のレイアウトは、あえて退屈な作りです。各ドキュメントは16桁の16進文字からなるドキュメントキーと16桁の16進文字の描画バリアントから名付けられたサブフォルダーを得て、各ページは<page>@<dpi>.pngとして保存され、ルートのindex.txtがスキーマタグの後ろでドキュメントを最終使用順に管理します。スキーマの不一致は、最初の使用でフォルダーをクリアします。書き込みはまず一時ファイルへ行われ、アトミック置換で入れ替わるので、書き込み途中のクラッシュは古いページか無かのどちらかを残し、半分のPNGは決して残しません。デコードに失敗したPNGは削除され、ミスとして数えられます

フォルダーを束縛する上限が3つあります:

  • RenderCacheMaxDocuments(デフォルト20)はドキュメントサブフォルダーの数を制限します。最も使われていないフォルダーが先に退避されます
  • RenderCacheMaxBytes(デフォルト524288000、つまり500 MB)はルート下の全PNGファイルの合計サイズを制限します
  • 各ドキュメントフォルダーは最大200枚のページ画像を保持します。このドキュメントごとの上限はTHotPDFによって固定されており、公開プロパティではありません

RenderCacheCapacity(デフォルト8)は別のノブです。メモリ層が保持する描画済みページの数を設定するもので、ディスクのフットプリントとは無関係です

uses
  SysUtils, Graphics, HPDFDoc;

procedure WarmThumbnails(const FileName: string);
var
  Pdf: THotPDF;
  Bmp: TBitmap;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    // 最初のキャッシュ付き描画の前にディスク層を設定する:
    // フォルダーと両リミットは、層が最初に使われるときに読まれる
    Pdf.RenderCacheFolder := IncludeTrailingPathDelimiter(
      GetEnvironmentVariable('LOCALAPPDATA')) + 'MyViewer\PageCache';
    Pdf.RenderCacheMaxDocuments := 50;
    Pdf.RenderCacheMaxBytes := Int64(1024) * 1024 * 1024; // 1 GiB
    Pdf.RenderCacheCapacity := 16;                        // メモリ内のページ数

    if Pdf.LoadFromFile(FileName) > 0 then
      for I := 0 to Pdf.LoadedPageCount - 1 do
      begin
        Bmp := Pdf.RenderLoadedPageToBitmapCached(I, 96);
        if Bmp <> nil then
        try
          // ここでコピーをサムネイルストリップへ渡す
        finally
          Bmp.Free; // キャッシュ付き呼び出しは常に呼び出し側所有のコピーを返す
        end;
      end;
  finally
    Pdf.Free; // v2.770.140以降、これはディスクエントリーを削除しなくなった
  end;
end;

同じ手順を2回走らせると、2回目はキャッシュに収まったページをラスタライズしません。ディスクキャッシュオブジェクトは最初のキャッシュ付き描画で遅延生成され、THotPDFインスタンスが解放されるまで生きます。だからその時点より後のRenderCacheFolder、RenderCacheMaxDocuments、RenderCacheMaxBytesの変更は、すでに開かれたキャッシュを移動もサイズ変更もしません。メモリの受入れポリシーには大きすぎるページ(デフォルトでは1エントリーが32ビットピクセルで64 MiBを超えてはいけない)も永続化されませんし、ディスク層が参照されるのはRenderFallbackPolicyがデフォルトのrfpIgnoreを保っている間だけです。フォールバック診断はPNGと並んで保存されないからです

v2.770.140より前のRenderCacheFolderが動かなかった理由

RenderCacheFolderがv2.770.140より前に効果を持たなかったのは、ディスク層がドキュメントを、普通のロードが決して保持しないソースバイトのハッシュでキー付けしていたからです。ドキュメントキーは生のPDFバイトの内部コピーに対するSHA-256から来ていましたが、LoadFromFileとLoadFromStreamはソースをその場でパースし、そんなコピーを保持しません。このフィールドが埋められたのは暗号化回復経路で一時的にだけで、直後にクリアされていました。バイトがなければキーは常に空で、空のキーはディスク層のバイパスを意味します。エラーも警告もなく、空のままのフォルダーだけが残りました

キーを空でなくすると、最初のバグの陰に隠れていた2つ目のバグが露呈しました。旧InvalidateRenderedPageCacheはドキュメントのディスクフォルダーを削除しており、InvalidateRenderedPageCacheはすべてのロードの開始時、すべての編集時、そしてFreeの中で走ります。だからキーが動いた瞬間、すべてのビュアーセッションが終了時に自分のキャッシュを破壊し、次のセッションは結局コールドスタートになっていたはずです。さらに悪いことに、キーは編集後に同じソースから再計算されていたので、編集済みドキュメントの描画は元ファイルのキーの下に保存され、無変更のPDFを開いた次のセッションへ供給されていたはずでした。v2.770.140はアイデンティティと無効化を一緒に直します。片方だけの修正なら、死んだキャッシュか嘘つきのキャッシュのどちらかを出荷していたはずです

HotPDFがファイル全体を読まずにPDFを識別する方法

HotPDFは、ローカルファイルからロードされたPDFを、サイズ、最終書き込み時刻、先頭と末尾の64 KiBからなるフィンガープリントで識別し、ストリームやランダムアクセスソースは、コンテンツ全体のSHA-256で識別します。どちらもロードが成功したときに一度だけ取得され、SHA-256ダイジェストの先頭16桁の16進文字(64ビット)がドキュメントキーになります

ソースアイデンティティコスト取得のタイミング
LoadFromFileサイズ+LastWriteTime+先頭と末尾の64 KiBをSHA-256でハッシュ最大128 KiBの読み取りで、ファイルサイズに非依存成功したすべてのロード。RenderCacheFolderは後から設定されても
LoadFromStreamストリーム全体のSHA-256ソース全体を1パスRenderCacheFolderがロード前に設定されていたときだけ
LoadFromRandomAccessSourceソース全体のSHA-256ソース全体を1パスフォルダーが先に設定されていて、範囲全体が利用可能なときだけ
/Encryptエントリーを持つ任意のソースなしなし決して取得しない。ディスク層はバイパスされる
ディスク描画キャッシュに対するHotPDFのソースアイデンティティマップを示す図。LoadFromFileはサイズ、LastWriteTime、先頭と末尾の64 KiBをハッシュし、LoadFromStreamとLoadFromRandomAccessSourceはRenderCacheFolderが先に設定されていたときにだけコンテンツ全体をハッシュし、/Encryptトレーラーを持つソースはいかなるアイデンティティも取得しません
ファイルは両端からフィンガープリントされます。ヘッダー、xref、トレーラーはそこに住んでいるからです。ストリームは先にキャッシュを要求したときにだけ全体ハッシュの代金を払い、暗号化ドキュメントは決してディスクへ書かれません

ファイルのフィンガープリントは意図的なトレードオフです。400 MBのスキャンアーカイブを開くたびに全体をハッシュするのは、ユーザーが実際に見る2ページの描画より高くつき得ます。サンプリングされた領域は恣意的ではありません。ヘッダーはファイルの先頭にあり、トレーラーと最後のクロスリファレンスセクションは末尾にあります(ISO 32000-1 §7.5)。インクリメンタル更新は新しいボディ、クロスリファレンスセクション、トレーラーを追記する(§7.5.6)ので、サイズと末尾を同時に変えます。普通のツールによる全体書き換えなら、最終書き込み時刻が変わります。128 KiBまでのファイルでは2つのサンプルが全バイトを覆うので、小さなドキュメントは事実上全体がハッシュされます

残留リスクは、大きなファイルの中ほどへの同サイズのその場変更で、ライターが元のタイムスタンプを復元するケースです。コンテンツを編集しながら更新時刻を故意に保存するツールが必要で、稀ではあっても不可能ではなく、その場合キャッシュは古いページを供給します。裏面は無害です。Windowsでのファイルのコピーは通常、最終書き込み時刻を保存するので、すでにキャッシュにあるドキュメントのコピーは同じエントリーへヒットします。バイトが同一なので、それは正しい挙動です

ストリームには更新時刻がまったくないので、唯一の正直なアイデンティティはコンテンツです。HotPDFがその全体SHA-256パスの代金を払うのは、ロード前にディスクキャッシュを要求したときだけです。それ以外のLoadFromStream呼び出し元は追加コストを見ません。だからプロパティ代入の順序が負荷を担う構造になります:

procedure OpenDownloadedPdf(Pdf: THotPDF; Data: TStream;
  const CacheRoot: string);
begin
  // ストリームでは順序が違う。コンテンツハッシュはフォルダーが
  // すでに設定済みのときしか計算されないので、ディスク層をバイパスする
  //   Pdf.LoadFromStream(Data);
  //   Pdf.RenderCacheFolder := CacheRoot;

  Pdf.RenderCacheFolder := CacheRoot; // 先に設定
  Data.Position := 0;
  if Pdf.LoadFromStream(Data) <= 0 then
    raise Exception.Create('The stream is not a loadable PDF');
end;

まだダウンロード中のランダムアクセスソース(一部の範囲がまだ利用不可)は、部分コンテンツのハッシュではなくアイデンティティを持ちませんし、何らかの理由でアイデンティティの計算が失敗しても、ロードは成功したままです。そのドキュメントは単にディスク層なしで描画します

何がHotPDFのディスクキャッシュエントリーを無効化するのか

HotPDFのディスクキャッシュエントリーは、編集時に削除されて無効化されることはありません。代わりに、ロード済みドキュメントの編集はドキュメントのアイデンティティを落とし、そのロードの間はディスク層がバイパスされ、保存済みページは無変更のソースに対して有効なまま残ります。エントリーがディスクを去るのは、LRUとバイト上限、壊れたPNG、スキーマ変更の場合だけです

キーが記述するのはディスク上のソースであって、メモリ内のオブジェクトグラフではありません。ページにスタンプを押したりアノテーションを変えたりしたら、ドキュメントはもはやそのソースと一致しないので、そのキーの下での読み書きはどちらも正しくなりません。v2.770.140以降、ドキュメントレベルとページレベルのどちらの無効化も、フォルダーに触る代わりにアイデンティティをクリアします。さらに、InvalidateRenderedPageCacheを呼ばなかった編集のための2つ目の防衛線があります。ディスク層を使う前に、THotPDFはダーティなロード済みオブジェクトがないか確認し、ダーティなドキュメントはアイデンティティなしとして扱います

描画設定は逆方向に働きます。PageRenderBackendの切り替え(やUseNativeGDIRenderBackendの呼び出し)と、ConfigureRenderICCWorkflowやClearRenderICCWorkflowの呼び出しは、メモリ内ページをフラッシュしますがアイデンティティは保持します。ドキュメントはまだソースと一致しているからです。それらの設定はメモリ内バリアントの一部でなくピクセルを変えるので、ディスクキーはバックエンド名、ブラックポイント補正フラグ、ICCプルーフと出力プロファイルのSHA-256ダイジェストを折り込みます。バリアント自体はすでに、カラーインテント、出力ディザリング、オーバープリントプレビュー、ルミノシティマスクモード、フォールバックポリシー、すべてのオプショナルコンテンツグループの可視性をカバーするので、レイヤーのトグルはデフォルトビューを上書きするのでなく、別のフォルダーへ描画します

RenderCacheFolderディスクキャッシュに対するHotPDFの無効化セマンティクスを示す図。ロード済みドキュメントやダーティオブジェクトの編集はソースアイデンティティを落として層をバイパスさせ、描画バックエンドやICCワークフローの変更は新しいバリアントキーの下でアイデンティティを保持し、保存して再ロードするとドキュメントはキーを更新します
編集は保存済みフォルダーを決して削除せず、設定変更は別のキーの下で描画し、編集済みドキュメントに新しいアイデンティティを与えるのは保存と再ロードだけです

編集済みドキュメントをディスク層へ戻すには、保存して結果をロードし、新しいソースアイデンティティを与えます:

procedure CommitEditsAndRekey(Pdf: THotPDF; const EditedFile: string);
begin
  // ロード済みドキュメントの編集後:メモリ内ページを更新する。
  // ソースアイデンティティはすでにないので、元ドキュメントの
  // ディスクフォルダーとの読み書きは何も起きない
  Pdf.InvalidateRenderedPageCache;

  // 保存されたファイルは新しいサイズと最終書き込み時刻を持つ。つまり
  // 新しいアイデンティティ。このロード後の描画は新しいキーでキャッシュされる
  Pdf.SaveLoadedDocument(EditedFile);
  if Pdf.LoadFromFile(EditedFile) <= 0 then
    raise Exception.Create('Could not reload the edited document');
end;

元ドキュメントのフォルダーはそのまま残り、他のエントリーと同じようにRenderCacheMaxDocumentsとRenderCacheMaxBytesで熟成退避します。ユーザーが編集前の元ファイルを開き直せば、ページはまだそこにあります

セキュリティ境界:暗号化ソースとリンクされたフォルダー

HotPDFのディスク描画キャッシュは、2種類の入力を意図的に拒否します。暗号化PDFのページは決してディスクへ書かず、ジャンクションやその他のリパースポイントであるドキュメントサブフォルダーは決して追いません。どちらのルールも、データの漏えいや間違ったファイルの削除の代わりに、キャッシュヒットを犠牲にします

暗号化PDFはディスクへキャッシュされない

描画されたページは復号済みコンテンツです。それを素のPNGとしてキャッシュフォルダーへ書くと、パスワード保護されたドキュメントの読めるコピーが、作者が選んだ保護の外側(ISO 32000-1 §7.6)のディスクに残ります。そこでHotPDFは、トレーラーに/Encryptエントリーを運ぶソース、パスワード付きで開かれたものや空のユーザーパスワードのものを含めて、いかなるアイデンティティも取得しません。それらのドキュメントはプロセスとともに消えるメモリ層だけを使います

ジャンクションのサブフォルダーはv2.770.173以降拒否される

キャッシュルートはあなたの選択で、ジャンクションを指すのは許されます。その下のドキュメントサブフォルダーは別問題です。キャッシュはそれらを自分の手で、起動時の回復(残された一時ファイルを除去する)で作成、読み取り、タイムスタンプ更新、削除し、探索(タイムスタンプ更新)、保存、無効化、3つの退避上限でも触ります。キャッシュルートへの書き込み権限を持つ誰かがドキュメントフォルダーを別のディレクトリへのジャンクションに置き換えたら、それらの経路はすべてそれを追い、退避はキャッシュが所有したことのない場所のファイルを削除することになります。v2.770.173以降、それらの入口のそれぞれがリパースポイント属性を検査し、リンクされたドキュメントフォルダーをスキップします。探索はミスとして数え、保存は書き込み失敗として数え、退避は手を出しません

Unicodeパスと共有ルート

ユーザープロファイルへ配備するなら、関連する2つの修正が効いてきます。v2.770.135より前、RenderCacheFolderはAnsiStringだったので、システムコードページの外にあるフォルダー(たとえば英語版Windowsでの中国語ユーザー名)は、キャッシュが見る前に可逆でなく変換されていました。プロパティは今やUnicodeのstringで、アトミック置換はワイド版Windows APIを使います。v2.770.52以降、同じルートを指す1プロセス内の複数のTHotPDFインスタンスは(パス展開後、大文字小文字を無視して比較して)、参照カウント付きの単一インデックスとロックを共有します。以前は各インスタンスが自分のコピーでindex.txtを上書きし、自分の部分的な見えに対して上限を強制していたので、フォルダーは予算の何倍にも膨らみ得ました

その共有はプロセス境界で止まります。同じルート上の2つの別プロセスは、依然として別々のメモリ内インデックスを保持するので、並行して走るアプリケーションごとに専用のキャッシュルートを与えてください。ワーカースレッドで描画するビュアーは1プロセス内なら問題ありません。PrefetchLoadedPagesも、リクエストキューによるバックグラウンド描画で扱うキューも、同じキャッシュ経路と同じロックを通るからです

クイックリファレンス:RenderCacheFolderチェックリスト

  • RenderLoadedPageToBitmapCachedの最初の呼び出し前に、RenderCacheFolder、RenderCacheMaxDocuments、RenderCacheMaxBytesを設定する。ストリームとランダムアクセスのロードでは、ロード前にフォルダーを設定する
  • ディスク層に頼るなら、v2.770.140以降へアップグレードする。それより前のバージョンはプロパティを受け入れるものの、普通のロードではディスクからページを1枚も供給しない
  • ディスクキャッシュが効かないのは、暗号化PDF、ロード後に編集されたドキュメント、そしてRenderFallbackPolicyがrfpIgnoreでない間だと思っておく
  • THotPDFインスタンスは普通に解放する。v2.770.140以降、FreeもInvalidateRenderedPageCacheもディスクエントリーを削除しない
  • PageRenderBackendやICCワークフローの変更は、ドキュメントを別のキーの下でディスク層に留める
  • 実行中アプリケーションごとに1つのキャッシュルートを使う。1プロセス内のインスタンスはv2.770.52以降インデックスを共有する
  • キャッシュルートはユーザーごとの場所に置く。ジャンクションであるドキュメントサブフォルダーはv2.770.173以降スキップされる

永続ページキャッシュが最も報いるのは、同じドキュメントを1日中開き直すビュアーです。このブログの他の箇所で述べたDelphiによるカスタムPDFビュアーアーキテクチャは、まさにその形です。RenderCacheFolder、メモリ内ラスターキャッシュ、ページレンダラーは、DelphiとC++Builder向けのHotPDF Delphi PDF componentに同梱されています