技术文章

HotPDF:用对象依赖图找出 PDF 内存元凶

HotPDF 把已加载 PDF 的内存画像暴露为一张可以查询的图:BuildLoadedObjectDependencyGraph 会为每一个间接对象返回一个节点,其中包含估计的浅层大小、基于支配关系计算的保留大小,以及一个标志位表示该对象是否仍然能从文档 Catalog 到达。这把"这个文件用了 800 MB"变成了"对象 4173,一个图像 XObject,独占持有 612 MB",后者是一个你能据此采取行动的事实

这两句话之间的区别就是全部要点所在。浅层大小告诉你一个对象本身有多大。保留大小告诉你如果这个对象消失,实际能释放出多少内存——而正是这个数字,决定了一次修复到底有没有用

为什么总内存占用不是一个可以据此行动的事实?

因为在一份 PDF 里,几乎没有什么东西是被恰好一个东西独占拥有的。一个内嵌的 CID 字体,会被每一个用到它的页面的资源字典引用。一个 ICC 颜色配置流,支撑着十个不同内容流共享的一个颜色空间。一个用作图章的 Form XObject,出现在全部 400 个页面上。如果你天真地按页面把对象大小加起来,这个字体会被数上 400 次,你会得出每一页都巨大无比的结论;如果你把它除以 400,你又会得出没什么东西开销大、内存另有去处的结论

支配关系分析用唯一一种经得起真实文档考验的方式消解了这种歧义。对象 X 被归因到唯一能独占持有它的最近对象,也就是说从 Catalog 到 X 的每一条引用路径都要经过这个支配节点。被所有页面共享的字体不会被归因到任何一个页面上;它会被归因到所有这些路径共同经过的最近节点,通常就是 Catalog 本身。一个只被一个页面用到的字体,就会被归因到那个页面。最终结果是 EstimatedRetainedBytes 能正确求和,而不是重复计数,排在列表最前面的对象,都是那些一旦移除就真的能释放内存的对象

这张图里实际包含什么

每个 THPDFObjectDependencyNode 都携带以 ObjectNumberGenerationNumber 表示的对象身份,加上 ObjectTypeLifecycleStateEstimatedShallowBytesEstimatedRetainedBytesIncomingReferenceCountOutgoingReferenceCount、它的直接支配节点身份,以及 ReachableFromCatalog。每个 THPDFObjectDependencyEdge 都记录着源节点、目标节点、发现这条引用所在的字典 Path,以及它是否 Resolved

Path 这个字段是最容易被人低估的一个。它的区别在于:知道对象 91 指向对象 4173,和知道它是通过 /Resources/XObject/Im3 这样指向的,二者完全不同——后者能立刻告诉你,你看到的到底是页面内容、批注外观流,还是一个从来没人渲染过的可选内容组

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;

这两个上限都是显式参数,默认值分别是 250,000 个对象和 2,000,000 条边。文档超出任何一个上限时,LimitExceeded 会被置位,返回的图是一个截断的前缀,而不是一个谎言:这是带标志位的部分结果,而不是悄悄错误的总数。做取证分析时可以有意调高这两个上限,同时要记住这项分析会遍历整个对象图,因此它属于诊断路径,不属于你的渲染循环

一个不可达的对象能告诉你什么?

ReachableFromCatalog 为 False 的对象,是文档携带着、但查看器永远不会显示的内存。实际中它通常来自三个来源:增量更新替换了某个对象的旧版本、却把原来的版本留在了文件里;生成端写出了对象却没能链接上它们;或者一个损坏的文件,其交叉引用表被重建后,收进了一些没有任何东西引用的定义

第一种情况正常且在预期之内,这正是增量更新与对象流被设计出来要做的事。第二种和第三种值得深入调查。当很大一部分保留字节数都落在不可达节点上时,你就找到了一个应该重写文件而不是继续追加更新的具体理由,而且你手上有具体的字节数,可以向任何提出质疑的人证明多花这些处理时间是合理的

两个值得优先查看的分配器计数器

在断定一份文档本身就很大之前,先检查一下开销是不是解析器自己造成的。HotPDF 提供了两个计数器,描述的是加载过程的行为,而不是文档本身包含什么

GetLastParserArenaStatistics 报告的是用于短生命周期解析器 token 和缓冲区的保留块 arena:RetainedBytesPeakUsedBytesAllocationCountReusedAllocationCountTokenCountTemporaryObjectElisionCount。最后这个字段统计的是直接解析进 arena、而不经过临时名称对象的字典键数量,这类分配曾经是解析字典密集型文件时的主要开销来源

GetDocumentStringInternStatistics 报告的是文档范围内的驻留池,用于对重复的 PDF 名称、内容流操作符和不超过 64 字节的不可变字符串去重。它给出 RequestCountHitCountMissCountBypassCountRetainedBytesReusedBytes,以及当前生效的上限。这个池被刻意限制在 65,536 条目和 4 MiB 以内,因此一份生成一百万个独一无二名称的恶意文档,无法把一项内存优化反转成一个内存放大器;一旦达到上限,后续字符串就会绕过这个池,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;

ReusedBytes 高、BypassCount 低,说明这份文档具备大多数真实 PDF 都有的那种重复性词汇特征,驻留池正在发挥作用。BypassCount 接近 RequestCount,则说明有点不寻常:要么是一份真正巨大的文档,要么是有意生成独一无二名称的文档,这在不受信任的接入路径上是一个值得记录下来的轻微信号

一套有效的排查顺序

先从图信息里的 CatalogRetainedBytes 看起。如果这个数字接近你进程的内存增长量,说明内存都在文档里,这张图会告诉你具体在哪。如果远低于这个增长量,说明内存在你自己的缓存里、在渲染出的位图里,或者在解析器里,arena 计数器会告诉你具体是哪一个

然后按 EstimatedRetainedBytes 取前十个节点,看它们的 ObjectType。图像 XObject 排在最前面,说明文件以扫描件为主,降采样就是解决办法。字体描述符排在最前面,说明本该用子集就够的地方却嵌入了完整字体。内容流排在最前面,通常说明是生成出来的矢量图形,多半是地图或 CAD 导出结果。只有到这一步之后,再去看不可达对象和分配器行为才划算。按这个顺序排查,你通常会在前两步里就找到答案,而对于非常大的文档,直接文件 API 工作流一文中描述的流式方案,往往才是结构性的解法,而不是任何针对单个对象的优化

所有这些诊断手段都是返回普通记录的普通 Pascal 调用,可以直接接入你现有的日志或遥测路径。HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,提供完整源码;API 参考和试用版见 HotPDF Delphi PDF 组件页面