技术文章

在 Delphi 中优化 GB 级 PDF 处理的 IO 性能

A PDF parser 的第一次有效读取发生在文件尾部而不是开头,格式会把 startxref 指针放在末尾字节,所以处理 1.8 GB 文件会先 seek 到尾部、读取 1 KB,再跳到交叉引用表标记的目录位置继续读取。随后解析会在整个字节区间上做随机跳转,因此缓冲 IO 的优势在这里几乎没有用

这篇文章第一版错误地称 2 GB 输入的 TMemoryStream 在 32 位上会被内存映射文件直接解决。这个说法是错的,错误会直接指向真实修复点:滑动映射窗口。接下来会看到访问模式、可编译的窗口映射器修正版,以及 1.8 GB、30 万对象测试文件的系统调用数量

为什么 PDF 布局让缓冲读失效

有三个结构事实决定了 IO 模式。第一是偏移驱动:交叉引用表将每个对象号映射到绝对字节位置,且这些偏移量不要求有序。经过多次增量更新后,对象 4102 可能在 1.6 GB 偏移,而对象 4103 可能在 30 KB 偏移。TFileStream 的循环里每次抓取都变成一次 Seek 加一次 Read,即两次内核切换,而下一次抓取常常相距数百兆字节

第二是对象流(ISO 32000-1 §7.5.7)把几十到上百个小字典打包进一个压缩容器。抓取一个 300 字节的页面字典时,往往要读并解压 100 KB 的集群。反过来讲,一起写入的对象也倾向一起读取,所以用到的集群大小正好能覆盖后续多个抓取,结构里这个规律最值得利用

第三是线性化。线性化文件会把第一页和 hint table 放在前面,方便边下载边展示。但大文件通常不是线性化的,增量更新和合并导致布局被打乱后这一优势消失。你要假设的是最坏场景:远程跳转、无序、尾部优先读取

32 位场景的正确叙述

32 位 Windows 进程只有 2 GB 用户态地址空间,MapViewOfFile 若不带长度参数会一次性申请与文件等量的连续地址段。对于 2 GB 文件,这种保留通常不可能成功:EXE、DLL 和线程栈已占掉空间后,常见可用连续块只有 700 到 1400 MB。这时调用会因 ERROR_NOT_ENOUGH_MEMORY 失败,这和 TMemoryStream.LoadFromFile 的报错是一回事,只是从“提交物理内存”变成了“地址空间保留”失败,完整文件映射只会把问题换个壳

正确修复方法是拆解映射的职责。CreateFileMapping 只负责创建 section 对象,和文件大小无关,不消耗地址空间。只有 MapViewOfFile 要消耗地址空间,而且并不要求一次映射全部内容,它接收 64 位偏移和视图长度。创建 section 后只映射 64 到 256 MB 的窗口,按窗口边界滑动并在滑动前释放旧窗口,地址空间成本是一个窗口而不是一个文件,只有一处约束:窗口偏移必须按 SYSTEM_INFO.dwAllocationGranularity 对齐,实际是 64 KB,像 1,000,000 这样的偏移会下调到 983,040 并让返回指针前移对应距离

在 Delphi 中实现滑动窗口映射器

下面的类覆盖了整套流程:一个 section 对象、一个活动视图、粒度对齐,以及窗口边界跨越时通过扩展当前视图来完成,不需要拼接两段视图

uses
  Winapi.Windows, System.SysUtils;

type
  TWindowedFileMapper = class
  private
    FFile: THandle;
    FMapping: THandle;
    FFileSize: Int64;
    FGranularity: DWORD;      // SYSTEM_INFO.dwAllocationGranularity
    FWindowSize: NativeUInt;  // default view size
    FViewBase: PByte;         // base of the current view (aligned)
    FViewOffset: Int64;       // file offset FViewBase corresponds to
    FViewSize: NativeUInt;    // bytes mapped in the current view
    procedure Unmap;
  public
    constructor Create(const FileName: string;
      WindowSize: NativeUInt = 64 * 1024 * 1024);
    destructor Destroy; override;
    function Map(Offset: Int64; Size: NativeUInt): PByte;
    procedure ReadBytes(Offset: Int64; var Buffer; Count: NativeUInt);
    property FileSize: Int64 read FFileSize;
  end;

