Article technique

Repérer les gouffres mémoire PDF : graphes HotPDF

HotPDF expose le profil mémoire d'un PDF chargé sous forme d'un graphe que vous pouvez interroger : BuildLoadedObjectDependencyGraph renvoie un nœud par objet indirect avec une taille superficielle estimée, une taille retenue basée sur les dominateurs, et un indicateur précisant si l'objet reste accessible depuis le Catalog du document. Cela transforme « ce fichier utilise 800 Mo » en « l'objet 4173, un XObject image, retient exclusivement 612 Mo », un fait sur lequel vous pouvez agir

La distinction entre ces deux phrases est tout l'enjeu. La taille superficielle vous indique la taille d'un objet. La taille retenue vous indique combien de mémoire serait réellement libérée si cet objet disparaissait, et c'est ce nombre qui détermine si une correction sert à quelque chose

Pourquoi l'utilisation totale de mémoire n'est-elle pas un fait exploitable ?

Parce que dans un PDF, presque rien n'appartient exclusivement à une seule chose. Une police CID incorporée unique est référencée depuis le dictionnaire de ressources de chaque page qui l'utilise. Un flux de profil ICC soutient un espace colorimétrique que dix flux de contenu différents partagent. Un XObject de formulaire utilisé comme tampon apparaît sur les 400 pages. Si vous additionnez naïvement les tailles d'objets par page, vous comptez cette police 400 fois et concluez que chaque page est énorme ; si vous divisez par 400, vous concluez que rien n'est coûteux et que la mémoire vient d'ailleurs

L'analyse par dominateurs résout l'ambiguïté de la seule façon qui survit au contact des documents réels. Un objet X est attribué à l'objet le plus proche qui le retient exclusivement, ce qui signifie que chaque chemin de référence allant du Catalog à X passe par ce dominateur. Une police partagée par toutes les pages n'est attribuée à aucune page ; elle est attribuée au nœud le plus proche par lequel passent tous ces chemins, généralement le Catalog lui-même. Une police utilisée par exactement une seule page est attribuée à cette page. Le résultat est que EstimatedRetainedBytes s'additionne correctement au lieu de compter en double, et les objets en tête de liste sont ceux dont la suppression libérerait réellement de la mémoire

Ce que le graphe contient réellement

Chaque THPDFObjectDependencyNode porte l'identité de l'objet via ObjectNumber et GenerationNumber, plus ObjectType, LifecycleState, EstimatedShallowBytes, EstimatedRetainedBytes, IncomingReferenceCount, OutgoingReferenceCount, l'identité de son dominateur immédiat, et ReachableFromCatalog. Chaque THPDFObjectDependencyEdge enregistre la source, la cible, le Path du dictionnaire sous lequel la référence a été trouvée, et si elle a Resolved

Ce champ Path est celui que l'on sous-utilise. C'est la différence entre savoir que l'objet 91 pointe vers l'objet 4173 et savoir qu'il le fait via /Resources/XObject/Im3, ce qui vous indique immédiatement si vous regardez du contenu de page, un flux d'apparence d'annotation, ou un groupe de contenu optionnel que personne ne rend jamais

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;

Les deux bornes sont des paramètres explicites avec des valeurs par défaut de 250 000 objets et 2 000 000 arêtes. Quand un document dépasse l'une ou l'autre, LimitExceeded est positionné et le graphe renvoyé est un préfixe tronqué plutôt qu'un mensonge : des résultats partiels avec un indicateur, pas des totaux silencieusement faux. Relevez les limites délibérément pour une exécution d'analyse forensique, et rappelez-vous que l'analyse parcourt l'intégralité du graphe d'objets, elle appartient donc à un chemin de diagnostic, pas à votre boucle de rendu

Que vous apprend un objet inaccessible ?

Un objet dont ReachableFromCatalog vaut False est de la mémoire que le document transporte mais que la visionneuse n'affichera jamais. En pratique, cela provient de trois sources : des mises à jour incrémentielles qui ont remplacé une version antérieure d'un objet et laissé l'original derrière elles, un producteur qui a écrit des objets qu'il n'a ensuite pas réussi à lier, ou un fichier endommagé dont la table de références croisées a été reconstruite et a récupéré des définitions que rien ne référence

