Technischer Artikel

Optimierung der IO-Leistung für die PDF-Verarbeitung im Gigabyte-Maßstab

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