Technischer Artikel

HotPDF: PDF-Speicherfresser per Objektgraph finden

HotPDF legt das Speicherprofil eines geladenen PDFs als abfragbaren Graphen offen: BuildLoadedObjectDependencyGraph liefert einen Knoten pro indirektem Objekt mit einer geschätzten Shallow-Größe, einer dominator-basierten Retained-Größe und einem Flag dafür, ob das Objekt noch vom Dokument-Catalog aus erreichbar ist. Das macht aus "diese Datei belegt 800 MB" ein "Objekt 4173, ein Bild-XObject, hält exklusiv 612 MB", und das ist eine Tatsache, nach der man handeln kann

Der Unterschied zwischen diesen beiden Sätzen ist der ganze Punkt. Die Shallow-Größe sagt, wie groß ein Objekt ist. Die Retained-Größe sagt, wie viel Speicher tatsächlich freigegeben würde, wenn dieses Objekt verschwände, und das ist die Zahl, die darüber entscheidet, ob eine Korrektur hilft

Warum ist die Gesamtspeichernutzung keine verwertbare Tatsache?

Weil in einem PDF fast nichts von genau einer Sache besessen wird. Eine einzelne eingebettete CID-Schriftart wird aus dem Ressourcen-Dictionary jeder Seite referenziert, die sie verwendet. Ein ICC-Profil-Stream steht hinter einem Farbraum, den zehn verschiedene Content-Streams teilen. Ein als Stempel verwendetes Form-XObject erscheint auf allen 400 Seiten. Addiert man Objektgrößen naiv pro Seite, zählt man diese Schriftart 400-mal und schließt, jede Seite sei riesig; teilt man durch 400, schließt man, nichts sei teuer und der Speicher stamme von woanders

Dominatoranalyse löst diese Mehrdeutigkeit auf die einzige Weise, die den Kontakt mit echten Dokumenten übersteht. Ein Objekt X wird dem nächstgelegenen Objekt zugeordnet, das es exklusiv hält, das heißt, jeder Referenzpfad vom Catalog zu X verläuft durch diesen Dominator. Eine von allen Seiten geteilte Schriftart wird keiner Seite zugeordnet; sie wird dem nächstgelegenen Knoten zugeordnet, durch den alle diese Pfade verlaufen, meist der Catalog selbst. Eine nur von einer Seite verwendete Schriftart wird dieser Seite zugeordnet. Das Ergebnis ist, dass sich EstimatedRetainedBytes korrekt aufsummiert, statt doppelt zu zählen, und die Objekte an der Spitze der Liste sind Objekte, deren Entfernung tatsächlich Speicher freigeben würde

Was der Graph tatsächlich enthält

Jeder THPDFObjectDependencyNode trägt die Objektidentität als ObjectNumber und GenerationNumber, plus ObjectType, LifecycleState, EstimatedShallowBytes, EstimatedRetainedBytes, IncomingReferenceCount, OutgoingReferenceCount, die Identität seines unmittelbaren Dominators und ReachableFromCatalog. Jede THPDFObjectDependencyEdge erfasst Quelle, Ziel, den Dictionary-Path, unter dem die Referenz gefunden wurde, und ob sie Resolved ist

Dieses Feld Path ist dasjenige, das am meisten unterschätzt wird. Es ist der Unterschied zwischen der Erkenntnis, dass Objekt 91 auf Objekt 4173 zeigt, und dem Wissen, dass es das über /Resources/XObject/Im3 tut, was sofort klärt, ob man Seiteninhalt, den Appearance-Stream einer Annotation oder eine Optional-Content-Gruppe vor sich hat, die nie jemand rendert

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;

Beide Grenzen sind explizite Parameter mit Standardwerten von 250.000 Objekten und 2.000.000 Kanten. Überschreitet ein Dokument eine davon, wird LimitExceeded gesetzt und der zurückgegebene Graph ist ein abgeschnittenes Präfix statt einer Lüge: Teilergebnisse mit einem Flag, keine still falschen Summen. Erhöhen Sie die Grenzen bewusst für einen forensischen Lauf, und denken Sie daran, dass die Analyse den gesamten Objektgraphen durchläuft, also gehört sie in einen Diagnosepfad, nicht in Ihre Render-Schleife

Was verrät ein nicht erreichbares Objekt?

Ein Objekt mit ReachableFromCatalog auf False ist Speicher, den das Dokument trägt, den der Viewer aber nie zeigen wird. In der Praxis kommt das von drei Stellen: inkrementelle Updates, die eine frühere Version eines Objekts abgelöst und das Original zurückgelassen haben, ein Erzeuger, der Objekte geschrieben hat, die er dann nicht verknüpft hat, oder eine beschädigte Datei, deren Querverweistabelle neu aufgebaut wurde und dabei Definitionen aufgenommen hat, auf die nichts verweist

