De eerste nuttige leesactie van een PDF-parser bevindt zich aan het verkeerde einde van het bestand. Het formaat plaatst de startxref-pointer in de laatste bytes, dus de verwerking van een archief van 1,8 GB begint met een zoekopdracht naar de staart (tail), een lezing van één kilobyte, en vervolgens een sprong naar de plek waar volgens de kruisverwijzingstabel de documentencatalogus zich bevindt. Vanaf daar is de parsing een willekeurige wandeling (random walk) over het hele bytebereik. Alles waar gebufferde IO goed in is — sequentieel vooruit lezen achter de bestandspointer — is gericht op een werklast die PDF niet heeft
De eerste versie van dit artikel beweerde dat een in het geheugen toegewezen bestand (memory-mapped file) de 32-bit 'out-of-memory' fout oplost die TMemoryStream tegenkomt bij een invoer van 2 GB. Die bewering klopt niet, en de manier waarop deze niet klopt, wijst naar de echte oplossing: een glijdend mappingvenster (sliding mapping window). Wat volgt is het toegangspatroon, het gecorrigeerde 32-bit verhaal met een compileerbare windowed mapper, en de syscall-rekenkunde op een testbestand van 1,8 GB met 300.000 objecten
Waarom PDF-lay-out gebufferde lezingen verslaat
Drie structurele feiten bepalen het IO-patroon. Ten eerste wordt de navigatie gestuurd door offsets: de kruisverwijzingstabel koppelt elk objectnummer aan een absolute bytepositie, en niets vereist dat die posities geordend zijn. Na jaren van incrementele updates kan object 4102 op een offset van 1,6 GB staan, terwijl object 4103 zich op 30 KB bevindt. Een TFileStream-lus verandert elke ophaling in een Seek plus een Read, twee kerneltransities, met een buffer die niets bijdraagt omdat de volgende ophaling honderden megabytes verderop ligt
Ten tweede verpakken objectstreams (ISO 32000-1 §7.5.7) tientallen of honderden kleine dictionary's in één 'deflated' container. Het ophalen van een paginadictionary van 300 bytes kan betekenen dat een cluster van 100 KB moet worden gelezen en opgeblazen (inflated). De keerzijde: objecten die samen worden geschreven, worden meestal ook samen gelezen, dus een buffer ter grootte van het cluster bedient het volgende dozijn ophalingen gratis — de meest bruikbare regelmaat in het formaat
Ten derde, linearisatie. Een gelineariseerd bestand laadt de eerste pagina en een hint-tabel vooraf, zodat consumenten het van voor naar achter kunnen lezen. Gigabyte-archieven zijn bijna nooit gelineariseerd: de linearisatie wordt vernietigd door dezelfde incrementele updates en samenvoegingen (merges) die het bestand groot maakten. Plan voor het vijandige scenario: grote sprongen, geen ordening, toegang via de staart
Het 32-bit verhaal, gecorrigeerd
Een 32-bit Windows-proces heeft 2 GB aan adresruimte voor de gebruiker, en MapViewOfFile met een byte-telling van nul vraagt om één aaneengesloten reservering ter grootte van het bestand. Voor een invoer van 2 GB kan die reservering niet slagen: na de EXE, verspreide DLL's en thread-stacks bevindt het grootste vrije aaneengesloten blok in een typisch 32-bit Delphi-proces zich ergens tussen de 700 MB en 1,4 GB. De aanroep mislukt met ERROR_NOT_ENOUGH_MEMORY, dezelfde muur waar TMemoryStream.LoadFromFile tegenaan botst, alleen verplaatst van toegewezen (committed) RAM naar reservering van adresruimte. Een mapping van het hele bestand is geen oplossing voor 32-bit, slechts dezelfde mislukking achter beter klinkende API-namen
De oplossing is het scheiden van de twee dingen die een mapping doet. CreateFileMapping creëert het sectie-object en kost helemaal geen adresruimte, ongeacht de bestandsgrootte. Alleen MapViewOfFile verbruikt adresruimte, en niets dwingt het om de hele sectie in kaart te brengen: het accepteert een 64-bit startoffset en een weergavelengte (view length). Creëer de sectie eenmalig, wijs een weergave van 64 tot 256 MB toe over de regio die wordt geparseerd, verwijder de mapping voordat je verder schuift: de kosten in adresruimte bedragen één venster, geen heel bestand. Eén beperking: weergave-offsets moeten veelvouden zijn van SYSTEM_INFO.dwAllocationGranularity, in de praktijk 64 KB, dus een verzoek voor offset 1.000.000 wordt naar beneden afgerond op 983.040 en de pointer van de aanroeper wordt met het verschil naar voren aangepast
Een sliding-window mapper in Delphi
De onderstaande klasse wikkelt de hele discipline in: één sectie-object, één live-weergave, het opnieuw uitlijnen van granulariteit, en het lezen over een venstergrens heen afgehandeld door die ene weergave te laten groeien in plaats van er twee aan elkaar te naaien
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;
Twee details dragen het gewicht. Het snelle pad bovenaan Map retourneert een pointer zonder kerneltransitie wanneer het gevraagde bereik al in de live-weergave zit; dankzij de clustering van objectstreams is dit de meest voorkomende situatie en de plek waar de besparingen vandaan komen. En een verzoek dat het einde van het standaardvenster overlapt, vergroot MapSize voor die ene weergave in plaats van er twee aan elkaar te naaien, wat ReadBytes een one-liner houdt en aanroepers vrijhoudt van 'partial-read'-lussen
Venstergrootte is een vergevingsgezinde knop: op 64 MB is een volledige sweep van een 1,8 GB bestand 29 weergaven, op 256 MB zijn het er 8 maar is elke reservering moeilijker te plaatsen in een gefragmenteerde 32-bit ruimte, en onder ongeveer 16 MB hermappen bestanden met veel sprongen vaak genoeg om het op te merken. Overal in het bereik van 64 tot 256 MB is kaartverkeer statistische ruis
De syscalls tellen
Nu de rekenkunde. Testbestand: 1,8 GB, 300.000 indirecte objecten van gemiddeld 600 bytes payload. Een per-object parser haalt elk op met SetFilePointerEx plus een 4 KB ReadFile: 600.000 kerneltransities. Een gecachete lees-syscall gaat heen en weer (round-trip) in grofweg 1,5 µs op huidige x64 hardware, dus dat is 600.000 × 1,5 µs ≈ 0,9 seconden aan pure kernel-overhead voordat er ook maar één byte wordt geparseerd — het warm-cache droomscenario. Koud (cold) is elke sprong een apparaatbewerking: bij de ~20 µs effectieve latentie van NVMe 4 KB willekeurige lezingen (random reads), kosten 300.000 daarvan ongeveer 6 seconden aan apparaattijd; op SATA-klasse opslag, minuten
De leesacties verplaatsen ook de verkeerde gegevens: 300.000 × 4 KB duwt 1,2 GB door gebruikersbuffers om ruwweg 180 MB aan payload af te leveren — een zesvoudige versterking, waarbij elke byte van de kernel naar de gebruiker wordt gekopieerd
Een read-ahead buffer op maat van de objectstream-clusters is de eerste oprechte verbetering: één leesactie van 256 KB per cluster in plaats van één per object, snijdt het aantal transities met één tot twee ordegroottes in. Het is ook de juiste tool op plekken waar mapping lastig is, meestal netwerkshares
De windowed mapper gaat verder. Een volledige sweep is 29 MapViewOfFile en 29 UnmapViewOfFile aanroepen, 58 expliciete transities tegenover 600.000. Een echte xref-gedreven parsing is geen schone sweep, maar het snelle pad absorbeert elke ophaalactie binnen het livevenster; een metadata-indexerende doorloop (pass) over het testarchief nestelde zich op een paar honderd remaps. Mapping verwijdert geen kernelwerk: het zet expliciete syscalls om in page faults die de geheugenbeheerder (memory manager) oplost in clusters met meerdere pagina's, rechtstreeks vanuit de bestandscache zonder kopie in gebruikersruimte, en regio's die nooit worden aangeraakt, kosten niets. Van begin tot eind ging de indexeringspass van 23 s koud en 7,1 s warm met per-object-lezingen naar 6,5 s koud en 1,9 s warm met de mapper; wat overblijft is de zlib inflate, niet IO
Waar FILE_FLAG_NO_BUFFERING past
FILE_FLAG_NO_BUFFERING omzeilt de systeemcache in ruil voor harde uitlijningsregels (alignment rules): offsets, lengtes en bufferadressen allemaal uitgelijnd per sector. Het bewijst zijn nut bij single-pass sequentiële taken die anders de cache zouden overspoelen met bytes die niemand een tweede keer leest — een batch her-serialisatie die het hele archief herschrijft, of een linearisatiedoorloop over afgewerkte uitvoer. Met 4 tot 8 MB uitgelijnde buffers benadert het de sequentiële bandbreedte van het apparaat zonder de cache te vervuilen
Het is precies verkeerd voor parsing. Willekeurige xref-sprongen door een ongebufferde handle veranderen elke 300-byte dictionary-ophaling in een volledige fysieke leesactie zonder cache om het tweede bezoek op te vangen — en PDF-parsing bezoekt regio's voortdurend, omdat verschillende pagina's worden opgelost naar dezelfde objectstreams. Ongebufferde IO voor de sequentiële herschrijving, gemapte of gecachete IO voor de willekeurige parse; de vlag is per handle, dus één pijplijn kan beide op hetzelfde bestand houden
64-bit, werksets (working sets), en de schrijfzijde
Bij een 64-bit build verdwijnt het bezwaar met adresruimte: geef de bestandsgrootte als venster op en de bovenstaande klasse degenereert in een enkele volledige mapping. Het addertje onder het gras bij langlopende diensten: alleen-lezen, door bestanden ondersteunde pagina's brengen geen toewijzingskosten (commit) in rekening, dus toewijzingstellers blijven rustig, maar elke aangeraakte pagina wordt toegevoegd aan de werkset (working set); parseer het grootste deel van de 1,8 GB en de werkset groeit evenredig mee, waardoor al het andere wordt verdreven (evicted). Begrensde vensters plaatsen daar een plafond op, dus het glijdende patroon blijft de juiste standaard, zelfs daar waar adresruimte vrij is
Aan de schrijfzijde is de goedkoopste IO de IO die nooit is uitgegeven. Het incrementele updatemechanisme van PDF (ISO 32000-1 §7.5.6) voegt de gewijzigde objecten en een nieuwe kruisverwijzingssectie toe na de originele bytes, die nooit verplaatsen. Het stempelen van één pagina op het archief van 1,8 GB voegt tientallen kilobytes toe; een volledige herschrijving verplaatst de hele 1,8 GB, wat vijf ordegroottes van elkaar verschilt, en het toevoegen (append) is pure sequentiële uitvoer aan de staart
Waar de losLab-bibliotheken passen
Beide losLab PDF-bibliotheken leveren deze discipline mee als API-oppervlak. De HotPDF Direct File API leest het aantal pagina's en de structuur via een file handle zonder de objectboom op te bouwen, kopieert en ontsleutelt op bestandsniveau, en schrijft delta's via BeginIncrementalUpdate — de hiervoor genoemde 'append-only' strategie, netjes verpakt. PDFlibPas volgt dezelfde route met de Direct Access-laag: een streaming-lezer die ter plekke (in-place) de kruisverwijzingstabel doorloopt, objecten 'lazy' (lui) ophaalt, paginabereiken van bestand naar bestand extraheert, en bewerkingen opslaat als incrementele revisies. Als je je eigen parser schrijft, is de mapper-klasse beschikbaar voor jou om te gebruiken; als je een documentenpijplijn beheert, laat de bibliotheek dan het venster netjes en eerlijk houden
Opmerking: Geoptimaliseerde IO-afhandeling voor documenten op gigabyte-schaal is direct ingebouwd in de HotPDF VCL Component voor Delphi en C++Builder