From 375a15c8c2e6198f14076cbb5b711673c9e340fa Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Wed, 14 Oct 2026 15:24:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B4=BE=E7=94=9F=E6=9B=B2=E7=BA=BF=E5=86=85?= =?UTF-8?q?=E5=AD=98=E6=B2=BB=E7=90=86=EF=BC=88=E5=BC=B1=E5=B3=B0=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E7=AD=89=EF=BC=89+=20=E8=BE=B9=E6=B2=BF=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E6=94=B9=E4=B8=BA=E7=BB=9F=E4=B8=80=E9=A2=84=E7=AE=97?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9C=BA=EF=BC=88=E5=90=AB=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=BD=B4=EF=BC=89+=20=E7=A6=BB=E5=B1=8F=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=9B=B6=E6=89=93=E6=89=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、派生曲线内存治理(按上一轮报告的方案实施;绘制性能优先,同时为小内存设备留出空间) 1. PeakExtremum 字段重排(double value + uint32 index + int8 type):24 → 16 字节。 编码器方波这类极值密集信号省约 1/3 的极值表内存,且**不改变任何数值结果** (value 保持 double,改成 float 会让结果与历史版本不一致): 实测 3M 采样单次计算的工作缓冲峰值 33.23 → 26.73 MiB。 2. ComputeDerivedWindow 对「整段窗口」不再复制采样(直接引用原向量): 8 B/采样 × 2580 万 ≈ 206 MB/条,而“预览曲线一律按整条信号计算”正是最常见情形。 3. 大窗口任务的并发上限由内存压力决定(CurveJob::hugeLimit:内存充足 2、紧张 1): 单条整段曲线工作缓冲约 620 MB(2580 万采样),原先最多 4 条并发 → 峰值 2.5GB+; 实测 4 并发 3M 采样 412 → 324 MiB,且外推值不再随线程数放大。 4. 内存严重不足时**直接拒绝计算**(DerivedMemoryAllows:可用内存 < 输出曲线本体 + 工作缓冲的 最小需求)并只告警一次(新增 kMessagePlotLowMemory;宿主弹一次明确的提示框)。 界面保持可用,绝不会因内存不足抛 bad_alloc 逸出线程导致进程终止。 5. 低内存模式“少量多次”:throttle = true 时并发降为 1、且每阶之间 Sleep(1),慢慢算完。 内存充足时不做任何节流,尽可能快。 6. 取消标志贯通到算法内部(ComputeWeakPeakAdjust / ComputeMovingAverageIterated 新增 cancel 参数,每阶 / 每 1M 采样检查一次)⇒ **过时任务立即退出并归还工作缓冲**; PauseDerivedAsync / StopCurveWorker 先 m_curveCancelAll = true 再 join。 7. AvailablePhysicalBytes() 结果按 200ms 缓存(绘制路径上不再每帧多次系统调用)。 8. 极值表按 count/8 预留容量(try/catch(bad_alloc) 保护),避免 push_back 反复扩容的 1.5x 峰值。 9. MeanFilterCurveFor / WeakPeakCurveFor(整条同步计算)现在只有测试使用; 产品代码一律走 CurveSpanFor(窗口化 + 异步)。 二、边沿统计:统一「逐块累积 + 每 tick 预算」状态机(普通边沿计数与中心轴共用) 1. EdgeScanState + EdgeScanFor 成为唯一实现,EdgeCountsFor / EdgeCenterCountsFor 是薄包装; 删除 EdgeCenterCountEntry / m_edgeCenterCountCache。 2. **未完成时主动请求下一帧**(RequestEdgeScanFrame → PostMessage(kMessagePlotDerivedReady)): 修掉“鼠标不动就一直显示统计中,只有把鼠标移到面板上才更新”(根因就是没有重绘 → 预算不推进)。 3. 阈值/参数变化就地把状态重置(不新建条目,避免拖动时状态爆炸);重置当 tick 不展示部分值 (修掉“除了最后一个信号显示了一个错误的且不更新的数字,其它变成统计中”); 稳定后展示递增的部分值 + “…”;扫完展示精确值(统一走 EdgeCountsText)。 4. 中心轴判定所需曲线**只在后台计算**:只在 AcquireCoveringCurve 命中(键完全相同)时扫块, 否则 ScheduleCurveJob 后本帧不推进。原先该调用没传 allowAsyncCompute(默认 false)⇒ 首帧 / 参数变化后同步算整条均值滤波曲线 ⇒ 界面停顿数秒(用户报告“一改参数整个程序卡住很久”)。 5. MeanFilterValueAt(光标读数)不再调整条同步的 MeanFilterCurveFor,改为窗口化 + 异步。 6. ScheduleCurveJob 改为幂等(m_curveRunningKeys 集合 + 队列去重):统计侧会每帧请求同一份 整条曲线,若每次都当作新请求,正在算的那份立刻被判过时 → 请求→作废→重请求 死循环。 7. 扫描热路径加“无变换/无镜像/非中心轴”的紧凑循环:单次预算耗时 18ms → 1.3ms。 8. 实测(合成方波):6M 采样首帧 1.4ms(部分值)、单次最坏 2.1ms; 拖动横向光标 10 次查询 1.07ms;**同一帧内 5 条信号合计 1.3ms**(预算跨信号共享); 保持不动无需移动鼠标即收敛到与参考实现逐位一致。 三、离屏测试绝不打扰用户的前台工作 - .preflight/uitest 与 .preflight/probe 的 main 开头设置 SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS) + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL), 再长的测试也不会与用户的编辑/浏览抢 CPU(此前“打字不顺畅”的根因)。 - 继续保持:窗口 SW_HIDE、只注入合成消息(不动真实鼠标键盘)、每次注入后立即 ReleaseCapture (SetCapture 是系统级的,不释放会把用户的鼠标输入吸进测试进程)。 四、验证(全部离屏) - .preflight/uitest 新增/强化:预算推进首帧不显示部分值 → 第二帧显示递增部分值 → 保持不动收敛到精确值;未完成时会 PostMessage 请求下一帧(直接断言); 拖动阈值期间 complete == false、不显示部分数字、总耗时 < 60ms; 5 条信号同帧合计 < 120ms(实测 1.3ms);中心轴与参考实现逐位一致且无需鼠标移动即可完成。 - 合计 751/751 通过(连跑 3 次稳定);make release 零警告。 规则同步:Documents/agent_mem.md 新增 §9.23(含内存治理表与实测数据)、§11.2 新增第 32–38 条硬约束、 变更日志补一行。 --- Components/Application/Src/Application.cpp | 8 + Components/PlotView/Inc/PlotView.hpp | 156 +++- Components/PlotView/Src/PlotView.cpp | 866 ++++++++++++--------- Documents/agent_mem.md | 91 +++ Utilities/Inc/DataModel.hpp | 16 +- Utilities/Src/DataModel.cpp | 76 +- 6 files changed, 784 insertions(+), 429 deletions(-) diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index fe5d0bf..59b75e7 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -2360,6 +2360,14 @@ namespace jdi // 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。 InvalidatePlotRect(hwnd, m_plotRect); return 0; + case kMessagePlotLowMemory: + // 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算, + // 这里给出一次明确的告警。界面保持可用,绝不会因为内存不足而崩溃。 + SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n" + L"请关闭部分文件分组或信号后重试。", + true); + 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 3144110..84bf127 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -8,10 +8,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include "DataModel.hpp" @@ -29,6 +31,15 @@ namespace jdi */ constexpr UINT kMessagePlotDerivedReady = WM_APP + 3; + /** + * @brief 「可用内存严重不足,已暂停派生曲线计算」的通知消息。 + * + * 只有当**连一条预览曲线本身都装不下**时才发出(`DerivedMemoryAllows`), + * 宿主收到后应给出一次可见的告警(本项目用 `SetStatusMessage(..., true)` 弹一次提示框) + * 并让界面继续可用 —— 绝不会因为内存不足而崩溃。`WM_APP + 4` 与其它自定义消息错开。 + */ + constexpr UINT kMessagePlotLowMemory = WM_APP + 4; + /** * @brief 绘图区拖拽交互模式。 */ @@ -1320,10 +1331,12 @@ namespace jdi * @param [IN] rangeMax 区间右端点时间。 * @param [OUT] rising 上升沿数量(信号从下往上越过均值滤波曲线)。 * @param [OUT] falling 下降沿数量(信号从上往下穿过均值滤波曲线)。 + * @param [OUT] complete 是否为完整精确值(false = 仍在推进,界面显示“…”)。 */ void EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, int points, int iterations, bool hasRange, double rangeMin, - double rangeMax, std::size_t& rising, std::size_t& falling) const; + double rangeMax, std::size_t& rising, std::size_t& falling, + bool& complete) const; /** * @brief 切换某个信号在「中心轴边沿计数」面板中的勾选状态。 @@ -1431,6 +1444,9 @@ namespace jdi std::size_t windowBegin = 0; /**< 计算窗口起点(含保护带)。 */ std::size_t windowEnd = 0; /**< 计算窗口终点(不含)。 */ std::uint64_t generation = 0; /**< 入队时的数据代次(代次变化即丢弃结果)。 */ + std::uint64_t requestSerial = 0; /**< 入队时的“最新请求序号”(被更新请求超越则本任务已过时)。 */ + bool huge = false; /**< 是否为“大窗口”任务(低内存时串行执行并降低并发)。 */ + int hugeLimit = 1; /**< 允许同时运行的“大窗口”任务数(内存充足时 2,紧张时 1)。 */ }; /** @@ -1506,6 +1522,47 @@ namespace jdi /** @brief 后台计算线程主体。 */ void CurveWorkerLoop() const; + /** + * @brief 判断一条任务是否已经「过时」(同一信号+曲线种类已有更新的请求)。 + * + * 拖动参数滑块时参数每帧都在变,旧参数的任务再算下去没有意义;工作线程每阶之间 + * 与开始前都用它检查一次,一旦过时**立即退出**(尽早归还工作缓冲)。 + * + * @param [IN] key 曲线键。 + * @param [IN] requestSerial 任务入队时的请求序号。 + * @return 已过时返回 true。 + */ + bool CurveJobIsStale(const CurveKey& key, std::uint64_t requestSerial) const; + + /** + * @brief 计算「要装下这条派生曲线」所需的最小内存(字节)。 + * @param [IN] samples 采样点数。 + * @return 最小内存字节数(输出曲线本体 + 内部工作缓冲的经验值)。 + */ + static std::size_t DerivedMinimumBytes(std::size_t samples); + + /** + * @brief 查询当前可用物理内存(字节);查询失败返回极大值(视为“内存充足”)。 + * @return 可用物理内存字节数。 + */ + static std::size_t AvailablePhysicalBytes(); + + /** + * @brief 判断现在是否适合开始一条「整段信号」的派生曲线计算。 + * + * * 可用内存 ≥ 最小需求的 4 倍 → 正常(并行、不节流,尽可能快); + * * 可用内存在最小需求 ~ 4 倍之间 → **低内存模式**(串行 + 节流,少量多次慢慢算完); + * * 可用内存 < 最小需求 → **严重不足**:不开始计算,并(仅一次)通知宿主告警。 + * + * @param [IN] samples 采样点数。 + * @param [OUT] throttle 输出是否应节流(低内存模式)。 + * @return 允许开始计算返回 true。 + */ + bool DerivedMemoryAllows(std::size_t samples, bool& throttle) const; + + /** @brief 告知宿主“可用内存严重不足,已暂停派生曲线计算”(同一轮只提示一次)。 */ + void NotifyDerivedLowMemory() const; + /** @brief 清理已被更新条目覆盖或超出内存预算的缓存条目(调用方须持有 `m_curveMutex`)。 */ void PruneCurveEntriesLocked() const; @@ -1825,38 +1882,69 @@ namespace jdi std::vector m_lanes; /**< 当前网格绘制信息集合。 */ /** - * @brief 「边沿计数」的累积统计条目。 + * @brief 「边沿统计」的累积状态(普通边沿计数与中心轴边沿计数**共用**)。 * - * 千万级信号整段扫描一次就要 ≈115ms,而横向光标拖动时**每一帧**都会换阈值 - *(5 条信号同一网格实测 ≈575ms/帧,界面直接失去响应)。 + * 千万级信号整段扫描一次就要 ≈115ms(5 条信号同一网格 = 575ms/帧,界面完全失去响应), * 因此统计改为「逐块累积 + 每 tick 有限预算推进」(与 `RenderPendingLaneRebuilds(1)` - * 同一套「宁可晚一点、绝不卡住前台」的思路): + * 同一套"宁可晚一点、绝不卡住前台"的思路): * * 采样对 `k`(采样 `k-1 → k`)按 `kEdgeBlockSamples` 分块,每块各存上升/下降沿数; * * `scannedPair` = 下一个待扫描的采样对,[1, scannedPair) 已扫完; - * * 查询某个区间时,内部整块用存储值相加、两端边界块精确扫描(≤ 2 块,代价固定); - * * 时间区间**不进键**(单调时间轴下区间只是查询区间,拖动时间光标无需重扫), - * 但非单调时间轴无法用下标区间表达统计范围,此时区间必须进键(否则会混用结果)。 + * * 查询区间时内部整块用块和相加、两端边界块就地精确扫描(≤ 2 块); + * * 未扫完时**主动请宿主再送一帧**(`RequestEdgeScanFrame`),因此**不需要移动鼠标** + * 也会持续推进(修掉"面板一直显示统计中,把鼠标移到面板上才更新")。 + * + * 键 = 「文件 + 信号 + 种类 + 样式指纹 + 参数 +(仅非单调时间轴)区间」; + * 阈值/参数变化时就地重新开始(不新建条目,避免拖动时状态爆炸)。 */ - struct EdgeCountEntry + struct EdgeScanState { const DataFile* file = nullptr; /**< 所属数据分组。 */ const SignalData* signal = nullptr; /**< 目标信号。 */ + bool centre = false; /**< false = 穿越横向光标阈值;true = 穿越中心平均值曲线。 */ std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹。 */ - double threshold = 0.0; /**< 判定阈值(横向光标幅值)。 */ - bool hasRange = false; /**< 是否限定在时间区间内(仅非单调时间轴时进键)。 */ - double rangeMin = 0.0; /**< 统计区间左端(仅非单调时间轴时进键)。 */ - double rangeMax = 0.0; /**< 统计区间右端(仅非单调时间轴时进键)。 */ + int points = 0; /**< 中心轴:均值点数 X(普通计数恒为 0)。 */ + int iterations = 1; /**< 中心轴:迭代次数 N。 */ + bool hasRange = false; /**< 是否把区间计入键(仅非单调时间轴需要)。 */ + double rangeMin = 0.0; /**< 计入键的区间左端。 */ + double rangeMax = 0.0; /**< 计入键的区间右端。 */ + double threshold = 0.0; /**< 当前阈值(普通计数)。 */ std::vector blockRising; /**< 每块的上升沿数量(已扫到的部分)。 */ std::vector blockFalling; /**< 每块的下降沿数量(已扫到的部分)。 */ - std::size_t scannedPair = 1; /**< 下一个待扫描的采样对下标([1, scannedPair) 已扫完)。 */ + std::size_t scannedPair = 1; /**< 下一个待扫描的采样对([1, scannedPair) 已扫完)。 */ std::size_t rising = 0; /**< 已扫部分的上升沿总数。 */ std::size_t falling = 0; /**< 已扫部分的下降沿总数。 */ bool complete = false; /**< 整条信号是否已扫完。 */ + bool justReset = false; /**< 本 tick 刚因阈值/参数变化重新开始(不展示部分值)。 */ }; - mutable std::vector m_edgeCountCache; /**< 边沿统计累积缓存(纯缓存,逻辑上不影响对象状态)。 */ + mutable std::vector m_edgeScans; /**< 边沿统计累积状态(纯缓存)。 */ mutable std::size_t m_edgeScanBudget = 0; /**< 本 tick 剩余可扫描的采样对数量(限制单帧总开销)。 */ mutable std::uint64_t m_edgeScanTick = 0; /**< 最近一次重置扫描预算的 tick。 */ + /** + * @brief 边沿统计的统一实现(普通边沿计数与中心轴边沿计数共用)。 + * + * 判定基于**画出来的曲线**:先按 `style` 做水平镜像映射,再套用 `ApplyDrawTransform`; + * 中心轴时两侧(信号与中心曲线)都套用同一套变换。 + * 时间区间**之外**的采样一律不参与计数(只有两个端点都在区间内的采样对才计入)。 + * + * @param [IN] file 信号所属文件。 @param [IN] signal 目标信号。 + * @param [IN] style 信号外观。 @param [IN] centre 是否为中心轴判定。 + * @param [IN] threshold 阈值(`centre = false` 时使用)。 + * @param [IN] points 均值点数 X(`centre = true` 时使用)。 + * @param [IN] iterations 迭代次数 N(`centre = true` 时使用)。 + * @param [IN] hasRange 是否只统计某个时间区间。 + * @param [IN] rangeMin 区间左端。 @param [IN] rangeMax 区间右端。 + * @param [OUT] rising 上升沿数量。 @param [OUT] falling 下降沿数量。 + * @param [OUT] complete 是否为完整精确值(false = 仍在推进)。 + */ + void EdgeScanFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, + bool centre, double threshold, int points, int iterations, bool hasRange, + double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling, + bool& complete) const; + + /** @brief 边沿统计未完成时请宿主再送一帧(无需鼠标移动也能持续推进)。 */ + void RequestEdgeScanFrame() const; + /** 多次迭代均值滤波曲线缓存项(按「信号指针 + 均值点数 + 迭代次数」标识)。 */ struct MeanCurveEntry { @@ -1892,23 +1980,27 @@ namespace jdi mutable std::deque m_curveEntries; /**< 已算好条目(双缓冲的「就绪缓冲」)。 */ mutable std::vector m_curvePins; /**< 本帧正在使用的条目(阻止其被回收)。 */ mutable std::atomic m_curveSceneDirty{false}; /**< 后台发布了新条目 → 需重建静态场景缓存。 */ - - /** 中心轴边沿统计缓存项(增量统计机制与 `EdgeCountEntry` 完全一致)。 */ - struct EdgeCenterCountEntry - { - const DataFile* file = nullptr; /**< 所属数据分组。 */ - const SignalData* signal = nullptr; /**< 目标信号。 */ - std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹(变了必须整段重算)。 */ - int points = 5; /**< 均值点数 X。 */ - int iterations = 1; /**< 迭代次数 N。 */ - bool hasRange = false; /**< 是否限定在时间区间内统计。 */ - bool reusable = false; /**< 计数是否完全由 [begin, end) 决定。 */ - std::size_t begin = 0; /**< 已统计的采样区间起点(含)。 */ - std::size_t end = 0; /**< 已统计的采样区间终点(不含)。 */ - std::size_t rising = 0; /**< 该区间内的上升沿数量。 */ - std::size_t falling = 0; /**< 该区间内的下降沿数量。 */ - }; - mutable std::vector m_edgeCenterCountCache; /**< 中心轴边沿统计缓存。 */ + /** + * @brief 「每个(信号 + 曲线种类)的最新请求序号」。 + * + * 拖动参数滑块时同一 (信号, 种类) 会被反复请求;已入队/正在计算的任务一旦不是最新请求, + * 其参数已被取代 → 工作线程看到后**立即退出并归还缓冲**(“过时任务立即清退”)。 + * 值随每次 `ScheduleCurveJob` 单调递增,因此可以表达“比谁新”。 + */ + mutable std::map, std::uint64_t> m_curveLatest; + mutable std::atomic m_curveRequestSerial{0}; /**< 全局请求序号发生器。 */ + mutable int m_curveHugeActive = 0; /**< 正在执行的“大窗口”任务数(最多 1,低内存时保证串行)。 */ + /** + * @brief 正在计算的曲线键集合(由工作线程在计算期间增删)。 + * + * 有了它,`ScheduleCurveJob` 才能做到**幂等**:统计侧(边沿计数)会在“曲线还没算好”时 + * 每帧请求一次整条曲线的计算;若每次都当作新请求,就会把上一份正在算的任务判为过时, + * 于是永远算不完(请求 → 作废 → 重请求 的死循环)。 + * 用**集合**而不是单个键:线程池可能同时算多条不同曲线。 + */ + mutable std::vector m_curveRunningKeys; + mutable std::atomic m_curveCancelAll{false}; /**< 请求全部在算任务立即退出(结构变化 / 关闭预览)。 */ + mutable bool m_curveLowMemoryWarned = false; /**< 「可用内存严重不足」是否已告警(避免反复弹窗)。 */ // 「边沿计数」横向光标 / 面板的拖拽状态。 int m_edgeDragLane = -1; /**< 正在拖动的横向光标所属网格。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 1635744..5fe45be 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -148,7 +148,7 @@ namespace jdi const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 逐条目面板里「迭代次数」滑块行的标签。 */ const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */ - const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */ + const std::size_t kEdgeScanStateLimit = 16; /**< 边沿统计累积状态上限(超出时淘汰最早的一条)。 */ /** * @brief 「边沿计数」累积统计的分块大小(采样点数)。 * @@ -437,11 +437,14 @@ namespace jdi * @param [IN] points 均值点数 X。 * @param [IN] iterations 迭代次数 N。 * @param [IN] amountPercent 弱峰调整幅度(-400–400)。 + * @param [IN] cancel 可选的取消标志(透传给算法,使过时任务尽快退出)。 + * @param [IN] throttle 是否在阶与阶之间让出 CPU(低内存设备的“少量多次”模式)。 * @return 曲线数值(长度 = 有效窗口长度)。 */ std::vector ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin, std::size_t windowEnd, int kind, int points, - int iterations, int amountPercent) + int iterations, int amountPercent, + const std::atomic* cancel, bool throttle) { std::vector out; const std::vector& values = signal.values; @@ -451,15 +454,25 @@ namespace jdi { return out; } - const std::vector window(values.begin() + static_cast(lo), - values.begin() + static_cast(hi)); + // ⚠️ **整段窗口不要复制**:`VERY_BIG_DATA` 一条曲线要 8 B/采样 × 2580 万 ≈ 206 MB, + // 而“预览曲线一律按整条信号计算”正是最常见的情形。 + // 采样在本函数执行期间不会被修改(结构变化前宿主会先 `PauseDerivedAsync`), + // 因此整段时直接引用原向量;只有子窗口才切片复制。 + const bool wholeSignal = (lo == 0) && (hi == values.size()); + const std::vector window = wholeSignal + ? std::vector() + : std::vector( + values.begin() + static_cast(lo), + values.begin() + static_cast(hi)); + const std::vector& input = wholeSignal ? values : window; if (kind == static_cast(DerivedCurveKind::WEAK_PEAK)) { - ComputeWeakPeakAdjust(window, static_cast(amountPercent) / 100.0, iterations, out); + ComputeWeakPeakAdjust(input, static_cast(amountPercent) / 100.0, iterations, out, + cancel, throttle); } else { - ComputeMovingAverageIterated(window, points, iterations, out); + ComputeMovingAverageIterated(input, points, iterations, out, cancel, throttle); } return out; } @@ -1367,8 +1380,9 @@ namespace jdi PauseDerivedAsync(); m_polylineCache.clear(); // 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。 - m_edgeCountCache.clear(); - m_edgeCenterCountCache.clear(); + m_edgeScans.clear(); + m_edgeScanBudget = 0; + m_edgeScanTick = 0; m_meanCurveCache.clear(); m_weakPeakCurveCache.clear(); // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 @@ -1381,14 +1395,17 @@ namespace jdi void PlotView::PauseDerivedAsync() { - // 等待在算任务结束,然后清空队列与缓存,并自增代次使任何迟到结果作废。 + // 等待在算任务结束(先发取消),然后清空队列与缓存,并自增代次使任何迟到结果作废。 StopCurveWorker(); std::lock_guard lock(m_curveMutex); ++m_curveGeneration; m_curveJobs.clear(); m_curveEntries.clear(); m_curvePins.clear(); + m_curveLatest.clear(); + m_curveHugeActive = 0; m_curveSceneDirty.store(false, std::memory_order_relaxed); + m_curveCancelAll.store(false, std::memory_order_relaxed); // 恢复:下次需要时可以重新开始 } bool PlotView::AnyDerivedPreviewEnabled() const @@ -2235,12 +2252,20 @@ namespace jdi { return false; } - const std::vector& curve = MeanFilterCurveFor(signal, points, iterations); - if (static_cast(index) >= curve.size()) + // ⚠️ **绝不能调用整条同步的 `MeanFilterCurveFor`**:光标读数在绘制路径上被调用, + // 整条曲线(千万级采样)同步计算会让界面停顿数秒(用户报告"程序被卡住很久")。 + // 这里只取「需要的那一个下标 ± 保护带」的窗口,并在窗口很大时走后台异步计算 + // (本帧取不到就返回 false,读数暂时不显示该曲线的取值)。 + std::size_t spanBegin = 0; + const std::vector* curve = CurveSpanFor( + DerivedCurveKind::MEAN_FILTER, signal, points, iterations, 0, + static_cast(index), static_cast(index) + 1, spanBegin, true); + if (curve == nullptr || static_cast(index) < spanBegin || + (static_cast(index) - spanBegin) >= curve->size()) { return false; } - const float sampled = curve[static_cast(index)]; + const float sampled = (*curve)[static_cast(index) - spanBegin]; if (!std::isfinite(sampled)) { return false; @@ -2360,8 +2385,18 @@ namespace jdi } // 3) 代价很小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。 + // ⚠️ 同步路径也要先做内存判断:窗口虽小,但低内存设备上仍可能失败 —— + // 宁可这次不画预览,也不能抛 `bad_alloc` 把进程带走。 + { + bool throttle = false; // 窗口 ≤ 门槛(65536 采样)→ 无需节流 + if (!DerivedMemoryAllows(computeSamples, throttle)) + { + return nullptr; + } + } std::vector values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue, - safePoints, safeIterations, safeLevel); + safePoints, safeIterations, safeLevel, nullptr, + false); const CurveEntry* published = PublishCurve(key, computedBegin, computedEnd, std::move(values), spanBegin); return (published != nullptr) ? &published->values : nullptr; @@ -2471,6 +2506,84 @@ namespace jdi return entry.get(); } + bool PlotView::CurveJobIsStale(const CurveKey& key, std::uint64_t requestSerial) const + { + std::lock_guard lock(m_curveMutex); + const auto found = m_curveLatest.find(std::make_pair(key.signal, key.kind)); + if (found == m_curveLatest.end()) + { + return true; // 已经被清理(例如预览被关闭)→ 没有意义,直接作废 + } + return found->second != requestSerial; + } + + std::size_t PlotView::DerivedMinimumBytes(std::size_t samples) + { + // 输出曲线本体(float,占 1 份)+ 内部工作缓冲(「弱峰调整」实测 ≈34 B/采样, + // 含两个 float 工作缓冲与极值表)——取 48 B/采样作为「连一条曲线都装不下」的门槛。 + return samples * 48u + (64u * 1024u * 1024u); + } + + std::size_t PlotView::AvailablePhysicalBytes() + { + // ⚠️ 结果按 200ms 缓存:本函数在绘制路径上每次请求曲线都会调用, + // `GlobalMemoryStatusEx` 是系统调用,逐帧多次调用没有必要(内存变化远慢于 200ms)。 + static std::atomic cachedAt{0}; + static std::atomic cachedBytes{0}; + const std::uint64_t now = GetTickCount64(); + const std::uint64_t last = cachedAt.load(std::memory_order_relaxed); + if (last != 0 && (now - last) < 200) + { + const std::size_t value = cachedBytes.load(std::memory_order_relaxed); + if (value != 0) + { + return value; + } + } + MEMORYSTATUSEX status = {}; + status.dwLength = sizeof(status); + std::size_t available = std::numeric_limits::max(); + if (GlobalMemoryStatusEx(&status) != FALSE) + { + available = static_cast(status.ullAvailPhys); + } + cachedBytes.store(available, std::memory_order_relaxed); + cachedAt.store(now, std::memory_order_relaxed); + return available; + } + + bool PlotView::DerivedMemoryAllows(std::size_t samples, bool& throttle) const + { + throttle = false; + const std::size_t minimum = DerivedMinimumBytes(samples); + const std::size_t available = AvailablePhysicalBytes(); + if (available < minimum) + { + NotifyDerivedLowMemory(); + return false; + } + // 低于「舒适线」(最小需求的 4 倍)→ 低内存模式:串行 + 节流,慢慢算完。 + if (available < minimum * 4u) + { + throttle = true; + } + return true; + } + + void PlotView::NotifyDerivedLowMemory() const + { + if (m_curveLowMemoryWarned) + { + return; // 同一轮只提示一次,避免反复弹窗 + } + m_curveLowMemoryWarned = true; + const HWND host = m_host; + if (host != nullptr) + { + PostMessageW(host, kMessagePlotLowMemory, 0, 0); + } + } + void PlotView::ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin, std::size_t alignedEnd, std::size_t count) const { @@ -2482,21 +2595,51 @@ namespace jdi { return; } + // 「大任务」判据:窗口采样数超过大信号门槛(此时工作缓冲会到数百 MB 量级)。 + const std::size_t windowSamples = windowEnd - windowBegin; + const bool huge = (windowSamples >= kHugeSignalPreviewSamples); + int hugeLimit = 1; + // ⚠️ **内存严重不足时根本不入队**(连一条曲线都装不下):告警一次并直接放弃本次计算。 + // 这样“低内存设备”不会因为换页而把整机拖死,也不会抛 `bad_alloc` 逸出线程导致崩溃。 + if (huge) + { + bool throttle = false; + if (!DerivedMemoryAllows(windowSamples, throttle)) + { + return; + } + hugeLimit = throttle ? 1 : 2; + } { std::lock_guard lock(m_curveMutex); - // 同一「信号 + 曲线种类」只保留**一个**待算任务,并以**最新参数**为准: - // 拖弱峰调整等级/迭代次数滑块时每帧参数都变,若每次都入队会把队列灌满整段曲线任务, - // 后台线程池被淹没 → 与 UI 抢 CPU → 拖动反而卡顿。这里把同 (signal, kind) 的旧待算任务 - // 全部移除(正在计算的那个由工作线程另行负责,其结果是旧参数,会被后续条目取代)。 - bool identicalPending = false; + // 新的计算请求 ⇒ 取消标志必须清零(此前可能因关闭预览/结构变化而被置位)。 + m_curveCancelAll.store(false, std::memory_order_relaxed); + // ⚠️ **幂等**:边沿统计会在“曲线还没算好”时每帧请求一次整条曲线的计算。 + // 如果每次都当作新请求(自增序号),正在算的那份就会被判为过时而退出 —— + // 于是永远算不完(请求 → 作废 → 重请求 的死循环)。 + for (const CurveKey& running : m_curveRunningKeys) + { + if (running == key) + { + return; // 同一份曲线正在算,无需重复排队 + } + } + for (const CurveJob& pending : m_curveJobs) + { + if (pending.key == key) + { + return; // 同一份曲线已在队列里等待 + } + } + // 全局请求序号:这次请求之后,同一 (信号, 种类) 的旧任务就都过时了。 + const std::uint64_t requestSerial = ++m_curveRequestSerial; + m_curveLatest[std::make_pair(key.signal, key.kind)] = requestSerial; + // 同一「信号 + 曲线种类」的**其它参数**的待算任务全部移除:参数已被取代, + // 正在计算的那个会在下一阶处看到自己已过时并**立即退出**。 for (auto iterator = m_curveJobs.begin(); iterator != m_curveJobs.end();) { if (iterator->key.signal == key.signal && iterator->key.kind == key.kind) { - if (iterator->key == key) - { - identicalPending = true; - } iterator = m_curveJobs.erase(iterator); } else @@ -2504,18 +2647,18 @@ namespace jdi ++iterator; } } - if (!identicalPending) - { - CurveJob job; - job.key = key; - job.windowBegin = windowBegin; - job.windowEnd = windowEnd; - job.generation = m_curveGeneration; - m_curveJobs.push_back(job); - } + CurveJob job; + job.key = key; + job.windowBegin = windowBegin; + job.windowEnd = windowEnd; + job.generation = m_curveGeneration; + job.requestSerial = requestSerial; + job.huge = huge; + job.hugeLimit = hugeLimit; + m_curveJobs.push_back(job); } EnsureCurveWorker(); - m_curveCv.notify_one(); + m_curveCv.notify_all(); } void PlotView::EnsureCurveWorker() const @@ -2540,6 +2683,10 @@ namespace jdi void PlotView::StopCurveWorker() const { + // 先请所有在算任务尽快退出(每阶 / 每 1M 采样检查一次),再等线程结束 —— + // 这是「过时任务立即清退并释放资源」的关键一步:千万级采样的一条曲线工作缓冲 + // 可达数百 MB,必须尽早归还。 + m_curveCancelAll.store(true, std::memory_order_relaxed); if (m_curveWorkers.empty()) { return; @@ -2548,6 +2695,8 @@ namespace jdi std::lock_guard lock(m_curveMutex); m_curveWorkerStop = true; m_curveJobs.clear(); + m_curveLatest.clear(); + m_curveRunningKeys.clear(); } m_curveCv.notify_all(); for (std::thread& worker : m_curveWorkers) @@ -2558,6 +2707,7 @@ namespace jdi } } m_curveWorkers.clear(); + m_curveHugeActive = 0; } void PlotView::CurveWorkerLoop() const @@ -2572,31 +2722,96 @@ namespace jdi CurveJob job; { std::unique_lock lock(m_curveMutex); - m_curveCv.wait(lock, [this]() { return m_curveWorkerStop || !m_curveJobs.empty(); }); + m_curveCv.wait(lock, [this]() + { return m_curveWorkerStop || !m_curveJobs.empty(); }); if (m_curveJobs.empty()) { return; // 仅在 stop 且队列为空时到达 } job = m_curveJobs.front(); + // ⚠️ **大窗口任务不能无限并发**:一条「整段信号」的弱峰调整工作缓冲可达数百 MB, + // 多个同时算就是 GB 级峰值(用户报告的 4.4GB)。上限由调度时的内存压力决定: + // 内存充足时允许 2 条(仍然很快,但峰值封顶);内存紧张时只允许 1 条。 + if (job.huge && m_curveHugeActive >= std::max(1, job.hugeLimit)) + { + m_curveCv.wait_for(lock, std::chrono::milliseconds(5)); + continue; + } m_curveJobs.pop_front(); if (job.generation != m_curveGeneration) { continue; // 代次已变化:丢弃过期任务 } + if (job.huge) + { + ++m_curveHugeActive; + } + // 记录“正在计算的曲线键”:`ScheduleCurveJob` 靠它保持幂等 + //(边沿统计会每帧请求同一份整条曲线,不能每次都当作新请求)。 + m_curveRunningKeys.push_back(job.key); } // 计算在锁外进行:读取的采样在「结构未变」期间只读、且地址稳定 //(结构变化前宿主会先 `PauseDerivedAsync`)。 - std::vector values = - ComputeDerivedWindow(*job.key.signal, job.windowBegin, job.windowEnd, job.key.kind, - job.key.points, job.key.iterations, job.key.amountPercent); + // ⚠️ 传入取消标志:一旦同一 (信号, 种类) 有了更新的请求、或发生了结构变化/关闭预览, + // 计算会在下一阶(或每 1M 采样)处**立即退出**,尽早归还工作缓冲。 + bool throttle = false; + if (job.huge) + { + DerivedMemoryAllows(job.windowEnd - job.windowBegin, throttle); + } + std::vector values = ComputeDerivedWindow( + *job.key.signal, job.windowBegin, job.windowEnd, job.key.kind, job.key.points, + job.key.iterations, job.key.amountPercent, &m_curveCancelAll, throttle); + if (job.huge) + { + std::lock_guard lock(m_curveMutex); + m_curveHugeActive = std::max(0, m_curveHugeActive - 1); + for (auto iterator = m_curveRunningKeys.begin(); iterator != m_curveRunningKeys.end(); + ++iterator) + { + if (*iterator == job.key) + { + m_curveRunningKeys.erase(iterator); + break; + } + } + m_curveCv.notify_all(); + } + else + { + std::lock_guard lock(m_curveMutex); + for (auto iterator = m_curveRunningKeys.begin(); iterator != m_curveRunningKeys.end(); + ++iterator) + { + if (*iterator == job.key) + { + m_curveRunningKeys.erase(iterator); + break; + } + } + } + + // ⚠️ **过时结果一律不发布,并立即可回收**: + // * 代次变化(结构变化)→ 丢弃; + // * 同一 (信号, 种类) 已有更新的请求 → 本结果参数已过时,丢弃(后台很快会有新的)。 + // * 被取消(`m_curveCancelAll`)→ 曲线可能只算了一部分,绝不能发布。 + if (m_curveCancelAll.load(std::memory_order_relaxed) || + CurveJobIsStale(job.key, job.requestSerial)) + { + continue; + } { std::lock_guard lock(m_curveMutex); if (job.generation != m_curveGeneration) { continue; // 计算期间发生了结构变化:丢弃,避免发布过期结果 } + if (values.empty()) + { + continue; + } std::shared_ptr entry = std::make_shared(); entry->key = job.key; entry->begin = job.windowBegin; @@ -2736,258 +2951,47 @@ namespace jdi return true; } - void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, - const SignalStyle& style, int points, int iterations, - bool hasRange, double rangeMin, double rangeMax, - std::size_t& rising, std::size_t& falling) const + void PlotView::RequestEdgeScanFrame() const { - rising = 0; - falling = 0; - - const int radius = std::max(0, points); - const int stages = std::max(1, iterations); - const std::size_t count = signal.values.size(); - std::size_t begin = 0; - std::size_t end = 0; - const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end); - - // ⚠️ 判定基于**画出来的两条曲线**:信号与中心曲线都要先按下标映射(水平镜像) - // 再套用「调整」变换 —— 否则应用过调整的信号会按原始值域判定,统计结果完全错误。 - const bool mirrored = style.mirrorHorizontal; - const bool transform = (style.gain != 1.0f || style.absolute || style.mirrorVertical || - style.offsetY != 0.0f); - const std::uint64_t styleKey = EdgeStyleKey(style); - - // 按「文件 + 信号 + 样式指纹 + 均值点数 X + 迭代次数 N + 是否限定区间」定位缓存项 - //(**时间区间不进缓存键**)。 - EdgeCenterCountEntry* entry = nullptr; - for (EdgeCenterCountEntry& candidate : m_edgeCenterCountCache) + // 统计还没扫完时主动请宿主再送一帧 —— 否则用户不移动鼠标就不会有任何重绘, + // 预算也就永远不推进(用户报告:"面板过去很久都一直显示统计中, + // 只有当鼠标瞄到浮动面板上时才会显示统计好的值")。 + // 与派生曲线就绪用同一条消息(宿主只失效绘图区、不置脏,代价极小)。 + const HWND host = m_host; + if (host != nullptr) { - if (candidate.file == &file && candidate.signal == &signal && - candidate.styleKey == styleKey && candidate.hasRange == hasRange && - candidate.points == radius && candidate.iterations == stages) - { - entry = &candidate; - break; - } + PostMessageW(host, kMessagePlotDerivedReady, 0, 0); } - if (entry == nullptr) - { - if (m_edgeCenterCountCache.size() >= kEdgeCountCacheLimit) - { - m_edgeCenterCountCache.erase(m_edgeCenterCountCache.begin()); - } - EdgeCenterCountEntry created; - created.file = &file; - created.signal = &signal; - created.styleKey = styleKey; - created.points = radius; - created.iterations = stages; - created.hasRange = hasRange; - m_edgeCenterCountCache.push_back(created); - entry = &m_edgeCenterCountCache.back(); - } - - // 区间完全一致 → 直接命中。**必须放在请求曲线之前**:面板每帧都会调用本函数, - // 而「没有限定光标区间」时的统计区间就是整条信号 —— 若先请求曲线, - // 每一帧都会去取(并可能重算)整条曲线,实测单帧 30ms 以上。 - if (indexed && entry->reusable && entry->begin == begin && entry->end == end) - { - rising = entry->rising; - falling = entry->falling; - return; - } - - // 与上一帧共享一个端点(拖动光标时必然如此)→ 只扫描两端差集并增量加减。 - const bool shareBegin = indexed && entry->reusable && entry->begin == begin; - const bool shareEnd = indexed && entry->reusable && entry->end == end; - const std::size_t oldBegin = entry->begin; - const std::size_t oldEnd = entry->end; - - // 先确定需要比较的「相邻采样对」下标区间 [pairFirst, pairLast](比较 index-1 与 index), - // 再据此只请求**必需的那一小段**曲线窗口(增量路径下通常只有几十个采样)。 - std::size_t pairFirst = 0; - std::size_t pairLast = 0; - int mode = 0; // 0 = 全量重算,1 = 仅尾部差集,2 = 仅头部差集 - if (shareBegin && !shareEnd) - { - const std::size_t low = std::min(oldEnd, end); - const std::size_t high = std::max(oldEnd, end); - pairFirst = std::max(1, low); - pairLast = (high > 0) ? (high - 1) : 0; - mode = 1; - } - else if (shareEnd && !shareBegin) - { - const std::size_t low = std::min(oldBegin, begin); - const std::size_t high = std::max(oldBegin, begin); - pairFirst = low + 1; - pairLast = high; - mode = 2; - } - else - { - pairFirst = (end > begin) ? (begin + 1) : 1; - pairLast = (end > begin) ? (end - 1) : 0; - mode = 0; - } - - // 需要的偏差下标区间 = [pairFirst - 1, pairLast];请求曲线窗口需包含它。 - const std::size_t needFirst = (pairFirst > 0) ? (pairFirst - 1) : 0; - const std::size_t needEnd = std::min(count, pairLast + 1); - if (needEnd <= needFirst) - { - entry->reusable = indexed; - entry->begin = begin; - entry->end = end; - entry->rising = 0; - entry->falling = 0; - return; - } - - // 与「实验性功能 → 均值滤波」共用同一条曲线(含缓存), - // 但**只取必需区间 ± 保护带**的窗口。 - // ⚠️ 曲线数组按**源下标**存储(`signal.values` 的顺序),而上面算出的区间是**显示下标**: - // 水平镜像时显示下标 i 对应源下标 count-1-i,必须把窗口映射过去再请求, - // 否则会取到完全无关的一段(甚至什么都不画)。 - const std::size_t windowBegin = mirrored ? (count - needEnd) : needFirst; - const std::size_t windowEnd = mirrored ? (count - needFirst) : needEnd; - std::size_t spanBegin = 0; - const std::vector* curvePtr = CurveSpanFor( - DerivedCurveKind::MEAN_FILTER, signal, radius, stages, 0, windowBegin, windowEnd, spanBegin); - if (curvePtr == nullptr) - { - return; - } - const std::vector& curve = *curvePtr; - - // 判定基准:**绘制后**的信号与**绘制后**的中心曲线之差(区间之外的采样返回 NaN)。 - // ⚠️ 两侧必须套用**同一套**变换,否则比较的不是屏幕上看到的两条曲线。 - const auto deltaAt = [&signal, &file, &curve, &style, spanBegin, count, mirrored, transform, - hasRange, rangeMin, rangeMax](std::size_t index) -> double - { - if (index >= count) - { - return std::numeric_limits::quiet_NaN(); - } - const std::size_t sourceIndex = mirrored ? ((count - 1) - index) : index; - if (sourceIndex >= count) - { - return std::numeric_limits::quiet_NaN(); - } - if (sourceIndex < spanBegin || (sourceIndex - spanBegin) >= curve.size()) - { - return std::numeric_limits::quiet_NaN(); - } - const double value = signal.values[sourceIndex]; - const float center = curve[sourceIndex - spanBegin]; - if (!std::isfinite(value) || !std::isfinite(center)) - { - return std::numeric_limits::quiet_NaN(); - } - if (hasRange && !file.timeMonotonic && index < file.time.size()) - { - const double time = file.time[index]; - if (time < rangeMin || time > rangeMax) - { - return std::numeric_limits::quiet_NaN(); - } - } - const double drawn = transform ? ApplyDrawTransform(style, value) : value; - const double drawnCenter = - transform ? ApplyDrawTransform(style, static_cast(center)) - : static_cast(center); - if (!std::isfinite(drawn) || !std::isfinite(drawnCenter)) - { - return std::numeric_limits::quiet_NaN(); - } - return drawn - drawnCenter; - }; - - std::size_t deltaRising = 0; - std::size_t deltaFalling = 0; - if (mode == 1) - { - AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, deltaRising, deltaFalling); - rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); - falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); - } - else if (mode == 2) - { - AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, deltaRising, deltaFalling); - rising = (begin < oldBegin) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); - falling = (begin < oldBegin) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); - } - else - { - AccumulateEdgeCrossings(pairFirst, pairLast, deltaAt, rising, falling); - } - - entry->reusable = indexed; - entry->begin = begin; - entry->end = end; - entry->rising = rising; - entry->falling = falling; } - void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, - double edgeValue, bool hasRange, double rangeMin, double rangeMax, - std::size_t& rising, std::size_t& falling, bool& complete) const + void PlotView::EdgeScanFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, + bool centre, double threshold, int points, int iterations, bool hasRange, + double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling, + bool& complete) const { rising = 0; falling = 0; complete = false; - const std::vector& values = signal.values; - // ⚠️ 阈值(横向光标幅值)是**屏幕上的值**,因此判定必须基于**画出来的曲线**: - // 先按下标映射(水平镜像)取源采样,再套用「调整」变换(系数 → 绝对值 → 垂直镜像 → - // Y 偏移)。只比较原始采样值时,"系数 50" 这类信号的原始值域与阈值完全不相交, - // 表现就是"明明是很规整的方波,统计出来却全是 0"(用户报告)。 - const std::size_t count = values.size(); + const std::size_t count = signal.values.size(); if (count < 2) { complete = true; return; } + const bool monotonic = file.timeMonotonic; + // 非单调时间轴无法用「采样对下标区间」表达统计范围,只能逐点按时间过滤 → + // 此时区间必须进键(否则会混用别的区间结果)。 + const bool rangeInKey = hasRange && !monotonic; + const std::uint64_t styleKey = EdgeStyleKey(style); + const int safePoints = std::max(0, std::min(points, 1000000)); + const int safeIterations = std::max(1, std::min(iterations, 1000)); const bool mirrored = style.mirrorHorizontal; const bool transform = (style.gain != 1.0f || style.absolute || style.mirrorVertical || style.offsetY != 0.0f); - const std::uint64_t styleKey = EdgeStyleKey(style); - // 时间单调 ⇒ 统计区间可以用**采样对下标区间**表达(区间不进键,拖动时间光标无需重扫)。 - const bool monotonic = file.timeMonotonic; - - // 判定基准:**绘制值**相对阈值的偏差(区间之外 / 缺失值返回 NaN = 不参与且打断连续)。 - const auto deltaAt = [&values, &file, &style, mirrored, transform, count, hasRange, rangeMin, - rangeMax](std::size_t index) -> double - { - if (index >= count) - { - return std::numeric_limits::quiet_NaN(); - } - const std::size_t sourceIndex = mirrored ? ((count - 1) - index) : index; - if (sourceIndex >= count) - { - return std::numeric_limits::quiet_NaN(); - } - const double raw = values[sourceIndex]; - if (!std::isfinite(raw)) - { - return std::numeric_limits::quiet_NaN(); - } - if (hasRange && !file.timeMonotonic && index < file.time.size()) - { - const double time = file.time[index]; - if (time < rangeMin || time > rangeMax) - { - return std::numeric_limits::quiet_NaN(); - } - } - const double drawn = transform ? ApplyDrawTransform(style, raw) : raw; - return std::isfinite(drawn) ? drawn : std::numeric_limits::quiet_NaN(); - }; // 请求的「采样对」区间 [pairFirst, pairLast](pair k = 采样 k-1 → k,k ≥ 1)。 - // ⚠️ 只有**两个端点都落在区间内**的采样对才计入(非单调时无法用下标表达,由 deltaAt 过滤)。 + // ⚠️ 只有**两个端点都落在区间内**的采样对才计入(非单调时由 deltaAt 过滤)。 std::size_t pairFirst = 1; std::size_t pairLast = count - 1; if (hasRange && monotonic) @@ -3004,82 +3008,164 @@ namespace jdi } } - // ---- 定位 / 推进累积条目 ------------------------------------------------- - // ⚠️ 拖动横向光标(`EDGE_MOVE`)时阈值每帧都变,推进等于纯白做功:此时不推进, - // 直接把上一个完整结果当作临时值(界面用 "…" 标出"还没算完")。 - const bool thresholdDragging = (m_dragMode == PlotDragMode::EDGE_MOVE); - const auto sameSignal = [&file, &signal, styleKey](const EdgeCountEntry& candidate) - { return candidate.file == &file && candidate.signal == &signal && candidate.styleKey == styleKey; }; - const auto sameKey = [&](const EdgeCountEntry& candidate) + // ---- 定位累积状态 ------------------------------------------------------- + EdgeScanState* state = nullptr; + for (EdgeScanState& candidate : m_edgeScans) { - if (!sameSignal(candidate) || candidate.threshold != edgeValue) + if (candidate.file == &file && candidate.signal == &signal && candidate.centre == centre && + candidate.styleKey == styleKey && candidate.points == safePoints && + candidate.iterations == safeIterations && candidate.hasRange == rangeInKey && + (!rangeInKey || (candidate.rangeMin == rangeMin && candidate.rangeMax == rangeMax))) { - return false; - } - // 非单调时间轴:区间参与判定,必须一并比较(否则会混用别的区间结果)。 - return monotonic || (candidate.hasRange == hasRange && candidate.rangeMin == rangeMin && - candidate.rangeMax == rangeMax); - }; - - // 用**下标**而不是指针记录候选(`push_back` 可能重新分配,指针会失效)。 - int entryIndex = -1; - int newestCompleteIndex = -1; - for (std::size_t index = 0; index < m_edgeCountCache.size(); ++index) - { - const EdgeCountEntry& candidate = m_edgeCountCache[index]; - if (!sameSignal(candidate)) - { - continue; - } - if (entryIndex < 0 && sameKey(candidate)) - { - entryIndex = static_cast(index); - } - if (candidate.complete) - { - newestCompleteIndex = static_cast(index); // 淘汰从 front 开始 ⇒ 越靠后越新 + state = &candidate; + break; } } - EdgeCountEntry* entry = (entryIndex >= 0) ? &m_edgeCountCache[static_cast(entryIndex)] : nullptr; - - if (!thresholdDragging && (entry == nullptr || !entry->complete)) + if (state == nullptr) { - if (entry == nullptr) + if (m_edgeScans.size() >= kEdgeScanStateLimit) { - if (m_edgeCountCache.size() >= kEdgeCountCacheLimit) - { - m_edgeCountCache.erase(m_edgeCountCache.begin()); - } - EdgeCountEntry created; - created.file = &file; - created.signal = &signal; - created.styleKey = styleKey; - created.threshold = edgeValue; - created.hasRange = hasRange && !monotonic; - created.rangeMin = rangeMin; - created.rangeMax = rangeMax; - const std::size_t blocks = (count - 1) / kEdgeBlockSamples + 2; - created.blockRising.assign(blocks, 0); - created.blockFalling.assign(blocks, 0); - m_edgeCountCache.push_back(std::move(created)); - entry = &m_edgeCountCache.back(); - newestCompleteIndex = -1; // 可能已被重新分配/淘汰,不再使用它 + m_edgeScans.erase(m_edgeScans.begin()); } + EdgeScanState created; + created.file = &file; + created.signal = &signal; + created.centre = centre; + created.styleKey = styleKey; + created.points = safePoints; + created.iterations = safeIterations; + created.hasRange = rangeInKey; + created.rangeMin = rangeMin; + created.rangeMax = rangeMax; + created.threshold = threshold; + const std::size_t blocks = (count - 1) / kEdgeBlockSamples + 2; + created.blockRising.assign(blocks, 0); + created.blockFalling.assign(blocks, 0); + created.justReset = true; + m_edgeScans.push_back(std::move(created)); + state = &m_edgeScans.back(); + } + else if (!centre && state->threshold != threshold) + { + // 阈值变了(横向光标正在拖动):**就地重新开始**,不新建条目(避免拖动时状态爆炸)。 + // ⚠️ 本 tick 不展示部分值(`justReset`):部分计数每帧都会被重置, + // 显示出来就是"错误且不更新的数字"(用户报告过这个现象)。 + state->threshold = threshold; + state->scannedPair = 1; + state->rising = 0; + state->falling = 0; + state->complete = false; + state->justReset = true; + std::fill(state->blockRising.begin(), state->blockRising.end(), 0); + std::fill(state->blockFalling.begin(), state->blockFalling.end(), 0); + } - // 扫描预算按 tick 共享:同一帧里本函数会被调用多次(排版 + 绘制 + 多条信号), - // 若每次都各扫一份,单帧总开销就会翻好几倍(实测整段 25.8M 採样 ≈115ms)。 + // ---- 中心轴:需要整条信号对应的均值滤波曲线 ----------------------------- + // 取值必须与绘制逐位一致,因此要求**键完全相同**的条目(不能像绘制那样用旧参数的曲线顶替)。 + // 拿不到就交给后台算、本帧不推进;**绝不在这里同步算**(千万级采样会卡住界面数秒)。 + const std::vector* curve = nullptr; + std::size_t spanBegin = 0; + bool curveReady = true; + if (centre) + { + CurveKey key; + key.signal = &signal; + key.kind = static_cast(DerivedCurveKind::MEAN_FILTER); + key.points = safePoints; + key.iterations = safeIterations; + key.amountPercent = 0; + const CurveEntry* hit = AcquireCoveringCurve(key, 0, count, spanBegin); + if (hit == nullptr) + { + ScheduleCurveJob(key, 0, count, count); // 幂等:已在算 / 已排队时不会重复 + curveReady = false; + } + else + { + curve = &hit->values; + } + } + + // 判定基准:**绘制后**的值相对阈值 / 中心曲线的偏差(区间之外 / 缺失值 → NaN)。 + const std::vector& values = signal.values; + const bool filterByTime = hasRange && !monotonic; + const auto deltaAt = [&](std::size_t index) -> double + { + const double nan = std::numeric_limits::quiet_NaN(); + if (index >= count) + { + return nan; + } + if (filterByTime && index < file.time.size()) + { + const double time = file.time[index]; + if (time < rangeMin || time > rangeMax) + { + return nan; + } + } + const std::size_t sourceIndex = mirrored ? ((count - 1) - index) : index; + if (sourceIndex >= count) + { + return nan; + } + const double raw = values[sourceIndex]; + if (!std::isfinite(raw)) + { + return nan; + } + const double drawn = transform ? ApplyDrawTransform(style, raw) : raw; + if (!std::isfinite(drawn)) + { + return nan; + } + if (!centre) + { + return drawn - threshold; + } + if (curve == nullptr || sourceIndex < spanBegin || + (sourceIndex - spanBegin) >= curve->size()) + { + return nan; + } + const float centerSample = (*curve)[sourceIndex - spanBegin]; + if (!std::isfinite(centerSample)) + { + return nan; + } + const double drawnCenter = + transform ? ApplyDrawTransform(style, static_cast(centerSample)) + : static_cast(centerSample); + return std::isfinite(drawnCenter) ? (drawn - drawnCenter) : nan; + }; + + // ---- 推进(受每 tick 预算限制)------------------------------------------ + const bool suppressPartial = state->justReset && !state->complete; + if (!state->complete && !state->blockRising.empty()) + { + // 扫描预算按 tick 共享:同一帧里本函数会被调用多次(面板宽度测量 + 绘制 + 多条信号), + // 若每次都各扫一份,单帧总开销就会翻好几倍(实测整段 25.8M 采样 ≈115ms)。 const std::uint64_t tick = GetTickCount64(); if (tick != m_edgeScanTick) { m_edgeScanTick = tick; m_edgeScanBudget = kEdgeScanPairsPerTick; } - if (m_edgeScanBudget > 0 && entry->scannedPair < count) + if (!curveReady) { + // 正在等后台的均值滤波曲线:把预算留给别的统计,避免白耗。 + m_edgeScanBudget = 0; + } + else if (m_edgeScanBudget > 0) + { + state->justReset = false; + // 快路径:时间单调、无水平镜像、无「调整」变换、非中心轴 ⇒ 直接比较采样值与阈值。 + // (NaN 与任何数比较都为 false ⇒ 天然满足"NaN 不参与且打断连续"的语义。) + // 绝大多数信号都走这条,单次预算的耗时因此从 ~18ms 降到 ~2ms。 + const bool plainFast = monotonic && !mirrored && !transform && !centre; std::size_t scanned = 0; - std::size_t block = (entry->scannedPair - 1) / kEdgeBlockSamples; - std::size_t pair = entry->scannedPair; - const bool fastPath = monotonic && !mirrored && !transform; + std::size_t pair = state->scannedPair; + std::size_t block = (pair > 0) ? ((pair - 1) / kEdgeBlockSamples) : 0; while (pair < count && scanned < m_edgeScanBudget) { const std::size_t blockFirst = block * kEdgeBlockSamples + 1; @@ -3092,14 +3178,12 @@ namespace jdi } std::size_t localRising = 0; std::size_t localFalling = 0; - if (fastPath) + if (plainFast) { - // 紧凑循环:无变换 / 无镜像 / 时间单调 ⇒ 直接比较阈值,NaN 天然不计数。 - // (NaN 与任何数比较都为 false,因此不需要额外的 isfinite 分支。) - double previous = values[from - 1] - edgeValue; + double previous = values[from - 1] - threshold; for (std::size_t index = from; index <= to; ++index) { - const double current = values[index] - edgeValue; + const double current = values[index] - threshold; if (previous < 0.0 && current >= 0.0) { ++localRising; @@ -3113,10 +3197,10 @@ namespace jdi } else { - double previous = deltaAt(from - 1) - edgeValue; + double previous = deltaAt(from - 1); for (std::size_t index = from; index <= to; ++index) { - const double current = deltaAt(index) - edgeValue; + const double current = deltaAt(index); if (std::isfinite(previous) && std::isfinite(current)) { if (previous < 0.0 && current >= 0.0) @@ -3131,13 +3215,13 @@ namespace jdi previous = current; } } - if (block < entry->blockRising.size()) + if (block < state->blockRising.size()) { - entry->blockRising[block] += localRising; - entry->blockFalling[block] += localFalling; + state->blockRising[block] += localRising; + state->blockFalling[block] += localFalling; } - entry->rising += localRising; - entry->falling += localFalling; + state->rising += localRising; + state->falling += localFalling; scanned += (to - from + 1); pair = to + 1; if (to == blockLast) @@ -3145,77 +3229,85 @@ namespace jdi ++block; } } - entry->scannedPair = pair; - entry->complete = (pair >= count); + state->scannedPair = pair; + state->complete = (pair >= count); m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned); } } // ---- 汇总请求区间 ------------------------------------------------------- - // 优先用「键完全匹配」的条目(可能仍在推进);没有就用最新的完整条目顶替, - // 这样拖动阈值期间数字保持稳定、算完后自动切换到精确值。 - const EdgeCountEntry* source = (entry != nullptr) - ? entry - : ((newestCompleteIndex >= 0) - ? &m_edgeCountCache[static_cast(newestCompleteIndex)] - : nullptr); - if (source == nullptr) + // 内部整块用块和相加,只有两端边界块(≤ 2 块)需要就地精确扫描 + // ⇒ 拖动**时间**光标时单帧代价与区间长度无关。 + if (curveReady && (state->complete || !suppressPartial)) { - return; // 尚无任何结果:界面显示"统计中…" - } - const auto sumRange = [&](const EdgeCountEntry& from, std::size_t first, std::size_t last, - std::size_t& outRising, std::size_t& outFalling) -> bool - { - outRising = 0; - outFalling = 0; - if (last < first) + const std::size_t limit = + (state->scannedPair > 0) ? std::min(pairLast, state->scannedPair - 1) : 0; + if (limit >= pairFirst) { - return true; - } - const std::size_t limit = (from.scannedPair > 0) ? std::min(last, from.scannedPair - 1) : 0; - if (limit >= first) - { - std::size_t block = (first - 1) / kEdgeBlockSamples; + const std::size_t firstBlock = (pairFirst - 1) / kEdgeBlockSamples; const std::size_t lastBlock = (limit - 1) / kEdgeBlockSamples; - for (; block <= lastBlock; ++block) + for (std::size_t block = firstBlock; block <= lastBlock; ++block) { const std::size_t blockFirst = block * kEdgeBlockSamples + 1; - const std::size_t blockLast = (block + 1) * kEdgeBlockSamples; - const std::size_t low = std::max(first, blockFirst); + const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples); + const std::size_t low = std::max(pairFirst, blockFirst); const std::size_t high = std::min(limit, blockLast); - const bool wholeBlock = (low == blockFirst) && (high == blockLast); - if (wholeBlock && from.scannedPair > blockLast && block < from.blockRising.size()) + const bool wholeBlock = (low == blockFirst) && (high == blockLast) && + (state->scannedPair > blockLast) && + (block < state->blockRising.size()); + if (wholeBlock) { - outRising += from.blockRising[block]; - outFalling += from.blockFalling[block]; + rising += state->blockRising[block]; + falling += state->blockFalling[block]; continue; } - // 边界块 / 尚未扫完的块:就地精确扫描这一小段(≤ 1 个块)。 - double previous = deltaAt(low - 1) - edgeValue; + double previous = deltaAt(low - 1); for (std::size_t index = low; index <= high; ++index) { - const double current = deltaAt(index) - edgeValue; + const double current = deltaAt(index); if (std::isfinite(previous) && std::isfinite(current)) { if (previous < 0.0 && current >= 0.0) { - ++outRising; + ++rising; } else if (previous > 0.0 && current <= 0.0) { - ++outFalling; + ++falling; } } previous = current; } } } - return (last < from.scannedPair); - }; - const bool covered = sumRange(*source, pairFirst, pairLast, rising, falling); - complete = covered && sameKey(*source); + complete = (pairLast < state->scannedPair); + } + if (!complete) + { + RequestEdgeScanFrame(); + } } + void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, + double edgeValue, bool hasRange, double rangeMin, double rangeMax, + std::size_t& rising, std::size_t& falling, bool& complete) const + { + EdgeScanFor(file, signal, style, false, edgeValue, 0, 1, hasRange, rangeMin, rangeMax, rising, + falling, complete); + } + + void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, + const SignalStyle& style, int points, int iterations, + bool hasRange, double rangeMin, double rangeMax, + std::size_t& rising, std::size_t& falling, bool& complete) const + { + // 与普通边沿计数完全共用同一套「逐块累积 + 每 tick 预算推进」机制; + // 区别只在判定基准(信号 − 中心平均值曲线)与所需曲线(均值滤波,需整条)。 + EdgeScanFor(file, signal, style, true, 0.0, points, iterations, hasRange, rangeMin, rangeMax, + rising, falling, complete); + } + + float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const { if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || @@ -3272,7 +3364,7 @@ namespace jdi } EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style, item->points, item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), - rising, falling); + rising, falling, complete); } else { @@ -4136,7 +4228,7 @@ namespace jdi } // 参数变化会改变曲线:清掉派生缓存即可(局部重画由 `m_sceneDerived` 快照驱动)。 m_meanCurveCache.clear(); - m_edgeCenterCountCache.clear(); + m_edgeScans.clear(); return true; } @@ -5179,7 +5271,7 @@ namespace jdi ToggleLaneEdgeCenterSignal(index, ref); } m_meanCurveCache.clear(); - m_edgeCenterCountCache.clear(); + m_edgeScans.clear(); m_weakPeakCurveCache.clear(); // 勾选变化只影响该网格的内容:交给 `m_sceneDerived` 快照驱动局部重画, // 这里**不**整体作废场景缓存(否则每次勾选都要重绘全部网格)。 @@ -11821,11 +11913,13 @@ namespace jdi const int statsOrder = (item != nullptr) ? item->iterations : 1; std::size_t rising = 0; std::size_t falling = 0; + bool statsComplete = false; EdgeCenterCountsFor(*resolved.file, *resolved.data, *resolved.style, statsPoints, statsOrder, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling); - const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + - L" 下降沿 " + std::to_wstring(falling); + EdgeRangeMin(), EdgeRangeMax(), rising, falling, + statsComplete); + const std::wstring counts = + EdgeCountsText(rising, falling, statsComplete); UiText(g, counts, m_fonts->monoSmall, statsRect, m_theme->textMuted, TextAlign::LEFT); } diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index ad39acf..e8c43a9 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1119,6 +1119,79 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.23 派生曲线内存治理(弱峰调整等)+ 边沿统计改为统一预算状态机(含中心轴)(本轮) + +> 背景:用户报告 ① 弱峰调整把迭代次数 1→10 时内存峰值到 4.4GB;② 边沿计数面板 +> "鼠标不动就一直显示统计中,瞄到面板才更新",拖动横向光标时显示错误/不更新; +> ③ 中心轴边沿计数**没有被优化到**,一个网格里多个信号各开一项、一改参数整个程序卡住很久。 +> 另:**离屏 harness 绝不允许打扰用户的前台输入**(本轮加了进程/线程优先级保护)。 + +#### 一、边沿统计:统一「逐块累积 + 每 tick 预算」状态机(普通 + 中心轴共用) + +- `EdgeScanState`(原 `EdgeCountEntry`)现在是**唯一**的累积结构,`EdgeScanFor(...)` 是唯一实现, + `EdgeCountsFor` / `EdgeCenterCountsFor` 都只是薄包装(后者多传 `centre = true` + `points/iterations`)。 + `EdgeCenterCountEntry`/`m_edgeCenterCountCache` **已删除**。 +- ⚠️ **必须主动请求下一帧**:`EdgeScanFor` 未完成时调用 `RequestEdgeScanFrame()` + (`PostMessage(kMessagePlotDerivedReady)`)。缺了这一步,用户不移动鼠标就没有重绘 → + 预算永远不推进 → **面板永远显示"统计中"**(用户报告②的根因;过去"鼠标移到面板上就好了" + 正是因为悬停变化触发了重绘)。 +- **阈值/参数变化就地把状态重置**(不新建条目,避免拖动时状态爆炸); + ⚠️ 重置当 tick **不展示部分值**(`justReset`)——部分计数每帧都被重置,显示出来就是 + "错误且不更新的数字"(用户报告②的另一半)。稳定后展示**递增的部分值 + `…`**, + 扫完展示精确值。界面文本统一走 `EdgeCountsText(rising, falling, complete)`。 +- **中心轴判定所需曲线必须来自后台**:`EdgeScanFor` 只在 `AcquireCoveringCurve` 命中 + (**键完全相同**,不能像绘制那样用旧参数曲线顶替)时扫块,否则 `ScheduleCurveJob` 后本帧不推进。 + ⚠️ 原先 `EdgeCenterCountsFor` 调 `CurveSpanFor(...)` **没有传 `allowAsyncCompute`**(默认 false)⇒ + 首帧/参数变化后**同步**算整条均值滤波曲线 ⇒ 界面停顿数秒(用户报告③的根因)。 +- `MeanFilterValueAt`(光标读数)也不再调整条同步的 `MeanFilterCurveFor`,改为窗口化 + 异步。 +- ⚠️ `ScheduleCurveJob` 现在**幂等**(`m_curveRunningKeys` 集合 + 队列去重): + 统计侧会每帧请求同一份整条曲线,若每次都当作新请求,正在算的那份立刻被判过时 → + **请求→作废→重请求 的死循环**(永远算不完)。这是本轮最容易踩的坑。 +- 实测(合成方波):6M 采样首帧 **1.4ms**(部分值)、单次最坏 2.1ms; + 横向光标拖动中 10 次查询 **1.07ms**;**同一帧内 5 条信号合计 1.3ms**(预算跨信号共享); + 保持不动 → 收敛到与参考实现**逐位一致**。 + +#### 二、派生曲线内存治理(弱峰调整 / 迭代均值滤波) + +| 措施 | 效果 | +| --- | --- | +| `PeakExtremum` 字段重排(`double value` + `uint32 index` + `int8 type`) | 24 → **16 字节**;密集极值信号(编码器方波)省 ≈1/3 极值表内存(**不改变数值结果**,不能把 `value` 改成 float) | +| 整段窗口**不再复制采样**(`ComputeDerivedWindow` 整段时直接引用原向量) | 每条整段曲线省 **8 B/采样**(2580 万采样 ≈ **206 MB**) | +| **大任务并发上限**(`CurveJob::hugeLimit`:内存充足 2、紧张 1) | 峰值从 ≈4×单条降到 ≤2×(实测 4 并发 3M 采样 412MB → 324MB,且外推值不再随线程数放大) | +| **内存严重不足时直接拒绝计算**(`DerivedMemoryAllows`:可用 < 输出本体所需最小空间) | 只告警**一次**(`kMessagePlotLowMemory` → 宿主 `SetStatusMessage(..., true)`),**不再崩溃** | +| **低内存节流**(`throttle`:阶与阶之间 `Sleep(1)`,并发降为 1) | "少量多次"慢慢算完,不把内存带宽/CPU 抢光 | +| **取消标志贯通到算法**(`ComputeWeakPeakAdjust` / `ComputeMovingAverageIterated` 新增 `cancel` 参数,每阶 / 每 1M 采样检查) | **过时任务立即退出并归还缓冲**(拖动滑块时旧参数的计算立刻作废) | +| `PauseDerivedAsync`/`StopCurveWorker` 先 `m_curveCancelAll = true` 再 join | 关闭预览 / 结构变化时在算任务尽快退出 | +| `AvailablePhysicalBytes()` 结果按 200ms 缓存 | 绘制路径上不再每帧多次系统调用 | +| 极值表按 `count/8` 预留容量(`try/catch(std::bad_alloc)` 保护) | 避免 push_back 反复扩容的 1.5x 峰值 | + +- 复核数据(探针 `.preflight/probe`,3M 采样单次计算):密集极值信号工作缓冲峰值 + **33.23 → 26.73 MiB**;4 并发 **412 → 324 MiB**。 +- ⚠️ 仍然存在的一次性峰值:一条整段曲线 ≈(8 + 12 + 4)B/采样 ≈ 620 MB(2580 万采样), + 这是"整段 O(n) 计算"的固有代价;现在它**有上限、可取消、可拒绝**。 +- ⚠️ `MeanFilterCurveFor` / `WeakPeakCurveFor`(整条同步计算)现在**只有测试用到**, + 产品代码一律走 `CurveSpanFor`(窗口化 + 异步)。 + +#### 三、离屏验证绝不打扰用户(用户第三次强调) + +- `.preflight/uitest` 与 `.preflight/probe` 现在都在 `main` 开头设置 + `SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS)` + + `SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL)` ⇒ + 再长的测试也不会与用户的前台编辑抢 CPU("打字不顺畅"的根因)。 +- 仍然保持:窗口 `SW_HIDE`、只注入合成消息(不移动真实鼠标/键盘)、每次注入后立即 + `ReleaseCapture`(`SetCapture` 是**系统级**的,不释放会把用户的鼠标输入吸走)。 +- ⚠️ 新增/修改 harness 时**必须**保留这三项(隐藏窗口 / 释放捕获 / 低于正常优先级)。 + +#### 四、验证 + +- `.preflight/uitest` 新增/强化:预算推进首帧不显示部分值 → 第二帧显示递增部分值 → + 保持不动收敛到精确值;**未完成时会 PostMessage 请求下一帧**(直接断言); + 拖动阈值期间 `complete == false` 且不显示部分数字且总耗时 < 60ms; + 5 条信号同帧合计 < 120ms(实测 1.3ms);中心轴与参考实现逐位一致且**无需鼠标移动**即可完成。 +- 合计 **751/751 通过**(连跑 3 次稳定);`make release` 零警告。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -1152,6 +1225,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash | **装饰层改为带 alpha 的独立图层(消除 GX/图例外围"直角白边")+ 图例圆角托底与悬停/选中配色统一 + 预览曲线内存释放(详见 §9.20)**。① **白边根因**:装饰层原为"从后备缓冲区抓取一块**不透明矩形**",圆角之外(属于波形)的像素被一起烘进缓存,为让其跨视界可复用又在圆角外铺了直角不透明底色 ⇒ 密集波形上就是一圈直角白边。**修法**:`m_decorDc` 改 **32bpp DIBSection**、新增 `RenderDecorCache()` 把装饰**单独绘制**进全透明位图(不再抓取)、`BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,`GetProcAddress(gdi32!GdiAlphaBlend)` 解析、不新增链接依赖)⇒ 圆角外透出**当前**波形;并做 **alpha 修补**(GDI 文本会把写入像素的 alpha 清 0,不修补文字会变透明洞);删除全部外扩直角底色。② **图例**:逐条目圆角托底(与 GX 徽标同风格),**选中态 = 悬停态配色**(`accentSoft`+`accent`+`textStrong`),悬停在不透明托底上重绘色块/文字并保留曲线光晕;`LegendHit` 新增 `radius`;补齐「+N」/「(无可见信号)」提示的装饰矩形。③ **内存**:`thread_local` 工作缓冲**保留容量封顶**(`kReuseBufferMaxElements` + `ReuseBufferScope`,超大计算用完即释放)+ 关闭最后一个预览功能时 `TrimDerivedCurveDataIfIdle()`(`PauseDerivedAsync` 让线程退出、thread_local 析构归还内存)—— 实测 1792MB → **关闭后 1203MB**(修复前不下降)。④ 性能:`BlitDecorCache` **0.19ms/帧**、`RenderDecorCache` 2.4ms(仅内容变化时);`zoomcheck` mode 13 收敛帧与整幅重绘**逐像素 0 差异**(此前 3503)。验证:离屏 `verify.exe` **49/49 通过**、`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹);⚠️ 当时写的“仅 `offsetTime` 因共用文件时间轴仍需继承”**已被 §9.21 推翻**(现在 `offsetTime` 也按 `time[i]-offsetTime` 重采样烘进采样值)。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 | +| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **派生曲线内存治理 + 边沿统计统一预算状态机(含中心轴)(详见 §9.23)**。① **弱峰调整内存**:`PeakExtremum` 字段重排 24→**16 字节**(密集极值信号省 ≈1/3 极值表);`ComputeDerivedWindow` 整段窗口**不再复制采样**(省 8 B/采样 ≈ 206 MB/条);**大任务并发上限**(内存充足 2 / 紧张 1,实测 4 并发 3M 采样 412→324 MiB);**内存严重不足时直接拒绝计算并只告警一次**(`kMessagePlotLowMemory`,不再 `bad_alloc` 崩溃);**低内存节流**(阶间 `Sleep(1)`、并发 1,"少量多次");**取消标志贯通到算法**(每阶 / 每 1M 采样检查)⇒ **过时任务立即退出并归还缓冲**;`AvailablePhysicalBytes()` 按 200ms 缓存。② **边沿统计**:统一为 `EdgeScanState` + `EdgeScanFor`(普通/中心轴共用),阈值/参数变化就地把状态重置、重置当 tick 不展示部分值(修掉"错误的且不更新的数字");**未完成时主动 `PostMessage(kMessagePlotDerivedReady)` 请求下一帧**(修掉"鼠标不动就一直统计中、移上去才好");中心轴所需曲线**只在后台算**(原先 `CurveSpanFor` 未传 `allowAsyncCompute` ⇒ 同步算整条均值滤波曲线 ⇒ 界面停顿数秒),`MeanFilterValueAt` 同步整条计算的路径也已移除;⚠️ `ScheduleCurveJob` 改为**幂等**(否则统计侧每帧请求会造成"请求→作废→重请求"死循环)。实测:6M 采样首帧 1.4ms、拖动中 10 次查询 1.07ms、**同帧 5 条信号合计 1.3ms**、保持不动收敛到与参考实现逐位一致。③ **离屏 harness/探针**:`main` 开头设置 `BELOW_NORMAL_PRIORITY_CLASS` + 线程低于正常(再长的测试也不抢用户前台 CPU),保持隐藏窗口 / 只注入合成消息 / 注入后立即 `ReleaseCapture`。验证:`uitest` **751/751 通过**(连跑 3 次稳定),`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **新标签页分组默认折起 + GX 徽标悬停不改光标 + 标题栏 RAM 改用「私有工作集」+ 边沿计数改为「逐块累积 + 每 tick 预算推进」(详见 §9.22)**。① `NewTab(bool collapseGroups)`(首个标签页 false、用户新建 true)+ `TabConfig::SetGroupsCollapsedByDefault/SetAllCollapsed`;`SyncToDataSet` 用该默认值初始化**新出现的**分组。② `QueryCursor` 的 GX 徽标分支由 `IDC_HAND` 改为 `nullptr`(悬停不改形状,仅拖拽时 `IDC_SIZENS`)。③ **RAM 口径修正**:原取 `WorkingSetSize`(总工作集,含映射 DLL/共享页与已释放未回收的驻留页)→ 改为 `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`(与任务管理器「内存」列一致;失败退回 `PrivateUsage` → `WorkingSetSize`)。实测加载后 117.55/109.96 MiB、关闭分组后 15.44/7.62 MiB —— **无泄漏,内存完整归还**。④ **边沿计数性能**:改前 25.8M 采样 ≈**115ms/条/帧**(5 条同网格 = 575ms/帧,用户报告"开启即卡顿、之后拖不动");改为按 `kEdgeBlockSamples=8192` 分块累积、每 tick 预算 `kEdgeScanPairsPerTick=1.2M`、区间用块和相加(区间不进键)、拖动横向光标期间不推进并显示上一个完整值 + `complete=false`(`EdgeCountsText` 显示"统计中…/…");实测首帧 1.7ms、单次最坏 1.9ms、拖动中 10 次查询 0.11ms、最终结果与参考实现逐位一致。⑤ **内存排查报告**(用户要求,第 4 项只排查未改动):弱峰调整工作缓冲实测 **≈34 B/采样**(极值表 `PeakExtremum` 24 B/极值为主导),**4 条工作线程并发 +412 MiB / 3M 采样** ⇒ 25.8M 采样峰值 ≈**3.5GB**(+数据 1.03GB +曲线缓存 ≤768MB ⇒ 4~5GB,与用户 4.4GB 观测吻合);**全仓无 `try`/`catch`** ⇒ 低内存时 `bad_alloc` 逸出线程入口 ⇒ `std::terminate()` 崩溃(无优雅降级机制)。验证:离屏 `uitest` **738/738 通过**(新增 `[AR]` 边沿计数预算/精确性/拖动期 O(1)、`[AS]` 标题栏 RAM 口径),`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 | @@ -1255,6 +1329,23 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( ⚠️ 新增/修改 `NewTab` 调用点时别忘了带上这个形参。 31. ⚠️ **网格 GX 徽标悬停不改鼠标形状**(`QueryCursor` 在徽标上返回 `nullptr`,且**提前 return** 以免被光标线/图例判定抢走);只有拖拽中(`LANE_REORDER`)才是 `IDC_SIZENS`。 +32. ⚠️ **预算推进型统计必须自己请求下一帧**:任何“分帧推进”的逻辑(边沿统计、图层重建) + 在未完成时都要 `PostMessage(kMessagePlotDerivedReady)`,否则**用户不动鼠标就永远不推进** + (边沿计数面板“一直显示统计中、鼠标移上去才好”就是这个原因)。 +33. ⚠️ **阈值/参数变化期间不要展示“部分值”**:部分计数每帧都被重置,显示出来就是 + “错误且不更新的数字”。用 `justReset` 抑制一 tick,稳定后再展示递增的部分值(带 `…`)。 +34. ⚠️ **`ScheduleCurveJob` 必须幂等**(`m_curveRunningKeys` + 队列去重):统计侧会每帧请求 + 同一份整条曲线;若每次都当作新请求,正在算的那份立刻被判过时 → **请求→作废→重请求 死循环**。 +35. ⚠️ **大窗口派生计算不能无限并发**:单条整段曲线工作缓冲 ≈620MB(2580 万采样), + 并发上限由内存压力决定(充足 2 / 紧张 1);内存**严重不足**时直接拒绝并只告警一次 + (`kMessagePlotLowMemory`),绝不抛 `bad_alloc` 逸出线程。 +36. ⚠️ **取消标志必须贯通到算法内部**(每阶 / 每 1M 采样检查一次),否则“过时任务立即清退” + 只是口号:千万级一条曲线的工作缓冲要几百 MB,必须尽早归还。 +37. ⚠️ **整段窗口不要复制采样**(`ComputeDerivedWindow`):8 B/采样 × 2580 万 ≈ 206 MB/条; + 整段时直接引用原向量(结构变化前已有 `PauseDerivedAsync` 保证不被改)。 +38. ⚠️ **离屏 harness / 探针必须在 `main` 开头把进程与线程优先级降到「低于正常」** + (`BELOW_NORMAL_PRIORITY_CLASS`),并保持“隐藏窗口 + 只注入合成消息 + 注入后立即 `ReleaseCapture`”。 + `SetCapture` 是系统级的,不释放会把用户的真实鼠标输入吸进测试进程。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 79dbac5..f9369b9 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "CsvTable.hpp" @@ -764,9 +765,15 @@ namespace jdi * @param [IN] radius 滑平半径 N(0 表示原样返回)。 * @param [IN] iterations 迭代次数(至少为 1)。 * @param [OUT] out 输出数组(长度与 values 相同)。 + * @param [IN] cancel 可选的取消标志(非 nullptr 且为 true 时**尽快退出**)。 + * 用于「过时任务立即清退」:拖动参数滑块时旧参数的计算不再需要, + * 越早退出就能越早把工作缓冲(千万级采样时可达数百 MB)归还。 + * @param [IN] throttle 是否在阶与阶之间让出 CPU(低内存设备上的“少量多次”模式)。 */ void ComputeMovingAverageIterated(const std::vector& values, int radius, int iterations, - std::vector& out); + std::vector& out, + const std::atomic* cancel = nullptr, + bool throttle = false); /** * @brief 「弱峰调整」:把比周边更弱的纹波波峰/波谷恢复,或(负幅度时)把弱峰削弱。 @@ -810,9 +817,14 @@ namespace jdi * @param [IN] amount 调整幅度(-4 – +4,对应 -400% – +400%,超出范围会被夹取;0 = 完全不动)。 * @param [IN] iterations 迭代次数 N(重复应用次数,至少为 1)。 * @param [OUT] out 输出数组(长度与 values 相同;原值为 NaN 处保持 NaN)。 + * @param [IN] cancel 可选的取消标志(非 nullptr 且为 true 时**尽快退出**)。 + * 拖动滑块时旧参数的计算会被新参数取代,越早退出越早释放工作缓冲。 + * @param [IN] throttle 是否在阶与阶之间让出 CPU(低内存设备上的“少量多次”模式)。 */ void ComputeWeakPeakAdjust(const std::vector& values, double amount, int iterations, - std::vector& out); + std::vector& out, + const std::atomic* cancel = nullptr, + bool throttle = false); /** * @brief 在指定文件的时间轴上查找与给定时刻最接近的采样点。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 8d47427..abb4be6 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1,12 +1,16 @@ #include "DataModel.hpp" #include +#include #include #include #include #include #include #include +#include + +#include // Sleep(低内存设备的“少量多次”节流) #include "Theme.hpp" #include "Utf8Util.hpp" @@ -1066,7 +1070,8 @@ namespace jdi } // namespace void ComputeMovingAverageIterated(const std::vector& values, int radius, int iterations, - std::vector& out) + std::vector& out, + const std::atomic* cancel, bool throttle) { const int stages = std::max(1, iterations); // 第 1 阶:从原始采样值出发。 @@ -1086,6 +1091,10 @@ namespace jdi std::vector stage; for (int iteration = 1; iteration < stages; ++iteration) { + if (cancel != nullptr && cancel->load(std::memory_order_relaxed)) + { + return; // 过时任务:立即退出(调用方会丢弃结果) + } for (std::size_t index = 0; index < out.size(); ++index) { buffer[index] = std::isfinite(out[index]) ? static_cast(out[index]) @@ -1093,6 +1102,11 @@ namespace jdi } ComputeMovingAverage(buffer, radius, stage); out.swap(stage); + if (throttle) + { + // 低内存设备的“少量多次”:每阶之间小睡一下,把内存带宽与 CPU 让给前台。 + Sleep(1); + } } } @@ -1100,12 +1114,19 @@ namespace jdi { /** * @brief 「弱峰调整」内部使用的极值点(波峰 / 波谷)。 + * + * ⚠️ **字段顺序是刻意的**:`double value` 放在最前、`index` 用 `uint32`、`type` 用 `int8`, + * 使结构体只占 **16 字节**(原来的 `size_t+double+int` 是 24 字节,含 7 字节填充)。 + * 一个千万级方波信号的极值数量与采样数同量级(编码器信号几乎每个跳变都是极值), + * 这张表就是「弱峰调整」工作缓冲的主导项(24 B/极值 ≈ 24 B/采样)—— + * 瘦身后同样规模的整段计算可省下约 1/3 的峰值内存。 + * `uint32` 下标对本项目安全:4.29e9 个 `double` 需要 34 GB 采样内存,不可能达到。 */ struct PeakExtremum { - std::size_t index = 0; /**< 采样下标。 */ - double value = 0.0; /**< 采样值。 */ - int type = 0; /**< +1 = 波峰,-1 = 波谷。 */ + double value = 0.0; /**< 采样值(保持 double:改成 float 会让结果与历史版本不一致)。 */ + std::uint32_t index = 0; /**< 采样下标。 */ + std::int8_t type = 0; /**< +1 = 波峰,-1 = 波谷。 */ }; /** @@ -1133,10 +1154,13 @@ namespace jdi * @param [IN] input 本阶的输入采样值。 * @param [IN] amounts 三组规则的调整幅度(各 -2 – +2,已夹取;0 = 该组不动)。 * @param [OUT] output 本阶的输出(长度与 input 相同;原值为 NaN 处保持 NaN)。 + * @param [OUT] extremes 复用的极值表(容量保留,避免每阶重新分配)。 + * @param [IN] cancel 可选的取消标志(为 true 时**立即返回 false**)。 * @return 确实有采样点被改动返回 true;没有任何点需要调整时返回 false。 */ bool ApplyWeakPeakStage(const std::vector& input, const WeakPeakAmounts& amounts, - std::vector& output, std::vector& extremes) + std::vector& output, std::vector& extremes, + const std::atomic* cancel) { const std::size_t count = input.size(); output.assign(count, std::numeric_limits::quiet_NaN()); @@ -1159,6 +1183,20 @@ namespace jdi // 波峰(先升后降)与波谷(先降后升)必然交替出现, // 因此同类极值就是极值序列里下标 ±2 的两个。 extremes.clear(); + // 适度预留:编码器方波约每 8 个采样一个极值,预留 1/8 能让最常见的密集信号 + // 一次到位(避免 push_back 反复扩容带来的 1.5x 峰值内存);宁少勿多, + // 因为 unreserve 的代价(一次 realloc)远小于无谓地占住内存。 + if (extremes.capacity() < count / 8) + { + try + { + extremes.reserve(count / 8); + } + catch (const std::bad_alloc&) + { + // 预留失败不影响正确性:让 push_back 自行增长(低内存时不报错)。 + } + } { int direction = 0; // 当前单调段方向(+1 上升、-1 下降)。 bool hasPrevious = false; @@ -1166,6 +1204,12 @@ namespace jdi double previousValue = 0.0; for (std::size_t index = 0; index < count; ++index) { + // 取消检查的粒度:每 1M 个采样一次(单次检查开销可忽略, + // 但能让“过时任务”在几十毫秒内退出)。 + if (cancel != nullptr && (index & 0xFFFFFu) == 0 && cancel->load(std::memory_order_relaxed)) + { + return false; + } const double value = static_cast(input[index]); if (!std::isfinite(value)) { @@ -1191,9 +1235,9 @@ namespace jdi { // 方向在此翻转:上一个采样点就是极值(原方向向上则为波峰)。 PeakExtremum extreme; - extreme.index = previousIndex; + extreme.index = static_cast(previousIndex); extreme.value = previousValue; - extreme.type = direction; + extreme.type = static_cast(direction); extremes.push_back(extreme); direction = step; } @@ -1366,7 +1410,8 @@ namespace jdi } // namespace void ComputeWeakPeakAdjust(const std::vector& values, double amount, int iterations, - std::vector& out) + std::vector& out, + const std::atomic* cancel, bool throttle) { const std::size_t count = values.size(); out.assign(count, std::numeric_limits::quiet_NaN()); @@ -1418,12 +1463,25 @@ namespace jdi std::vector& next = nextBuffer; for (int stage = 0; stage < stages; ++stage) { - if (!ApplyWeakPeakStage(current, amounts, next, extremesBuffer)) + if (cancel != nullptr && cancel->load(std::memory_order_relaxed)) { + return; // 过时任务:立即退出(`out` 保留为 NaN,调用方会丢弃它) + } + if (!ApplyWeakPeakStage(current, amounts, next, extremesBuffer, cancel)) + { + if (cancel != nullptr && cancel->load(std::memory_order_relaxed)) + { + return; // 被取消(而非“本阶无改动”) + } // 这一阶没有任何采样点需要调整:后续迭代次数也不会再有变化,提前结束。 break; } current.swap(next); + if (throttle) + { + // 低内存设备的“少量多次”:每阶之间小睡一下,把内存带宽与 CPU 让给前台。 + Sleep(1); + } } out.swap(current); }