HotPDFは、自分で実装した任意のランダムアクセスソースからPDFをロードできます。THPDFCoalescingRandomAccessSourceはそのソースをラップし、パーサーが行う散発的な小さな読み取りを、非同期プリフェッチを伴う有界なキャッシュ済みブロック範囲の集合に変換します。HTTPの範囲リクエストで配信されるドキュメントでは、これが数百回のラウンドトリップと数十回のラウンドトリップの差を生みます
パーサー側は何も変わりません。呼び出すのは相変わらずLoadFromRandomAccessSourceで、返ってくるドキュメントオブジェクトも同じ、ページAPIも同じように動作します。変わるのは、その下で流れるトラフィックです
同じPDFがローカルでは瞬時に開き、ネットワーク経由では這うように遅くなるのはなぜか
なぜなら、PDFパーサーはファイルを「読む」のではなく「たどる」からです。startxrefを求めて末尾までシークし、クロスリファレンステーブルまで戻ってジャンプし、トレーラー辞書を解決し、Catalogへの参照をたどり、次にページツリーのルート、次にページノード、次にそのリソース辞書へと進みます。これらの各ステップは、それぞれ異なるオフセットから数十バイトを読み取ります
ローカルファイルでは、このパターンはほぼ無償です。オペレーティングシステムがすでに周囲の4KiBページをキャッシュしているため、2回目の読み取りはmemcpy相当のコストで済みます。ネットワーク経由の転送には、そのような局所性がありません。各読み取りはそれぞれ独自のレイテンシを持つリクエストであり、40ミリ秒の逐次リクエストが300回続けば、そのほぼすべてが待機に費やされる12秒になります。修正の方向性は「読み取る量を減らす」ことではありません。パーサーは要求したものをそのまま必要としています。修正の方向性は、次の論理的な読み取りが求めるものを、1回の物理的な読み取りでより多くカバーすることです
結合処理が何を変えるのか
結合ソースは、すべての読み取りを1ブロック単位に切り上げて、そのブロックをキャッシュします。BlockSizeのデフォルトは262,144バイト、MaxCacheBytesのデフォルトは2,097,152バイトで、デフォルトでは8ブロックが常駐し、厳格なバイト予算に対して最も最近使われていない順に追い出されます。トレーラーキーに対するパーサーの40バイトの読み取りは、その周囲の256KiBを一緒に取り込み、クロスリファレンスとCatalogのデータが存在するその近傍での次の十数回の読み取りは、メモリから供給されます
自分で実装するソース自体はシンプルなままで構いません。GetSizeとReadAtを実装し、転送方式が処理中に中断できるならReadAtCancellableをオーバーライドし、キャッシュ、結合、プリフェッチはラッパーに任せてください
type
THttpRangeSource = class(THPDFRandomAccessSource)
private
FClient: TMyHttpClient;
FUrl: string;
FSize: Int64;
public
function GetSize: Int64; override;
function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
CancellationToken: THPDFCancellationToken): Longint; override;
end;
var
Raw: THttpRangeSource;
Cached: THPDFCoalescingRandomAccessSource;
Pdf: THotPDF;
begin
Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
// OwnsSource=True:ラッパーが自分自身と一緒にRawを解放する
Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
Pdf := THotPDF.Create(nil);
try
Cached.AsyncPrefetchEnabled := True;
Cached.AdaptiveReadAheadEnabled := True;
Cached.MaxReadAheadBlocks := 8;
if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
RenderFirstPage(Pdf);
finally
Pdf.Free;
end;
end;
どこまで先読みすべきか
適応的先読みは、推測を強いる代わりに、この問いにドキュメントごとに答えを出します。AdaptiveReadAheadEnabledを設定すると、持続的な前方読み取りが積み重なるにつれてウィンドウは1、2、4、8ブロックへと拡大していき、MaxReadAheadBlocksや設定済みのキャッシュ容量を超えることは決してありません。直前の読み取りの終了位置とおおむね一致しない読み取りが到着した瞬間、ウィンドウは崩壊し、プリフェッチは抑制されます
デフォルト値4,096のSequentialReadToleranceBytesが、この「おおむね」を定義します。前の読み取りの終了位置からその距離以内に収まる読み取りは、依然として逐次読み取りとしてカウントされます。これが重要なのは、コンテンツストリームをたどるPDFパーサーが完全に連続したオフセットを生成するわけではなく、あるところでは長さフィールドを飛ばし、あるところではインライン辞書を飛ばすからです。この許容値を低く設定しすぎると、通常の前方スキャンがランダムと分類されてしまい、先読みが一切働きません。高く設定しすぎると、本当のランダムアクセスが逐次アクセスに見えてしまい、誰も望んでいないメガバイト単位のデータを取得してしまいます。デフォルト値はコンテンツストリームの走査向けに調整されていますが、自分の転送方式がそれに合わない場合は統計情報が教えてくれます
この非対称性は意図的なものです。拡大は緩やかで、崩壊は即座に起こります。ランダムアクセス型のワークロードで過剰に取得すると、従量制の転送では実際の帯域幅と実際の費用がかかるため、高くつく間違いよりも安く済む間違いのほうが選ばれます
転送を実際に止めるキャンセル処理
基底クラスはReadAtCancellableを宣言しており、結合ソースはこれをエンドツーエンドで尊重します。実行中のプリフェッチがカバーしていない範囲に対してフォアグラウンドの読み取りが到着すると、プリフェッチは最後まで実行させられるのではなくキャンセルされるため、ユーザーのページリクエストが投機的なトラフィックの後ろに並ばされることはありません。THPDFRandomAccessSourceのデフォルト実装は単純なReadAtにフォールバックするため、この機能は転送方式ごとのオプトインになります。リクエストの中断に対応するHTTPクライアントは本物のキャンセルを得られ、よりシンプルなソースは変更なしに動作し続けます
これを、自分のUI全体に通したキャンセルトークンと組み合わせれば、ユーザーがドキュメントを閉じたときに、トラフィックが自然に流れきるのを待つのではなく、実際にネットワークトラフィックそのものを止められます。同じトークンモデルはリクエストキューによるバックグラウンドレンダリングで説明されているキューイングの土台にもなっており、1つのトークンでビューポートからソケットまでの経路全体をカバーできます
範囲キャッシュ統計を読み解く
GetStatisticsは、自分の転送方式が行ったこととキャッシュが行ったことを切り分けたTHPDFRangeCacheStatisticsレコードを埋めます。SourceReadCountとSourceBytesReadは物理的なトラフィックです。CacheHitCountとCacheMissCountは論理的なトラフィックです。SequentialReadCountとRandomReadCountはアクセスパターンがどう分類されたかを示し、CurrentReadAheadBlocksとPeakReadAheadBlocksはウィンドウがどこまで広がったかを示し、PrefetchRequestCount、PrefetchCompletedCount、PrefetchCancelledCount、SuppressedPrefetchCountは投機が報われたかどうかを示します
var
S: THPDFRangeCacheStatistics;
begin
Cached.GetStatistics(S);
Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
[S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
[S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
[S.PrefetchRequestCount, S.PrefetchCompletedCount,
S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;
3つの読み取り値が、何を変更すべきかを教えてくれます。キャンセルされたプリフェッチが多く、ランダム読み取り数も多い場合は、ドキュメントが順不同でアクセスされていることを意味するため、MaxReadAheadBlocksを下げて、捨てることになる帯域幅への支払いをやめてください。ピークウィンドウが1のままミスが多い場合は、許容値が実質的には逐次的なパターンを拒否していることを意味するため、SequentialReadToleranceBytesを引き上げてください。そして読み取ったバイト数がファイルサイズを大幅に上回っている場合は、キャッシュがスラッシングを起こしていることを意味するため、他の何かに手を付ける前にMaxCacheBytesを引き上げてください
リニアライズされたファイルは計算そのものを変える
生成側を自分でコントロールできるなら、ドキュメントをリニアライズすることは、問題を最適化するのではなく問題そのものを変えてしまいます。リニアライズされたPDFは、1ページ目のオブジェクトとヒントテーブルをファイルの先頭に配置するため、ビューアは残りの部分を見なくても、先頭の1メガバイトから1ページ目をレンダリングできます。HotPDFはこの経路をGetProgressiveLinearizedLoadInfoとReadProgressiveLinearizedFirstPageSectionを通じて直接公開しており、書き込み側についてはヒントテーブル付きリニアライズPDFの生成で扱っています
この2つの技法は組み合わせられます。結合処理はどんなドキュメントでも低速回線上で我慢できる程度にし、リニアライズは自分で生成するドキュメントの1ページ目を高速に届けます。ローカルディスク上にはあるもののメモリに収めるには大きすぎるファイルについては、直接ファイルAPIワークフローで説明されているマップドファイルと遅延ストリームの経路のほうが、通常はより適したツールです。そもそも償却すべきラウンドトリップレイテンシが存在しないからです
HotPDFはDelphiとC++Builder向けのネイティブVCL PDFコンポーネントであり、パーサー用の外部DLLは不要で、フルソースも入手できます。ランダムアクセスソースAPI、結合ラッパー、プログレッシブローディングのエントリポイントはHotPDF Delphi PDFコンポーネントページで説明されています