技術記事

DelphiでメモリマップPDFをスライディングウィンドウで読む

PDFlibPasは、上限を設けた読み取り専用のmemory-mapped viewを通じてローカルPDFを開けます。LoadFromMappedFileとDAOpenMappedFileはファイル上に正確に1つのスライディングウィンドウを保持し、必要に応じてremapし、絶対オフセットのreadで各オブジェクト断片を供給します。Delphi PDF libraryはソース全体をメモリに保持しないため、ファイルが大きくなってもアドレス空間の使用量は一定です。この設計が必要なのは、parserの初回ロードが終わった後も、オブジェクト単位、stream fragment単位でディスクへ戻り続けるギガバイト級PDFというワークロードです

PDFをロードした後も疎なreadが高くつく理由

PDFをロードしても読み取りが終わるわけではなく、数ギガバイトのファイルではその差に時間がかかります。cross-reference tableまたはcross-reference stream(ISO 32000-1 §7.5.4および§7.5.8)が記録するのは各indirect objectの開始位置だけです。実際のバイトは、ページをrenderするとき、font programをdecodeするとき、またはembedded file stream(ISO 32000-1 §7.11.4)をextractするときに後から到着します。数万個のオブジェクトを持つ2 GBのarchiveは、順序づけられていない小さなreadを数万回発生させますが、それらはロード時にはどれも分かりません

以前このreadが通っていた経路は、1つのpositioned streamで共有されたSeekの後にReadを行うもので、2方向で破綻していました。ページがすでにOS cacheに常駐していても、各断片がfile readのコストを払い、cursorは共有されたmutable stateでした。そのため、prefetch付きのprogressive PDF range loadingの背後にあるlocal fileとbyte-range sourceは、positionを奪い合わずに同じparser codeを実行できませんでした。PDFlibPasは、absolute-offset readを最適化ではなく契約へ格上げすることで両方を直します

TPDFReadAtStreamは何を保証するのか

TPDFReadAtStreamは、logical stream cursorに依存せず、またそれを乱さないabsolute offsetでのreadを保証します。これは正確に1つのvirtual methodを持つabstractなTStream descendantで、library内のcursor-independent sourceは両方ともここから派生します。ローカルファイル用のTReadOnlyMappedFileStreamと、range配信されたremote source用のTByteRangeStreamです。object-slice readerはsourceがTPDFReadAtStreamかどうかを一度だけ確認し、そうでなければ従来のseek-then-readへfallbackします。そのため通常のfile streamやmemory streamも変更なしで動作します

PDFlibPas が object slice の読み取りを TPDFReadAtStream へ集約する様子を示す図。抽象 TStream 派生クラスの唯一の仮想メソッド ReadAt(Offset, Buffer, Count) は Position に触れず絶対 offset を扱い、TReadOnlyMappedFileStream がローカルファイルを mapped window で、TByteRangeStream が range 配信のリモートソースを覆い、parser は interface を一度尋ねるだけで、だめなら通常の Seek + Read の手順へ fallback する
PDFlibPas の cursor 非依存な 2 つのソースは同じ抽象基底から派生するため、入れ子の parser 層は save と restore のステップを踏まずに絶対 offset の読み取りを出せます。普通の stream でも seek してから読む fallback で動きます
type
  // 共有されたSeekとReadを避けるabsolute read対応のread-only stream
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // 1つのローカルファイルへのwindowed read-only access
  TReadOnlyMappedFileStream = class(TPDFReadAtStream)
  private
    FMemoryMapped: Boolean;
  public
    constructor Create(const FileName: WideString; WindowSize: Int64 = 0);
    function GetStats: TPDFMappedFileStats;
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; override;
    property MemoryMapped: Boolean read FMemoryMapped;
  end;

この違いはsignatureから受ける印象より重要です。ReadAtは渡されたoffsetを使い、Positionをそのまま残します。これによってnested parser levelは、各callの周囲でsave-and-restoreを行わずにreadを発行できます。TReadOnlyMappedFileStreamは他のTStreamと同じようにRead、Seek、Sizeも実装します。Seekはlogical positionをファイル内にclampし、sourceがread-onlyで開かれるためWriteは常に0を返します

