技術記事

ギガバイト規模のPDF処理のIOパフォーマンスの最適化

PDFパーサーの最初の有用な読み取りは、ファイルの反対側の端で行われます。このフォーマットは startxref ポインターを最後のバイトに配置するため、1.8 GBのアーカイブの処理は、末尾へのシーク、1キロバイトの読み取り、そして相互参照テーブルがドキュメントカタログの場所として示す場所へのホップから始まります。そこから、解析は全バイト範囲にわたるランダムウォークになります。バッファリングされたIOが得意とすること(ファイルポインターの背後での順次先読み)はすべて、PDFが持っていないワークロードを対象としています

この記事の最初のバージョンでは、メモリマップトファイルが、2 GBの入力で TMemoryStream が陥る32ビットのメモリ不足エラーを解決すると主張していました。その主張は間違っており、その間違い方は実際の修正点、つまりスライディングマッピングウィンドウを指し示しています。以下は、アクセスパターン、コンパイル可能なウィンドウマッパーを備えた修正済みの32ビットのストーリー、および1.8 GBで300,000オブジェクトのテストファイルに対するシステムコール演算です

PDFレイアウトがバッファ付き読み取りを打ち負かす理由

3つの構造的な事実がIOパターンを形成します。第1に、ナビゲーションはオフセット駆動です。相互参照テーブルはすべてのオブジェクト番号を絶対バイト位置にマッピングしますが、それらの位置が順序付けられている必要はありません。何年もの増分更新の後、オブジェクト4102は1.6 GBのオフセットに配置され、オブジェクト4103は30 KBに配置される可能性があります。TFileStream ループは、すべてのフェッチを SeekRead の2つのカーネルトランジションに変えますが、次のフェッチが数億バイト離れているため、バッファは何も貢献しません

第2に、オブジェクトストリーム(ISO 32000-1 §7.5.7)は、数十または数百の小さな辞書を圧縮された1つのコンテナにパックします。1つの300バイトのページ辞書をフェッチするということは、100 KBのクラスターを読み取って解凍することを意味する場合があります。裏を返せば、一緒に書き込まれたオブジェクトは一緒に読み取られる傾向があるため、クラスターのサイズに合わせたバッファは、次の数十回のフェッチを無料で提供します。これは、このフォーマットで最も悪用可能な規則性です

第3に、リニアライズ(線形化)です。リニアライズされたファイルは、最初のページとヒントテーブルを前にロードし、コンシューマーが前から順に読み取れるようにします。ギガバイトのアーカイブがリニアライズされていることはほとんどありません。リニアライズは、ファイルを大きくしたのと同じ増分更新とマージによって破壊されます。不利なケース、つまり長いホップ、順序なし、末尾からのエントリを想定して計画してください

修正された32ビットのストーリー

32ビットWindowsプロセスは2 GBのユーザーアドレス空間を持ち、バイト数がゼロの MapViewOfFile はファイルサイズの連続した1つの予約を要求します。2 GBの入力の場合、その予約は成功しません。EXE、散在するDLL、スレッドスタックの後、典型的な32ビットDelphiプロセスで最大の連続した空きブロックは、700 MBから1.4 GBの間のどこかにあります。呼び出しは ERROR_NOT_ENOUGH_MEMORY で失敗します。これは TMemoryStream.LoadFromFile がぶつかるのと同じ壁であり、コミットされたRAMからアドレス空間の予約に移動しただけです。ファイル全体のマッピングは32ビットでの解決策ではなく、聞こえの良いAPI名の背後にある同じ失敗にすぎません

修正は、マッピングが行う2つのことを分離することです。CreateFileMapping はセクションオブジェクトを作成し、ファイルサイズに関係なくアドレス空間をまったく消費しません。MapViewOfFile だけがアドレス空間を消費し、セクション全体をマップするよう強制するものは何もありません。64ビットの開始オフセットとビューの長さを取ります。セクションを1回作成し、解析される領域上に64〜256 MBのビューをマップし、次にスライドする前にアンマップします。アドレス空間のコストは、1つのファイルではなく1つのウィンドウになります。1つの制約があります。ビューのオフセットは SYSTEM_INFO.dwAllocationGranularity(実際には64 KB)の倍数である必要があるため、オフセット1,000,000の要求は983,040に切り捨てられ、呼び出し元のポインターはその差だけ前方に調整されます

Delphiでのスライディングウィンドウマッパー

以下のクラスは、その原則全体をラップしています。1つのセクションオブジェクト、1つのライブビュー、粒度の再調整、および2つを縫い合わせるのではなく、その1つのビューを拡大することによって処理されるウィンドウ境界を越える読み取りです

uses
  Winapi.Windows, System.SysUtils;