Der erste Fall ist normal und erwartet, und genau dafür sind inkrementelle Updates und Object-Streams gedacht. Der zweite und dritte Fall lohnen eine Untersuchung. Steckt ein großer Anteil der Retained-Bytes in nicht erreichbaren Knoten, hat man ein konkretes Argument dafür gefunden, die Datei neu zu schreiben statt sie fortzuschreiben, und man hat die Bytezahl, um die zusätzliche Verarbeitungszeit gegenüber allen Beteiligten zu rechtfertigen

Zwei Allokator-Zähler, die sich zuerst lohnen

Bevor Sie schließen, dass ein Dokument von Natur aus groß ist, prüfen Sie, ob die Kosten beim Parser selbst liegen. HotPDF legt zwei Zähler offen, die beschreiben, wie das Laden verlief, statt was das Dokument enthält

GetLastParserArenaStatistics meldet die Retained-Block-Arena, die für kurzlebige Parser-Tokens und -Puffer verwendet wird: RetainedBytes, PeakUsedBytes, AllocationCount, ReusedAllocationCount, TokenCount und TemporaryObjectElisionCount. Dieses letzte Feld zählt Dictionary-Schlüssel, die direkt in die Arena geparst wurden statt über ein temporäres Name-Objekt, und das ist genau die Allokation, die das Parsen dictionary-lastiger Dateien früher dominiert hat

GetDocumentStringInternStatistics meldet den dokumentweiten Intern-Pool, der wiederholte PDF-Namen, Content-Stream-Operatoren und unveränderliche Strings bis 64 Bytes dedupliziert. Er liefert RequestCount, HitCount, MissCount, BypassCount, RetainedBytes und ReusedBytes, zusammen mit den geltenden Obergrenzen. Der Pool ist bewusst auf 65.536 Einträge und 4 MiB begrenzt, sodass ein böswilliges Dokument, das eine Million eindeutiger Namen erzeugt, aus einer Speicheroptimierung keinen Speicherverstärker machen kann; ist die Obergrenze erreicht, umgehen weitere Strings den Pool und BypassCount steigt

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;

Ein hoher ReusedBytes-Wert bei niedrigem BypassCount bedeutet, dass das Dokument das sich wiederholende Vokabular hat, das die meisten echten PDFs haben, und der Pool sich lohnt. Ein BypassCount, der sich RequestCount annähert, bedeutet etwas Ungewöhnliches: entweder ein wirklich riesiges Dokument oder eines, das absichtlich eindeutige Namen erzeugt, was ein mildes Signal ist, das man auf einem nicht vertrauenswürdigen Aufnahmepfad protokollieren sollte

Eine Triage-Reihenfolge, die funktioniert

Beginnen Sie mit CatalogRetainedBytes aus den Graph-Informationen. Liegt diese Zahl nahe am Wachstum Ihres Prozesses, steckt der Speicher im Dokument, und der Graph zeigt, wo. Liegt sie weit darunter, steckt der Speicher in Ihren eigenen Caches, in gerenderten Bitmaps oder im Parser, und die Arena-Zähler sagen, wo

Nehmen Sie dann die zehn obersten Knoten nach EstimatedRetainedBytes und schauen Sie sich deren ObjectType an. Bild-XObjects an der Spitze bedeuten, dass die Datei scan-lastig ist und Downsampling die Lösung ist. Font-Descriptors an der Spitze bedeuten, dass vollständige Schriftschnitte eingebettet wurden, wo Subsets gereicht hätten. Content-Streams an der Spitze bedeuten meist generierte Vektorgrafik, oft Karten oder CAD-Exporte. Erst danach lohnt sich der Blick auf nicht erreichbare Objekte und das Allokatorverhalten. In dieser Reihenfolge findet man die Antwort meist schon in den ersten beiden Schritten, und bei sehr großen Dokumenten ist der in der Direct-File-API-Workflow beschriebene Streaming-Ansatz häufig die strukturelle Lösung statt jeder Pro-Objekt-Optimierung

Alle diese Diagnosen sind einfache Pascal-Aufrufe, die einfache Records zurückgeben, sodass sie sich direkt in ein bestehendes Logging oder eine Telemetrie-Pipeline einfügen. HotPDF ist eine native VCL-PDF-Komponente für Delphi und C++Builder mit vollständigem Quellcode; die API-Referenz und ein Testbuild stehen auf der HotPDF Delphi PDF-Komponente-Seite