Delphiでmapped viewを通してPDFを開く

mapped sourceを開く明示的なentry pointは2つあり、既存のentry pointの動作はどちらも変えません。LoadFromMappedFileはdocumentをloadしてselectし、DAOpenMappedFileは同じファイル上のDirect Access handleを返します。これはDirect Accessを通じてギガバイト級PDFをmergeとsplitするときに使うmodeです。LoadFromFileとDAOpenFileはfile sharing、error、compatibilityの意味を従来のまま保つため、opt inしないcallerには何も移動しません。mapped entry pointはどちらも要求するWindowSizeをbyteで受け、Options bitmaskを受け取り、どちらにも0を渡せます

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0は64 MiBのdefaultを選択し、ここではmappingを必須にする
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // deferred extractionはseekではなくmapped windowをたどる
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

PDF_MAPPED_FILE_REQUIRE_MAPPINGは実際に何を強制するのか

PDF_MAPPED_FILE_REQUIRE_MAPPINGは、黙ったfallbackをopen時の即時で診断可能なfailureへ変えます。Optionsを0のままにすると、どちらのentry pointもread-only file-stream fallbackを受け入れます。platformにmapping codeがない場合やmapping callが失敗した場合でもdocumentは開き、すべてのreadは通常のfile streamを通ります。flagを設定すると、最初のviewが確立した場合にだけinputを受け入れます。古い経路とまったく同じ動作を静かにするdocumentをloadする代わりに、LastErrorCode 401で拒否を報告します

PDFlibPas の mapped entry point である LoadFromMappedFile と DAOpenMappedFile は mapping を確立できなければ読み取り専用の file stream へ fallback する一方、PDF_MAPPED_FILE_REQUIRE_MAPPING を付けるとこの静かな格下げが open 時点での即時拒否に変わり、LastErrorCode 401 を返す。負の WindowSize や未知の Options bit への回答と同じコード
flag がなければ mapping の欠落は黙って通常の file stream 読み取りへ格下げされます。flag を付ければ最初の view が存在するときだけ文書が開き、正当に失敗しうる portability の約束は open 時点で自ら名乗り上げます

Windowsではmapped streamがFILE_SHARE_READ、FILE_SHARE_WRITE、FILE_SHARE_DELETEとFILE_FLAG_RANDOM_ACCESSを付けた2つ目のread-only handleを開き、その上にPAGE_READONLY mappingを作り、constructor内で最初のwindowをmapします。eagerにmappingすることが肝心です。「mapping required」のfailureは、render jobの途中で最初のlazy object readを行うときではなく、LoadFromMappedFileで表面化します。ただし、保証がどこで止まるかは明確にしてください。mapping codeはWindows target向けにだけcompileされ、zero-byte fileではmappingを試みません。そのためPDF_MAPPED_FILE_REQUIRE_MAPPINGは正当に失敗し得るrequestであり、portableなpromiseではありません。負のWindowSizeや、文書化された値以外のbitをOptionsに含める場合も、同じerror 401で即座に拒否されます

1つのwindowをallocation granularityに合わせてremapする

保持するviewは常に1つだけで、これによりaddress-spaceの使用量をファイルサイズから独立させます。WindowSizeが0なら64 MiBを選び、system allocation granularityより小さい値はそこまで引き上げ、1 GiBを超える値はcapし、結果をgranularity unitの整数倍へ切り上げます。WindowsではGetSystemInfoが別のdwAllocationGranularityを報告しない限り65536 byteです。readが現在のviewの外側に到達すると、PDFlibPasはunmapし、要求されたoffsetをgranularity境界まで下へalignし、そこに新しいwindowをmapします。最後のwindowは物理ファイルサイズにclampされるため、viewがファイル末尾を越えることはありません

