fix(perf): 公式映射导致确认/适应视图/拖拽排序/组复选框四处卡死根治

四次卡顿是**同一个根因**:公式信号(不保序)的幅值范围与包络带各写了一份
「逐点扫描整条信号」,且都没有缓存。2580 万采样时实测 1589ms + 1481ms,
而幅值范围指纹包含公式文本 / 信号顺序 / 可见性 / lane.autoRange
⇒ 确认编辑、适应视图、拖拽排序、组复选框每一个都会让它失效、每次都重付 3.1 秒。

修法:
- 新增 DataSet::FormulaTransformCache / FormulaTransformFor:一次扫描同时算出
  绘制值域与逐分组包络,缓存到数据层(std::deque,指针稳定)。
- TabConfig::ComputeLaneRange 的公式分支改为查该缓存(保留非法公式的逐点回退)。
- PlotView::AcquireFormulaEnvelope 改为直接复用数据层同一条目,
  删除 PlotView 自有的 FormulaEnvelopeEntry / m_formulaEnvelopes 与两个重复常量。
- FormulaEval 新增 FormulaProgram(编译一次、批量求值):always_inline、
  常量折叠(2/1.25 => 1.6)、幂次改写(x^2 => x*x、x^3、x^0.5 => sqrt)、
  按分组两级循环(去掉每点一次整数除法)。
- EvaluateFormula 复用 thread_local FormulaProgram(按内容缓存),两条路径语义一致。

实测(2580 万采样,纯控制台探针):确认公式 3070ms => 379ms;改 x^2 1.9s => 0.19s;
适应视图 / 拖拽排序 / 组复选框 1589ms => 0.0015ms(命中缓存)。
回归:缓存命中/失效/值域、14 组公式语义与逐位一致性、非法/空公式回退全部 PASS;
release/debug 零警告;Release TargetCheck.exe 890,880 B(< 1MB)。

未做:跨组移动共享时间轴仍深拷贝 197MB(55-66ms,值语义决定,风险大于收益);
首次 379ms 仍同步阻塞(后台化的复杂度暂不划算)。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-11-04 02:02:03 +08:00
1 parent 8b9c1e3de3
commit 266ab657be
9 files changed
+725 -259

No files matched your search

