修复「偶发的改一个信号的中心轴参数、其它信号也重新统计」
一、现象(用户报告)
加载 VERY_BIG_DATA.CSV、所有信号显示在同一网格、同时启用普通边沿计数与中心轴边沿计数
并勾选全部信号;改变某一个信号的中心轴参数时,**偶发**地让其它几个信号的统计也重新计算
(不是必现,多测几次才出现)。
二、根因(用间歇性探针实测定位,不是推测)
EdgeScanFor 的中心轴分支原先靠 AcquireCoveringCurve + ScheduleCurveJob 拿**整条**均值滤波曲线:
- 千万级信号(2580 万采样)一条整段曲线 ≈ 103MB;每多试一个参数值就多一条(参数变化产生新键),
绘制侧按可见窗口还会再发布一份;
- 这些条目共享同一个曲线缓存,预算为 kCurveSpanCacheMaxBytes = 768MB;
- 一旦超限,PruneCurveEntriesLocked() 从**队首**淘汰 —— 最早发布的往往正是**其它信号**的整段曲线;
- 于是那些信号被判为「曲线未就绪」→ 界面显示「统计中…」并在后台重新排队计算 →
表现出来就是「其它几个信号重新统计」,而且**偶发**(取决于当时缓存里积了多少条目)。
⇒ 统计数值本身是正确的,错的是「几条信号的统计通过一块共享缓存互相牵连」,
而这块缓存的压力取决于用户试过多少个参数值。这才是要根除的问题。
三、修法
1. EdgeScanState 新增 localCurve / localBegin / localEnd / localValid:
中心轴扫描时**就地计算**自己需要的一小段均值滤波曲线(ensureLocalCurve):
- 半径 = points * iterations(迭代 N 次 X 点均值滤波最多向两侧传播 X*N 个采样),
再夹到 kDerivedCurveGuardMax;
- 窗口至少覆盖 8 个扫描块(kEdgeBlockSamples * 8),把「窗口 × 迭代次数」的重复计算摊薄;
- 窗口只有几十 KB~几百 KB,与曲线缓存、与其它信号**彻底解耦**;
- 计算成本按「采样数 × 迭代次数」计入扫描预算(不会一帧算太多);
- **扫完后立即释放** localCurve(区间查询需要时按需重算)→ 多信号网格下常驻内存很小。
2. 删除原「中心轴曲线未就绪就把整 tick 扫描预算清零」的门控:
它会把**同一帧里排在后面的其它信号的统计一起饿死**(测试里表现为全部卡在「统计中」)。
要「让路」只应跳过自己。
3. 加固状态上限:kEdgeScanStateLimit 16 → 64,且超限时**优先淘汰「已扫完」的最旧状态**,
绝不淘汰正在推进的。上限太小会淘汰其它信号的已扫完状态 → 它们从头重扫(同一类症状);
实测 10 个信号 × (普通 + 中心轴)= 20 个状态,原来的 16 就会踩到。
4. 附带收益:统计不再等后台算整条曲线,超大文件下第一个统计值出现得更快。
四、验证(全部离屏,harness 与探针均为 BELOW_NORMAL 优先级,不占用鼠标/键盘/屏幕)
1. uitest 新增 [AU](间歇性复现探针):5 个信号合并在同一网格、普通 + 中心轴全部启用,
用真实编辑路径**连续改 25 次**某个信号的中心轴参数,每轮 14 帧逐帧检查**未被改动的**统计:
CASE A = 状态被删除;CASE B = 状态还在但其曲线消失(显示 0 / "统计中");
CASE C = 状态被重置或截断。
- 修复前:baseline **0/10 完成**(全部卡在「统计中」,此时 curve entries 0);
- 修复后:baseline **10/10 完成**,25 次编辑 **CASE A/B/C 全 0**,且 curve entries 0
(证明统计已完全不需要后台曲线缓存)。
2. 新增「本地窗口 vs 整条曲线」正确性断言:points ∈ {5,17,64,300} × iterations ∈ {1,2,4}
共 12 组,中心轴结果与「整条信号均值滤波后逐点判定」的参考实现**逐位一致**
(证明 X*N 半径的保护带足够)。
3. 合计 789/789 通过(连跑 3 次:2 次全绿,1 次命中既有的菜单点击类 harness 抖动——
[AP] reset 图标与面板菜单,重跑即好,与本轮改动无关);make release 零警告。
规则同步:Documents/agent_mem.md 新增 §9.25,§11.2 新增第 40–42 条硬约束
(派生曲线不得依赖共享整条曲线缓存 / 预算不得因等待而清零 / 状态上限必须大于 种类×信号数),
变更日志补一行。
This commit is contained in:
1 parent
48bc534bc1
commit
65f7625dce
3 files changed
+222
-34
No files matched your search
@@ -148,7 +148,16 @@ 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 kEdgeScanStateLimit = 16; /**< 边沿统计累积状态上限(超出时淘汰最早的一条)。 */
|
||||
/**
|
||||
* @brief 边沿统计累积状态上限。
|
||||
*
|
||||
* ⚠️ 必须足够大:`普通 + 中心轴` × 一个网格里的信号数(都勾选时)会被全部使用。
|
||||
* 上限太小会**淘汰其它信号的已扫完状态** → 它们从头重扫(表现为“改一个参数、
|
||||
* 其它几个信号也重新统计”)。一个状态仅占「块数组 ≈ (采样数/8192)×16B」+ 几十 KB
|
||||
* 本地曲线,64 个状态对内存无压力。
|
||||
* 真的超限时**优先淘汰「已扫完」的最旧状态**(它重新扫完很快),绝不淘汰正在推进的。
|
||||
*/
|
||||
const std::size_t kEdgeScanStateLimit = 64;
|
||||
/**
|
||||
* @brief 「边沿计数」累积统计的分块大小(采样点数)。
|
||||
*
|
||||
@@ -3048,7 +3057,25 @@ namespace jdi
|
||||
{
|
||||
if (m_edgeScans.size() >= kEdgeScanStateLimit)
|
||||
{
|
||||
m_edgeScans.erase(m_edgeScans.begin());
|
||||
// ⚠️ 超限时**优先淘汰「已扫完」的最旧状态**(它重算很快、而且通常已经不需要再变),
|
||||
// 绝不淘汰正在推进的那个 —— 否则其它信号会被迫从头重扫
|
||||
//(那正是“改一个参数、其它几个信号也重新统计”这类现象)。
|
||||
std::size_t victim = 0;
|
||||
bool foundVictim = false;
|
||||
for (std::size_t index = 0; index < m_edgeScans.size(); ++index)
|
||||
{
|
||||
if (m_edgeScans[index].complete)
|
||||
{
|
||||
victim = index;
|
||||
foundVictim = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundVictim)
|
||||
{
|
||||
victim = 0;
|
||||
}
|
||||
m_edgeScans.erase(m_edgeScans.begin() + static_cast<std::ptrdiff_t>(victim));
|
||||
}
|
||||
EdgeScanState created;
|
||||
created.file = &file;
|
||||
@@ -3083,35 +3110,84 @@ namespace jdi
|
||||
std::fill(state->blockFalling.begin(), state->blockFalling.end(), 0);
|
||||
}
|
||||
|
||||
// ---- 中心轴:需要整条信号对应的均值滤波曲线 -----------------------------
|
||||
// 取值必须与绘制逐位一致,因此要求**键完全相同**的条目(不能像绘制那样用旧参数的曲线顶替)。
|
||||
// 拿不到就交给后台算、本帧不推进;**绝不在这里同步算**(千万级采样会卡住界面数秒)。
|
||||
const std::vector<float>* curve = nullptr;
|
||||
std::size_t spanBegin = 0;
|
||||
bool curveReady = true;
|
||||
// ---- 中心轴:**就地**计算所需的那一小段均值滤波曲线 ---------------------
|
||||
// ⚠️ 过去这里用 `AcquireCoveringCurve`/`ScheduleCurveJob` 拿**整条**曲线,带来两个问题:
|
||||
// 1. 千万级信号的一条曲线 ≈103MB,多条信号叠加会超出曲线缓存预算(768MB)→ 条目被
|
||||
// 淘汰后该信号被判为“曲线未就绪”,界面显示“统计中…”并从头重扫 ——
|
||||
// **偶发**(取决于当时缓存里积了多少条目,用户报告“改一个参数其它几个信号也重新统计”);
|
||||
// 2. 几个信号的统计通过这块共享缓存互相牵连。
|
||||
// 现在只算「当前扫描块 ± 滤波半径」:`points * iterations` 就足以让块内取值与整条曲线
|
||||
// 完全一致(迭代 N 次、半径 X 的均值滤波最多向两侧传播 X*N 个采样),内存只有几十 KB,
|
||||
// 且与其它信号、其它曲线彻底解耦。
|
||||
std::size_t localReach = 0;
|
||||
if (centre)
|
||||
{
|
||||
CurveKey key;
|
||||
key.signal = &signal;
|
||||
key.kind = static_cast<int>(DerivedCurveKind::MEAN_FILTER);
|
||||
key.points = safePoints;
|
||||
key.iterations = safeIterations;
|
||||
key.amountPercent = 0;
|
||||
const CurveEntry* hit = AcquireCoveringCurve(key, 0, count, spanBegin);
|
||||
if (hit == nullptr)
|
||||
localReach = static_cast<std::size_t>(safePoints) * static_cast<std::size_t>(safeIterations) + 2u;
|
||||
localReach = std::min(localReach, kDerivedCurveGuardMax);
|
||||
}
|
||||
const std::vector<double>& values = signal.values;
|
||||
const bool filterByTime = hasRange && !monotonic;
|
||||
|
||||
/** 把显示下标区间映射为源下标区间(水平镜像时头尾对调)。 */
|
||||
const auto toSourceRange = [&](std::size_t displayFirst, std::size_t displayLast,
|
||||
std::size_t& srcFirst, std::size_t& srcLast)
|
||||
{
|
||||
if (mirrored)
|
||||
{
|
||||
ScheduleCurveJob(key, 0, count, count); // 幂等:已在算 / 已排队时不会重复
|
||||
curveReady = false;
|
||||
srcFirst = (count - 1) - displayLast;
|
||||
srcLast = (count - 1) - displayFirst;
|
||||
}
|
||||
else
|
||||
{
|
||||
curve = &hit->values;
|
||||
srcFirst = displayFirst;
|
||||
srcLast = displayLast;
|
||||
}
|
||||
}
|
||||
};
|
||||
/** 确保本地曲线覆盖显示下标区间 [displayFirst, displayLast](含端点)。 */
|
||||
const auto ensureLocalCurve = [&](std::size_t displayFirst, std::size_t displayLast) -> bool
|
||||
{
|
||||
if (!centre)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
std::size_t srcFirst = 0;
|
||||
std::size_t srcLast = 0;
|
||||
toSourceRange(displayFirst, displayLast, srcFirst, srcLast);
|
||||
if (state->localValid && state->localBegin <= srcFirst && state->localEnd > srcLast)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
const std::size_t windowBegin = (srcFirst > localReach) ? (srcFirst - localReach) : 0;
|
||||
std::size_t windowEnd = std::min(count, srcLast + 1u + localReach);
|
||||
// 至少覆盖 8 个扫描块:迭代均值滤波每算一次窗口要 O(窗口 × 迭代次数),
|
||||
// 只覆盖一个块会让参数较大时每块都重算一遍(摊薄到 8 个块上就便宜得多)。
|
||||
const std::size_t minSpan = kEdgeBlockSamples * 8u;
|
||||
windowEnd = std::min(count, std::max(windowEnd, windowBegin + minSpan));
|
||||
if (windowEnd <= windowBegin)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::vector<float> local =
|
||||
ComputeDerivedWindow(signal, windowBegin, windowEnd,
|
||||
static_cast<int>(DerivedCurveKind::MEAN_FILTER), safePoints,
|
||||
safeIterations, 0, nullptr, false);
|
||||
if (local.size() != (windowEnd - windowBegin))
|
||||
{
|
||||
++m_edgeScanLocalCurveFails;
|
||||
return false; // 计算失败(例如内存不足):本帧不推进,下帧重试
|
||||
}
|
||||
state->localCurve = std::move(local);
|
||||
state->localBegin = windowBegin;
|
||||
state->localEnd = windowEnd;
|
||||
state->localValid = true;
|
||||
// 本地窗口的计算成本也计入预算(按「采样数 × 迭代次数」折算),避免一帧算太多。
|
||||
const std::size_t cost =
|
||||
(windowEnd - windowBegin) * static_cast<std::size_t>(safeIterations);
|
||||
m_edgeScanBudget -= std::min(m_edgeScanBudget, cost);
|
||||
return true;
|
||||
};
|
||||
|
||||
// 判定基准:**绘制后**的值相对阈值 / 中心曲线的偏差(区间之外 / 缺失值 → NaN)。
|
||||
const std::vector<double>& values = signal.values;
|
||||
const bool filterByTime = hasRange && !monotonic;
|
||||
const auto deltaAt = [&](std::size_t index) -> double
|
||||
{
|
||||
const double nan = std::numeric_limits<double>::quiet_NaN();
|
||||
@@ -3146,12 +3222,12 @@ namespace jdi
|
||||
{
|
||||
return drawn - threshold;
|
||||
}
|
||||
if (curve == nullptr || sourceIndex < spanBegin ||
|
||||
(sourceIndex - spanBegin) >= curve->size())
|
||||
if (!state->localValid || sourceIndex < state->localBegin ||
|
||||
sourceIndex >= state->localEnd)
|
||||
{
|
||||
return nan;
|
||||
}
|
||||
const float centerSample = (*curve)[sourceIndex - spanBegin];
|
||||
const float centerSample = state->localCurve[sourceIndex - state->localBegin];
|
||||
if (!std::isfinite(centerSample))
|
||||
{
|
||||
return nan;
|
||||
@@ -3174,12 +3250,7 @@ namespace jdi
|
||||
m_edgeScanTick = tick;
|
||||
m_edgeScanBudget = kEdgeScanPairsPerTick;
|
||||
}
|
||||
if (!curveReady)
|
||||
{
|
||||
// 正在等后台的均值滤波曲线:把预算留给别的统计,避免白耗。
|
||||
m_edgeScanBudget = 0;
|
||||
}
|
||||
else if (m_edgeScanBudget > 0)
|
||||
if (m_edgeScanBudget > 0)
|
||||
{
|
||||
state->justReset = false;
|
||||
// 快路径:时间单调、无水平镜像、无「调整」变换、非中心轴 ⇒ 直接比较采样值与阈值。
|
||||
@@ -3199,6 +3270,11 @@ namespace jdi
|
||||
{
|
||||
break;
|
||||
}
|
||||
// 中心轴:先确保本地区间曲线覆盖本块(含两端各一个采样点,供边界比较)。
|
||||
if (!ensureLocalCurve(from - 1, to))
|
||||
{
|
||||
break; // 算不出来(内存不足等):本帧到此为止,下帧重试
|
||||
}
|
||||
std::size_t localRising = 0;
|
||||
std::size_t localFalling = 0;
|
||||
if (plainFast)
|
||||
@@ -3254,6 +3330,16 @@ namespace jdi
|
||||
}
|
||||
state->scannedPair = pair;
|
||||
state->complete = (pair >= count);
|
||||
if (state->complete)
|
||||
{
|
||||
// 扫完后**释放**本地曲线(区间查询需要时会按需重算)——
|
||||
// 否则多信号网格下每个状态都要常驻几十 KB~几 MB。
|
||||
state->localCurve.clear();
|
||||
state->localCurve.shrink_to_fit();
|
||||
state->localBegin = 0;
|
||||
state->localEnd = 0;
|
||||
state->localValid = false;
|
||||
}
|
||||
m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned);
|
||||
}
|
||||
}
|
||||
@@ -3261,7 +3347,7 @@ namespace jdi
|
||||
// ---- 汇总请求区间 -------------------------------------------------------
|
||||
// 内部整块用块和相加,只有两端边界块(≤ 2 块)需要就地精确扫描
|
||||
// ⇒ 拖动**时间**光标时单帧代价与区间长度无关。
|
||||
if (curveReady && (state->complete || !suppressPartial))
|
||||
if (!suppressPartial)
|
||||
{
|
||||
const std::size_t limit =
|
||||
(state->scannedPair > 0) ? std::min(pairLast, state->scannedPair - 1) : 0;
|
||||
@@ -3269,7 +3355,8 @@ namespace jdi
|
||||
{
|
||||
const std::size_t firstBlock = (pairFirst - 1) / kEdgeBlockSamples;
|
||||
const std::size_t lastBlock = (limit - 1) / kEdgeBlockSamples;
|
||||
for (std::size_t block = firstBlock; block <= lastBlock; ++block)
|
||||
bool boundaryOk = true;
|
||||
for (std::size_t block = firstBlock; block <= lastBlock && boundaryOk; ++block)
|
||||
{
|
||||
const std::size_t blockFirst = block * kEdgeBlockSamples + 1;
|
||||
const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples);
|
||||
@@ -3284,6 +3371,12 @@ namespace jdi
|
||||
falling += state->blockFalling[block];
|
||||
continue;
|
||||
}
|
||||
// 边界块 / 尚未扫完的块:就地精确扫描这一小段(≤ 1 个块)。
|
||||
if (!ensureLocalCurve(low - 1, high))
|
||||
{
|
||||
boundaryOk = false; // 中心轴曲线算不出来:本帧不展示(下帧重试)
|
||||
break;
|
||||
}
|
||||
double previous = deltaAt(low - 1);
|
||||
for (std::size_t index = low; index <= high; ++index)
|
||||
{
|
||||
@@ -3302,8 +3395,13 @@ namespace jdi
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
// 边界块扫描失败(中心轴曲线算不出来)时不展示结果:保持 complete=false。
|
||||
complete = boundaryOk && (pairLast < state->scannedPair);
|
||||
}
|
||||
else
|
||||
{
|
||||
complete = (pairLast < state->scannedPair);
|
||||
}
|
||||
complete = (pairLast < state->scannedPair);
|
||||
}
|
||||
if (!complete)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user