派生曲线内存治理(弱峰调整等)+ 边沿统计改为统一预算状态机(含中心轴)+ 离屏测试零打扰

一、派生曲线内存治理(按上一轮报告的方案实施;绘制性能优先,同时为小内存设备留出空间)
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 条硬约束、
变更日志补一行。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-14 15:24:19 +08:00
1 parent d6d3fcd6c1
commit 375a15c8c2
6 files changed
+784 -429

No files matched your search

+124 -32
View File
@@ -8,10 +8,12 @@
#include <atomic>
#include <condition_variable>
#include <deque>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#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<LaneVisual> 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<std::size_t> blockRising; /**< 每块的上升沿数量(已扫到的部分)。 */
std::vector<std::size_t> 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<EdgeCountEntry> m_edgeCountCache; /**< 边沿统计累积缓存(纯缓存,逻辑上不影响对象状态)。 */
mutable std::vector<EdgeScanState> 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<CurveEntryPtr> m_curveEntries; /**< 已算好条目(双缓冲的「就绪缓冲」)。 */
mutable std::vector<CurveEntryPtr> m_curvePins; /**< 本帧正在使用的条目(阻止其被回收)。 */
mutable std::atomic<bool> 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<EdgeCenterCountEntry> m_edgeCenterCountCache; /**< 中心轴边沿统计缓存。 */
/**
* @brief 「每个(信号 + 曲线种类)的最新请求序号」。
*
* 拖动参数滑块时同一 (信号, 种类) 会被反复请求;已入队/正在计算的任务一旦不是最新请求,
* 其参数已被取代 → 工作线程看到后**立即退出并归还缓冲**(“过时任务立即清退”)。
* 值随每次 `ScheduleCurveJob` 单调递增,因此可以表达“比谁新”。
*/
mutable std::map<std::pair<const SignalData*, int>, std::uint64_t> m_curveLatest;
mutable std::atomic<std::uint64_t> m_curveRequestSerial{0}; /**< 全局请求序号发生器。 */
mutable int m_curveHugeActive = 0; /**< 正在执行的“大窗口”任务数(最多 1,低内存时保证串行)。 */
/**
* @brief 正在计算的曲线键集合(由工作线程在计算期间增删)。
*
* 有了它,`ScheduleCurveJob` 才能做到**幂等**:统计侧(边沿计数)会在“曲线还没算好”时
* 每帧请求一次整条曲线的计算;若每次都当作新请求,就会把上一份正在算的任务判为过时,
* 于是永远算不完(请求 → 作废 → 重请求 的死循环)。
* 用**集合**而不是单个键:线程池可能同时算多条不同曲线。
*/
mutable std::vector<CurveKey> m_curveRunningKeys;
mutable std::atomic<bool> m_curveCancelAll{false}; /**< 请求全部在算任务立即退出(结构变化 / 关闭预览)。 */
mutable bool m_curveLowMemoryWarned = false; /**< 「可用内存严重不足」是否已告警(避免反复弹窗)。 */
// 「边沿计数」横向光标 / 面板的拖拽状态。
int m_edgeDragLane = -1; /**< 正在拖动的横向光标所属网格。 */