Artykuł techniczny

Żarłocy pamięci PDF: grafy zależności obiektów HotPDF

HotPDF udostępnia profil pamięci wczytanego pliku PDF jako graf, który możesz odpytać: BuildLoadedObjectDependencyGraph zwraca jeden węzeł na każdy obiekt pośredni z oszacowanym rozmiarem płytkim, rozmiarem zatrzymanym opartym na dominatorach oraz flagą wskazującą, czy obiekt jest wciąż osiągalny z katalogu dokumentu. To zamienia „ten plik zajmuje 800 MB” w „obiekt 4173, XObject obrazu, wyłącznie zatrzymuje 612 MB”, czyli fakt, na którym można działać

Rozróżnienie między tymi dwoma zdaniami to sedno sprawy. Rozmiar płytki mówi, jak duży jest jeden obiekt. Rozmiar zatrzymany mówi, ile pamięci faktycznie zostałoby zwolnione, gdyby ten obiekt zniknął, czyli liczba decydująca o tym, czy poprawka pomaga

Dlaczego całkowite zużycie pamięci nie jest faktem, na którym można działać?

Ponieważ w pliku PDF niemal nic nie jest własnością wyłącznie jednej rzeczy. Pojedyncza osadzona czcionka CID jest przywoływana ze słownika zasobów każdej strony, która jej używa. Profil ICC wspiera przestrzeń kolorów współdzieloną przez dziesięć różnych strumieni treści. Form XObject użyty jako pieczątka pojawia się na wszystkich 400 stronach. Jeśli naiwnie zsumujesz rozmiary obiektów per strona, policzysz tę czcionkę 400 razy i dojdziesz do wniosku, że każda strona jest ogromna; jeśli podzielisz przez 400, dojdziesz do wniosku, że nic nie jest drogie, a pamięć wzięła się skądinąd

Analiza dominatorów rozwiązuje tę niejednoznaczność w jedyny sposób, który przetrwa kontakt z rzeczywistymi dokumentami. Obiekt X jest przypisywany do najbliższego obiektu, który go wyłącznie zatrzymuje, co oznacza, że każda ścieżka odwołań od katalogu do X przechodzi przez ten dominator. Czcionka współdzielona przez wszystkie strony nie jest przypisana do żadnej strony; jest przypisana do najbliższego węzła, przez który przechodzą wszystkie te ścieżki, co zwykle jest samym katalogiem. Czcionka używana przez dokładnie jedną stronę jest przypisana do tej strony. W efekcie EstimatedRetainedBytes sumuje się poprawnie zamiast liczyć podwójnie, a obiekty na szczycie listy to obiekty, których usunięcie rzeczywiście zwolniłoby pamięć

Co faktycznie zawiera graf

Każdy THPDFObjectDependencyNode niesie tożsamość obiektu jako ObjectNumber i GenerationNumber, plus ObjectType, LifecycleState, EstimatedShallowBytes, EstimatedRetainedBytes, IncomingReferenceCount, OutgoingReferenceCount, tożsamość swojego bezpośredniego dominatora oraz ReachableFromCatalog. Każda THPDFObjectDependencyEdge zapisuje źródło, cel, Path w słowniku, pod którym znaleziono odwołanie, oraz to, czy zostało Resolved

To pole Path jest tym, którego ludzie nie doceniają. Stanowi różnicę między wiedzą, że obiekt 91 wskazuje na obiekt 4173, a wiedzą, że robi to przez /Resources/XObject/Im3, co od razu mówi, czy patrzysz na treść strony, strumień wyglądu adnotacji, czy grupę treści opcjonalnej, której nikt nigdy nie renderuje

var
  Pdf: THotPDF;
  Nodes: THPDFObjectDependencyNodeArray;
  Edges: THPDFObjectDependencyEdgeArray;
  Info: THPDFObjectDependencyGraphInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('report-800mb.pdf') <> 1 then Exit;

    if not Pdf.BuildLoadedObjectDependencyGraph(Nodes, Edges, Info) then Exit;

    if Info.LimitExceeded then
      Log('Graph truncated: raise MaxObjects / MaxEdges');

    Log(Format('%d objects, %d edges, %d reachable, catalog retains %d bytes',
      [Info.ObjectCount, Info.EdgeCount, Info.ReachableObjectCount,
       Info.CatalogRetainedBytes]));

    SortByRetainedDescending(Nodes);
    for I := 0 to Min(9, High(Nodes)) do
      Log(Format('%d %d obj: shallow %d, retained %d, dominator %d',
        [Nodes[I].ObjectNumber, Nodes[I].GenerationNumber,
         Nodes[I].EstimatedShallowBytes, Nodes[I].EstimatedRetainedBytes,
         Nodes[I].ImmediateDominatorObjectNumber]));
  finally
    Pdf.Free;
  end;
end;

Oba limity to jawne parametry z wartościami domyślnymi 250 000 obiektów i 2 000 000 krawędzi. Gdy dokument przekracza jeden z nich, LimitExceeded zostaje ustawione, a zwrócony graf jest obciętym prefiksem, a nie kłamstwem: częściowe wyniki z flagą, a nie po cichu błędne sumy. Podnoś limity świadomie na potrzeby analizy śledczej i pamiętaj, że analiza przechodzi przez cały graf obiektów, więc jej miejsce jest w ścieżce diagnostycznej, a nie w pętli renderowania

Co mówi ci obiekt nieosiągalny?

