Teknisk artikel

Parallell PDF-sidrendering i Delphi utan minnesbrist

HotPDF renderar många PDF-sidor samtidigt via två ingångspunkter: RenderLoadedPagesParallel, som returnerar en array av bitmappar, och RenderLoadedPagesParallelOrdered, som levererar varje sida till en callback i indataordning så snart den är klar. Den ordnade formen är den som skalar, eftersom den aldrig håller fler än ett begränsat antal bitmappar i minnet oavsett hur många sidor du bad om

Skillnaden är hela artikeln. Att rendera 10 000 sidor vid 300 DPI till en array betyder 10 000 levande bitmappar, vilket inte är ett genomströmningsproblem utan en krasch på grund av minnesbrist. Att rendera dem via en ordnad callback med ett ködjup på två betyder två levande bitmappar, och jobbet körs med ett fast minnesavtryck oavsett hur långt dokumentet är

Varför behöver parallell rendering två stadier?

Att rendera en PDF-sida är egentligen två olika jobb under samma namn. Först tokeniseras och kompileras innehållsströmmen till en visningslista, vilket är CPU-bundet och rör vid delade cachar. Sedan spelas visningslistan upp till en bitmapp, vilket också är CPU-bundet men allokerar tungt. Att behandla dem som en odelbar enhet tvingar fram ett val mellan att serialisera den cacherörande halvan och att duplicera arbete

HotPDF delar upp dem. Låset för visningslistecachen skyddar bara frekvensräkningen, LRU-ordningen, användningsräknarna och tillträdesbesluten; själva kompileringen körs utanför låset. Efter kompileringen frågar arbetaren cachen igen: om en annan arbetare publicerade samma sida under tiden släpper den sin egen kopia och tar en användningsräknare på den publicerade posten. Det håller dubbelarbetet begränsat och förhindrar två poster för samma sida, utan att någonsin serialisera kompilering över sidor

Den uppmätta effekten på en komplex 32-sidig testfixtur är det värt att komma ihåg. Totaltiden på Win64 sjönk från 1 565 ms med en arbetare till 696 ms med fyra, ungefär 2,25 gånger. Tiden till det första resultatet sjönk från 1 255 ms till 63 ms, cirka 95 procent, eftersom arbetare börjar spela upp färdiga visningslistor medan andra fortfarande kompilerar istället för att vänta vid en fullständig kompileringsbarriär

Diagram över HotPDF:s tvåstegs parallella renderingspipeline i Delphi där ett kompileringssteg matar en delad visningslistecache innan bitmappsuppspelning
HotPDF kompilerar varje sida till en cachad visningslista och spelar upp den på valfri ledig arbetare, så uppspelning överlappar kompilering istället för att köa bakom en barriär

Ordnad leverans och den reserverade platsen

Det ordnade API:et garanterar att din callback ser sidor i den ordning du begärde dem, medan arbetare blir klara i vilken ordning de nu blir det. Färdiga bitmappar publiceras till en utdataplats indexerad efter indataposition, och callbacken körs seriellt på den anropande tråden

Två regler gör detta begränsat snarare än bara ordnat. Utdataköns antal inkluderar bitmappar som för närvarande används av callbacken, så en långsam callback kan inte låta producenter smyga förbi ködjupet. Och senare sidor får uppta högst queueDepth - 1 platser, eftersom en plats permanent är reserverad för nästa sida som ska levereras. Utan den reservationen kan en långsam första sida låsas ute av färdiga senare sidor som fyller kön, och pipelinen når dödläge i kösens huvud medan varje arbetare är sysslolös

Diagram över ordnad leverans i HotPDF där arbetarresultat i oordnad följd fyller utdataplatser och en reserverad plats håller nästa förfallna sida flytande
Färdiga sidor hamnar i platser adresserade efter indataposition och töms genom en seriell callback med lånade bitmappar, med en plats reserverad så att huvudsidan aldrig kan låsas ute
uses
  HPDFDoc;

