Technický článek

Paralelní vykreslování stránek PDF v Delphi bez OOM

HotPDF vykresluje mnoho stránek PDF souběžně přes dva vstupní body: RenderLoadedPagesParallel, který vrací pole bitmap, a RenderLoadedPagesParallelOrdered, který doručuje každou stránku do callbacku ve vstupním pořadí, jakmile je připravena. Uspořádaná forma je ta, která škáluje, protože nikdy nedrží v paměti více než omezený počet bitmap bez ohledu na to, kolik stránek jste požadovali

Tento rozdíl je celý smysl článku. Vykreslení 10 000 stránek při 300 DPI do pole znamená 10 000 živých bitmap, což není problém propustnosti, ale pád kvůli nedostatku paměti. Vykreslení přes uspořádaný callback s hloubkou fronty dva znamená dvě živé bitmapy a úloha běží v pevné paměťové stopě bez ohledu na to, jak dlouhý je dokument

Proč paralelní vykreslování potřebuje dvě fáze?

Vykreslení stránky PDF jsou ve skutečnosti dvě různé úlohy pod jedním názvem. Nejprve se obsahový stream tokenizuje a zkompiluje do zobrazovacího seznamu, což je vázáno na CPU a dotýká se sdílených keší. Pak se zobrazovací seznam přehraje do bitmapy, což je také vázáno na CPU, ale hodně alokuje. Zacházet s nimi jako s jednou nedělitelnou jednotkou nutí volit mezi serializací té poloviny, která se dotýká keší, a zdvojováním práce

HotPDF je rozděluje. Zámek keše zobrazovacích seznamů chrání pouze počítání frekvence, pořadí LRU, počty použití a rozhodnutí o přijetí; samotná kompilace běží mimo zámek. Po kompilaci si pracovník znovu dotáže keš: pokud mezitím jiný pracovník publikoval stejnou stránku, uvolní svou vlastní kopii a převezme počet použití publikované položky. To udržuje zdvojenou práci omezenou a zabraňuje dvěma položkám pro stejnou stránku, aniž by kdy serializovalo kompilaci napříč stránkami

Naměřený efekt na komplexním vzorku o 32 stránkách stojí za zapamatování. Celkový čas na Win64 klesl z 1 565 ms s jedním pracovníkem na 696 ms se čtyřmi, zhruba 2,25krát. Čas do prvního výsledku klesl z 1 255 ms na 63 ms, přibližně o 95 procent, protože pracovníci začnou přehrávat dokončené zobrazovací seznamy, zatímco jiní stále kompilují, místo aby čekali u plné kompilační bariéry

Diagram dvojfázové paralelní vykreslovací pipeline HotPDF v Delphi, kde kompilační fáze plní sdílenou keš zobrazovacích seznamů před přehráním do bitmapy
HotPDF zkompiluje každou stránku do keše zobrazovacích seznamů a přehraje ji na libovolném volném pracovníkovi, takže přehrávání se překrývá s kompilací místo čekání za bariérou

Uspořádané doručování a rezervovaný slot

Uspořádané API garantuje, že váš callback vidí stránky v pořadí, v jakém jste je požadovali, zatímco pracovníci dokončují v jakémkoli pořadí, v jakém skončí. Dokončené bitmapy se publikují do výstupního slotu indexovaného pozicí ve vstupu a callback běží sériově na volajícím vlákně

Dvě pravidla tomu dávají omezenost, nejen uspořádanost. Počet ve výstupní frontě zahrnuje bitmapy, které callback právě používá, takže pomalý callback nemůže nechat producenty proklouznout kolem hloubky fronty. A pozdější stránky mohou obsadit nejvýše queueDepth - 1 slotů, protože jeden slot je trvale rezervován pro další stránku, která je na řadě k doručení. Bez této rezervace může být pomalá první stránka zablokována dokončenými pozdějšími stránkami, které zaplní frontu, a pipeline se zablokuje na čele fronty s každým pracovníkem nečinným

Diagram uspořádaného doručování v HotPDF, kde výsledky pracovníků v neuspořádaném pořadí zaplňují výstupní sloty a rezervovaný slot udržuje průtok pro další stránku na řadě
Dokončené stránky přistávají ve slotech adresovaných pozicí ve vstupu a odtékají přes sériový callback s vypůjčenou bitmapou, přičemž jeden slot je rezervovaný, aby stránka na čele nikdy nemohla být zablokována
uses
  HPDFDoc;

