Articolo tecnico

Trova i divoratori di memoria PDF con HotPDF

HotPDF espone il profilo di memoria di un PDF caricato come un grafo interrogabile: BuildLoadedObjectDependencyGraph restituisce un nodo per ogni oggetto indiretto con una dimensione shallow stimata, una dimensione retained basata sui dominator, e un flag che indica se l'oggetto è ancora raggiungibile dal Catalog del documento. Questo trasforma «questo file usa 800 MB» in «l'oggetto 4173, un image XObject, trattiene in esclusiva 612 MB», che è un fatto su cui puoi agire

La distinzione tra queste due frasi è tutto il punto. La dimensione shallow ti dice quanto è grande un oggetto. La dimensione retained ti dice quanta memoria verrebbe effettivamente liberata se quell'oggetto scomparisse, ed è il numero che decide se una correzione serve davvero

Perché l'uso totale di memoria non è un dato su cui agire?

Perché in un PDF quasi nulla è posseduto esattamente da una cosa sola. Un singolo font CID incorporato è referenziato dal resource dictionary di ogni pagina che lo usa. Uno stream di profilo ICC sostiene un colour space condiviso da dieci content stream diversi. Un Form XObject usato come timbro compare su tutte le 400 pagine. Se sommi ingenuamente le dimensioni degli oggetti per pagina, conti quel font 400 volte e concludi che ogni pagina è enorme; se lo dividi per 400, concludi che nulla è costoso e che la memoria è venuta da qualche altra parte

L'analisi dei dominator risolve l'ambiguità nell'unico modo che sopravvive al contatto con documenti reali. Un oggetto X viene attribuito all'oggetto più vicino che lo trattiene in esclusiva, cioè ogni percorso di riferimento dal Catalog a X passa attraverso quel dominator. Un font condiviso da tutte le pagine non viene attribuito a nessuna pagina; viene attribuito al nodo più vicino attraverso cui passano tutti quei percorsi, che di solito è il Catalog stesso. Un font usato da esattamente una pagina viene attribuito a quella pagina. Il risultato è che EstimatedRetainedBytes somma correttamente invece di contare due volte, e gli oggetti in cima alla lista sono oggetti la cui rimozione libererebbe davvero memoria

Cosa contiene davvero il grafo

Ogni THPDFObjectDependencyNode porta l'identità dell'oggetto come ObjectNumber e GenerationNumber, più ObjectType, LifecycleState, EstimatedShallowBytes, EstimatedRetainedBytes, IncomingReferenceCount, OutgoingReferenceCount, l'identità del suo dominator immediato, e ReachableFromCatalog. Ogni THPDFObjectDependencyEdge registra source, target, il Path del dictionary sotto cui è stato trovato il riferimento, e se è Resolved

Quel campo Path è quello che viene sottoutilizzato. È la differenza tra sapere che l'oggetto 91 punta all'oggetto 4173 e sapere che lo fa attraverso /Resources/XObject/Im3, il che ti dice immediatamente se stai guardando contenuto di pagina, un appearance stream di un'annotazione, o un optional-content group che nessuno renderizza mai

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;

Entrambi i limiti sono parametri espliciti con valori predefiniti di 250.000 oggetti e 2.000.000 archi. Quando un documento supera l'uno o l'altro, LimitExceeded viene impostato e il grafo restituito è un prefisso troncato anziché una bugia: risultati parziali con un flag, non totali sbagliati in silenzio. Alza i limiti deliberatamente per un'esecuzione forense, e ricorda che l'analisi percorre l'intero grafo degli oggetti, quindi appartiene a un percorso diagnostico, non al tuo ciclo di rendering

Cosa ti dice un oggetto non raggiungibile?

