From 99247622c1163af65a4060b15b14ab13dddf4624 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Sun, 11 Oct 2026 12:04:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B4=BE=E7=94=9F=E6=9B=B2=E7=BA=BF=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E4=B8=8E=20UI=20=E7=BB=98=E5=88=B6=E5=88=86=E7=A6=BB?= =?UTF-8?q?=EF=BC=88=E5=90=8E=E5=8F=B0=E7=BA=BF=E7=A8=8B=20+=20=E5=8F=8C?= =?UTF-8?q?=E7=BC=93=E5=86=B2=EF=BC=89=EF=BC=8C=E5=BD=BB=E5=BA=95=E6=B6=88?= =?UTF-8?q?=E9=99=A4=E4=BA=A4=E4=BA=92=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:重计算发生在绘制线程内(Paint 同步算派生预览曲线),一次整段弱波增强约 368ms 会阻塞消息循环,滑块/光标/视界失去跟手。 方案:新增后台计算线程 + 双缓冲。大窗口预览曲线不在绘制线程计算,本帧用最新旧曲线顶替并排后台任务;小窗口仍同步以保证预览实时;后台算完通过自定义消息通知宿主重绘(双缓冲切换)。条目用 shared_ptr 共享、绘制线程钉住整帧有效,后台只追加不改写,无数据竞争。任何改变数据层结构的操作前先 PauseDerivedAsync(join 后台线程),杜绝读取已释放/已移动采样。 实测 VERY_BIG_DATA(5 网格):弱波增强平移 2247ms→15ms,拖滑块 2303ms→15ms,静止重绘→0.25ms,交互均 ≤约 30ms,无 300ms/2s 级尖峰。小窗口同步路径与旧实现逐像素 0 差异。 --- Components/Application/Src/Application.cpp | 15 +- Components/PlotView/Inc/PlotView.hpp | 196 +++++++-- Components/PlotView/Src/PlotView.cpp | 464 +++++++++++++++------ Documents/agent_mem.md | 12 + 4 files changed, 520 insertions(+), 167 deletions(-) diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index d2f7c3a..a1bb4d5 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -583,6 +583,8 @@ namespace jdi } const std::wstring name = file->fileName; + // 移除文件会释放大量采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。 + m_plot.PauseDerivedAsync(); // 先按旧下标修正各标签页配置与共享选中项,再从数据层移除,最后统一对齐。 for (TabConfig& tab : m_tabs) { @@ -617,6 +619,8 @@ namespace jdi return; } + // 移除信号会释放采样内存:先暂停后台派生曲线计算,避免其读取已释放内存。 + m_plot.PauseDerivedAsync(); // 先按旧下标修正各标签页配置与共享选中项,再从数据层移除(释放采样内存),最后对齐。 for (TabConfig& tab : m_tabs) { @@ -937,6 +941,9 @@ namespace jdi } int fileIndex = -1; + // 插入新文件会移动 DataFile/SignalData 的存储位置,必须先暂停后台派生曲线计算, + // 否则后台线程可能正在读取即将被移动的采样数据。 + m_plot.PauseDerivedAsync(); // 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。 if (!m_dataSet.AddDataFile(std::move(file), fileIndex)) { @@ -1498,6 +1505,8 @@ namespace jdi // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 int newIndex = -1; + // 插入派生信号会移动该文件的信号存储:先暂停后台计算,避免其读取已移动的数据。 + m_plot.PauseDerivedAsync(); if (!m_dataSet.InsertDerivedSignal(request.fileIndex, request.signalIndex, std::move(derived), newIndex)) { @@ -1919,7 +1928,7 @@ namespace jdi // WM_PAINT / WM_TIMER 的重绘范围由各自的处理分支判断,这里不做标记。 if (message != WM_PAINT && message != WM_MOUSEMOVE && message != WM_MOUSELEAVE && message != WM_SETCURSOR && message != WM_NCHITTEST && message != WM_TIMER && - message != WM_ERASEBKGND) + message != WM_ERASEBKGND && message != kMessagePlotDerivedReady) { m_chromeDirty = true; } @@ -2145,6 +2154,10 @@ namespace jdi switch (message) { + case kMessagePlotDerivedReady: + // 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。 + InvalidatePlotRect(hwnd, m_plotRect); + return 0; case WM_SIZE: { ReleaseBackBuffer(); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index f7195a5..e1968bd 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -5,8 +5,13 @@ #include #include +#include +#include #include +#include +#include #include +#include #include #include "DataModel.hpp" @@ -16,6 +21,14 @@ namespace jdi { + /** + * @brief 后台派生曲线计算完成后由 `PlotView` 投递给宿主的自定义消息。 + * + * 宿主收到后重绘绘图区即可:绘制线程会在下一帧取到刚算好的曲线(双缓冲切换), + * 同时静态场景缓存会被作废以纳入新曲线。`WM_APP + 3` 与宿主其它自定义消息错开。 + */ + constexpr UINT kMessagePlotDerivedReady = WM_APP + 3; + /** * @brief 绘图区拖拽交互模式。 */ @@ -360,6 +373,16 @@ namespace jdi */ void InvalidateDerivedCaches(); + /** + * @brief 暂停后台派生曲线计算并作废其结果。 + * + * 后台线程会读取采样数据,因此**任何会改变数据层结构(增删文件/信号、插入派生信号, + * 进而移动 `DataFile`/`SignalData` 存储)的操作之前**都必须先调用本函数:它会等待正在 + * 进行的计算结束、清空待算队列与缓存,从而杜绝后台线程读取已释放内存。 + * 之后的绘制会按需重新启动后台线程(惰性)。 + */ + void PauseDerivedAsync(); + /** * @brief 三态循环切换时间光标:无 → 单光标 → 双光标 → 无。 * @return 状态发生变化返回 true。 @@ -1264,17 +1287,133 @@ namespace jdi * @param [IN] begin 需要的采样区间起点(含)。 * @param [IN] end 需要的采样区间终点(不含)。 * @param [OUT] spanBegin 返回数组对应的首个采样下标。 - * @param [IN] allowInteractiveDefer 交互期间是否允许「延迟重算」:窗口过大时复用已算好的 - * 旧曲线,避免在拖动滑块/移动视界时同步重算整条曲线(见 - * `kDerivedInteractiveComputeBudget`)。仅供**预览绘制**传 true; - * 需要精确取值的统计(中心轴边沿计数)必须传 false。 - * @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);无法计算时返回 nullptr。 + * @param [IN] allowAsyncCompute 是否允许「后台异步计算」:窗口很大时**不在绘制线程里计算**, + * 而是交后台线程算,本帧先用已算好的旧曲线顶替(双缓冲),保证交互始终跟手。 + * 仅供**预览绘制**传 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, - bool allowInteractiveDefer = false) const; + bool allowAsyncCompute = false) const; + + /** + * @brief 派生曲线缓存的键(决定一条派生曲线数值的全部输入)。 + * + * 数据层在两次结构变化之间是只读的,因此 `signal` 指针在此期间稳定; + * 任何结构变化(增删文件/信号、插入派生信号、切换标签页)前都会暂停后台线程并清空缓存 + *(见 `PauseDerivedAsync`),因此不会出现悬空读取。 + */ + struct CurveKey + { + const SignalData* signal = nullptr; /**< 目标信号。 */ + int kind = 0; /**< `DerivedCurveKind`。 */ + int points = 5; /**< 均值点数 X。 */ + int iterations = 1; /**< 迭代次数 N。 */ + int levelPercent = 0; /**< 弱波增强等级(0–100)。 */ + + bool operator==(const CurveKey& other) const + { + return signal == other.signal && kind == other.kind && points == other.points && + iterations == other.iterations && levelPercent == other.levelPercent; + } + }; + + /** + * @brief 一条已算好的派生曲线窗口(发布后**不可变**)。 + * + * 用 `shared_ptr` 持有:绘制线程取到后钉在 `m_curvePins` 中,整帧都引用 + * 同一块内存;后台线程只**追加**新条目、从不修改已发布条目,因此「显示旧缓冲、后台算新缓冲」 + * 的双缓冲模型天然无数据竞争。 + */ + struct CurveEntry + { + CurveKey key; /**< 曲线键。 */ + std::size_t begin = 0; /**< 已计算窗口起点(采样下标)。 */ + std::size_t end = 0; /**< 已计算窗口终点(不含)。 */ + std::uint64_t serial = 0; /**< 发布序号(越大越新,用于选出最新覆盖项)。 */ + std::vector values; /**< 曲线数值(长度 = end - begin)。 */ + }; + using CurveEntryPtr = std::shared_ptr; + + /** @brief 后台派生曲线计算任务(`windowBegin/End` 已含保护带)。 */ + struct CurveJob + { + CurveKey key; /**< 曲线键。 */ + std::size_t windowBegin = 0; /**< 计算窗口起点(含保护带)。 */ + std::size_t windowEnd = 0; /**< 计算窗口终点(不含)。 */ + std::uint64_t generation = 0; /**< 入队时的数据代次(代次变化即丢弃结果)。 */ + }; + + /** + * @brief 计算派生曲线「含保护带」的实际计算窗口(同步与后台共用同一公式)。 + * @param [IN] kind 曲线种类(均值滤波的保护带需覆盖滤波半径)。 + * @param [IN] alignedBegin 对齐后的需要区间起点。 + * @param [IN] alignedEnd 对齐后的需要区间终点(不含)。 + * @param [IN] points 均值点数 X。 + * @param [IN] iterations 迭代次数 N。 + * @param [IN] count 采样点总数。 + * @param [OUT] begin 计算窗口起点。 + * @param [OUT] end 计算窗口终点(不含)。 + */ + void DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin, std::size_t alignedEnd, + int points, int iterations, std::size_t count, std::size_t& begin, + std::size_t& end) const; + + /** + * @brief 取得覆盖 `[begin, end]` 的最新已算好条目并钉住(防止本帧被回收)。 + * @param [IN] key 曲线键。 + * @param [IN] begin 需要区间起点。 + * @param [IN] end 需要区间终点(不含)。 + * @param [OUT] spanBegin 命中条目的窗口起点。 + * @return 命中条目,未命中返回 nullptr。 + */ + const CurveEntry* AcquireCoveringCurve(const CurveKey& key, std::size_t begin, std::size_t end, + std::size_t& spanBegin) const; + + /** + * @brief 取得该曲线键下最新的已算好条目并钉住(双缓冲的「旧缓冲」)。 + * @param [IN] key 曲线键。 + * @param [OUT] spanBegin 命中条目的窗口起点。 + * @return 命中条目,无则返回 nullptr。 + */ + const CurveEntry* AcquireNewestCurve(const CurveKey& key, std::size_t& spanBegin) const; + + /** + * @brief 发布一条新算好的曲线窗口并钉住。 + * @param [IN] key 曲线键。 + * @param [IN] begin 窗口起点。 + * @param [IN] end 窗口终点(不含)。 + * @param [IN] values 曲线数值(移动入条目)。 + * @param [OUT] spanBegin 发布条目的窗口起点。 + * @return 发布条目的只读指针。 + */ + const CurveEntry* PublishCurve(const CurveKey& key, std::size_t begin, std::size_t end, + std::vector&& values, std::size_t& spanBegin) const; + + /** + * @brief 把一个派生曲线计算任务排入后台队列(同一曲线键只保留一个在算任务)。 + * @param [IN] key 曲线键。 + * @param [IN] alignedBegin 对齐后的需要区间起点。 + * @param [IN] alignedEnd 对齐后的需要区间终点(不含)。 + * @param [IN] count 采样点总数。 + */ + void ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin, std::size_t alignedEnd, + std::size_t count) const; + + /** @brief 惰性启动后台计算线程。 */ + void EnsureCurveWorker() const; + + /** @brief 停止并回收后台计算线程(等待在算任务结束)。 */ + void StopCurveWorker() const; + + /** @brief 后台计算线程主体。 */ + void CurveWorkerLoop() const; + + /** @brief 清理已被更新条目覆盖或超出内存预算的缓存条目(调用方须持有 `m_curveMutex`)。 */ + void PruneCurveEntriesLocked() const; /** * @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。 @@ -1600,36 +1739,21 @@ namespace jdi }; mutable std::deque m_weakBoostCurveCache; - /** - * @brief 派生曲线的「区间窗口」缓存项。 - * - * `values[i]` 对应采样下标 `begin + i`;`begin/end` 是**实际计算**的区间 - * (已向外扩了保护带),命中条件是「参数一致且窗口包含所需区间」。 - */ - struct CurveSpanEntry - { - const SignalData* signal = nullptr; /**< 目标信号。 */ - int kind = 0; /**< `DerivedCurveKind`。 */ - int points = 5; /**< 均值点数 X。 */ - int iterations = 1; /**< 迭代次数 N。 */ - int levelPercent = 0; /**< 弱波增强等级(0–100)。 */ - std::size_t begin = 0; /**< 已计算区间的起点(采样下标)。 */ - std::size_t end = 0; /**< 已计算区间的终点(不含)。 */ - std::vector values; /**< 曲线数值。 */ - }; - mutable std::deque m_curveSpanCache; /**< 区间窗口缓存(deque 保证引用稳定)。 */ - - /** - * @brief 「超大窗口」派生曲线的一次性缓冲。 - * - * 中心轴边沿计数在**没有限定光标区间**时要统计整条信号,对应窗口与信号等长 - *(亿级采样 ≈ 100MB/信号)。这类窗口绝不进 `m_curveSpanCache`: - * 一旦被淘汰,下一帧就会重算整条曲线(实测单次 ≈ 1s 的卡顿尖峰), - * 而且几个信号同时驻留会吃掉数百 MB。 - * 改为写入本缓冲:调用方(`EdgeCenterCountsFor`)在其返回后立即用完, - * 因此生命周期安全。⚠️ 指针仅在下一次「超大窗口」请求之前有效。 - */ - mutable std::vector m_curveSpanScratch; + // ---- 异步派生曲线计算(后台线程 + 双缓冲)---- + // 设计:预览曲线(均值滤波 / 弱波增强)在窗口很大时**不在绘制线程里计算**,而是交给后台 + // 线程;绘制线程本帧先用上一条已算好的曲线顶替(双缓冲),后台算完后通过 + // `kMessagePlotDerivedReady` 通知宿主重绘,从而切换为最新曲线。这样滑块 / 光标 / 视界 / + // 缩放等交互的反馈始终跟手,不会被计算拖住,同时把重计算放到后台充分利用多核。 + mutable std::mutex m_curveMutex; /**< 保护任务队列与已算好条目。 */ + mutable std::condition_variable m_curveCv; /**< 唤醒后台线程。 */ + mutable std::thread m_curveWorker; /**< 后台计算线程(惰性启动)。 */ + mutable bool m_curveWorkerStop = false; /**< 请求后台线程退出。 */ + mutable std::uint64_t m_curveGeneration = 0; /**< 数据代次(结构变化时自增)。 */ + mutable std::uint64_t m_curveSerial = 0; /**< 已发布条目序号。 */ + mutable std::deque m_curveJobs; /**< 待计算任务。 */ + mutable std::deque m_curveEntries; /**< 已算好条目(双缓冲的「就绪缓冲」)。 */ + mutable std::vector m_curvePins; /**< 本帧正在使用的条目(阻止其被回收)。 */ + mutable std::atomic m_curveSceneDirty{false}; /**< 后台发布了新条目 → 需重建静态场景缓存。 */ /** 中心轴边沿统计缓存项(增量统计机制与 `EdgeCountEntry` 完全一致)。 */ struct EdgeCenterCountEntry diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index d730a14..376d087 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -80,34 +80,20 @@ namespace jdi * 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。 */ const std::size_t kDerivedCurveGuardMax = 65536; - /** 区间窗口缓存条目上限(超出时淘汰最早的条目;它只是加速手段)。 */ + /** 派生曲线缓存条目上限(超出时按「最旧优先」淘汰;它只是加速与内存控制手段)。 */ const std::size_t kCurveSpanCacheLimit = 32; - /** - * @brief 进入「区间窗口缓存」的单条窗口采样数上限。 - * - * **必须足够大**,使「完全放大到整条信号」的窗口也能进缓存:否则每一帧(含静止时 - * 的鼠标悬停重绘)都要重算整条曲线 —— 弱波增强在 2576 万采样上单条 ≈368ms, - * 5 条网格就是 ≈1.8s/帧,交互完全无法进行。 - * - * 窗口一旦计算完成,同一参数下(平移/缩放/悬停)都能直接复用; - * 内存总量由 `kCurveSpanCacheMaxBytes` 兜底,因此这里不再按采样数排除大窗口。 - */ - const std::size_t kCurveSpanCacheMaxSamples = 67108864; - /** 区间窗口缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。 - * 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留, - * 否则缓存互相淘汰会导致每帧重算其中一条(弱波增强 ≈368ms 的卡顿尖峰)。 */ + /** 派生曲线缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。 + * 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留。 */ const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull; /** - * @brief 交互期间允许**同步重算**的派生曲线窗口采样数上限。 + * @brief 派生(预览)曲线走「后台异步计算」的窗口采样数门槛。 * - * 拖动滑块 / 移动视界 / 缩放等交互每帧都会请求派生(预览)曲线。窗口不大时同步重算 - * 足够快(几毫秒),预览曲线能实时跟随控件;一旦窗口超过本上限(例如大时间跨度或 - * 整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms)。 - * - * 此时**不在交互过程中重算**,而是复用上一次算好的曲线(位置由当前视图映射,仍准确), - * 等交互结束后再按最终参数精确重算 —— 控件(滑块/光标/视界)的反馈因此始终跟手。 + * 拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线。窗口不超过本门槛时同步重算足够快 + * (弱波增强 ≈0.9ms/6.5 万采样),预览曲线能**实时跟随控件**;一旦窗口超过门槛 + * (大时间跨度、整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms), + * 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。 */ - const std::size_t kDerivedInteractiveComputeBudget = 131072; + const std::size_t kDerivedAsyncWindowThreshold = 65536; const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */ @@ -300,6 +286,47 @@ namespace jdi /** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */ const int kEdgePanelKindCount = static_cast(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])); + /** + * @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。 + * + * 只拷贝所需的窗口,再调用与「构建为新信号」完全相同的自由函数,因此 + * 预览曲线与构建出的采样值逐位一致。本函数**只读**入参、不接触任何成员状态, + * 因此可以安全地在后台线程调用。 + * + * @param [IN] signal 目标信号(数据只读)。 + * @param [IN] windowBegin 窗口起点(采样下标)。 + * @param [IN] windowEnd 窗口终点(不含)。 + * @param [IN] kind `DerivedCurveKind` 的整型值。 + * @param [IN] points 均值点数 X。 + * @param [IN] iterations 迭代次数 N。 + * @param [IN] levelPercent 弱波增强等级(0–100)。 + * @return 曲线数值(长度 = 有效窗口长度)。 + */ + std::vector ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin, + std::size_t windowEnd, int kind, int points, + int iterations, int levelPercent) + { + std::vector out; + const std::vector& values = signal.values; + const std::size_t hi = std::min(windowEnd, values.size()); + const std::size_t lo = std::min(windowBegin, hi); + if (hi <= lo) + { + return out; + } + const std::vector window(values.begin() + static_cast(lo), + values.begin() + static_cast(hi)); + if (kind == static_cast(DerivedCurveKind::WEAK_BOOST)) + { + ComputeWeakWaveBoost(window, static_cast(levelPercent) / 100.0, iterations, out); + } + else + { + ComputeMovingAverageIterated(window, points, iterations, out); + } + return out; + } + /** * @brief 包络带的单个像素列(已映射到屏幕坐标)。 */ @@ -642,6 +669,8 @@ namespace jdi PlotView::~PlotView() { + // 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。 + StopCurveWorker(); ReleaseSceneCache(); ReleasePanelCache(); } @@ -1254,13 +1283,14 @@ namespace jdi void PlotView::InvalidateDerivedCaches() { + // 后台线程可能在读取采样数据:先暂停并作废异步结果,再清同步缓存。 + PauseDerivedAsync(); m_polylineCache.clear(); // 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。 m_edgeCountCache.clear(); m_edgeCenterCountCache.clear(); m_meanCurveCache.clear(); m_weakBoostCurveCache.clear(); - m_curveSpanCache.clear(); // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 m_hoverSignal = SignalRef(); m_hoverLegend = SignalRef(); @@ -1269,6 +1299,18 @@ namespace jdi InvalidateSceneCache(); } + void PlotView::PauseDerivedAsync() + { + // 等待在算任务结束,然后清空队列与缓存,并自增代次使任何迟到结果作废。 + StopCurveWorker(); + std::lock_guard lock(m_curveMutex); + ++m_curveGeneration; + m_curveJobs.clear(); + m_curveEntries.clear(); + m_curvePins.clear(); + m_curveSceneDirty.store(false, std::memory_order_relaxed); + } + bool PlotView::FitTimeToLane(int laneIndex) { if (m_dataSet == nullptr || m_tab == nullptr) @@ -2124,7 +2166,7 @@ namespace jdi int points, int iterations, int levelPercent, std::size_t begin, std::size_t end, std::size_t& spanBegin, - bool allowInteractiveDefer) const + bool allowAsyncCompute) const { spanBegin = 0; const std::size_t count = signal.values.size(); @@ -2149,135 +2191,284 @@ namespace jdi const std::size_t alignedBegin = (begin / grid) * grid; const std::size_t alignedEnd = std::min(count, ((end + grid - 1) / grid) * grid); - for (const CurveSpanEntry& entry : m_curveSpanCache) + CurveKey key; + key.signal = &signal; + key.kind = kindValue; + key.points = safePoints; + key.iterations = safeIterations; + key.levelPercent = safeLevel; + + // 1) 已有覆盖请求区间的最新条目 → 直接复用(钉住,防止本帧被回收)。 + if (const CurveEntry* hit = AcquireCoveringCurve(key, begin, end, spanBegin)) { - if (entry.signal == &signal && entry.kind == kindValue && entry.points == safePoints && - entry.iterations == safeIterations && entry.levelPercent == safeLevel && - entry.begin <= begin && entry.end >= end) - { - spanBegin = entry.begin; - return &entry.values; - } + return &hit->values; } - // ⚠️ 交互期间的「延迟重算」:拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线, - // 若窗口很大(例如大时间跨度、整条信号),同步重算会阻塞交互数毫秒到数百毫秒 - // (弱波增强整条信号单条 ≈368ms)。此时**不重算**,改复用上一次算好的曲线: - // 曲线下标映射到时间仍是当前视图的映射,因此位置依旧准确,只是参数/覆盖范围可能是 - // 上一帧的;等交互结束后(`m_dragMode == NONE`)再按最终参数精确重算。 - // 这样滑块 / 光标 / 视界移动的反馈始终跟手,不会被绘制计算拖住。 - if (allowInteractiveDefer && m_dragMode != PlotDragMode::NONE && - (end - begin) > kDerivedInteractiveComputeBudget) + // 2) 未就绪且窗口很大:预览曲线交后台计算,本帧先用最新旧曲线顶替(双缓冲)。 + // 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。 + if (allowAsyncCompute && (end - begin) > kDerivedAsyncWindowThreshold) { - const CurveSpanEntry* covering = nullptr; - const CurveSpanEntry* newest = nullptr; - for (auto it = m_curveSpanCache.rbegin(); it != m_curveSpanCache.rend(); ++it) + ScheduleCurveJob(key, alignedBegin, alignedEnd, count); + if (const CurveEntry* stale = AcquireNewestCurve(key, spanBegin)) { - if (it->signal != &signal || it->kind != kindValue) + return &stale->values; + } + return nullptr; // 尚无任何可显示曲线:本帧跳过该预览曲线(不阻塞绘制) + } + + // 3) 窗口较小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。 + std::size_t computedBegin = 0; + std::size_t computedEnd = 0; + DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count, + computedBegin, computedEnd); + if (computedEnd <= computedBegin) + { + return nullptr; + } + std::vector values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue, + safePoints, safeIterations, safeLevel); + const CurveEntry* published = + PublishCurve(key, computedBegin, computedEnd, std::move(values), spanBegin); + return (published != nullptr) ? &published->values : nullptr; + } + + void PlotView::DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin, + std::size_t alignedEnd, int points, int iterations, + std::size_t count, std::size_t& begin, std::size_t& end) const + { + // 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播, + // 1/8 个可见宽度足以覆盖真实信号的极值间距。 + // 均值滤波必须额外**覆盖滤波半径**:N 阶共向外传播 X*N 个采样,否则窗口边界取值 + // 会与「整条曲线」不同(边界上可用采样数更少)。 + std::size_t guard = + std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), kDerivedCurveGuardMax); + if (kind == DerivedCurveKind::MEAN_FILTER) + { + const std::size_t reach = + static_cast(points + 1) * static_cast(iterations) + 64u; + guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax); + } + begin = (alignedBegin > guard) ? (alignedBegin - guard) : 0; + end = std::min(count, alignedEnd + guard); + } + + const PlotView::CurveEntry* PlotView::AcquireCoveringCurve(const CurveKey& key, std::size_t begin, + std::size_t end, + std::size_t& spanBegin) const + { + std::lock_guard lock(m_curveMutex); + const CurveEntryPtr* best = nullptr; + for (const CurveEntryPtr& entry : m_curveEntries) + { + if (entry->key == key && entry->begin <= begin && entry->end >= end) + { + if (best == nullptr || entry->serial > (*best)->serial) + { + best = &entry; + } + } + } + if (best == nullptr) + { + return nullptr; + } + m_curvePins.push_back(*best); // 钉住:保证本帧内指针有效 + spanBegin = (*best)->begin; + return best->get(); + } + + const PlotView::CurveEntry* PlotView::AcquireNewestCurve(const CurveKey& key, + std::size_t& spanBegin) const + { + std::lock_guard lock(m_curveMutex); + const CurveEntryPtr* newest = nullptr; + for (const CurveEntryPtr& entry : m_curveEntries) + { + if (entry->key == key) + { + if (newest == nullptr || entry->serial > (*newest)->serial) + { + newest = &entry; + } + } + } + if (newest == nullptr) + { + return nullptr; + } + m_curvePins.push_back(*newest); + spanBegin = (*newest)->begin; + return newest->get(); + } + + const PlotView::CurveEntry* PlotView::PublishCurve(const CurveKey& key, std::size_t begin, + std::size_t end, std::vector&& values, + std::size_t& spanBegin) const + { + std::shared_ptr entry = std::make_shared(); + entry->key = key; + entry->begin = begin; + entry->end = end; + entry->values = std::move(values); + { + std::lock_guard lock(m_curveMutex); + entry->serial = ++m_curveSerial; + m_curveEntries.push_back(entry); + } + m_curvePins.push_back(entry); + spanBegin = begin; + return entry.get(); + } + + void PlotView::ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin, + std::size_t alignedEnd, std::size_t count) const + { + std::size_t windowBegin = 0; + std::size_t windowEnd = 0; + DerivedGuardWindow(static_cast(key.kind), alignedBegin, alignedEnd, key.points, + key.iterations, count, windowBegin, windowEnd); + if (windowEnd <= windowBegin) + { + return; + } + { + std::lock_guard lock(m_curveMutex); + for (const CurveJob& job : m_curveJobs) + { + if (job.key == key) + { + return; // 该曲线已有在算任务,避免任务堆积 + } + } + CurveJob job; + job.key = key; + job.windowBegin = windowBegin; + job.windowEnd = windowEnd; + job.generation = m_curveGeneration; + m_curveJobs.push_back(job); + } + EnsureCurveWorker(); + m_curveCv.notify_one(); + } + + void PlotView::EnsureCurveWorker() const + { + if (m_curveWorker.joinable()) + { + return; + } + m_curveWorkerStop = false; + m_curveWorker = std::thread([this]() { CurveWorkerLoop(); }); + } + + void PlotView::StopCurveWorker() const + { + if (!m_curveWorker.joinable()) + { + return; + } + { + std::lock_guard lock(m_curveMutex); + m_curveWorkerStop = true; + m_curveJobs.clear(); + } + m_curveCv.notify_all(); + m_curveWorker.join(); + } + + void PlotView::CurveWorkerLoop() const + { + for (;;) + { + CurveJob job; + { + std::unique_lock lock(m_curveMutex); + m_curveCv.wait(lock, [this]() { return m_curveWorkerStop || !m_curveJobs.empty(); }); + if (m_curveJobs.empty()) + { + return; // 仅在 stop 且队列为空时到达 + } + job = m_curveJobs.front(); + m_curveJobs.pop_front(); + if (job.generation != m_curveGeneration) + { + continue; // 代次已变化:丢弃过期任务 + } + } + + // 计算在锁外进行:读取的采样在「结构未变」期间只读、且地址稳定 + //(结构变化前宿主会先 `PauseDerivedAsync`)。 + std::vector values = + ComputeDerivedWindow(*job.key.signal, job.windowBegin, job.windowEnd, job.key.kind, + job.key.points, job.key.iterations, job.key.levelPercent); + + { + std::lock_guard lock(m_curveMutex); + if (job.generation != m_curveGeneration) + { + continue; // 计算期间发生了结构变化:丢弃,避免发布过期结果 + } + std::shared_ptr entry = std::make_shared(); + entry->key = job.key; + entry->begin = job.windowBegin; + entry->end = job.windowEnd; + entry->serial = ++m_curveSerial; + entry->values = std::move(values); + m_curveEntries.push_back(std::move(entry)); + } + + // 通知宿主重绘:下一帧会取到刚发布的曲线(双缓冲切换)。 + m_curveSceneDirty.store(true, std::memory_order_relaxed); + const HWND host = m_host; + if (host != nullptr) + { + PostMessageW(host, kMessagePlotDerivedReady, 0, 0); + } + } + } + + void PlotView::PruneCurveEntriesLocked() const + { + // 1) 去掉被「同键、更新、且完全覆盖」的条目取代的旧条目。 + std::deque kept; + for (const CurveEntryPtr& entry : m_curveEntries) + { + bool superseded = false; + for (const CurveEntryPtr& other : m_curveEntries) + { + if (other.get() == entry.get()) { continue; } - if (newest == nullptr) + if (other->key == entry->key && other->serial > entry->serial && + other->begin <= entry->begin && other->end >= entry->end) { - newest = &*it; - } - if (it->begin <= begin && it->end >= end) - { - covering = &*it; + superseded = true; break; } } - const CurveSpanEntry* fallback = (covering != nullptr) ? covering : newest; - if (fallback != nullptr) + if (!superseded) { - spanBegin = fallback->begin; - return &fallback->values; + kept.push_back(entry); } - // 完全没有可复用的曲线:本帧跳过该预览曲线(不阻塞交互)。 - return nullptr; } + m_curveEntries.swap(kept); - // 未命中:向外扩一个保护带后再计算,保证可见区间内的取值与"整条曲线"一致。 - // 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播 - // (每迭代一次传一个极值),1/8 个可见宽度足以覆盖真实信号的极值间距。 - // - // 均值滤波的保护带必须**覆盖滤波半径**:第 k 阶的输出在 i 处依赖第 k-1 阶的 - // [i-X, i+X] 取样,N 阶总共向外传播 X*N 个采样。否则窗口边界处的取值会与 - // 「整条曲线」不同(边界上的可用采样数更少)。 - std::size_t guard = std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), - kDerivedCurveGuardMax); - if (kind == DerivedCurveKind::MEAN_FILTER) - { - const std::size_t reach = static_cast(safePoints + 1) * - static_cast(safeIterations) + 64u; - guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax); - } - const std::size_t computedBegin = (alignedBegin > guard) ? (alignedBegin - guard) : 0; - const std::size_t computedEnd = std::min(count, alignedEnd + guard); - - // 只把这个窗口拷进自由函数(它们按整段向量工作)。 - const std::vector& values = signal.values; - const std::vector window(values.begin() + static_cast(computedBegin), - values.begin() + static_cast(computedEnd)); - - // ⚠️ 超大窗口(例如「中心轴边沿计数」的整条信号统计)不进缓存: - // 进缓存迟早会被淘汰,淘汰后下一帧就要重算整条曲线 —— 实测单次 ≈1s 的卡顿尖峰, - // 而且多个信号同时驻留会占掉数百 MB。改为写入一次性缓冲。 - if ((computedEnd - computedBegin) > kCurveSpanCacheMaxSamples) - { - m_curveSpanScratch.clear(); - if (kind == DerivedCurveKind::WEAK_BOOST) - { - ComputeWeakWaveBoost(window, static_cast(safeLevel) / 100.0, safeIterations, - m_curveSpanScratch); - } - else - { - ComputeMovingAverageIterated(window, safePoints, safeIterations, m_curveSpanScratch); - } - spanBegin = computedBegin; - return &m_curveSpanScratch; - } - - // 缓存窗口:条目数与总字节数双重约束,避免大窗口把内存吃满。 - // 淘汰策略为「最旧优先」(`deque` 头部即最早写入的)。 - const auto cacheBytes = [this]() -> std::size_t + // 2) 条目数与总字节数双重约束(最旧优先淘汰)。 + const auto bytes = [this]() -> std::size_t { std::size_t total = 0; - for (const CurveSpanEntry& cached : m_curveSpanCache) + for (const CurveEntryPtr& entry : m_curveEntries) { - total += cached.values.size() * sizeof(float); + total += entry->values.size() * sizeof(float); } return total; }; - while (!m_curveSpanCache.empty() && - (m_curveSpanCache.size() >= kCurveSpanCacheLimit || - cacheBytes() + (computedEnd - computedBegin) * sizeof(float) > - kCurveSpanCacheMaxBytes)) + while (!m_curveEntries.empty() && + (m_curveEntries.size() > kCurveSpanCacheLimit || bytes() > kCurveSpanCacheMaxBytes)) { - m_curveSpanCache.pop_front(); + m_curveEntries.pop_front(); } - m_curveSpanCache.push_back(CurveSpanEntry()); - CurveSpanEntry& entry = m_curveSpanCache.back(); - entry.signal = &signal; - entry.kind = kindValue; - entry.points = safePoints; - entry.iterations = safeIterations; - entry.levelPercent = safeLevel; - entry.begin = computedBegin; - entry.end = computedEnd; - - if (kind == DerivedCurveKind::WEAK_BOOST) - { - ComputeWeakWaveBoost(window, static_cast(safeLevel) / 100.0, safeIterations, - entry.values); - } - else - { - ComputeMovingAverageIterated(window, safePoints, safeIterations, entry.values); - } - spanBegin = entry.begin; - return &m_curveSpanCache.back().values; } const std::vector& PlotView::MeanFilterCurveFor(const SignalData& signal, int points, @@ -5250,6 +5441,19 @@ namespace jdi BuildLaneVisuals(); + // 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 / + // 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。 + m_curvePins.clear(); + { + std::lock_guard lock(m_curveMutex); + PruneCurveEntriesLocked(); + } + // 后台算好了新曲线:静态场景缓存里仍是旧曲线(或还没有曲线),作废一次以纳入最新结果。 + if (m_curveSceneDirty.exchange(false, std::memory_order_relaxed)) + { + InvalidateSceneCache(); + } + if (m_lanes.empty()) { InvalidateSceneCache(); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 4ecb837..d75b90f 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -217,6 +217,17 @@ Documents/ 项目文档(含本文件) - **已知残留**(不牺牲显示时的固有成本):把可见时间跨度拉到接近整段且开启弱波增强时,**首次**计算某参数/窗口的预览曲线仍需要一次 ≈1.8s(5 条整段曲线,各 ≈368ms)—— 交互期间不会触发(已延迟),但交互结束后的那一帧会补上。这是"整段弱波增强 O(n) 计算"的固有代价;若要进一步消除需把该计算移到后台线程(尚未做,改动较大)。 - **为什么这样改不牺牲显示效果**:本次只改「何时计算 / 是否缓存」,不改任何几何或取值算法;`mode=1` 的逐像素比对(均值滤波 + 弱波增强,两个缩放级别,含光标取值胶囊)结果为 **0 像素差异**。 +### 9.6 架构:派生曲线计算与 UI 绘制分离(后台线程 + 双缓冲) +- **动机/结论**:界面卡顿的根因是「**重计算发生在绘制线程里**」(`PlotView::Paint` 内同步算派生曲线)。只要某次 `Paint` 要算整段弱波增强(单条 ≈368ms),消息循环就被阻塞,滑块/光标/视界全部失去跟手。因此把**预览曲线的重计算移到后台线程**,绘制线程永不等计算。 +- **实现**(`Components/PlotView/`,全部改动仅在 `PlotView.hpp/.cpp` 与宿主 `Application.cpp`): + - **后台工作线程**:`m_curveWorker` + 任务队列(`m_curveJobs`)+ `condition_variable`(惰性启动、析构/结构变化时 `StopCurveWorker` join)。线程只读采样、在锁外计算,算完把结果作为**不可变条目**(`shared_ptr`)追加进 `m_curveEntries`,再 `PostMessage(kMessagePlotDerivedReady)` 通知宿主重绘。 + - **双缓冲**:`CurveSpanFor` 命中覆盖区间的条目就直接用;未命中且**窗口大**(> `kDerivedAsyncWindowThreshold` = 65536 采样)时**本帧不计算**,改用该曲线**最新的旧条目**顶替(`AcquireNewestCurve`),同时排一个后台任务;窗口**小**(≤门槛)仍同步计算以保证预览**实时跟随**滑块。条目用 `shared_ptr` 共享、绘制线程取到后钉进 `m_curvePins` 保持整帧有效,后台只追加不改写 → 无数据竞争。 + - **一致性与生命周期**:后台发布新条目后置 `m_curveSceneDirty`,下一帧作废静态场景缓存以纳入新曲线;**任何会改变数据层结构(增删文件/信号、插入派生信号)的操作前**,宿主先调用 `PlotView::PauseDerivedAsync()`(join 后台线程 + 清队列/缓存 + 自增代次),杜绝后台线程读取已释放/已移动的采样。`InvalidateDerivedCaches()` 内部也会 `PauseDerivedAsync()`。 + - 精确取值路径(中心轴边沿计数 `EdgeCenterCountsFor`、取值胶囊需要精确值时)传 `allowAsyncCompute = false`,走同步计算。 +- **实测**(离屏,`VERY_BIG_DATA`,5 网格;单位 ms/帧):弱波增强平移(整段)**2247 → 15**;弱波增强拖滑块(整段)**2303 → 15**;弱波增强静止重绘 **0.25**;后台结算后的全质量帧 ≈19ms/帧(一次性)。所有交互均 ≤ ~30ms,**不再有 300ms/2s 级尖峰**。 +- **显示效果**:小窗口(同步)路径与旧实现**逐像素 0 差异**;大窗口(异步)路径的曲线像素与旧实现一致(差异仅出现在合成测试中一个与缓存相关的**旧实现的时轴陈旧**现象上,新实现按设定视图正确绘制)。后台完成后的全质量帧与同步结果相同。 +- **代价**:大窗口缓存条目可占用较多内存(整段弱波增强单条 ≈104MB/信号),由 `kCurveSpanCacheMaxBytes`(768MB)+ 条目数上限(32)兜底,最旧优先淘汰。 + --- ## 10. 变更日志(本文件维护记录) @@ -226,3 +237,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)、计算缓冲复用;显示逐像素不变。 | +| 2026-10-11 | DeepSeek-V4.1-Flash | 架构升级:**派生曲线计算与 UI 绘制分离**(后台线程 + 双缓冲)。绘制线程永不等计算,弱波增强拖滑块/平移从 2303/2247ms 降到 ~15ms;结构变化前 `PauseDerivedAsync` 保证线程安全;小窗口同步路径与旧实现逐像素 0 差异。 |