Articol tehnic

Optimizarea performanței IO pentru procesarea PDF la scară de gigabytes

Prima citire utilă a unui parser PDF se află la capătul greșit al fișierului. Formatul plasează indicatorul startxref în octeții finali, astfel încât procesarea unei arhive de 1,8 GB începe cu o căutare la coadă, o citire de un kilobyte, apoi un salt acolo unde tabelul de referințe încrucișate spune că se află catalogul documentului. De acolo, analiza este o plimbare aleatorie pe întregul interval de octeți. Tot ceea ce face bine IO cu buffer — citire secvențială în avans în spatele indicatorului fișierului — este îndreptat către un volum de muncă pe care PDF nu îl are

Prima versiune a acestui articol susținea că un fișier mapat în memorie rezolvă eroarea de memorie insuficientă pe 32 de biți pe care o întâmpină TMemoryStream la o intrare de 2 GB. Această afirmație este greșită, iar modul în care este greșită indică soluția reală: o fereastră de mapare glisantă. Ceea ce urmează este modelul de acces, povestea corectată pe 32 de biți cu un mapator cu ferestre care poate fi compilat și aritmetica apelurilor de sistem pe un fișier de test de 1,8 GB, 300.000 de obiecte

De ce aspectul PDF învinge citirile cu buffer

Trei fapte structurale modelează tiparul IO. În primul rând, navigarea este condusă de decalaje: tabelul de referințe încrucișate mapează fiecare număr de obiect la o poziție absolută în octeți și nimic nu impune ca acele poziții să fie ordonate. După ani de actualizări incrementale, obiectul 4102 se poate afla la decalajul de 1,6 GB, în timp ce obiectul 4103 se află la 30 KB. O buclă TFileStream transformă fiecare preluare într-un Seek plus un Read, două tranziții ale nucleului, cu un buffer care nu contribuie cu nimic deoarece următoarea preluare este la sute de megabytes distanță

În al doilea rând, fluxurile de obiecte (ISO 32000-1 §7.5.7) împachetează zeci sau sute de dicționare mici într-un singur container dezumflat. Preluarea unui dicționar de pagini de 300 de octeți poate însemna citirea și umflarea unui cluster de 100 KB. Reversul: obiectele scrise împreună tind să fie citite împreună, deci un buffer dimensionat la cluster servește gratuit următoarele zeci de preluări — cea mai exploatabilă regularitate din format

În al treilea rând, liniarizarea. Un fișier liniarizat încarcă în avans prima pagină și un tabel de indicii, astfel încât consumatorii să îl poată citi de la început la sfârșit. Arhivele de gigabytes nu sunt aproape niciodată liniarizate: liniarizarea este distrusă de aceleași actualizări și îmbinări incrementale care au mărit fișierul. Planificați pentru cazul ostil: salturi lungi, nicio ordonare, intrare de la coadă

Povestea pe 32 de biți, corectată

Un proces Windows pe 32 de biți are 2 GB de spațiu de adrese de utilizator, iar MapViewOfFile cu un număr de octeți de zero cere o singură rezervare contiguă de dimensiunea fișierului. Pentru o intrare de 2 GB, acea rezervare nu poate reuși: după EXE, DLL-urile împrăștiate și stivele de fire de execuție, cel mai mare bloc contiguu liber într-un proces Delphi tipic pe 32 de biți se află undeva între 700 MB și 1,4 GB. Apelul eșuează cu ERROR_NOT_ENOUGH_MEMORY, același zid pe care îl lovește TMemoryStream.LoadFromFile, doar mutat din memoria RAM angajată în rezervarea spațiului de adrese. O mapare a întregului fișier nu este o soluție pe 32 de biți, ci doar același eșec în spatele unor nume de API care sună mai bine