procedure TExportJob.PageReady(Sender: TObject; InputIndex,
  PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
  // Bitmapa je vypůjčená: platná pouze pro toto volání. Spotřebujte ji zde
  // (zapište na disk, zakódujte, hašujte) a neuchovávejte odkaz
  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;

Smlouva o vypůjčené bitmapě je důvodem, proč paměťová stopa zůstává plochá. Knihovna uvolní bitmapu, jakmile se váš callback vrátí, takže vzorek o 10 000 stránkách s hloubkou fronty 2 dosahuje vrcholu přesně dvou bitmap na Win32 i Win64. Pokud bitmapu potřebujete i po callbacku, zkopírujte ji, a pak vlastníte paměť i účtování vy

Jak paměťový rozpočet vybírá počet pracovníků

Více pracovníků není vždy rychlejší a často je to fatální. Šest pracovníků vykreslujících stránky A3 při 600 DPI potřebuje několik set megabajtů živé bitmapy bez ohledu na to, kolik jader je k dispozici, a stroj s osmi jádry nemusí mít tolik volného adresního prostoru

HotPDF proto normalizuje požadovaný počet pracovníků proti velikosti požadavku, počtu stránek a pevnému stropu a pak dělí aktuálně dostupný globální rozpočet nejkonzervativnějším odhadem jedné stránky v požadavku. Alespoň jeden pracovník vždy přežije, takže i příliš velká stránka se vykreslí, byť samostatně. V referenčním měření běžela šestistránková úloha při 600 DPI pod rozpočtem 512 MiB se třemi pracovníky místo šesti, s vrcholem rezervace 504 583 296 bajtů

Každý pracovník provede rezervaci paměti poté, co si zabere stránku a před kompilací, a rezervace pokrývá kompilaci i přehrávání dohromady. Rezervace jen kolem rastrovací fáze by přehlédla zobrazovací seznam a pracovní sadu obsahového streamu, což je na graficky náročných stránkách větší polovina. Dvě API se pak liší: pole uvolní svou rezervaci, jakmile je bitmapa hotová, protože volající vlastní výsledek a plánovač ho už nemůže sledovat, zatímco uspořádaná forma přenese rezervaci spolu s bitmapou do výstupního slotu a drží ji, dokud se callback nevrátí. To je další důvod, proč preferovat uspořádané API, když je výstupní sada velká

Diagram paměťového rozpočtu HotPDF, který dělí 512 MiB nejvyšším odhadem jedné stránky, aby přijal tři pracovníky místo šesti
Rozpočet přijímá pracovníky podle nejvyššího odhadu jedné stránky, každá rezervace pokrývá kompilaci i přehrávání a uspořádané API drží rezervaci aktivní, dokud se callback nevrátí

Zrušení bez deadlocku

Nastavení Cancel v callbacku zastaví novou práci a probudí každého pracovníka čekajícího na zpětný tlak. Stránky již uvnitř rendereru se dokončí a uklidí, místo aby byly opuštěny, a uchová se jen první diagnostika selhání, vyvolaná poté, co se připojí každý pracovník

Pořadí je zde nosné. Stav zrušení se nastaví jako první, nepublikované výstupní rezervace se uvolní jako druhé a teprve pak se pracovníci připojí. Prohození posledních dvou kroků vytváří uzavřenou smyčku: pracovník čeká na přijetí do rozpočtu, který se neuvolní, dokud spotřebitel neskončí, zatímco spotřebitel čeká, až tento pracovník skončí

Interaktivní aplikace by měly znát i preempci na popředí. Předběžné načítání stránek běží na vlastních tokenech zrušení a volání vykreslování na popředí zruší a připojí pracovníky předběžného načítání dřív, než provedou vlastní práci, takže propustnost na pozadí nikdy nepřidá latenci ke stránce, na kterou uživatel čeká. Zrušení se kontroluje v intervalech omezených 4 KiB skenování bajtů nebo 256 tokeny, ačkoli atomické volání kodeku nebo operačního systému nelze přerušit uprostřed běhu — záruka odezvy pokrývá smyčky vlastněné knihovnou, ne dekodéry třetích stran. Přístup interaktivního vykreslování založený na frontě popisuje článek vykreslování na pozadí s frontou požadavků

Čtení statistik pipeline

THPDFParallelRenderPipelineInfo odděluje čísla, která se snadno pletou. RequestedWorkerCount proti WorkerCount a MemoryBudgetWorkerLimit vám řekne, zda rozpočet snížil vaši souběžnost. CompiledPageCount proti CacheHitPageCount vám řekne, kolik ušetřila keš zobrazovacích seznamů. CompiledPagesAtFirstResult ukazuje, zda se pipeline opravdu překrývala v kompilaci a přehrávání, nebo degenerovala na bariéru

Pro ladění jsou nejužitečnější dvě hodnoty: BackpressureWaitCount a MemorySchedulerWaitMilliseconds. Vysoký počet čekání na zpětný tlak znamená, že úzkým hrdlem je váš callback, takže ho buď zrychlete, nebo zvyšte hloubku fronty, pokud to paměť dovolí. Vysoké čekání plánovače znamená, že úzkým hrdlem je rozpočet, takže snižte DPI, snižte souběžnost, nebo zvyšte rozpočet. Zvýšení počtu pracovníků v obou případech propustnost zhorší, což je ta neintuitivní část

Praktický výchozí bod pro dávkový export: souběžnost rovná fyzickému počtu jader minus jedna, hloubka fronty dvě nebo tři a DPI zvolené podle skutečného požadavku na výstup, ne ze zvyku. Pak si přečtěte statistiky z reálného dokumentu a upravte jednou, místo abyste hádali dvakrát. Pro úlohy, kde je samotný dokument příliš velký na to, aby se vešel do paměti, kombinujte toto se streamovacím modelem přístupu popsaným v článku přímé souborové API pro workflow s velkými PDF

Paralelní vykreslování, předběžné načítání a keš zobrazovacích seznamů jsou součástí stejného vykreslovacího stacku pro Delphi a C++Builder; úplný seznam funkcí najdete na stránce komponenty HotPDF Delphi PDF component