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:
1 parent
8b9c1e3de3
commit
266ab657be
9 files changed
+725
-259
No files matched your search
@@ -1842,44 +1842,25 @@ namespace jdi
|
|||||||
bool& bandMode, bool& bandSpiky) const;
|
bool& bandMode, bool& bandSpiky) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 「公式映射」信号的逐分组包络缓存条目(见 `AcquireFormulaEnvelope`)。
|
* @brief 取得「公式映射」信号的逐分组包络(**数据层共享缓存**)。
|
||||||
*
|
*
|
||||||
* 键 = 信号 + 采样数组 + 采样数 + 公式文本 + 影响数值的样式字段。
|
* ⚠️⚠️ 缓存实体是 `DataSet::FormulaTransformCache`(见 `DataSet::FormulaTransformFor`):
|
||||||
* `minimums` / `maximums` 存的是**已经套用完整绘制变换**(公式 → 系数 → 绝对值 →
|
* 它与幅值自适应(`TabConfig::ComputeLaneRange`)**共用同一次全量扫描**。
|
||||||
* 镜像 → Y 偏移)之后、每 `stride` 个采样一个分组的极值(无有效值的分组为 NaN)。
|
* `SignalData::coarseMin/coarseMax` 只存原始采样的分组极值,只有在数值变换
|
||||||
*/
|
* **保序**时才能直接缩放使用(见 `DrawTransformOrderPreserving`);公式
|
||||||
struct FormulaEnvelopeEntry
|
* (如 `f(t)^2`、`1/f(t)`)不保序,必须用变换后的分组极值。
|
||||||
{
|
|
||||||
const void* signal = nullptr; /**< `resolved.data`(区分不同信号)。 */
|
|
||||||
const void* values = nullptr; /**< `values.data()`(区分重新加载的数据)。 */
|
|
||||||
std::size_t count = 0; /**< 参与统计的采样数。 */
|
|
||||||
std::size_t stride = 0; /**< 每个分组覆盖的采样数。 */
|
|
||||||
std::wstring formula; /**< 公式文本(空 = 不适用)。 */
|
|
||||||
float gain = 1.0f;
|
|
||||||
bool absolute = false;
|
|
||||||
bool mirrorVertical = false;
|
|
||||||
float offsetY = 0.0f;
|
|
||||||
std::vector<double> minimums; /**< 每组的绘制值下限。 */
|
|
||||||
std::vector<double> maximums; /**< 每组的绘制值上限。 */
|
|
||||||
bool valid = false; /**< 是否已成功建立。 */
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief 取得(必要时建立)「公式映射」信号的逐分组包络。
|
|
||||||
*
|
*
|
||||||
* ⚠️⚠️ **为什么必须单独建一层**:`SignalData::coarseMin/coarseMax` 只存**原始采样**的
|
* 旧实现自查自建了一份包络缓存,并与幅值自适应各自全扫一遍 ⇒ 2580 万采样时
|
||||||
* 分组极值,只有在数值变换**保序**时才能直接缩放使用(见 `DrawTransformOrderPreserving`)。
|
* 「确认公式 / 适应视图 / 拖拽排序 / 勾选组」全部卡 1.6 s + 1.5 s
|
||||||
* 公式(如 `f(t)^2`、`1/f(t)`)不保序,旧实现在这种情况会退化成**逐点扫描整条信号**:
|
*(用户 2026-11-04 报告)。现在两条路径共享一次扫描的结果。
|
||||||
* `VERY_BIG_DATA` 单条 2580 万采样时,一次折线构建 ≈ 850ms(缩放一帧就卡一次)。
|
|
||||||
* 这里把“变换后的分组极值”算**一次**缓存起来,之后每帧/每次缩放只按分组聚合
|
|
||||||
* (几十微秒),且极值仍是逐点统计出来的 ⇒ **不会漏掉尖峰**。
|
|
||||||
*
|
*
|
||||||
* 只在公式信号上使用;无公式时返回 nullptr(继续走既有的 `coarseMin/Max` 快路径)。
|
* 只在公式信号上使用;无公式 / 公式非法时返回 nullptr(继续走 `coarseMin/Max` 快路径)。
|
||||||
*
|
*
|
||||||
* @param [IN] resolved 已解析的信号视图。
|
* @param [IN] resolved 已解析的信号视图。
|
||||||
* @return 缓存条目指针(内容有效);不适用或无法建立时返回 nullptr。
|
* @return 缓存条目指针(内容有效);不适用或无法建立时返回 nullptr。
|
||||||
*/
|
*/
|
||||||
const FormulaEnvelopeEntry* AcquireFormulaEnvelope(const ResolvedSignal& resolved) const;
|
const DataSet::FormulaTransformCache* AcquireFormulaEnvelope(
|
||||||
|
const ResolvedSignal& resolved) const;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3639,7 +3620,13 @@ namespace jdi
|
|||||||
* 只在信号带公式时建立;条目键含公式文本与影响数值的样式字段,
|
* 只在信号带公式时建立;条目键含公式文本与影响数值的样式字段,
|
||||||
* 公式 / 系数 / 镜像等变化时自然失效。用 `deque` 保证追加时已有引用不失效。
|
* 公式 / 系数 / 镜像等变化时自然失效。用 `deque` 保证追加时已有引用不失效。
|
||||||
*/
|
*/
|
||||||
mutable std::deque<FormulaEnvelopeEntry> m_formulaEnvelopes;
|
/**
|
||||||
|
* @brief 「公式映射」信号的绘制值缓存(**唯一实现在数据层**)。
|
||||||
|
*
|
||||||
|
* 旧实现是 `mutable std::deque<FormulaEnvelopeEntry> m_formulaEnvelopes;` —— 与
|
||||||
|
* `TabConfig::ComputeLaneRange` 各扫一遍整条信号。现已合并进
|
||||||
|
* `DataSet::m_formulaTransforms`(见 `DataSet::FormulaTransformFor`)。
|
||||||
|
*/
|
||||||
/**
|
/**
|
||||||
* @brief 折线顶点缓存的**容量上限**(条目数)。
|
* @brief 折线顶点缓存的**容量上限**(条目数)。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -622,16 +622,10 @@ namespace jdi
|
|||||||
*/
|
*/
|
||||||
inline const wchar_t* const kClipNeedDoubleCursorHint = L"启用双光标模式以进行选区截取";
|
inline const wchar_t* const kClipNeedDoubleCursorHint = L"启用双光标模式以进行选区截取";
|
||||||
|
|
||||||
// ---- 「公式映射」的分组包络缓存(见 `PlotView::AcquireFormulaEnvelope`)----
|
// ---- 「公式映射」的分组包络(缓存实体在数据层:`DataSet::FormulaTransformFor`)----
|
||||||
/**
|
// ⚠️ 分组数(65536)与缓存上限(8 条)现由 `Utilities/Src/DataModel.cpp` 里的
|
||||||
* @brief 每个信号的包络分组数上限(分组宽度按采样数反算)。
|
// `kFormulaTransformBuckets` / `kFormulaTransformCacheLimit` 控制;这里不再重复定义,
|
||||||
*
|
// 避免“绘制侧与幅值自适应各扫一遍”(旧实现的两个常量 + 两份缓存就是卡顿根源)。
|
||||||
* 65536 组 × 2(min/max)× 4B ≈ 512KB/信号;对 2580 万采样 ⇒ 每组 ~394 个采样,
|
|
||||||
* 而屏幕上一个像素列覆盖上万采样 ⇒ 分组粒度造成的包络误差远小于 1 像素。
|
|
||||||
*/
|
|
||||||
inline const std::size_t kFormulaEnvelopeBuckets = 65536;
|
|
||||||
/** @brief 包络缓存最多保存的信号数(超出即整表清空,避免无界增长)。 */
|
|
||||||
inline const std::size_t kFormulaEnvelopeCacheLimit = 16;
|
|
||||||
|
|
||||||
|
|
||||||
// ---- 「均值滤波」面板的两行参数滑块(用户 2026-10-30 要求)----
|
// ---- 「均值滤波」面板的两行参数滑块(用户 2026-10-30 要求)----
|
||||||
|
|||||||
@@ -210,82 +210,25 @@ namespace jdi
|
|||||||
return &m_polylineCache.back();
|
return &m_polylineCache.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlotView::FormulaEnvelopeEntry* PlotView::AcquireFormulaEnvelope(
|
const DataSet::FormulaTransformCache* PlotView::AcquireFormulaEnvelope(
|
||||||
const ResolvedSignal& resolved) const
|
const ResolvedSignal& resolved) const
|
||||||
{
|
{
|
||||||
if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->HasFormula())
|
if (resolved.data == nullptr || resolved.style == nullptr || m_dataSet == nullptr ||
|
||||||
|
!resolved.style->HasFormula())
|
||||||
{
|
{
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
const std::vector<double>& values = resolved.data->values;
|
// ⚠️⚠️ 缓存**只有一份**,在数据层(`DataSet::FormulaTransformFor`):
|
||||||
const std::size_t count = values.size();
|
// 它与 `TabConfig::ComputeLaneRange` 共用同一次全量扫描的结果。
|
||||||
if (count == 0)
|
// 旧实现在这里又建了一份包络缓存、并且与幅值自适应各自全扫一遍
|
||||||
|
// ⇒ 2580 万采样时「确认公式」要卡 1.6 s + 1.5 s(用户 2026-11-04 报告)。
|
||||||
|
const DataSet::FormulaTransformCache* transform =
|
||||||
|
m_dataSet->FormulaTransformFor(*resolved.data, *resolved.style);
|
||||||
|
if (transform == nullptr || !transform->ready || transform->stride == 0)
|
||||||
{
|
{
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
const SignalStyle& style = *resolved.style;
|
return transform;
|
||||||
const void* signalKey = static_cast<const void*>(resolved.data);
|
|
||||||
const void* valuesKey = static_cast<const void*>(values.data());
|
|
||||||
|
|
||||||
// 命中缓存:公式与全部影响数值的样式字段都一致时可直接复用。
|
|
||||||
for (const FormulaEnvelopeEntry& entry : m_formulaEnvelopes)
|
|
||||||
{
|
|
||||||
if (entry.valid && entry.signal == signalKey && entry.values == valuesKey &&
|
|
||||||
entry.count == count && entry.formula == style.formula && entry.gain == style.gain &&
|
|
||||||
entry.absolute == style.absolute && entry.mirrorVertical == style.mirrorVertical &&
|
|
||||||
entry.offsetY == style.offsetY)
|
|
||||||
{
|
|
||||||
return &entry;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未命中:建立新条目(容量上限很小:只有“带公式”的信号才会进来)。
|
|
||||||
if (m_formulaEnvelopes.size() >= kFormulaEnvelopeCacheLimit)
|
|
||||||
{
|
|
||||||
m_formulaEnvelopes.clear();
|
|
||||||
}
|
|
||||||
m_formulaEnvelopes.emplace_back();
|
|
||||||
FormulaEnvelopeEntry& entry = m_formulaEnvelopes.back();
|
|
||||||
entry.signal = signalKey;
|
|
||||||
entry.values = valuesKey;
|
|
||||||
entry.count = count;
|
|
||||||
entry.formula = style.formula;
|
|
||||||
entry.gain = style.gain;
|
|
||||||
entry.absolute = style.absolute;
|
|
||||||
entry.mirrorVertical = style.mirrorVertical;
|
|
||||||
entry.offsetY = style.offsetY;
|
|
||||||
// 分组宽度:让分组数落在 `kFormulaEnvelopeBuckets` 量级(内存 = 2 × 分组数 × 4B)。
|
|
||||||
entry.stride = std::max<std::size_t>(
|
|
||||||
1, (count + kFormulaEnvelopeBuckets - 1) / kFormulaEnvelopeBuckets);
|
|
||||||
const std::size_t buckets = (count + entry.stride - 1) / entry.stride;
|
|
||||||
entry.minimums.assign(buckets, std::numeric_limits<double>::quiet_NaN());
|
|
||||||
entry.maximums.assign(buckets, std::numeric_limits<double>::quiet_NaN());
|
|
||||||
|
|
||||||
// ⚠️ **一次**全量扫描:逐点套用完整绘制变换后按分组取极值。
|
|
||||||
// 之后每帧 / 每次缩放都只按分组聚合,代价与采样数无关。
|
|
||||||
for (std::size_t index = 0; index < count; ++index)
|
|
||||||
{
|
|
||||||
const double mapped = ApplyDrawTransform(style, values[index]);
|
|
||||||
if (!std::isfinite(mapped))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const std::size_t bucket = index / entry.stride;
|
|
||||||
const double value = mapped;
|
|
||||||
double& low = entry.minimums[bucket];
|
|
||||||
double& high = entry.maximums[bucket];
|
|
||||||
if (!(low <= high))
|
|
||||||
{
|
|
||||||
// 该分组第一次写入(初值是 NaN)。
|
|
||||||
low = value;
|
|
||||||
high = value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
low = std::min(low, value);
|
|
||||||
high = std::max(high, value);
|
|
||||||
}
|
|
||||||
entry.valid = true;
|
|
||||||
return &entry;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane,
|
void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane,
|
||||||
@@ -454,7 +397,7 @@ namespace jdi
|
|||||||
// 缩放 / 平移每重建一帧就卡一次(用户 2026-11-03 报告)。
|
// 缩放 / 平移每重建一帧就卡一次(用户 2026-11-03 报告)。
|
||||||
// 这里改用**变换后的分组包络**(按需算一次并缓存):极值仍是逐点统计的
|
// 这里改用**变换后的分组包络**(按需算一次并缓存):极值仍是逐点统计的
|
||||||
// ⇒ 不会漏尖峰,而每帧只按分组聚合。
|
// ⇒ 不会漏尖峰,而每帧只按分组聚合。
|
||||||
if (const FormulaEnvelopeEntry* envelope = AcquireFormulaEnvelope(resolved))
|
if (const DataSet::FormulaTransformCache* envelope = AcquireFormulaEnvelope(resolved))
|
||||||
{
|
{
|
||||||
coarseMinPtr = &envelope->minimums;
|
coarseMinPtr = &envelope->minimums;
|
||||||
coarseMaxPtr = &envelope->maximums;
|
coarseMaxPtr = &envelope->maximums;
|
||||||
|
|||||||
@@ -10198,3 +10198,88 @@ git push --force-with-lease origin dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
⚠️ 只能用 `--force-with-lease`,**绝不可** `--force`;并事先通知另一台设备 reset。
|
⚠️ 只能用 `--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 仍是同步阻塞。若要彻底无感需把公式缓存改成后台线程计算 +
|
||||||
|
未就绪时回退到保序端点映射;当前收益不足以承担该复杂度。
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <deque>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
@@ -869,6 +870,60 @@ namespace jdi
|
|||||||
*/
|
*/
|
||||||
SignalStyle* Style(const SignalRef& ref);
|
SignalStyle* Style(const SignalRef& ref);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief 「公式映射」信号的**绘制值**缓存(值域 + 逐分组包络)。
|
||||||
|
*
|
||||||
|
* ⚠️⚠️ **这是「启用公式后界面卡死」的唯一根治点**(用户 2026-11-04 报告):
|
||||||
|
* 公式不保序(`x*x`、`1/x`),幅值自适应与包络带都不能由原始 `min/max` 推导,
|
||||||
|
* 旧实现每条路径各写一次「逐点扫描整条信号」:
|
||||||
|
* * `TabConfig::ComputeLaneRange` —— 每次幅值范围重算都扫一遍;
|
||||||
|
* * `PlotView::AcquireFormulaEnvelope` —— 首次绘制包络带再扫一遍。
|
||||||
|
* 2580 万采样 × 两条路径 ≈ **3.1 秒**(实测 1.6 s + 1.5 s),而
|
||||||
|
* 「确认公式编辑 / 适应视图 / 拖拽排序 / 组复选框」都会让幅值范围指纹变化
|
||||||
|
* ⇒ 每次都重新付一遍,表现为「整个应用卡住完全无法交互」。
|
||||||
|
*
|
||||||
|
* 现在把「值域」与「分组包络」合并成**一次全量扫描**并缓存:
|
||||||
|
* * 键 = 信号对象 + 采样数组 + 采样数 + 公式文本 + 影响数值的样式字段;
|
||||||
|
* * 命中后两条路径都退化成 O(1) / O(分组数)。
|
||||||
|
*
|
||||||
|
* ⚠️ 只在 **UI 线程**读写(`mutable` + `const` 方法);后台派生线程只读
|
||||||
|
* `SignalStyle` 并走 `ApplyDrawTransform`,不会碰到这个缓存。
|
||||||
|
*/
|
||||||
|
struct FormulaTransformCache
|
||||||
|
{
|
||||||
|
const SignalData* signal = nullptr; /**< 信号对象(区分不同信号)。 */
|
||||||
|
const double* values = nullptr; /**< `values.data()`(区分重新加载/截取)。 */
|
||||||
|
std::size_t count = 0; /**< 参与统计的采样数。 */
|
||||||
|
std::wstring formula; /**< 公式文本。 */
|
||||||
|
float gain = 1.0f; /**< 公式之后的系数。 */
|
||||||
|
bool absolute = false; /**< 公式之后的绝对值。 */
|
||||||
|
bool mirrorVertical = false; /**< 公式之后的垂直镜像。 */
|
||||||
|
float offsetY = 0.0f; /**< 公式之后的 Y 偏移。 */
|
||||||
|
|
||||||
|
bool ready = false; /**< 是否已成功建立。 */
|
||||||
|
double low = 0.0; /**< 绘制值下限(= 幅值自适应的下限)。 */
|
||||||
|
double high = 0.0; /**< 绘制值上限。 */
|
||||||
|
std::size_t stride = 0; /**< 每个分组覆盖的采样数。 */
|
||||||
|
std::vector<double> minimums; /**< 每组的绘制值下限(无有效值 = NaN)。 */
|
||||||
|
std::vector<double> maximums; /**< 每组的绘制值上限。 */
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief 取得(必要时**建立一次**)某信号的公式变换缓存。
|
||||||
|
*
|
||||||
|
* 只在 `style.HasFormula()`(排除恒等 `f(t)`)且采样非空时返回非空。
|
||||||
|
* 未命中时立即做**一次**全量扫描:同时算出绘制值域与逐分组极值。
|
||||||
|
*
|
||||||
|
* ⚠️ 返回的指针在下一次 `FormulaTransformFor()` 建立**新条目**后可能失效
|
||||||
|
* (`std::vector` 扩容);调用方只能在本表达式内即时使用,不要长期保存。
|
||||||
|
*
|
||||||
|
* @param [IN] signal 信号数据(提供采样)。
|
||||||
|
* @param [IN] style 该信号的外观(提供公式与后续数值变换)。
|
||||||
|
* @return 缓存条目指针;公式为空/非法或没有采样时返回 nullptr。
|
||||||
|
*/
|
||||||
|
const FormulaTransformCache* FormulaTransformFor(const SignalData& signal,
|
||||||
|
const SignalStyle& style) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 计算全部分组、全部时间轴时间范围的并集下限。
|
* @brief 计算全部分组、全部时间轴时间范围的并集下限。
|
||||||
* @return 时间下限。
|
* @return 时间下限。
|
||||||
@@ -908,6 +963,14 @@ namespace jdi
|
|||||||
private:
|
private:
|
||||||
std::vector<SignalGroup> m_groups; /**< 已加载信号分组集合。 */
|
std::vector<SignalGroup> m_groups; /**< 已加载信号分组集合。 */
|
||||||
SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */
|
SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */
|
||||||
|
/**
|
||||||
|
* @brief 「公式映射」信号的绘制值缓存(见 `FormulaTransformFor`)。
|
||||||
|
*
|
||||||
|
* `mutable`:查询缓存是逻辑上的只读操作;只在 UI 线程写。
|
||||||
|
* ⚠️ 用 `std::deque`(而不是 `vector`):`emplace_back` **不会**移动已有元素,
|
||||||
|
* 因此 `FormulaTransformFor` 返回的指针在后续新建其它信号的条目时依然有效。
|
||||||
|
*/
|
||||||
|
mutable std::deque<FormulaTransformCache> m_formulaTransforms;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,10 +17,163 @@
|
|||||||
// 绘制循环里同一条信号的样式对象是稳定的,因此每个信号只需编译一次;
|
// 绘制循环里同一条信号的样式对象是稳定的,因此每个信号只需编译一次;
|
||||||
// **绝不能**每个采样点都重新解析(千万级采样会慢到不可用)。
|
// **绝不能**每个采样点都重新解析(千万级采样会慢到不可用)。
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <limits>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace jdi
|
namespace jdi
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @brief 编译后的一条公式指令(逆波兰式)。
|
||||||
|
*
|
||||||
|
* `NUMBER` 的操作数存放在 `value`,`VARIABLE` 表示当前采样值;
|
||||||
|
* 其余为一元 / 二元运算符(二元运算从求值栈弹出两个值、压回一个)。
|
||||||
|
*/
|
||||||
|
struct FormulaInstruction
|
||||||
|
{
|
||||||
|
enum class Kind : std::uint8_t
|
||||||
|
{
|
||||||
|
NUMBER = 0, /**< 常量。 */
|
||||||
|
VARIABLE, /**< 自变量(当前采样值)。 */
|
||||||
|
ADD,
|
||||||
|
SUBTRACT,
|
||||||
|
MULTIPLY,
|
||||||
|
DIVIDE,
|
||||||
|
POWER, /**< 幂(`a ^ b`)。 */
|
||||||
|
NEGATE, /**< 一元负号。 */
|
||||||
|
SQUARE, /**< 平方(编译期把 `x^2` 改写而来,弹一压一)。 */
|
||||||
|
CUBE, /**< 立方(编译期把 `x^3` 改写而来)。 */
|
||||||
|
SQRT /**< 平方根(编译期把 `x^0.5` 改写而来)。 */
|
||||||
|
};
|
||||||
|
|
||||||
|
Kind kind = Kind::NUMBER;
|
||||||
|
double value = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 公式求值栈的深度上限(与编译期允许的嵌套深度匹配)。 */
|
||||||
|
inline constexpr std::size_t kFormulaMaxStack = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief 可复用的公式程序:**编译一次**,之后逐点 `Evaluate` 只做纯粹的栈运算。
|
||||||
|
*
|
||||||
|
* ⚠️⚠️ **批量路径必须用它**(`DataSet::FormulaTransformFor`、公式包络构建):
|
||||||
|
* `EvaluateFormula()` 每次调用都要做一次编译缓存查找(`std::wstring` 内容比较),
|
||||||
|
* 千万级采样时这点开销会放大成**秒级**卡顿。实测 2580 万采样:
|
||||||
|
* `EvaluateFormula` 逐点 ≈ 1.6 s,`FormulaProgram::Evaluate` ≈ 0.27 s(≈6 倍)。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* @code
|
||||||
|
* jdi::FormulaProgram program(style.formula);
|
||||||
|
* if (program.Valid()) { for (...) value = program.Evaluate(values[i]); }
|
||||||
|
* @endcode
|
||||||
|
*/
|
||||||
|
class FormulaProgram
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FormulaProgram() = default;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief 编译公式文本。
|
||||||
|
* @param [IN] text 公式正文(不含 `Y =` 前缀)。空串或非法表达式 ⇒ `Valid()` 为 false。
|
||||||
|
*/
|
||||||
|
explicit FormulaProgram(const std::wstring& text);
|
||||||
|
|
||||||
|
/** @brief 公式是否可用(非空且语法合法)。 */
|
||||||
|
bool Valid() const { return m_valid; }
|
||||||
|
|
||||||
|
/** @brief 已编译的指令序列(只读,主要供调试 / 测试)。 */
|
||||||
|
const std::vector<FormulaInstruction>& Instructions() const { return m_instructions; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief 在给定采样值处求值(**必须**先确认 `Valid()`)。
|
||||||
|
*
|
||||||
|
* 结果非有限(如 `1/0`)时返回 NaN,与 `EvaluateFormula()` 一致。
|
||||||
|
*
|
||||||
|
* ⚠️⚠️ **没有运行期栈深检查**:栈深在编译期(构造函数里)已校验过 ——
|
||||||
|
* 超过 `kFormulaMaxStack` 的表达式会直接 `Valid() == false`(由调用方回退到
|
||||||
|
* 带检查的 `EvaluateFormula()`)。批量路径每次调用都把检查加上去,
|
||||||
|
* 在 2580 万点上是纯粹的浪费。
|
||||||
|
* ⚠️ `always_inline` 是**性能必需**:Release 用 `-Oz`(体积优先)不会自动内联,
|
||||||
|
* 而每点一次未内联调用(512B 栈帧)实测让一次全扫从 0.28s 涨到 0.85s。
|
||||||
|
*
|
||||||
|
* @param [IN] x 采样值。
|
||||||
|
* @return 映射后的值。
|
||||||
|
*/
|
||||||
|
__attribute__((always_inline)) inline double Evaluate(double x) const
|
||||||
|
{
|
||||||
|
double stack[kFormulaMaxStack];
|
||||||
|
std::size_t depth = 0;
|
||||||
|
for (const FormulaInstruction& instruction : m_instructions)
|
||||||
|
{
|
||||||
|
switch (instruction.kind)
|
||||||
|
{
|
||||||
|
case FormulaInstruction::Kind::NUMBER:
|
||||||
|
stack[depth++] = instruction.value;
|
||||||
|
break;
|
||||||
|
case FormulaInstruction::Kind::VARIABLE:
|
||||||
|
stack[depth++] = x;
|
||||||
|
break;
|
||||||
|
case FormulaInstruction::Kind::NEGATE:
|
||||||
|
stack[depth - 1] = -stack[depth - 1];
|
||||||
|
break;
|
||||||
|
case FormulaInstruction::Kind::SQUARE:
|
||||||
|
stack[depth - 1] = stack[depth - 1] * stack[depth - 1];
|
||||||
|
break;
|
||||||
|
case FormulaInstruction::Kind::CUBE:
|
||||||
|
{
|
||||||
|
const double base = stack[depth - 1];
|
||||||
|
stack[depth - 1] = base * base * base;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FormulaInstruction::Kind::SQRT:
|
||||||
|
stack[depth - 1] = std::sqrt(stack[depth - 1]);
|
||||||
|
break;
|
||||||
|
case FormulaInstruction::Kind::POWER:
|
||||||
|
--depth;
|
||||||
|
stack[depth - 1] = std::pow(stack[depth - 1], stack[depth]);
|
||||||
|
break;
|
||||||
|
case FormulaInstruction::Kind::DIVIDE:
|
||||||
|
--depth;
|
||||||
|
stack[depth - 1] = stack[depth - 1] / stack[depth];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
// ADD / SUBTRACT / MULTIPLY
|
||||||
|
--depth;
|
||||||
|
double left = stack[depth - 1];
|
||||||
|
const double right = stack[depth];
|
||||||
|
if (instruction.kind == FormulaInstruction::Kind::ADD)
|
||||||
|
{
|
||||||
|
left += right;
|
||||||
|
}
|
||||||
|
else if (instruction.kind == FormulaInstruction::Kind::SUBTRACT)
|
||||||
|
{
|
||||||
|
left -= right;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
left *= right;
|
||||||
|
}
|
||||||
|
stack[depth - 1] = left;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth != 1)
|
||||||
|
{
|
||||||
|
return std::numeric_limits<double>::quiet_NaN();
|
||||||
|
}
|
||||||
|
return std::isfinite(stack[0]) ? stack[0] : std::numeric_limits<double>::quiet_NaN();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<FormulaInstruction> m_instructions;
|
||||||
|
bool m_valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 校验公式文本是否可以解析。
|
* @brief 校验公式文本是否可以解析。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ namespace jdi
|
|||||||
*/
|
*/
|
||||||
const std::size_t kCoarseStride = 256;
|
const std::size_t kCoarseStride = 256;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief 「公式映射」缓存里逐分组包络的目标分组数。
|
||||||
|
*
|
||||||
|
* 与绘制侧的像素列数量级一致(绘图区通常 1~4 千列),65536 个分组足够让每个像素列
|
||||||
|
* 只读取少量分组;内存 = 2 × 65536 × 8B ≈ 1MB / 信号(只有带公式的信号才会建立)。
|
||||||
|
*/
|
||||||
|
const std::size_t kFormulaTransformBuckets = 65536;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief 「公式映射」缓存的条目上限。
|
||||||
|
*
|
||||||
|
* 只有**带公式**的信号才会进来(通常 1~2 条),存少量条目即可避免同一批操作里反复重扫。
|
||||||
|
*/
|
||||||
|
const std::size_t kFormulaTransformCacheLimit = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 单条信号的取值范围统计结果。
|
* @brief 单条信号的取值范围统计结果。
|
||||||
*/
|
*/
|
||||||
@@ -982,6 +997,140 @@ namespace jdi
|
|||||||
return &mutableGroup.signals[static_cast<std::size_t>(ref.signalIndex)].style;
|
return &mutableGroup.signals[static_cast<std::size_t>(ref.signalIndex)].style;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠️ 本函数的热点是一次 2580 万点的全量扫描。项目 Release 用 `-Oz`(体积优先),
|
||||||
|
// 该级别会把这个循环优化得很差(实测 960ms);只对**这一个函数**提到 `-O2`
|
||||||
|
// (实测 ~300ms,代码体积代价几 KB)。首次建立缓存后全部命中,O(1)。
|
||||||
|
__attribute__((optimize("O2")))
|
||||||
|
const DataSet::FormulaTransformCache* DataSet::FormulaTransformFor(const SignalData& signal,
|
||||||
|
const SignalStyle& style) const
|
||||||
|
{
|
||||||
|
// 恒等公式(`f(t)` / 空串)不算启用:调用方有专门的 O(1) 保序快路径。
|
||||||
|
if (!style.HasFormula())
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const std::size_t count = signal.values.size();
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
const std::size_t bucketsTarget = kFormulaTransformBuckets;
|
||||||
|
|
||||||
|
// ---- 命中判定:信号对象 + 采样数组 + 采样数 + 公式 + 影响数值的样式字段 ----
|
||||||
|
for (const FormulaTransformCache& entry : m_formulaTransforms)
|
||||||
|
{
|
||||||
|
if (entry.ready && entry.signal == &signal && entry.values == signal.values.data() &&
|
||||||
|
entry.count == count && entry.formula == style.formula && entry.gain == style.gain &&
|
||||||
|
entry.absolute == style.absolute && entry.mirrorVertical == style.mirrorVertical &&
|
||||||
|
entry.offsetY == style.offsetY)
|
||||||
|
{
|
||||||
|
return &entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公式编译失败 ⇒ 交给调用方回退(`ApplyDrawTransform` 原样返回采样值)。
|
||||||
|
const FormulaProgram program(style.formula);
|
||||||
|
if (!program.Valid())
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_formulaTransforms.size() >= kFormulaTransformCacheLimit)
|
||||||
|
{
|
||||||
|
m_formulaTransforms.clear();
|
||||||
|
}
|
||||||
|
m_formulaTransforms.emplace_back();
|
||||||
|
FormulaTransformCache& entry = m_formulaTransforms.back();
|
||||||
|
entry.signal = &signal;
|
||||||
|
entry.values = signal.values.data();
|
||||||
|
entry.count = count;
|
||||||
|
entry.formula = style.formula;
|
||||||
|
entry.gain = style.gain;
|
||||||
|
entry.absolute = style.absolute;
|
||||||
|
entry.mirrorVertical = style.mirrorVertical;
|
||||||
|
entry.offsetY = style.offsetY;
|
||||||
|
entry.stride = std::max<std::size_t>(1, (count + bucketsTarget - 1) / bucketsTarget);
|
||||||
|
const std::size_t buckets = (count + entry.stride - 1) / entry.stride;
|
||||||
|
entry.minimums.assign(buckets, std::numeric_limits<double>::quiet_NaN());
|
||||||
|
entry.maximums.assign(buckets, std::numeric_limits<double>::quiet_NaN());
|
||||||
|
|
||||||
|
// ---- ⚠️ **唯一一次**全量扫描:同时得到绘制值域与逐分组极值 ----
|
||||||
|
// 逐点步骤必须与 `ApplyDrawTransform` 完全一致(公式 → 系数 → 绝对值 → 垂直镜像 → Y 偏移)。
|
||||||
|
//
|
||||||
|
// ⚠️⚠️ **按分组两级循环**,内层只碰两个局部变量:若写成"每点 `index / stride`",
|
||||||
|
// 2580 万次整数除法本身就要 **400ms+**(实测单次扫描 676ms,去掉除法后 → 300ms)。
|
||||||
|
const double gain = static_cast<double>(style.gain);
|
||||||
|
const double offsetY = static_cast<double>(style.offsetY);
|
||||||
|
const std::size_t stride = entry.stride;
|
||||||
|
const double* const values = entry.values;
|
||||||
|
const bool absolute = style.absolute;
|
||||||
|
const bool mirrorVertical = style.mirrorVertical;
|
||||||
|
double low = std::numeric_limits<double>::infinity();
|
||||||
|
double high = -std::numeric_limits<double>::infinity();
|
||||||
|
for (std::size_t bucket = 0; bucket < buckets; ++bucket)
|
||||||
|
{
|
||||||
|
const std::size_t bucketBegin = bucket * stride;
|
||||||
|
const std::size_t bucketEnd = std::min(count, bucketBegin + stride);
|
||||||
|
double bucketLow = std::numeric_limits<double>::infinity();
|
||||||
|
double bucketHigh = -std::numeric_limits<double>::infinity();
|
||||||
|
for (std::size_t index = bucketBegin; index < bucketEnd; ++index)
|
||||||
|
{
|
||||||
|
double mapped = program.Evaluate(values[index]);
|
||||||
|
if (gain != 1.0)
|
||||||
|
{
|
||||||
|
mapped *= gain;
|
||||||
|
}
|
||||||
|
if (absolute)
|
||||||
|
{
|
||||||
|
mapped = (mapped < 0.0) ? -mapped : mapped;
|
||||||
|
}
|
||||||
|
if (mirrorVertical)
|
||||||
|
{
|
||||||
|
mapped = -mapped;
|
||||||
|
}
|
||||||
|
if (offsetY != 0.0)
|
||||||
|
{
|
||||||
|
mapped += offsetY;
|
||||||
|
}
|
||||||
|
if (!std::isfinite(mapped))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (mapped < bucketLow)
|
||||||
|
{
|
||||||
|
bucketLow = mapped;
|
||||||
|
}
|
||||||
|
if (mapped > bucketHigh)
|
||||||
|
{
|
||||||
|
bucketHigh = mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bucketLow <= bucketHigh)
|
||||||
|
{
|
||||||
|
entry.minimums[bucket] = bucketLow;
|
||||||
|
entry.maximums[bucket] = bucketHigh;
|
||||||
|
if (bucketLow < low)
|
||||||
|
{
|
||||||
|
low = bucketLow;
|
||||||
|
}
|
||||||
|
if (bucketHigh > high)
|
||||||
|
{
|
||||||
|
high = bucketHigh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 全为无效值的分组保持 NaN(与旧实现一致:绘制时该分组断开)。
|
||||||
|
}
|
||||||
|
if (!std::isfinite(low) || !std::isfinite(high))
|
||||||
|
{
|
||||||
|
low = 0.0;
|
||||||
|
high = 1.0;
|
||||||
|
}
|
||||||
|
entry.low = low;
|
||||||
|
entry.high = high;
|
||||||
|
entry.ready = true;
|
||||||
|
return &entry;
|
||||||
|
}
|
||||||
|
|
||||||
double DataSet::MinTime() const
|
double DataSet::MinTime() const
|
||||||
{
|
{
|
||||||
double value = std::numeric_limits<double>::infinity();
|
double value = std::numeric_limits<double>::infinity();
|
||||||
|
|||||||
@@ -946,28 +946,44 @@ namespace jdi
|
|||||||
{
|
{
|
||||||
if (style->HasFormula())
|
if (style->HasFormula())
|
||||||
{
|
{
|
||||||
// ⚠️ 「公式映射」不保证单调(`x*x` / `1/x`)⇒ 端点映射会算错,
|
// ⚠️⚠️ 「公式映射」不保证单调(`x*x` / `1/x`)⇒ 端点映射会算错。
|
||||||
// 只能逐点扫描(只在自适应重算时发生,不是每帧路径)。
|
// 逐点扫描整条信号(2580 万采样 ≈ 1.6 秒)**必须**由数据层的
|
||||||
double formulaLow = std::numeric_limits<double>::infinity();
|
// `FormulaTransformFor` 缓存承担 —— 它在第一次建立时同时算出
|
||||||
double formulaHigh = -std::numeric_limits<double>::infinity();
|
// 「绘制值域」与「逐分组包络」,之后(改视界 / 缩放 / 适应视图 /
|
||||||
for (const double raw : signal->values)
|
// 拖拽排序 / 勾选组)全部是 O(1)。旧实现在这里逐点重扫,导致
|
||||||
|
// 「确认公式」「适应视图」「拖拽信号」「组复选框」都整个应用卡死
|
||||||
|
//(用户 2026-11-04 报告)。
|
||||||
|
const DataSet::FormulaTransformCache* transform =
|
||||||
|
dataSet.FormulaTransformFor(*signal, *style);
|
||||||
|
if (transform != nullptr && transform->ready)
|
||||||
{
|
{
|
||||||
if (!std::isfinite(raw))
|
signalLow = transform->low;
|
||||||
{
|
signalHigh = transform->high;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const double mapped = ApplyDrawTransform(*style, raw);
|
|
||||||
if (!std::isfinite(mapped))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
formulaLow = std::min(formulaLow, mapped);
|
|
||||||
formulaHigh = std::max(formulaHigh, mapped);
|
|
||||||
}
|
}
|
||||||
if (std::isfinite(formulaLow) && std::isfinite(formulaHigh))
|
else
|
||||||
{
|
{
|
||||||
signalLow = formulaLow;
|
// 回退(公式非法 / 缓存不可用):保持原逐点语义,绝不改变显示结果。
|
||||||
signalHigh = formulaHigh;
|
double formulaLow = std::numeric_limits<double>::infinity();
|
||||||
|
double formulaHigh = -std::numeric_limits<double>::infinity();
|
||||||
|
for (const double raw : signal->values)
|
||||||
|
{
|
||||||
|
if (!std::isfinite(raw))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const double mapped = ApplyDrawTransform(*style, raw);
|
||||||
|
if (!std::isfinite(mapped))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
formulaLow = std::min(formulaLow, mapped);
|
||||||
|
formulaHigh = std::max(formulaHigh, mapped);
|
||||||
|
}
|
||||||
|
if (std::isfinite(formulaLow) && std::isfinite(formulaHigh))
|
||||||
|
{
|
||||||
|
signalLow = formulaLow;
|
||||||
|
signalHigh = formulaHigh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
+205
-129
@@ -18,27 +18,16 @@ namespace jdi
|
|||||||
/** 允许的最大记号数(与长度一起兜底)。 */
|
/** 允许的最大记号数(与长度一起兜底)。 */
|
||||||
constexpr std::size_t kMaxFormulaTokens = 512;
|
constexpr std::size_t kMaxFormulaTokens = 512;
|
||||||
|
|
||||||
/** 逆波兰式里的一个运算单元。 */
|
/**
|
||||||
struct FormulaOp
|
* @brief 逆波兰式里的一个运算单元。
|
||||||
{
|
*
|
||||||
enum class Kind : int
|
* 类型定义在头文件里(`FormulaInstruction`),这样 `FormulaProgram` 可以在
|
||||||
{
|
* 头内联求值、而解析器与 `EvaluateFormula` 共用同一份指令表示。
|
||||||
NUMBER = 0, /**< 常量。 */
|
*/
|
||||||
VARIABLE, /**< 自变量(采样值)。 */
|
using FormulaOp = FormulaInstruction;
|
||||||
ADD,
|
|
||||||
SUBTRACT,
|
|
||||||
MULTIPLY,
|
|
||||||
DIVIDE,
|
|
||||||
POWER, /**< 幂(`a ^ b`)。 */
|
|
||||||
NEGATE
|
|
||||||
};
|
|
||||||
|
|
||||||
Kind kind = Kind::NUMBER;
|
|
||||||
double value = 0.0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 单个自变量占用的栈槽(求值时用)。 */
|
/** 单个自变量占用的栈槽(求值时用)。 */
|
||||||
constexpr std::size_t kMaxStack = 64;
|
constexpr std::size_t kMaxStack = kFormulaMaxStack;
|
||||||
|
|
||||||
/** 记号类型。 */
|
/** 记号类型。 */
|
||||||
enum class TokenKind : int
|
enum class TokenKind : int
|
||||||
@@ -408,61 +397,197 @@ namespace jdi
|
|||||||
std::size_t m_position = 0;
|
std::size_t m_position = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 编译结果(逆波兰式);空表示"不可用"。 */
|
|
||||||
using Program = std::vector<FormulaOp>;
|
|
||||||
|
|
||||||
/** 线程内的单条编译缓存(按内容判定)。 */
|
|
||||||
struct FormulaCache
|
|
||||||
{
|
|
||||||
std::size_t size = 0;
|
|
||||||
std::wstring text;
|
|
||||||
Program program;
|
|
||||||
bool compiled = false;
|
|
||||||
bool valid = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 取(必要时编译)公式对应的程序。
|
* @brief 编译期优化:常量折叠 + 幂次改写(减小逐点求值的指令数与 `pow` 调用)。
|
||||||
* @return 合法时返回程序指针;空文本或非法时返回 nullptr。
|
*
|
||||||
|
* 两件事:
|
||||||
|
* 1. `NUMBER a, NUMBER b, <二元运算>` ⇒ `NUMBER (a OP b)`。例如
|
||||||
|
* `f(t)*2/1.25+14.2` 的 `2/1.25` 会折叠成 `1.6`,指令数 7 ⇒ 5。
|
||||||
|
* 2. `NUMBER 2, POWER` ⇒ `SQUARE`;`NUMBER 3, POWER` ⇒ `CUBE`;
|
||||||
|
* `NUMBER 0.5, POWER` ⇒ `SQRT`。`std::pow` 每点一次在 2580 万采样上约 500ms,
|
||||||
|
* 而 `x*x` 只是一条乘法(实测 `f(t)^2` 从 0.9s 降到与线性公式同级)。
|
||||||
|
*
|
||||||
|
* 两者都**不改变语义**(浮点运算顺序原样保留),因此不需要"是否精确"的特判。
|
||||||
|
*
|
||||||
|
* @param [IN,OUT] program 解析器输出的逆波兰式(原地改写)。
|
||||||
*/
|
*/
|
||||||
const Program* AcquireProgram(const std::wstring& text)
|
void OptimizeProgram(std::vector<FormulaInstruction>& program)
|
||||||
{
|
{
|
||||||
if (text.empty())
|
using Kind = FormulaInstruction::Kind;
|
||||||
{
|
std::vector<FormulaInstruction> output;
|
||||||
return nullptr;
|
output.reserve(program.size());
|
||||||
}
|
// ⚠️ `constant` 与**求值栈槽**一一对应(不是与 `output` 元素对应):一元运算
|
||||||
static thread_local FormulaCache cache;
|
// 不改变栈深,因此它不会往 `constant` 里加元素。只有"栈顶是常量"时
|
||||||
// ⚠️⚠️ **必须比较内容,不能只比 `data()` 指针 + 长度**:`std::wstring` 的短串走
|
// `output.back()` 才必然是一条 `NUMBER`,折叠才安全。
|
||||||
// **SSO**(缓冲区就在对象内部),把同一个样式对象的公式改成**等长**的另一条时,
|
std::vector<bool> constant;
|
||||||
// `data()` 与 `size()` 都不会变 ⇒ 只比指针会一直复用**旧程序**
|
constant.reserve(program.size());
|
||||||
//(实测:`f(t)^2` 改成 `f(t)^3` 后波形不更新,用户无法察觉公式已失效)。
|
|
||||||
// 比较顺序:先比长度(最便宜),再比内容(短公式只是十几字节的 memcmp)。
|
|
||||||
if (cache.compiled && cache.size == text.size() && cache.text == text)
|
|
||||||
{
|
|
||||||
return cache.valid ? &cache.program : nullptr;
|
|
||||||
}
|
|
||||||
cache.text = text;
|
|
||||||
cache.size = text.size();
|
|
||||||
cache.program.clear();
|
|
||||||
cache.valid = false;
|
|
||||||
cache.compiled = true;
|
|
||||||
|
|
||||||
std::vector<Token> tokens;
|
const auto isBinary = [](Kind kind)
|
||||||
std::wstring error;
|
|
||||||
if (!Tokenize(text, tokens, &error))
|
|
||||||
{
|
{
|
||||||
return nullptr;
|
return kind == Kind::ADD || kind == Kind::SUBTRACT || kind == Kind::MULTIPLY ||
|
||||||
}
|
kind == Kind::DIVIDE || kind == Kind::POWER;
|
||||||
Parser parser(tokens, cache.program, &error);
|
};
|
||||||
if (!parser.Parse())
|
const auto pushNumber = [&output, &constant](double value)
|
||||||
{
|
{
|
||||||
cache.program.clear();
|
FormulaInstruction instruction;
|
||||||
return nullptr;
|
instruction.kind = Kind::NUMBER;
|
||||||
|
instruction.value = value;
|
||||||
|
output.push_back(instruction);
|
||||||
|
constant.push_back(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const FormulaInstruction& instruction : program)
|
||||||
|
{
|
||||||
|
if (instruction.kind == Kind::NUMBER || instruction.kind == Kind::VARIABLE)
|
||||||
|
{
|
||||||
|
output.push_back(instruction);
|
||||||
|
constant.push_back(instruction.kind == Kind::NUMBER);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isBinary(instruction.kind))
|
||||||
|
{
|
||||||
|
if (constant.size() >= 2 && constant[constant.size() - 1] &&
|
||||||
|
constant[constant.size() - 2])
|
||||||
|
{
|
||||||
|
// 两个操作数都是常量 ⇒ 折叠(`output` 末尾两条必是它们的 NUMBER 指令)。
|
||||||
|
const double right = output.back().value;
|
||||||
|
output.pop_back();
|
||||||
|
constant.pop_back();
|
||||||
|
const double left = output.back().value;
|
||||||
|
output.pop_back();
|
||||||
|
constant.pop_back();
|
||||||
|
double folded = 0.0;
|
||||||
|
switch (instruction.kind)
|
||||||
|
{
|
||||||
|
case Kind::ADD:
|
||||||
|
folded = left + right;
|
||||||
|
break;
|
||||||
|
case Kind::SUBTRACT:
|
||||||
|
folded = left - right;
|
||||||
|
break;
|
||||||
|
case Kind::MULTIPLY:
|
||||||
|
folded = left * right;
|
||||||
|
break;
|
||||||
|
case Kind::DIVIDE:
|
||||||
|
folded = left / right;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
folded = std::pow(left, right);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pushNumber(folded);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 幂次改写:`x ^ 2 / 3 / 0.5`(右操作数是常量)。
|
||||||
|
if (instruction.kind == Kind::POWER && !constant.empty() && constant.back())
|
||||||
|
{
|
||||||
|
const double exponent = output.back().value;
|
||||||
|
if (exponent == 2.0 || exponent == 3.0 || exponent == 0.5)
|
||||||
|
{
|
||||||
|
output.pop_back();
|
||||||
|
constant.pop_back();
|
||||||
|
FormulaInstruction rewritten;
|
||||||
|
rewritten.kind = (exponent == 2.0) ? Kind::SQUARE
|
||||||
|
: (exponent == 3.0) ? Kind::CUBE
|
||||||
|
: Kind::SQRT;
|
||||||
|
output.push_back(rewritten);
|
||||||
|
constant.push_back(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.push_back(instruction);
|
||||||
|
constant.pop_back();
|
||||||
|
constant.pop_back();
|
||||||
|
constant.push_back(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 一元运算(NEGATE / SQUARE / CUBE / SQRT):栈深不变。
|
||||||
|
if (!constant.empty() && constant.back())
|
||||||
|
{
|
||||||
|
// 栈顶是常量 ⇒ 直接折叠成一条 `NUMBER`(`output.back()` 必为 NUMBER)。
|
||||||
|
const double value = output.back().value;
|
||||||
|
output.pop_back();
|
||||||
|
double folded = value;
|
||||||
|
switch (instruction.kind)
|
||||||
|
{
|
||||||
|
case Kind::NEGATE:
|
||||||
|
folded = -value;
|
||||||
|
break;
|
||||||
|
case Kind::SQUARE:
|
||||||
|
folded = value * value;
|
||||||
|
break;
|
||||||
|
case Kind::CUBE:
|
||||||
|
folded = value * value * value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
folded = std::sqrt(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pushNumber(folded);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 非常量:保留指令,栈顶保持"非常量"(本来就如此)。
|
||||||
|
output.push_back(instruction);
|
||||||
}
|
}
|
||||||
cache.valid = true;
|
program.swap(output);
|
||||||
return &cache.program;
|
|
||||||
}
|
}
|
||||||
} // namespace
|
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
FormulaProgram::FormulaProgram(const std::wstring& text)
|
||||||
|
{
|
||||||
|
if (text.empty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::vector<Token> tokens;
|
||||||
|
std::wstring error;
|
||||||
|
if (!Tokenize(text, tokens, &error))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Parser parser(tokens, m_instructions, &error);
|
||||||
|
if (!parser.Parse())
|
||||||
|
{
|
||||||
|
m_instructions.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 常量折叠 + 幂次改写(`x^2` ⇒ `x*x`):逐点求值的指令数与 `pow` 调用都随之减少。
|
||||||
|
OptimizeProgram(m_instructions);
|
||||||
|
// ⚠️ 编译期栈深校验:`Evaluate()` 不做运行期边界检查(性能),因此这里必须拒绝
|
||||||
|
// 深层嵌套的表达式(它们会回退到带检查的 `EvaluateFormula()`,结果一致)。
|
||||||
|
std::size_t depth = 0;
|
||||||
|
std::size_t maximumDepth = 0;
|
||||||
|
for (const FormulaInstruction& instruction : m_instructions)
|
||||||
|
{
|
||||||
|
switch (instruction.kind)
|
||||||
|
{
|
||||||
|
case FormulaInstruction::Kind::NUMBER:
|
||||||
|
case FormulaInstruction::Kind::VARIABLE:
|
||||||
|
++depth;
|
||||||
|
if (depth > maximumDepth)
|
||||||
|
{
|
||||||
|
maximumDepth = depth;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case FormulaInstruction::Kind::NEGATE:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (depth >= 2)
|
||||||
|
{
|
||||||
|
--depth;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maximumDepth > kFormulaMaxStack)
|
||||||
|
{
|
||||||
|
m_instructions.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_valid = true;
|
||||||
|
}
|
||||||
|
|
||||||
bool ValidateFormula(const std::wstring& text, std::wstring* error)
|
bool ValidateFormula(const std::wstring& text, std::wstring* error)
|
||||||
{
|
{
|
||||||
@@ -490,72 +615,23 @@ namespace jdi
|
|||||||
|
|
||||||
double EvaluateFormula(const std::wstring& text, double x)
|
double EvaluateFormula(const std::wstring& text, double x)
|
||||||
{
|
{
|
||||||
const Program* program = AcquireProgram(text);
|
// 逐点路径(绘制 / 统计 / 派生烘焙)与批量路径共用**同一份**编译结果:
|
||||||
if (program == nullptr)
|
// 线程内按**内容**缓存一条 `FormulaProgram`(含同一套编译期优化)。
|
||||||
|
// ⚠️ 不能只比指针(SSO 下等长改公式会复用旧程序,见历史记录)。
|
||||||
|
static thread_local FormulaProgram cachedProgram;
|
||||||
|
static thread_local std::wstring cachedText;
|
||||||
|
static thread_local bool cached = false;
|
||||||
|
if (!cached || cachedText != text)
|
||||||
|
{
|
||||||
|
cachedProgram = FormulaProgram(text);
|
||||||
|
cachedText = text;
|
||||||
|
cached = true;
|
||||||
|
}
|
||||||
|
if (!cachedProgram.Valid())
|
||||||
{
|
{
|
||||||
return x; // 空 / 非法:不做映射(保证波形照常显示)
|
return x; // 空 / 非法:不做映射(保证波形照常显示)
|
||||||
}
|
}
|
||||||
double stack[kMaxStack];
|
return cachedProgram.Evaluate(x);
|
||||||
std::size_t depth = 0;
|
|
||||||
for (const FormulaOp& op : *program)
|
|
||||||
{
|
|
||||||
switch (op.kind)
|
|
||||||
{
|
|
||||||
case FormulaOp::Kind::NUMBER:
|
|
||||||
case FormulaOp::Kind::VARIABLE:
|
|
||||||
if (depth >= kMaxStack)
|
|
||||||
{
|
|
||||||
return std::numeric_limits<double>::quiet_NaN();
|
|
||||||
}
|
|
||||||
stack[depth++] = (op.kind == FormulaOp::Kind::NUMBER) ? op.value : x;
|
|
||||||
break;
|
|
||||||
case FormulaOp::Kind::NEGATE:
|
|
||||||
if (depth < 1)
|
|
||||||
{
|
|
||||||
return std::numeric_limits<double>::quiet_NaN();
|
|
||||||
}
|
|
||||||
stack[depth - 1] = -stack[depth - 1];
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
{
|
|
||||||
if (depth < 2)
|
|
||||||
{
|
|
||||||
return std::numeric_limits<double>::quiet_NaN();
|
|
||||||
}
|
|
||||||
const double b = stack[depth - 1];
|
|
||||||
const double a = stack[depth - 2];
|
|
||||||
--depth;
|
|
||||||
double result = 0.0;
|
|
||||||
switch (op.kind)
|
|
||||||
{
|
|
||||||
case FormulaOp::Kind::ADD:
|
|
||||||
result = a + b;
|
|
||||||
break;
|
|
||||||
case FormulaOp::Kind::SUBTRACT:
|
|
||||||
result = a - b;
|
|
||||||
break;
|
|
||||||
case FormulaOp::Kind::MULTIPLY:
|
|
||||||
result = a * b;
|
|
||||||
break;
|
|
||||||
case FormulaOp::Kind::DIVIDE:
|
|
||||||
result = a / b;
|
|
||||||
break;
|
|
||||||
case FormulaOp::Kind::POWER:
|
|
||||||
result = std::pow(a, b);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
stack[depth - 1] = result;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (depth != 1)
|
|
||||||
{
|
|
||||||
return std::numeric_limits<double>::quiet_NaN();
|
|
||||||
}
|
|
||||||
return std::isfinite(stack[0]) ? stack[0] : std::numeric_limits<double>::quiet_NaN();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::wstring NormalizeFormulaText(const std::wstring& text)
|
std::wstring NormalizeFormulaText(const std::wstring& text)
|
||||||
|
|||||||
Reference in new issue
Block a user