Soluția este separarea celor două lucruri pe care le face o mapare. CreateFileMapping creează obiectul de secțiune și nu costă deloc spațiu de adrese, indiferent de dimensiunea fișierului. Doar MapViewOfFile consumă spațiu de adrese și nimic nu îl obligă să mapeze întreaga secțiune: ia un decalaj de început pe 64 de biți și o lungime a vizualizării. Creați secțiunea o dată, mapați o vizualizare de 64 până la 256 MB peste regiunea analizată, demapați înainte de a glisa: costul spațiului de adrese este de o fereastră, nu de un fișier. O constrângere: decalajele vizualizării trebuie să fie multipli ai SYSTEM_INFO.dwAllocationGranularity, 64 KB în practică, astfel încât o cerere pentru decalajul 1.000.000 este rotunjită în jos la 983.040 și indicatorul apelantului este ajustat înainte cu diferența

Un mapator cu ferestre glisante în Delphi

Clasa de mai jos înglobează întreaga disciplină: un obiect de secțiune, o vizualizare activă, realinierea granularității și citiri care trec de o graniță a ferestrei gestionate prin creșterea acelei singure vizualizări în loc de unirea a două

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;

Două detalii duc greutatea. Calea rapidă din partea de sus a funcției Map returnează un indicator fără nicio tranziție a nucleului atunci când intervalul solicitat se află deja în vizualizarea activă; datorită grupării fluxului de obiecte, acesta este cazul obișnuit și de aici provin economiile. Iar o cerere care se întinde pe sfârșitul ferestrei implicite mărește MapSize pentru acea singură vizualizare, în loc să unească două, ceea ce păstrează ReadBytes o singură linie și apelanții liberi de bucle de citire parțială

Dimensiunea ferestrei este un buton iertător: la 64 MB o scanare completă a unui fișier de 1,8 GB reprezintă 29 de vizualizări, la 256 MB este de 8, dar fiecare rezervare este mai greu de plasat într-un spațiu fragmentat pe 32 de biți, iar sub aproximativ 16 MB fișierele cu multe salturi se remapează suficient de des pentru a fi observate. Oriunde în intervalul de la 64 până la 256 MB, traficul hărții este un zgomot statistic

Numărarea apelurilor de sistem

Acum aritmetica. Fișier de test: 1,8 GB, 300.000 de obiecte indirecte cu o medie de aproximativ 600 de octeți de date utile. Un analizator per obiect preia fiecare cu SetFilePointerEx plus un ReadFile de 4 KB: 600.000 de tranziții ale nucleului. Un apel de sistem de citire din cache face o rundă completă în aproximativ 1,5 μs pe hardware-ul x64 actual, deci adică 600.000 × 1,5 μs ≈ 0,9 secunde de cheltuieli generale pure ale nucleului înainte de a analiza un singur octet — cel mai bun caz de cache cald. La rece, fiecare salt este o operație a dispozitivului: la latența efectivă de ~20 μs a citirilor aleatorii NVMe de 4 KB, 300.000 dintre ele costă aproximativ 6 secunde din timpul dispozitivului; pe stocarea din clasa SATA, minute

Citirile mută, de asemenea, datele greșite: 300.000 × 4 KB împinge 1,2 GB prin bufferele de utilizator pentru a furniza aproximativ 180 MB de date utile — amplificare de șase ori, fiecare octet copiat din nucleu la utilizator

Un buffer de citire în avans dimensionat la grupurile de fluxuri de obiecte este prima îmbunătățire onestă: o citire de 256 KB pe grup în loc de una pe obiect reduce numărul de tranziții cu unul până la două ordine de mărime. Este, de asemenea, instrumentul potrivit acolo unde maparea este incomodă, de obicei pe partajările de rețea

Mapatorul cu ferestre merge mai departe. O scanare completă înseamnă 29 de apeluri MapViewOfFile și 29 UnmapViewOfFile, 58 de tranziții explicite față de 600.000. O analiză reală condusă de xref nu este o scanare curată, dar calea rapidă absoarbe fiecare preluare în fereastra activă; o trecere de indexare a metadatelor peste arhiva de test s-a stabilit la câteva sute de remapări. Maparea nu elimină munca nucleului: convertește apelurile de sistem explicite în defecte de pagină pe care managerul de memorie le rezolvă în clustere cu mai multe pagini, direct din memoria cache a fișierului, fără nicio copie în spațiul utilizatorului, iar regiunile care nu au fost atinse nu costă nimic. De la un capăt la altul, trecerea de indexare a trecut de la 23 s la rece și 7,1 s la cald cu citiri per obiect la 6,5 s la rece și 1,9 s la cald cu mapatorul; ceea ce rămâne este zlib inflate, nu IO

