Техническа статия

Memory-mapped PDF в Delphi: плъзгащ прозорец

PDFlibPas може да отвори локален PDF през ограничен read-only memory-mapped view: LoadFromMappedFile и DAOpenMappedFile поддържат точно един плъзгащ се прозорец върху файла, remap-ват го при нужда и обслужват всеки object slice чрез четене по абсолютен offset. Delphi PDF library никога не държи целия source в паметта, така че използваното address space остава равно, докато файлът расте. Дизайнът съществува за една конкретна workload: гигабайтови PDF-и, при които parser-ът е приключил loading-а, но продължава да се връща към диска, object по object и stream fragment по stream fragment

Защо sparse read-овете остават скъпи след зареждането на PDF-а?

Зареждането на PDF не означава, че четенето е приключило, а при многогигабайтов файл точно тази разлика поглъща времето. Cross-reference table или cross-reference stream (ISO 32000-1 §7.5.4 и §7.5.8) записва само откъде започва всеки indirect object. Байтовете идват по-късно, когато се render-ва страница, decode-ва се font program или се извлича embedded file stream (ISO 32000-1 §7.11.4). Архив от 2 GB с десетки хиляди object-и се превръща в десетки хиляди малки unordered read-ове и никой от тях не е известен при load

Пътят на тези read-ове преди минаваше през общ Seek, последван от Read върху един positional stream, и се проваляше в две посоки едновременно. Всеки fragment плаща за file read, дори когато страницата вече е в operating-system cache-а, а cursor-ът е споделено mutable state, така че локален файл и byte-range source-ът зад progressive PDF range loading с prefetch не можеха да използват един и същ parser code без борба за position-а. PDFlibPas решава и двете, като превръща четенето по absolute offset от optimization в contract

Какво гарантира TPDFReadAtStream?

TPDFReadAtStream гарантира read по абсолютен offset, който нито зависи от, нито променя логическия cursor на stream-а. Това е abstract TStream descendant с точно един virtual method и от него произлизат двата cursor-independent source-а в library-то: TReadOnlyMappedFileStream за локални файлове и TByteRangeStream за remote source-ове, обслужвани чрез range. Object-slice reader-ът проверява веднъж дали source-ът е TPDFReadAtStream и се връща към старата последователност seek-then-read, когато не е, така че обикновен file stream или memory stream продължава да работи без промяна

type
  // Read-only stream-ове, чиито absolute read-ове избягват shared Seek плюс Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Windowed read-only достъп до един локален файл
  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 точно там, където е бил, което позволява на вложените parser нива да издават read-ове без save-and-restore танц около всяко извикване. TReadOnlyMappedFileStream все пак имплементира Read, Seek и Size като всеки друг TStream, Seek ограничава логическата позиция в рамките на файла, а Write винаги връща 0, защото source-ът е отворен read-only

Отваряне на PDF през mapped view в Delphi

Две explicit entry point-а отварят mapped source и нито един от тях не променя поведението на вече използваните entry point-и. LoadFromMappedFile зарежда и избира document; DAOpenMappedFile връща Direct Access handle към същия файл, което е режимът за сливане и разделяне на гигабайтови PDF-и през Direct Access. LoadFromFile и DAOpenFile запазват своите file-sharing, error и compatibility semantics, така че за caller-и, които не opt-in-ват, нищо не се променя. И двата mapped entry point-а приемат заявен WindowSize в bytes и Options bitmask, като и за двата 0 е допустима стойност

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 избира default от 64 MiB; 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 вече обхожда mapped windows вместо да seek-ва
    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 в незабавен и диагностицируем failure още при open. Когато Options е 0, и двата entry point-а приемат read-only file-stream fallback: ако platform-ата няма mapping code или mapping call-ът се провали, document-ът пак се отваря и всеки read минава през обикновен file stream. С flag-а PDFlibPas приема input-а само когато първият view е установен и докладва отказа чрез LastErrorCode 401, вместо да зареди document, който тихо се държи точно като стария path

