Prvo korisno čitanje PDF parsera nalazi se na pogrešnom kraju datoteke. Format smješta pokazivač startxref u zadnje bajtove, pa tako obrada arhive od 1.8 GB počinje skokom na sam kraj, čitanjem jednog kilobajta, a zatim skokom tamo gdje tablica unakrsnih referenci kaže da živi katalog dokumenta. Od tamo pa nadalje parsiranje je nasumična šetnja kroz cijeli raspon bajtova. Sve ono u čemu je buffered IO dobar — sekvencijalno unaprijedno čitanje iza pokazivača datoteke — usmjereno je na radno opterećenje kakvo PDF jednostavno nema
Prva verzija ovog članka tvrdila je da memorijski mapirana datoteka rješava 32-bitni kvar s manjkom memorije s kojim se susreće TMemoryStream pri unosu od 2 GB. Ta je tvrdnja pogrešna, a način na koji je pogrešna ukazuje na pravo rješenje: pomični prozor mapiranja. Ono što slijedi jest obrazac pristupa, ispravljena 32-bitna priča s kompilabilnim prozorskim maperom te aritmetika sistemskih poziva na probnoj datoteci od 1.8 GB i 300.000 objekata
Zašto PDF raspored poražava međuspremno čitanje
Tri strukturne činjenice oblikuju IO obrazac. Prvo, navigacija je vođena pomacima: tablica unakrsnih referenci mapira svaki broj objekta na apsolutnu poziciju bajta, i ništa ne zahtijeva da te pozicije budu poredane. Nakon godina inkrementalnih ažuriranja, objekt 4102 može sjediti na pomaku od 1.6 GB, dok objekt 4103 sjedi na 30 KB. Petlja unutar TFileStream pretvara svako dohvaćanje u Seek plus Read, odnosno u dvije tranzicije kernela, s međuspremnikom koji ne pridonosi ničemu jer je iduće dohvaćanje stotinama megabajta daleko
Drugo, tokovi objekata (ISO 32000-1 §7.5.7) pakiraju desetke ili stotine malih rječnika u jedan ispuhani spremnik. Dohvaćanje jednog rječnika stranice od 300 bajtova može značiti čitanje i napuhavanje klastera od 100 KB. S druge strane: objekti koji su zapisani zajedno obično se i čitaju zajedno, stoga međuspremnik dimenzioniran prema klasteru besplatno poslužuje sljedećih desetak dohvaćanja — što je najiskoristivija pravilnost u cijelom formatu
Treće, linearizacija. Linearizirana datoteka učitava unaprijed prvu stranicu i tablicu sa smjernicama kako bi je potrošači mogli čitati od naprijed prema natrag. Gigabajtne arhive gotovo nikada nisu linearizirane: linearizacija biva uništena istim inkrementalnim ažuriranjima i spajanjima koja su samu datoteku učinila velikom. Planirajte neprijateljski slučaj: duge skokove, bez redoslijeda, ulazak s kraja
Ispravljena 32-bitna priča
32-bitni Windows proces ima 2 GB korisničkog adresnog prostora, a MapViewOfFile s brojem bajtova postavljenim na nulu traži jednu kontinuiranu rezervaciju veličine same datoteke. Za ulaz od 2 GB takva rezervacija ne može uspjeti: nakon same EXE datoteke, raštrkanih DLL-ova i stogova dretvi, najveći slobodni kontinuirani blok u tipičnom 32-bitnom Delphi procesu leži negdje između 700 MB i 1.4 GB. Poziv završava s pogreškom ERROR_NOT_ENOUGH_MEMORY, što je isti zid na koji udara TMemoryStream.LoadFromFile, samo premješten s alocirane RAM memorije na rezervaciju adresnog prostora. Mapiranje pune datoteke nije nikakvo rješenje u 32-bitnom sustavu, već tek isti kvar prikriven API nazivima koji bolje zvuče
Rješenje se nalazi u razdvajanju dviju stvari koje mapiranje obavlja. CreateFileMapping kreira objekt odjeljka i uopće ne košta nikakav adresni prostor, kolika god datoteka bila. Samo MapViewOfFile troši adresni prostor, i ništa ga ne tjera da mapira cijeli odjeljak: on prima 64-bitni početni pomak i duljinu prikaza. Kreirajte odjeljak jednom, mapirajte prikaz od 64 do 256 MB preko regije koja se parsira, te odmapirajte prije nastavka pomicanja: trošak adresnog prostora jednak je jednom prozoru, a ne cijeloj datoteci. Postoji jedno ograničenje: pomaci prikaza moraju biti višekratnici od SYSTEM_INFO.dwAllocationGranularity, što u praksi iznosi 64 KB, pa se tako zahtjev za pomak na 1.000.000 zaokružuje naniže na 983.040, dok se pozivateljev pokazivač prilagođava unaprijed za nastalu razliku
Maper s pomičnim prozorom u Delphiju
Klasa u nastavku omotava cjelokupnu disciplinu: jedan objekt odjeljka, jedan živi prikaz, ponovno usklađivanje granularnosti te čitanja koja prelaze granicu prozora, pri čemu se to rješava povećanjem tog jednog prikaza umjesto šivanjem dva odvojena
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;
Dva detalja nose čitavu težinu. Brza staza na vrhu Map vraća pokazivač bez tranzicije kernela onda kada se zatraženi raspon već nalazi unutar živog prikaza; zahvaljujući grupiranju tokova objekata to je uobičajen slučaj i upravo onaj iz kojeg proizlaze uštede. Uz to, zahtjev koji se proteže izvan kraja zadanog prozora povećava MapSize za taj jedan prikaz umjesto da ih šiva dva, što održava ReadBytes rješenjem u jednom redu i oslobađa pozivatelje potrebe za petljama s djelomičnim čitanjem
Veličina prozora oprašta pogreške kod ugađanja: na 64 MB potpuni prolazak kroz datoteku od 1.8 GB donosi 29 prikaza, na 256 MB donosi 8, ali svaku takvu rezervaciju teže je smjestiti u fragmentiranom 32-bitnom prostoru, dok se ispod oko 16 MB datoteke zasićene skokovima remapiraju dovoljno često da to postane uočljivo. Bilo gdje u rasponu od 64 do 256 MB, mapirani promet je tek statistički šum
Brojanje sistemskih poziva
A sada aritmetika. Testna datoteka: 1.8 GB, 300.000 neizravnih objekata u prosjeku s oko 600 bajtova korisnog opterećenja. Parser po objektu dohvaća svakoga pomoću SetFilePointerEx plus 4 KB dugačkim ReadFile pozivom: 600.000 tranzicija kernela. Keširani sistemski poziv za čitanje odradi se otprilike za 1.5 μs na trenutnom x64 hardveru, tako da je to 600.000 × 1.5 μs ≈ 0.9 sekundi čistog preopterećenja kernela i to prije parsiranja ijednog jedinog bajta — što je najbolji mogući slučaj u kojem je predmemorija topla. Ako je hladna, svaki je skok operacija na uređaju: uz ~20 μs efektivne latencije za NVMe nasumična čitanja po 4 KB, 300.000 njih košta oko 6 sekundi vremena uređaja; a na SATA klasi pohrane, na to odu minute
Osim toga, ova čitanja premještaju krive podatke: 300.000 × 4 KB gura 1.2 GB kroz korisničke međuspremnike kako bi isporučilo približno 180 MB korisnog opterećenja — šesterostruko povećanje, a svaki bajt je kopiran iz kernela prema korisniku
Međuspremnik za unaprijedno čitanje dimenzioniran prema klasterima tokova objekata prvo je doista pošteno poboljšanje: jedno čitanje od 256 KB po klasteru, umjesto jednog po objektu, smanjuje broj prijelaza za jedan do dva reda veličine. To je također i pravi alat za mjesta na kojima je mapiranje nespretno, što se najčešće odnosi na mrežna dijeljenja
Prozorski maper ide i dalje. Puni prolaz iziskuje 29 MapViewOfFile i 29 UnmapViewOfFile poziva, što je 58 eksplicitnih tranzicija naspram prijašnjih 600.000. Pravo xref-vođeno parsiranje nije čisti linearni prolaz, ali brzi put apsorbira svaki dohvat unutar živog prozora; prolaz indeksiranja metapodataka preko testne arhive smirio se na nekoliko stotina remapiranja. Samo mapiranje ne uklanja posao kernela: pretvara eksplicitne sistemske pozive u stranice s pogreškama koje upravitelj memorijom rješava u klasterima s više stranica, ravno iz predmemorije datoteke bez ikakvog kopiranja u korisnički prostor, dok regije koje nikada nisu ni dotaknute ne koštaju apsolutno ništa. Ukupno gledano, od početka do kraja, prolaz za indeksiranje pao je sa 23 s za hladni i 7.1 s za topli prolaz uz čitanja po pojedinom objektu, na 6.5 s za hladni i 1.9 s za topli prolaz uz pomoć mapera; ono što preostaje odnosi se na zlib inflate, a ne više na IO
Gdje se uklapa FILE_FLAG_NO_BUFFERING
FILE_FLAG_NO_BUFFERING zaobilazi sistemsku predmemoriju u zamjenu za čvrsta pravila poravnanja: pomaci, duljine i adrese međuspremnika su poravnati sa sektorima. Zarađuje svoje mjesto na sekvencijalnim poslovima iz jednog prolaza koji bi inače preplavili predmemoriju bajtovima koje nitko nikada ne čita dvaput — npr. serijska reserijalizacija koja prepisuje cjelokupnu arhivu ili prolaz linearizacije preko konačnog izlaza. S 4 do 8 MB poravnatim međuspremnicima približava se pojasnoj širini sekvencijalnog uređaja bez onečišćenja same predmemorije
Potpuno je pogrešan izbor za parsiranje. Nasumični xref skokovi preko nepredmemoriranog handlea pretvaraju svaki pokušaj dohvata rječnika od 300 bajtova u potpuno fizičko čitanje pri čemu nema predmemorije koja bi upila onaj drugi posjet — a parsiranje PDF-a konstantno nanovo posjećuje regije jer se različite stranice razrješavaju u iste tokove objekata. Nepredmemorirani IO idealan je za sekvencijalno prepisivanje, dok za nasumično parsiranje služi mapirani ili predmemorirani IO; zastavica se dodjeljuje po pojedinačnom handleu, pa stoga jedan cjevovod može imati i jedno i drugo nad istom datotekom
64 bita, radni skupovi i strana za pisanje
U 64-bitnom izdanju nestaje sav prigovor oko adresnog prostora: jednostavno proslijedite veličinu datoteke kao prozor i navedena klasa iznad degradirat će u jedinstveno mapiranje cjelokupnog dokumenta. Kvaka koja leži unutar dugotrajnih servisa: stranice samo za čitanje koje su potpomognute datotekama ne naplaćuju commit, pa commit brojači ostaju mirni, ali se zato svaka dotaknuta stranica pridružuje radnom skupu; ako parsirate većinu od tih 1.8 GB, i vaš će radni skup narasti u jednakoj mjeri, usput izbacujući sve ostalo. Ograničeni prozori postavljaju gornji limit na tu brojku, stoga pomični obrazac ostaje ispravan standardni izbor čak i tamo gdje je adresni prostor potpuno slobodan
Na strani za pisanje, najjeftiniji IO je onaj IO koji nikada nije ni izdan. PDF-ov mehanizam inkrementalnog ažuriranja (ISO 32000-1 §7.5.6) dodaje promijenjene objekte kao i novi odjeljak unakrsnih referenci odmah nakon originalnih bajtova, koji se nikada ne pomiču. Stavljanje pečata na jednu stranicu na arhivu od 1.8 GB priključit će desetke kilobajta; ali će potpuno prepisivanje premjestiti čitavih 1.8 GB, što razdvaja ova dva rješenja na pet reda veličine, dok je dodavanje na kraj čisti sekvencijalni izlaz s kraja datoteke
Gdje se uklapaju losLab biblioteke
Obje losLabove PDF biblioteke isporučuju takvu disciplinu kao API površinu. HotPDF Direct File API čita broj stranica i strukturu preko datotečnog handlea, bez same izgradnje hijerarhije objekata, zatim kopira i dešifrira na datotečnoj razini, a zapisuje izmijenjene razlike korištenjem naredbe BeginIncrementalUpdate — što pak predstavlja upakiranu strategiju navedenog dodavanja na kraj. PDFlibPas ide istim tim smjerom sa svojim podslojem izravnog pristupa: s čitačem toka koji šeta tablicom unakrsnih referenci direktno na samom mjestu, lijeno dohvaća objekte, ekstrahira raspone stranica na relaciji datoteka na datoteku i trajno sprema uređivanja kao inkrementalne revizije. Ako pak pišete vlastiti parser, sama maper klasa je vaša za uzimanje; ako pak pokrećete vlastiti dokumentni cjevovod, prepustite onda to radije biblioteci koja će brinuti za pošteno stanje vašeg prozora
Napomena: Optimizirano upravljanje IO operacijama za dokumente u gigabajtnim omjerima ugrađeno je izravno u HotPDF VCL Component za jezike Delphi i C++Builder