四次卡顿是**同一个根因**:公式信号(不保序)的幅值范围与包络带各写了一份 「逐点扫描整条信号」,且都没有缓存。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 仍同步阻塞(后台化的复杂度暂不划算)。
217 lines
7.4 KiB
C++
217 lines
7.4 KiB
C++
#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__
|