Technischer Artikel

Paralleles PDF-Seitenrendering in Delphi ohne Speicherüberlauf

HotPDF rendert viele PDF-Seiten gleichzeitig über zwei Einstiegspunkte: RenderLoadedPagesParallel, das ein Array von Bitmaps zurückgibt, und RenderLoadedPagesParallelOrdered, das jede Seite in Eingabereihenfolge an einen Callback liefert, sobald sie fertig ist. Die geordnete Form ist diejenige, die skaliert, denn sie hält unabhängig von der Anzahl der angeforderten Seiten nie mehr als eine begrenzte Anzahl von Bitmaps im Speicher

Diese Unterscheidung ist der ganze Artikel. 10.000 Seiten mit 300 DPI in ein Array zu rendern bedeutet 10.000 lebende Bitmaps im Speicher, was kein Durchsatzproblem ist, sondern ein Speicherüberlauf-Absturz. Sie über einen geordneten Callback mit einer Warteschlangentiefe von zwei zu rendern bedeutet zwei lebende Bitmaps, und der Job läuft mit festem Speicherbedarf, egal wie lang das Dokument ist

Warum benötigt paralleles Rendern zwei Stufen?

Das Rendern einer PDF-Seite sind zwei verschiedene Aufgaben unter einem Namen. Zuerst wird der Content-Stream tokenisiert und zu einer Display-Liste kompiliert, was CPU-gebunden ist und gemeinsame Caches berührt. Dann wird die Display-Liste in eine Bitmap abgespielt, was ebenfalls CPU-gebunden ist, aber stark Speicher zuweist. Behandelt man beides als eine unteilbare Einheit, zwingt das zur Wahl zwischen dem Serialisieren der cache-berührenden Hälfte und doppelter Arbeit

HotPDF trennt sie. Die Sperre des Display-Listen-Caches schützt nur die Häufigkeitszählung, die LRU-Reihenfolge, die Nutzungszähler und die Aufnahmeentscheidungen; die Kompilierung selbst läuft außerhalb der Sperre. Nach der Kompilierung fragt der Worker den Cache erneut ab: Hat inzwischen ein anderer Worker dieselbe Seite veröffentlicht, gibt er seine eigene Kopie frei und übernimmt einen Nutzungszähler für den veröffentlichten Eintrag. Das hält doppelte Arbeit begrenzt und verhindert zwei Einträge für dieselbe Seite, ohne die Kompilierung über Seiten hinweg jemals zu serialisieren

Der gemessene Effekt an einer komplexen 32-seitigen Testvorlage ist der Teil, den man sich merken sollte. Die Gesamtzeit auf Win64 fiel von 1.565 ms mit einem Worker auf 696 ms mit vier, also etwa das 2,25-Fache. Die Zeit bis zum ersten Ergebnis fiel von 1.255 ms auf 63 ms, etwa 95 Prozent, weil Worker bereits fertige Display-Listen abspielen, während andere noch kompilieren, statt an einer vollständigen Kompilierungsbarriere zu warten

Diagramm der zweistufigen parallelen Render-Pipeline von HotPDF in Delphi, bei der eine Kompilierungsstufe einen gemeinsamen Display-Listen-Cache vor der Bitmap-Wiedergabe speist
HotPDF kompiliert jede Seite in eine zwischengespeicherte Display-Liste und spielt sie auf jedem freien Worker ab, sodass die Wiedergabe die Kompilierung überlappt, statt hinter einer Barriere zu warten

Geordnete Auslieferung und der reservierte Slot

Die geordnete API garantiert, dass Ihr Callback die Seiten in der von Ihnen angeforderten Reihenfolge sieht, während die Worker in beliebiger Reihenfolge fertig werden. Fertige Bitmaps werden in einen Ausgabe-Slot veröffentlicht, der nach Eingabeposition indiziert ist, und der Callback läuft seriell auf dem aufrufenden Thread

Zwei Regeln machen das begrenzt und nicht nur geordnet. Die Ausgabe-Warteschlangenzählung schließt Bitmaps ein, die gerade vom Callback verwendet werden, sodass ein langsamer Callback Produzenten nicht über die Warteschlangentiefe hinausschlüpfen lassen kann. Und spätere Seiten dürfen höchstens queueDepth - 1 Slots belegen, da ein Slot dauerhaft für die nächste zur Auslieferung fällige Seite reserviert ist. Ohne diese Reservierung könnte eine langsame erste Seite ausgesperrt werden, weil fertige spätere Seiten die Warteschlange füllen, und die Pipeline würde am Anfang der Reihe blockieren, während jeder Worker untätig ist

