Repository navigation
perf(cpp): decode each time page once and skip unbounded time filters - #1093
Conversation
Two related fixes on the aligned chunk reader: * build_page_plan() decodes a page's timestamps to classify it, and decode_page_lazy() then decoded the same page again. For single-page chunks the times kept by the planner are exactly what the lazy path needs, so hand them over instead of reading and decoding the page twice. * When the time predicate accepts every timestamp (TimeBetween over the full int64 range, which is what a query without a time bound uses), the planner no longer evaluates the predicate row by row; it knows the whole page matches and only has to apply OFFSET. Filter gains is_unbounded_time_filter(), false by default and overridden by TimeBetween. 3.2M-row scan: 0.069s -> 0.064s (~6%).
Add regression tests for the chunk-reader changes: * UnboundedTimeFilterReturnsAllRows drives the table-reader path with a TimeBetween over the full int64 range, checking that every row comes back and that OFFSET/LIMIT still apply, in serial and parallel mode. * MultiPreparedUnboundedTimeFilterWithOffset uses the prepared multi-column path, which pushes OFFSET into the page plan; the written chunks have a single statistic-less page, so the planner reaches the BOUNDARY branch where the unbounded shortcut replaces the per-row time predicate. It also covers a bounded window and an out-of-range query.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #1093 +/- ##
===========================================
+ Coverage 66.59% 66.63% +0.04%
===========================================
Files 782 782
Lines 56850 56862 +12
Branches 9170 9173 +3
===========================================
+ Hits 37859 37890 +31
+ Misses 17318 17296 -22
- Partials 1673 1676 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Review follow-ups on the page-plan work: * An unbounded time predicate made a page a BOUNDARY page, so the read loop still called satisfy_start_end_time() once per row and lost the bulk-copy path. Without a FIELD filter such a page is now marked FULL_PASS and OFFSET is consumed by the shared FULL_PASS handling; the duplicated OFFSET logic in the unbounded branch is gone. With a FIELD filter the page stays a BOUNDARY page (the rows depend on the mask, and OFFSET is applied after the mask by the block reader). * The parallel page-plan workers re-decoded every time page, undoing the planner's reuse exactly in the default (parallel) configuration. Skip slots the planner already filled. Tests: * TimeBetween::is_unbounded_time_filter() only for a non-negated full-range TimeBetween (negated/inverted/half-open/narrow/TimeIn are false). * NOT BETWEEN over the full range returns no rows. * The prepared multi-column test now runs its whole matrix with the parallel path on and off; with the pool off it reaches decode_page_lazy(), where the planner's times are handed over (probe: every lazy decode used prefilled times), and with the pool on every worker slot is served from the planner's times. * Guard the new collect lambdas against a null result set.
There was a problem hiding this comment.
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
std::numeric_limitsis used in this test, but this file’s diff does not add#include <limits>.… · New Usingper_page_times_[page_idx].empty()as the 'already decoded' sentinel can cause redundant… · Newstd::numeric_limitsis referenced in this header, but the diff does not show#include <limits>… · New
What changed in this PR
Improves AlignedChunkReader performance by reusing decoded time pages and skipping per-row evaluation for unbounded time predicates, with new tests validating correctness across serial/parallel and prepared-query paths.
Changes:
- Reuse time-page decoding results between planning and page decoding (including parallel workers).
- Add
Filter::is_unbounded_time_filter()withTimeBetweenfull-range detection to bypass per-row checks. - Add/extend tests covering unbounded time filters, OFFSET behavior, prepared multi-column queries, and negated full-range filters.
| File | Description |
|---|---|
| cpp/test/reader/table_view/tsfile_reader_table_test.cc | Adds integration tests for full-range TimeBetween behavior with OFFSET/limits and multi-column aligned reader paths. |
| cpp/test/reader/prepared_series_test.cc | Adds prepared multi-column query tests validating unbounded time shortcut and OFFSET under parallel/non-parallel reads. |
| cpp/test/reader/filter/time_in_filter_test.cc | Adds unit test for is_unbounded_time_filter() semantics across filter variants. |
| cpp/src/reader/filter/time_operator.h | Implements TimeBetween::is_unbounded_time_filter() for full-range, non-negated between. |
| cpp/src/reader/filter/filter.h | Adds default Filter::is_unbounded_time_filter() API. |
| cpp/src/reader/aligned_chunk_reader.cc | Reuses decoded times from the planner, skips redundant decoding in parallel workers, and applies unbounded-time shortcuts. |
Review effort: Lite
There was a problem hiding this comment.
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
6 open issues
std::numeric_limitsis used in this test, but this file’s diff does not add#include <limits>.…TimeBetweennow usesstd::numeric_limits, but this header should include<limits>directly to… · New This test introducesstd::numeric_limitsusage; ensure this translation unit includes<limits>… · New Dereferencingreinterpret_cast<const int64_t*>(timestamp)/ `reinterpret_cast<const… · Newstd::numeric_limitsis referenced in this header, but the diff does not show#include <limits>… Usingper_page_times_[page_idx].empty()as the 'already decoded' sentinel can cause redundant…
Review effort: Lite
Avoid reinterpret_cast to int64_t/double on the row buffer, which has no alignment guarantee; memcpy into aligned locals.
Parallel CTest processes can seed the name generator in the same clock tick; one then removes the file another is opening (E_ALREADY_EXIST at the fixture's open, observed as SEGFAULT / test failure on CI). Append pid and a per-process counter, as the writer tests already do.
develop's contiguous-time-filter helper and this branch's unbounded filter check are independent; keep both virtuals.
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues block approval of the aligned-chunk read performance improvements.
No open issues 🎉
6 resolved since last review
std::numeric_limitsis used in this test, but this file’s diff does not add#include <limits>.… Dereferencingreinterpret_cast<const int64_t*>(timestamp)/ `reinterpret_cast<const… This test introducesstd::numeric_limitsusage; ensure this translation unit includes<limits>…TimeBetweennow usesstd::numeric_limits, but this header should include<limits>directly to…std::numeric_limitsis referenced in this header, but the diff does not show#include <limits>… Usingper_page_times_[page_idx].empty()as the 'already decoded' sentinel can cause redundant…
Review effort: Balanced


Problem
Three related costs in
AlignedChunkReader(aligned chunks; pages of single-page chunks carry no page statistics):build_page_plan()decodes a page's timestamps to classify it, thendecode_page_lazy()decoded the same page again — a redundant file read plus decompress and decode.TimeBetweenover the full int64 range, which is what a query without a time bound constructs — the planner still evaluated the predicate row by row.Change
decode_page_lazy()skips non-empty slots, and the parallel page-plan workers no longer re-decode a slot the planner filled.Filter::is_unbounded_time_filter()(default false, overridden byTimeBetween). Without a FIELD filter the planner marks the pageFULL_PASS, so the read loop skips the per-row predicate and takes the bulk-copy path; OFFSET is consumed by the existingFULL_PASShandling. With a FIELD filter the page stays aBOUNDARYpage (rows depend on the mask) and OFFSET is applied after the mask by the block reader.Benchmark
3.2M rows / 1000 devices, table-model full scan, single thread (parallel read disabled), alternating dylib A/B. The workload only reaches the planner for chunks whose pages lack statistics, which is the case targeted here:
The measurement is from before the follow-up commits that completed the fast path (row-loop skip moved into
FULL_PASS, parallel re-decode removed).Testing
Full C++ suite: 1463 tests, 1460 passed, 3 skipped. New cases cover full-range OFFSET (parallel and serial readers), prepared multi-column queries with the pool on and off, NOT BETWEEN over the full range, and the
is_unbounded_time_filter()predicate itself.