From ced2ca78112cc1297e8b842b8bd68c3db06d84bc Mon Sep 17 00:00:00 2001 From: gaozheng Date: Wed, 24 Jun 2026 10:37:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(render):=20AsyncRegionBuilder=20=E5=B0=B1?= =?UTF-8?q?=E7=BB=AA=E7=BC=93=E5=AD=98LRU+=E9=A2=84=E5=8F=96+=E7=9B=B8?= =?UTF-8?q?=E5=90=8C=E7=9B=AE=E6=A0=87=E7=9F=AD=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C3-3:为 AsyncRegionBuilder 加按 RegionTarget 键的有界 LRU 就绪缓存 (容量N默认6)、prefetch 后台低优先预取(不抢占主目标)、getReady 非阻塞 缓存命中,并短路 requestTarget 相同目标(修 C3-2 LOW)。worker 每轮主 目标优先,否则建预取队列最新;缓存/refcount 增减全在锁内,析构干净 join。 ViewAdaptiveVolumeSource:updateView 提交主目标后预测下一目标(细一层 同区+沿相机方向平移一格 brick 列)并 prefetch;currentImages 走 getReady(主目标)命中即用否则沿用上一张。线程安全延续 C3-1。 --- src/render/source/AsyncRegionBuilder.cpp | 158 ++++++++++++++---- src/render/source/AsyncRegionBuilder.hpp | 92 +++++++--- .../source/ViewAdaptiveVolumeSource.cpp | 52 +++++- .../source/ViewAdaptiveVolumeSource.hpp | 14 +- tests/render/test_async_region_builder.cpp | 125 ++++++++++++++ 5 files changed, 385 insertions(+), 56 deletions(-) diff --git a/src/render/source/AsyncRegionBuilder.cpp b/src/render/source/AsyncRegionBuilder.cpp index 3aa0bed..ac63702 100644 --- a/src/render/source/AsyncRegionBuilder.cpp +++ b/src/render/source/AsyncRegionBuilder.cpp @@ -4,8 +4,10 @@ namespace geopro::render { -AsyncRegionBuilder::AsyncRegionBuilder(const std::string& storeDir) - : store_(storeDir) { +AsyncRegionBuilder::AsyncRegionBuilder(const std::string& storeDir, + std::size_t cacheCapacity) + : store_(storeDir), + cacheCapacity_(cacheCapacity > 0 ? cacheCapacity : 1) { worker_ = std::thread([this] { workerLoop(); }); } @@ -23,42 +25,125 @@ void AsyncRegionBuilder::setMaxTextureDim(int dim) { if (dim > 0) maxTextureDim_ = dim; } +std::list::iterator +AsyncRegionBuilder::findCached(const RegionTarget& t) { + for (auto it = cache_.begin(); it != cache_.end(); ++it) + if (it->target == t) return it; + return cache_.end(); +} + +void AsyncRegionBuilder::insertCacheLocked(const RegionTarget& t, + vtkSmartPointer img, + int level) { + // 已存在 → 更新并提到 front;否则新插 front,超容淘汰 back(最久未用)。 + auto it = findCached(t); + if (it != cache_.end()) { + it->image = std::move(img); + it->level = level; + cache_.splice(cache_.begin(), cache_, it); // 移到 front(touch) + return; + } + cache_.push_front(CacheEntry{t, std::move(img), level}); + while (cache_.size() > cacheCapacity_) { + cache_.pop_back(); // 旧 image refcount 在锁内释放(单线程独占) + } +} + void AsyncRegionBuilder::requestTarget(const RegionTarget& t) { { std::lock_guard lk(mutex_); - // 与已就绪结果对应的目标相同则不必重建(避免重复劳动);与在建/排队的期望相同 - // 亦无需扰动。简化:直接覆盖期望并标脏——worker 会以最新期望为准(supersede)。 - desired_ = t; - hasDesired_ = true; + mainTarget_ = t; + hasMain_ = true; + // 短路:已在就绪缓存→无需重建,但仍把缓存结果发布给 takeLatest(兼容 C3-1/C3-2: + // 主目标切到一个已缓存区域时下一帧 takeLatest 即换上),并 touch LRU。 + // 否则标记主目标待建。 + auto it = findCached(t); + if (it != cache_.end()) { + mainPending_ = false; + latestMain_ = it->image; + latestMainLevel_ = it->level; + latestMainFresh_ = true; + cache_.splice(cache_.begin(), cache_, it); // touch LRU + } else { + mainPending_ = true; + } } cv_.notify_one(); } -vtkSmartPointer AsyncRegionBuilder::takeLatest() { - // 非阻塞:仅在锁内做指针移动(不做任何重组/IO/长时操作)。 - // std::move 把 ready_ 的引用移交给返回值(无 refcount 增减,且全程在锁内/单线程), - // ready_ 置空——下次无新结果再调返回 nullptr。 +void AsyncRegionBuilder::prefetch(const std::vector& targets) { + { + std::lock_guard lk(mutex_); + for (const auto& t : targets) { + // 跳过已缓存、与主目标相同、或已在预取队列中的(去重,省空转)。 + if (findCached(t) != cache_.end()) continue; + if (hasMain_ && t == mainTarget_) continue; + bool dup = false; + for (const auto& q : prefetchQ_) + if (q == t) { dup = true; break; } + if (!dup) prefetchQ_.push_back(t); + } + } + cv_.notify_one(); +} + +bool AsyncRegionBuilder::pickNextLocked(RegionTarget& out, bool& isMain) { + // 主目标优先:未缓存的主目标先建。 + if (hasMain_ && mainPending_) { + out = mainTarget_; + isMain = true; + return true; + } + // 否则预取:建队列里最新(back)且未缓存的;顺手丢弃已缓存的过期项。 + while (!prefetchQ_.empty()) { + RegionTarget t = prefetchQ_.back(); + prefetchQ_.pop_back(); + if (findCached(t) != cache_.end()) continue; // 已缓存→跳过 + out = t; + isMain = false; + return true; + } + return false; +} + +vtkSmartPointer AsyncRegionBuilder::getReady(const RegionTarget& t, + int& levelOut) { std::lock_guard lk(mutex_); - return std::move(ready_); + auto it = findCached(t); + if (it == cache_.end()) return nullptr; + levelOut = it->level; + vtkSmartPointer img = it->image; // 锁内拷贝 SmartPointer + cache_.splice(cache_.begin(), cache_, it); // touch LRU(移到 front) + return img; +} + +vtkSmartPointer AsyncRegionBuilder::takeLatest() { + int dummy = 0; + return takeLatest(dummy); } vtkSmartPointer AsyncRegionBuilder::takeLatest(int& outLevel) { + // 非阻塞:仅在锁内做指针移动。消费式——仅当主目标有未消费的新就绪结果时返回非空。 std::lock_guard lk(mutex_); - if (ready_) outLevel = readyLevel_; // 仅有新结果时回传 level - return std::move(ready_); + if (!latestMainFresh_) return nullptr; + outLevel = latestMainLevel_; + latestMainFresh_ = false; + return std::move(latestMain_); // 移交所有权(锁内,单线程 refcount) } void AsyncRegionBuilder::workerLoop() { std::unique_lock lk(mutex_); for (;;) { - // 等到有期望或停止。 - cv_.wait(lk, [this] { return stop_.load() || hasDesired_; }); + cv_.wait(lk, [this] { + return stop_.load() || (hasMain_ && mainPending_) || !prefetchQ_.empty(); + }); if (stop_.load()) return; - // 取出最新期望(supersede:只建当前最新,丢弃中途旧请求)。 - const RegionTarget target = desired_; + RegionTarget target{}; + bool isMain = false; + if (!pickNextLocked(target, isMain)) continue; // 虚假唤醒/已全缓存 + const int maxDim = maxTextureDim_; - hasDesired_ = false; building_ = true; // 解锁后构建:worker 在自己线程内重组,主线程全程不触碰该局部 image。 @@ -69,25 +154,40 @@ void AsyncRegionBuilder::workerLoop() { if (stop_.load()) return; - // 若构建期间又来了更新的期望,则本次结果已过期——丢弃,继续建最新。 - // built 离开作用域前在锁内释放 refcount(单线程独占)。 - if (hasDesired_) { - building_ = false; - built = nullptr; // 锁内释放(单线程独占 refcount) + building_ = false; + + if (built == nullptr) { + // 空区间(不应发生于合法目标):标记主目标已尝试,避免死循环空转。 + if (isMain && hasMain_ && mainTarget_ == target) mainPending_ = false; continue; } - // publish:在锁内把所有权 move 进 ready_(旧 ready_ 若未取走在此处锁内释放)。 - // 所有 refcount 增减均在锁内/单线程独占完成。随结果一同发布其 level。 - ready_ = std::move(built); - readyLevel_ = target.level; - building_ = false; + // 入就绪缓存(锁内,所有 refcount 增减单线程独占)。 + insertCacheLocked(target, built, target.level); + + if (isMain) { + // 主目标就绪:仅当它仍是当前主目标时清 pending、发布给 takeLatest。 + // (构建期间主目标若已变更,则本次非最新——保留新主目标的 pending 由下轮建。) + if (hasMain_ && mainTarget_ == target) { + mainPending_ = false; + // 消费式发布:从缓存拷一份给 takeLatest(content 同缓存,不改)。 + latestMain_ = built; + latestMainLevel_ = target.level; + latestMainFresh_ = true; + } + } + built = nullptr; // 锁内释放本地引用(缓存已持有) } } bool AsyncRegionBuilder::hasPending() const { std::lock_guard lk(mutex_); - return hasDesired_ || building_; + return (hasMain_ && mainPending_) || building_ || !prefetchQ_.empty(); +} + +std::size_t AsyncRegionBuilder::cacheSize() const { + std::lock_guard lk(mutex_); + return cache_.size(); } } // namespace geopro::render diff --git a/src/render/source/AsyncRegionBuilder.hpp b/src/render/source/AsyncRegionBuilder.hpp index 63468bc..2409af4 100644 --- a/src/render/source/AsyncRegionBuilder.hpp +++ b/src/render/source/AsyncRegionBuilder.hpp @@ -1,9 +1,12 @@ #pragma once #include #include +#include +#include #include #include #include +#include #include #include @@ -16,19 +19,27 @@ namespace geopro::render { // C3 并发核心:把「从 store 重组视野区域单纹理」放后台线程,主线程非阻塞取最新 // 就绪结果——使拖动/缩放时不被解压+重组卡住。 // +// C3-3 增强:就绪缓存(按 RegionTarget LRU,容量 N) + 预取(后台额外建预测的下一目标, +// 不抢占主目标) + 相同目标短路(已就绪/在建则不重复提交,修 C3-2 LOW)。 +// // 线程安全设计(VTK 引用计数跨线程要小心): // - 后台 worker 在自己线程内调 reorganizeRegion(C2/C3 共用重组核)构建出一个 // vtkSmartPointer(全程不被主线程触碰)。 -// - 完成后在 mutex 下把它移交到成员 ready_。主线程在同一 mutex 下取走(移动出)。 -// 所有 vtkImageData 引用计数的增减都发生在锁内/单线程独占——避免两线程同时碰 -// 同一对象的 refcount。worker 构建期间主线程看不到它;publish 后所有权经锁交给 -// 主线程。 -// - 请求合并/取最新(supersede):worker 在建 A 时若 requestTarget(B),完成 A 后 -// 去建最新期望(B),不堆积旧请求——主线程高频 update 不致排队爆炸。 +// - 完成后在 mutex 下把它存入【就绪缓存】(按 target 键的 LRU)。主线程在同一 +// mutex 下从缓存取(拷贝 SmartPointer 引用)。所有 vtkImageData 引用计数的增减 +// 都发生在锁内——避免两线程同时碰同一对象的 refcount。worker 构建期间主线程看 +// 不到它;publish 后所有权经锁可见。 +// - 主目标优先:worker 每轮先建主目标(若未缓存),否则建预取队列里最新的; +// 预取永不抢占主目标——主目标一旦变更,worker 完成当前一格后立即转向主目标。 +// - 短路:requestTarget 同一目标若已缓存或正在建,不重复提交,省 worker 空转。 class AsyncRegionBuilder { public: - // 起 worker 线程。storeDir:含金字塔的分块 store。 - explicit AsyncRegionBuilder(const std::string& storeDir); + // 就绪缓存容量(按 RegionTarget LRU)。 + static constexpr std::size_t kDefaultCacheCapacity = 6; + + // 起 worker 线程。storeDir:含金字塔的分块 store。cacheCapacity:就绪缓存容量。 + explicit AsyncRegionBuilder(const std::string& storeDir, + std::size_t cacheCapacity = kDefaultCacheCapacity); // 停 worker(join)。析构忙时干净 join 不崩、不泄漏、不死锁。 ~AsyncRegionBuilder(); @@ -36,40 +47,77 @@ class AsyncRegionBuilder { AsyncRegionBuilder(const AsyncRegionBuilder&) = delete; AsyncRegionBuilder& operator=(const AsyncRegionBuilder&) = delete; - // 主线程调:设期望目标。与当前在建/已建不同则唤醒 worker 重建;相同则忽略。 + // 主线程调:设主目标(优先建)。短路:若该 target 已在就绪缓存或正在建,不重复 + // 提交(仅置为主目标供 getReady/takeLatest 命中),省 worker 空转。 void requestTarget(const RegionTarget& t); - // 主线程调:取最新已就绪 image(非阻塞)。无新结果返回 nullptr(主线程继续用 - // 上一张)。取走后清空,下次无新结果再调返回 nullptr。 - // 永不阻塞主线程:仅在锁内做指针移动。 + // 主线程调:预取这些目标(后台额外建,低优先,绝不抢占主目标)。已缓存或与主目标 + // 相同的会被跳过。建好后入就绪缓存(LRU 有界)。 + void prefetch(const std::vector& targets); + + // 主线程调:非阻塞取某 target 的就绪 image。缓存命中→返回该 image 并 touch LRU + // (回传其 level 到 levelOut);未命中→nullptr(levelOut 不变)。永不阻塞主线程。 + vtkSmartPointer getReady(const RegionTarget& t, int& levelOut); + + // 主线程调:取最新已就绪的【主目标】image(非阻塞,兼容 C3-1/C3-2 行为)。 + // 仅当主目标自上次 take 后有新就绪结果时返回非空(消费式:取走后再调返回 nullptr + // 直到主目标再次就绪)。无新结果返回 nullptr(主线程继续用上一张)。 vtkSmartPointer takeLatest(); - // 同上,并通过 outLevel 回传该就绪结果对应的 target.level(仅当返回非空时有效; - // 返回空时 outLevel 不变)。供调用方同步 UI 的 LOD 显示(C3-2 ViewAdaptive 用)。 - // 非破坏式重载:无参版行为不变。 + // 同上,并通过 outLevel 回传该就绪结果对应的 target.level(仅当返回非空时有效)。 vtkSmartPointer takeLatest(int& outLevel); - // 是否有在建/排队(供 UI/测试)。 + // 是否有在建/排队(主目标未就绪或预取队列非空,供 UI/测试)。 bool hasPending() const; + // 就绪缓存当前条目数(供测试验证 LRU 有界)。 + std::size_t cacheSize() const; + // 单张 3D 纹理各轴上限(GL_MAX_3D_TEXTURE_SIZE)。须在 requestTarget 前设。 void setMaxTextureDim(int dim); private: void workerLoop(); + // 就绪缓存条目(LRU:front=最近用,back=最久未用)。 + struct CacheEntry { + RegionTarget target; + vtkSmartPointer image; + int level; + }; + + // 以下私有辅助均要求调用方已持锁。 + // 查缓存命中(返回迭代器或 end)。 + std::list::iterator findCached(const RegionTarget& t); + // 插入/更新缓存并把该条目移到 front;超容则淘汰 back。 + void insertCacheLocked(const RegionTarget& t, vtkSmartPointer img, + int level); + // worker 选下一个要建的目标:主目标未缓存→主目标;否则预取队列里最新未缓存的; + // 都没有→返回 false。out 回传目标与是否为主目标。 + bool pickNextLocked(RegionTarget& out, bool& isMain); + geopro::data::ChunkedVolumeStore store_; mutable std::mutex mutex_; std::condition_variable cv_; // 受 mutex_ 保护的共享状态: - RegionTarget desired_{}; // 主线程最新期望目标 - bool hasDesired_ = false; // 是否有未消费的期望 - bool building_ = false; // worker 当前是否在建 - vtkSmartPointer ready_; // 已就绪、待主线程取走的最新结果 - int readyLevel_ = 0; // ready_ 对应 target.level(随 ready_ 一同发布) - std::atomic stop_{false}; // 析构置位,唤醒 worker 退出 + RegionTarget mainTarget_{}; // 主线程最新主目标 + bool hasMain_ = false; // 是否设过主目标 + bool mainPending_ = false; // 主目标尚未就绪(未在缓存)→ 需建 + std::vector prefetchQ_; // 预取队列(低优先;建最新的先) + bool building_ = false; // worker 当前是否在建 + + // 就绪缓存(LRU)。front=最近用。 + std::list cache_; + std::size_t cacheCapacity_ = kDefaultCacheCapacity; + + // takeLatest 消费式发布:worker 建好主目标后置位,takeLatest 取走后清零。 + vtkSmartPointer latestMain_; + int latestMainLevel_ = 0; + bool latestMainFresh_ = false; // 有未被 takeLatest 消费的新主目标结果 + + std::atomic stop_{false}; // 析构置位,唤醒 worker 退出 int maxTextureDim_ = 16384; std::thread worker_; // 最后声明:构造完上述成员后再起线程 diff --git a/src/render/source/ViewAdaptiveVolumeSource.cpp b/src/render/source/ViewAdaptiveVolumeSource.cpp index 0266d1e..d42ded9 100644 --- a/src/render/source/ViewAdaptiveVolumeSource.cpp +++ b/src/render/source/ViewAdaptiveVolumeSource.cpp @@ -64,13 +64,59 @@ void ViewAdaptiveVolumeSource::updateView(const CameraView& cam, target.bz0 = sel.bz0; target.bz1 = sel.bz1; target.exagg = exagg_; - builder_.requestTarget(target); // 与在建/已建相同则忽略;否则唤醒 worker 重建 + mainTarget_ = target; + hasMainTarget_ = true; + builder_.requestTarget(target); // 短路:已就绪/在建则不重复;否则唤醒 worker + + // C3-3:预测下一目标并预取(低优先,不抢主目标)。 + const std::vector next = predictNext(target, cam); + if (!next.empty()) builder_.prefetch(next); +} + +std::vector ViewAdaptiveVolumeSource::predictNext( + const RegionTarget& main, const CameraView& cam) const { + std::vector out; + + // (a) 拉近预测:更细一层(level-1)同区。金字塔下一细层每个 brick 区间在更细层 + // 大致翻倍(level L 维度 = ceil(n/2^L))。简单可用:level-1,brick 区间 ×2。 + if (main.level > 0) { + RegionTarget finer = main; + finer.level = main.level - 1; + finer.bx0 = main.bx0 * 2; + finer.bx1 = main.bx1 * 2; + finer.by0 = main.by0 * 2; + finer.by1 = main.by1 * 2; + finer.bz0 = main.bz0 * 2; + finer.bz1 = main.bz1 * 2; + out.push_back(finer); + } + + // (b) 邻接平移:沿相机水平前进方向(focal-pos 在 x 上的符号)平移一格 brick 列。 + // 简单可用:只在 x 轴平移主区间宽度的「一格 brick」(区间宽 = bx1-bx0,平移 1 列)。 + const int wx = main.bx1 - main.bx0; + if (wx > 0) { + const double dx = cam.focal[0] - cam.pos[0]; + const int dir = dx >= 0 ? 1 : -1; // 朝相机视线前进方向 + RegionTarget shifted = main; + shifted.bx0 = main.bx0 + dir; + shifted.bx1 = main.bx1 + dir; + // 越界(负)则改向另一侧,仍非负才纳入(避免无效区间)。 + if (shifted.bx0 < 0) { + shifted.bx0 = main.bx0 - dir; + shifted.bx1 = main.bx1 - dir; + } + if (shifted.bx0 >= 0 && shifted != main) out.push_back(shifted); + } + + return out; } void ViewAdaptiveVolumeSource::pullLatest() const { - // 非阻塞:取最新已就绪。有新结果则换上 current_ 并同步 lastLevel_;否则沿用上一张。 + // 非阻塞:主目标 getReady 命中(含预取已备好的区域→即刻就绪)则换上 current_ 并 + // 同步 lastLevel_;否则沿用上一张(C3-2 行为:拖动/出界不闪空)。 + if (!hasMainTarget_) return; int level = lastLevel_; - vtkSmartPointer latest = builder_.takeLatest(level); + vtkSmartPointer latest = builder_.getReady(mainTarget_, level); if (latest) { current_ = std::move(latest); lastLevel_ = level; diff --git a/src/render/source/ViewAdaptiveVolumeSource.hpp b/src/render/source/ViewAdaptiveVolumeSource.hpp index 2896156..133f08a 100644 --- a/src/render/source/ViewAdaptiveVolumeSource.hpp +++ b/src/render/source/ViewAdaptiveVolumeSource.hpp @@ -89,10 +89,16 @@ class ViewAdaptiveVolumeSource : public IVolumeRenderSource { // 由 meta + exagg 填 VolumeView(spacing 已含 exagg 于 y/z)。 VolumeView volumeView() const; - // 从 builder 拉一次最新就绪结果:有新结果则更新 current_/lastLevel_。 - // const(仅刷新 mutable 缓存),供 currentImages/sliceSource 共用(DRY)。 + // 从 builder 拉一次最新就绪结果:主目标 getReady 命中→用之并更新 current_/ + // lastLevel_;否则沿用上一张(C3-2 行为)。const(仅刷新 mutable 缓存),供 + // currentImages/sliceSource 共用(DRY)。 void pullLatest() const; + // C3-3 预测下一目标(先简单可用):从主选区派生 (a) 更细一层同区、(b) 沿相机 + // 前进方向平移一格 brick 列的邻接区间。返回去重后的预取候选。 + std::vector predictNext(const RegionTarget& main, + const CameraView& cam) const; + geopro::data::ChunkedVolumeStore store_; geopro::data::StoreMeta meta_; double exagg_ = 1.0; @@ -107,6 +113,10 @@ class ViewAdaptiveVolumeSource : public IVolumeRenderSource { // 最新已就绪单图 + 其 level(mutable:currentImages/sliceSource const 内懒取最新)。 mutable vtkSmartPointer current_; // 空指针 = 从未就绪 mutable int lastLevel_ = 0; + + // 当前主目标(updateView 设;pullLatest 用它向 builder getReady)。 + RegionTarget mainTarget_{}; + bool hasMainTarget_ = false; }; } // namespace geopro::render diff --git a/tests/render/test_async_region_builder.cpp b/tests/render/test_async_region_builder.cpp index b828890..f33d141 100644 --- a/tests/render/test_async_region_builder.cpp +++ b/tests/render/test_async_region_builder.cpp @@ -214,3 +214,128 @@ TEST(AsyncRegionBuilder, TakeLatestNonBlockingWhenEmpty) { EXPECT_EQ(img.Get(), nullptr); EXPECT_LT(dtMs, 100); // 远小于任一次重组耗时,证明未阻塞等 worker } + +// ── C3-3:轮询某 target 的 getReady 直到非空(带超时)────────────────────── +vtkSmartPointer waitGetReady(AsyncRegionBuilder& b, + const RegionTarget& t, + int maxTries = 800, int stepMs = 5) { + vtkSmartPointer img; + int lv = -1; + for (int i = 0; i < maxTries && img == nullptr; ++i) { + img = b.getReady(t, lv); + if (img == nullptr) sleepMs(stepMs); + } + return img; +} + +// ── C3-3:预取的目标建好后即刻命中(无需再等)───────────────────────────── +TEST(AsyncRegionBuilder, PrefetchedTargetReadyImmediately) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_prefetch").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + const RegionTarget cur = makeTarget(1, 0, 2, 0, 1, 0, 1); + const RegionTarget nxt = makeTarget(1, 1, 3, 0, 2, 0, 1); + + AsyncRegionBuilder b(dir); + b.requestTarget(cur); + b.prefetch({nxt}); + + // 等两者都进缓存就绪。 + ASSERT_NE(waitGetReady(b, cur).Get(), nullptr); + ASSERT_NE(waitGetReady(b, nxt).Get(), nullptr); + + // 此后 getReady(nxt) 即刻命中(无需再等;计时极短)。 + const auto t0 = std::chrono::steady_clock::now(); + int lv = -1; + auto hit = b.getReady(nxt, lv); + const auto dtMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count(); + ASSERT_NE(hit.Get(), nullptr); + EXPECT_EQ(lv, nxt.level); + EXPECT_LT(dtMs, 50); + + // 内容 == 同步重组(缓存不改内容)。 + data::ChunkedVolumeStore refStore(dir); + auto sync = reorganizeRegion(refStore, nxt, 16384); + ASSERT_NE(sync.Get(), nullptr); + EXPECT_TRUE(imagesEqual(hit.Get(), sync.Get())); +} + +// ── C3-3:缓存有界 LRU——请求/预取 > 容量个不同 target → size ≤ N ──────────── +TEST(AsyncRegionBuilder, CacheIsBoundedLru) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_lru").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + const std::size_t cap = 3; + AsyncRegionBuilder b(dir, cap); + + // 提交 6 个不同 target(> 容量),逐个等就绪。 + std::vector ts = { + makeTarget(0, 0, 1, 0, 1, 0, 1), makeTarget(0, 1, 2, 0, 1, 0, 1), + makeTarget(0, 2, 3, 0, 1, 0, 1), makeTarget(1, 0, 1, 0, 1, 0, 1), + makeTarget(1, 1, 2, 0, 1, 0, 1), makeTarget(2, 0, 1, 0, 1, 0, 1), + }; + for (const auto& t : ts) { + b.requestTarget(t); + ASSERT_NE(waitGetReady(b, t).Get(), nullptr) << "target 未就绪"; + } + + // 缓存有界:size ≤ 容量。 + EXPECT_LE(b.cacheSize(), cap); + + // 最久未用的(第一个)应被淘汰:getReady 即刻 nullptr(不重建——非阻塞)。 + int lv = -1; + EXPECT_EQ(b.getReady(ts.front(), lv).Get(), nullptr) + << "最久未用项应已被 LRU 淘汰"; + // 最近的几个仍命中。 + EXPECT_NE(b.getReady(ts.back(), lv).Get(), nullptr); +} + +// ── C3-3:相同请求短路——同一 target 连请求多次不重复建(建一次后命中缓存)──── +TEST(AsyncRegionBuilder, IdenticalRequestShortCircuits) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_short").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + AsyncRegionBuilder b(dir); + const RegionTarget t = makeTarget(1, 0, 2, 0, 1, 0, 1); + + b.requestTarget(t); + ASSERT_NE(waitGetReady(b, t).Get(), nullptr); + + // 已就绪后再连发同一 target:短路(不应再有 pending/在建),缓存仍 1 条。 + for (int i = 0; i < 10; ++i) b.requestTarget(t); + // 给 worker 一点时间(若误重建会出现 pending/building)。 + sleepMs(50); + EXPECT_FALSE(b.hasPending()) << "同一已就绪 target 不应触发重建"; + EXPECT_EQ(b.cacheSize(), 1u) << "同一 target 不应在缓存里重复占位"; + + // 短路时主目标仍可经 takeLatest 取到(兼容 C3-1/C3-2)。 + int lv = -1; + EXPECT_NE(b.getReady(t, lv).Get(), nullptr); +} + +// ── C3-3:预取不饿死主目标——大量 prefetch + 一个主 requestTarget → 主先就绪 ── +TEST(AsyncRegionBuilder, PrefetchDoesNotStarveMainTarget) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_starve").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + AsyncRegionBuilder b(dir, /*cap*/ 6); + + // 先灌大量预取(不同 target),再设主目标。 + std::vector many; + for (int i = 0; i < 12; ++i) + many.push_back(makeTarget(0, i % 3, (i % 3) + 1, 0, 1, 0, 1, 1.0 + i)); + b.prefetch(many); + + const RegionTarget main = makeTarget(2, 0, 1, 0, 1, 0, 1); + b.requestTarget(main); + + // 主目标应优先就绪(轮询 getReady(main) 命中)。 + ASSERT_NE(waitGetReady(b, main, 800, 2).Get(), nullptr) + << "预取饿死了主目标"; +}