派生曲线计算与 UI 绘制分离(后台线程 + 双缓冲),彻底消除交互卡顿

根因:重计算发生在绘制线程内(Paint 同步算派生预览曲线),一次整段弱波增强约 368ms 会阻塞消息循环,滑块/光标/视界失去跟手。

方案:新增后台计算线程 + 双缓冲。大窗口预览曲线不在绘制线程计算,本帧用最新旧曲线顶替并排后台任务;小窗口仍同步以保证预览实时;后台算完通过自定义消息通知宿主重绘(双缓冲切换)。条目用 shared_ptr 共享、绘制线程钉住整帧有效,后台只追加不改写,无数据竞争。任何改变数据层结构的操作前先 PauseDerivedAsync(join 后台线程),杜绝读取已释放/已移动采样。

实测 VERY_BIG_DATA(5 网格):弱波增强平移 2247ms→15ms,拖滑块 2303ms→15ms,静止重绘→0.25ms,交互均 ≤约 30ms,无 300ms/2s 级尖峰。小窗口同步路径与旧实现逐像素 0 差异。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-11 12:04:53 +08:00
1 parent aca84df0ac
commit 99247622c1
4 files changed
+520 -167

No files matched your search

+334 -130
View File
@@ -80,34 +80,20 @@ namespace jdi
* 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。
*/
const std::size_t kDerivedCurveGuardMax = 65536;
/** 区间窗口缓存条目上限(超出时淘汰最早的条目;它只是加速手段)。 */
/** 派生曲线缓存条目上限(超出时按「最旧优先」淘汰;它只是加速与内存控制手段)。 */
const std::size_t kCurveSpanCacheLimit = 32;
/**
* @brief 进入「区间窗口缓存」的单条窗口采样数上限。
*
* **必须足够大**,使「完全放大到整条信号」的窗口也能进缓存:否则每一帧(含静止时
* 的鼠标悬停重绘)都要重算整条曲线 —— 弱波增强在 2576 万采样上单条 ≈368ms,
* 5 条网格就是 ≈1.8s/帧,交互完全无法进行。
*
* 窗口一旦计算完成,同一参数下(平移/缩放/悬停)都能直接复用;
* 内存总量由 `kCurveSpanCacheMaxBytes` 兜底,因此这里不再按采样数排除大窗口。
*/
const std::size_t kCurveSpanCacheMaxSamples = 67108864;
/** 区间窗口缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。
* 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留,
* 否则缓存互相淘汰会导致每帧重算其中一条(弱波增强 ≈368ms 的卡顿尖峰)。 */
/** 派生曲线缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。
* 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留。 */
const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull;
/**
* @brief 交互期间允许**同步重算**的派生曲线窗口采样数上限。
* @brief 派生(预览)曲线走「后台异步计算」的窗口采样数门槛。
*
* 拖动滑块 / 移动视界 / 缩放等交互每帧都会请求派生(预览)曲线。窗口不大时同步重算
* 足够快(几毫秒),预览曲线能实时跟随控件;一旦窗口超过本上限(例如大时间跨度或
* 整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms)。
*
* 此时**不在交互过程中重算**,而是复用上一次算好的曲线(位置由当前视图映射,仍准确),
* 等交互结束后再按最终参数精确重算 —— 控件(滑块/光标/视界)的反馈因此始终跟手。
* 拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线。窗口不超过本门槛时同步重算足够快
* (弱波增强 ≈0.9ms/6.5 万采样),预览曲线能**实时跟随控件**;一旦窗口超过门槛
* (大时间跨度、整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms),
* 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。
*/
const std::size_t kDerivedInteractiveComputeBudget = 131072;
const std::size_t kDerivedAsyncWindowThreshold = 65536;
const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */
const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */
const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */
@@ -300,6 +286,47 @@ namespace jdi
/** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */
const int kEdgePanelKindCount = static_cast<int>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0]));
/**
* @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。
*
* 只拷贝所需的窗口,再调用与「构建为新信号」完全相同的自由函数,因此
* 预览曲线与构建出的采样值逐位一致。本函数**只读**入参、不接触任何成员状态,
* 因此可以安全地在后台线程调用。
*
* @param [IN] signal 目标信号(数据只读)。
* @param [IN] windowBegin 窗口起点(采样下标)。
* @param [IN] windowEnd 窗口终点(不含)。
* @param [IN] kind `DerivedCurveKind` 的整型值。
* @param [IN] points 均值点数 X。
* @param [IN] iterations 迭代次数 N。
* @param [IN] levelPercent 弱波增强等级(0–100)。
* @return 曲线数值(长度 = 有效窗口长度)。
*/
std::vector<float> ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin,
std::size_t windowEnd, int kind, int points,
int iterations, int levelPercent)
{
std::vector<float> out;
const std::vector<double>& values = signal.values;
const std::size_t hi = std::min(windowEnd, values.size());
const std::size_t lo = std::min(windowBegin, hi);
if (hi <= lo)
{
return out;
}
const std::vector<double> window(values.begin() + static_cast<std::ptrdiff_t>(lo),
values.begin() + static_cast<std::ptrdiff_t>(hi));
if (kind == static_cast<int>(DerivedCurveKind::WEAK_BOOST))
{
ComputeWeakWaveBoost(window, static_cast<double>(levelPercent) / 100.0, iterations, out);
}
else
{
ComputeMovingAverageIterated(window, points, iterations, out);
}
return out;
}
/**
* @brief 包络带的单个像素列(已映射到屏幕坐标)。
*/
@@ -642,6 +669,8 @@ namespace jdi
PlotView::~PlotView()
{
// 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。
StopCurveWorker();
ReleaseSceneCache();
ReleasePanelCache();
}
@@ -1254,13 +1283,14 @@ namespace jdi
void PlotView::InvalidateDerivedCaches()
{
// 后台线程可能在读取采样数据:先暂停并作废异步结果,再清同步缓存。
PauseDerivedAsync();
m_polylineCache.clear();
// 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。
m_edgeCountCache.clear();
m_edgeCenterCountCache.clear();
m_meanCurveCache.clear();
m_weakBoostCurveCache.clear();
m_curveSpanCache.clear();
// 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。
m_hoverSignal = SignalRef();
m_hoverLegend = SignalRef();
@@ -1269,6 +1299,18 @@ namespace jdi
InvalidateSceneCache();
}
void PlotView::PauseDerivedAsync()
{
// 等待在算任务结束,然后清空队列与缓存,并自增代次使任何迟到结果作废。
StopCurveWorker();
std::lock_guard<std::mutex> lock(m_curveMutex);
++m_curveGeneration;
m_curveJobs.clear();
m_curveEntries.clear();
m_curvePins.clear();
m_curveSceneDirty.store(false, std::memory_order_relaxed);
}
bool PlotView::FitTimeToLane(int laneIndex)
{
if (m_dataSet == nullptr || m_tab == nullptr)
@@ -2124,7 +2166,7 @@ namespace jdi
int points, int iterations, int levelPercent,
std::size_t begin, std::size_t end,
std::size_t& spanBegin,
bool allowInteractiveDefer) const
bool allowAsyncCompute) const
{
spanBegin = 0;
const std::size_t count = signal.values.size();
@@ -2149,135 +2191,284 @@ namespace jdi
const std::size_t alignedBegin = (begin / grid) * grid;
const std::size_t alignedEnd = std::min(count, ((end + grid - 1) / grid) * grid);
for (const CurveSpanEntry& entry : m_curveSpanCache)
CurveKey key;
key.signal = &signal;
key.kind = kindValue;
key.points = safePoints;
key.iterations = safeIterations;
key.levelPercent = safeLevel;
// 1) 已有覆盖请求区间的最新条目 → 直接复用(钉住,防止本帧被回收)。
if (const CurveEntry* hit = AcquireCoveringCurve(key, begin, end, spanBegin))
{
if (entry.signal == &signal && entry.kind == kindValue && entry.points == safePoints &&
entry.iterations == safeIterations && entry.levelPercent == safeLevel &&
entry.begin <= begin && entry.end >= end)
{
spanBegin = entry.begin;
return &entry.values;
}
return &hit->values;
}
// ⚠️ 交互期间的「延迟重算」:拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线,
// 若窗口很大(例如大时间跨度、整条信号),同步重算会阻塞交互数毫秒到数百毫秒
// (弱波增强整条信号单条 ≈368ms)。此时**不重算**,改复用上一次算好的曲线:
// 曲线下标映射到时间仍是当前视图的映射,因此位置依旧准确,只是参数/覆盖范围可能是
// 上一帧的;等交互结束后(`m_dragMode == NONE`)再按最终参数精确重算。
// 这样滑块 / 光标 / 视界移动的反馈始终跟手,不会被绘制计算拖住。
if (allowInteractiveDefer && m_dragMode != PlotDragMode::NONE &&
(end - begin) > kDerivedInteractiveComputeBudget)
// 2) 未就绪且窗口很大:预览曲线交后台计算,本帧先用最新旧曲线顶替(双缓冲)。
// 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。
if (allowAsyncCompute && (end - begin) > kDerivedAsyncWindowThreshold)
{
const CurveSpanEntry* covering = nullptr;
const CurveSpanEntry* newest = nullptr;
for (auto it = m_curveSpanCache.rbegin(); it != m_curveSpanCache.rend(); ++it)
ScheduleCurveJob(key, alignedBegin, alignedEnd, count);
if (const CurveEntry* stale = AcquireNewestCurve(key, spanBegin))
{
if (it->signal != &signal || it->kind != kindValue)
return &stale->values;
}
return nullptr; // 尚无任何可显示曲线:本帧跳过该预览曲线(不阻塞绘制)
}
// 3) 窗口较小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。
std::size_t computedBegin = 0;
std::size_t computedEnd = 0;
DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count,
computedBegin, computedEnd);
if (computedEnd <= computedBegin)
{
return nullptr;
}
std::vector<float> values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue,
safePoints, safeIterations, safeLevel);
const CurveEntry* published =
PublishCurve(key, computedBegin, computedEnd, std::move(values), spanBegin);
return (published != nullptr) ? &published->values : nullptr;
}
void PlotView::DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin,
std::size_t alignedEnd, int points, int iterations,
std::size_t count, std::size_t& begin, std::size_t& end) const
{
// 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播,
// 1/8 个可见宽度足以覆盖真实信号的极值间距。
// 均值滤波必须额外**覆盖滤波半径**:N 阶共向外传播 X*N 个采样,否则窗口边界取值
// 会与「整条曲线」不同(边界上可用采样数更少)。
std::size_t guard =
std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), kDerivedCurveGuardMax);
if (kind == DerivedCurveKind::MEAN_FILTER)
{
const std::size_t reach =
static_cast<std::size_t>(points + 1) * static_cast<std::size_t>(iterations) + 64u;
guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax);
}
begin = (alignedBegin > guard) ? (alignedBegin - guard) : 0;
end = std::min(count, alignedEnd + guard);
}
const PlotView::CurveEntry* PlotView::AcquireCoveringCurve(const CurveKey& key, std::size_t begin,
std::size_t end,
std::size_t& spanBegin) const
{
std::lock_guard<std::mutex> lock(m_curveMutex);
const CurveEntryPtr* best = nullptr;
for (const CurveEntryPtr& entry : m_curveEntries)
{
if (entry->key == key && entry->begin <= begin && entry->end >= end)
{
if (best == nullptr || entry->serial > (*best)->serial)
{
best = &entry;
}
}
}
if (best == nullptr)
{
return nullptr;
}
m_curvePins.push_back(*best); // 钉住:保证本帧内指针有效
spanBegin = (*best)->begin;
return best->get();
}
const PlotView::CurveEntry* PlotView::AcquireNewestCurve(const CurveKey& key,
std::size_t& spanBegin) const
{
std::lock_guard<std::mutex> lock(m_curveMutex);
const CurveEntryPtr* newest = nullptr;
for (const CurveEntryPtr& entry : m_curveEntries)
{
if (entry->key == key)
{
if (newest == nullptr || entry->serial > (*newest)->serial)
{
newest = &entry;
}
}
}
if (newest == nullptr)
{
return nullptr;
}
m_curvePins.push_back(*newest);
spanBegin = (*newest)->begin;
return newest->get();
}
const PlotView::CurveEntry* PlotView::PublishCurve(const CurveKey& key, std::size_t begin,
std::size_t end, std::vector<float>&& values,
std::size_t& spanBegin) const
{
std::shared_ptr<CurveEntry> entry = std::make_shared<CurveEntry>();
entry->key = key;
entry->begin = begin;
entry->end = end;
entry->values = std::move(values);
{
std::lock_guard<std::mutex> lock(m_curveMutex);
entry->serial = ++m_curveSerial;
m_curveEntries.push_back(entry);
}
m_curvePins.push_back(entry);
spanBegin = begin;
return entry.get();
}
void PlotView::ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin,
std::size_t alignedEnd, std::size_t count) const
{
std::size_t windowBegin = 0;
std::size_t windowEnd = 0;
DerivedGuardWindow(static_cast<DerivedCurveKind>(key.kind), alignedBegin, alignedEnd, key.points,
key.iterations, count, windowBegin, windowEnd);
if (windowEnd <= windowBegin)
{
return;
}
{
std::lock_guard<std::mutex> lock(m_curveMutex);
for (const CurveJob& job : m_curveJobs)
{
if (job.key == key)
{
return; // 该曲线已有在算任务,避免任务堆积
}
}
CurveJob job;
job.key = key;
job.windowBegin = windowBegin;
job.windowEnd = windowEnd;
job.generation = m_curveGeneration;
m_curveJobs.push_back(job);
}
EnsureCurveWorker();
m_curveCv.notify_one();
}
void PlotView::EnsureCurveWorker() const
{
if (m_curveWorker.joinable())
{
return;
}
m_curveWorkerStop = false;
m_curveWorker = std::thread([this]() { CurveWorkerLoop(); });
}
void PlotView::StopCurveWorker() const
{
if (!m_curveWorker.joinable())
{
return;
}
{
std::lock_guard<std::mutex> lock(m_curveMutex);
m_curveWorkerStop = true;
m_curveJobs.clear();
}
m_curveCv.notify_all();
m_curveWorker.join();
}
void PlotView::CurveWorkerLoop() const
{
for (;;)
{
CurveJob job;
{
std::unique_lock<std::mutex> lock(m_curveMutex);
m_curveCv.wait(lock, [this]() { return m_curveWorkerStop || !m_curveJobs.empty(); });
if (m_curveJobs.empty())
{
return; // 仅在 stop 且队列为空时到达
}
job = m_curveJobs.front();
m_curveJobs.pop_front();
if (job.generation != m_curveGeneration)
{
continue; // 代次已变化:丢弃过期任务
}
}
// 计算在锁外进行:读取的采样在「结构未变」期间只读、且地址稳定
//(结构变化前宿主会先 `PauseDerivedAsync`)。
std::vector<float> values =
ComputeDerivedWindow(*job.key.signal, job.windowBegin, job.windowEnd, job.key.kind,
job.key.points, job.key.iterations, job.key.levelPercent);
{
std::lock_guard<std::mutex> lock(m_curveMutex);
if (job.generation != m_curveGeneration)
{
continue; // 计算期间发生了结构变化:丢弃,避免发布过期结果
}
std::shared_ptr<CurveEntry> entry = std::make_shared<CurveEntry>();
entry->key = job.key;
entry->begin = job.windowBegin;
entry->end = job.windowEnd;
entry->serial = ++m_curveSerial;
entry->values = std::move(values);
m_curveEntries.push_back(std::move(entry));
}
// 通知宿主重绘:下一帧会取到刚发布的曲线(双缓冲切换)。
m_curveSceneDirty.store(true, std::memory_order_relaxed);
const HWND host = m_host;
if (host != nullptr)
{
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
}
}
}
void PlotView::PruneCurveEntriesLocked() const
{
// 1) 去掉被「同键、更新、且完全覆盖」的条目取代的旧条目。
std::deque<CurveEntryPtr> kept;
for (const CurveEntryPtr& entry : m_curveEntries)
{
bool superseded = false;
for (const CurveEntryPtr& other : m_curveEntries)
{
if (other.get() == entry.get())
{
continue;
}
if (newest == nullptr)
if (other->key == entry->key && other->serial > entry->serial &&
other->begin <= entry->begin && other->end >= entry->end)
{
newest = &*it;
}
if (it->begin <= begin && it->end >= end)
{
covering = &*it;
superseded = true;
break;
}
}
const CurveSpanEntry* fallback = (covering != nullptr) ? covering : newest;
if (fallback != nullptr)
if (!superseded)
{
spanBegin = fallback->begin;
return &fallback->values;
kept.push_back(entry);
}
// 完全没有可复用的曲线:本帧跳过该预览曲线(不阻塞交互)。
return nullptr;
}
m_curveEntries.swap(kept);
// 未命中:向外扩一个保护带后再计算,保证可见区间内的取值与"整条曲线"一致。
// 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播
// (每迭代一次传一个极值),1/8 个可见宽度足以覆盖真实信号的极值间距。
//
// 均值滤波的保护带必须**覆盖滤波半径**:第 k 阶的输出在 i 处依赖第 k-1 阶的
// [i-X, i+X] 取样,N 阶总共向外传播 X*N 个采样。否则窗口边界处的取值会与
// 「整条曲线」不同(边界上的可用采样数更少)。
std::size_t guard = std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096),
kDerivedCurveGuardMax);
if (kind == DerivedCurveKind::MEAN_FILTER)
{
const std::size_t reach = static_cast<std::size_t>(safePoints + 1) *
static_cast<std::size_t>(safeIterations) + 64u;
guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax);
}
const std::size_t computedBegin = (alignedBegin > guard) ? (alignedBegin - guard) : 0;
const std::size_t computedEnd = std::min(count, alignedEnd + guard);
// 只把这个窗口拷进自由函数(它们按整段向量工作)。
const std::vector<double>& values = signal.values;
const std::vector<double> window(values.begin() + static_cast<std::ptrdiff_t>(computedBegin),
values.begin() + static_cast<std::ptrdiff_t>(computedEnd));
// ⚠️ 超大窗口(例如「中心轴边沿计数」的整条信号统计)不进缓存:
// 进缓存迟早会被淘汰,淘汰后下一帧就要重算整条曲线 —— 实测单次 ≈1s 的卡顿尖峰,
// 而且多个信号同时驻留会占掉数百 MB。改为写入一次性缓冲。
if ((computedEnd - computedBegin) > kCurveSpanCacheMaxSamples)
{
m_curveSpanScratch.clear();
if (kind == DerivedCurveKind::WEAK_BOOST)
{
ComputeWeakWaveBoost(window, static_cast<double>(safeLevel) / 100.0, safeIterations,
m_curveSpanScratch);
}
else
{
ComputeMovingAverageIterated(window, safePoints, safeIterations, m_curveSpanScratch);
}
spanBegin = computedBegin;
return &m_curveSpanScratch;
}
// 缓存窗口:条目数与总字节数双重约束,避免大窗口把内存吃满。
// 淘汰策略为「最旧优先」(`deque` 头部即最早写入的)。
const auto cacheBytes = [this]() -> std::size_t
// 2) 条目数与总字节数双重约束(最旧优先淘汰)。
const auto bytes = [this]() -> std::size_t
{
std::size_t total = 0;
for (const CurveSpanEntry& cached : m_curveSpanCache)
for (const CurveEntryPtr& entry : m_curveEntries)
{
total += cached.values.size() * sizeof(float);
total += entry->values.size() * sizeof(float);
}
return total;
};
while (!m_curveSpanCache.empty() &&
(m_curveSpanCache.size() >= kCurveSpanCacheLimit ||
cacheBytes() + (computedEnd - computedBegin) * sizeof(float) >
kCurveSpanCacheMaxBytes))
while (!m_curveEntries.empty() &&
(m_curveEntries.size() > kCurveSpanCacheLimit || bytes() > kCurveSpanCacheMaxBytes))
{
m_curveSpanCache.pop_front();
m_curveEntries.pop_front();
}
m_curveSpanCache.push_back(CurveSpanEntry());
CurveSpanEntry& entry = m_curveSpanCache.back();
entry.signal = &signal;
entry.kind = kindValue;
entry.points = safePoints;
entry.iterations = safeIterations;
entry.levelPercent = safeLevel;
entry.begin = computedBegin;
entry.end = computedEnd;
if (kind == DerivedCurveKind::WEAK_BOOST)
{
ComputeWeakWaveBoost(window, static_cast<double>(safeLevel) / 100.0, safeIterations,
entry.values);
}
else
{
ComputeMovingAverageIterated(window, safePoints, safeIterations, entry.values);
}
spanBegin = entry.begin;
return &m_curveSpanCache.back().values;
}
const std::vector<float>& PlotView::MeanFilterCurveFor(const SignalData& signal, int points,
@@ -5250,6 +5441,19 @@ namespace jdi
BuildLaneVisuals();
// 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 /
// 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。
m_curvePins.clear();
{
std::lock_guard<std::mutex> lock(m_curveMutex);
PruneCurveEntriesLocked();
}
// 后台算好了新曲线:静态场景缓存里仍是旧曲线(或还没有曲线),作废一次以纳入最新结果。
if (m_curveSceneDirty.exchange(false, std::memory_order_relaxed))
{
InvalidateSceneCache();
}
if (m_lanes.empty())
{
InvalidateSceneCache();