Technisch artikel

PDF-geheugenvreters vinden met HotPDF-objectgrafen

HotPDF legt het geheugenprofiel van een geladen PDF bloot als een graaf die u kunt bevragen: BuildLoadedObjectDependencyGraph geeft één node per indirect object terug, met een geschatte oppervlakkige grootte, een op dominators gebaseerde retained-grootte, en een vlag die aangeeft of het object nog bereikbaar is vanuit de documentcatalogus. Dat verandert "dit bestand gebruikt 800 MB" in "object 4173, een image-XObject, houdt exclusief 612 MB vast", en dat is een feit waarop u kunt handelen

Het verschil tussen die twee zinnen is precies waar het om draait. Oppervlakkige grootte vertelt u hoe groot één object is. Retained-grootte vertelt u hoeveel geheugen er daadwerkelijk vrijkomt als dat object zou verdwijnen, en dat is het getal dat bepaalt of een oplossing helpt

Waarom is totaal geheugengebruik geen bruikbaar feit?

Omdat in een PDF vrijwel niets door precies één ding wordt bezeten. Eén ingesloten CID-font wordt aangehaald vanuit de resource-dictionary van elke pagina die het gebruikt. Een ICC-profielstroom ondersteunt een kleurruimte die tien verschillende inhoudsstromen delen. Een Form-XObject dat als stempel gebruikt wordt, verschijnt op alle 400 pagina's. Als u naïef objectgroottes per pagina optelt, telt u dat font 400 keer mee en concludeert u dat elke pagina enorm is; deelt u het door 400, dan concludeert u dat niets duur is en het geheugen ergens anders vandaan komt

Dominatoranalyse lost de dubbelzinnigheid op de enige manier op die contact met echte documenten overleeft. Een object X wordt toegeschreven aan het dichtstbijzijnde object dat het exclusief vasthoudt, wat betekent dat elk verwijzingspad van de catalogus naar X door die dominator loopt. Een font dat door alle pagina's gedeeld wordt, wordt aan geen enkele pagina toegeschreven; het wordt toegeschreven aan de dichtstbijzijnde node waar al die paden doorheen lopen, meestal de catalogus zelf. Een font dat door precies één pagina gebruikt wordt, wordt aan die pagina toegeschreven. Het resultaat is dat EstimatedRetainedBytes correct optelt in plaats van dubbel te tellen, en de objecten bovenaan de lijst zijn objecten waarvan verwijdering werkelijk geheugen zou vrijmaken

Wat de graaf werkelijk bevat

Elke THPDFObjectDependencyNode draagt de objectidentiteit als ObjectNumber en GenerationNumber, plus ObjectType, LifecycleState, EstimatedShallowBytes, EstimatedRetainedBytes, IncomingReferenceCount, OutgoingReferenceCount, de identiteit van zijn onmiddellijke dominator, en ReachableFromCatalog. Elke THPDFObjectDependencyEdge registreert bron, doel, het dictionary-Path waaronder de verwijzing gevonden werd, en of die Resolved is

Dat Path-veld is degene die mensen onderbenutten. Het is het verschil tussen weten dat object 91 naar object 4173 verwijst, en weten dat het dat doet via /Resources/XObject/Im3, wat u onmiddellijk vertelt of u naar pagina-inhoud kijkt, een verschijningsstroom van een annotatie, of een optionele-inhoudsgroep die niemand ooit 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 zijn expliciete parameters met standaardwaarden van 250.000 objecten en 2.000.000 edges. Wanneer een document een van beide overschrijdt, wordt LimitExceeded gezet en is de teruggegeven graaf een afgekapt prefix in plaats van een leugen: gedeeltelijke resultaten met een vlag, geen stilzwijgend foutieve totalen. Verhoog de limieten bewust voor een forensische run, en onthoud dat de analyse de volledige objectgraaf doorloopt, dus hoort ze thuis op een diagnostisch pad en niet in uw renderlus

Wat vertelt een onbereikbaar object u?

