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
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
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
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