技术文章

Delphi 中不引发 OOM 的并行 PDF 页面渲染

HotPDF 通过两个入口点并发渲染多个 PDF 页面:RenderLoadedPagesParallel 返回一个位图数组,而 RenderLoadedPagesParallelOrdered 则会在每一页准备好后立即按输入顺序把它交付给一个回调。真正具备可伸缩性的是按序交付这种形式,因为无论你请求了多少页,它在内存中始终只保留有限数量的位图

这一区别就是本文的全部要点。把 10,000 页以 300 DPI 渲染进一个数组,意味着 10,000 个存活位图,这不是吞吐量问题,而是内存耗尽崩溃。通过一个队列深度为 2 的按序回调来渲染它们,则意味着只有两个存活位图,无论文档有多长,这项工作都在固定的内存占用中运行

为什么并行渲染需要两个阶段?

渲染一个 PDF 页面其实是套着同一个名字的两项不同工作。首先内容流被分词并编译成一个显示列表,这一步是 CPU 密集型的,并会触及共享缓存。然后显示列表被回放进一个位图,这一步同样是 CPU 密集型的,但会大量分配内存。把它们当作一个不可分割的整体来处理,就会被迫在"把触及缓存的那一半串行化"和"重复劳动"之间二选一

HotPDF 把它们拆开了。显示列表缓存锁只保护频率统计、LRU 顺序、使用计数和准入决策;编译本身在锁外运行。编译完成后,工作线程会重新查询缓存:如果与此同时另一个工作线程已经发布了同一页,它就释放自己的副本,转而对已发布的条目增加一次使用计数。这样既能限制重复劳动的上限,又能防止同一页出现两个条目,而且从不需要跨页串行化编译

在一个 32 页复杂测试样本上实测的效果值得记住。在 Win64 上,总耗时从单个工作线程的 1,565 毫秒降到四个工作线程的 696 毫秒,大约快了 2.25 倍。得到首个结果的时间从 1,255 毫秒降到 63 毫秒,降幅约 95%,因为工作线程会在其他线程仍在编译时就开始回放已完成的显示列表,而不是在一道完整的编译屏障前等待

示意图:HotPDF 在 Delphi 中的两阶段并行渲染流水线,编译阶段先写入一个共享显示列表缓存,再进行位图回放
HotPDF 把每一页编译成一份带缓存的显示列表,并在任意空闲工作线程上回放,从而让回放与编译重叠,而不是排队等在一道屏障之后

按序交付与保留槽位

按序 API 保证你的回调看到的页面顺序与你请求的顺序一致,而工作线程本身则按各自完成的先后顺序结束。已完成的位图会被发布到按输入位置编号的输出槽位中,回调则在调用线程上串行执行

有两条规则使其做到"有限"而不仅仅是"有序"。输出队列计数包含当前正在被回调使用的位图,因此一个缓慢的回调无法让生产者的进度超出队列深度。而且后面的页面最多只能占据 queueDepth - 1 个槽位,因为总有一个槽位被永久保留给下一个待交付的页面。如果没有这个预留,一个进度缓慢的第一页就可能被后面已完成、占满队列的页面挤到无法交付,导致整条流水线在队首死锁,而所有工作线程都处于空闲状态

示意图:HotPDF 中的按序交付机制,乱序完成的工作线程结果填入输出槽位,一个保留槽位确保下一个待交付页面持续流动
已完成的页面落入按输入位置编号的槽位,并通过一个串行的借用位图回调排出,其中一个槽位被保留,使队首页面永远不会被挤出
uses
  HPDFDoc;

procedure TExportJob.PageReady(Sender: TObject; InputIndex,
  PageIndex: Integer; Bitmap: TBitmap; var Cancel: boolean);
begin
  // 位图是借用的:仅在本次调用期间有效。请在此处使用
  // (写入磁盘、编码、计算哈希),不要保留引用
  Bitmap.SaveToFile(Format('page-%.4d.bmp', [InputIndex + 1]));
  Cancel := FUserCancelled;
end;

procedure TExportJob.Run;
var
  Pdf: THotPDF;
  Pages: array of Integer;
  Info: THPDFParallelRenderPipelineInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('manual.pdf') <= 0 then
      Exit;
    SetLength(Pages, Pdf.LoadedPageCount);
    for I := 0 to High(Pages) do
      Pages[I] := I;

    Pdf.RenderLoadedPagesParallelOrdered(Pages, 200, 4, 2,
      PageReady, Info);

    Writeln(Format('workers=%d peak queued=%d peak bytes=%d',
      [Info.WorkerCount, Info.PeakQueuedPageCount, Info.PeakQueuedBytes]));
    Writeln(Format('first result=%d ms  total=%d ms  backpressure waits=%d',
      [Info.FirstResultMilliseconds, Info.TotalMilliseconds,
       Info.BackpressureWaitCount]));
  finally
    Pdf.Free;
  end;