Unde se potrivește FILE_FLAG_NO_BUFFERING

FILE_FLAG_NO_BUFFERING ocolește memoria cache a sistemului în schimbul unor reguli stricte de aliniere: decalaje, lungimi și adrese de buffer toate aliniate la sector. Își câștigă existența pe lucrări secvențiale cu o singură trecere care altfel ar inunda memoria cache cu octeți pe care nimeni nu-i citește de două ori — o reserializare în loturi care rescrie întreaga arhivă sau o trecere de liniarizare peste ieșirea finită. Cu buffere aliniate de 4 până la 8 MB se apropie de lățimea de bandă secvențială a dispozitivului fără a polua memoria cache

Este exact greșit pentru parsare. Salturile xref aleatorii printr-un handle fără buffer transformă fiecare preluare de dicționar de 300 de octeți într-o citire fizică completă fără memorie cache care să absoarbă a doua vizită — iar analizarea PDF revizitează în mod constant regiunile, deoarece diferite pagini se rezolvă în aceleași fluxuri de obiecte. IO fără buffer pentru rescrierea secvențială, IO mapat sau în cache pentru analizarea aleatorie; flag-ul este pe handle, deci un pipeline le poate ține pe ambele pe același fișier

Pe 64 de biți, seturile de lucru și partea de scriere

Pe o versiune de 64 de biți, obiecția spațiului de adrese dispare: treceți dimensiunea fișierului ca fereastră și clasa de mai sus degenerează într-o singură mapare completă. Capcana în serviciile care rulează mult timp: paginile doar pentru citire susținute de fișiere nu încarcă niciun commit, deci contoarele de commit rămân calme, dar fiecare pagină atinsă se alătură setului de lucru; analizați majoritatea de 1,8 GB și setul de lucru crește pentru a se potrivi, evacuând restul. Ferestrele mărginite pun un plafon în acest sens, deci modelul glisant rămâne varianta implicită corectă chiar și acolo unde spațiul de adrese este liber

Pe partea de scriere, cel mai ieftin IO este IO-ul care nu a fost emis niciodată. Mecanismul de actualizare incrementală al PDF-ului (ISO 32000-1 §7.5.6) adaugă obiectele modificate și o nouă secțiune de referințe încrucișate după octeții originali, care nu se mută niciodată. Ștampilarea unei pagini pe arhiva de 1,8 GB adaugă zeci de kilobyți; o rescriere completă mută toți cei 1,8 GB, cinci ordine de mărime distanță, iar adăugarea este o ieșire secvențială pură la coadă

Unde se potrivesc bibliotecile losLab

Ambele biblioteci PDF losLab livrează această disciplină ca suprafață API. HotPDF Direct File API citește numărul de pagini și structura printr-un handle de fișier fără a construi arborele de obiecte, copiază și decriptează la nivel de fișier și scrie diferențele prin BeginIncrementalUpdate — strategia doar de adăugare de mai sus, împachetată. PDFlibPas ia aceeași rută cu stratul său Direct Access: un cititor de flux care parcurge tabelul de referințe încrucișate la locul său, preia obiecte în mod leneș, extrage intervale de pagini de la fișier la fișier și persistă editările ca revizuiri incrementale. Dacă vă scrieți propriul analizator, clasa mapator este a voastră să o luați; dacă rulați o conductă de documente, lăsați biblioteca să mențină fereastra onestă

Notă: Manipularea optimizată a IO pentru documente la scară de gigabytes este integrată direct în Componenta VCL HotPDF pentru Delphi și C++Builder