type
  TWindowedFileMapper = class
  private
    FFile: THandle;
    FMapping: THandle;
    FFileSize: Int64;
    FGranularity: DWORD;      // SYSTEM_INFO.dwAllocationGranularity
    FWindowSize: NativeUInt;  // default view size
    FViewBase: PByte;         // base of the current view (aligned)
    FViewOffset: Int64;       // file offset FViewBase corresponds to
    FViewSize: NativeUInt;    // bytes mapped in the current view
    procedure Unmap;
  public
    constructor Create(const FileName: string;
      WindowSize: NativeUInt = 64 * 1024 * 1024);
    destructor Destroy; override;
    function Map(Offset: Int64; Size: NativeUInt): PByte;
    procedure ReadBytes(Offset: Int64; var Buffer; Count: NativeUInt);
    property FileSize: Int64 read FFileSize;
  end;

constructor TWindowedFileMapper.Create(const FileName: string;
  WindowSize: NativeUInt);
var
  Info: TSystemInfo;
begin
  inherited Create;
  FFile := CreateFile(PChar(FileName), GENERIC_READ, FILE_SHARE_READ, nil,
    OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
  if FFile = INVALID_HANDLE_VALUE then
    RaiseLastOSError;
  if not GetFileSizeEx(FFile, FFileSize) then
    RaiseLastOSError;
  // The section object reserves no address space, whatever the file size
  FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
  if FMapping = 0 then
    RaiseLastOSError;
  GetSystemInfo(Info);
  FGranularity := Info.dwAllocationGranularity;  // 64 KB in practice
  FWindowSize := WindowSize;
end;

destructor TWindowedFileMapper.Destroy;
begin
  Unmap;
  if FMapping <> 0 then CloseHandle(FMapping);
  if FFile <> INVALID_HANDLE_VALUE then CloseHandle(FFile);
  inherited;
end;

procedure TWindowedFileMapper.Unmap;
begin
  if FViewBase <> nil then
  begin
    UnmapViewOfFile(FViewBase);
    FViewBase := nil;
    FViewSize := 0;
  end;
end;

function TWindowedFileMapper.Map(Offset: Int64; Size: NativeUInt): PByte;
var
  AlignedOffset: Int64;
  Delta, MapSize: NativeUInt;
begin
  if (Offset < 0) or (Offset + Int64(Size) > FFileSize) then
    raise ERangeError.CreateFmt(
      'Map request at %d for %d bytes is outside the file',
      [Offset, Int64(Size)]);

  // Fast path: the requested range already sits inside the live view
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // slide: never hold two views at once

  // Views must start on an allocation-granularity boundary
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // request straddles the window end:
    MapSize := Size + Delta;       // grow this one view to cover it
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // clamp at EOF

  FViewBase := MapViewOfFile(FMapping, FILE_MAP_READ,
    DWORD(AlignedOffset shr 32), DWORD(AlignedOffset and $FFFFFFFF),
    MapSize);
  if FViewBase = nil then
    RaiseLastOSError;

  FViewOffset := AlignedOffset;
  FViewSize := MapSize;
  Result := FViewBase + NativeInt(Delta);
end;

procedure TWindowedFileMapper.ReadBytes(Offset: Int64; var Buffer;
  Count: NativeUInt);
begin
  Move(Map(Offset, Count)^, Buffer, Count);
end;

2つの詳細が重要です。Map の先頭にある高速パスは、要求された範囲がすでにライブビュー内にある場合、カーネルトランジションなしでポインターを返します。オブジェクトストリームのクラスタリングのおかげでこれが一般的なケースであり、節約の源泉となります。そして、デフォルトウィンドウの終わりをまたぐ要求は、2つを縫い合わせるのではなく、その1つのビューの MapSize を拡大します。これにより ReadBytes はワンライナーに保たれ、呼び出し元は部分読み取りループから解放されます

ウィンドウサイズは寛容な調整ノブです。64 MBでは、1.8 GBファイルのフルスイープは29個のビューになります。256 MBでは8個ですが、断片化された32ビット空間に各予約を配置するのは難しくなります。また、16 MB未満では、ホップが多いファイルは気付くほど頻繁に再マップされます。64〜256 MBの範囲であればどこでも、マップのトラフィックは統計的ノイズです

システムコールのカウント

次に演算です。テストファイル:1.8 GB、平均約600バイトのペイロードを持つ300,000個の間接オブジェクト。オブジェクトごとのパーサーは、SetFilePointerEx と4 KBの ReadFile でそれぞれをフェッチします。つまり600,000回のカーネルトランジションです。キャッシュされた読み取りシステムコールは現在のx64ハードウェアで約1.5 μsで往復するため、1バイトを解析する前に600,000 × 1.5 μs ≈ 0.9秒の純粋なカーネルオーバーヘッドになります。これはウォームキャッシュの最良のケースです。コールド状態では、各ホップはデバイス操作になります。NVMeの4 KBランダム読み取りの約20 μsの有効レイテンシでは、そのうちの300,000回で約6秒のデバイス時間がかかります。SATAクラスのストレージでは数分です

読み取りは間違ったデータも移動します。300,000 × 4 KBはユーザーバッファを介して1.2 GBを押し出し、約180 MBのペイロードを配信します。つまり、6倍の増幅であり、すべてのバイトがカーネルからユーザーにコピーされます

オブジェクトストリームクラスターのサイズに合わせた先読みバッファは、最初の誠実な改善です。オブジェクトごとに1回ではなく、クラスターごとに256 KBの読み取りを1回行うことで、トランジション数が1桁から2桁減少します。また、ネットワーク共有など、マッピングが扱いにくい場合に適切なツールでもあります

ウィンドウマッパーはさらに進んでいます。フルスイープは、29回の MapViewOfFile 呼び出しと29回の UnmapViewOfFile 呼び出し、つまり600,000回に対して58回の明示的なトランジションです。実際のxref駆動の解析はクリーンなスイープではありませんが、高速パスはライブウィンドウ内のすべてのフェッチを吸収します。テストアーカイブでのメタデータインデックス作成パスは数百回の再マップで落ち着きました。マッピングはカーネルの作業を排除するわけではありません。明示的なシステムコールを、メモリマネージャーが複数ページのクラスターで、ファイルキャッシュから直接、ユーザー空間へのコピーなしで解決するページフォールトに変換し、決して触れられない領域には何のコストもかかりません。エンドツーエンドで、インデックス作成パスはオブジェクトごとの読み取りでコールド23秒、ウォーム7.1秒だったのに対し、マッパーを使用するとコールド6.5秒、ウォーム1.9秒になりました。残っているのはzlibの解凍であり、IOではありません

FILE_FLAG_NO_BUFFERINGが適合する場所

FILE_FLAG_NO_BUFFERING は、ハードなアラインメント規則(オフセット、長さ、バッファアドレスがすべてセクターに揃っていること)と引き換えにシステムキャッシュをバイパスします。これは、誰も2度読まないバイトでキャッシュをあふれさせてしまうような、シングルパスの順次ジョブ(アーカイブ全体を書き換えるバッチの再シリアル化、または完成した出力でのリニアライズパス)でその価値を発揮します。4〜8 MBの整列されたバッファを使用すると、キャッシュを汚染することなくデバイスの順次帯域幅に近づきます

解析にはまったく不適切です。バッファなしハンドルによるランダムなxrefホップは、300バイトの辞書のフェッチをすべて、2回目の訪問を吸収するキャッシュがない完全な物理読み取りに変えてしまいます。また、異なるページが同じオブジェクトストリームに解決されるため、PDF解析は領域に常に再訪問します。順次書き換えにはバッファなしのIO、ランダム解析にはマップされたIOまたはキャッシュされたIOを使用します。フラグはハンドルごとに設定されるため、1つのパイプラインで同じファイルに両方を保持できます

64ビット、ワーキングセット、および書き込み側

64ビットビルドでは、アドレス空間の反対は消えます。ウィンドウとしてファイルサイズを渡すと、上記のクラスは単一の完全なマッピングに退化します。長期間実行されるサービスでの注意点:読み取り専用のファイルバックページはコミットを要求しないため、コミットカウンターは穏やかなままですが、触れられたすべてのページはワーキングセットに加わります。1.8 GBの大部分を解析するとワーキングセットもそれに応じて増大し、他のすべてを追い出します。制限されたウィンドウはそこに上限を設けるため、アドレス空間が空いている場合でも、スライディングパターンが適切なデフォルトのままになります

書き込み側では、最も安価なIOは決して発行されないIOです。PDFの増分更新メカニズム(ISO 32000-1 §7.5.6)は、元のバイト(移動しない)の後に変更されたオブジェクトと新しい相互参照セクションを追加します。1.8 GBのアーカイブに1ページをスタンプすると、数万バイトが追加されます。完全な書き換えはすべての1.8 GBを移動し、桁が5つも異なりますが、追加は末尾への純粋な順次出力です

losLabライブラリが適合する場所

losLabのPDFライブラリはどちらも、この規律をAPIサーフェスとして出荷しています。HotPDFダイレクトファイルAPI は、オブジェクトツリーを構築することなくファイルハンドルを介してページ数と構造を読み取り、ファイルレベルでコピーおよび復号化し、BeginIncrementalUpdate を通じてデルタを書き込みます。これは、上記の追加専用戦略をパッケージ化したものです。PDFlibPasはそのダイレクトアクセスレイヤーで同じルートを採用しています。相互参照テーブルをその場でたどり、オブジェクトを遅延フェッチし、ファイル間でページ範囲を抽出し、編集を増分リビジョンとして永続化するストリーミングリーダーです。独自のパーサーを作成している場合は、マッパークラスを採用してください。ドキュメントパイプラインを実行している場合は、ライブラリにウィンドウを適切に保たせてください

注: ギガバイト規模のドキュメントの最適化されたIO処理は、DelphiおよびC++Builder用の HotPDF VCLコンポーネント に直接組み込まれています