В Windows mapped stream-ът отваря втори read-only handle с FILE_SHARE_READ, FILE_SHARE_WRITE и FILE_SHARE_DELETE плюс FILE_FLAG_RANDOM_ACCESS, създава PAGE_READONLY mapping върху него и map-ва първия window вътре в constructor-а. Eager mapping-ът е цялата идея: failure за „mapping required“ се появява при LoadFromMappedFile, а не при първия lazy object read по средата на rendering job. Важно е обаче да се каже къде спира гаранцията. Mapping code-ът се компилира само за Windows target-и, а файл с нулев размер изобщо не опитва mapping, така че PDF_MAPPED_FILE_REQUIRE_MAPPING е заявка, която легитимно може да се провали, а не portable promise. Отрицателен WindowSize или който и да е bit в Options, различен от единствената документирана стойност, се отхвърля директно със същата грешка 401

Един прозорец, remap-нат към allocation granularity

Задържа се само един view и точно това прави използването на address space независимо от размера на файла. WindowSize 0 избира 64 MiB; стойност под system allocation granularity се повишава до нея; стойност над 1 GiB се ограничава; резултатът се закръгля нагоре до цяло число granularity units, 65536 bytes в Windows, освен ако GetSystemInfo не върне различен dwAllocationGranularity. Когато read попадне извън текущия view, PDFlibPas unmap-ва стария, подравнява заявения offset надолу към granularity boundary и map-ва нов window там. Последният window се ограничава до физическия размер на файла, така че view-ът никога не излиза след края му

Един read може да пресече произволен брой windows: loop-ът копира каквото текущият view може да даде, remap-ва и продължава, а заявка, която излиза след края, връща short count вместо failure. PDFlibPas нарочно не ви дава pointer към view-а, защото следващият cross-window read го инвалидира и никой caller не би могъл разумно да се защити от това. Mapped bytes се копират директно в parser-owned destination buffers, което премахва допълнителния file input buffer и превключването на position-а, но library-то не твърди zero-copy за крайното parser storage. Windowing-ът при read се съчетава и със страната за write, тъй като byte-level reference shifting при бързо PDF merge извежда object bytes, докато mapped source-ът ги подава навътре. Trade-off-ът за window size е очевиден: по-малък window държи по-малко address space и remap-ва по-често, което обикновено е правилният избор в 32-bit process

Какво защитава lock-ът и какво докладва GetMappedFileInfo

Един critical section покрива mapped view-а, fallback file cursor-а, логическата позиция и statistics-ите, а разделянето между двата read метода следва директно от това. ReadAt взема lock-а и извиква lock-free internal reader; Read взема същия lock, извиква същия internal reader при текущата логическа позиция и после я придвижва. Повторното използване на internal function вместо public ReadAt избягва recursive locking, а задържането на lock-а през целия copy loop запазва коректността на single-window remap при concurrent call-ове. Един Free Pascal detail си струва да се знае преди port: FPC Windows unit-ът декларира собствен record с име TCriticalSection, затова field-ът и конструирането му трябва да бъдат написани като SyncObjs.TCriticalSection. Delphi компилира неквалифицираната форма без проблем, а FPC я разрешава до record без Create, Enter или Leave

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 е false, когато е активен portable file-stream fallback-ът, и е единственото поле, което доказва, че mapping никога не е установен
  • windowSize е ефективният подравнен window, а не заявената стойност, докато mappedBytes е по-малък в tail window-а
  • mappedOffset е allocation-aligned началото на задържания view или -1, когато в момента няма активен view
  • readCalls брои успешните in-range read заявки, bytesRead брои байтовете, копирани към caller-и, а remapCount включва първия view

Targeted regression-ите покриват absolute read-ове през граница на window, запазване на логическия cursor, short read-ове в края, невалидни offset-и, отхвърлени write-ове, remapping между разделени windows, deferred extraction на 220 KB incompressible attachment и невалидни statistics след DACloseFile; headless suite-овете за Win32 и Win64 откриха по 1467 теста и преминаха всички без ignored, failed, errored или leaked резултати. Ако работите с гигабайтови PDF-и в Delphi или C++Builder и profiler-ът ви продължава да сочи file read-ове вместо parsing, mapped-file entry point-ите заслужават един следобед измерване, а GetMappedFileInfo ще ви каже дали действително сте получили mapping. Пълният API reference и trial build са на страницата на PDFlibPas Delphi PDF library