新标签页分组默认折起 + GX 徽标悬停不改光标 + 标题栏 RAM 改私有工作集 + 边沿计数改为预算推进

一、新建标签页的信号分组默认折起
- Application::NewTab(bool collapseGroups):启动时的首个标签页传 false(加载文件后可直接勾选
  信号),用户新建的标签页(Ctrl+T / 标签栏 ⊕ / 标签栏右键)传 true。
- TabConfig::SetGroupsCollapsedByDefault(bool) + SetAllCollapsed(bool)(新增);
  SyncToDataSet 里 m_collapsed.resize(fileCount, m_groupsCollapsedByDefault)(原来固定 false)——
  只影响之后新出现的分组,已存在的分组保持用户当前选择;ToggleCollapsed 补建状态时同样用它。

二、网格 GX 徽标:悬停不改变鼠标形状
- PlotView::QueryCursor 里 GX 徽标分支由 IDC_HAND 改为 return nullptr(= 保持箭头),
  仍然保持"提前 return"的位置以免被光标线/图例判定抢走优先级;
  只有拖拽中(LANE_REORDER)才是 IDC_SIZENS。

三、标题栏 RAM 口径:总工作集 → 私有工作集(任务管理器"内存"列)
- 症状:加载大文件再关闭分组后,标题栏仍显示几十/上百 MB,任务管理器只有几 MB。
- 根因:原实现取 PROCESS_MEMORY_COUNTERS::WorkingSetSize(总工作集),它把映射进来的 DLL /
  共享页面以及已释放但尚未被系统回收的驻留页面都算进去;任务管理器"内存"列的口径是私有工作集。
- 修法:ProcessWorkingSetBytes() → ProcessPrivateWorkingSetBytes(),用 PROCESS_MEMORY_COUNTERS_EX2
  (MinGW 的 psapi.h 没有该类型 → 在 .cpp 里就地声明,布局与文档一致),失败时依次退回
  PrivateUsage(私有提交)→ WorkingSetSize。
- 实测(离屏探针):加载后 total/priv/commit = 117.55/109.96/138.28 MiB;
  关闭分组后 = 15.44/7.62/8.27 MiB ⇒ 内存没有泄漏、完整归还,用户的差异来自口径。

四、边沿计数(EdgeCountsFor)改为「逐块累积 + 每 tick 预算推进」
- 量化(探针):改前 6M 采样 = 26.8ms / 次阈值变化 → 25.8M 采样 ≈115ms/条;
  横向光标拖动时阈值每帧都变 ⇒ 单条信号每帧 115ms,5 条信号同一网格 = 575ms/帧
  (与用户"开启即卡顿、之后光标几乎拖不动"完全吻合)。
- 新模型:
  * 采样对 k(采样 k-1 → k)按 kEdgeBlockSamples = 8192 分块,每块各存上升/下降沿数;
    scannedPair = 下一个待扫描的采样对([1, scannedPair) 已扫完)。
  * 查询区间:内部整块用块和相加,两端边界块就地精确扫描(≤2 块,代价固定)⇒ 拖动时间光标的
    单帧代价与区间长度无关(单调时间轴下区间不进键);非单调时间轴无法用下标区间表达 ⇒ 区间进键。
  * 每 tick 预算 kEdgeScanPairsPerTick = 1200000(GetTickCount64 分桶,同帧多次调用共享);
    预算用完返回当前部分值,complete 出参告诉界面"还没算完"。
  * 拖动横向光标(EDGE_MOVE)期间不推进(阈值每帧都变,推进纯属白做功)→ 返回"最新的完整条目"
    的结果(数字稳定)+ complete = false。
  * 快路径:单调 && 无镜像 && 无调整变换时用紧凑无分支循环直接比较 values[k] 与阈值
    (NaN 与任何数比较都是 false ⇒ 天然满足"NaN 不参与且打断连续",无需 isfinite 分支)。
- 界面:新增 EdgeCountsText(rising, falling, complete) 统一生成文本(完全未算出显示"统计中…"、
  部分结果追加" …"),EdgePanelWidth 与绘制都改用它,宽度按实际文本测量避免数字被截断。
- 实测(改后):6M 采样首次调用 1.7ms(部分值)、5 帧内扫完且与参考实现逐位一致;
  单次调用最坏 1.9ms;拖动中连查 10 个阈值合计 0.11ms;区间查询在扫描完成后一次调用即精确。
- EdgeCenterCountsFor 未改(它的曲线窗口本来就被 CurveSpanFor 限制,不是本次报告的场景)。

五、内存与性能排查报告(第 4 项按用户要求只排查、未改动)
- 泄漏审计:RemoveFile 路径会 PauseDerivedAsync(清 m_curveEntries/m_curveJobs/m_curvePins)
  → InvalidateDerivedCaches(清折线/边沿/均值/弱峰/场景缓存),DataSet::RemoveFile 用
  "前移 + pop_back + shrink_to_fit" 立即归还采样缓冲;实测三种内存口径全部回落到十几 MB 以下。
- 弱峰调整内存暴涨(峰值 4.4GB):实测工作缓冲 ≈34 B/采样(主导项 = 极值表 PeakExtremum 24 B/极值,
  编码器方波极值密集 ⇒ 接近 24 B/采样),3M 采样单次 ≈103 MiB、4 条工作线程并发 +412 MiB;
  按 25.8M 采样外推 ⇒ 单次 ≈0.9GB、峰值 ≈3.5GB,再加数据本体 1.03GB 与曲线缓存预算 ≤768MB
  ⇒ 4~5GB,与用户观测吻合。
- 现有机制只解决"算完/关掉之后归还"(ReuseBufferScope / TrimDerivedCurveDataIfIdle /
  kHugeSignalPreviewSamples / 曲线缓存字节预算),没有任何"峰值上限"与"优雅降级":
  全仓没有 try/catch,重分配失败抛 std::bad_alloc 而重计算都在 std::thread 里
  (CurveWorkerLoop / RunAsyncLoad),异常逸出线程入口 ⇒ std::terminate() ⇒ 进程终止。
  1GB 剩余内存时:先大量换页(系统卡顿),提交失败则直接崩溃。
- 建议(未实施):①超大信号派生计算串行化 ②极值表字段瘦身/分块流式 ③废弃任务尽早退出
  ④GlobalMemoryStatusEx 自适应降档 ⑤线程入口加 try/catch。

