Artykuł techniczny

Równoległe renderowanie stron PDF w Delphi bez OOM

HotPDF renderuje wiele stron PDF równocześnie za pomocą dwóch punktów wejścia: RenderLoadedPagesParallel, który zwraca tablicę bitmap, oraz RenderLoadedPagesParallelOrdered, który dostarcza każdą stronę do wywołania zwrotnego w kolejności wejściowej, gdy tylko jest gotowa. To wariant uporządkowany skaluje się dobrze, ponieważ nigdy nie przechowuje w pamięci więcej niż ograniczoną liczbę bitmap, niezależnie od tego, o ile stron poprosiłeś

To rozróżnienie to sedno całego artykułu. Wyrenderowanie 10 000 stron w 300 DPI do tablicy oznacza 10 000 żywych bitmap, co nie jest problemem przepustowości, lecz awarią z powodu braku pamięci. Wyrenderowanie ich przez uporządkowane wywołanie zwrotne z głębokością kolejki równą dwa oznacza dwie żywe bitmapy, a zadanie działa w stałym śladzie pamięciowym bez względu na to, jak długi jest dokument

Dlaczego renderowanie równoległe wymaga dwóch etapów?

Renderowanie strony PDF to w rzeczywistości dwa różne zadania noszące jedną nazwę. Najpierw strumień treści jest tokenizowany i kompilowany do listy wyświetlania, co obciąża procesor i dotyka współdzielonych pamięci podręcznych. Następnie lista wyświetlania jest odtwarzana do bitmapy, co również obciąża procesor, ale intensywnie alokuje pamięć. Traktowanie tego jako jednej niepodzielnej jednostki wymusza wybór między serializacją połowy dotykającej pamięci podręcznej a powielaniem pracy

HotPDF dzieli te etapy. Blokada pamięci podręcznej listy wyświetlania chroni wyłącznie rozliczanie częstotliwości, kolejność LRU, liczniki użycia i decyzje o przyjęciu; sama kompilacja działa poza blokadą. Po skompilowaniu wątek roboczy ponownie odpytuje pamięć podręczną: jeśli inny wątek roboczy w międzyczasie opublikował tę samą stronę, zwalnia swoją własną kopię i przejmuje licznik użycia opublikowanego wpisu. To ogranicza powielaną pracę i zapobiega powstaniu dwóch wpisów dla tej samej strony, nigdy nie serializując przy tym kompilacji między stronami

Zmierzony efekt na złożonej próbce 32 stron jest tym, co warto zapamiętać. Całkowity czas na Win64 spadł z 1565 ms przy jednym wątku roboczym do 696 ms przy czterech, czyli około 2,25 raza. Czas do pierwszego wyniku spadł z 1255 ms do 63 ms, czyli o około 95 procent, ponieważ wątki robocze zaczynają odtwarzać ukończone listy wyświetlania, podczas gdy inne wciąż kompilują, zamiast czekać przy pełnej barierze kompilacji

Diagram dwuetapowego potoku równoległego renderowania HotPDF w Delphi, w którym etap kompilacji zasila współdzieloną pamięć podręczną list wyświetlania przed odtworzeniem bitmapy
HotPDF kompiluje każdą stronę do listy wyświetlania trzymanej w pamięci podręcznej i odtwarza ją na dowolnym wolnym wątku roboczym, dzięki czemu odtwarzanie nakłada się na kompilację zamiast czekać w kolejce za barierą

Uporządkowane dostarczanie i zarezerwowany slot

API uporządkowane gwarantuje, że twoje wywołanie zwrotne widzi strony w kolejności, w jakiej o nie poprosiłeś, podczas gdy wątki robocze kończą pracę w dowolnej kolejności, w jakiej faktycznie kończą. Ukończone bitmapy są publikowane do slotu wyjściowego indeksowanego pozycją wejściową, a wywołanie zwrotne uruchamiane jest szeregowo w wątku wywołującym

