HotXLS 作为原生的 Delphi 和 C++Builder Excel 库,通过三阶段加载在多个线程上解析 XLSX 工作表:工作表 XML 被串行解压,然后并行解析,最后串行读取其余的小部件。该功能的第一个版本仅获得了 12–25% 的性能提升,因为 Delphi 默认的内存管理器锁使工作线程串行化了。将每个单元格的堆分配从大约 20 次削减到 9.1 次,使 8 个线程上的并行加速比提高到了 1.90 倍。本文将介绍测量过程、走过的弯路以及实际起作用的两个修复方案
HotXLS 是如何并行解析 XLSX 工作表的?
HotXLS 将 Open 拆分为三个阶段,并且只有中间阶段在工作线程上运行。原因在于 zip 容器:zip 归档文件是一个带有一个解压(inflate)状态机的共享输入流,并且该状态机无法由两个线程同时读取。将其包裹在锁中毫无意义,因为每个条目的解压本质上是串行的,因此锁只会以额外的开销重现串行执行。因此,阶段 A 在保持单线程的同时,将每个工作表的 XML 解压到其自身的 TMemoryStream 中;在我们的基准测试文件中,解压 8 个工作表部件大约需要 4 毫秒,所以这绝不是瓶颈所在。阶段 B 在工作线程池上为每个工作表运行 ParseWorksheetXml,这几乎占据了所有的加载时间。阶段 C 则以串行方式返回到 zip 以读取小部件:批注、绘图、图表和表格
工作线程池本身特意设计得很简单。工作线程通过 InterlockedIncrement 从共享计数器中拉取任务索引,因此即使工作表大小不均,也会在没有任何调度程序的情况下自然达到平衡。线程数是 min(sheet count, CPU cores)(工作表数量与 CPU 核心数的较小值),捕获第一个工作线程异常对象并使用 AcquireExceptionObject 在 join 后在主线程上重新抛出,当只有 0 个或 1 个任务时,分发器会退化为普通的串行循环。TXLSXWorkbook 上的两个属性控制该功能:ParallelParse 开启线程池,ParallelParseThreads 限制线程数,0 表示自动。多工作表的工作簿是受益的形态,包括您通过复制几十次模板工作表产生的那类工作簿
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
Book.ParallelParse := True; // 启用并行工作线程池
Book.ParallelParseThreads := 0; // 0 = 自动: min(sheets, CPU cores)
if Book.Open('quarterly-ledger.xlsx') <= 0 then
raise Exception.Create('open failed');
// ... 照常读取单元格;工作簿已完全实例化 ...
finally
Book.Free;
end;
end;
为什么添加线程会导致 Delphi 中的 XLSX 解析变慢?
因为 Delphi 的默认内存管理器使用全局锁来保护其堆,而工作表解析是内存分配密集型的:数以百万计的单元格、Variant 和 WideString。每一个接触堆的工作线程都会在该锁上排队,因此源代码中看起来独立的线程在实践中几乎只能一次执行一个。我们的第一个基准测试痛苦而具体地证明了这一点。在一个拥有 8 个工作表且每个工作表包含 5,000 行乘 4 列的工作簿上,在 Win64 下使用 i5-11600K(6 核心,12 线程)测量,并行 Open 仅比计划估计的至少 40% 提高了 12–25%。在 2、3、4、6 和 8 线程上进行的线程数扫描产生了一条平坦的曲线,并且在后来的仪表化运行中,2 线程配置实际上比串行慢 26%,这是两个线程在竞争锁上乒乓交替的典型特征
三次测量确定了诊断结果,每一次都颠覆了先前的直觉。第一,一个微型文件(8 张工作表各包含 1 行)在 1.2 毫秒内打开,证明解析基本上占了 Open 的 100%,没有隐藏的固定成本可归咎。第二,纯内存分配流失的微基准测试表明 Delphi 内存管理器的扩展性能呈反向发展:在 8 个线程上运行总量 200 万个对象 and 和 AnsiString 内存分配的耗时比在单线程上慢 60%,而针对 WideString 堆(这是 COM BSTR 分配器,而不是 Delphi 内存管理器)的相同流失规模则扩展到了 3.7 倍。HotXLS 全程使用 WideString 最终被证明是一个对我们有利的历史偶然。第三,GetProcessTimes 表明,在并行 Open 期间,CPU 时间大约等于挂钟时间:八个名义上的线程消耗了大约 1.3 个线程的 CPU。工作线程并没有自旋;它们是在内存管理器的竞争路径中入睡,阻塞着而不是在忙碌
这一实践教训具有普遍意义,并不局限于电子表格。如果 Delphi 工作负载进行了密集的分配,在分配率下降之前增加线程数是毫无作用的,并且很容易使事情变得更糟。在进行此修复之前,我们告诉调整 ParallelParseThreads 的用户一个老实的事实:在受分配限制的文件上,更多线程几乎买不来任何提升
每个单元格 20 次的堆分配是从哪里来的?
使用 SetMemoryManager 安装的一个计数包装器精确地回答了这个问题:每个单元格大约有 20 次 Delphi 内存管理器分配,其中 287 万次在 32 字节或以下。元凶根本不是单元格对象。TXMLScaner.GetTokenValue 在每次调用时都具体化一个全新的 AnsiString,而它在每个单元格上大约被调用 15–20 次:分别针对元素名称、属性名称、属性值和文本内容各调用一次。最重要的是,RTL 的 UTF8ToWideString 路由在每次转换时都会制造一个临时的 UnicodeString 中间变量。单元格对象仅占 16 万次分配,约占总数的 8%,这直接击碎了我们的初始计划:我们本打算构建一个单元格对象池,而数据表明这永远不会收回成本
var
OldMM, NewMM: TMemoryManagerEx;
AllocCount, TinyCount: Int64;
function CountingGetMem(Size: NativeInt): Pointer;
begin
AtomicIncrement(AllocCount);
if Size <= 32 then
AtomicIncrement(TinyCount); // 我们关心的微小对象流失
Result := OldMM.GetMem(Size);
end;
// 在 Open 之前安装,在之后恢复
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);
修复:标记池化与零中间变量的 UTF-8 解码器
XML 读取器中两项有针对性的更改在不触及解析器结构的情况下消除了超过一半的每单元格分配。首先是元素名称的池化(interning)。工作表 XML 无休止地重复着一个微小的词汇表:row、c、v、r、t、s 和少量属性名称。InternTokenName 保存了一个包含 64 个槽位的先前见过名称的缓存,并使用 TokenEqualsAnsi(不分配任何内容的直接字节比对)比对扫描器的生成缓冲区与缓存项。如果命中,它返回缓存的 AnsiString,在这里,类型的选择很重要:AnsiString 是带有引用计数的,因此返回一个缓存实例只需要花费一次引用计数增加的成本,而产生零堆流量。WideString 没有引用计数,并且每次赋值都会通过 SysAllocString,因此池化 WideString 节省不了任何东西。池化仅在带有引用计数的字符串类型上值得做
function TXMLScaner.InternTokenName: AnsiString;
var
Slot: Integer;
begin
Slot := TokenHash mod 64;
if TokenEqualsAnsi(FInternNames[Slot]) then
Result := FInternNames[Slot] // 仅 refcount++,没有分配
else
begin
Result := GetTokenValue; // 实例化一次,然后缓存
FInternNames[Slot] := Result;
end;
end;
第二项修改针对单元格文本。旧的路径构建一个 AnsiString 标记,将其传递给 UTF8ToWideString,后者构建一个 UnicodeString 中间变量,最终将其转换为单元格存储的 WideString:在真正的分配之前,每个文本标记有两次 Delphi 内存管理器分配。替代的 XmlUtf8ToWide(TokenPtr, TokenLen) 是一个双阶段纯 Pascal UTF-8 解码器,它直接从扫描缓冲区读取:第一阶段测量 UTF-16 长度,第二阶段解码为分配一次的 WideString。每个文本标记的净开销为:一次 COM 分配,零次 Delphi 内存管理器分配。针对谨慎开发者的一点语义注意:在畸形的 UTF-8 序列上,新的解码器会直接传递字节,而不是像 RTL 那样替换为替代字符,这仅影响损坏文件的退化方式;在有效输入上,输出在字节上完全一致。XML 字符实体永远不会到达解码器,因为扫描器已经事先在标记缓冲区中将它们解析为了 UTF-8
这带来了什么提升,以及并行解析在何处仍然帮不上忙
这两项修复将每个单元格的分配从大约 20 次削减到 9.1 次,并行数据便按照理论预测的方向发展。在相同的 8 工作表、5,000 行的基准测试和相同的 6C12T 机器上,8 线程的提升从 14% 变为 47.4%,相对于串行实现了 1.90 倍的加速。2 线程的情况从慢 26% 变为快 23.6%,且测得的 CPU 利用率从 1.0 倍提高到 2.2 倍。作为额外奖励,串行路径也快了大约 3%,因为更少的分配对单线程也有帮助。每个单元格剩余的约 9 次分配大约一半是单元格对象本身,另一半是分摊的容器增长;我们测量了它们,断定回报在递减并停下脚步,如果未来的工作负载需要新一轮优化,内存管理器包装器已准备好按调用站点重新采样
这些边界界限值得与取得的成绩一样坦白说明。HotXLS 在工作表粒度上进行并行化,因此无论 ParallelParseThreads 怎么说,包含一个超级工作表的工作簿都在单线程上解析;对于这种形态,流式直接读取器是更好的工具,因为它完全避免了实例化工作簿。由于设计上阶段 C(绘图、图表和批注)保持为串行,时间主要花在阶段 C 的文件在此受到的益处较少。小文件根本不值得进行线程化,这也是为什么分发器在琐碎的任务计数上会悄悄运行串行。并且内存管理器的天花板并没有消失,只是退后了:在每个单元格 9.1 次分配下,全局锁仍然对工作线程施加压力,这也是为什么 8 个线程产生了 1.90 倍而不是 4 倍的加速比。对于缩短加载和保存时间的更广泛工具集,包括样式、池以及批量行回调,请参阅我们的Delphi 中大型工作簿性能指南
此处介绍的并行 XLSX 解析、ParallelParse 和 ParallelParseThreads 属性以及分配精简型 XML 读取器,作为 HotXLS Delphi Excel Component 的标准部分发售,其不涉及任何 Excel 自动化而原生在 Delphi 和 C++Builder 中读写 XLS、XLSX 和 ODS