+85
View File
@@ -10198,3 +10198,88 @@ git push --force-with-lease origin dev
```
⚠️ 只能用 `--force-with-lease`,**绝不可** `--force`;并事先通知另一台设备 reset。
---
## 2026-11-04 · 第 32 轮(本机 dev):公式映射导致的四处「整个应用卡死」根治
> 用户报告(两次 `VERY_BIG_DATA.CSV`、全部信号合并到同一网格 = 10 条 × 2580 万采样):
> ① 给其中一条信号启用「公式映射」并**确认编辑**(`f(t)*2/1.25+14.2`)⇒ 整个应用卡住完全无法交互;
> ② 随后用「选区放大」多次放大时间轴,再点「适应视图」⇒ 卡住;
> ③ 拖拽信号名在组内排序、或拖到另一个组,**松开左键** ⇒ 卡住;
> ④ 点击组名前复选框取消/恢复显示 ⇒ 卡住。
### 1. ⭐⭐⭐ 四个症状是**同一个根因**:公式信号的幅值范围/包络各扫一遍整条信号
`SignalStyle::HasFormula()` 为真时,`DrawTransformOrderPreserving()` 返回 false
(公式不保证单调),于是两条完全独立的路径都退化成「逐点扫描整条信号」:
| 路径 | 旧成本(2580 万采样,实测) |
| --- | --- |
| `TabConfig::ComputeLaneRange`(幅值自适应) | **1589 ms** |
| `PlotView::AcquireFormulaEnvelope`(包络带首次建立) | **1481 ms** |
而**幅值范围指纹**(`PlotView::BuildLaneVisuals` 的 `rangeKey`)包含可见性、信号顺序、
`lane.autoRange`、公式文本等 ⇒ 用户报告的四个操作恰好**每一个**都会让它变化:
* 确认公式 → 公式文本变;
* 适应视图 → `autoRange` 被重置为 true;
* 拖拽排序 / 跨组移动 → 信号顺序变;
* 组复选框 → `IsVisible` 变。
⇒ 每次操作都要重付 **≈3.1 秒**,表现为「卡住完全无法交互」。
### 2. 修法:**把「值域」与「分组包络」合并成一次扫描并缓存到数据层**
* 新增 `DataSet::FormulaTransformCache` + `DataSet::FormulaTransformFor(signal, style)`:
* 键 = 信号对象 + `values.data()` + 采样数 + 公式文本 + `gain/absolute/mirrorVertical/offsetY`;
* 未命中时**只扫一次**,同时算出绘制值域 `low/high` 与逐分组极值 `minimums/maximums`;
* 命中后两条路径都是 O(1)(值域)/ O(分组数)(包络)。
* `TabConfig::ComputeLaneRange` 的公式分支改为查该缓存(保留逐点回退分支,仅公式非法时走)。
* `PlotView::AcquireFormulaEnvelope` 改为**直接返回数据层的同一条目**
(删除了 `PlotView::FormulaEnvelopeEntry` 与 `m_formulaEnvelopes`、以及
`kFormulaEnvelopeBuckets/kFormulaEnvelopeCacheLimit` 两个重复常量)。
* 容器用 `std::deque`:`emplace_back` 不移动已有元素 ⇒ 已返回的指针不会因新建其它条目而失效。
⚠️ **教训:同一份「昂贵且只依赖数据+样式」的派生量,全仓只能有一处缓存**。
本轮之前是"两条路径各建一份、各扫一遍",所以才出现 1.6s + 1.5s 的双重成本。
### 3. 让那一次扫描本身更快(1.6 s → 0.38 s,`f(t)^2` 0.9 s → 0.12 s)
`FormulaEval` 新增 `FormulaProgram`(编译一次、可批量求值),并做三件事:
1. **`always_inline` 求值**:Release 用 `-Oz`(体积优先)不会自动内联,每点一次
未内联调用(512B 栈帧)实测让一次全扫从 0.28s 涨到 0.85s。
2. **常量折叠**:`NUMBER a, NUMBER b, OP` ⇒ `NUMBER (a OP b)`。
`f(t)*2/1.25+14.2` 的 `2/1.25` 折叠成 `1.6`(7 条指令 ⇒ 5 条)。
3. **幂次改写**:`x^2` ⇒ `SQUARE`(`x*x`)、`x^3` ⇒ `CUBE`、`x^0.5` ⇒ `SQRT`。
`std::pow` 每点一次在 2580 万采样上约 500ms(`f(t)^2` 0.94s ⇒ 0.12s)。
4. **按分组两级循环**:旧写法每点 `index / stride`,2580 万次整数除法单独就要 400ms+。
⚠️ 常量折叠的**常量标记必须与"求值栈槽"一一对应**(不是与输出指令一一对应):
一元运算不改变栈深,若把它也计入标记数组,后续折叠会取到 `NEGATE` 当操作数(本轮初版写错过)。
一元运算在操作数是常量时**当场折叠**成 `NUMBER`,否则标记栈顶为非常量。
`EvaluateFormula`(逐点路径:绘制/统计/派生烘焙)改为复用一个 `thread_local FormulaProgram`
(按**内容**缓存,SSO 下不能只比指针),保证两条路径语义完全一致。
### 4. 实测(2580 万采样,纯控制台探针,无窗口)
| 场景 | 修复前 | 修复后 |
| --- | --- | --- |
| 确认公式(首次全扫) | 1589 + 1481 = **3070 ms** | **379 ms** |
| 改公式为 `f(t)^2` | ≈940 + ≈900 ms | **195 ms** |
| 适应视图 / 拖拽排序 / 组复选框 | 每次 **1589 ms** | **0.0015 ms**(命中缓存) |
验证:`Temp/perf_verify.exe` **PASS**(缓存命中/失效/值域正确性 + 14 组公式语义
`FormulaProgram` 与 `EvaluateFormula` 逐位一致 + 非法/空公式回退 + `2e` 仍被拒绝);
release/debug **零警告**;Release `TargetCheck.exe` **890,880 B**(< 1 MB)。
### 5. 未做的(诚实记录)
* **跨组移动共享时间轴**仍会深拷贝一条 197MB 的 `time` 数组(实测 55–66ms)。
这是 `SignalGroup::axes` 的值语义决定的,改成共享需要动 `TimeAxis::time` 的类型,
风险远大于收益(66ms 不属于"无法交互")。
* 首次 379ms 仍是同步阻塞。若要彻底无感需把公式缓存改成后台线程计算 +
未就绪时回退到保序端点映射;当前收益不足以承担该复杂度。