Dwie zasady sprawiają, że jest to ograniczone, a nie tylko uporządkowane. Licznik kolejki wyjściowej obejmuje bitmapy aktualnie używane przez wywołanie zwrotne, więc wolne wywołanie zwrotne nie może pozwolić producentom prześlizgnąć się poza głębokość kolejki. A późniejsze strony mogą zająć co najwyżej queueDepth - 1 slotów, ponieważ jeden slot jest na stałe zarezerwowany dla kolejnej strony należnej do dostarczenia. Bez tej rezerwacji wolna pierwsza strona mogłaby zostać zablokowana przez ukończone późniejsze strony wypełniające kolejkę, a potok zakleszczyłby się na czele kolejki, mimo że każdy wątek roboczy jest bezczynny

Diagram uporządkowanego dostarczania w HotPDF, w którym wyniki wątków roboczych napływające poza kolejnością wypełniają sloty wyjściowe, a zarezerwowany slot utrzymuje przepływ kolejnej należnej strony
Ukończone strony trafiają do slotów adresowanych pozycją wejściową i są odbierane przez szeregowe wywołanie zwrotne z pożyczoną bitmapą, przy czym jeden slot jest zarezerwowany, aby strona na czele nigdy nie została zablokowana
uses
  HPDFDoc;

procedure TExportJob.PageReady(Sender: TObject; InputIndex,
  PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
  // Bitmapa jest pożyczona: ważna tylko na czas tego wywołania. Skonsumuj ją tutaj
  // (zapisz na dysk, zakoduj, policz skrót) i nie zachowuj referencji
  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;

Umowa pożyczonej bitmapy jest powodem, dla którego ślad pamięciowy pozostaje płaski. Biblioteka zwalnia bitmapę zaraz po powrocie twojego wywołania zwrotnego, więc próbka licząca 10 000 stron przy głębokości kolejki 2 osiąga szczyt dokładnie dwóch bitmap zarówno na Win32, jak i Win64. Jeśli potrzebujesz bitmapy po zakończeniu wywołania zwrotnego, skopiuj ją, a wtedy to ty zarządzasz pamięcią i rozliczeniem

Jak budżet pamięci dobiera liczbę wątków roboczych

Więcej wątków roboczych nie zawsze oznacza szybciej, a często bywa zabójcze. Sześć wątków roboczych renderujących strony A3 w 600 DPI potrzebuje kilkuset megabajtów żywych bitmap niezależnie od liczby dostępnych rdzeni, a maszyna z ośmioma rdzeniami może nie mieć aż tyle wolnej przestrzeni adresowej

HotPDF normalizuje więc żądaną liczbę wątków roboczych względem rozmiaru żądania, liczby stron i twardego limitu, a następnie dzieli aktualnie dostępny globalny budżet przez największe konserwatywne oszacowanie pojedynczej strony w żądaniu. Zawsze przetrwa co najmniej jeden wątek roboczy, więc nawet zbyt duża strona nadal się renderuje, choć wyłącznie sama. W pomiarze referencyjnym zadanie z sześcioma stronami w 600 DPI przy budżecie 512 MiB uruchomiło się z trzema wątkami roboczymi zamiast sześciu, ze szczytową rezerwacją 504 583 296 bajtów

Każdy wątek roboczy dokonuje rezerwacji pamięci po przejęciu strony, a przed kompilacją, przy czym rezerwacja obejmuje łącznie kompilację i odtwarzanie. Rezerwowanie pamięci wyłącznie wokół etapu rastrowania pominęłoby listę wyświetlania i zbiór roboczy strumienia treści, który na stronach bogatych w grafikę jest większą połową. Oba API różnią się dalej: wariant tablicowy zwalnia swoją rezerwację, gdy tylko bitmapa jest gotowa, ponieważ to wywołujący przejmuje wynik i planista nie może go już rozliczać, podczas gdy wariant uporządkowany przenosi rezerwację razem z bitmapą do slotu wyjściowego i utrzymuje ją do momentu powrotu wywołania zwrotnego. To kolejny powód, by przy dużym zbiorze wyjściowym preferować API uporządkowane

Diagram budżetu pamięci HotPDF dzielącego 512 MiB przez największe oszacowanie strony, aby dopuścić trzy wątki robocze zamiast sześciu
Budżet dopuszcza wątki robocze na podstawie największego oszacowania pojedynczej strony, każda rezerwacja obejmuje kompilację i odtwarzanie, a API uporządkowane utrzymuje rezerwację przy życiu aż do powrotu wywołania zwrotnego

Anulowanie bez zakleszczenia

Ustawienie Cancel w wywołaniu zwrotnym zatrzymuje nową pracę i budzi każdy wątek roboczy czekający na przeciwciśnienie. Strony już będące wewnątrz renderera kończą pracę i są sprzątane, a nie porzucane, a zachowywana jest tylko pierwsza diagnostyka błędu, zgłaszana po dołączeniu wszystkich wątków roboczych

Kolejność ma tu kluczowe znaczenie. Stan anulowania jest ustawiany jako pierwszy, niepublikowane rezerwacje wyjściowe są zwalniane jako drugie, a dopiero potem dołączane są wątki robocze. Odwrócenie kolejności ostatnich dwóch kroków tworzy zamkniętą pętlę: wątek roboczy czeka na dopuszczenie do budżetu, który nie zostanie zwolniony, dopóki konsument nie skończy, podczas gdy konsument czeka, aż ten wątek roboczy zakończy pracę

Aplikacje interaktywne powinny też wiedzieć o wywłaszczaniu na rzecz pierwszego planu. Wstępne pobieranie stron działa na własnych tokenach anulowania, a wywołania renderowania pierwszoplanowego anulują i dołączają wątki robocze wstępnego pobierania, zanim wykonają własną pracę, dzięki czemu przepustowość w tle nigdy nie dodaje opóźnienia do strony, na którą czeka użytkownik. Anulowanie jest sprawdzane w odstępach ograniczonych do 4 KiB skanowanych bajtów lub 256 tokenów, choć atomowego wywołania kodeka lub systemu operacyjnego nie da się przerwać w locie — gwarancja responsywności obejmuje pętle należące do biblioteki, a nie zewnętrzne dekodery. Podejście oparte na kolejce żądań do renderowania interaktywnego opisano w renderowaniu w tle z kolejką żądań

Odczytywanie statystyk potoku

THPDFParallelRenderPipelineInfo rozdziela liczby, które łatwo pomylić. Zestawienie RequestedWorkerCount z WorkerCount i MemoryBudgetWorkerLimit mówi, czy budżet ograniczył twoją równoległość. Zestawienie CompiledPageCount z CacheHitPageCount mówi, ile zaoszczędziła pamięć podręczna list wyświetlania. CompiledPagesAtFirstResult pokazuje, czy potok faktycznie nałożył na siebie kompilację i odtwarzanie, czy zdegenerował się do bariery

Do strojenia najbardziej przydatne są dwie wartości: BackpressureWaitCount i MemorySchedulerWaitMilliseconds. Wysoka liczba oczekiwań na przeciwciśnienie oznacza, że wąskim gardłem jest twoje wywołanie zwrotne, więc albo je przyspiesz, albo zwiększ głębokość kolejki, jeśli pamięć na to pozwala. Wysoka liczba oczekiwań planisty oznacza, że wąskim gardłem jest budżet, więc zmniejsz DPI, zmniejsz równoległość albo zwiększ budżet. Zwiększenie liczby wątków roboczych w obu przypadkach pogarsza przepustowość, co jest częścią sprzeczną z intuicją

Praktyczny punkt wyjścia dla eksportu wsadowego: równoległość równa liczbie fizycznych rdzeni minus jeden, głębokość kolejki dwa lub trzy, a DPI dobrane z rzeczywistego wymogu wyjściowego, a nie z przyzwyczajenia. Następnie odczytaj statystyki z rzeczywistego dokumentu i dostosuj ustawienia raz, zamiast zgadywać dwukrotnie. W zadaniach, w których sam dokument jest zbyt duży, by zmieścić się w pamięci, połącz to z modelem dostępu strumieniowego opisanym w bezpośrednim API plikowym dla dużych przepływów PDF

Renderowanie równoległe, wstępne pobieranie i pamięć podręczna list wyświetlania są częścią tego samego stosu renderującego dla Delphi i C++Builder; pełną listę funkcji znajdziesz na stronie komponentu HotPDF PDF dla Delphi