Een object waarvan ReachableFromCatalog op False staat, is geheugen dat het document meedraagt maar dat de viewer nooit zal tonen. In de praktijk komt het uit drie bronnen: incrementele updates die een eerdere versie van een object vervingen en het origineel achterlieten, een producent die objecten wegschreef die hij vervolgens niet koppelde, of een beschadigd bestand waarvan de cross-referentietabel herbouwd is en definities oppikte waar niets naar verwijst

Het eerste geval is normaal en verwacht, en precies waarvoor incrementele updates en objectstromen ontworpen zijn. Het tweede en derde geval zijn het onderzoeken waard. Wanneer een groot deel van de retained bytes in onbereikbare nodes zit, hebt u een concreet argument gevonden om het bestand te herschrijven in plaats van eraan toe te voegen, en hebt u het aantal bytes om de extra verwerkingstijd te rechtvaardigen tegenover wie er ook maar naar vraagt

Twee allocatortellers die het waard zijn om eerst te lezen

Voordat u concludeert dat een document inherent groot is, controleert u of de parser zelf de kosten veroorzaakt. HotPDF legt twee tellers bloot die beschrijven hoe het laden verliep, in plaats van wat het document bevat

GetLastParserArenaStatistics rapporteert de retained blockarena die gebruikt wordt voor kortlevende parsertokens en buffers: RetainedBytes, PeakUsedBytes, AllocationCount, ReusedAllocationCount, TokenCount en TemporaryObjectElisionCount. Dat laatste veld telt dictionary-sleutels die rechtstreeks in de arena geparseerd zijn in plaats van via een tijdelijk naamobject, wat de allocatie is die vroeger het parseren van dictionary-zware bestanden domineerde

GetDocumentStringInternStatistics rapporteert de documentbrede intern-pool die herhaalde PDF-namen, content-stream-operatoren en immutable strings tot 64 bytes dedupliceert. Het geeft u RequestCount, HitCount, MissCount, BypassCount, RetainedBytes en ReusedBytes, samen met de van kracht zijnde plafonds. De pool is bewust begrensd op 65.536 items en 4 MiB, zodat een vijandig document dat een miljoen unieke namen genereert een geheugenoptimalisatie niet in een geheugenversterker kan veranderen; zodra het plafond bereikt is, omzeilen verdere strings de pool en klimt BypassCount

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;

Een hoge ReusedBytes-waarde met een lage BypassCount betekent dat het document de repetitieve woordenschat heeft die de meeste echte PDF's hebben en dat de pool haar bestaansrecht waarmaakt. Een BypassCount die RequestCount nadert, betekent iets ongewoons: ofwel een werkelijk enorm document, ofwel een document dat opzettelijk unieke namen genereert, wat een mild signaal is dat het waard is om te loggen op een niet-vertrouwd intakepad

Een triage-volgorde die werkt

Begin met CatalogRetainedBytes uit de graafinfo. Ligt dat getal dicht bij uw procesgroei, dan zit het geheugen in het document en zal de graaf u laten zien waar. Ligt het daar ver onder, dan zit het geheugen in uw eigen caches, in gerenderde bitmaps, of in de parser, en de arena-tellers zullen zeggen welke

Neem vervolgens de top tien nodes op EstimatedRetainedBytes en bekijk hun ObjectType. Image-XObjects bovenaan betekenen dat het bestand scan-zwaar is en downsampling de oplossing is. Fontdescriptors bovenaan betekenen dat volledige fonts ingesloten werden waar subsets hadden volstaan. Content streams bovenaan betekenen meestal gegenereerde vectorafbeeldingen, vaak kaarten of CAD-exports. Pas daarna loont het om naar onbereikbare objecten en allocatorgedrag te kijken. Werkt u in die volgorde, dan vindt u het antwoord meestal al in de eerste twee stappen, en voor zeer grote documenten is de streaming-aanpak beschreven in de workflow van de Direct File API vaak de structurele oplossing in plaats van enige optimalisatie per object

Al deze diagnostiek bestaat uit gewone Pascal-aanroepen die gewone records teruggeven, dus ze passen rechtstreeks in een bestaand logging- of telemetriepad. HotPDF is een native VCL PDF-component voor Delphi en C++Builder met volledige broncode; de API-referentie en een proefversie staan op de HotPDF Delphi PDF-componentpagina