Der erste nützliche Lesevorgang eines PDF-Parsers befindet sich am falschen Ende der Datei. Das Format setzt den startxref-Zeiger in die letzten Bytes, sodass die Verarbeitung eines 1,8 GB großen Archivs mit einer Suche am Ende, einem Ein-Kilobyte-Lesevorgang und einem Sprung dorthin beginnt, wo laut Kreuzverweistabelle der Dokumentenkatalog liegt. Von da an ist die Analyse ein zufälliger Spaziergang über den gesamten Byte-Bereich. Alles, was gepufferte IO gut kann – sequenzielles Vorauslesen hinter dem Dateizeiger – ist auf eine Arbeitslast ausgerichtet, die PDF nicht hat
Die erste Version dieses Artikels behauptete, eine speicherabgebildete Datei löse den 32-Bit-Out-of-Memory-Fehler, auf den TMemoryStream bei einer 2-GB-Eingabe stößt. Diese Behauptung ist falsch, und die Art und Weise, wie sie falsch ist, weist auf die wahre Lösung hin: ein verschiebbares Mapping-Fenster. Es folgen das Zugriffsmuster, die korrigierte 32-Bit-Geschichte mit einem kompilierbaren gefensterten Mapper und die Syscall-Arithmetik an einer 1,8 GB großen Testdatei mit 300.000 Objekten
Warum das PDF-Layout gepufferte Lesevorgänge vereitelt
Drei strukturelle Fakten prägen das IO-Muster. Erstens ist die Navigation offsetgesteuert: Die Kreuzverweistabelle ordnet jeder Objektnummer eine absolute Byte-Position zu, und nichts erfordert, dass diese Positionen geordnet sind. Nach Jahren inkrementeller Updates kann Objekt 4102 bei Offset 1,6 GB sitzen, während Objekt 4103 bei 30 KB sitzt. Eine TFileStream-Schleife wandelt jeden Abruf in ein Seek plus ein Read um, zwei Kernel-Übergänge, mit einem Puffer, der nichts beiträgt, da der nächste Abruf Hunderte von Megabytes entfernt ist
Zweitens verpacken Objektströme (ISO 32000-1 §7.5.7) Dutzende oder Hunderte von kleinen Wörterbüchern in einen komprimierten Container. Das Abrufen eines 300-Byte-Seitenwörterbuchs kann bedeuten, einen 100-KB-Cluster zu lesen und zu entpacken. Die Kehrseite: Zusammen geschriebene Objekte werden in der Regel auch zusammen gelesen, sodass ein auf den Cluster abgestimmter Puffer das nächste Dutzend Abrufe kostenlos bedient – die am besten nutzbare Regelmäßigkeit in diesem Format
Drittens, Linearisierung. Eine linearisierte Datei lädt die erste Seite und eine Hinweistabelle vorab, damit Verbraucher sie von vorne nach hinten lesen können. Gigabyte-Archive sind fast nie linearisiert: Die Linearisierung wird durch dieselben inkrementellen Updates und Zusammenführungen zerstört, die die Datei groß gemacht haben. Planen Sie für den feindseligen Fall: lange Sprünge, keine Sortierung, Einstieg am Ende
Die korrigierte 32-Bit-Geschichte
Ein 32-Bit-Windows-Prozess verfügt über 2 GB Benutzeradressraum, und MapViewOfFile mit einer Byte-Anzahl von null fordert eine fortlaufende Reservierung in der Größe der Datei an. Bei einer 2-GB-Eingabe kann diese Reservierung nicht gelingen: Nach der EXE-Datei, verstreuten DLLs und Thread-Stacks liegt der größte freie zusammenhängende Block in einem typischen 32-Bit-Delphi-Prozess irgendwo zwischen 700 MB und 1,4 GB. Der Aufruf schlägt mit ERROR_NOT_ENOUGH_MEMORY fehl, derselben Wand, gegen die TMemoryStream.LoadFromFile prallt, nur verlagert vom zugewiesenen RAM zur Adressraumreservierung. Ein Mapping der gesamten Datei ist auf 32-Bit keine Lösung, sondern nur derselbe Fehler hinter besser klingenden API-Namen
Die Lösung besteht darin, die beiden Dinge, die ein Mapping tut, zu trennen. CreateFileMapping erstellt das Abschnittsobjekt und kostet unabhängig von der Dateigröße überhaupt keinen Adressraum. Nur MapViewOfFile verbraucht Adressraum, und nichts zwingt es dazu, den gesamten Abschnitt zuzuordnen: Es erfordert einen 64-Bit-Startoffset und eine Ansichtslänge. Erstellen Sie den Abschnitt einmal, legen Sie eine Ansicht von 64 bis 256 MB über den zu analysierenden Bereich und heben Sie die Zuordnung auf, bevor Sie weiterrutschen: Die Kosten für den Adressraum betragen ein Fenster, nicht eine Datei. Eine Einschränkung: Ansichtsoffsets müssen Vielfache von SYSTEM_INFO.dwAllocationGranularity sein, in der Praxis 64 KB, sodass eine Anforderung für Offset 1.000.000 auf 983.040 abgerundet und der Zeiger des Aufrufers um die Differenz nach vorne korrigiert wird
Ein Sliding-Window-Mapper in Delphi
Die Klasse unten fasst die gesamte Vorgehensweise zusammen: Ein Abschnittsobjekt, eine aktive Ansicht, Granularitätsanpassung und Lesevorgänge, die eine Fenstergrenze überschreiten, werden durch das Erweitern dieser einen Ansicht behandelt, anstatt zwei Ansichten zusammenzufügen
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;
Zwei Details fallen ins Gewicht. Der schnelle Pfad oben in Map gibt einen Zeiger ohne Kernel-Übergang zurück, wenn der angeforderte Bereich bereits innerhalb der aktiven Ansicht liegt; dank Objekt-Stream-Clustering ist dies der häufigste Fall und der Grund für die Einsparungen. Und eine Anforderung, die das Ende des Standardfensters überschreitet, vergrößert MapSize für diese eine Ansicht, anstatt zwei zusammenzufügen, wodurch ReadBytes ein Einzeiler bleibt und Anrufer von Schleifen mit partiellem Lesen befreit werden
Die Fenstergröße ist ein nachsichtiger Schalter: Bei 64 MB umfasst ein vollständiger Durchlauf einer 1,8 GB großen Datei 29 Ansichten, bei 256 MB sind es 8, aber jede Reservierung ist in einem fragmentierten 32-Bit-Speicher schwerer zu platzieren, und unter etwa 16 MB werden Dateien mit vielen Sprüngen oft genug neu gemappt, um dies zu bemerken. Überall im Bereich von 64 bis 256 MB ist der Map-Datenverkehr statistisches Rauschen
Syscalls zählen
Nun zur Arithmetik. Testdatei: 1,8 GB, 300.000 indirekte Objekte mit durchschnittlich etwa 600 Bytes Nutzlast. Ein parser-pro-Objekt ruft jedes einzelne mit SetFilePointerEx plus einem 4 KB großen ReadFile ab: 600.000 Kernel-Übergänge. Ein zwischengespeicherter Lese-Syscall benötigt auf aktueller x64-Hardware für den Hin- und Rückweg etwa 1,5 μs, was 600.000 × 1,5 μs ≈ 0,9 Sekunden reinen Kernel-Overheads bedeutet, bevor auch nur ein einziges Byte geparst wird – der Idealfall für den warmen Cache. Kalt ist jeder Sprung eine Geräteoperation: Bei der effektiven Latenzzeit von ~20 μs bei zufälligen 4-KB-Lesevorgängen von NVMe kosten 300.000 davon etwa 6 Sekunden Gerätezeit; bei SATA-Speichern Minuten
Die Lesevorgänge verschieben zudem die falschen Daten: 300.000 × 4 KB schieben 1,2 GB durch Benutzerpuffer, um rund 180 MB Nutzlast zu liefern – eine sechsfache Verstärkung, jedes Byte vom Kernel zum Benutzer kopiert
Ein Read-Ahead-Puffer, der an die Größe der Objekt-Stream-Cluster angepasst ist, ist die erste echte Verbesserung: Ein 256-KB-Lesevorgang pro Cluster anstelle eines Lesevorgangs pro Objekt reduziert die Anzahl der Übergänge um ein bis zwei Größenordnungen. Er ist auch das richtige Werkzeug dort, wo das Mapping umständlich ist, normalerweise bei Netzwerkfreigaben
Der Windowed-Mapper geht noch weiter. Ein vollständiger Durchlauf besteht aus 29 Aufrufen von MapViewOfFile und 29 Aufrufen von UnmapViewOfFile, 58 explizite Übergänge im Vergleich zu 600.000. Eine echte xref-gesteuerte Analyse ist kein sauberer Durchlauf, aber der schnelle Pfad absorbiert jeden Abruf innerhalb des aktiven Fensters; ein Durchlauf zur Indizierung von Metadaten über das Testarchiv pendelte sich bei einigen hundert Remaps ein. Mapping beseitigt keine Kernel-Arbeit: Es wandelt explizite Syscalls in Seitenfehler (Page Faults) um, die der Speichermanager in mehrseitigen Clustern direkt aus dem Dateicache ohne Kopie im Benutzerbereich (User Space) auflöst, und Regionen, die nie berührt werden, kosten nichts. Von Anfang bis Ende sank der Indizierungsdurchlauf von 23 s kalt und 7,1 s warm bei leseweisen Abrufen pro Objekt auf 6,5 s kalt und 1,9 s warm mit dem Mapper; was bleibt, ist Zlib-Inflate, nicht IO
Wo FILE_FLAG_NO_BUFFERING passt
FILE_FLAG_NO_BUFFERING umgeht den Systemcache im Austausch gegen harte Ausrichtungsregeln (Alignment): Offsets, Längen und Pufferadressen müssen alle sektororientiert ausgerichtet sein. Es bewährt sich bei sequenziellen Single-Pass-Aufgaben, die den Cache andernfalls mit Bytes überfluten würden, die niemand zweimal liest – eine Batch-Reserialisierung, die das gesamte Archiv neu schreibt, oder ein Linearisierungsdurchlauf über fertige Ausgaben. Mit ausgerichteten Puffern von 4 bis 8 MB nähert es sich der sequenziellen Gerätebandbreite an, ohne den Cache zu verunreinigen
Es ist genau falsch für das Parsen. Zufällige Xref-Sprünge durch ein ungepuffertes Handle verwandeln jeden Abruf eines 300-Byte-Wörterbuchs in einen vollständigen physischen Lesevorgang, ohne dass ein Cache den zweiten Besuch auffangen könnte – und das Parsen von PDFs greift ständig auf Bereiche zurück, da verschiedene Seiten in dieselben Objektströme aufgelöst werden. Ungepufferte IO für das sequenzielle Neuschreiben, gemappte oder gecachte IO für das zufällige Parsen; das Flag gilt pro Handle, sodass eine Pipeline beides auf derselben Datei halten kann
64-Bit, Working Sets und die Schreibseite
Bei einem 64-Bit-Build entfällt der Einwand des Adressraums: Übergeben Sie die Dateigröße als Fenster und die obige Klasse degeneriert zu einem einzigen vollständigen Mapping. Der Haken bei lang laufenden Diensten: Schreibgeschützte dateibasierte Seiten belasten das Commit nicht, sodass die Commit-Zähler ruhig bleiben, aber jede berührte Seite wird Teil des Working Sets; parsen Sie den Großteil von 1,8 GB, wächst das Working Set entsprechend und verdrängt alles andere. Begrenzte Fenster setzen dem eine Obergrenze, sodass das Schiebemuster der richtige Standard bleibt, selbst dort, wo der Adressraum frei ist
Auf der Schreibseite ist die günstigste IO diejenige, die niemals ausgegeben wird. Der Mechanismus für inkrementelle Aktualisierungen von PDF (ISO 32000-1 §7.5.6) fügt die geänderten Objekte und einen neuen Kreuzverweisabschnitt hinter den ursprünglichen Bytes an, die sich nie bewegen. Das Stempeln einer Seite auf das 1,8 GB große Archiv fügt Zehntausende von Kilobytes an; ein vollständiges Umschreiben bewegt alle 1,8 GB, fünf Größenordnungen auseinander, und das Anhängen ist eine reine sequenzielle Ausgabe am Ende
Wo die losLab-Bibliotheken reinpassen
Beide losLab PDF-Bibliotheken liefern diese Disziplin als API-Oberfläche. Die HotPDF Direct File API liest Seitenanzahlen und Strukturen über ein Datei-Handle, ohne den Objektbaum aufzubauen, kopiert und entschlüsselt auf Dateiebene und schreibt Deltas durch BeginIncrementalUpdate – die oben genannte Nur-Anhängen-Strategie, verpackt. PDFlibPas geht denselben Weg mit seiner Direct Access-Schicht: Ein Streaming-Reader, der die Kreuzverweistabelle direkt durchläuft, Objekte bei Bedarf (lazy) abruft, Seitenbereiche von Datei zu Datei extrahiert und Änderungen als inkrementelle Revisionen speichert. Wenn Sie Ihren eigenen Parser schreiben, können Sie die Mapper-Klasse gerne übernehmen; wenn Sie eine Dokumenten-Pipeline betreiben, lassen Sie die Bibliothek das Fenster ehrlich halten
Hinweis: Eine optimierte IO-Handhabung für Dokumente im Gigabyte-Maßstab ist direkt in die HotPDF VCL-Komponente für Delphi und C++Builder integriert