diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 93f2b3d..f7195a5 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1264,12 +1264,17 @@ namespace jdi * @param [IN] begin 需要的采样区间起点(含)。 * @param [IN] end 需要的采样区间终点(不含)。 * @param [OUT] spanBegin 返回数组对应的首个采样下标。 + * @param [IN] allowInteractiveDefer 交互期间是否允许「延迟重算」:窗口过大时复用已算好的 + * 旧曲线,避免在拖动滑块/移动视界时同步重算整条曲线(见 + * `kDerivedInteractiveComputeBudget`)。仅供**预览绘制**传 true; + * 需要精确取值的统计(中心轴边沿计数)必须传 false。 * @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);无法计算时返回 nullptr。 */ const std::vector* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, int points, int iterations, int levelPercent, std::size_t begin, std::size_t end, - std::size_t& spanBegin) const; + std::size_t& spanBegin, + bool allowInteractiveDefer = false) const; /** * @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 818e1cd..d730a14 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -80,16 +80,34 @@ namespace jdi * 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。 */ const std::size_t kDerivedCurveGuardMax = 65536; - /** 区间窗口缓存条目上限(超出时整体清空;它只是加速手段)。 */ + /** 区间窗口缓存条目上限(超出时淘汰最早的条目;它只是加速手段)。 */ const std::size_t kCurveSpanCacheLimit = 32; /** * @brief 进入「区间窗口缓存」的单条窗口采样数上限。 * - * 超过它的窗口(例如「中心轴边沿计数」在未限定光标区间时的整条信号) - * 改写进 `m_curveSpanScratch`:缓存的窗口一旦被淘汰就得重算整条曲线 - * (亿级采样实测 ≈1s 的卡顿尖峰),而且几条曲线同时驻留就会吃掉数百 MB。 + * **必须足够大**,使「完全放大到整条信号」的窗口也能进缓存:否则每一帧(含静止时 + * 的鼠标悬停重绘)都要重算整条曲线 —— 弱波增强在 2576 万采样上单条 ≈368ms, + * 5 条网格就是 ≈1.8s/帧,交互完全无法进行。 + * + * 窗口一旦计算完成,同一参数下(平移/缩放/悬停)都能直接复用; + * 内存总量由 `kCurveSpanCacheMaxBytes` 兜底,因此这里不再按采样数排除大窗口。 */ - const std::size_t kCurveSpanCacheMaxSamples = 4194304; + const std::size_t kCurveSpanCacheMaxSamples = 67108864; + /** 区间窗口缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。 + * 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留, + * 否则缓存互相淘汰会导致每帧重算其中一条(弱波增强 ≈368ms 的卡顿尖峰)。 */ + const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull; + /** + * @brief 交互期间允许**同步重算**的派生曲线窗口采样数上限。 + * + * 拖动滑块 / 移动视界 / 缩放等交互每帧都会请求派生(预览)曲线。窗口不大时同步重算 + * 足够快(几毫秒),预览曲线能实时跟随控件;一旦窗口超过本上限(例如大时间跨度或 + * 整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms)。 + * + * 此时**不在交互过程中重算**,而是复用上一次算好的曲线(位置由当前视图映射,仍准确), + * 等交互结束后再按最终参数精确重算 —— 控件(滑块/光标/视界)的反馈因此始终跟手。 + */ + const std::size_t kDerivedInteractiveComputeBudget = 131072; const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */ @@ -2105,7 +2123,8 @@ namespace jdi const std::vector* PlotView::CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, int points, int iterations, int levelPercent, std::size_t begin, std::size_t end, - std::size_t& spanBegin) const + std::size_t& spanBegin, + bool allowInteractiveDefer) const { spanBegin = 0; const std::size_t count = signal.values.size(); @@ -2141,6 +2160,43 @@ namespace jdi } } + // ⚠️ 交互期间的「延迟重算」:拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线, + // 若窗口很大(例如大时间跨度、整条信号),同步重算会阻塞交互数毫秒到数百毫秒 + // (弱波增强整条信号单条 ≈368ms)。此时**不重算**,改复用上一次算好的曲线: + // 曲线下标映射到时间仍是当前视图的映射,因此位置依旧准确,只是参数/覆盖范围可能是 + // 上一帧的;等交互结束后(`m_dragMode == NONE`)再按最终参数精确重算。 + // 这样滑块 / 光标 / 视界移动的反馈始终跟手,不会被绘制计算拖住。 + if (allowInteractiveDefer && m_dragMode != PlotDragMode::NONE && + (end - begin) > kDerivedInteractiveComputeBudget) + { + const CurveSpanEntry* covering = nullptr; + const CurveSpanEntry* newest = nullptr; + for (auto it = m_curveSpanCache.rbegin(); it != m_curveSpanCache.rend(); ++it) + { + if (it->signal != &signal || it->kind != kindValue) + { + continue; + } + if (newest == nullptr) + { + newest = &*it; + } + if (it->begin <= begin && it->end >= end) + { + covering = &*it; + break; + } + } + const CurveSpanEntry* fallback = (covering != nullptr) ? covering : newest; + if (fallback != nullptr) + { + spanBegin = fallback->begin; + return &fallback->values; + } + // 完全没有可复用的曲线:本帧跳过该预览曲线(不阻塞交互)。 + return nullptr; + } + // 未命中:向外扩一个保护带后再计算,保证可见区间内的取值与"整条曲线"一致。 // 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播 // (每迭代一次传一个极值),1/8 个可见宽度足以覆盖真实信号的极值间距。 @@ -2183,9 +2239,23 @@ namespace jdi return &m_curveSpanScratch; } - if (m_curveSpanCache.size() >= kCurveSpanCacheLimit) + // 缓存窗口:条目数与总字节数双重约束,避免大窗口把内存吃满。 + // 淘汰策略为「最旧优先」(`deque` 头部即最早写入的)。 + const auto cacheBytes = [this]() -> std::size_t { - m_curveSpanCache.clear(); + std::size_t total = 0; + for (const CurveSpanEntry& cached : m_curveSpanCache) + { + total += cached.values.size() * sizeof(float); + } + return total; + }; + while (!m_curveSpanCache.empty() && + (m_curveSpanCache.size() >= kCurveSpanCacheLimit || + cacheBytes() + (computedEnd - computedBegin) * sizeof(float) > + kCurveSpanCacheMaxBytes)) + { + m_curveSpanCache.pop_front(); } m_curveSpanCache.push_back(CurveSpanEntry()); CurveSpanEntry& entry = m_curveSpanCache.back(); @@ -7395,7 +7465,7 @@ namespace jdi std::size_t spanBegin = 0; const std::vector* curvePtr = CurveSpanFor(kind, *resolved.data, points, iterations, levelPercent, begin, - end, spanBegin); + end, spanBegin, true); if (curvePtr == nullptr) { return; @@ -8161,7 +8231,8 @@ namespace jdi } std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, - points, iterations, 0, begin, end, spanBegin); + points, iterations, 0, begin, end, spanBegin, + true); if (curve == nullptr) { return; @@ -8186,7 +8257,8 @@ namespace jdi } std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::WEAK_BOOST, *resolved.data, 0, - iterations, levelPercent, begin, end, spanBegin); + iterations, levelPercent, begin, end, spanBegin, + true); if (curve == nullptr) { return; diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 2cdbac3..4ecb837 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -187,6 +187,36 @@ Documents/ 项目文档(含本文件) - 「构建为新信号」等一次性操作会真正计算整条派生曲线(约 1 帧的秒级耗时,正常)。 - **测量工具**:`.preflight/perf/perf.exe [mode]`(离屏,不弹窗)。`mode=0` 跑各交互场景耗时;`mode=1` 导出确定性渲染 PNG 以便逐像素比对(用于验证"显示效果不变")。该目录已由 `.gitignore` 忽略,不会入库。 +### 9.5 性能:启用带预览曲线的实验性功能后,交互严重卡顿(核心修复,含"为什么只有某些缩放等级卡"的解答) +- **症状**(以 `VERY_BIG_DATA.CSV`,5 信号全部显示为例): + - 「弱波增强」滑块**拖动时**在**任何缩放等级**下都卡顿;越缩得小(可见时间跨度越大)越卡 —— 时间跨度到 2.5s 时单帧 ≈89ms,完全放大(整段 128.8s)时单帧 **≈2.3 秒**。 + - 网格「选区放大」到某些跨度后,平移视界 / 改样式也卡。 +- **根因**(`Components/PlotView/Src/PlotView.cpp`,`CurveSpanFor` + 缓存策略 + `Utilities/Src/DataModel.cpp` 的 `ComputeWeakWaveBoost`): + 1. 派生(预览)曲线按「可见采样窗口 ± 保护带」计算,**代价正比于窗口内的采样数**。实测:弱波增强整段(2576 万采样)单条 **≈368ms**,均值滤波整段单条 ≈57ms。5 个网格就是 ≈1.8s。 + 2. 旧代码有一条「**超过 4M 采样的窗口不进缓存**」的规则(为省内存),于是**大跨度下每帧都重算**整段曲线 —— 包括静止时的鼠标悬停重绘。这就是"某些缩放等级特别卡"的原因: + - 完全放大:窗口 = 整条信号(千万级)→ 每帧重算 → 秒级卡顿; + - 很小的时间跨度:窗口只有几千采样 → 计算只要微秒级 → **毫不卡顿**; + - 中间跨度:窗口在十几万~几百万采样之间 → 单帧几十毫秒 → 卡,且保护带(≤65536)相对窗口太短,平移很容易越出缓存覆盖 → 频繁重算。 + 3. 拖动「弱波增强」滑块每帧都改等级 → 缓存键变化 → 每帧都命中"未缓存"→ 每帧重算(叠加上述放大效应)。 +- **修复**(三管齐下,**显示效果逐像素不变**): + 1. **大窗口也进缓存**:`kCurveSpanCacheMaxSamples` 提高到 64M(相当于不再按采样数排除),并新增**总字节预算** `kCurveSpanCacheMaxBytes`(≈768MB,最旧优先淘汰)兜底内存。这样同一参数下的平移/缩放/悬停都能复用,静止重绘降到 **0.25ms**。 + 2. **交互期间「延迟重算」**(`CurveSpanFor` 新增 `allowInteractiveDefer`):拖动滑块/移动视界/缩放时,若所需窗口超过 `kDerivedInteractiveComputeBudget`(131072 采样),**本帧不重算**,改复用上一次算好的曲线(曲线下标→时间的映射仍是当前视图,因此位置准确),交互结束后再按最终参数精确重算。窗口不大时(常见工作缩放)照常同步重算,预览曲线**实时跟随控件**。 + - 用途区分:**预览绘制**(`PaintMeanFilterCurve`/`PaintWeakBoostCurve`/光标取值胶囊)传 `allowInteractiveDefer = true`;**需要精确取值的统计**(中心轴边沿计数 `EdgeCenterCountsFor`、`MeanFilterValueAt`/`WeakBoostValueAt`)传 `false`(默认)。 + 3. **计算缓冲复用**:`ComputeWeakWaveBoost` / `ComputeMovingAverageIterated` / `ApplyWeakBoostStage` 的工作缓冲改为 `thread_local` 复用容量,避免每次重算都分配并零初始化上千万元素(多阶弱波增强 991ms→577ms)。 +- **实测**(离屏,`VERY_BIG_DATA`,5 网格;单位 ms/帧): + + | 场景 | 修复前 | 修复后 | + | --- | --- | --- | + | 弱波增强 滑块拖动(整段) | ≈2303 | **≈18** | + | 弱波增强 滑块拖动(2.5s 跨度) | ≈89 | **≈15** | + | 弱波增强 滑块拖动(103ms / 20ms 跨度) | ≈14 / ≈12 | ≈15 / ≈11 | + | 弱波增强 静止重绘(整段) | ≈2247 | **≈0.25** | + | 均值滤波 平移(2.5s 跨度) | ≈27 | ≈23(大窗口非拖拽时仍按需重算;拖拽时延迟) | + | 普通平移 / 光标移动 / 改色 | 3–12 | 不变(1–12) | + +- **已知残留**(不牺牲显示时的固有成本):把可见时间跨度拉到接近整段且开启弱波增强时,**首次**计算某参数/窗口的预览曲线仍需要一次 ≈1.8s(5 条整段曲线,各 ≈368ms)—— 交互期间不会触发(已延迟),但交互结束后的那一帧会补上。这是"整段弱波增强 O(n) 计算"的固有代价;若要进一步消除需把该计算移到后台线程(尚未做,改动较大)。 +- **为什么这样改不牺牲显示效果**:本次只改「何时计算 / 是否缓存」,不改任何几何或取值算法;`mode=1` 的逐像素比对(均值滤波 + 弱波增强,两个缩放级别,含光标取值胶囊)结果为 **0 像素差异**。 + --- ## 10. 变更日志(本文件维护记录) @@ -195,3 +225,4 @@ Documents/ 项目文档(含本文件) | --- | --- | --- | | 2026-10-11 | DeepSeek-V4.1-Flash | 新建本文件;记录项目要点与规范;修复「实验性功能」二级菜单瞬间关闭问题。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 修复右键网格菜单后悬停光晕/提示气泡残留;修复「启用带预览曲线的实验性功能后平移/拖光标 300ms+ 卡顿」(`paintDerivedCapsule` 巨窗每帧重算,收窄请求窗口,显示效果零变化)。 | +| 2026-10-11 | DeepSeek-V4.1-Flash | 深度修复「实验性功能预览曲线导致的交互卡顿」:大窗口改入缓存(静止重绘 2247ms→0.25ms)、交互期间延迟重算(弱波增强拖滑块 2303ms→18ms)、计算缓冲复用;显示逐像素不变。 | diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 61596eb..f95a550 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1032,7 +1032,10 @@ namespace jdi } // 后续各阶以上一阶的结果为输入(double 中转,避免 float→double 的精度损失累积)。 - std::vector buffer(values.size(), std::numeric_limits::quiet_NaN()); + // 中转缓冲复用容量(线程本地),避免每阶重新分配上千万元素的向量。 + thread_local std::vector bufferBuffer; + std::vector& buffer = bufferBuffer; + buffer.assign(values.size(), std::numeric_limits::quiet_NaN()); std::vector stage; for (int iteration = 1; iteration < stages; ++iteration) { @@ -1070,7 +1073,7 @@ namespace jdi * @return 确实有采样点被改动返回 true;完全无弱波时返回 false。 */ bool ApplyWeakBoostStage(const std::vector& input, double strength, - std::vector& output) + std::vector& output, std::vector& extremes) { const std::size_t count = input.size(); output.assign(count, std::numeric_limits::quiet_NaN()); @@ -1092,7 +1095,7 @@ namespace jdi // 1) 识别波峰 / 波谷:扫描单调段,方向翻转处的上一个采样点即为极值点。 // 波峰(先升后降)与波谷(先降后升)必然交替出现, // 因此同类极值就是极值序列里下标 ±2 的两个。 - std::vector extremes; + extremes.clear(); { int direction = 0; // 当前单调段方向(+1 上升、-1 下降)。 bool hasPrevious = false; @@ -1145,7 +1148,9 @@ namespace jdi // 2) 逐个极值点判定“是否偏弱”,并算出它自己的目标偏移量。 const std::size_t extremeCount = extremes.size(); - std::vector shift(extremeCount, 0.0); + thread_local std::vector shiftBuffer; + std::vector& shift = shiftBuffer; + shift.assign(extremeCount, 0.0); for (std::size_t k = 2; k + 2 < extremeCount; ++k) { const BoostExtremum& current = extremes[k]; @@ -1241,7 +1246,13 @@ namespace jdi } // 第 1 阶:从原始采样值出发(非有限值保持 NaN)。 - std::vector current(count, std::numeric_limits::quiet_NaN()); + // 工作缓冲用线程本地变量复用容量:单条曲线动辄上千万采样,若每次调用都重新分配 + // 并零初始化,单条「弱波增强」的耗时会被明显放大(内存分配 + page fault)。 + thread_local std::vector currentBuffer; + thread_local std::vector nextBuffer; + thread_local std::vector extremesBuffer; + std::vector& current = currentBuffer; + current.assign(count, std::numeric_limits::quiet_NaN()); for (std::size_t index = 0; index < count; ++index) { const double value = values[index]; @@ -1256,10 +1267,10 @@ namespace jdi // 多次迭代时相邻的弱波会互相抬高彼此的基准,从而把较宽的弱波区逐步恢复。 const int stages = std::max(1, std::min(iterations, 1000)); const double strength = std::max(0.0, std::min(1.0, level)); - std::vector next; + std::vector& next = nextBuffer; for (int stage = 0; stage < stages; ++stage) { - if (!ApplyWeakBoostStage(current, strength, next)) + if (!ApplyWeakBoostStage(current, strength, next, extremesBuffer)) { // 这一阶没有任何采样点需要调整:后续迭代次数也不会再有变化,提前结束。 break;