Technischer Artikel

HotPDF: PDFs per Range Coalescing über HTTP streamen

HotPDF lädt ein PDF aus jeder selbst implementierten Random-Access-Quelle, und THPDFCoalescingRandomAccessSource umschließt diese Quelle so, dass die verstreuten kleinen Lesevorgänge des Parsers zu einer begrenzten Menge zwischengespeicherter Blockbereiche mit asynchronem Prefetch werden. Bei einem über HTTP-Range-Requests ausgelieferten Dokument ist das der Unterschied zwischen ein paar hundert Roundtrips und ein paar Dutzend

Am Parser ändert sich nichts. Sie rufen weiterhin LoadFromRandomAccessSource auf, dasselbe Dokumentobjekt kommt zurück, und dieselbe Seiten-API funktioniert. Was sich ändert, ist der Datenverkehr darunter

Warum lädt dasselbe PDF lokal sofort und übers Netz im Kriechtempo?

Weil ein PDF-Parser eine Datei nicht liest, sondern durch sie navigiert. Er springt ans Ende für startxref, springt zurück zur Querverweistabelle, löst das Trailer-Dictionary auf, folgt einer Referenz zum Catalog, dann zur Wurzel des Seitenbaums, dann zu einem Seitenknoten, dann zu dessen Ressourcen-Dictionary. Jeder dieser Schritte liest ein paar Dutzend Bytes von einem jeweils anderen Offset

Bei einer lokalen Datei ist dieses Muster fast kostenlos: Das Betriebssystem hat die umliegende 4-KiB-Seite bereits im Cache, sodass der zweite Lesevorgang nur ein memcpy kostet. Über einen Netzwerktransport gibt es keine solche Lokalität. Jeder Lesevorgang ist ein Request mit eigener Latenz, und 300 sequenzielle Requests zu je 40 ms sind zwölf Sekunden, die fast ausschließlich mit Warten verbracht werden. Die Lösung ist nicht, weniger zu lesen; der Parser braucht genau das, was er anfordert. Die Lösung ist, jeden physischen Lesevorgang mehr von dem abdecken zu lassen, was der nächste logische Lesevorgang will

Was Coalescing ändert

Die Coalescing-Quelle rundet jeden Lesevorgang auf einen Block auf und speichert den Block zwischen. BlockSize ist standardmäßig 262.144 Bytes und MaxCacheBytes standardmäßig 2.097.152, sodass standardmäßig acht Blöcke resident sind und nach Least-recently-used-Reihenfolge gegen ein hartes Byte-Budget verdrängt werden. Der 40-Byte-Lesevorgang des Parsers für einen Trailer-Schlüssel zieht die umliegenden 256 KiB heran, und die nächsten Dutzend Lesevorgänge in dieser Nachbarschaft, wo Querverweis- und Catalog-Daten liegen, werden aus dem Speicher bedient

Die eigene Quelle bleibt einfach. Implementieren Sie GetSize und ReadAt, überschreiben Sie ReadAtCancellable, wenn Ihr Transport während des Transfers abbrechen kann, und überlassen Sie dem Wrapper Caching, Coalescing und Prefetch

type
  THttpRangeSource = class(THPDFRandomAccessSource)
  private
    FClient: TMyHttpClient;
    FUrl: string;
    FSize: Int64;
  public
    function GetSize: Int64; override;
    function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
    function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
      CancellationToken: THPDFCancellationToken): Longint; override;
  end;

var
  Raw: THttpRangeSource;
  Cached: THPDFCoalescingRandomAccessSource;
  Pdf: THotPDF;
begin
  Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
  // OwnsSource=True: der Wrapper gibt Raw mit sich selbst frei
  Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
  Pdf := THotPDF.Create(nil);
  try
    Cached.AsyncPrefetchEnabled := True;
    Cached.AdaptiveReadAheadEnabled := True;
    Cached.MaxReadAheadBlocks := 8;

    if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

Wie weit sollte vorausgelesen werden?

Adaptives Read-Ahead beantwortet diese Frage pro Dokument, statt Sie raten zu lassen. Bei aktiviertem AdaptiveReadAheadEnabled wächst das Fenster durch 1, 2, 4 und 8 Blöcke, während sich anhaltende Vorwärtslesevorgänge summieren, und es überschreitet nie MaxReadAheadBlocks oder die konfigurierte Cache-Kapazität. Sobald ein Lesevorgang eintrifft, der nicht ungefähr dort liegt, wo der vorherige endete, kollabiert das Fenster und Prefetch wird unterdrückt

SequentialReadToleranceBytes, standardmäßig 4.096, definiert "ungefähr". Lesevorgänge, die innerhalb dieser Distanz vom Ende des vorherigen liegen, zählen noch als sequenziell, was zählt, weil ein PDF-Parser, der einen Content-Stream durchläuft, keine perfekt zusammenhängenden Offsets erzeugt; er überspringt hier ein Längenfeld, dort ein Inline-Dictionary. Setzt man die Toleranz zu niedrig, wird ein normaler Vorwärtsscan als zufällig eingestuft, sodass Read-Ahead nie greift. Setzt man sie zu hoch, wirkt echter Zufallszugriff sequenziell, sodass man Megabytes abruft, die niemand will. Die Vorgabe ist auf Content-Stream-Traversierung kalibriert, und die Statistiken sagen Ihnen, ob Ihr Transport das anders sieht