constructor TWindowedFileMapper.Create(const FileName: string;
  WindowSize: NativeUInt);
var
  Info: TSystemInfo;
begin
  inherited Create;
  FFile := CreateFile(PChar(FileName), GENERIC_READ, FILE_SHARE_READ, nil,
    OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
  if FFile = INVALID_HANDLE_VALUE then
    RaiseLastOSError;
  if not GetFileSizeEx(FFile, FFileSize) then
    RaiseLastOSError;
  // The section object reserves no address space, whatever the file size
  FMapping := CreateFileMapping(FFile, nil, PAGE_READONLY, 0, 0, nil);
  if FMapping = 0 then
    RaiseLastOSError;
  GetSystemInfo(Info);
  FGranularity := Info.dwAllocationGranularity;  // 64 KB in practice
  FWindowSize := WindowSize;
end;

destructor TWindowedFileMapper.Destroy;
begin
  Unmap;
  if FMapping <> 0 then CloseHandle(FMapping);
  if FFile <> INVALID_HANDLE_VALUE then CloseHandle(FFile);
  inherited;
end;

procedure TWindowedFileMapper.Unmap;
begin
  if FViewBase <> nil then
  begin
    UnmapViewOfFile(FViewBase);
    FViewBase := nil;
    FViewSize := 0;
  end;
end;

function TWindowedFileMapper.Map(Offset: Int64; Size: NativeUInt): PByte;
var
  AlignedOffset: Int64;
  Delta, MapSize: NativeUInt;
begin
  if (Offset < 0) or (Offset + Int64(Size) > FFileSize) then
    raise ERangeError.CreateFmt(
      'Map request at %d for %d bytes is outside the file',
      [Offset, Int64(Size)]);

  // Fast path: the requested range already sits inside the live view
  if (FViewBase <> nil) and (Offset >= FViewOffset) and
     (Offset + Int64(Size) <= FViewOffset + Int64(FViewSize)) then
    Exit(FViewBase + NativeInt(Offset - FViewOffset));

  Unmap;  // slide: never hold two views at once

  // Views must start on an allocation-granularity boundary
  AlignedOffset := Offset - (Offset mod FGranularity);
  Delta := NativeUInt(Offset - AlignedOffset);

  MapSize := FWindowSize;
  if MapSize < Size + Delta then   // request straddles the window end:
    MapSize := Size + Delta;       // grow this one view to cover it
  if AlignedOffset + Int64(MapSize) > FFileSize then
    MapSize := NativeUInt(FFileSize - AlignedOffset);  // clamp at EOF

  FViewBase := MapViewOfFile(FMapping, FILE_MAP_READ,
    DWORD(AlignedOffset shr 32), DWORD(AlignedOffset and $FFFFFFFF),
    MapSize);
  if FViewBase = nil then
    RaiseLastOSError;

  FViewOffset := AlignedOffset;
  FViewSize := MapSize;
  Result := FViewBase + NativeInt(Delta);
end;

procedure TWindowedFileMapper.ReadBytes(Offset: Int64; var Buffer;
  Count: NativeUInt);
begin
  Move(Map(Offset, Count)^, Buffer, Count);
end;

关键的性能收益来自于两个细节:第一,Map 的快路径会在请求范围落在活动窗口时直接返回指针,无需再次切换内核,这在对象流聚类场景是常态;第二,跨越窗口尾部的请求会把本次 MapSize 扩展以覆盖该范围,而不需要拼两段视图,这让 ReadBytes 保持一行实现而调用者不必写处理半截读取的循环

窗口大小是友好的调优参数。64 MB 时完整扫描 1.8 GB 需要 29 次映射,256 MB 时只要 8 次但在 32 位碎片化地址空间里更难放下,窗口小于约 16 MB 会因为频繁 remap 而明显变慢。64 到 256 MB 区间内,映射开销通常只是统计噪音

系统调用计数

现在做一次算术。测试文件是 1.8 GB、30 万个间接对象,平均每对象载荷约 600 字节。若按对象逐一读取,就会执行 600,000 次内核交互,每次 SetFilePointerExReadFile 4 KB,按 1.5 μs 的最优内核往返预算大约是 0.9 秒,在解析 1 字节前就白耗掉这部分性能

读取的内容也会放大。300,000 次 × 4 KB 会把 1.2 GB 进用户缓冲区才得到约 180 MB 的有效载荷,相当于 6 倍放大,每一字节都经历 kernel 到 user 的拷贝

使用 256 KB 的预读缓冲覆盖对象流集群是第一步优化:每个集群读一次 256 KB,代替每对象一次读,可以把切换次数降低一个到两个数量级,在映射麻烦时也很有价值

滑动映射进一步压缩。完整扫描只需 29 次 MapViewOfFile 和 29 次 UnmapViewOfFile,共 58 次显式切换,对比 600,000 次的逐对象读取;真实的 xref 驱动解析会更乱,但在常见窗口内可复用快速路径,实际 remap 次数通常只是几百次。映射并未消除内核工作,只是把显式系统调用转给内存管理器,以文件缓存为基础做多页簇处理,不访问的区域成本为 0。实测 end-to-end 中,索引遍历从“23 秒冷缓存、7.1 秒热缓存”降到“6.5 秒冷、1.9 秒热”,剩下的瓶颈变成 zlib 解压而不是 IO

何时需要 FILE_FLAG_NO_BUFFERING

FILE_FLAG_NO_BUFFERING 会绕过系统缓存,但要求偏移、长度和缓冲区地址严格对齐。它更适合单次顺序任务,例如把档案整体重写到新输出,不需要反复回访同一块数据的场景,配合 4 到 8 MB 对齐缓冲可接近设备顺序带宽且不污染缓存

而在解析流程中则通常不适合。随机跳转读 unbuffered handle 会让每个 300 字节字典都变成一次完整物理读取,没有缓存吸收第二次命中,而 PDF 解析经常反复触达同一对象流。一个可行策略是顺序重写时用 unbuffered IO,随机解析时仍用映射或缓存 IO;因为标志是按 handle 生效,所以同一文件可以在不同句柄上混合策略

64 位、工作集与写入侧

在 64 位构建里,地址空间不再是短板:若把窗口设为文件大小,类会退化成一次完整映射。长期服务里风险在于工作集膨胀,读取文件页只要被访问过就会加入工作集。完整映射会让解析接近 1.8 GB 时把工作集推高到同量级并挤掉其他内存,滑动窗口才是更稳妥的默认方案

在写入侧,最省 IO 的依然是不要发起写入。PDF 的增量更新机制(ISO 32000-1 §7.5.6)会把变更对象和新交叉引用区追加到文件尾,原始字节位置保持不动,因此在 1.8 GB 文件上追加一页通常只增加几万字节,而全量重写会重新输出全部 1.8 GB,差距是五个数量级以上

losLab 库在处理中的角色

两款 losLab PDF 库都把这套策略包装成可直接调用的 API。HotPDF Direct File API 使用文件句柄读取页数和结构,避免先构建对象树,能在文件级别完成拷贝和解密,并通过 BeginIncrementalUpdate 进行增量写入。另一个 PDFlibPas 的 Direct Access 同样在文件层遍历 xref、惰性抓取对象、按需抽取页范围并写入增量修订;如果你自己做解析器,窗口映射类直接可用;如果你维护完整文档流水线,就让库去保证窗口策略

注意:面向超大文档的优化 IO 处理已直接内建于 Delphi 和 C++Builder 版 HotPDF VCL Component

`r`n