perf(io): batch cache misses in multiread - #2636
Conversation
|
/label status/waiting-for-review |
|
Automated pull request review failed. Review effort: socket hang up No GitHub review was submitted. |
Merge Protections🟢 All 2 merge protections satisfied — ready to merge. Show 2 satisfied protections🟢 Require kind label
🟢 Require version label
|
There was a problem hiding this comment.
Pull request overview
This PR improves the BasicIO read-cache path for MultiRead by deduplicating page fetches, batching cache misses into a single backend MultiReadImpl call, and sharing in-flight page loads to avoid redundant backend reads under concurrency.
Changes:
- Updated
BasicIO::MultiReadto precompute required page IDs, bulk-load missing pages, then serve reads from cached pages. - Added in-flight page-load coordination (
loading_pages_) to share concurrent misses and prevent stale in-flight pages from being inserted into the cache. - Added unit tests covering batched misses, cross-page reads, concurrent shared loads, and retry after failed load.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/io/common/basic_io.h | Implements batched cache-miss loading for MultiRead and shared in-flight page loads with stale prevention. |
| src/io/read_cache/read_cache_test.cpp | Adds CountingIO test backend and new tests for batched misses, concurrency sharing, and failure retry behavior. |
Suppressed comments (1)
src/io/read_cache/read_cache_test.cpp:216
- This busy-wait loop spins without yielding, which can burn CPU during test runs. Adding a yield (or a condition_variable) makes the test friendlier and less noisy under contention.
while (not start.load()) {
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/io/common/basic_io.h:640
ClearCache()setsLoadingPage::done = trueand notifies waiters without ensuringstate->pageis populated. A concurrentLoadCachedPages()waiter can observedone==true, proceed, and readstate->page == nullptr, causing spurious read failures.
ClearCache() should not set done/notify for in-flight loads; it should just clear the cache and mark in-flight loads as stale so FinishPageLoads() won’t insert them into the cache.
for (const auto& [page_id, state] : loading_pages_) {
state->stale = true;
state->done = true;
states_to_notify.push_back(state);
}
LHT129
left a comment
There was a problem hiding this comment.
[suggestion] In InvalidateCacheRange, when the range overflows (offset > UINT64_MAX - (size - 1)), the function calls cache_->Clear() and returns early without marking in-flight LoadingPage entries as stale. This means an in-flight page load that completes after the clear could insert now-stale data into the empty cache via FinishPageLoads (since state->stale would still be false).
The normal path below (the for-loop over pages) correctly marks in-flight pages as stale via iter->second->stale = true. Consider also marking in-flight pages as stale in the overflow path for consistency:
if (offset > UINT64_MAX - (size - 1)) {
cache_->Clear();
for (const auto& [pid, state] : loading_pages_) {
state->stale = true;
}
return;
}This is a pre-existing pattern (the early return was there before this PR), but since this PR introduces the stale flag mechanism, the overflow path should be updated to use it.
LHT129
left a comment
There was a problem hiding this comment.
I reviewed the batched cache-miss changes across basic_io.h, page_cache.{h,cpp}, read_cache_test.cpp, and test_ivf.cpp. The implementation is well-structured and the test coverage is thorough.
Summary of changes:
PageCachegainsAcquire/Wait/Complete/IsStalefor coordinating in-flight page loads, with aLoadingPagestate tracked per page.BasicIOcaptures aReadCacheSnapshot(shared_ptr + page_id_base) at the start of each read operation, providing snapshot isolation without holdingcache_mutex_across I/O.MultiReadnow deduplicates pages across sub-reads, batches all cache misses into a singleMultiReadImplcall, and copies results back from cached pages.GetOrLoadPage(single-page path) uses the sameAcquire/Completeprotocol, sharing in-flight loads with concurrentMultiReadcallers.InvalidateCacheRangeandClearCachetake a local snapshot of the cache pointer before operating, avoiding lock nesting with the page cache mutex.EnableReadCachenow takescache_mutex_to prevent races with concurrent snapshot captures.- Tests cover: batched misses, single-read backend for
Read, shared in-flight loads, failure retry, snapshot isolation during cache replacement, and stale-page reload for bothMultiReadandRead.
Observations (non-blocking):
-
GetOrLoadPagecaptures theReadCacheSnapshotonce and uses it across the entire retry loop. IfSetReadCachereplaces the cache between iterations, the function continues using the old snapshot (kept alive byshared_ptr). This is correct for snapshot isolation, but a page loaded into the old cache will not be visible to subsequent reads through the new cache — the next read for that page will incur another I/O. This is a reasonable tradeoff. -
The
LoadCachedPagesretry loop callspages.clear()before each retry, which is correct. Thestaleflag is properly propagated fromLoadCachedPagesOncethrough thebool&out-parameter. -
The test helper
CountingIOusescondition_variable-based blocking for deterministic thread synchronization, which is a significant improvement over the earlier timing-based approach. TheBlockMultiRead/WaitForMultiReadBlock/UnblockMultiReadpattern is clean and race-free.
No blocking issues found. The PR looks ready to merge from a review perspective.
LHT129
left a comment
There was a problem hiding this comment.
Code Review for PR #2636
[critical] Deadlock in InitIO → EnableReadCache
In src/io/common/basic_io.h, InitIO calls GetReadCacheSnapshot() (which locks cache_mutex_ via std::scoped_lock) and then calls EnableReadCache() (which also locks cache_mutex_ via std::scoped_lock added in this PR). Since std::mutex is non-recursive, this causes a deadlock on the same thread.
The call chain:
InitIO→GetReadCacheSnapshot()acquirescache_mutex_(line ~328)InitIO→EnableReadCache()attempts to acquirecache_mutex_again → deadlock (line ~395)
EnableReadCache added the scoped_lock in this PR, but InitIO was not updated to avoid the double-lock.
Suggested fix: restructure InitIO to release the snapshot before calling EnableReadCache:
inline void
InitIO(const IOParamPtr& io_param) {
if constexpr (not InMemory) {
bool should_enable = false;
bool should_clear = false;
{
const auto cache = GetReadCacheSnapshot();
if (cache.cache == nullptr) {
should_enable = true;
} else if (io_param != nullptr and io_param->enable_read_cache_) {
should_enable = true;
} else {
should_clear = true;
}
} // lock released here
if (should_enable) {
EnableReadCache(io_param);
} else if (should_clear) {
ClearCache();
}
}
...Alternatively, remove the lock from EnableReadCache and document that callers must hold cache_mutex_.
225ee50 to
ba35b29
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Suppressed comments (3)
src/io/read_cache/read_cache_test.cpp:137
- This test-side wait has no timeout, so if the cache regresses and never enters
MultiReadImpl, the whole test process hangs instead of reporting a failure. Use a boundedwait_for/wait_untiland assert that the block was reached; the analogous read-block helper and start barrier should use the same protection.
void
WaitForMultiReadBlock() const {
std::unique_lock<std::mutex> lock(multi_read_mutex_);
multi_read_blocked_cv_.wait(lock, [this] { return multi_read_blocked_; });
src/io/read_cache/read_cache_test.cpp:86
WriteImplstill writes the shared, non-atomicsize_on every overwrite. A concurrent cached read can accesssize_while this helper writes it, so the invalidation tests retain a C++ data race (and this reintroduces the earlier test-helper issue). Only updatesize_when the backing buffer grows, as the write below is not changing the logical extent.
if (data_.size() < offset + size) {
data_.resize(offset + size);
}
std::memcpy(data_.data() + offset, data, size);
size_ = std::max(size_, offset + size);
src/io/common/basic_io.h:667
- This path allocates one full
Pagefor every owned miss and then allocates a second contiguousread_databuffer containing all of those pages. The amount of temporary memory therefore scales with the number of unique misses, notmax_pages_; a large MultiRead can cause a substantial memory spike or OOM even when the configured cache is small. Refill the misses in bounded chunks (or otherwise avoid retaining all page buffers until the whole request completes).
if (success and not owned_loads.empty()) {
if (batch_read) {
read_data.resize(total_size);
|
Regarding the review note about a possible InitIO -> EnableReadCache self-deadlock: no mutex is retained by ReadCacheSnapshot. GetReadCacheSnapshot() acquires cache_mutex_ only in its local scoped_lock and releases it before returning the shared_ptr/page-id value object. InitIO therefore calls EnableReadCache() after that lock has already been released, so there is no recursive lock acquisition. I rechecked this path on ba35b29; no code change is needed for that note. |
ba35b29 to
7dd1bed
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
src/io/common/basic_io.h:671
- This materializes every miss in the request as both a full 128 KiB
Pageand a packedread_databuffer before invoking the backend. A sparse or largeMultiReadcan therefore allocate far more than the cache'smax_pages_bound (for example, 10,000 misses require roughly 2.4 GiB for these two temporary buffers), causing avoidable memory spikes or OOM. Load owned misses in bounded chunks, or otherwise cap and release the temporary storage.
std::vector<PagePtr, AllocatorWrapper<PagePtr>> loaded_pages(allocator_);
std::vector<uint64_t, AllocatorWrapper<uint64_t>> read_sizes(allocator_);
std::vector<uint64_t, AllocatorWrapper<uint64_t>> read_offsets(allocator_);
std::vector<uint8_t, AllocatorWrapper<uint8_t>> read_data(allocator_);
|
Addressed the three suppressed Copilot observations in 7dd1bed:
|
7dd1bed to
abe72ff
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/io/read_cache/page_cache.cpp:163
- Please add a regression test for calling
Clear()while a page load is in flight, verifying that the waiter wakes with a stale result and a later acquire can retry. The newdone/staletransition here is not covered by the existing tests (which coverRemove()and insertion failures), and a missed notification would leave readers blocked indefinitely.
for (const auto& [page_id, state] : loading_pages_) {
state->stale = true;
state->done = true;
states_to_notify.push_back(state);
}
Co-authored-by: opencode <opencode@anthropic.com> Signed-off-by: LHT129 <tianlan.lht@antgroup.com>
abe72ff to
10ebe39
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/io/common/basic_io.h:708
- When this path is reached because
staleis true, the batch will retry, butabandon_owned_loads()completes every owned handle as a non-stale failure. A concurrent waiter on one of those otherwise-valid pages then receivesnullptr, seesIsStale == falseinGetOrLoadPage, and returns a read failure instead of retrying. Cancel the owned loads as stale (or otherwise signal retry) when aborting a stale batch, while preserving the non-stale failure behavior for genuine load failures.
auto page = cache.cache->Wait(result.handle);
stale = stale or cache.cache->IsStale(result.handle);
if (page == nullptr or stale) {
abandon_owned_loads();
return false;
src/io/common/basic_io.h:372
- This mutex is now taken on every
Releasecall, includingInMemoryBasicIOinstantiations where the cache branch is compile-time disabled andcached_direct_reads_can never contain an entry. That adds unnecessary contention to allocated direct reads (for example, cross-blockMemoryBlockIOreads); guard this lookup withif constexpr (not InMemory)to preserve the lock-free in-memory path.
{
std::scoped_lock<std::mutex> lock(cached_direct_reads_mutex_);
if (cached_direct_reads_.erase(data) != 0) {
| Release(const uint8_t* data) const { | ||
| if constexpr (not InMemory) { | ||
| if (cache_ != nullptr) { | ||
| { |
There was a problem hiding this comment.
[suggestion] The Release method lost its if constexpr (not InMemory) guard. For InMemory IO types, this now unconditionally acquires cached_direct_reads_mutex_ on every Release call even though cached_direct_reads_ is always empty for those types.
The original code guarded the entire cache-related block with if constexpr (not InMemory), which avoided the lock overhead for in-memory IOs. Consider restoring that guard around the cached_direct_reads_ lookup.
| return offset <= size_ and size <= size_ - offset; | ||
| } | ||
|
|
||
| ReadCacheSnapshot |
There was a problem hiding this comment.
[suggestion] GetReadCacheSnapshot() returns a snapshot by value under cache_mutex_, but the returned shared_ptr<PageCache> is then used without the mutex. While this is safe (shared_ptr reference counting is thread-safe, and PageCache has its own internal mutex), the snapshot captures cache_page_id_base_ at a single point in time. If EnableReadCache is called concurrently, the snapshot may refer to a stale cache that is no longer the active one. This is intentional and correct (in-flight operations should complete against the cache they started with), but worth documenting as a design decision.
| mutable std::shared_ptr<PageCache> cache_; | ||
| uint64_t cache_page_id_base_{0}; | ||
| mutable std::mutex cached_direct_reads_mutex_; | ||
| mutable UnorderedSet<const uint8_t*> cached_direct_reads_; |
There was a problem hiding this comment.
[note] The cached_direct_reads_ set tracks heap pointers returned by the cached direct-read path so Release() can route deallocation to the allocator instead of the IO backend. The set uses UnorderedSet<const uint8_t*> keyed by raw pointer value. If the allocator ever reuses a freed address before the corresponding Release() call, a stale entry could cause a double-free or use-after-free. In practice this requires a missed Release() call (a caller bug), but worth noting the implicit contract: every pointer returned via need_release=true must be paired with exactly one Release() call.
| PagePtr | ||
| PageCache::Wait(const LoadHandle& handle) { | ||
| std::unique_lock<std::mutex> lock(mutex_); | ||
| handle.state_->cv.wait(lock, [&handle] { return handle.state_->done; }); |
There was a problem hiding this comment.
[suggestion] Complete() catches exceptions from InsertLocked() and stores them in error, then sets state->done = true and notifies waiters before rethrowing. This ensures waiters are never left blocked indefinitely even when eviction fails. However, the waiter receives state->page = nullptr (since result stays null when InsertLocked throws). The caller in LoadCachedPagesOnce treats a null page from Complete as a failure and will retry the entire batch if stale is true. This is correct behavior: the eviction failure is transient and retrying may succeed after other pages are released.
One edge case: if InsertLocked throws std::bad_alloc, retrying is unlikely to help. Consider whether eviction failures should be distinguished from allocation failures for retry decisions.
LHT129
left a comment
There was a problem hiding this comment.
Review Summary
This PR has been through multiple review iterations and the code quality is now solid. The core changes are well-designed:
Strengths:
- The
Acquire/Wait/Complete/IsStaleAPI onPageCachecorrectly implements in-flight page load sharing, preventing duplicate I/O for concurrent readers requesting the same page. - The
ReadCacheSnapshotpattern cleanly decouples read operations from cache lifecycle changes — readers hold a consistent view of the cache they started with. MultiReadnow deduplicates page requests and batches cache misses into a singleMultiReadImplbackend call, which is the primary performance goal of this PR.- Stale page detection and retry logic is correctly wired through the entire stack:
PageCache::Removemarks in-flight loads stale →Completeskips insertion for stale pages →LoadCachedPagesretries the batch. - Exception safety is thorough:
abandon_owned_loadsensures in-flight loads are completed (as failures) on any error path, preventing waiter threads from blocking indefinitely. - Test coverage is comprehensive: batched misses, cross-page reads, concurrent load sharing, cache snapshot during in-flight reads, invalidation + reload, failed-load retry, and eviction-during-insert.
Minor suggestions posted inline:
ReadCacheSnapshotdesign is correct but the snapshot semantics are implicit — consider a brief comment.cached_direct_reads_raw-pointer set has an implicit contract that everyneed_release=truepointer must be paired with exactly oneRelease().Complete()retry-on-eviction-failure could potentially loop onstd::bad_alloc— consider distinguishing transient eviction failures from fatal allocation failures.
No blocking issues found. The PR is ready to merge from a code review perspective.
Closes #2638\n\nSummary: deduplicate cached MultiRead pages and load owned misses through one backend MultiReadImpl call. Share in-flight page loads, prevent stale in-flight pages from entering the cache, and add coverage for batched misses, cross-page reads, concurrent loads, and failed-load retry.\n\nValidation: make fmt; make test CASE=[ReadCache]; make release.