Files
DeepSeek-V4.1-Flash 266ab657be 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 仍同步阻塞(后台化的复杂度暂不划算)。
2026-11-04 02:02:03 +08:00

217 lines
7.4 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#ifndef __FORMULA_EVAL_HPP__
#define __FORMULA_EVAL_HPP__
// 「公式映射」的表达式编译与求值(信号样式区的 `Y = …` 输入框)。
//
// 语法(刻意保持极小,避免引入脚本引擎与额外的体积):
// expr := term (('+' | '-') term)*
// term := unary (('*' | '/') unary)*
// unary := ('+' | '-') unary | power
// power := primary ('^' unary)? —— 右结合:2^3^2 = 2^(3^2)
// primary := number | '(' expr ')' | variable | constant
// number := 十进制小数(支持 e/E 指数)
// constant:= `e`(自然常数)| `pi`(圆周率)—— 大小写均接受,保存前统一为小写
// variable:= `X` / `x` / `f(t)` —— 都表示"当前采样点的值"(**不支持单独的 `f`**)
//
// ⚠️ 求值走 `thread_local` 的单条缓存(按 `std::wstring` 的 data 指针 + 内容判定):
// 绘制循环里同一条信号的样式对象是稳定的,因此每个信号只需编译一次;
// **绝不能**每个采样点都重新解析(千万级采样会慢到不可用)。
#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 校验公式文本是否可以解析。
*
* 空文本是合法输入(表示"不做映射")。
*
* @param [IN] text 公式文本(不含 `Y =` 前缀)。
* @param [OUT] error 解析失败时的原因(成功时清空),允许为空。
* @return 语法合法返回 true。
*/
bool ValidateFormula(const std::wstring& text, std::wstring* error);
/**
* @brief 计算公式在给定采样值处的取值。
*
* 文本为空或无法解析时**原样返回** `x`(保证绘制不会因为非法公式而消失)。
* 计算结果非有限(如 1/0)时返回 NaN(保持断线语义)。
*
* @param [IN] text 公式文本。
* @param [IN] x 采样值(公式中的 `f(t)` / `X`)。
* @return 映射后的值。
*/
double EvaluateFormula(const std::wstring& text, double x);
/**
* @brief 把用户输入里的自变量写法统一成 `f(t)`、常量统一为小写(保存前调用)。
*
* 规则:
* * 把独立的 `X` / `x` / `f(t)`(含 `F(T)`)记号改写为 `f(t)`;
* * 把 `E` / `Pi` / `PI` 等写法改写为小写 `e` / `pi`;
* * **单独的 `f` 不改写**(它不是合法写法,留给校验报错)。
*
* 只做记号级替换(不会动标识符中间或数字里的字符,如 `1e5` 保持不变)。
*
* @param [IN] text 用户输入的公式。
* @return 归一化后的文本。
*/
std::wstring NormalizeFormulaText(const std::wstring& text);
} // namespace jdi
#endif // __FORMULA_EVAL_HPP__