Un oggetto con ReachableFromCatalog impostato a False è memoria che il documento porta con sé ma che il viewer non mostrerà mai. In pratica proviene da tre fonti: incremental update che hanno sostituito una versione precedente di un oggetto lasciandosi indietro l'originale, un producer che ha scritto oggetti che poi non è riuscito a collegare, o un file danneggiato la cui cross-reference table è stata ricostruita e ha raccolto definizioni a cui nulla fa riferimento

Il primo caso è normale e atteso, ed è esattamente ciò per cui sono progettati gli incremental update e gli object stream. Il secondo e il terzo meritano un'indagine. Quando una grande frazione dei retained byte risiede in nodi non raggiungibili, hai trovato un argomento concreto per riscrivere il file invece di aggiungervi in append, e hai il conteggio dei byte per giustificare il tempo di elaborazione extra a chiunque lo chieda

Due contatori dell'allocatore da leggere per primi

Prima di concludere che un documento è intrinsecamente grande, verifica se il costo è il parser stesso. HotPDF espone due contatori che descrivono come si è comportato il caricamento, non cosa contiene il documento

GetLastParserArenaStatistics riporta l'arena a blocchi retained usata per i token e i buffer di breve durata del parser: RetainedBytes, PeakUsedBytes, AllocationCount, ReusedAllocationCount, TokenCount e TemporaryObjectElisionCount. Quest'ultimo campo conta le chiavi di dictionary analizzate direttamente nell'arena invece che tramite un oggetto nome temporaneo, che è l'allocazione che un tempo dominava il parsing dei file ricchi di dictionary

GetDocumentStringInternStatistics riporta il pool di intern con scope documento che deduplica nomi PDF ripetuti, operatori di content stream e stringhe immutabili fino a 64 byte. Ti fornisce RequestCount, HitCount, MissCount, BypassCount, RetainedBytes e ReusedBytes, insieme ai limiti in vigore. Il pool è deliberatamente limitato a 65.536 voci e 4 MiB, così un documento ostile che genera un milione di nomi unici non può trasformare un'ottimizzazione di memoria in un amplificatore di memoria; una volta raggiunto il limite, le stringhe successive bypassano il pool e BypassCount sale

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;

Un valore alto di ReusedBytes con un BypassCount basso significa che il documento ha il vocabolario ripetitivo tipico della maggior parte dei PDF reali e che il pool si sta guadagnando il suo posto. Un BypassCount che si avvicina a RequestCount significa qualcosa di insolito: o un documento genuinamente enorme, o uno che genera nomi unici di proposito, il che è un segnale lieve che vale la pena registrare su un percorso di ingestion non attendibile

Un ordine di triage che funziona

Inizia con CatalogRetainedBytes dalle graph info. Se quel numero è vicino alla crescita del tuo processo, la memoria è nel documento e il grafo ti mostrerà dove. Se è molto più basso, la memoria è nelle tue cache, nei bitmap renderizzati, o nel parser, e i contatori dell'arena diranno quale

Poi prendi i primi dieci nodi per EstimatedRetainedBytes e guarda il loro ObjectType. Image XObject in cima significa che il file è scan-heavy e il downsampling è la correzione. Descrittori di font in cima significano che sono state incorporate face complete dove sarebbero bastati subset. Content stream in cima di solito significano grafica vettoriale generata, spesso mappe o export CAD. Solo dopo conviene guardare agli oggetti non raggiungibili e al comportamento dell'allocatore. Lavorando in quest'ordine, di solito troverai la risposta nei primi due passi, e per i documenti molto grandi l'approccio in streaming descritto in il workflow della direct file API è spesso la correzione strutturale invece di qualunque ottimizzazione per singolo oggetto

Tutte queste diagnostiche sono semplici chiamate Pascal che restituiscono semplici record, quindi si inseriscono direttamente in un percorso di logging o telemetria esistente. HotPDF è un componente PDF VCL nativo per Delphi e C++Builder con codice sorgente completo; il riferimento API e una build di prova sono sulla pagina del componente PDF Delphi HotPDF