修完回写后开图期仍有 1733 次临时文件写入,需要判断"改内存读取"能省多少。 在 RegionAcisInDwgInFields 与 CoreTools::StringToRegion 两个重载里,把 "写临时文件+关闭"和"AcDbBody::acisIn"拆成独立计时(RegIn.write / RegIn.acisIn / Str2Reg.write / Str2Reg.acisIn)。 实测(big1.dwg,93 s 开图,1733 次未命中): RegIn.write 374 次 70.5 ms 189 us RegIn.acisIn 374 次 1422.0 ms 3.80 ms Str2Reg.write 1359 次 267.7 ms 197 us Str2Reg.acisIn 1359 次 5999.5 ms 4.41 ms 结论:写文件合计 338 ms,只占 9.6 s 恢复耗时的 3.5%;杀软扫描已在 write 的 190 us 里 (Defender 在关闭时扫)。即使完全省掉文件往返,上限也只有 0.3~0.5 s(开图的 0.4%)。 而 AcDbBody::acisIn 在 R19~R26 只有文件名重载,真要内存解析只能改存盘格式走 acdbModelerRestoreEntityFromDwgFiler(即暂缓的 Phase 3)。因此不做。 剩余 7.4 s 是纯解析(1733 次 × 4.3 ms),只能靠减少次数;text 通道 1560 个唯一文本 是真实不同的截面,命中率已接近上限。继续提速应转向 Parasolid 绘制那条线。 数据与结论已写入 pipe-section-region-performance-plan.md。 本次只加诊断计时(Debug + AEC_PERF_COUNTER=1 才生效),不改任何业务行为。
34 KiB
管道截面(Region)恢复性能改造计划
目标:降低"打开大图纸"时截面恢复的开销。 依据:
diagsession-cpu-analysis.md的实测数据;report-cpu-hotspots.md为原始表格。 相关既有结论:GiDrawUtility-analysis.md§7.4 E2、AecDbEwPipeBase-region-sat-plan.md§3.8。
0. 结论摘要(本轮范围)
本轮只做运行期优化,不动存盘格式。
- Phase 0:先测量(不改行为,Debug + 环境变量开关)。
- Phase 1:截面内容缓存(核心)——按 SAT 文本 hash 缓存 region 母体,命中返回
clone()。 平台/版本无关,不涉及任何格式/协议改动,覆盖AecDbEwPipeBase全部子类 +CoreTools::StringToRegion的调用方。 预期:临时文件落盘次数从 O(实体数) 降到 O(唯一截面数)(通常几十次),CPU 省 约 6~10 个百分点。 - 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 文本 + 一个文件名。恢复流程:
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)
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:先测量(不改行为)
已在 AecDbEwPipeBase::RegionAcisInDwgInFields / RegionAcisoutDwgOutFields 与
CoreTools::StringToRegion(两个重载)/ RegionToString 上接入探针,
实现见 inc/SectionPerfCounter.h + CoreLibrary/SectionPerfCounter.cpp。
用法(必须是 Debug 构建):
set AEC_PERF_COUNTER=1 :: 必须在启动 CAD 之前设好,进程内只读一次
:: 打开图纸
ENVIPERFDUMP :: 命令行打印汇总,并追加到 %TEMP%\aec_section_perf.txt
ENVIPERFRESET :: 清零,方便多次对比
Release 里 SectionPerf::Enabled() 恒为 false,调用点只剩一次 bool 判断,无发布风险。
采集项:
| 计数项 | 用途 |
|---|---|
RegionTexts / UniqueHashes / SimulatedHits / HitRate |
复用率 = 1 − 唯一/总数 → Phase 1 收益上限 |
每个 tag 的 count / total_ms / avg_us / max_us |
单次成本,判断值不值得做 |
TempFiles / TempBytes |
杀软扫描影响面 |
WriteBacks |
打开图纸时为何在回写(3.07 %) |
决策点 D0:唯一数是否远小于总次数(例如 30 种 × 1500 次)。
- 是 → Phase 1 直接做
- 否(每个实体文本都不同)→ 停下重新评估(此时缓存收益接近 0)
Phase 0 实测结果(2026-10-28,big1.dwg / AutoCAD 2020 / R230 Debug)
自动跑图:tools/run_acad_perf.ps1(等价于 acad.exe /p SHS-ENVI2026 /b <dump.scr> big1.dwg),
开图到出报告 108 s。
RegionTexts=5641 UniqueHashes=2597 SimulatedHits=3044 HitRate=54.0%
TempFiles=6773 TempBytes=9,341,125 WriteBacks=1140
---- timing (by tag) ----
count total_ms avg_us max_us tag
2559 14620.1 5713.2 320828 CoreTools::StringToRegion
3082 8315.3 2698.0 17447 EwPipe::RegionAcisIn
1140 2282.1 2001.8 35390 EwPipe::RegionAcisout
解读:
| 结论 | 数值 |
|---|---|
| 一次开图做 SAT 恢复 | 5641 次(此前基于采样的估算只有 550 |
| 恢复总耗时 | 22.9 s(14.6 + 8.3),占 108 s 开图约 21 % |
| 单次成本 | 2.7~5.7 ms,实为「写临时文件 + 从盘 acisIn + 删」而非内存解析 |
| 极端值 | 单次最长 321 ms(StringToRegion) |
| 唯一截面内容 | 2597 种 → 命中率 54.0 % |
| 临时文件 | 6773 次写入 / 8.9 MB(每次都过杀软) |
| 打开时仍在回写 | 1140 次 / 2.3 s,开图期间不应发生的序列化 |
D0 结论:值得做。 按命中率折算 3044 次命中 × 加权均值 4.07 ms ≈ 省 12.4 s,
减去 clone 成本(几十 µs 级,合计约 0.10.3 s)→ 净省约 12 s / 108 s ≈ 11 %,
并少约 4000 次临时文件写盘。与基于 trace 的估算(1014 %)吻合。
属性 key 命中率实测(同日第二次跑图,113 s)
在 AecDbEwPipeBase::RegionAcisInDwgInFields 里按"类名 + 除编号外的全部 spec XData"
建第二套 key(跳过 Id / 点 / 向量 / 矩阵 / 二进制类型),与内容 hash 并行统计:
---- attr key (by class, would-be cache hit rate) ----
total unique hits hit_rate class
1088 173 915 84.1% AecDbEwPipe
1172 3 1169 99.7% AecDbEwPipeBase
96 1 95 99.0% AecDbWeir
48 1 47 97.9% AecDbColumn
84 2 82 97.6% AecDbBeam
40 1 39 97.5% AecDbMultiPartEntity
184 44 140 76.1% AecDbXWall
78 22 56 71.8% AecDbGuardBar
214 122 92 43.0% AecDbXSlab
...(合计 3082 次,2672 命中)
属性 key 合计命中率 86.7 %,明显高于内容 hash 的 54.0 %——证实"同数据的管子/管件截面必然相同"。
但要注意覆盖率:属性 key 只覆盖 EwPipe::RegionAcisIn 这条路径(3082 / 5641 次)。
另外 2559 次走 CoreTools::StringToRegion(设备、保温等),拿不到 spec 数据,只能用内容 hash。
所以 Phase 1 应是混合策略:
| 路径 | key | 可用次数 | 预计命中 |
|---|---|---|---|
管道/管件族(AecDbEwPipeBase::RegionAcisInDwgInFields) |
属性 key | 3082 | 2672 |
CoreTools::StringToRegion / StringToBody |
内容 hash | 2559 | ≈1380(按 54 %) |
预计节省:2672 × 2.72 ms + 1380 × 5.54 ms ≈ 14.9 s, 占恢复总耗时 22.6 s 的 66 %,占 113 s 开图的 约 13 %(比纯内容 hash 多省 2~3 s)。
尚未验证:属性 key 命中时两份 SAT 文本是否真的相同。若存在"同数据不同文本" (旧版本存盘、不同代码路径)的情况,直接复用会有风险,需要 textHash 兜底校验, 代价是每次多算一次 hash(几微秒)。这一项应在 Phase 1 实现时顺带统计。
三个待追查项(不阻塞 Phase 1):
- 命中率只有 54 %。2597 种唯一文本对 5641 次恢复,说明同 DN 的 SAT 文本并非完全一致。 需确认差异是"真的不同几何"(不同管径/壁厚/异形)还是"带了绝对坐标等无关内容"。 前者属性 key 也救不了;后者归一化后命中率有望到 80 %+。
StringToRegion单次成本是RegionAcisIn的 2 倍(多一步fixACISFormat改写文本)。- 开图期间回写 1140 次 SAT(2.3 s) 属于纯亏,应单独立项查清来源。
Phase 1:截面内容缓存(核心,全平台)
新增一个缓存模块(建议 CoreLibrary/SectionRegionCache.h/.cpp,私有实现放匿名 namespace):
- key:输入 SAT 文本字节的 64 位 hash(自实现 FNV-1a/xxHash,不引第三方),长度参与计算。 → 内容寻址,不需要任何失效逻辑:样式/参数变了,文本变了,key 自然变。 → 选它的核心理由是可证明正确:字节相同即几何相同,不存在"属性漏项导致复用错几何"的风险。
- 关于"用管子属性做 key"(业务上更自然的思路,实测命中率可能高于内容 hash):
属性相同的管子截面必然相同,但不能单独当 key——DWG 里存的 SAT 文本可能来自旧版本、
旧代码路径或用户手工编辑过的异形截面,这些不是属性区分的;只要有一项影响截面的属性漏进
key,就会静默复用错误几何。若要做,用两级:
对几 KB 文本做一次 hash 只需几微秒,这个校验几乎免费,但把"属性漏项"从静默错误 降级为缓存不命中。属性 key 还需随样式表(DN→尺寸)版本变化失效,内容 hash 不需要。 落地前应先加一路"属性 key 命中率"计数,与内容 hash 并行比较再决定是否值得引入。
L1 内容 hash → region (可证明正确,当前实测 54%) L2 属性 key → textHash (加速索引;命中后仍比对 textHash,不一致按 miss 处理) - value:母体
AcDbRegion*(heap 对象,不入库,永不外泄)。 - API:
AcDbRegion* SectionRegionCache::Acquire(const char* psz, Adesk::UInt32 nLen)—— 命中返回clone(),未命中返回 NULLvoid SectionRegionCache::Put(const char* psz, Adesk::UInt32 nLen, AcDbRegion* pMaster)—— 由调用方在成功构造后存入
- 容量:条目上限(如 512)+ 总字节上限(如 32 MB)+ LRU;条目析构
delete母体。 - 线程:先加
std::mutex(trace 显示 98 % 单线程,开销可忽略)。 若将来绘制并行,注意 modeler 实体不能跨 partition 共享,需改为每线程缓存(注释里写清楚)。
接入点(两处即可覆盖绝大部分):
AecDbEwPipeBase::RegionAcisInDwgInFields—— 在写临时文件之前先查缓存。CoreTools::StringToRegion(两个重载)与StringToBody—— 同样在写文件前查缓存。
不变量(必须守住):
- 缓存只发 clone;调用方拿到的永远是独立对象(现有
DrawPipe的clone()+transfromRegion()不受影响)。 - 母体不被任何调用方
delete。 - miss 路径的
DeleteFile逻辑保持原样(含失败分支)。
Phase 1 实施结果(2026-10-28,big1.dwg / R230 Debug)
已实现 CoreLibrary/SectionRegionCache.{h,cpp},三个通道:
| 通道 | key | 用在哪 |
|---|---|---|
kParam |
纯参数 hash(dD/dt/HLR 标志) | AecDbEwFitting::GetSection 两个重载 |
kAttr |
类名 + spec XData | 仅 AecDbEwPipe(其余派生类实例间差异就是截面,不适用) |
kText |
SAT 文本内容 hash | AecDbEwPipeBase::RegionAcisInDwgInFields 的其它类 + CoreTools::StringToRegion 两个重载 |
命中返回 clone(),未命中走原逻辑后 Put() 入库;母体不外泄;AEC_SECTION_CACHE=0 可整体关闭做 A/B。
关键发现:SAT 文本第 2 行带存盘时间戳
导出未命中的 SAT 文本后发现:同一几何存盘时间不同,整份文本就不同——
700 56 1 0
16 Autodesk AutoCAD 19 ASM 219.0.0.5600 NT 24 Thu Oct 22 10:19:20 2026 ← 时间戳
1 9.9999999999999995e-007 1e-010
采样里两份 2596 字节、内容完全一致的文本,只差 10:19:20 与 10:19:16。
这就是内容 hash 命中率只有 54 % 的真正原因,与"截面是否相同"无关。
修复:SectionRegionCache::HashSatText() 在计算 hash 时跳过第 2 行
(仅当该行含 "Autodesk" 时才跳过,避免误伤其它格式)。计数器改用同一口径。
实测对比
| 指标 | 基线(AEC_SECTION_CACHE=0) |
启用缓存 + 时间戳归一化 |
|---|---|---|
| 开图墙钟 | 108~113 s | 98 s |
| 截面恢复总耗时 | 22.4 s | 9.87 s(−56 %) |
CoreTools::StringToRegion |
14.05 s | 7.73 s |
EwPipe::RegionAcisIn |
8.38 s | 2.14 s |
Fitting::GetSection |
0.13 s | 0.035 s |
| 临时文件 | 6773 次 / 9.34 MB | 2875 次 / 3.61 MB |
| 缓存命中 | — | text 65.5 %、attr 84.1 %、param 88.8 % |
| clone 开销 | — | 命中 58 µs、未命中 73 µs(合计仅 0.37 s) |
clone 成本可以忽略,说明"每次无论命中与否都发 clone"这个保守设计没有代价。
仍未解决
EwPipe::RegionAcisout仍是 1140 次 / 2.38 s——已查明原因,见下节。Fitting::GetSection在开图阶段只有 232 次调用(0.13 s),收益很小; 它的价值可能在绘制/平移时,需要另一次针对"打开后操作"的测量才能确认。StringToRegion仍有 1570 次未命中(唯一文本 1562),这部分是真实的不同截面, 想再提升只能靠业务层的 key,暂不做。
开图期 1140 次回写(RegionAcisout)的成因
用 AEC_WRITEBACK_TRACE=1 抓了一次调用栈(SectionPerf::TraceCallStack,输出在报告文件里):
aecrxbase.arx +0x21cb4 AecDbCurveBase::subViewportDraw
aecrxbase.arx +0x28905 AecDbEntityCommon::base_ViewportDraw
corecommand.arx +0x7ddcef ← 绘制路径里的我们的代码(未解析到符号)
corecommand.arx +0x7e09d0
corecommand.arx +0x7e1300
acdb23.dll +0x97a6b
ac1st23.dll +0x19847
corecommand.arx +0x58f051 ← AecDbEntityCommon::base_DeepClone
acdb23.dll +0x558e3 AcDbObject::dwgOut
corecommand.arx +0x58eb33 AecDbEwPipeBase::dwgOutFields
corecommand.arx +0x59627b AecDbEwPipeBase::RegionAcisoutDwgOutFields
结论:不是保存,是"绘制期深拷贝"。
AecRxBase/AecDbEntityCommon.cpp:1265(base_DeepClone)用最朴素的方式实现深拷贝:
AcDbDeepCloneFiler filer;
GetMe()->dwgOut(&filer); // → dwgOutFields → acisOut 写临时 SAT 文件
filer.seek(0L, AcDb::kSeekFromStart);
pClone->dwgIn(&filer); // → dwgInFields → acisIn 读回来
于是每次绘制期克隆实体,截面都要"写一个 SAT 临时文件、再读回来"。 1140 次 × 2.08 ms ≈ 2.38 s,全部发生在开图的绘制阶段。
(读的那一半现在已被 kText 缓存吃掉;剩下的是 acisOut 写文件这一半。)
触发点:绘制路径里有 clone()(栈帧在 corecommand.arx 内,未解析到符号)。
代码里一处明确的可疑点是 AecDbX3dPartEntity::subGetTransformedCopy
(AecDbX3dPartEntity.cpp:757 直接 clone()),但尚未证实它就是本次的调用者,
需要用 PDB 解析上面三个 RVA 才能钉死。
哪个类在回写:
---- write-backs by entity class ----
1140 AecDbEwPipeBase
全部 1140 次都是 AecDbEwPipeBase 基类实例(不是 AecDbEwPipe,也不是具名子类),
与属性 key 统计里 AecDbEwPipeBase 的 1172 次几乎一一对应。
这些实例由 AecDbAssembleWell.cpp 的装配井代码创建,多为矩形截面段。
完整调用链(已用 PDB 解符号确认)
前期用 AEC_WRITEBACK_TRACE=1 抓到栈,但帧里的 RVA 当时没解析,先后误判成
AecDbCurveBase::subGetTransformedCopy 与 AecDbEntityCommon::base_DeepClone——
两者都已被证伪(断点不命中、PerfTraceMark 打点也没触发)。
用 tools/resolve_rva.ps1(dbghelp + 本地 PDB)解析后,真实链路是:
| 层次 | 位置 |
|---|---|
| Z 型楼梯绘制 | AecDbZLineStair::Draw3d → DrawSteep3d → DrawGuarBar3d |
| 拷贝管道实体 | AecDbEwPipeBase::copyFrom(CoreLibrary/AecDbEwPipeBase.cpp:1605) |
| ↑ 调用 | CopyObject(*pBase,*this)(同文件 :1627) |
| CopyObject 模板 | inc/AecDbEntityCommon.h:331:AcDbDeepCloneFiler + src.dwgOutFields() + dest.dwgInFields() |
| 写字段 | AecDbEwPipeBase::dwgOutFields(CoreLibrary/AecDbEwPipeBase.cpp:1440) |
| 写截面 | AecDbEwPipeBase::RegionAcisoutDwgOutFields(同文件 :1275) |
即:楼梯画护栏时会 copyFrom 一份管道实体,而 copyFrom 用"整实体序列化"来拷字段,
于是截面被写成一个临时 SAT 文件再读回来。
关键点:那次序列化是多余的。 copyFrom 在 CopyObject 之后又把所有字段用内存级方式
拷了一遍:
| 字段 | CopyObject(序列化往返) |
copyFrom 后面手工做的 |
|---|---|---|
| 曲线 | 序列化 | m_pBaseCurve->clone() / copyFrom() |
| 截面 | acisOut → 临时文件 → acisIn | m_pRegion->clone() / copyFrom() |
| spec XData | 序列化 | m_mapSpecXData.CopyFrom() |
| 用户界面数据 | 序列化 | m_arUserInfs.append() |
| 标志位 / 图层 / 线型 / Breaks | 序列化 | 逐项赋值 |
WriteUserInterface 只写 m_arUserInfs,同样已被手工拷贝。
所以最小改动是:把 copyFrom 里那句 CopyObject(*pBase,*this) 去掉,
再补上源对象为 NULL 时清空目标的分支(目前这个"清空"是 CopyObject 顺带完成的)。
预计收益:省掉 1140 次 acisOut(2.38 s)及其对应的读回;
copyFrom 本身也从"全实体序列化"变成纯内存拷贝。
风险:copyFrom 是所有管系实体(管道/管件/井…)的公共拷贝路径,涉及复制粘贴、块内拷贝等,
必须实测验证(复制粘贴后截面、XData、GUID、图层线型是否都正确)。
诊断手法记录:
AEC_WRITEBACK_TRACE=1+SectionPerf::TraceCallStack()出栈, 再用tools/resolve_rva.ps1把"模块+RVA"解析成函数名。 单靠栈不解析符号很容易误判(本次就误判了两次)。
开图期回写的修复:把 clone 延迟到 ToJson()
按上面的结论,m_pipes 的唯一用途是 ToJson() 导出 __pipes__,
而截面参数(KEY_SECSHAPE / KEY_SIZE1 / KEY_SIZE2 / PIPE_THICK / KEY_SECCEN)
在建截面时已经写进 spec XData,完全可以按参数重建。
所以绘制期不必保留几何,等导出时再建。
改动:
AecDbEwPipeBase新增两个方法BuildSectionFromXData():按 XData 参数重建截面(圆形 / 矩形),自定义截面返回 NULLCloneParamsOnly():生成只带参数(XData / 放样线 / 实体属性 / GUID / Breaks)、 不含截面几何的副本
AecDbEwPipeBase::ToJson():GetSection()为空时回退到BuildSectionFromXData(), 保证__section__/__section2__仍在- 把所有
m_pipes.push_back((AecDbEwPipeBase*)pipe.clone())改成m_pipes.push_back(pipe.CloneParamsOnly()),共 30 处 / 6 个文件: ClimbStair(10)、ZLineStair(6)、BossHolder(4)、MRPLineStair(4)、Hanger(3)、Holder(3)
先只改楼梯两处时,回写从 1140 降到 960 —— 用 AEC_WRITEBACK_TRACE 再抓栈,
解析出剩余 960 全部来自 AecDbClimbStair::DrawLadderH3d(爬梯),
恰好对应 ClimbStair 的 10 处,于是把其余 20 处一并改掉。
实测(big1.dwg,AutoCAD 2020 / R230 Debug):
| 指标 | 初始 | 加缓存后 | 本次修复后 |
|---|---|---|---|
| 开图墙钟 | 108~113 s | 98 s | 93 s |
| 开图期回写 | 1140 次 / 2.38 s | 1140 次 | 0 |
EwPipe::RegionAcisIn |
3082 次 | 3082 次 | 1942 次 |
| 临时文件 | 6773 次 / 9.34 MB | 2694 次 / 3.34 MB | 1733 次 / 2.34 MB |
| 截面恢复总耗时 | 22.4 s | 9.87 s | 9.4 s |
待验证(本次没条件跑):JSON 导出。m_pipes 里现在是"无几何的参数副本",
ToJson() 靠 BuildSectionFromXData() 重建,需要实际触发一次导出,
确认 __pipes__ 里每条仍有 __section__ / __section2__ 且数值与改动前一致。
是否改用"内存读取"?——实测后决定不做
修完回写之后,开图期仍有 1733 次临时文件写入(2.34 MB),自然会想:
如果 acisIn 能从内存读,不就省掉 I/O 和杀软扫描了吗?
把"写文件"和"acisIn"分开计时后(RegIn.write / RegIn.acisIn / Str2Reg.write / Str2Reg.acisIn):
| 环节 | 次数 | 总耗时 | 平均 |
|---|---|---|---|
RegIn.write(实体路径,写+关闭,含杀软扫描) |
374 | 70.5 ms | 189 µs |
RegIn.acisIn |
374 | 1422 ms | 3.80 ms |
Str2Reg.write |
1359 | 267.7 ms | 197 µs |
Str2Reg.acisIn |
1359 | 5999 ms | 4.41 ms |
写文件合计只有 338 ms,占 9.6 s 恢复耗时的 3.5%,其余 7.4 s 是 acisIn 解析文本本身。
杀软扫描的时间已经包含在 write 的 190 µs 里(Defender 在关闭时扫),不是额外开销。
结论:即使完全省掉文件往返,最多省 0.30.5 s(开图的 0.4%)——不值得做。
而且 R26 只有文件名重载,真要"内存解析"只能改存盘格式
走 AcDbBody::acisIn 在 R19acdbModelerRestoreEntityFromDwgFiler(即此前评估后暂缓的 Phase 3)。
剩下的 7.4 s = 1733 次 × 4.3 ms 的纯解析,只能靠减少解析次数, 而 text 通道的 1560 个唯一文本是真实不同的截面,命中率已接近上限。 继续提速应转向 Parasolid 绘制那条线(早先 trace 里独占约 43%),不是截面恢复。
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 是年份
(20232026),与 ARX 的 1926 数值区间重叠,直接比较必然误判(AGENTS.md 红线)。
# 将来需要时:在 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. 未决问题
- D0:SAT 文本的唯一 hash 数到底是多少?(Phase 0 用计数器回答)
- 打开图纸时为什么在回写 SAT(
RegionToString3.07 %)?是copyFrom/深拷贝走了dwgOutFields, 还是别的原因——Phase 0 计数确认后再定是否单独立项。 - Phase 1 之后
AecDbX3dPart::EnsureCreated/Rebuild仍每次都重建(栈上 8.4 % 却不命中)—— 这是另一个独立问题,优先级可能高于截面缓存(见diagsession-cpu-analysis.md§4)。 AcDbBody::acisIn只有文件名版本;若将来想彻底去掉临时文件,只能走附录 B 的通道 (或自行接入 ACIS API,成本与授权需另评)。
8. 决策记录:为什么本轮不改存盘格式
考虑过的方案是"双写":既写现有 SAT 文本,又把几何用
acdbModelerSaveEntityToDwgFiler 写成一个附加块放到存盘数据末尾,新版本优先读新块,
老版本忽略尾部数据 → 兼顾"新版本更快"与"老版本可读"。
暂缓的理由:
- 收益不明确。无法确定
acdbModelerSave/RestoreEntityFromDwgFiler比SAT 文本 → 临时文件 → acisIn快多少。目前能确认的收益只有一条: 省掉临时文件,从而省掉杀软扫描时间(用户对此有切身体会)。 - 而这条收益在 Phase 1 之后变得很小。缓存落地后,落盘次数已从 O(实体数) 降到 O(唯一截面数)(几十次),杀软影响面同步缩小。
- 复杂度不低:不能升版本号(否则老版本整实体变 proxy)、读端要延后构造否则会白读一遍 SAT、 DWG 体积翻倍、还要过一整套老版本兼容验证矩阵(附录 B)。
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
// 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 再推广:
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 %,可作为"先测量、再缓存"的参考范例。