技術記事

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

PDFlibPasは、上限を設けた読み取り専用のmemory-mapped viewを通じてローカルPDFを開けます。LoadFromMappedFileDAOpenMappedFileはファイル上に正確に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も変更なしで動作します

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と同じようにReadSeekSizeも実装します。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です。LoadFromFileDAOpenFileは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で拒否を報告します

Windowsではmapped streamがFILE_SHARE_READFILE_SHARE_WRITEFILE_SHARE_DELETEFILE_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がファイル末尾を越えることはありません

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はそれをCreateEnterLeaveのない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のページにあります