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