Le premier cas est normal et attendu, c'est précisément ce que les mises à jour incrémentielles et les flux d'objets sont conçus pour faire. Les deuxième et troisième cas méritent d'être examinés. Quand une grande partie des octets retenus se trouve dans des nœuds inaccessibles, vous avez trouvé un argument concret pour réécrire le fichier plutôt que d'y ajouter des données, et vous disposez du nombre d'octets pour justifier le temps de traitement supplémentaire auprès de quiconque le demande

Deux compteurs d'allocateur à consulter en premier

Avant de conclure qu'un document est intrinsèquement volumineux, vérifiez si le coût vient du parseur lui-même. HotPDF expose deux compteurs qui décrivent comment le chargement s'est comporté plutôt que ce que le document contient

GetLastParserArenaStatistics rapporte l'arène de blocs retenus utilisée pour les jetons et tampons de courte durée du parseur : RetainedBytes, PeakUsedBytes, AllocationCount, ReusedAllocationCount, TokenCount et TemporaryObjectElisionCount. Ce dernier champ compte les clés de dictionnaire analysées directement dans l'arène plutôt que via un objet nom temporaire, l'allocation qui dominait autrefois l'analyse des fichiers riches en dictionnaires

GetDocumentStringInternStatistics rapporte le pool d'internement à portée document qui déduplique les noms PDF répétés, les opérateurs de flux de contenu et les chaînes immuables jusqu'à 64 octets. Il vous donne RequestCount, HitCount, MissCount, BypassCount, RetainedBytes et ReusedBytes, ainsi que les plafonds en vigueur. Le pool est délibérément borné à 65 536 entrées et 4 Mio, si bien qu'un document hostile qui génère un million de noms uniques ne peut pas transformer une optimisation mémoire en amplificateur de mémoire ; une fois le plafond atteint, les chaînes suivantes contournent le pool et BypassCount grimpe

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;

Une valeur élevée de ReusedBytes avec un BypassCount faible signifie que le document possède le vocabulaire répétitif qu'ont la plupart des PDF réels et que le pool se justifie pleinement. Un BypassCount qui s'approche de RequestCount signifie quelque chose d'inhabituel : soit un document réellement énorme, soit un document qui génère des noms uniques exprès, un signal léger qui mérite d'être journalisé sur un chemin d'entrée non fiable

Un ordre de tri qui fonctionne

Commencez par CatalogRetainedBytes dans les informations du graphe. Si ce nombre est proche de la croissance de votre processus, la mémoire se trouve dans le document et le graphe vous montrera où. S'il est très inférieur, la mémoire se trouve dans vos propres caches, dans des bitmaps rendus, ou dans le parseur, et les compteurs d'arène vous diront lequel

Prenez ensuite les dix premiers nœuds par EstimatedRetainedBytes et regardez leur ObjectType. Des XObjects image en tête signifient que le fichier est chargé de numérisations et que le sous-échantillonnage est la solution. Des descripteurs de police en tête signifient que des polices complètes ont été incorporées là où des sous-ensembles auraient suffi. Des flux de contenu en tête signifient généralement des graphiques vectoriels générés, souvent des cartes ou des exports CAO. Ce n'est qu'ensuite qu'il vaut la peine de regarder les objets inaccessibles et le comportement de l'allocateur. En travaillant dans cet ordre, vous trouverez généralement la réponse dès les deux premières étapes, et pour les très gros documents, l'approche en flux décrite dans le flux de travail Direct File API est souvent la correction structurelle plutôt qu'une quelconque optimisation par objet

Tous ces diagnostics sont de simples appels Pascal renvoyant de simples enregistrements, si bien qu'ils s'intègrent directement dans un chemin de journalisation ou de télémétrie existant. HotPDF est un composant VCL PDF natif pour Delphi et C++Builder avec le code source complet ; la référence API et une version d'essai se trouvent sur la page HotPDF Delphi PDF component