Skip to content

perf(cpp): cache deserialized device index nodes - #1091

Merged
ColinLeeo merged 13 commits into
developfrom
perf/index-node-cache
Nov 15, 2026
Merged

ColinLeeo merged 13 commits into
developfrom
perf/index-node-cache

Conversation

@ColinLeeo

Copy link
Copy Markdown
Contributor

Problem

Every device lookup walks the device-level index tree with search_from_internal_node(), which read and deserialized the same index node from the file each time — using a scratch arena that it destroyed right after. A leaf node holds up to max_degree_of_index_node entries, so scanning N devices that live in one leaf deserialized that node N times, together with the heap allocations for every device id it contains. This dominated the profile's malloc/free samples (~2800 of ~11000 active samples).

Change

  • Add a small LRU (capacity 16) keyed by the node's start offset, holding the deserialized MetaIndexNode together with the PageArena that backs it.
  • search_from_internal_node() consults the cache and only reads/deserializes on a miss; entries are cloned into the caller's arena as before.
  • Cached nodes are immutable: callers only binary-search them. The previous code called destroy() on the temporary node after use; that is removed, since it would invalidate the cached children vector.
  • The cache is cleared in reset(), and access is mutex-guarded so parallel readers stay safe.

Benchmark

3.2M rows / 1000 devices, full scan, single thread (alternating dylib A/B):

read
before 0.178–0.180 s
after 0.113–0.114 s (−37%)

Profile: malloc/free samples drop from ~2800 to ~280. Python test suite wall time also drops from 17.7 s to 9.6 s on the same machine.

Testing

Full C++ suite: 1454 tests passed (includes the fault-injection reader tests and the reader memory-accounting tests).

Each device lookup walks the device-level index tree with
search_from_internal_node(), which used to read and deserialize the same
node from the file every time. A leaf node holds up to
max_degree_of_index_node entries, so a scan over N devices in one leaf
deserialized that node N times -- together with the heap allocations for
every device id in it -- and this dominated the profile's malloc/free.

Add a small LRU (capacity 16) keyed by the node's file offset that keeps
the deserialized node and the arena backing it. Cached nodes are
immutable: callers only binary-search them and clone entries into their
own arena, so they must not destroy them (the previous code called
destroy() on the temporary node; that is no longer done).

3.2M-row scan over 1000 devices: 0.180s -> 0.113s (~37%); malloc/free
samples in the profile drop from ~2800 to ~280.
The member was default-constructed, so it used common::Cache's default
bounds (64 entries, elasticity 10) instead of INDEX_NODE_CACHE_CAPACITY.
Caught by the new test that asserts the cache honours its capacity.

test(cpp): cover the device index node cache

Repeated lookups across 40 devices (small index degree, several levels)
must return the same indexes and keep the cache within capacity, and
reset() must drop every cached node.
…rena

TsFileIOReader::reset() destroyed tsfile_meta_page_arena_ while
tsfile_meta_ still held the MetaIndexNode graph allocated inside it; the
shared_ptr deleters then ran the nodes' destructors (and their children
vectors) on freed memory at reader destruction. Clear the map first,
while the arena is alive.

Found by IndexNodeCacheTest.ResetClearsCache.
reset() frees tsfile_meta_page_arena_, and TsFileMeta::bloom_filter_ is
placement-new'ed inside that arena.  The reader's own destructor runs
~TsFileMeta later, which dereferenced the freed BloomFilter and called
destroy() on it: a use-after-free that MSVC debug-heap and ASan report
as an access violation (IndexNodeCacheTest.ResetClearsCache failed on
Windows) while release builds on Linux/macOS silently read stale memory.

Destroy the bloom filter and clear the pointer while the arena is still
alive, alongside the existing index-node-map cleanup.
get_cached_index_node() handed out only the node, while the arena holding
the node, its children and their names stayed owned by the cache entry.
An LRU eviction or replacement of that entry (possible from another query
on the same reader, which the header says is supported) released the
arena while the caller was still walking the node: use-after-free on the
node and on the node destructor when the caller's shared_ptr was released.

Return the whole CachedIndexNode and hold it for the duration of the
search, mirroring get_cached_device_node().

Also validate the [start_offset, end_offset) pair before allocating, as
the device-node path already does, so a corrupt index cannot turn into a
~4 GiB or truncated allocation.

