Technisch artikel

Parallel PDF-pagina's renderen in Delphi zonder OOM

HotPDF rendert veel PDF-pagina's gelijktijdig via twee toegangspunten: RenderLoadedPagesParallel, dat een array van bitmaps teruggeeft, en RenderLoadedPagesParallelOrdered, dat elke pagina in invoervolgorde aan een callback levert zodra ze klaar is. De geordende vorm is degene die schaalt, omdat ze nooit meer dan een begrensd aantal bitmaps in het geheugen houdt, ongeacht hoeveel pagina's u hebt aangevraagd

Dat onderscheid is het hele artikel. 10.000 pagina's op 300 DPI naar een array renderen betekent 10.000 levende bitmaps, wat geen doorvoerprobleem is maar een out-of-memory-crash. Ze via een geordende callback met een wachtrijdiepte van twee renderen betekent twee levende bitmaps, en de taak draait met een vaste geheugenvoetafdruk hoe lang het document ook is

Waarom heeft parallel renderen twee fasen nodig?

Het renderen van een PDF-pagina is twee verschillende taken onder één naam. Eerst wordt de content stream getokeniseerd en gecompileerd tot een display list, wat CPU-gebonden is en gedeelde caches aanraakt. Daarna wordt de display list afgespeeld naar een bitmap, wat ook CPU-gebonden is maar zwaar alloceert. Ze als één ondeelbare eenheid behandelen dwingt tot een keuze tussen het serialiseren van de cache-aanrakende helft en het dupliceren van werk

HotPDF splitst ze op. De vergrendeling van de display-list-cache beschermt alleen de frequentieboekhouding, de LRU-volgorde, gebruikstellers en toelatingsbeslissingen; het compileren zelf draait buiten de vergrendeling. Na het compileren bevraagt de worker de cache opnieuw: als een andere worker ondertussen dezelfde pagina heeft gepubliceerd, geeft hij zijn eigen kopie vrij en neemt een gebruikstelling op de gepubliceerde entry. Dat houdt dubbel werk begrensd en voorkomt twee entries voor dezelfde pagina, zonder ooit compilatie over pagina's heen te serialiseren

Het gemeten effect op een fixture van 32 pagina's is het onthouden waard. De totale tijd op Win64 daalde van 1.565 ms met één worker naar 696 ms met vier, ongeveer 2,25 keer. De tijd tot het eerste resultaat daalde van 1.255 ms naar 63 ms, ongeveer 95 procent, omdat workers beginnen met het afspelen van voltooide display lists terwijl andere nog aan het compileren zijn in plaats van te wachten op een volledige compilatiebarrière

Diagram van de tweefasen-parallelrenderpijplijn van HotPDF in Delphi, waarbij een compilatiefase een gedeelde display-list-cache voedt voordat de bitmap wordt afgespeeld
HotPDF compileert elke pagina tot een gecachte display list en speelt die af op elke vrije worker, zodat afspelen overlapt met compileren in plaats van te wachten achter een barrière

Geordende levering en de gereserveerde slot

De geordende API garandeert dat uw callback pagina's in de volgorde ziet waarin u ze hebt aangevraagd, terwijl workers klaarkomen in welke volgorde dan ook. Voltooide bitmaps worden gepubliceerd in een outputslot geïndexeerd op invoerpositie, en de callback draait serieel op de aanroepende thread

Twee regels maken dat begrensd in plaats van louter geordend. Het aantal in de outputwachtrij omvat bitmaps die momenteel door de callback worden gebruikt, zodat een trage callback producenten niet voorbij de wachtrijdiepte kan laten glippen. En latere pagina's mogen ten hoogste queueDepth - 1 slots bezetten, omdat één slot permanent gereserveerd is voor de volgende pagina die geleverd moet worden. Zonder die reservering kan een trage eerste pagina worden buitengesloten door afgeronde latere pagina's die de wachtrij vullen, en loopt de pijplijn vast aan het begin van de rij terwijl elke worker inactief is

Diagram van geordende levering in HotPDF waarbij ongeordende workerresultaten outputslots vullen en een gereserveerd slot ervoor zorgt dat de volgende verschuldigde pagina blijft doorstromen
Voltooide pagina's komen terecht in slots geadresseerd op invoerpositie en stromen af via een seriële callback met geleende bitmap, waarbij één slot gereserveerd is zodat de kopgroeppagina nooit buitengesloten kan worden
uses
  HPDFDoc;