procedure TExportJob.PageReady(Sender: TObject; InputIndex,
  PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
  // Bitmappen är lånad: giltig endast för detta anrop. Konsumera den
  // här (skriv till disk, koda, hasha) och behåll inte referensen
  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;

Kontraktet med lånade bitmappar är anledningen till att minnesavtrycket förblir platt. Biblioteket släpper bitmappen så snart din callback returnerar, så en testfixtur på 10 000 sidor vid ködjup 2 når som mest exakt två bitmappar på både Win32 och Win64. Om du behöver bitmappen efter callbacken, kopiera den, och då äger du minnet och bokföringen

Hur minnesbudgeten väljer ett antal arbetare

Fler arbetare är inte alltid snabbare och är ofta ödesdigert. Sex arbetare som renderar A3-sidor vid 600 DPI behöver flera hundra megabyte levande bitmapp oavsett hur många kärnor som är tillgängliga, och maskinen som har åtta kärnor kanske inte har så mycket ledigt adressutrymme

HotPDF normaliserar därför det begärda antalet arbetare mot förfrågans storlek, sidantalet och ett hårt tak, och delar sedan den aktuella tillgängliga globala budgeten med det största konservativa enkelsidesestimat i förfrågan. Minst en arbetare överlever alltid, så en överdimensionerad sida renderas fortfarande, dock ensam. I referensmätningen körde ett sexsidigt 600 DPI-jobb under en 512 MiB-budget med tre arbetare istället för sex, med en reservationstopp på 504 583 296 byte

Varje arbetare gör en minnesreservation efter att ha tagit en sida och innan kompilering, och reservationen täcker kompilering och uppspelning tillsammans. Att bara reservera runt rasterstadiet skulle missa visningslistan och innehållsströmmens arbetsmängd, vilket på grafiktunga sidor är den större halvan. De två API:erna skiljer sig sedan åt: arrayformen släpper sin reservation så snart bitmappen är klar, eftersom anroparen äger resultatet och schemaläggaren inte längre kan bokföra det, medan den ordnade formen för över reservationen tillsammans med bitmappen in i utdataplatsen och håller den tills callbacken returnerar. Det är ännu ett skäl att föredra det ordnade API:et när utdatamängden är stor

Diagram över HotPDF:s minnesbudget som delar 512 MiB med det största sidestimatet för att släppa in tre arbetare istället för sex
Budgeten släpper in arbetare utifrån det största enkelsidesestimatet, varje reservation spänner över kompilering och uppspelning, och det ordnade API:et håller reservationen vid liv tills callbacken returnerar

Avbrytning utan dödläge

Att sätta Cancel i callbacken stoppar nytt arbete och väcker varje arbetare som väntar på mottryck. Sidor redan inne i renderaren blir klara och städas upp istället för att överges, och endast den första feldiagnosen behålls, kastad efter att varje arbetare har återförenats

Ordningen här är bärande. Avbrytningstillståndet sätts först, opublicerade utdatareservationer släpps för det andra, och först då återförenas arbetarna. Att kasta om de sista två stegen skapar en sluten loop: en arbetare väntar på tillträde till en budget som inte släpps förrän konsumenten är klar, medan konsumenten väntar på att den arbetaren ska avsluta

Interaktiva applikationer bör också känna till förgrundsförträngning. Sidförhämtning körs på sina egna avbrytningstokens och förgrundsrenderingsanropen avbryter och återförenar förhämtningsarbetarna innan de gör sitt eget arbete, så att bakgrundsgenomströmning aldrig lägger till latens till sidan en användare väntar på. Avbrytning kontrolleras vid intervall begränsade till 4 KiB byteskanning eller 256 tokens, även om en atomär kodek eller ett OS-anrop inte kan avbrytas mitt i flödet — responsgarantin täcker bibliotekets egna loopar, inte tredjepartsavkodare. Den köbaserade metoden för interaktiv rendering beskrivs i bakgrundsrendering med en förfrågningskö

Att läsa pipelinestatistiken

THPDFParallelRenderPipelineInfo separerar siffror som lätt blandas ihop. RequestedWorkerCount mot WorkerCount och MemoryBudgetWorkerLimit visar om budgeten begränsade din samtidighet. CompiledPageCount mot CacheHitPageCount visar hur mycket visningslistecachen sparade. CompiledPagesAtFirstResult visar om pipelinen verkligen överlappade kompilering och uppspelning eller degenererade till en barriär

För finjustering är de två mest användbara BackpressureWaitCount och MemorySchedulerWaitMilliseconds. Höga mottrycksväntetider betyder att din callback är flaskhalsen, så gör den antingen snabbare eller höj ködjupet om minnet tillåter. Höga schemaläggarväntetider betyder att budgeten är flaskhalsen, så sänk DPI, sänk samtidigheten, eller höj budgeten. Att höja antalet arbetare i endera fallet gör genomströmningen sämre, vilket är den kontraintuitiva delen

En praktisk startpunkt för batch-export: samtidighet lika med fysiska kärnor minus en, ködjup två eller tre, och DPI valt utifrån det faktiska utdatakravet snarare än av vana. Läs sedan statistiken från ett verkligt dokument och justera en gång, snarare än att gissa två gånger. För jobb där dokumentet i sig är för stort för att hålla i minnet, kombinera detta med strömningsåtkomstmodellen i det direkta fil-API:et för stora PDF-arbetsflöden

Parallell rendering, förhämtning och visningslistecachen är alla delar av samma renderingsstack för Delphi och C++Builder; den fullständiga funktionslistan finns på HotPDF Delphi PDF-komponentsidan