docs: 增加管道截面恢复性能改造计划 #94

Merged
gjm merged 2 commits from company-desktop/company-desktop-computer/plan-pipe-section-region-perf AGit into main 2026-10-28 15:44:43 +08:00
+330
View File
@@ -0,0 +1,330 @@
# 管道截面(Region)恢复性能改造计划
> 目标:降低"打开大图纸"时**截面恢复**的开销。
> 依据:`diagsession-cpu-analysis.md` 的实测数据;`report-cpu-hotspots.md` 为原始表格。
> 相关既有结论:`GiDrawUtility-analysis.md` §7.4 E2、`AecDbEwPipeBase-region-sat-plan.md` §3.8。
## 0. 结论摘要(本轮范围)
**本轮只做运行期优化,不动存盘格式。**
1. **Phase 0:先测量**(不改行为,Debug + 环境变量开关)。
2. **Phase 1:截面内容缓存**(核心)——按 SAT 文本 hash 缓存 region 母体,命中返回 `clone()`。
平台/版本无关,不涉及任何格式/协议改动,覆盖 `AecDbEwPipeBase` 全部子类 + `CoreTools::StringToRegion` 的调用方。
预期:临时文件落盘次数从 **O(实体数)** 降到 **O(唯一截面数)**(通常几十次),CPU 省 **约 6~10 个百分点**。
3. **Phase 2:临时目录治理**(可选,止血)——把 SAT 临时文件从 `%TEMP%` 挪到可配置目录,可加入杀软排除。
**明确不在本轮范围**:改用 `acdbModelerSaveEntityToDwgFiler` 存盘(含"双写"方案)。
决策理由见 §8,调研结论与将来要做时的注意事项见附录 A/B,避免以后重新踩一遍。
**长期唯一硬要求**:新版本软件必须能读老图纸。当前 SAT 文本通道继续保留,天然满足。
## 1. 数据依据
本机第二轮 trace(`report.diagsession`,CAD 2019 系 / R190 部署,Debug 构建):
1 ms 采样,acad.exe 42,888 个带栈采样 ≈ **42.9 s CPU**。
| 指标(栈内出现率,每栈去重) | 采样 | 占 acad CPU |
| --- | ---: | ---: |
| `AecDbEwPipeBase::RegionAcisInDwgInFields` | 3,130 | **7.30 %** |
| `CoreTools::StringToRegion` | 2,949 | **6.88 %** |
| `AecDbEwPipeBase::dwgInFields`(上层) | 3,420 | 7.97 % |
| `CoreTools::RegionToString`(反向回写) | 1,315 | 3.07 % |
| 参照:`AecDbEntityCommon::base_ViewportDraw` | 31,856 | 74.28 % |
| 参照:Parasolid(ASM* 家族独占) | — | ≈ 43 % |
两条恢复入口有部分重叠,保守估计 **截面恢复 ≈ 10~14 %**。
**两个必须记住的采样局限**:
- 采样只统计**在跑 CPU** 的时间,临时文件 write/read 的**等待不进采样** →
实际墙钟占比 **高于** 上表。
- 这里的 10~14 % 是"载入期付一次";绘制期还会再走扫掠/布尔,两者是不同问题(见 §7 第 4 条)。
## 2. 现状:相关代码路径
### 2.1 `AecDbEwPipeBase::RegionAcisInDwgInFields`(`AecDbEwPipeBase.cpp:794`)
DWG 里存的是 **SAT 文本 + 一个文件名**。恢复流程:
```cpp
pFiler->readString(&pszFileName); pFiler->readUInt32(&nLen); pFiler->readBytes(pszTxt, nLen);
sFullPath = FilePath::getSystemTemporaryFolder() + "\\" + SanitizeRegionTempName(sFileName);
file.Open(sFullPath, CFile::modeCreate | CFile::modeWrite); file.Write(pszTxt, nLen); file.Close();
AcDbBody::acisIn(sFullPath, arBodyPtrs); // 从盘上解析
::DeleteFile(sFullPath);
```
该函数是 `AecDbEwPipeBase` 的方法 → **`AecDbEwPipe` 及所有管件子类共用**
(`AecDbEwPipeBase.cpp:910` 由 `SectionRegionDwgInFields` 调用,`:955` 赋给 `m_pRegion`)。
### 2.2 `CoreTools::StringToRegion`(`CoreTools.cpp:3519` / `:3581`)
同样的"写临时文件 → `acisIn` → `DeleteFile`",比 2.1 多一步 `fixACISFormat` 改写文本。
调用方覆盖 `AecDbEwEquipment`、`AecDbInsulation`、`AecDbBDPipeBase`、`PipelineCmd`(`EQU_MODIFY.cpp`)等。
另有 `CoreTools::StringToBody`(`:3712`)同一模式。
### 2.3 绘制期:直管几何的构建(`AcDbX3dSolid.cpp:1242~1300`)
```cpp
createFromCurves([外圆 pl]) → pRegO // 第 1 次
createFromCurves([内圆 pPlI]) → pRegI // 第 2 次
extrudeAlongPath(pRegO, ln) → pSolidO // 扫掠 1
extrudeAlongPath(pRegI, ln) → pSolidI // 扫掠 2
pSolidO->booleanOper(kBoolSubtract, pSolidI) // 布尔减
```
`GiDrawUtility::DrawPipe`(`GiDrawUtility.cpp:938`)里另有 `pRegion->clone()` + `transfromRegion()`,
因为原 region 会被变换。**这也是 Phase 1 能零风险接入的原因:clone 语义本来就存在。**
### 2.4 存盘现状(本轮不改)
- `kCurrentVersionNumber = 7`(`AecDbEwPipeBase.cpp:382`)
- region 块布局:`writeUInt32(0)`(SAT 通道标志)+ `RegionAcisoutDwgOutFields` 写 SAT 文本
(`SectionRegionDwgOutFields:1279`)
- `dwgInFields:938` 有 `if (version > kCurrentVersionNumber) return Acad::eMakeMeProxy;`
## 3. 本轮方案
### Phase 0:先测量(不改行为)
在 `RegionAcisInDwgInFields` 与 `StringToRegion` 两个入口 + 临时文件写/删处加计数,
`_DEBUG` 编译进、环境变量 `AEC_PERF_COUNTER=1` 开启、命令 `ENVIPERFDUMP` 打印汇总:
| 计数项 | 用途 |
| --- | --- |
| 调用次数、单次/累计 µs(QPC 包住整段) | 单次成本(预估 3~15 ms) |
| 输入文本 hash 的**唯一值数** | 复用率 = 1 − 唯一/总数 → Phase 1 收益上限 |
| **模拟命中率**(只统计、不改行为) | 同上,更直观 |
| 临时文件写入次数 + 累计字节 | 杀软影响面 |
| `RegionToString` / `RegionAcisoutDwgOutFields` 次数 | 查清打开时为何在回写(3.07 %) |
**决策点 D0**:唯一数是否远小于总次数(例如 30 种 × 1500 次)。
- 是 → Phase 1 直接做
- 否(每个实体文本都不同)→ 停下重新评估(此时缓存收益接近 0)
### Phase 1:截面内容缓存(核心,全平台)
新增一个缓存模块(建议 `CoreLibrary/SectionRegionCache.h/.cpp`,私有实现放匿名 namespace):
- **key**:输入 SAT 文本字节的 64 位 hash(自实现 FNV-1a/xxHash,不引第三方),长度参与计算。
→ **内容寻址,不需要任何失效逻辑**:样式/参数变了,文本变了,key 自然变。
- **value**:**母体** `AcDbRegion*`(heap 对象,不入库,永不外泄)。
- **API**:
- `AcDbRegion* SectionRegionCache::Acquire(const char* psz, Adesk::UInt32 nLen)` —— 命中返回 `clone()`,未命中返回 NULL
- `void SectionRegionCache::Put(const char* psz, Adesk::UInt32 nLen, AcDbRegion* pMaster)` —— 由调用方在成功构造后存入
- **容量**:条目上限(如 512)+ 总字节上限(如 32 MB)+ LRU;条目析构 `delete` 母体。
- **线程**:先加 `std::mutex`(trace 显示 98 % 单线程,开销可忽略)。
若将来绘制并行,注意 modeler 实体不能跨 partition 共享,需改为每线程缓存(注释里写清楚)。
接入点(两处即可覆盖绝大部分):
1. `AecDbEwPipeBase::RegionAcisInDwgInFields` —— 在**写临时文件之前**先查缓存。
2. `CoreTools::StringToRegion`(两个重载)与 `StringToBody` —— 同样在写文件前查缓存。
**不变量(必须守住)**:
- 缓存只发 **clone**;调用方拿到的永远是独立对象(现有 `DrawPipe` 的 `clone()` + `transfromRegion()` 不受影响)。
- 母体不被任何调用方 `delete`。
- miss 路径的 `DeleteFile` 逻辑保持原样(含失败分支)。
### Phase 2:临时目录治理(可选,止血)
Phase 1 之后残余落盘只剩"每个唯一截面一次",收益小;但如果现场仍被杀软拖慢,可做这一步:
- 现状两处硬编码 `%TEMP%`,而 `%TEMP%` 通常**不在**客户"工作目录排除"范围内 → 每次落盘都被扫。
- 改用仓库已有的 `CoreTools::getTempFilePath(main, suffix, pszParentPath, ...)`(`CoreTools.h:495`),
父目录从 INI 读(如 `SectionTempDir`),空/不存在时回落系统临时目录。
- 安装/部署脚本把该目录加入 Defender 排除:`Add-MpPreference -ExclusionPath "<dir>"`。
- 顺带收益:现在临时文件名来自 DWG 里存的 `sFileName`,多实体同名会互相覆盖
(`temp-file-naming.md` 已记录此类隐患);缓存命中时该隐患消失。
## 4. 平台与版本矩阵
本轮**不引入任何按版本/平台分叉的代码**,因此:
| 编译目标 | 本轮改动 |
| --- | --- |
| ARX R19.0(AutoCAD 2013-2014,本地习惯目标) | Phase 0/1/2 全部生效 |
| ARX R20.0 ~ R26.0 | 同上 |
| ZRX Z2023 ~ Z2026 | 同上 |
不需要能力宏。将来若重启"换存盘通道"(附录 B),再按下面的方式加开关,
**不要**在源码里比较 ARX 版本号:`cmakepresets.json` 里 ZRX 的 `CAD_SDK_VERSION` 是年份
(`2023`~`2026`),与 ARX 的 `19`~`26` 数值区间重叠,直接比较必然误判(AGENTS.md 红线)。
```cmake
# 将来需要时:在 cmakelists.txt 里与现有 ZWCAD 判定放在一起
if (NOT IS_ZWCAD AND CAD_SDK_VERSION GREATER_EQUAL 20)
add_compile_definitions(AEC_HAS_MODELER_FILER)
endif()
```
## 5. 风险与回滚
| 风险 | 影响 | 应对 |
| --- | --- | --- |
| 缓存母体被调用方污染 | 几何错乱 | 只发 clone;母体不去库、不外泄;Code Review 专项检查 |
| `AcDbRegion::clone()` 深拷贝语义不足 | 共享脏数据 | 沿用现有 `m_pRegion->clone()` 的语义;验证"同一母体 clone 两次分别变换,互不影响" |
| hash 碰撞 | 用错截面 | 64 位 hash + 长度参与;可选命中后做一次字节比对(成本远低于重建) |
| 内存增长 | 大图纸爆内存 | 条目数 + 总字节双上限 + LRU;打印峰值供现场反馈 |
| 回写(`RegionToString` 3.07 %)原因不明 | 白做一次序列化 | Phase 0 计数确认;若确属不必要,单独一个 PR 修掉 |
**回滚**:Phase 1 由宏/配置一键关闭(走原路径);Phase 2 只改路径来源;
Phase 0 只在 Debug 编译进,无发布风险。
## 6. 验证清单
编译:
- [ ] `R230`(日常调试目标)Debug/Release 编译通过
- [ ] `R190` 编译通过
- [ ] 至少一个 ZRX preset(如 `Z2026`)编译通过
- [ ] 改动涉及 `.cpp/.h` 时为 UTF-8 with BOM;`cmakelists.txt` 保持 ASCII 无 BOM
功能(CAD 实测,逐项记录):
- [ ] 新建管道 → 存盘 → 重开:截面几何、面积、位置一致
- [ ] 老图纸(曲线通道 / SAT 文本通道)打开后截面正确
- [ ] 老图纸另存 → 重开,截面仍正确
- [ ] 复制粘贴(`copyFrom` 走 `m_pRegion->clone()`)不丢截面
- [ ] 管件、设备(`AecDbEwEquipment`)、保温(`AecDbInsulation`)截面均正确
- [ ] 同一母体 clone 出的两个 region 分别做不同变换,互不影响
性能(用 `tools/perf_diagsession.py`):
- [ ] 同一张图、同一 preset、同一构建配置下各录一次 trace 对比
- [ ] 关键指标:截面恢复占比、临时文件写入次数、`base_ViewportDraw` 总占比、`AecDbX3dPart::EnsureCreated` 命中情况
- [ ] 记录墙钟耗时(点开到可操作)与 CPU 采样时间两个口径
## 7. 未决问题
1. **D0**:SAT 文本的唯一 hash 数到底是多少?(Phase 0 用计数器回答)
2. 打开图纸时为什么在**回写** SAT(`RegionToString` 3.07 %)?是 `copyFrom`/深拷贝走了 `dwgOutFields`,
还是别的原因——Phase 0 计数确认后再定是否单独立项。
3. Phase 1 之后 `AecDbX3dPart::EnsureCreated/Rebuild` 仍每次都重建(栈上 8.4 % 却不命中)——
这是**另一个独立问题**,优先级可能高于截面缓存(见 `diagsession-cpu-analysis.md` §4)。
4. `AcDbBody::acisIn` 只有文件名版本;若将来想彻底去掉临时文件,只能走附录 B 的通道
(或自行接入 ACIS API,成本与授权需另评)。
## 8. 决策记录:为什么本轮不改存盘格式
考虑过的方案是"**双写**":既写现有 SAT 文本,又把几何用
`acdbModelerSaveEntityToDwgFiler` 写成一个附加块放到存盘数据末尾,新版本优先读新块,
老版本忽略尾部数据 → 兼顾"新版本更快"与"老版本可读"。
**暂缓的理由**:
1. **收益不明确**。无法确定 `acdbModelerSave/RestoreEntityFromDwgFiler` 比
`SAT 文本 → 临时文件 → acisIn` 快多少。目前能确认的收益只有一条:
**省掉临时文件,从而省掉杀软扫描时间**(用户对此有切身体会)。
2. **而这条收益在 Phase 1 之后变得很小**。缓存落地后,落盘次数已从 O(实体数) 降到
O(唯一截面数)(几十次),杀软影响面同步缩小。
3. **复杂度不低**:不能升版本号(否则老版本整实体变 proxy)、读端要延后构造否则会白读一遍 SAT、
DWG 体积翻倍、还要过一整套老版本兼容验证矩阵(附录 B)。
4. **`region-sat-plan.md` §3.8 已确立"一条写路径"原则**,双写与之相悖,需要更强的收益理由。
**结论**:先把缓存做掉,用 Phase 0/1 的数据说话。等有实测证明"modeler 通道比 SAT 通道显著更快"
(或 DWG 体积成为独立诉求)时,再回到附录 B。
---
## 附录 A:SDK 能力边界(调研结论,备查)
| 接口 | 位置 | R19.0 | R20.0+ |
| --- | --- | --- | --- |
| `AcDbBody::acisIn(const ACHAR* fileName, ...)` | `Arx/*/inc/dbBody.h:49` | 有(**仅文件名**,无 buffer 重载) | 有 |
| `acdbModelerSaveEntityToDwgFiler` / `...ToDxfFiler` | `Arx/*/inc/dbacis.h` | **无** | 有 |
| `acdbModelerRestoreEntityFromDwgFiler` / `...FromDxfFiler` | 同上 | **无** | 有 |
| `acdbGetModelerDwgVer(dwgVer, pObj)` | 同上 | 无 | 有 |
- `Arx/` 里搜不到 `api_restore_entity_list` 等 ACIS 底层 API → **想彻底不落盘,只能用 modeler filer 通道**。
- modeler filer 的 `modelerVersion` 参数(`<0` 跟随 DWG、`0` 当前、`>0` 指定如 `2170`)与
`restoreOptions` 的 `kReadForeignEntities | kReadUnknownEntities`,是官方的**跨版本兼容机制**。
- **坑**:`acdbModelerSaveEntityToDwgFiler` 要裸 `ENTITY*`,而 `AcDbBody` 只提供标
**INTERNAL USE ONLY** 的 `getLockedASMBody()` / `unlockASMBody()` / `setASMBody()`
(`Arx/R23.0/inc/dbBody.h:38-43`;R19 也有这些函数但没有对应的 save/restore)。
→ 将来要用,必须先做 round-trip 最小验证(存/读后比对拓扑、体积、与 `acisIn` 结果一致性)。
## 附录 B:将来若做"双写",必须注意的点
### B.1 红线:绝对不能升 `kCurrentVersionNumber`
```cpp
// AecDbEwPipeBase.cpp:938
if ( version > AecDbEwPipeBase::kCurrentVersionNumber )
return (Acad::eMakeMeProxy) ;
```
当前版本号 7。升到 8 → **老版本软件打开新图时整个实体变 proxy**,比"截面读不到"严重得多。
做法应沿用 SAT 迁移时的既有模式(`nLen == 0` 作标志、不动版本号)。
### B.2 逻辑漏洞:新软件会白读一遍 SAT
当前流布局(`dwgOutFields:1343`):
```
1 父类字段 → 2 version=7 → 3 m_mapSpecXData → 4 m_pBaseCurve
→ 5 ★region 块(writeUInt32(0) + SAT 文本)→ 6 m_arBreaks
→ 7 m_nInfActive / GUID / 各种 Id → 8 WriteUserInterface() → 9 ← 新块只能加在这里
```
SAT 在**中间**、新块在**末尾** → 读到 SAT 时还不知道末尾有没有新块。
若"先按 SAT 恢复、末尾再替换",等于每个截面多付一次"写盘 + acisIn",
**双写反而更慢**。
**修法**:延后的是**构造**,不是读取。
- 读到 region 块时只把 SAT 文本读进内存(不落盘、不 `acisIn`)
- 继续读完 6~8(需逐项确认不依赖 `m_pRegion`,尤其 `ReadUserInterface`)
- 末尾尝试读新块:成功 → 用它构造 `m_pRegion` 并丢弃文本;失败 → 用文本走原路径
### B.3 块格式建议
```
magic(8B) + blockVer(2B) + modelerVer(2B) + flags(4B) + len(4B) + payload
```
`blockVer` 是这个块自己的版本,与 `kCurrentVersionNumber` 无关;`len` 用于跳过与自愈。
读端任何一步失败都**静默回退**(读不到 magic 属正常老数据,不能刷日志)。
### B.4 必须验证的兼容矩阵
- 老版本插件正常打开新图 / 外部参照 / 局部打开
- 老版本 `AUDIT` / `RECOVER` 不报错、不删实体
- 新图 `WBLOCK` / 复制粘贴到老图
- 老版本打开后另存 → 新版本再打开(应回落 SAT,不报错)
- 确认未触发 `eMakeMeProxy`
- DXF 路径(`acdbModeler*ToDxfFiler`)同样过一遍
- `copyFrom` / 深拷贝(内存 filer 走同一套 `dwgOut/dwgIn`)
### B.5 代价
| 代价 | 说明 |
| --- | --- |
| DWG 体积 | 几何存两份(SAT 文本 + 二进制),需实测单构件增量 × 实体数 |
| 存盘时间 | `acisOut` 与 modeler save 各跑一遍 |
| 打开 I/O | 流变长,即使跳过 SAT 也要读过去/跳过 |
| 维护面 | 两套序列化都要测;读端优先级必须明确(新块优先、SAT 兜底) |
| 老版本保存后 | 新块丢失 → 新版本回落 SAT,性能退化但不丢数据(需在文档中写明) |
### B.6 覆盖范围
`AecDbEwPipeBase` 是"region 块"结构,而 `AecDbEwEquipment` / `AecDbInsulation` 走 `StringToRegion`
(文本存在普通字符串字段里),追加位置不同。建议先抽一对 helper 再推广:
```cpp
Acad::ErrorStatus WriteSectionModelerBlock(AcDbDwgFiler*, const AcDbRegion*);
bool TryReadSectionModelerBlock(AcDbDwgFiler*, AcDbRegion*& out); // 失败返回 false,不报错
```
## 附录 C:与既有文档的关系
- `AecDbEwPipeBase-region-sat-plan.md` §3.8:否决的是「**存储**按标准/异形分流」与「曲线通道写路径保留」。
本计划的 Phase 1 是**运行期缓存**,不改存储,不违背该结论。
- `GiDrawUtility-analysis.md` §7.4 E2:否决的是「同一 draw 内跨分段复用截面」,
理由是"大头是 N−1 次 boolean unite"。本计划不再碰那个分支,而是把"造截面"整体次数降下来;
同时提醒:**别指望截面缓存解决 Parasolid 的扫掠/布尔开销**,那是另一个 P0 项。
- `temp-file-naming.md`:临时文件命名/多实例规范,Phase 2 改动需遵守。
- `sqlite_query_cache.md`:同类问题(绘制期重复查库),已由 #87/#88 解决;
trace 实测 sqlite 从 17.0 % 降到 0.06 %,可作为"先测量、再缓存"的参考范例。