PDFlibPas がギガバイト級 PDF の上に読み取り専用の window を常に 1 つだけ滑らせて維持する様子を示す図。現在の view の外にある読み取りは unmap から始まり、offset を 65536 バイトの割り当て粒度へ切り下げて整列し、ファイルサイズに切り詰めた新しい window を map し直す。GetMappedFileInfo が報告する windowSize・mappedOffset・remapCount が実際に保持したものを映す
保持される view は常に 1 つだけで、必要になった時点で割り当て粒度の境界に remap されます。ギガバイト級ファイルでもアドレス空間は平らなまま、すべての byte は絶対 offset の copy として parser の buffer へ届きます

1回のreadがいくつのwindowをまたいでも構いません。loopは現在のviewが供給できる分をcopyし、remapして続けます。末尾を越えるrequestはfailureではなくshort countを返します。PDFlibPasが意図的に行わないのは、view内を指すpointerを渡すことです。次のcross-window readでそれは無効になり、callerが合理的に守る方法がないからです。mapped byteはparserが所有するdestination bufferへ直接copyされます。これで余分なfile input bufferとposition switchingはなくなりますが、最終的なparser storageについてzero-copyを主張するものではありません。read側のwindowingはwrite側とも組み合わせられます。高速PDF merge中のbyte-level reference shiftingがobject byteを外へstreamする間に、mapped sourceが内へstreamできます。window sizeのtrade-offは明白です。小さなwindowはaddress spaceを少なく使う代わりにremapが増えるため、32-bit process内では通常こちらが正しい選択です

lockが守るものとGetMappedFileInfoが返すもの

1つのcritical sectionがmapped view、fallback file cursor、logical position、statisticsを覆い、2つのread methodの分担はそこから素直に決まります。ReadAtはlockを取り、lock-freeなinternal readerを呼びます。Readは同じlockを取り、現在のlogical positionで同じinternal readerを呼んでからpositionを進めます。publicなReadAtを再利用せずinternal functionを使うのはrecursive lockingを避けるためです。またcopy loop全体でlockを保持するのは、同時callの下でもsingle-window remapを正しく保つためです。port前に知っておくべきFree Pascalの細部があります。FPCのWindows unitはTCriticalSectionという独自のrecordも宣言します。そのためfieldとconstructionはSyncObjs.TCriticalSectionと書く必要があります。Delphiは修飾なしの形を問題なくcompileしますが、FPCはそれをCreate、Enter、Leaveのないrecordとして解決します

var
  Pdf: TPDFlib;
  Handle, PageRef: Integer;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Handle := Pdf.DAOpenMappedFile('archive-2026.pdf', '',
      16 * 1024 * 1024, PDF_MAPPED_FILE_REQUIRE_MAPPING);
    if Handle = 0 then
      Exit;
    try
      PageRef := Pdf.DAFindPage(Handle, 1);
      Writeln(Pdf.DAExtractPageText(Handle, PageRef, 0));

      // {"memoryMapped":true,"fileSize":...,"remapCount":...}
      if Pdf.DAGetMappedFileInfo(Handle, Info) = 1 then
        Writeln(Info);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;
  • memoryMappedはportable file-stream fallbackが有効なとき常にfalseで、mappingが一度も確立されなかったことを証明する唯一のfieldです
  • windowSizeは要求した値ではなく有効なaligned windowで、mappedBytesはtail windowではそれより小さくなります
  • mappedOffsetは保持中viewのallocation-alignedな開始位置で、現在viewが有効でなければ-1です
  • readCallsは成功した範囲内read requestを数え、bytesReadはcallerへcopyしたbyteを数え、remapCountには初回viewも含まれます

対象を絞ったregressionでは、cross-window absolute read、logical cursor preservation、tailでのshort read、invalid offset、拒否されたwrite、離れたwindow間のremap、220 KBのincompressible attachmentのdeferred extraction、DACloseFile後にstatisticsがinvalidになることをカバーしています。Win32とWin64のheadless suiteはそれぞれ1467 testsを発見し、ignored、failed、errored、leakedの結果なしですべてに合格しました。DelphiまたはC++Builderでギガバイト級PDFを扱い、profilerがparsingではなくfile readを指し続けるなら、mapped-file entry pointを午後ひとつ使って計測する価値があります。GetMappedFileInfoで本当にmappingを得たかも分かります。完全なAPI referenceとtrial buildはPDFlibPas Delphi PDF libraryのページにあります