Obiekt z ReachableFromCatalog ustawionym na False to pamięć, którą dokument niesie, ale której przeglądarka nigdy nie pokaże. W praktyce bierze się to z trzech miejsc: aktualizacji przyrostowych, które zastąpiły wcześniejszą wersję obiektu i zostawiły oryginał w tyle, producenta, który zapisał obiekty, a potem nie zdołał ich powiązać, albo uszkodzonego pliku, którego tablica odsyłaczy krzyżowych została odbudowana i przejęła definicje, do których nic się nie odwołuje

Pierwszy przypadek jest normalny i oczekiwany, i dokładnie do tego służą aktualizacje przyrostowe i strumienie obiektów. Drugi i trzeci są warte zbadania. Gdy duża część zatrzymanych bajtów siedzi w węzłach nieosiągalnych, znalazłeś konkretny argument za przepisaniem pliku od nowa zamiast dopisywania do niego, i masz liczbę bajtów, by uzasadnić dodatkowy czas przetwarzania przed każdym, kto zapyta

Dwa liczniki alokatora warte sprawdzenia jako pierwsze

Zanim uznasz, że dokument jest z natury duży, sprawdź, czy kosztem nie jest sam analizator. HotPDF udostępnia dwa liczniki opisujące przebieg wczytywania, a nie zawartość dokumentu

GetLastParserArenaStatistics zgłasza arenę bloków zatrzymanych, używaną dla krótkotrwałych tokenów i buforów analizatora: RetainedBytes, PeakUsedBytes, AllocationCount, ReusedAllocationCount, TokenCount i TemporaryObjectElisionCount. To ostatnie pole liczy klucze słownika sparsowane bezpośrednio do areny zamiast przez tymczasowy obiekt nazwy, czyli alokację, która kiedyś dominowała w parsowaniu plików gęstych w słowniki

GetDocumentStringInternStatistics zgłasza pulę internowania w zakresie dokumentu, deduplikującą powtarzające się nazwy PDF, operatory strumienia treści i niezmienne łańcuchy znaków do 64 bajtów. Daje ci RequestCount, HitCount, MissCount, BypassCount, RetainedBytes i ReusedBytes, wraz z obowiązującymi pułapami. Pula jest celowo ograniczona do 65 536 wpisów i 4 MiB, więc wrogi dokument generujący milion unikalnych nazw nie może zamienić optymalizacji pamięci we wzmacniacz zużycia pamięci; po osiągnięciu pułapu kolejne łańcuchy omijają pulę, a BypassCount rośnie

var
  Arena: THPDFParserArenaStatistics;
  Intern: THPDFDocumentStringInternStatistics;
begin
  if Pdf.GetLastParserArenaStatistics(Arena) then
    Log(Format('arena: peak %d, reused %d of %d allocations, %d tokens',
      [Arena.PeakUsedBytes, Arena.ReusedAllocationCount,
       Arena.AllocationCount, Arena.TokenCount]));

  if Pdf.GetDocumentStringInternStatistics(Intern) then
    Log(Format('intern: %d/%d hits, %d bypassed, %d bytes reused',
      [Intern.HitCount, Intern.RequestCount, Intern.BypassCount,
       Intern.ReusedBytes]));
end;

Wysoka wartość ReusedBytes przy niskim BypassCount oznacza, że dokument ma powtarzalne słownictwo typowe dla większości prawdziwych plików PDF, a pula zarabia na siebie. BypassCount zbliżający się do RequestCount oznacza coś nietypowego: albo naprawdę ogromny dokument, albo taki, który celowo generuje unikalne nazwy, co jest łagodnym sygnałem wartym zalogowania na niezaufanej ścieżce przyjmowania plików

Kolejność triażu, która działa

Zacznij od CatalogRetainedBytes z informacji o grafie. Jeśli ta liczba jest bliska wzrostowi twojego procesu, pamięć jest w dokumencie, a graf pokaże ci, gdzie. Jeśli jest znacznie niższa, pamięć jest w twoich własnych pamięciach podręcznych, w wyrenderowanych bitmapach albo w analizatorze, a liczniki areny powiedzą, w którym z nich

Następnie weź dziesięć najwyższych węzłów według EstimatedRetainedBytes i przyjrzyj się ich ObjectType. XObjecty obrazów na szczycie oznaczają, że plik jest gęsty w skany, a downsampling jest lekarstwem. Deskryptory czcionek na szczycie oznaczają, że osadzono pełne czcionki tam, gdzie wystarczyłyby podzbiory. Strumienie treści na szczycie zwykle oznaczają wygenerowaną grafikę wektorową, często mapy albo eksporty CAD. Dopiero po tym warto spojrzeć na obiekty nieosiągalne i zachowanie alokatora. Pracując w tej kolejności, zwykle znajdziesz odpowiedź w pierwszych dwóch krokach, a dla bardzo dużych dokumentów podejście strumieniowe opisane w przepływie pracy bezpośredniego API plikowego jest częściej rozwiązaniem strukturalnym niż jakakolwiek optymalizacja pojedynczego obiektu

Wszystkie te diagnostyki to zwykłe wywołania Pascala zwracające zwykłe rekordy, więc wpinają się bezpośrednio w istniejącą ścieżkę logowania albo telemetrii. HotPDF to natywny komponent VCL PDF dla Delphi i C++Builder z pełnym źródłem; dokumentacja API i wersja próbna są na stronie komponentu HotPDF dla Delphi