Diagramm der geordneten Auslieferung in HotPDF, bei der außerhalb der Reihenfolge fertige Worker-Ergebnisse Ausgabe-Slots füllen und ein reservierter Slot die nächste fällige Seite fließen lässt
Fertige Seiten landen in nach Eingabeposition adressierten Slots und fließen über einen seriellen Callback mit geliehenen Bitmaps ab, wobei ein Slot reserviert ist, damit die vorderste Seite nie ausgesperrt werden kann
uses
  HPDFDoc;

procedure TExportJob.PageReady(Sender: TObject; InputIndex,
  PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
  // Die Bitmap ist geliehen: nur für diesen Aufruf gültig. Hier verbrauchen
  // (auf Festplatte schreiben, kodieren, hashen) und die Referenz nicht behalten
  Bitmap.SaveToFile(Format('page-%.4d.bmp', [InputIndex + 1]));
  Cancel := FUserCancelled;
end;

procedure TExportJob.Run;
var
  Pdf: THotPDF;
  Pages: array of Integer;
  Info: THPDFParallelRenderPipelineInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('manual.pdf') <= 0 then
      Exit;
    SetLength(Pages, Pdf.LoadedPageCount);
    for I := 0 to High(Pages) do
      Pages[I] := I;

    Pdf.RenderLoadedPagesParallelOrdered(Pages, 200, 4, 2,
      PageReady, Info);

    Writeln(Format('workers=%d peak queued=%d peak bytes=%d',
      [Info.WorkerCount, Info.PeakQueuedPageCount, Info.PeakQueuedBytes]));
    Writeln(Format('first result=%d ms  total=%d ms  backpressure waits=%d',
      [Info.FirstResultMilliseconds, Info.TotalMilliseconds,
       Info.BackpressureWaitCount]));
  finally
    Pdf.Free;
  end;
end;

Der geliehene-Bitmap-Vertrag ist der Grund, warum der Speicherbedarf konstant bleibt. Die Bibliothek gibt die Bitmap frei, sobald Ihr Callback zurückkehrt, sodass eine 10.000-seitige Testvorlage bei Warteschlangentiefe 2 auf sowohl Win32 als auch Win64 bei genau zwei Bitmaps ihren Höchstwert erreicht. Benötigen Sie die Bitmap nach dem Callback noch, kopieren Sie sie, und dann gehören Ihnen der Speicher und die Abrechnung

Wie das Speicherbudget die Worker-Anzahl bestimmt

Mehr Worker sind nicht immer schneller und oft sogar fatal. Sechs Worker, die A3-Seiten mit 600 DPI rendern, benötigen mehrere hundert Megabyte an lebenden Bitmaps, unabhängig davon, wie viele Kerne verfügbar sind, und die Maschine mit acht Kernen verfügt möglicherweise nicht über so viel freien Adressraum

HotPDF normalisiert daher die angeforderte Worker-Anzahl anhand der Anfragegröße, der Seitenzahl und einer festen Obergrenze und teilt dann das aktuell verfügbare globale Budget durch die größte konservative Einzelseitenschätzung in der Anfrage. Mindestens ein Worker bleibt immer bestehen, sodass eine übergroße Seite trotzdem gerendert wird, exklusiv. In der Referenzmessung lief ein sechsseitiger 600-DPI-Job unter einem 512-MiB-Budget mit drei statt sechs Workern, mit einem Reservierungsspitzenwert von 504.583.296 Bytes

Jeder Worker nimmt nach dem Beanspruchen einer Seite und vor der Kompilierung eine Speicherreservierung vor, und die Reservierung deckt Kompilierung und Wiedergabe gemeinsam ab. Eine Reservierung nur um die Rasterstufe herum würde die Display-Liste und den Content-Stream-Arbeitsspeicher übersehen, der bei grafiklastigen Seiten die größere Hälfte ausmacht. Die beiden APIs unterscheiden sich dann: Die Array-Form gibt ihre Reservierung frei, sobald die Bitmap fertig ist, da der Aufrufer das Ergebnis besitzt und der Scheduler es nicht mehr abrechnen kann, während die geordnete Form die Reservierung zusammen mit der Bitmap in den Ausgabe-Slot überträgt und bis zur Rückkehr des Callbacks hält. Das ist ein weiterer Grund, die geordnete API zu bevorzugen, wenn die Ausgabemenge groß ist

Diagramm des HotPDF-Speicherbudgets, das 512 MiB durch die größte Seitenschätzung teilt, um drei statt sechs Worker zuzulassen
Das Budget lässt Worker anhand der größten Einzelseitenschätzung zu, jede Reservierung umfasst Kompilierung und Wiedergabe, und die geordnete API hält die Reservierung aufrecht, bis der Callback zurückkehrt

Abbruch ohne Deadlock

Das Setzen von Cancel im Callback stoppt neue Arbeit und weckt jeden Worker, der auf Backpressure wartet. Seiten, die sich bereits im Renderer befinden, werden fertiggestellt und aufgeräumt statt verworfen, und nur die erste Fehlerdiagnose wird behalten, ausgelöst, nachdem jeder Worker beigetreten ist

Die Reihenfolge hier ist tragend. Zuerst wird der Abbruchzustand gesetzt, danach werden unveröffentlichte Ausgabereservierungen freigegeben, und erst dann werden die Worker zusammengeführt. Vertauscht man die letzten beiden Schritte, entsteht eine geschlossene Schleife: Ein Worker wartet auf Zugang zu einem Budget, das erst freigegeben wird, wenn der Konsument fertig ist, während der Konsument darauf wartet, dass dieser Worker beendet

Interaktive Anwendungen sollten auch die Vordergrund-Präemption kennen. Das Seiten-Prefetching läuft mit eigenen Abbruch-Tokens, und die Vordergrund-Rendering-Aufrufe brechen die Prefetch-Worker ab und führen sie zusammen, bevor sie ihre eigene Arbeit erledigen, sodass der Hintergrunddurchsatz der Seite, auf die ein Benutzer wartet, nie zusätzliche Latenz hinzufügt. Abbrüche werden in Intervallen von höchstens 4 KiB Byte-Scan oder 256 Tokens geprüft, wobei ein atomarer Codec- oder Betriebssystemaufruf nicht mitten im Ablauf unterbrochen werden kann — die Reaktionsfähigkeits-Garantie gilt für bibliothekseigene Schleifen, nicht für Decoder von Drittanbietern. Der warteschlangenbasierte Ansatz für interaktives Rendern ist unter Hintergrund-Rendering mit einer Anfragewarteschlange beschrieben

Die Pipeline-Statistik lesen

THPDFParallelRenderPipelineInfo trennt Zahlen, die man leicht verwechselt. RequestedWorkerCount gegenüber WorkerCount und MemoryBudgetWorkerLimit zeigt Ihnen, ob das Budget Ihre Nebenläufigkeit reduziert hat. CompiledPageCount gegenüber CacheHitPageCount zeigt Ihnen, wie viel der Display-Listen-Cache eingespart hat. CompiledPagesAtFirstResult zeigt, ob die Pipeline Kompilierung und Wiedergabe tatsächlich überlappt hat oder zu einer Barriere degeneriert ist

Für die Feinabstimmung sind die beiden nützlichsten BackpressureWaitCount und MemorySchedulerWaitMilliseconds. Hohe Backpressure-Wartezeiten bedeuten, dass Ihr Callback der Flaschenhals ist, also entweder beschleunigen oder die Warteschlangentiefe erhöhen, sofern der Speicher es erlaubt. Hohe Scheduler-Wartezeiten bedeuten, dass das Budget der Flaschenhals ist, also DPI reduzieren, Nebenläufigkeit reduzieren oder das Budget erhöhen. Die Worker-Anzahl in beiden Fällen zu erhöhen verschlechtert den Durchsatz, was der kontraintuitive Teil ist

Ein praktischer Ausgangspunkt für den Batch-Export: Nebenläufigkeit gleich physische Kerne minus eins, Warteschlangentiefe zwei oder drei, und DPI nach der tatsächlichen Ausgabeanforderung gewählt statt aus Gewohnheit. Lesen Sie dann die Statistik eines echten Dokuments und passen Sie einmal an, statt zweimal zu raten. Für Jobs, bei denen das Dokument selbst zu groß ist, um es im Speicher zu halten, kombinieren Sie dies mit dem Streaming-Zugriffsmodell unter der direkten Datei-API für große PDF-Workflows

Paralleles Rendern, Prefetching und der Display-Listen-Cache sind alle Teil desselben Rendering-Stacks für Delphi und C++Builder; die vollständige Funktionsliste finden Sie auf der HotPDF-Delphi-PDF-Komponentenseite