Tests: assert the second lookup round performs no new node reads (the
fixture now has few enough nodes to fit the 16-entry cache), so a broken
cache can no longer pass as long as its size is non-zero.
@codecov-commenter

codecov-commenter commented Nov 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 74.13793% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.94%. Comparing base (b16df6b) to head (e257a18).

Files with missing lines Patch % Lines
cpp/src/file/tsfile_io_reader.cc 78.37% 1 Missing and 7 partials ⚠️
cpp/src/file/tsfile_io_reader.h 66.66% 4 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1091      +/-   ##
===========================================
+ Coverage    66.93%   66.94%   +0.01%     
===========================================
  Files          788      788              
  Lines        57536    57573      +37     
  Branches      9297     9304       +7     
===========================================
+ Hits         38509    38543      +34     
+ Misses       17358    17355       -3     
- Partials      1669     1675       +6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ColinLeeo
ColinLeeo requested a balanced review from Copilot November 12, 2026 14:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

3 open issues
What changed in this PR

Adds an LRU cache for deserialized index-tree nodes in TsFileIOReader to avoid repeated file reads/deserialization during device lookups, significantly reducing allocator churn and improving scan performance.

Changes:

  • Introduces an index-node LRU cache keyed by node file offset, storing both MetaIndexNode and its backing PageArena.
  • Updates search_from_internal_node() to consult the cache and removes the previous per-lookup node destruction.
  • Adds targeted tests to verify cache reuse across lookups and clearing on reset().
File Description
cpp/​test/​file/​index_node_cache_test.cc Adds tests validating cache hit behavior across repeated lookups and cache clearing on reader reset.
cpp/​src/​file/​tsfile_io_reader.h Declares cached node container, cache members, cache access API, and test hooks.
cpp/​src/​file/​tsfile_io_reader.cc Implements cached node loading/insertion and integrates caching into internal-node traversal; clears cache on reset.

Review effort: Lite

Comment thread cpp/src/file/tsfile_io_reader.cc Resolved
Comment thread cpp/src/file/tsfile_io_reader.cc Resolved
Comment thread cpp/test/file/index_node_cache_test.cc Resolved
CTest runs cases in parallel processes; two can seed the name generator
in the same clock tick, and one then removes the file the other is
opening (E_ALREADY_EXIST from TsFileWriter::open, observed on the MSVC
Release job). Append the pid and a per-process counter, as the writer
tests already do.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Concurrent cache misses for the same offset can trigger a critical use-after-free because insertion frees the old arena before the old node’s deleter runs.

1 open issue
3 resolved since last review

Review effort: Balanced

Comment thread cpp/src/file/tsfile_io_reader.cc Resolved
Cache::insert() replaces an existing entry with a move assignment.  For
CachedIndexNode/CachedDeviceNode the implicit member-wise form assigns
the arena first, so when the cache was the sole owner the arena's pages
were freed before the old node's deleter ran on them: a use-after-free
when two queries missed the same offset and the second inserted after
the first.

Re-check the cache under the lock before inserting (as the device-node
path already does), and give both cached types assignment operators that
release the node before the arena.
Several fixtures build their .tsfile name from a clock count alone.  CTest
runs cases in parallel processes, so two can read the same tick and then
one removes the file the other is opening or reopening
(E_ALREADY_EXIST / E_FILE_OPEN_ERR, seen as SEGFAULT on macOS).

Append the process id and a per-process counter, the pattern the writer
tests already use.  Covers table_read_failure, table_field_filter,
chunk_reader_resource, aligned_chunk_reader_bulk,
aligned_field_filter_regression, masked_bulk_copy and
timeseries_schema_codec.
@ColinLeeo
ColinLeeo requested a balanced review from Copilot November 13, 2026 10:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

4 open issues
1 resolved since last review

Review effort: Lite

Comment thread cpp/src/file/tsfile_io_reader.cc Resolved
Comment thread cpp/test/file/index_node_cache_test.cc Resolved
getpid()/_getpid() are used in the fixtures unconditionally, but the
process.h/unistd.h block landed inside the ENABLE_THREADS guard in
aligned_chunk_reader_bulk_test and chunk_reader_resource_test, which
would break a build with threads disabled.
The buffer used to deserialize a cached node was allocated from the
node's arena, so every cache entry pinned read_size bytes until
eviction; use a temporary array instead. Also make the cache test file
name unique per process.
@ColinLeeo
ColinLeeo merged commit aa7ed1e into develop Nov 15, 2026
49 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants