Technical note
OCCT Splitter 性能分析:为什么 PaveFiller 才是真正的瓶颈
一次从 OCCT Splitter 调用链出发的性能排查记录,重点分析 PaveFiller、Arguments/Tools、候选拓扑 pair 与外部 task scheduler 之间的关系。
背景:为什么重新看 Splitter
这篇文章来自一次 CAD/CAE 模型聚合流程的性能排查。
当时的问题是:为了保证后续网格能够保持共形,我们不能简单地把真实接触的 shape 强行按空间切散;但如果把一个大的连通 group 整体丢给 BRepAlgoAPI_Splitter,计算时间又会被迅速放大。
一开始我关注的是怎么把 group 拆成更多 task。后来重新看了一遍 Splitter 的调用链,才意识到真正需要控制的不是 task 数量本身,而是每一次 BOPAlgo_PaveFiller 能看到多少参与相交计算的输入对象。
换句话说,问题不只是“拆不拆 group”,而是:
每一次 PaveFiller 的输入集合里,会产生多少 VV / VE / EE / VF / EF / FF 候选 pair?
以下分析基于我当时阅读到的 OCCT 源码调用链,不同版本实现细节可能会有差异。这里记录的是一次工程排查中的判断过程,不是对所有场景都成立的绝对结论。
结论先行
这次分析后,我对 Splitter 性能问题形成了几个判断:
Splitter的主要压力通常不在最后组装结果,而在前面的BOPAlgo_PaveFiller交集填充阶段。Arguments和Tools都会参与交集计算,所以Tools不输出结果,并不代表它们没有计算成本。- 巨大的 halo 方案看似保留了邻接关系,但如果 halo 太大,本质上仍然是在给 PaveFiller 喂一个大输入集合。
- 对大连通 group,更合理的方向不是盲目空间强拆,而是尽量让每个 task 只包含当前对象和直接邻居。
SetUseOBB(true)值得保留,但它只能改善候选筛选,不能替代上层 task scheduler。
这几个判断后来影响了我对外部优化方案的选择:与其继续围绕“怎么拆 group”打转,不如把问题收敛到“怎么控制每次 PaveFiller 的输入规模”。
Splitter 的调用链:Build 里先做的是 IntersectShapes
当时外部调用大概是这样的:
BRepAlgoAPI_Splitter localSplitter;
localSplitter.SetArguments(arguments);
localSplitter.SetTools(tools);
localSplitter.Build(...);
从调用链看,BRepAlgoAPI_Splitter::Build() 内部大致可以分成两段:
BRepAlgoAPI_Splitter::Build
↓
IntersectShapes(arguments + tools)
↓
BOPAlgo_Splitter / BOPAlgo_Builder 构建结果
↓
Fill History
↓
返回 result shape
在 BRepAlgoAPI_BuilderAlgo::Build() 里,也能看到类似的阶段划分:先做 IntersectShapes(...),再创建 Builder 并构建结果。
这里有一个很容易被忽略的细节:对于 Splitter 来说,Arguments 和 Tools 会先合并到一个列表里参与交集计算。也就是说,Tool 虽然不作为最终输出对象,但它仍然会参与前面的 PaveFiller 阶段。
这个细节对外部调度策略非常重要。
如果一个 task 是:
Objects = 1 个
Tools = 500 个
那么从交集阶段看,它并不是一个很小的任务。因为 PaveFiller 看到的输入仍然接近:
PaveFiller 输入 = 501 个 shape
所以,Objects / Tools 的价值不在于“Tools 不参与计算”,而在于它可以帮助我们控制输出归属、history 归属,以及更清晰地表达“谁被谁压印”。
PaveFiller 做的不是简单相交判断
IntersectShapes() 里真正创建的是:
BOPAlgo_PaveFiller
然后把相关参数设置进去:
myDSFiller->SetArguments(theArgs);
myDSFiller->SetRunParallel(myRunParallel);
myDSFiller->SetFuzzyValue(myFuzzyValue);
myDSFiller->SetNonDestructive(myNonDestructive);
myDSFiller->SetGlue(myGlue);
myDSFiller->SetUseOBB(myUseOBB);
myDSFiller->Perform(theRange);
也就是说,我们外部设置的很多参数,真正影响的是 PaveFiller 阶段,例如:
SetRunParallel
SetFuzzyValue
SetNonDestructive
SetGlue
SetUseOBB
BOPAlgo_PaveFiller::PerformInternal() 做的事情并不是简单判断两个 shape 是否相交,而是按拓扑维度逐层建立交点、切边、切面、pcurve 和内部数据结构。
它大致会经历这些阶段:
Init
Prepare
PerformVV Vertex / Vertex
PerformVE Vertex / Edge
PerformEE Edge / Edge
PerformVF Vertex / Face
PerformEF Edge / Face
RepeatIntersection
ForceInterfEE
ForceInterfEF
PerformFF Face / Face
MakeSplitEdges
MakeBlocks
MakePCurves
ProcessDE
这些阶段背后对应的是不同维度的拓扑相交关系。
例如:
VV / VE / EE:
建立点、边之间的交点和 pave block
VF / EF:
建立点/边落到面上的关系,生成边与面的切分信息
FF:
面面求交,生成 section curve / section edge
MakeSplitEdges:
根据交点切分边
MakeBlocks:
根据面面交线和切分边组织面块
MakePCurves:
为新边在面参数域上建立 pcurve
ProcessDE:
处理退化边、特殊边界数据
对于 CAD/CAE 共形网格预处理来说,最关键的往往是:
EF + FF + MakeSplitEdges + MakeBlocks + MakePCurves
因为这些阶段决定接触界面是否真正被压印、切边、切面。也正因为它们会生成新的边、面块和参数曲线,所以输入规模一旦放大,后面的复杂度也会被一起放大。
真正放大耗时的是 EF / FF 候选 pair
继续往里看,PaveFiller 会先统计不同拓扑维度的候选 pair 数量,例如:
VVSize
VESize
EESize
VFSize
EFSize
FFSize
然后根据这些规模估算不同阶段的权重。
这里最值得关注的是:
PerformEF
PerformFF
MakeBlocks
MakePCurves
尤其是 Face / Face 和 Edge / Face。
在复杂曲面模型或者高密度结构模型里,Face 数量和 Edge-Face / Face-Face 候选 pair 很容易膨胀。它们带来的影响不只是某一步相交判断变慢,还会继续放大后续的 section edge、pave block、pcurve 和 face image 构建。
所以 Splitter 的主要卡点通常不是“最后输出了多少 shape”,而是:
1. Face / Face 候选 pair 太多
2. Edge / Face 候选 pair 太多
3. Face / Face 之后产生大量 section edge / pave block / pcurve
4. Builder 阶段的 FillImagesFaces / FillImagesSolids / PrepareHistory 被放大
交集完成后,BOPAlgo_Builder 主要基于 PaveFiller 已经产生的数据结构构建结果。它不是重新求交,而是沿着 Vertex、Edge、Wire、Face、Shell、Solid、Compound 等层级填充 image、构建结果并准备 history。
因此,后面的 Builder 阶段当然也可能慢,尤其是产生大量碎面、碎边以后。但从这次排查看,更应该优先控制的是 PaveFiller 阶段看到的输入集合。
Arguments / Tools 的一个容易误解的点
这次阅读源码以后,我觉得最关键的点是:
Arguments + Tools 都会参与 PaveFiller 的交集阶段。
所以,如果外部 task 设计成:
Objects = 1 个
Tools = 数百个
它在输出结果上看起来是一个很小的 task,但在交集阶段并不小。
这解释了为什么我之前对 halo 方案的预期会偏乐观。
所谓 halo,可以理解成:为了保证局部对象和周围邻接对象之间的接触关系不丢失,给核心对象额外带上一圈邻居对象。这个思路本身没有问题,但如果 halo 太大,就会变成另一个大输入集合。
也就是说,halo 方案的风险在于:
输出只关心 core object,
但 PaveFiller 仍然要处理 core + halo 的完整输入。
如果这个输入集合很大,那么它并没有真正降低 PaveFiller 的负担,只是换了一种方式把大 group 喂了进去。
所以 Objects / Tools 的收益不是来自“Tools 不计算”,而是来自:
Tools 不进入结果
Tools 不写入该 task 的 history 归属
Tools 可以限制为直接作用对象
前提仍然是:
每个 task 的 Objects + Tools 总输入数量必须足够小。
为什么巨大 halo 方案会变慢
假设有一个严格连通 group,里面有数百个 shape。所谓严格连通,是指这些 shape 之间通过真实接触关系连成了一个整体。为了保证共形,我们不能随意把它们切散,否则跨组接触界面可能不会在同一个 Splitter 里被正确处理。
但反过来,如果把整个 group 一次性丢进 Splitter,PaveFiller 又会面对一个很大的输入集合。
这时候最容易想到的方案是:每个 task 只处理一个核心对象,但给它带上一圈 halo 邻居。问题在于,如果 halo 不是直接邻居,而是包含了太多传递连通链上的对象,PaveFiller 仍然会看到很多不必要的输入。
比如:
A 接触 B
B 接触 C
C 接触 D
当处理 A 的时候,A 确实需要 B,因为 B 会影响 A 的压印。但 A 通常不应该继续带上 C、D。否则就从“直接接触关系”退化成了“传递连通 group 的局部复制”。
这也是我后来改变判断的地方:外部优化不应该只是把大 group 换成很多带巨大 halo 的小 task,而应该明确区分:
直接邻居
传递邻居
前者影响当前对象的压印,后者不一定应该进入当前 PaveFiller。
更稳妥的方向:控制每个 task 的输入集合
基于这个判断,一个更直接的策略是 per-object direct tools:
for each shape i:
Objects = { i }
Tools = directNeighbors(i)
也就是每个 task 只输出当前对象,同时只把它的直接接触邻居作为 Tools。
这样做的好处是比较清楚的:
A 与 B 接触:
A task 里 B 是 Tool,A 被 B 压印
B task 里 A 是 Tool,B 被 A 压印
同时它避免了:
A 接触 B,B 接触 C,C 接触 D
=> A 不需要带 C、D
这个策略真正想控制的是:
inputSize = objectCount + toolCount
也就是每一次 PaveFiller 看到的输入规模。
当然,这里不能简单地认为 per-object 一定更快。如果某个对象本身有大量直接邻居,例如一个大基板接触了数百个贴片,那么:
Objects = 1
Tools = 数百个
仍然会变成一个很大的 PaveFiller 输入。
所以 task scheduler 必须有硬阈值。比如可以先采用比较保守的规则:
inputSize <= 64:
优先使用 per-object task
64 < inputSize <= 160:
可以继续使用 per-object task,但要控制外层并行,避免过度抢资源
inputSize > 160:
不盲目生成 per-object task,进入特殊处理或保守回退
这里的具体阈值需要后续结合模型继续验证,不应该写死成“最佳值”。真正重要的是引入这个判断维度:不能只看 group size,而要看每个 task 实际喂给 PaveFiller 的 inputSize。
大 carrier 是另一个问题
还有一类对象需要单独处理,我暂时把它叫做 carrier object。比如:
大基板
大机身
大壳体
这类对象的特点是:它本身可能是一个大的承载对象,周围接触了很多小对象。
例如:
基板接触 300 个贴片
如果基板作为 Object,那么 task 可能会变成:
Objects = 基板
Tools = 300 个贴片
这对 PaveFiller 来说仍然是大输入。
但如果把基板强行按空间块拆开,又可能破坏同一个 shape 内部的拓扑一致性、history 归属和后续结果合并。这个问题和普通 halo 不一样,不应该用同一种策略粗暴处理。
更合理的方向可能是:
小贴片各自作为 Object,被基板作为 Tool 压印;
大基板作为 Object 时,再按接触区域做更细的 face-level 或 bbox-level 局部分区。
不过这已经不是第一版 task scheduler 能稳妥解决的问题。它涉及同一个 carrier 的局部结果合并、history 合并,以及跨区域边界一致性。对这种问题,我更倾向于后续单独验证,而不是一开始就把它混进基础调度逻辑里。
SetUseOBB 值得保留,但不是万能的
SetUseOBB(true) 仍然是值得保留的。
从调用链看,它会影响 PaveFiller 内部候选 pair 的空间筛选阶段。相比普通包围盒,OBB 对一些倾斜或细长对象的误判可能更少,因此在复杂模型里通常是有意义的。
但它解决不了所有问题。
如果一个 task 本身输入太大,或者真实接触关系确实非常复杂,那么 OBB 只能减少一部分空间筛选误判,不能从根本上改变任务规模。
也就是说:
SetUseOBB(true) 是底层筛选优化;
task scheduler 是上层输入规模控制。
这两个方向不冲突,但不能互相替代。
为什么暂时不改 OCCT 内部
这次排查后,我不倾向于直接修改 OCCT 内部。
原因不是 OCCT 内部没有优化空间,而是 PaveFiller 本身牵涉的状态太多,包括:
BOPDS_DS
Iterator
Images
History
容差
拓扑一致性
退化边和特殊边界数据
如果直接改内部实现,很容易引入更难定位的问题。尤其在 CAD/CAE 预处理场景里,正确性通常比局部性能更敏感。一旦 history、压印关系或拓扑一致性被破坏,后续网格、边界条件、选择映射都会被影响。
相比之下,更稳妥的做法是从外部控制输入集合:
buildShapeGroups:
保证真实接触关系形成的共形连通组件
buildSplitterTasks:
控制每个 PaveFiller 的 Objects + Tools 输入规模
也就是说,大 group 仍然用于表达“这些对象之间存在共形约束”,但真正执行 Splitter 时,不一定把整个 group 一次性喂给 PaveFiller。
一个保守的落地方案
如果要做第一版,我会优先选择比较保守的 task scheduler。
对于小 group:
如果 group.size() <= smallGroupThreshold:
task.Objects = group
task.Tools = empty
对于大 group:
对 group 内每个 shape:
task.Objects = { shape }
task.Tools = directNeighbors(shape)
同时增加一个安全阈值:
inputSize = objectCount + toolCount
当 inputSize 超过阈值时,不急着强行拆,而是先保守回退或进入特殊策略。这样做虽然不一定最快,但更容易保证正确性,也更容易定位问题。
初始可以考虑:
smallGroupThreshold = 96
maxTaskInputSize = 160
这两个值不是结论,只是一个便于验证的起点。后续需要结合真实模型的耗时、失败率、共形结果和碎面数量继续调整。
我会避免一开始就做过度复杂的策略,比如:
大规模 shape-level halo
直接改 OCCT 内部
对 carrier 做跨区域结果合并
过早引入大量日志和特殊分支
因为这些方案很容易让问题变得不可控。当前更重要的是先验证一个核心判断:
减少单次 PaveFiller 的输入规模,是否能在不破坏共形的前提下降低总耗时?
小结
这次读 Splitter 调用链后,我最大的收获是:性能问题不应该只从“拆成多少个 task”来理解,而应该看每个 task 最终会给 BOPAlgo_PaveFiller 带来多少拓扑相交候选。
如果一个方案只是把大 group 换成带巨大 halo 的局部 task,但 Objects + Tools 的总输入仍然很大,那么它并没有真正降低 PaveFiller 的负担。
因此,后续优化更合理的方向是:
保留真实接触关系,避免破坏共形;
每个 task 尽量只包含当前对象和直接邻居;
对 inputSize 设置硬阈值;
对大基板、大壳体这类 carrier object 单独处理;
暂时不轻易修改 OCCT 内部实现。
这不是一个一步到位的方案,但它让我从“怎么拆 group”转向了一个更明确的问题:
怎么控制每一次 PaveFiller 看到的输入集合?
对这类几何计算性能问题来说,这个转变比单纯多加几个并行 task 更重要。