Technical note
从全局 Splitter 到局部调度:一次 OCCT 共形聚合性能优化实践
记录一次 CAD/CAE 模型聚合性能优化过程:从强制分组、halo 方案、direct tools 到局部 batch scheduler,在实验模型中将 OCCT Splitter 聚合耗时降低到约三分之一。
背景:全局 Splitter 的正确性和性能冲突
这篇文章接着上一篇关于 OCCT Splitter / PaveFiller 的分析继续写。
上一篇里我主要确认了一件事:BRepAlgoAPI_Splitter 慢的时候,真正需要关注的往往不是最后结果怎么组装,而是前面的 BOPAlgo_PaveFiller 到底看到了多少输入对象,以及这些对象之间会产生多少拓扑相交候选。
这次问题更具体:在一个 CAD/CAE 前处理流程里,进入网格剖分之前,需要先对一批几何对象做压印和聚合。原因很直接,如果两个 shape 在几何上接触,后续面网格需要在接触界面保持共形。接触界面不能只是空间上“贴着”,而应该在拓扑上被正确切分、压印和映射。
最直接的做法,是把所有存在接触关系的 shape 放进同一个 BRepAlgoAPI_Splitter:
BRepAlgoAPI_Splitter splitter;
splitter.SetArguments(arguments);
splitter.SetTools(tools);
splitter.Build();
这样做在正确性上比较稳。只要真实接触关系都在同一个 Splitter 输入里,跨对象压印、切边、切面和 history 关系都比较容易保持一致。
但在大模型上,这个方案很快会遇到性能问题。
这次实验里,一个飞机类大模型中有一个严格连通 group,包含 2560 个 shape。如果把它们整体丢进一个 Splitter,计算时间会变得不可接受。
矛盾就在这里:
为了共形,不能随便把真实接触链切散;
为了性能,又不能把整个大连通 group 一次性喂给 Splitter。
这篇文章记录的是这个问题从几次错误尝试,到最后形成一个现行可用版本的过程。
错误尝试一:强制空间分组会破坏接触链
一开始最容易想到的是空间分组。
比如把一个大 group 按 bbox 或空间块拆成多个小组:
group 2560
↓
space group 1
space group 2
space group 3
...
这样每个 Splitter 的输入数量会下降,单次计算难度也会下降。实际测试中,这个方向确实能让流程变快。
但它的问题也很明显:如果这个 group 是由真实接触关系连起来的,强制按空间拆成多个独立 Splitter,就可能让跨组接触界面没有被同一个 Splitter 或等价的压印关系处理。
例如:
A 接触 B
B 接触 C
C 接触 D
如果空间分组把 B 和 C 拆到了不同组,而跨组接触没有额外处理,那么 B-C 这条接触关系就可能丢失。后续网格看起来仍然能剖分,但接触界面不一定共形。
所以空间强拆虽然能提升速度,但我后来不再把它作为默认主路径。
对 CAD/CAE 网格预处理来说,性能优化不能以牺牲共形正确性为代价。几何预处理阶段少压印了一条接触边,后面可能会在网格、边界条件、材料继承或结果映射里变成更难排查的问题。
错误尝试二:halo 不一定降低 PaveFiller 成本
后来我尝试过类似 core + halo 的思路。
所谓 halo,可以理解成:每个局部 group 除了自己的核心对象,还额外带上一圈邻居对象,避免跨组接触关系丢失。
这个思路听起来合理,但真正放到 OCCT Splitter 里以后,会遇到一个关键问题:
Tools 不输出结果,不代表 Tools 没有计算成本。
BRepAlgoAPI_Splitter 内部的交集阶段会把 Arguments 和 Tools 一起送进 BOPAlgo_PaveFiller。也就是说,一个 task 如果是:
Objects = 32 个
Tools = 500 个
从输出归属看,它可能只关心 32 个对象。但从 PaveFiller 的角度看,它看到的是:
输入 = 532 个 shape
这并不是一个小任务。
所以 halo 方案的风险在于:如果 halo 太大,它本质上仍然是在给 PaveFiller 喂一个大输入集合,只是换了一种组织方式。这样既可能引入重复计算,又没有真正降低底层交集复杂度。
这也是这次优化中的一个重要转折点:问题不应该只看 task 数,而应该看每次 PaveFiller 实际看到的输入集合。
后来我把问题重新定义成:
如何在保持共形正确的前提下,
控制每一次 PaveFiller 的 inputSize?
这里的 inputSize 可以粗略理解为:
inputSize = objectCount + toolCount
真实耗时当然还和 face 数、edge 数、曲面复杂度、Face/Face 候选、Edge/Face 候选有关。但在外部 scheduler 层面,inputSize 至少是一个很重要的第一指标。
per-object direct tools:正确但太碎
基于这个判断,第一版可验证方案是 per-object direct tools:
for each shape:
Objects = { 当前 shape }
Tools = directNeighbors(shape)
也就是每个 shape 作为 Object 输出一次,它的直接接触邻居作为 Tools 参与压印。
如果 A 和 B 接触,那么 A 的 task 里带上 B,A 会被 B 压印;B 的 task 里带上 A,B 也会被 A 压印。
测试证明,这个方向在共形正确性上是成立的。它没有像强制空间分组那样直接丢掉跨组接触关系。
但它很快暴露了另一个性能问题:task 太碎。
在一个小模型上,原始大 group 是 567 个对象。per-object 以后直接变成:
taskCount = 567
虽然每个 task 的输入都变小了,但大量小 Splitter 的固定开销、重复 Tool 计算和 history 管理成本叠加起来,反而可能比一个 Splitter 更慢。
这个结果说明:per-object direct tools 可以证明方向正确,但它不是最终性能方案。
它解决了“输入过大”的问题,又引入了“任务过碎”的问题。
LocalBatchDirectTools:在接触图上做局部 batch
下一步就是把 per-object 合并成 batch。
基本思想是:
多个 Object 放在同一个 task 里;
Tools = 这些 Object 的直接邻居并集 - Object 自身。
这样如果 A 和 B 在同一个 batch 里,它们会一起作为 Arguments 参与同一个 Splitter;如果 A 和 B 不在同一个 batch 里,它们仍然会互相作为 Tool 参与压印。
也就是说,batch 不再是纯粹按空间硬拆,而是在 direct contact graph 上组织局部任务。
第一版 batch 只是按 inputSize 贪心合并。后来进一步调整成 LocalBatchDirectTools:从接触图中的 seed 出发,优先吸收和当前 batch 内部接触边更多的邻居,同时控制 inputSize 和 batch object 数量。
当时比较稳定的一组参数大致是:
targetBatchInputSize = 192
maxTaskInputSize = 256
maxBatchObjectCount = 64
workerCount = 6
这些参数不是通用最优值,只是这个实验模型上的有效起点。
这里真正重要的不是某个数字,而是策略变化:
不再从空间上强行切 group;
而是在接触图上构造局部 batch;
每个 batch 都控制 PaveFiller 的输入规模。
一个关键坑:bbox 误判制造了虚假超级节点
优化过程中遇到过一个很典型的坑。
最开始构造 direct contact graph 时,只用了 bbox 近似判断接触关系。结果在实验模型里出现了非常异常的日志:
groupSize=2560
maxDegree=2559
maxInputSize=2560
这意味着有一个对象看起来和所有其他对象都接触。
但从工程直觉上看,这很不合理。一个复杂装配模型里,不太可能真的存在一个 shape 和另外 2559 个对象都发生真实接触。
后来我加了精筛:
bbox 粗筛
↓
发现 suspicious node
↓
对 suspicious node 相关边做 BRepExtrema_DistShapeShape 精确距离判断
修正后,结果变成:
coarseMaxDegree=2559
exactRejectedCount=2551
finalMaxDegree=107
finalAvgDegree=10.62
这个结果说明:之前的 maxDegree=2559 基本就是 bbox 误判。
这一步非常重要。因为如果不修正 contact graph,后面的 batch scheduler 会基于错误的邻接关系生成巨大 task,最终又退化成全局大 PaveFiller。
也就是说,scheduler 本身再合理,如果输入的 contact graph 是错的,最后仍然会得到错误的任务规划。
最终结果:2560 个 shape 被规划成 114 个局部 task
修正 contact graph 后,这个大 group 的真实图结构其实比较健康:
groupSize=2560
finalMaxDegree=107
avgInputSize=11.62
largeTaskCount=0
后续使用 LocalBatchDirectTools 后,任务规划结果变成:
batchTaskCount=114
largeTaskCount=0
targetBatchInputSize=192
maxBatchInputSize=192
workerCount=6
这说明,大 group 没有被强行按空间切散,但执行阶段也不再一次性把 2560 个对象送进 Splitter,而是拆成了 114 个局部 task。
最终结果是:
taskCount=114
succeedCount=114
failedCount=0
historyCount=114
historyCacheCount=2560
全部 task 成功,history cache 覆盖了全部 2560 个 shape。
从完整日志估算,整个聚合流程大约耗时 19 分钟。其中 contact graph 精筛约 38 秒,真正执行 114 个 Splitter task 大约 18 分钟。
在这个实验模型中,相对之前约 1 小时左右的可运行版本,耗时大约降低到原来的三分之一,整体约 3 倍左右提升。
这个数据不是通用性能承诺。它只说明在这个模型、这组参数和当前实现下,接触图驱动的 LocalBatchDirectTools 比之前的可运行方案更适合这个问题。
工程补充:并行和 history cache
这次优化里还有两个工程细节比较重要,但它们不是主线。
第一个是并行策略。
一开始外层 worker 数设置得比较保守,后来在 small task 较多的情况下,适当增加了外层 worker。当前实验里,workerCount=6 的效果比较好。
这里的基本判断是:
small task:
外层多线程执行
Splitter 内部并行关闭
large task:
串行执行
Splitter 内部并行开启
这样做的目的,是避免外层线程和 OCCT 内部线程互相抢资源。
几何计算里的并行不是简单地“线程越多越好”。如果外层 task scheduler 和内部算法同时开并行,很容易出现线程过度竞争,反而让总耗时不稳定。
第二个是 history cache。
原来整个 group 只跑一个 Splitter,所以 history 只有一份。但现在拆成了多个 task:
historyCount=114
如果后续属性继承、材料继承、边界条件继承仍然线性扫描所有 task history,就可能变成隐藏耗时。
因此这里加了一个简单缓存:
shapeId -> BRepTools_History
也就是通过原始 shapeId 快速找到对应的 history。
这不是最优雅的长期设计。更规范的做法,应该是把 history cache 作为聚合上下文的一部分,明确生命周期和所有权。但对当前阶段来说,这个低侵入版本可以先验证问题是否存在,也能避免在主逻辑还没稳定时引入过多接口变更。
后续方向:PaveFiller 复用要放在局部 cluster 里做
优化过程中还讨论过一个更底层的方向:手动创建 BOPAlgo_PaveFiller,然后把已经准备好的 PaveFiller 传给 Splitter 或 Builder 复用。
这个方向不是临时偏方。OCCT 本身支持 prepared PaveFiller 这种高级用法。
它的价值在于:如果多个操作共享同一批输入 shape,那么它们可以复用同一次 intersection phase,而不是每个 Splitter task 都重新计算一遍 PaveFiller。
但这个方案不能简单地全局使用。
如果直接做:
PaveFiller 输入 = 全部 2560 个 shape
那就等于重新回到了全局大交集,正好违背了这次优化的目标。
更合理的方式应该是:
LocalCluster:
clusterShapes = 一片局部结构相关的 shape
PaveFiller(clusterShapes) 只跑一次
cluster 内多个 Splitter / Builder 复用这份 PaveFiller
也就是说,prepared PaveFiller 应该和 LocalCluster 配合,而不是直接替换当前每个 task 的 Splitter。
这个方向值得后续实验,但我不会把它直接并入当前主流程。当前更稳妥的顺序是:先稳定 LocalBatchDirectTools 主路径,再引入 LocalCluster,最后在 LocalCluster 内实验 PaveFiller reuse。
当前版本还剩下什么问题
虽然这次已经跑出了可用版本,但还有几个明显优化点。
第一,task 复杂度不能只看 inputSize。
日志里最慢的 task 并不一定是 inputSize 最大的 task。有些 task inputSize 不算特别大,却耗时很长。这说明真实耗时还取决于:
face 数
edge 数
曲面复杂度
Face/Face 候选 pair
Edge/Face 候选 pair
MakePCurves 成本
下一步更合理的是给每个 task 统计:
objectFaceCount
toolFaceCount
objectEdgeCount
toolEdgeCount
costScore
elapsedMs
然后按 costScore 调度,而不是只按 inputSize。
第二,contact graph 精筛仍然花了几十秒。
目前它不是主卡点,但如果后续 task 执行继续降低,精筛阶段也会变得更显眼。这里可以继续优化 suspicious node 的检测、距离判断缓存,或者对异常 bbox 做更细的局部筛选。
第三,PaveFiller reuse 还没有进入主流程。
这部分值得后续单独实验,但不应该在当前主路径还没完全稳定时贸然接入。
这些问题都没有影响当前方案作为一个可用版本落地,但它们说明这条优化线还没有结束。
小结
这次优化最大的收获不是某一个参数,而是思路上的变化。
一开始问题看起来像是:
大 group 要不要拆?
后来逐渐变成:
怎么在不破坏共形关系的前提下,
控制每一次 PaveFiller 的输入集合?
最终形成的策略是:
1. buildShapeGroups 仍然负责找严格共形连通 group;
2. 不强行按空间切散真实接触 group;
3. 对大 group 构建 direct contact graph;
4. 修正 bbox 造成的虚假超级节点;
5. 用 LocalBatchDirectTools 生成局部 task;
6. small task 外层并行,Splitter 内部并行关闭;
7. history 通过 shapeId cache 快速回查。
这个版本把一个包含 2560 个 shape 的大连通 group 规划成 114 个局部 Splitter task,并在保持共形正确性的前提下完成聚合。
在这个实验模型中,它把一个约 1 小时左右的可运行方案压缩到约 19 分钟,提升约 3 倍。这个结果不能直接推广到所有模型,但说明这条优化路径是有效的。
对我来说,这次排查最重要的经验是:
几何计算优化不能只看“拆成多少组”,
而要看每一次底层算法实际会处理多少拓扑关系。
在 OCCT Splitter 这条路径里,这个底层算法通常就是 BOPAlgo_PaveFiller。
只要 PaveFiller 看到的输入集合不受控,外部再怎么包装 task 都可能重新变慢。反过来,只要能稳定控制每次 PaveFiller 的输入规模,再配合合理的局部 batch 和并行调度,就可以在不牺牲共形正确性的前提下,把一个原本很难跑的流程变成现行可用的工程方案。
这不是最终答案,但已经比最开始“全局 Splitter 或强制空间拆分”两个极端选择,往前走了一步。