diff --git a/pipe-section-region-performance-plan.md b/pipe-section-region-performance-plan.md new file mode 100644 index 0000000..f318d6c --- /dev/null +++ b/pipe-section-region-performance-plan.md @@ -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 ""`。 +- 顺带收益:现在临时文件名来自 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 %,可作为"先测量、再缓存"的参考范例。