Diese Asymmetrie ist Absicht: Wachstum ist allmählich, Kollaps ist sofort. Über-Fetching bei einer Random-Access-Last kostet auf nutzungsbasierten Transporten echte Bandbreite und echtes Geld, daher wird der billige Fehler dem teuren vorgezogen

Cancellation, die den Transfer tatsächlich stoppt

Die Basisklasse deklariert ReadAtCancellable, und die Coalescing-Quelle respektiert das durchgängig. Trifft ein Foreground-Lesevorgang für einen Bereich ein, der von einem laufenden Prefetch nicht bedient wird, wird der Prefetch abgebrochen statt zu Ende geführt, sodass die Seitenanfrage des Nutzers nicht hinter spekulativem Datenverkehr wartet. Die Standardimplementierung auf THPDFRandomAccessSource fällt auf ein einfaches ReadAt zurück, was bedeutet, dass das Feature pro Transport optional ist: HTTP-Clients, die Request-Abbruch unterstützen, erhalten echte Cancellation, und einfachere Quellen funktionieren unverändert weiter

Kombiniert man das mit einem durch die eigene UI geführten Cancellation-Token, stoppt ein Nutzer, der ein Dokument schließt, den Netzwerkverkehr tatsächlich, statt darauf zu warten, dass er versickert. Dasselbe Token-Modell liegt dem in Hintergrund-Rendering mit einer Request-Warteschlange beschriebenen Queueing zugrunde, sodass ein einziges Token den gesamten Pfad vom Viewport bis zum Socket abdecken kann

Die Range-Cache-Statistiken lesen

GetStatistics füllt einen THPDFRangeCacheStatistics-Record, der trennt, was der eigene Transport getan hat, von dem, was der Cache getan hat. SourceReadCount und SourceBytesRead sind physischer Datenverkehr. CacheHitCount und CacheMissCount sind logischer Datenverkehr. SequentialReadCount und RandomReadCount zeigen, wie das Zugriffsmuster eingestuft wurde, CurrentReadAheadBlocks und PeakReadAheadBlocks zeigen, wie weit sich das Fenster geöffnet hat, und PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount und SuppressedPrefetchCount zeigen, ob sich die Spekulation gelohnt hat

var
  S: THPDFRangeCacheStatistics;
begin
  Cached.GetStatistics(S);
  Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
    [S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
  Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
    [S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
  Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
    [S.PrefetchRequestCount, S.PrefetchCompletedCount,
     S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;

Drei Messwerte sagen Ihnen, was zu ändern ist. Viele abgebrochene Prefetches bei hoher Zufallslesequote bedeuten, dass auf das Dokument außer der Reihe zugegriffen wird, also senken Sie MaxReadAheadBlocks und hören Sie auf, für verworfene Bandbreite zu zahlen. Viele Misses bei einem Spitzenfenster von nur 1 bedeuten, dass die Toleranz ein Muster ablehnt, das faktisch sequenziell ist, also erhöhen Sie SequentialReadToleranceBytes. Und gelesene Bytes, die die Dateigröße bei weitem übersteigen, bedeuten, dass der Cache thrasht, also erhöhen Sie MaxCacheBytes, bevor Sie irgendetwas anderes anfassen

Linearisierte Dateien ändern die Rechnung

Kontrolliert man den Erzeuger, ändert Linearisieren des Dokuments das Problem, statt es nur zu optimieren. Ein linearisiertes PDF platziert die Objekte der ersten Seite und eine Hint-Tabelle am Anfang der Datei, sodass ein Viewer Seite eins aus dem ersten Megabyte rendern kann, ohne den Rest zu sehen. HotPDF legt diesen Pfad direkt über GetProgressiveLinearizedLoadInfo und ReadProgressiveLinearizedFirstPageSection offen, und die Schreibseite wird in Erzeugen linearisierter PDFs mit Hint-Tabellen behandelt

Beide Techniken lassen sich kombinieren. Coalescing macht jedes Dokument über eine langsame Verbindung erträglich; Linearisierung lässt die erste Seite bei selbst erzeugten Dokumenten schnell ankommen. Für Dateien, die auf einer lokalen Platte liegen, aber zu groß für den Speicher sind, sind die in der Direct-File-API-Workflow beschriebenen Mapped-File- und Lazy-Stream-Pfade meist das bessere Werkzeug, da dort von vornherein keine Roundtrip-Latenz zu amortisieren ist

HotPDF ist eine native VCL-PDF-Komponente für Delphi und C++Builder, ohne externe DLL für den Parser und mit vollständig verfügbarem Quellcode. Die Random-Access-Source-API, der Coalescing-Wrapper und die Einstiegspunkte für progressives Laden sind auf der HotPDF Delphi PDF-Komponente-Seite dokumentiert