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

+63
View File
@@ -6,6 +6,7 @@
#include <cstddef>
#include <cstdint>
#include <array>
#include <deque>
#include <limits>
#include <string>
#include <atomic>
@@ -869,6 +870,60 @@ namespace jdi
*/
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 计算全部分组、全部时间轴时间范围的并集下限。
* @return 时间下限。
@@ -908,6 +963,14 @@ namespace jdi
private:
std::vector<SignalGroup> m_groups; /**< 已加载信号分组集合。 */
SignalRef m_selected; /**< 当前选中信号(跨标签页共享)。 */
/**
* @brief 「公式映射」信号的绘制值缓存(见 `FormulaTransformFor`)。
*
* `mutable`:查询缓存是逻辑上的只读操作;只在 UI 线程写。
* ⚠️ 用 `std::deque`(而不是 `vector`):`emplace_back` **不会**移动已有元素,
* 因此 `FormulaTransformFor` 返回的指针在后续新建其它信号的条目时依然有效。
*/
mutable std::deque<FormulaTransformCache> m_formulaTransforms;
};
/**
+153
View File
@@ -17,10 +17,163 @@
// 绘制循环里同一条信号的样式对象是稳定的,因此每个信号只需编译一次;
// **绝不能**每个采样点都重新解析(千万级采样会慢到不可用)。
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <string>
#include <vector>
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 校验公式文本是否可以解析。
*