Files
envi-code/pipe-section-region-performance-plan.md
T
company-desktop 56c89c5fa4 diag: 拆分"写临时文件"与"acisIn"计时,判定内存读取不值得做
修完回写后开图期仍有 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 才生效),不改任何业务行为。
2026-10-28 22:09:48 +08:00

34 KiB
Raw Blame History

管道截面(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 文本 + 一个文件名。恢复流程:

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 次(此前基于采样的估算只有 5501000,低估了 510 倍)
恢复总耗时 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):

  1. 命中率只有 54 %。2597 种唯一文本对 5641 次恢复,说明同 DN 的 SAT 文本并非完全一致。 需确认差异是"真的不同几何"(不同管径/壁厚/异形)还是"带了绝对坐标等无关内容"。 前者属性 key 也救不了;后者归一化后命中率有望到 80 %+。
  2. StringToRegion 单次成本是 RegionAcisIn 的 2 倍(多一步 fixACISFormat 改写文本)。
  3. 开图期间回写 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,就会静默复用错误几何。若要做,用两级:
    L1 内容 hash → region          (可证明正确,当前实测 54%)
    L2 属性 key  → textHash        (加速索引;命中后仍比对 textHash,不一致按 miss 处理)
    
    对几 KB 文本做一次 hash 只需几微秒,这个校验几乎免费,但把"属性漏项"从静默错误 降级为缓存不命中。属性 key 还需随样式表(DN→尺寸)版本变化失效,内容 hash 不需要。 落地前应先加一路"属性 key 命中率"计数,与内容 hash 并行比较再决定是否值得引入。
  • 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 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"这个保守设计没有代价。

仍未解决

  1. EwPipe::RegionAcisout 仍是 1140 次 / 2.38 s——已查明原因,见下节。
  2. Fitting::GetSection 在开图阶段只有 232 次调用(0.13 s),收益很小; 它的价值可能在绘制/平移时,需要另一次针对"打开后操作"的测量才能确认。
  3. 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,完全可以按参数重建。 所以绘制期不必保留几何,等导出时再建。

改动:

  1. AecDbEwPipeBase 新增两个方法
    • BuildSectionFromXData():按 XData 参数重建截面(圆形 / 矩形),自定义截面返回 NULL
    • CloneParamsOnly():生成只带参数(XData / 放样线 / 实体属性 / GUID / Breaks)、 不含截面几何的副本
  2. AecDbEwPipeBase::ToJson():GetSection() 为空时回退到 BuildSectionFromXData(), 保证 __section__ / __section2__ 仍在
  3. 把所有 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%)——不值得做。 而且 AcDbBody::acisIn 在 R19R26 只有文件名重载,真要"内存解析"只能改存盘格式 走 acdbModelerRestoreEntityFromDwgFiler(即此前评估后暂缓的 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. 未决问题

  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

// 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 %,可作为"先测量、再缓存"的参考范例。