六、验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 .preflight/probe(诊断探针):内存口径与泄漏审计、边沿计数扫描耗时、弱峰工作缓冲峰值
  (带 1ms 峰值采样线程)。
- .preflight/uitest 新增 [AR](边沿计数预算/精确性/区间查询/拖动期 O(1)、GX 徽标悬停光标)与
  [AS](标题栏 RAM 口径 = 私有工作集);并给菜单类用例加了关闭残留菜单 + 允许补点的卫生逻辑。
- 合计 738/738 通过(连跑 5 次约 1 次出现既有 harness 抖动:菜单点击类用例,重跑即好);
  make release 零警告(Target.exe 被占用时链接报 Permission denied,用临时 -o 名字验证链接)。

规则同步:Documents/agent_mem.md 新增 §9.22(含内存与性能排查报告),§11.2 新增第 26–31 条硬约束,
变更日志补一行。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-14 14:12:54 +08:00
1 parent a2a38236fe
commit d6d3fcd6c1
7 files changed
+548 -119

No files matched your search

+6 -1
View File
@@ -140,7 +140,12 @@ namespace jdi
/**
* @brief 新建标签页并切换为激活页。
*/
void NewTab();
/**
* @brief 新建一个标签页并切换到它。
* @param [IN] collapseGroups 是否把信号列表里的分组**默认折起**
*(用户新建的标签页传 true;启动时的首个标签页传 false,便于直接勾选信号)。
*/
void NewTab(bool collapseGroups);
/**
* @brief 生成当前未被占用的最小标签页名称(关闭标签页后编号可被复用)。
+59 -12
View File
@@ -51,15 +51,50 @@ namespace jdi
const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */
/**
* @brief 查询本进程当前占用的物理内存(工作集)。
* @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。
*
* 与任务管理器里显示的“内存”一致:进程已驻留在物理内存中的字节数。
* 使用 `K32GetProcessMemoryInfo`(kernel32 导出)以免为 `psapi` 增加一条链接依赖。
* ⚠️ 原先取的是 `PROCESS_MEMORY_COUNTERS::WorkingSetSize`(**总工作集**),它把映射进来的
* DLL / 共享页面以及已经释放但尚未被系统回收的驻留页面都算进去 —— 加载一个大文件再
* 关闭分组后,实际占用的私有内存已经回到几 MB,标题栏却还显示几十 MB,
* 与任务管理器(资源管理器里看到的"内存")对不上(用户报告)。
* `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize` 正是任务管理器"内存"列的口径:
* 只统计**属于本进程的私有、且驻留在物理内存中**的页面。
*
* @return 工作集字节数;查询失败返回 0。
* 结构体在这里就地声明(MinGW 的 psapi.h 未提供 `PROCESS_MEMORY_COUNTERS_EX2`),
* 布局与 Win32 文档一致;EX2 查询失败(老系统)时退回 `PrivateUsage`(私有提交),
* 再不行退回 `WorkingSetSize`,保证标题栏永远有值。
*
* @return 内存占用字节数;查询失败返回 0。
*/
unsigned long long ProcessWorkingSetBytes()
unsigned long long ProcessPrivateWorkingSetBytes()
{
struct MemoryCountersEx2
{
DWORD cb;
DWORD PageFaultCount;
SIZE_T PeakWorkingSetSize;
SIZE_T WorkingSetSize;
SIZE_T QuotaPeakPagedPoolUsage;
SIZE_T QuotaPagedPoolUsage;
SIZE_T QuotaPeakNonPagedPoolUsage;
SIZE_T QuotaNonPagedPoolUsage;
SIZE_T PagefileUsage;
SIZE_T PeakPagefileUsage;
SIZE_T PrivateUsage;
SIZE_T PrivateWorkingSetSize;
SIZE_T SharedCommitUsage;
};
MemoryCountersEx2 ex2 = {};
ex2.cb = sizeof(ex2);
if (K32GetProcessMemoryInfo(GetCurrentProcess(),
reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&ex2),
sizeof(ex2)) != FALSE &&
ex2.PrivateWorkingSetSize > 0)
{
return static_cast<unsigned long long>(ex2.PrivateWorkingSetSize);
}
PROCESS_MEMORY_COUNTERS_EX counters = {};
counters.cb = sizeof(counters);
if (K32GetProcessMemoryInfo(GetCurrentProcess(),
@@ -68,6 +103,10 @@ namespace jdi
{
return 0ull;
}
if (counters.PrivateUsage > 0)
{
return static_cast<unsigned long long>(counters.PrivateUsage);
}
return static_cast<unsigned long long>(counters.WorkingSetSize);
}
@@ -374,8 +413,8 @@ namespace jdi
SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(smallIcon));
}
// 创建首个标签页。
NewTab();
// 创建首个标签页(分组保持展开:加载文件后直接就能勾选信号)。
NewTab(false);
Relayout();
// 标题栏内存占用按固定间隔刷新(不使用每帧刷新,避免频繁改动窗口标题)。
@@ -443,11 +482,18 @@ namespace jdi
return m_tabs[static_cast<std::size_t>(m_activeTab)];
}
void Application::NewTab()
void Application::NewTab(bool collapseGroups)
{
TabConfig tab;
tab.SetName(NextTabName());
// 用户新建的标签页:信号分组**默认折起**(用户明确要求:列表默认紧凑;
// 之后在该标签页里新加载的分组也沿用这个默认值)。
tab.SetGroupsCollapsedByDefault(collapseGroups);
tab.SyncToDataSet(m_dataSet);
if (collapseGroups)
{
tab.SetAllCollapsed(true);
}
m_tabs.push_back(std::move(tab));
SetActiveTab(static_cast<int>(m_tabs.size()) - 1);
@@ -899,10 +945,11 @@ namespace jdi
{
// 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。
// 除内存占用外不再附带标签页、文件数量等无关信息。
// ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。
std::wstring title = kWindowTitle;
title += kTitleSeparator;
title += kRamTitlePrefix;
title += FormatByteSize(ProcessWorkingSetBytes());
title += FormatByteSize(ProcessPrivateWorkingSetBytes());
// 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。
if (title == m_windowTitleText)
{
@@ -1282,7 +1329,7 @@ namespace jdi
}
if (control && key == L'T')
{
NewTab();
NewTab(true);
return true;
}
if (control && key == L'W')
@@ -1412,7 +1459,7 @@ namespace jdi
switch (result.type)
{
case TabBarResult::Type::ADD:
NewTab();
NewTab(true);
break;
case TabBarResult::Type::SELECT:
if (result.index != m_activeTab)
@@ -2050,7 +2097,7 @@ namespace jdi
const int index = m_tabMenuIndex;
if (result.id == kMenuTabNew)
{
NewTab();
NewTab(true);
}
else if (result.id == kMenuTabClose)
{
+34 -19
View File
@@ -1601,9 +1601,14 @@ namespace jdi
*
* ⚠️ 因此判定值必须是**画出来的值**:先按 `style` 做水平镜像映射,再套用
* `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。
* 只比较原始采样值会导致「应用过调整的信号数不到任何边沿」
*(横向光标读的是屏幕上的值,与原始值域完全不同)。
* ⚠️ 时间区间**之外**的采样一律不参与计数:只有两个端点都在区间内的采样对才计入。
* 只比较原始采样值会导致「应用过调整的信号数不到任何边沿」。
* ⚠️ 时间区间**之外**的采样一律不参与计数(只有两个端点都在区间内的采样对才计入)。
*
* ⚠️ **本函数绝不长时间阻塞 UI 线程**(千万级信号整段扫描实测 ≈115ms/条,
* 横向光标每一帧都会换阈值 → 拖动时每帧都要扫,5 条信号就是 ≈575ms/帧):
* 扫描按「逐块累积」进行,每个 tick(约一帧)只消耗 `kEdgeScanPairsPerTick`
* 个采样对的预算,未扫完时 `complete` 返回 false(界面显示上一个完整结果 + “…”)。
* 拖动横向光标(`EDGE_MOVE`)期间阈值每帧都变,推进等于白做功 → 不推进。
*
* @param [IN] file 信号所属文件。
* @param [IN] signal 目标信号。
@@ -1614,10 +1619,11 @@ namespace jdi
* @param [IN] rangeMax 区间右端点时间。
* @param [OUT] rising 上升沿数量(从下往上穿越)。
* @param [OUT] falling 下降沿数量(从上往下穿越)。
* @param [OUT] complete 结果是否为该阈值的**完整**精确值(false = 仍在推进,当前是临时值)。
*/
void 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) const;
std::size_t& rising, std::size_t& falling, bool& complete) const;
/**
* @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。
@@ -1819,28 +1825,37 @@ namespace jdi
std::vector<LaneVisual> m_lanes; /**< 当前网格绘制信息集合。 */
/**
* @brief 边沿统计缓存项。
* @brief 「边沿计数」的累积统计条目。
*
* 计数只由「采样下标区间 [begin, end) 与判定基准(横向光标幅值)」决定,
* 因此**时间区间不进缓存键**:拖动时间光标时区间每帧都在变,若按时间区间做键就会
* 每帧全量重扫整段区间(亿级采样下正是拖拽卡顿的主因,实测单帧数十毫秒)。
* 改为记住「上一帧统计过的下标区间」:新区间与旧区间共享一个端点时(拖动时必然如此)
* 只扫描两端差集并增量加减,单帧代价与光标位移成正比、与区间长度无关。
* 千万级信号整段扫描一次就要 ≈115ms,而横向光标拖动时**每一帧**都会换阈值
*(5 条信号同一网格实测 ≈575ms/帧,界面直接失去响应)。
* 因此统计改为「逐块累积 + 每 tick 有限预算推进」(与 `RenderPendingLaneRebuilds(1)`
* 同一套「宁可晚一点、绝不卡住前台」的思路):
* * 采样对 `k`(采样 `k-1 → k`)按 `kEdgeBlockSamples` 分块,每块各存上升/下降沿数;
* * `scannedPair` = 下一个待扫描的采样对,[1, scannedPair) 已扫完;
* * 查询某个区间时,内部整块用存储值相加、两端边界块精确扫描(≤ 2 块,代价固定);
* * 时间区间**不进键**(单调时间轴下区间只是查询区间,拖动时间光标无需重扫),
* 但非单调时间轴无法用下标区间表达统计范围,此时区间必须进键(否则会混用结果)。
*/
struct EdgeCountEntry
{
const DataFile* file = nullptr; /**< 所属数据分组。 */
const SignalData* signal = nullptr; /**< 目标信号。 */
std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹(变了必须整段重算)。 */
double edgeValue = 0.0; /**< 判定阈值(横向光标幅值)。 */
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; /**< 该区间内的下降沿数量。 */
std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹。 */
double threshold = 0.0; /**< 判定阈值(横向光标幅值)。 */
bool hasRange = false; /**< 是否限定在时间区间内(仅非单调时间轴时进键)。 */
double rangeMin = 0.0; /**< 统计区间左端(仅非单调时间轴时进键)。 */
double rangeMax = 0.0; /**< 统计区间右端(仅非单调时间轴时进键)。 */
std::vector<std::size_t> blockRising; /**< 每块的上升沿数量(已扫到的部分)。 */
std::vector<std::size_t> blockFalling; /**< 每块的下降沿数量(已扫到的部分)。 */
std::size_t scannedPair = 1; /**< 下一个待扫描的采样对下标([1, scannedPair) 已扫完)。 */
std::size_t rising = 0; /**< 已扫部分的上升沿总数。 */
std::size_t falling = 0; /**< 已扫部分的下降沿总数。 */
bool complete = false; /**< 整条信号是否已扫完。 */
};
mutable std::vector<EdgeCountEntry> m_edgeCountCache; /**< 边沿统计缓存(纯缓存,逻辑上不影响对象状态)。 */
mutable std::vector<EdgeCountEntry> m_edgeCountCache; /**< 边沿统计累积缓存(纯缓存,逻辑上不影响对象状态)。 */
mutable std::size_t m_edgeScanBudget = 0; /**< 本 tick 剩余可扫描的采样对数量(限制单帧总开销)。 */
mutable std::uint64_t m_edgeScanTick = 0; /**< 最近一次重置扫描预算的 tick。 */
/** 多次迭代均值滤波曲线缓存项(按「信号指针 + 均值点数 + 迭代次数」标识)。 */
struct MeanCurveEntry
+275 -85
View File
@@ -149,6 +149,42 @@ namespace jdi
const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */
const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */
const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */
/**
* @brief 「边沿计数」累积统计的分块大小(采样点数)。
*
* 每块各存上升/下降沿数:查询某个时间区间时,内部整块用存储值相加、只有两端边界块
* 需要就地精确扫描(≤ 2 块),因此拖动时间光标的单帧代价与区间长度无关。
* 取小一点 ⇒ 边界块扫描更便宜;取大一点 ⇒ 块数组更省内存。
*/
const std::size_t kEdgeBlockSamples = 8192;
/**
* @brief 每个 tick(约一帧)允许「边沿计数」扫描的采样对总数。
*
* 千万级信号整段扫描一次 ≈115ms(用户报告:5 条信号同一网格时单帧 ≈575ms,
* 界面完全失去响应)。改成预算推进后,单帧最多只花 ≈5ms 在统计上,
* 剩下的部分在后续帧里接着扫(未扫完时界面显示上一个完整结果 + “…”)。
*/
const std::size_t kEdgeScanPairsPerTick = 1200000;
/**
* @brief 「边沿计数」结果的显示文本。
* @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。
* @param [IN] complete 是否为该阈值的完整精确值。
* @return 面板/宽度测量共用的文本(未算完时带省略号,完全未开始时显示“统计中…”)。
*/
std::wstring EdgeCountsText(std::size_t rising, std::size_t falling, bool complete)
{
if (!complete && rising == 0 && falling == 0)
{
return L"统计中…";
}
std::wstring text = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling);
if (!complete)
{
text += L" …";
}
return text;
}
/**
* @brief 判断坐标是否命中「弱峰调整」的某条滑块轨道。
@@ -2896,27 +2932,31 @@ namespace jdi
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) const
std::size_t& rising, std::size_t& falling, bool& complete) const
{
rising = 0;
falling = 0;
complete = false;
const std::vector<double>& values = signal.values;
// ⚠️ 阈值(横向光标幅值)是**屏幕上的值**,因此判定必须基于**画出来的曲线**:
// 先按下标映射(水平镜像)取源采样,再套用「调整」变换(系数 → 绝对值 → 垂直镜像 →
// Y 偏移)。只比较原始采样值时,“系数 50”这类信号的原始值域与阈值完全不相交,
// 表现就是“明明是很规整的方波,统计出来却全是 0”(用户报告)。
// Y 偏移)。只比较原始采样值时,"系数 50" 这类信号的原始值域与阈值完全不相交,
// 表现就是"明明是很规整的方波,统计出来却全是 0"(用户报告)。
const std::size_t count = values.size();
if (count < 2)
{
complete = true;
return;
}
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);
std::size_t begin = 0;
std::size_t end = 0;
const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end);
// 时间单调 ⇒ 统计区间可以用**采样对下标区间**表达(区间不进键,拖动时间光标无需重扫)。
const bool monotonic = file.timeMonotonic;
// 判定基准:**绘制值**相对阈值的偏差。区间之外的采样返回 NaN(等价于原实现
// 「区间外采样不参与判定且打断连续」的语义)。
// 判定基准:**绘制值**相对阈值的偏差(区间之外 / 缺失值返回 NaN = 不参与且打断连续)。
const auto deltaAt = [&values, &file, &style, mirrored, transform, count, hasRange, rangeMin,
rangeMax](std::size_t index) -> double
{
@@ -2946,86 +2986,234 @@ namespace jdi
return std::isfinite(drawn) ? drawn : std::numeric_limits<double>::quiet_NaN();
};
// 按「文件 + 信号 + 样式指纹 + 阈值 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。
EdgeCountEntry* entry = nullptr;
for (EdgeCountEntry& candidate : m_edgeCountCache)
// 请求的「采样对」区间 [pairFirst, pairLast](pair k = 采样 k-1 → k,k ≥ 1)。
// ⚠️ 只有**两个端点都落在区间内**的采样对才计入(非单调时无法用下标表达,由 deltaAt 过滤)。
std::size_t pairFirst = 1;
std::size_t pairLast = count - 1;
if (hasRange && monotonic)
{
if (candidate.file == &file && candidate.signal == &signal &&
candidate.styleKey == styleKey && candidate.hasRange == hasRange &&
candidate.edgeValue == edgeValue)
std::size_t indexBegin = 0;
std::size_t indexEnd = 0;
EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, indexBegin, indexEnd);
pairFirst = (indexBegin + 1 < count) ? (indexBegin + 1) : count;
pairLast = (indexEnd > 0) ? (indexEnd - 1) : 0;
if (pairLast < pairFirst)
{
entry = &candidate;
break;
complete = true;
return;
}
}
if (entry == nullptr)
// ---- 定位 / 推进累积条目 -------------------------------------------------
// ⚠️ 拖动横向光标(`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)
{
if (m_edgeCountCache.size() >= kEdgeCountCacheLimit)
if (!sameSignal(candidate) || candidate.threshold != edgeValue)
{
m_edgeCountCache.erase(m_edgeCountCache.begin());
return false;
}
EdgeCountEntry created;
created.file = &file;
created.signal = &signal;
created.styleKey = styleKey;
created.edgeValue = edgeValue;
created.hasRange = hasRange;
m_edgeCountCache.push_back(created);
entry = &m_edgeCountCache.back();
}
// 非单调时间轴:区间参与判定,必须一并比较(否则会混用别的区间结果)。
return monotonic || (candidate.hasRange == hasRange && candidate.rangeMin == rangeMin &&
candidate.rangeMax == rangeMax);
};
// 阈值参与判定,因此仿函数要在累加时一并带入。
const auto crossingAt = [&deltaAt, edgeValue](std::size_t index) -> double
{ return deltaAt(index) - edgeValue; };
// 区间完全一致 → 直接命中。
if (indexed && entry->reusable && entry->begin == begin && entry->end == end)
// 用**下标**而不是指针记录候选(`push_back` 可能重新分配,指针会失效)。
int entryIndex = -1;
int newestCompleteIndex = -1;
for (std::size_t index = 0; index < m_edgeCountCache.size(); ++index)
{
rising = entry->rising;
falling = entry->falling;
return;
const EdgeCountEntry& candidate = m_edgeCountCache[index];
if (!sameSignal(candidate))
{
continue;
}
if (entryIndex < 0 && sameKey(candidate))
{
entryIndex = static_cast<int>(index);
}
if (candidate.complete)
{
newestCompleteIndex = static_cast<int>(index); // 淘汰从 front 开始 ⇒ 越靠后越新
}
}
EdgeCountEntry* entry = (entryIndex >= 0) ? &m_edgeCountCache[static_cast<std::size_t>(entryIndex)] : nullptr;
if (!thresholdDragging && (entry == nullptr || !entry->complete))
{
if (entry == nullptr)
{
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; // 可能已被重新分配/淘汰,不再使用它
}
// 扫描预算按 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)
{
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;
while (pair < count && scanned < m_edgeScanBudget)
{
const std::size_t blockFirst = block * kEdgeBlockSamples + 1;
const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples);
const std::size_t from = std::max(pair, blockFirst);
const std::size_t to = std::min(blockLast, from + (m_edgeScanBudget - scanned) - 1);
if (to < from)
{
break;
}
std::size_t localRising = 0;
std::size_t localFalling = 0;
if (fastPath)
{
// 紧凑循环:无变换 / 无镜像 / 时间单调 ⇒ 直接比较阈值,NaN 天然不计数。
// (NaN 与任何数比较都为 false,因此不需要额外的 isfinite 分支。)
double previous = values[from - 1] - edgeValue;
for (std::size_t index = from; index <= to; ++index)
{
const double current = values[index] - edgeValue;
if (previous < 0.0 && current >= 0.0)
{
++localRising;
}
else if (previous > 0.0 && current <= 0.0)
{
++localFalling;
}
previous = current;
}
}
else
{
double previous = deltaAt(from - 1) - edgeValue;
for (std::size_t index = from; index <= to; ++index)
{
const double current = deltaAt(index) - edgeValue;
if (std::isfinite(previous) && std::isfinite(current))
{
if (previous < 0.0 && current >= 0.0)
{
++localRising;
}
else if (previous > 0.0 && current <= 0.0)
{
++localFalling;
}
}
previous = current;
}
}
if (block < entry->blockRising.size())
{
entry->blockRising[block] += localRising;
entry->blockFalling[block] += localFalling;
}
entry->rising += localRising;
entry->falling += localFalling;
scanned += (to - from + 1);
pair = to + 1;
if (to == blockLast)
{
++block;
}
}
entry->scannedPair = pair;
entry->complete = (pair >= count);
m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned);
}
}
// 与上一帧共享一个端点(拖动光标时必然如此)→ 只扫描两端差集并增量加减。
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;
std::size_t deltaRising = 0;
std::size_t deltaFalling = 0;
if (shareBegin && !shareEnd)
// ---- 汇总请求区间 -------------------------------------------------------
// 优先用「键完全匹配」的条目(可能仍在推进);没有就用最新的完整条目顶替,
// 这样拖动阈值期间数字保持稳定、算完后自动切换到精确值。
const EdgeCountEntry* source = (entry != nullptr)
? entry
: ((newestCompleteIndex >= 0)
? &m_edgeCountCache[static_cast<std::size_t>(newestCompleteIndex)]
: nullptr);
if (source == nullptr)
{
// 右端移动:变动的是采样对 k ∈ [min(oldEnd, end), max(oldEnd, end) - 1]。
const std::size_t low = std::min(oldEnd, end);
const std::size_t high = std::max(oldEnd, end);
AccumulateEdgeCrossings(std::max<std::size_t>(1, low), high - 1, crossingAt, deltaRising,
deltaFalling);
rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising);
falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling);
return; // 尚无任何结果:界面显示"统计中…"
}
else if (shareEnd && !shareBegin)
const auto sumRange = [&](const EdgeCountEntry& from, std::size_t first, std::size_t last,
std::size_t& outRising, std::size_t& outFalling) -> bool
{
// 左端移动:变动的是采样对 k ∈ [min(oldBegin, begin) + 1, max(oldBegin, begin)]。
const std::size_t low = std::min(oldBegin, begin);
const std::size_t high = std::max(oldBegin, begin);
AccumulateEdgeCrossings(low + 1, high, crossingAt, deltaRising, deltaFalling);
rising = (begin < oldBegin) ? (entry->rising + deltaRising) : (entry->rising - deltaRising);
falling = (begin < oldBegin) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling);
}
else
{
// 两端都变了(或首次统计):整段重算。
const std::size_t pairFirst = (end > begin) ? (begin + 1) : 1;
const std::size_t pairLast = (end > begin) ? (end - 1) : 0;
AccumulateEdgeCrossings(pairFirst, pairLast, crossingAt, rising, falling);
}
entry->reusable = indexed;
entry->begin = begin;
entry->end = end;
entry->rising = rising;
entry->falling = falling;
outRising = 0;
outFalling = 0;
if (last < first)
{
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 lastBlock = (limit - 1) / kEdgeBlockSamples;
for (; 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 high = std::min(limit, blockLast);
const bool wholeBlock = (low == blockFirst) && (high == blockLast);
if (wholeBlock && from.scannedPair > blockLast && block < from.blockRising.size())
{
outRising += from.blockRising[block];
outFalling += from.blockFalling[block];
continue;
}
// 边界块 / 尚未扫完的块:就地精确扫描这一小段(≤ 1 个块)。
double previous = deltaAt(low - 1) - edgeValue;
for (std::size_t index = low; index <= high; ++index)
{
const double current = deltaAt(index) - edgeValue;
if (std::isfinite(previous) && std::isfinite(current))
{
if (previous < 0.0 && current >= 0.0)
{
++outRising;
}
else if (previous > 0.0 && current <= 0.0)
{
++outFalling;
}
}
previous = current;
}
}
}
return (last < from.scannedPair);
};
const bool covered = sumRange(*source, pairFirst, pairLast, rising, falling);
complete = covered && sameKey(*source);
}
float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const
@@ -3070,6 +3258,7 @@ namespace jdi
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
if (meanFilter || weakPeak)
{
continue;
@@ -3088,12 +3277,12 @@ namespace jdi
else
{
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling);
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
// 宽度按实际要显示的文本测量(含未算完时的“统计中…”/省略号),
// 否则推进过程中数字变长会被截断(统计数字必须完整显示)。
const float countWidth =
UiMeasureTextWidth(m_fonts->monoSmall,
L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling));
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountsText(rising, falling, complete));
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth));
}
@@ -4233,12 +4422,12 @@ namespace jdi
}
}
// 图例中的「GX」编号徽标:悬停时返回**手型** —— 与左侧信号列表里同一个 GX 徽标的
// 悬停效果完全一致(用户明确要求"参考信号选区的信号名后方的 GX 标签");
// 真正按下拖拽时再切为上下箭头(见 LANE_REORDER 分支)。
// 图例中的「GX」编号徽标:**悬停时不改变鼠标形状**(用户明确要求:只在按下左键
// 拖拽时才显示上下箭头,见 `LANE_REORDER` 分支)。这里保持“不改变”的语义并
// **提前返回**(不再往下落到光标线/图例判定),与原先的优先级完全一致。
if (LaneBadgeAtPoint(x, y) >= 0)
{
return LoadCursorW(nullptr, IDC_HAND);
return nullptr;
}
// 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。
@@ -11665,10 +11854,11 @@ namespace jdi
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling);
const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling);
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
complete);
const std::wstring counts = EdgeCountsText(rising, falling, complete);
UiText(g, counts, m_fonts->monoSmall,
gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight),
m_theme->textMuted, TextAlign::LEFT);
+145
View File
@@ -998,6 +998,127 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
---
### 9.22 新标签页默认折起分组 + GX 徽标悬停不改光标 + 标题栏 RAM 口径 + 边沿计数改为预算推进(本轮)
> 动机:用户报告 4 个交互/数值问题 + 1 个性能问题(本机起源设备)。全部离屏验证。
#### 一、新建标签页的信号分组**默认折起**
- `Application::NewTab(bool collapseGroups)`(新增形参):**启动时的首个标签页传 false**(加载文件后可直接勾选信号),
**用户新建的标签页(Ctrl+T / 标签栏 ⊕ / 标签栏右键「新建标签页」)传 true**。
- `TabConfig::SetGroupsCollapsedByDefault(bool)` + `TabConfig::SetAllCollapsed(bool)`(新增);
`SyncToDataSet` 里 `m_collapsed.resize(fileCount, m_groupsCollapsedByDefault)`(原来固定 `false`)——
⚠️ 只影响**新出现的**分组(之后新加载的文件),已存在的分组保持用户当前选择。
`ToggleCollapsed` 里为越界下标补建状态时同样用该默认值。
#### 二、网格 GX 徽标:悬停**不改变**鼠标形状,只有拖拽才是上下箭头
- `PlotView::QueryCursor` 里 GX 徽标分支由 `IDC_HAND` 改为 **`return nullptr`**(= 保持箭头)。
位置仍在原位(先于光标线/图例判定返回),优先级语义不变;
拖拽中(`LANE_REORDER`)仍是 `IDC_SIZENS`。
- ⚠️ 注意不要"顺手"把 `QueryCursor` 的徽标分支改成"落到后面继续判定":那会让经过徽标的光标线
抢到 `IDC_SIZEWE`(原实现用提前 return 就是为了这个优先级)。
#### 三、标题栏 RAM 口径:总工作集 → **私有工作集**(任务管理器"内存"列)
- **症状**:加载大文件再关闭分组后,标题栏还显示几十/上百 MB,而任务管理器只有几 MB。
- **根因**:原实现取 `PROCESS_MEMORY_COUNTERS::WorkingSetSize`(**总工作集**)——
它把映射进来的 DLL/共享页面、以及已释放但尚未被系统回收的驻留页面都算进去;
任务管理器"内存"列的口径是 **私有工作集**(`PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`)。
实测(本机离屏探针):加载 `Problem_Data.csv` 后 total-WS **117.55 MiB** / priv-WS **109.96 MiB** /
commit 138.28 MiB;关闭分组后 total-WS **15.44** / priv-WS **7.62** / commit **8.27** MiB。
- **修法**:`ProcessWorkingSetBytes()` → **`ProcessPrivateWorkingSetBytes()`**,用
`PROCESS_MEMORY_COUNTERS_EX2`(MinGW 的 psapi.h 没有这个类型 → **在 .cpp 里就地声明**,
布局与文档一致),失败(老系统)依次退回 `PrivateUsage`(私有提交)→ `WorkingSetSize`。
- 验证:harness 新增 `[AS]` —— 标题文本必须包含 `FormatByteSize(privWS)`,且在两者格式不同时
**不得**包含 `FormatByteSize(totalWS)`(实测 priv 158.29 MB / total 195.53 MB)。
#### 四、边沿计数(`EdgeCountsFor`):逐块累积 + 每 tick 预算推进(**修掉"拖横向光标卡死"**)
- **量化(探针 `.preflight/probe`,合成方波)**:改前 **6M 采样 = 26.8 ms / 次阈值变化**
→ 折算 **25.8M 采样 ≈ 115 ms/条**。横向光标拖动时阈值每帧都变 ⇒ 单条信号每帧 115ms,
**5 条信号同一网格 = 575ms/帧**(用户报告"开启即卡顿、之后光标几乎拖不动"完全吻合)。
- **新模型**(与 `RenderPendingLaneRebuilds(1)` 同一套"宁可晚一点、绝不卡住前台"的思路):
* 采样对 `k`(采样 `k-1 → k`)按 `kEdgeBlockSamples = 8192` 分块,每块各存上升/下降沿数;
* `EdgeCountEntry::scannedPair` = 下一个待扫描的采样对,`[1, scannedPair)` 已扫完;
* **查询区间**:内部整块用块和相加,两端边界块就地精确扫描(≤ 2 块,代价固定)⇒
拖动**时间**光标的单帧代价与区间长度无关(区间**不进键**,单调时间轴下无需重扫);
非单调时间轴无法用下标区间表达 ⇒ 区间进键(进键与否由 `file.timeMonotonic` 决定);
* **每 tick 预算** `kEdgeScanPairsPerTick = 1200000`(`GetTickCount64` 分桶,同帧多次调用共享),
预算用完即返回当前部分值;`complete` 出参告诉界面"还没算完";
* **拖动横向光标(`EDGE_MOVE`)期间不推进**(阈值每帧都变,推进纯属白做功)→ 直接返回
"最新的完整条目"的结果(数字稳定)+ `complete = false`;
* 快路径:`单调 && 无镜像 && 无调整变换` 时用**紧凑无分支循环**直接比较 `values[k]` 与阈值
(NaN 与任何数比较都是 false ⇒ 天然满足"NaN 不参与且打断连续"的语义,无需 isfinite 分支)。
- **界面**:`EdgeCountsText(rising, falling, complete)` 统一生成文本 —— 完全未算出时显示
**`统计中…`**,部分结果追加 **` …`**;`EdgePanelWidth` 与绘制都改用它(宽度按实际文本测量,
避免推进过程中数字变长被截断)。
- **实测(改后)**:6M 采样首次调用 **1.7 ms**(部分值),5 帧内扫完且与参考实现**逐位一致**;
**单次调用最坏 1.9 ms**;拖动中连查 10 个阈值 **合计 0.11 ms**(O(1)/帧);
区间查询在扫描完成后**一次调用即精确**。
- ⚠️ 本轮**只改了普通「边沿计数」**;`EdgeCenterCountsFor` 仍沿用原增量实现
(它的曲线窗口本来就被 `CurveSpanFor` 限制,不是本次用户报告的场景)。
- ⚠️ harness 的 `EdgeCountsFor` 调用**必须循环到 `complete`**(模拟逐帧推进),
否则拿到的是部分值。
- ⚠️ **harness 抖动(菜单点击类用例)**:留在打开的弹出菜单/内联编辑态里的第一次点击会
被吞掉("输入框外的第一次点击 = 提交编辑"是产品设计)。本轮给菜单类用例加了
`CloseAllMenus(app)` 前置卫生与**允许补点 1~2 次**的逻辑(`[AL]` 的「系数/偏移」按钮、
`[AR]` 的调整按钮),显著降低抖动;但 `[AP]` 的 reset 图标点击仍**偶发**失败(连跑 5 次约 1 次),
重跑即好 —— 属既有 harness 抖动,不是产品缺陷。
#### 五、⚠️ 内存与性能排查报告(用户要求;第 4 项**只排查未改动**)
##### 5.1 内存管理审计(对应"标题栏数字不准 / 怀疑野指针")
- **结论 1:内存没有泄漏,且能完整归还。** 关闭分组(`RemoveFile`)路径实测
`117.55 → 15.44 MiB`(总工作集)/`109.96 → 7.62`(私有工作集)/`138.28 → 8.27`(私有提交)。
该路径会 `PauseDerivedAsync()`(清 `m_curveEntries`/`m_curveJobs`/`m_curvePins`)
→ `InvalidateDerivedCaches()`(清折线/边沿/均值/弱峰/场景缓存);`DataSet::RemoveFile` 用
"前移 + pop_back + shrink_to_fit" 立即归还采样缓冲。
- **结论 2:用户看到的差异来自"口径",不是"错误"。** 见上文第三节 —— 标题原来显示总工作集。
- **结论 3(⚠️ 真实隐患,本轮未修):全仓没有任何 `try`/`catch`。** 所有重分配
(`std::vector::assign/resize`)失败时抛 `std::bad_alloc`;而重计算都跑在
`std::thread` 上(`PlotView::CurveWorkerLoop`、`Application::RunAsyncLoad`,
以及并行图层 / 并行重建),**异常逸出线程入口 ⇒ `std::terminate()` = 进程直接终止**。
即"内存不足"在本项目当前实现里是**崩溃**而不是"报错并放弃本次操作"。
##### 5.2 弱峰调整 "迭代次数 1→10" 内存暴涨(4.4GB)——原因 / 改进方案 / 低内存行为
- **实测(探针,3M 采样合成方波,单次 `ComputeWeakPeakAdjust`)**:
单次计算的工作缓冲峰值 ≈ **103 MiB / 3M 采样 ≈ 34 B/采样**;
**4 条并发计算峰值 +412 MiB**(即 4 × 103 MiB)。按 25.8M 采样线性外推 ⇒
**单次 ≈ 0.9 GB,4 线程 ≈ 3.5 GB**;再加数据本体 1.03 GB 与曲线缓存预算(≤768 MB)
⇒ **峰值 4~5 GB**,与用户观测的 4.4 GB 吻合。
- **构成**(每 1 个采样):`current`+`next` 两个 `float` 工作缓冲 = 8 B;
调用方的 `out`(float)= 4 B;**极值表 `PeakExtremum`(`size_t index` + `double value` + `int type`
= 24 B/极值)**;位移表 `shift`(double)= 8 B/极值。
编码器方波是**极值密集**信号(每个跳变都是极值)⇒ 极值表可达采样数量级 ⇒ **24 B/采样成为主导项**。
- **为什么"拖到 10"会同时拉起 4 份**:`ScheduleCurveJob` 对同 (信号, 曲线种类) 只保留**一个待算**任务,
但**正在计算的任务无法取消**;工作线程池 = `clamp(核数-1, 1, 4)`,参数一路变化时会连续留下
若干个"算到一半的旧参数任务",每个都持有自己的 `thread_local` 大缓冲。
(`CurveSpanFor` 的 `kHugeSignalPreviewSamples` 只在**拖拽期间**不再排队,松手后仍会算一次最终参数。)
- **改进方案(建议,未实施)**:
1. **按信号规模限制并发**:超大信号(> `kHugeSignalPreviewSamples`)时派生计算**串行化**(1 线程)
⇒ 峰值直接除以 4;
2. **缩小极值表**:`{uint32 index; float value; int8 type}` = 12 B(减半),或**分块流式处理**,
不落地整张极值表;
3. **抢占/取消**:任务在每阶之间检查"本参数是否已被更新的参数取代"(比对 generation/最新键),
废弃任务尽早退出,避免为过时参数白算 10 阶;
4. **低内存自适应**:跑之前用 `GlobalMemoryStatusEx` 看可用物理内存,不足时降档
(减少并发 / 拒绝本次重建 / 明确提示"内存不足");
5. **异常保护**(见 5.1 结论 3):给所有线程入口加 `try/catch(std::bad_alloc)`,
把"崩溃"变成"提示 + 跳过本次计算"。
- **低内存场景(1 GB 剩余)现有机制够不够?**
* **有的**:`ReuseBufferScope`(容量封顶,超大缓冲用完即释放)、
`TrimDerivedCurveDataIfIdle`(关掉预览即 `PauseDerivedAsync` 归还内存)、
`kHugeSignalPreviewSamples`(拖拽期不排队)、曲线缓存总字节预算 `kCurveSpanCacheMaxBytes`。
* **没有的**:**任何"峰值上限"与"优雅降级"**。上面 4 项都只解决"算完/关掉之后归还",
不解决"算的时候需要 4 GB"。
* 因此 1 GB 剩余内存时的行为是:先大量换页(全系统卡顿)→ 提交失败则 `bad_alloc`
→ **无线程级 catch ⇒ 进程终止**(崩溃)。用户这台机器内存足够所以只表现为"内存暴涨"。
---
## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
@@ -1031,6 +1152,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(**起源设备**) | **新标签页分组默认折起 + 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` 零警告。 |
---
@@ -1110,6 +1232,29 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
25. ⚠️ **`PopupMenu::Open(..., expandUp = true)`** 表示"锚点是菜单**左下角**、向**上**展开"。
给按钮挂"二级面板"时用它(并把锚点设在按钮左上角再上移 `kAdjustMenuGap`);
`RebuildLayout()` **必须复用 `m_buildExpandUp`**,否则数值回写触发的重排会让面板跳回向下展开。
26. ⚠️ **标题栏 RAM = "私有工作集"**(`PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`,
与任务管理器「内存」列同口径),**不是** `WorkingSetSize`(总工作集还包括映射的 DLL/共享页面与
已释放但未被回收的驻留页面,会明显偏大)。EX2 失败时退回 `PrivateUsage` → `WorkingSetSize`。
27. ⚠️ **千万级信号上任何"按阈值判定"的统计都不允许每帧重扫整段**:
`EdgeCountsFor` 已改为「逐块累积(`kEdgeBlockSamples`)+ 每 tick 预算推进(`kEdgeScanPairsPerTick`)」;
拖动横向光标(`EDGE_MOVE`)期间**不推进**(只返回上一个完整值 + `complete=false`),
界面用 `EdgeCountsText` 显示"统计中… / …"。实测:改前 25.8M 采样 ≈115ms/条/帧(5 条同网格 = 575ms/帧),
改后单次调用 ≤1.9ms、拖动中 10 次查询合计 0.11ms。
⚠️ 新增同类统计时**必须**沿用这个模型(先量化单次全扫代价,再决定分块与预算)。
28. ⚠️ **全仓没有 `try`/`catch`**:所有重分配失败抛 `std::bad_alloc`,而重计算都在 `std::thread`
(`CurveWorkerLoop` / `RunAsyncLoad` / 并行图层)里 —— 异常逸出线程入口 ⇒ `std::terminate()` ⇒ **进程终止**。
低内存下这是"崩溃"而不是"降级"。要改就从线程入口加 `catch` 开始。
29. ⚠️ **弱峰调整的工作缓冲 ≈ 34 B/采样(峰值)**,主导项是极值表(`PeakExtremum` = 24 B/极值,
编码器方波极值密集 ⇒ 接近 24 B/采样);而工作线程池最多 **4** 条并发 ⇒
25.8M 采样时单次 ≈0.9GB、峰值 ≈3.5GB(+数据 1.03GB +曲线缓存 ≤768MB ⇒ **4~5GB**)。
已测:3M 采样单次 ≈103MiB、4 并发 +412MiB。若日后要"限制峰值",优先:
①超大信号串行化 ②极值表字段瘦身/分块流式 ③废弃任务尽早退出 ④`GlobalMemoryStatusEx` 自适应降档。
30. ⚠️ **新建标签页的信号分组默认折起**(`NewTab(true)`;启动首个标签页 `NewTab(false)` 保持展开);
`SyncToDataSet` 用 `TabConfig::SetGroupsCollapsedByDefault` 的值初始化**新出现的**分组,
不得覆盖已存在分组的用户选择。
⚠️ 新增/修改 `NewTab` 调用点时别忘了带上这个形参。
31. ⚠️ **网格 GX 徽标悬停不改鼠标形状**(`QueryCursor` 在徽标上返回 `nullptr`,且**提前 return**
以免被光标线/图例判定抢走);只有拖拽中(`LANE_REORDER`)才是 `IDC_SIZENS`。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
+26
View File
@@ -1045,6 +1045,25 @@ namespace jdi
*/
void ToggleCollapsed(int fileIndex);
/**
* @brief 设置「新出现的文件分组是否默认折起」。
*
* 只影响**之后**新出现的分组(例如新加载的文件);已存在的分组保持用户当前的选择。
* 用户新建的标签页会把它设为 true,使信号列表默认紧凑。
*
* @param [IN] collapsed 是否默认折起。
*/
void SetGroupsCollapsedByDefault(bool collapsed) { m_groupsCollapsedByDefault = collapsed; }
/**
* @brief 把当前所有文件分组设为折叠或展开。
* @param [IN] collapsed true = 全部折起,false = 全部展开。
*/
void SetAllCollapsed(bool collapsed)
{
std::fill(m_collapsed.begin(), m_collapsed.end(), collapsed);
}
/**
* @brief 计算某网格的 Y 轴显示范围。
* @param [IN] dataSet 数据集合。
@@ -1100,6 +1119,13 @@ namespace jdi
std::wstring m_name; /**< 标签页名称。 */
std::vector<std::vector<bool>> m_visible; /**< 信号可见性,按 [文件][信号] 索引(本页独立)。 */
std::vector<bool> m_collapsed; /**< 文件分组折叠状态,按文件索引。 */
/**
* @brief 新出现的(首次见到的)文件分组是否默认折起。
*
* 用户新建的标签页把它设为 true(信号列表默认紧凑);启动时的首个标签页保持 false,
* 这样加载完文件就能直接勾选信号。
*/
bool m_groupsCollapsedByDefault = false;
std::vector<Lane> m_lanes; /**< 网格划分。 */
LaneMode m_laneMode = LaneMode::PER_SIGNAL; /**< 网格划分策略。 */
double m_viewMin = 0.0; /**< 视图左边界时间。 */
+3 -2
View File
@@ -1731,7 +1731,8 @@ namespace jdi
{
const std::size_t fileCount = dataSet.FileCount();
m_visible.resize(fileCount);
m_collapsed.resize(fileCount, false);
// ⚠️ 只对**新出现的**分组应用默认折叠状态:已存在的分组保持用户当前的展开/折叠选择。
m_collapsed.resize(fileCount, m_groupsCollapsedByDefault);
for (std::size_t fileIndex = 0; fileIndex < fileCount; ++fileIndex)
{
@@ -2331,7 +2332,7 @@ namespace jdi
}
if (static_cast<std::size_t>(fileIndex) >= m_collapsed.size())
{
m_collapsed.resize(static_cast<std::size_t>(fileIndex) + 1, false);
m_collapsed.resize(static_cast<std::size_t>(fileIndex) + 1, m_groupsCollapsedByDefault);
}
m_collapsed[static_cast<std::size_t>(fileIndex)] = !m_collapsed[static_cast<std::size_t>(fileIndex)];
}