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 次内核交互,每次 SetFilePointerEx 配 ReadFile 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 中