procedure TExportJob.PageReady(Sender: TObject; InputIndex,
  PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
  // De bitmap is geleend: alleen geldig voor deze aanroep. Verwerk hem hier
  // (naar schijf schrijven, coderen, hashen) en bewaar de referentie niet
  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;

Het contract van de geleende bitmap is de reden waarom de voetafdruk plat blijft. De library geeft de bitmap vrij zodra uw callback terugkeert, dus een fixture van 10.000 pagina's op wachtrijdiepte 2 piekt op precies twee bitmaps op zowel Win32 als Win64. Als u de bitmap na de callback nog nodig hebt, kopieer hem dan, en dan bent u eigenaar van het geheugen en de boekhouding

Hoe kiest het geheugenbudget een aantal workers?

Meer workers is niet altijd sneller en is vaak fataal. Zes workers die A3-pagina's op 600 DPI renderen hebben enkele honderden megabytes aan levende bitmap nodig, ongeacht hoeveel cores beschikbaar zijn, en de machine met acht cores heeft misschien niet zoveel vrije adresruimte

HotPDF normaliseert daarom het gevraagde aantal workers tegen de aanvraaggrootte, het paginaaantal en een harde bovengrens, en deelt vervolgens het momenteel beschikbare globale budget door de grootste conservatieve schatting per pagina in de aanvraag. Er blijft altijd minstens één worker over, zodat een te grote pagina nog steeds wordt gerenderd, exclusief. In de referentiemeting draaide een taak van zes pagina's op 600 DPI onder een budget van 512 MiB met drie workers in plaats van zes, met een reserveringspiek van 504.583.296 bytes

Elke worker neemt een geheugenreservering nadat hij een pagina heeft geclaimd en voordat hij compileert, en de reservering dekt compileren en afspelen samen. Alleen reserveren rond de rasterfase zou de display list en de werkset van de content stream missen, wat op grafiek-zware pagina's de grotere helft is. De twee API's verschillen dan: de arrayvorm geeft haar reservering vrij zodra de bitmap klaar is, omdat de aanroeper het resultaat bezit en de scheduler er niet meer voor kan afrekenen, terwijl de geordende vorm de reservering samen met de bitmap overdraagt naar het outputslot en vasthoudt totdat de callback terugkeert. Dat is nog een reden om de geordende API te verkiezen wanneer de outputset groot is

Diagram van het geheugenbudget van HotPDF dat 512 MiB deelt door de grootste paginaschatting om drie workers toe te laten in plaats van zes
Het budget laat workers toe op basis van de grootste schatting per pagina, elke reservering omvat compileren en afspelen samen, en de geordende API houdt de reservering in stand tot de callback terugkeert

Annuleren zonder deadlock

Het instellen van Cancel in de callback stopt nieuw werk en maakt elke worker wakker die op backpressure wacht. Pagina's die al binnen de renderer zitten, worden afgerond en opgeruimd in plaats van verlaten, en alleen de eerste faaldiagnose wordt bewaard, opgeworpen nadat elke worker zich heeft aangesloten

De volgorde hier is dragend. De annuleringsstatus wordt eerst ingesteld, niet-gepubliceerde outputreserveringen worden als tweede vrijgegeven, en pas daarna sluiten de workers zich aan. Het omkeren van de laatste twee stappen creëert een gesloten lus: een worker wacht op toelating tot een budget dat pas wordt vrijgegeven wanneer de consument klaar is, terwijl de consument wacht tot die worker afsluit

Interactieve toepassingen moeten ook op de hoogte zijn van voorgrondpreemptie. Pagina-prefetching draait op zijn eigen annuleringstokens en de voorgrondrenderingaanroepen annuleren en sluiten de prefetch-workers aan voordat ze hun eigen werk doen, zodat achtergronddoorvoer nooit latentie toevoegt aan de pagina waarop een gebruiker wacht. Annulering wordt gecontroleerd met intervallen begrensd door 4 KiB aan byte-scanning of 256 tokens, hoewel een atomaire codec of OS-aanroep niet halverwege kan worden onderbroken — de responsiviteitsgarantie geldt voor lussen die eigendom zijn van de library, niet voor decoders van derden. De queue-gebaseerde aanpak voor interactief renderen wordt beschreven in achtergrondrendering met een aanvraagwachtrij

De pijplijnstatistieken lezen

THPDFParallelRenderPipelineInfo scheidt cijfers die makkelijk door elkaar worden gehaald. RequestedWorkerCount tegenover WorkerCount en MemoryBudgetWorkerLimit vertelt u of het budget uw concurrency heeft verlaagd. CompiledPageCount tegenover CacheHitPageCount vertelt u hoeveel de display-list-cache heeft bespaard. CompiledPagesAtFirstResult toont of de pijplijn compileren en afspelen werkelijk overlapte of ontaardde in een barrière

Voor het afstemmen zijn BackpressureWaitCount en MemorySchedulerWaitMilliseconds de twee nuttigste. Hoge backpressure-wachttijden betekenen dat uw callback het knelpunt is, dus maak hem sneller of verhoog de wachtrijdiepte als het geheugen dat toelaat. Hoge scheduler-wachttijden betekenen dat het budget het knelpunt is, dus verlaag de DPI, verlaag de concurrency, of verhoog het budget. Het aantal workers in beide gevallen verhogen maakt de doorvoer slechter, wat het contra-intuïtieve deel is

Een praktisch startpunt voor batchexport: concurrency gelijk aan fysieke cores minus één, wachtrijdiepte twee of drie, en DPI gekozen op basis van de daadwerkelijke outputvereiste in plaats van gewoonte. Lees vervolgens de statistieken van een echt document en pas ze één keer aan, in plaats van twee keer te gokken. Voor taken waarbij het document zelf te groot is om in het geheugen te passen, combineert u dit met het streamingtoegangsmodel in de directe bestands-API voor grote PDF-workflows

Parallel renderen, prefetching en de display-list-cache maken allemaal deel uit van dezelfde renderstack voor Delphi en C++Builder; de volledige functielijst staat op de HotPDF Delphi PDF component-pagina