end;

内存占用之所以能保持平稳,靠的正是"借用位图"这一约定。库会在你的回调返回后立即释放位图,因此一个 10,000 页、队列深度为 2 的测试样本,在 Win32 和 Win64 上都只会峰值出现恰好两个位图。如果你需要在回调结束后继续保留这个位图,就复制一份,此后内存和统计都由你自己负责

内存预算如何决定工作线程数

更多的工作线程并不总是更快,反而常常是致命的。六个工作线程以 600 DPI 渲染 A3 页面,无论有多少核心可用,都需要几百兆字节的存活位图,而一台拥有八个核心的机器未必有那么多空闲地址空间

因此 HotPDF 会根据请求规模、页数和一个硬性上限对请求的工作线程数做归一化处理,然后用当前可用的全局预算,除以该请求中最大的、保守估计的单页内存占用。至少会有一个工作线程始终存活,因此即便是超大页面也仍能渲染,只是独占进行。在参考测量中,一个六页、600 DPI、预算为 512 MiB 的任务,最终以三个而不是六个工作线程运行,预留内存峰值为 504,583,296 字节

每个工作线程在认领一页之后、开始编译之前都会做一次内存预留,该预留同时覆盖编译和回放。如果只围绕光栅化阶段预留,就会漏算显示列表和内容流的工作集,而在图形密集的页面上,这部分往往占更大的份额。两个 API 在这里的处理并不相同:数组形式的 API 会在位图完成后立即释放其预留,因为调用方拥有了结果,调度器也就无法再对其记账;而按序形式的 API 则会把预留连同位图一起转移进输出槽位,直到回调返回才释放。这也是当输出集较大时更适合选用按序 API 的另一个原因

示意图:HotPDF 的内存预算用 512 MiB 除以最大单页估算值,从而准入三个工作线程而不是六个
预算根据最大单页估算值来准入工作线程,每份预留同时覆盖编译与回放,按序 API 会让预留一直存活到回调返回为止

取消而不死锁

在回调中设置 Cancel 会停止新的工作,并唤醒任何正在等待背压的工作线程。已经进入渲染器内部的页面会正常完成并被清理,而不是被直接丢弃,并且只保留第一个失败诊断信息,在所有工作线程都结束后再抛出

这里的顺序是有承重作用的。先设置取消状态,再释放尚未发布的输出预留,最后才等待工作线程结束。如果把最后两步的顺序颠倒,就会形成一个闭环:某个工作线程在等待获准使用一份预算,而这份预算要等消费者结束才会被释放,与此同时消费者又在等待这个工作线程退出

交互式应用还应该了解前台抢占机制。页面预取运行在自己的一组取消令牌上,前台渲染调用会先取消并等待预取工作线程结束,然后才进行自己的工作,因此后台吞吐量永远不会给用户正在等待的那一页增加延迟。取消检查以每扫描 4 KiB 字节或每 256 个令牌为间隔进行,不过一次原子的编解码调用或系统调用是无法在执行途中被中断的——响应性保证覆盖的是库自身拥有的循环,而不是第三方解码器。基于队列的交互式渲染方案见 用请求队列实现后台渲染

解读流水线统计信息

THPDFParallelRenderPipelineInfo 把容易混淆的数字区分开来。RequestedWorkerCount 对照 WorkerCount 和 MemoryBudgetWorkerLimit,能告诉你预算是否压低了并发度。CompiledPageCount 对照 CacheHitPageCount,能告诉你显示列表缓存节省了多少。CompiledPagesAtFirstResult 则表明流水线是否真的把编译和回放重叠了起来,还是退化成了一道屏障

用于调优时,最有用的两项是 BackpressureWaitCount 和 MemorySchedulerWaitMilliseconds。背压等待次数高,说明瓶颈在你的回调,要么让它更快,要么在内存允许的情况下加大队列深度。调度器等待次数高,说明瓶颈在预算,要降低 DPI、降低并发度,或者提高预算。无论哪种情况,提高工作线程数都会让吞吐量变差,这是反直觉的地方

批量导出的一个实用起点是:并发度设为物理核心数减一,队列深度设为 2 或 3,DPI 则根据实际输出需求选取,而不是凭习惯。然后从真实文档中读取统计信息,调整一次,而不是靠猜测调整两次。对于文档本身大到无法整个装入内存的作业,可以把这套方法与 面向大型 PDF 工作流的直接文件 API 中的流式访问模型结合使用

并行渲染、预取和显示列表缓存,都属于 Delphi 与 C++Builder 同一套渲染栈的一部分;完整功能列表见 HotPDF Delphi PDF 组件页面