This is an automated email from the ASF dual-hosted git repository.
lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 7470e4c6 perf(read): warm up next data file in ConcatBatchReader (#420)
7470e4c6 is described below
commit 7470e4c62c8970683474e5fdc0e5fceb8f138857
Author: Nicholas Jiang <[email protected]>
AuthorDate: Fri Oct 9 15:40:47 2026 +0700
perf(read): warm up next data file in ConcatBatchReader (#420)
---
docs/source/user_guide/prefetch.rst | 48 ++++++++++
src/paimon/common/reader/concat_batch_reader.cpp | 25 ++++-
src/paimon/common/reader/concat_batch_reader.h | 12 +++
.../common/reader/concat_batch_reader_test.cpp | 102 +++++++++++++++++++++
4 files changed, 186 insertions(+), 1 deletion(-)
diff --git a/docs/source/user_guide/prefetch.rst
b/docs/source/user_guide/prefetch.rst
index 4e601219..e8a3a348 100644
--- a/docs/source/user_guide/prefetch.rst
+++ b/docs/source/user_guide/prefetch.rst
@@ -70,3 +70,51 @@ Stripe metadata, the selected columns, and the configured
natural read size.
Prefetch is enabled when the estimated compressed size of the selected columns
exceeds the configured threshold; otherwise the reader uses the normal
single-reader path.
+
+Warmup
+======
+
+Prefetching parallelizes the reads inside one file. When the data files of a
+split are read one after another, warmup also overlaps the files with each
+other: while one file is being consumed, the reader already starts the first
+read of the next one, instead of waiting until the current file reaches its
+end. Warmup looks one file ahead of the file being read, and merge-on-read with
+the default ``loser-tree`` sort engine additionally warms the first file of
+every sorted run before merging. It applies to append-table reads and append
+compaction as well, but does not cross split boundaries.
+
+How far the next file is prepared is set with
+``ReadContextBuilder::SetWarmupLevel()``. Append compaction builds its own read
+context and always uses the default level.
+
+- ``WarmupLevel::NONE``: do not warm up.
+- ``WarmupLevel::RAW`` (default): fetch the next file's raw bytes through the
+ read-ahead cache without decoding them. It has no effect when the read-ahead
+ cache is disabled with ``ReadContextBuilder::SetReadAheadCacheEnabled()``.
+- ``WarmupLevel::DECODED``: also start decoding the next file, which hides the
+ most latency but uses the most memory.
+
+Warmup goes through the prefetch reader, so it only applies to Parquet and ORC
+files that are actually read with prefetching, and enabling prefetch alone does
+not guarantee that. With the adaptive strategy, a file whose first read range
+holds more batches than one prefetch queue can buffer is read without
+prefetching, and so without warmup; the ``prefetch.adaptive-disabled-count``
+metric counts how often this happens. Append compaction uses a prefetch queue
+of three batches, so it only warms files whose first read range spans no more
+than three full batches, such as files with small row groups.
+
+``WarmupLevel::RAW`` issues its fetches from the reading thread through the
+file system's asynchronous read, so it only overlaps I/O when that read is
+really asynchronous. The local file system serves it synchronously, so there
+``RAW`` warmup reads the next file on the reading thread before the current
+batch is returned, adding latency instead of hiding it. On such file systems,
+prefer ``WarmupLevel::DECODED``, which fetches on the background decode thread,
+or ``WarmupLevel::NONE``.
+
+Warmup preserves the returned rows and their order, but it costs memory: under
+``WarmupLevel::RAW`` the next file may hold up to the read-ahead cache's
+pre-buffer limit (256 MiB by default, see ``CacheConfig::SetPreBufferLimit()``)
+on top of the file being read. It may also fetch data from a file that the
+reader never consumes: a read that stops early, for example because of a LIMIT,
+may already have warmed the next file, and closing it waits for that file's
+in-flight fetches.
diff --git a/src/paimon/common/reader/concat_batch_reader.cpp
b/src/paimon/common/reader/concat_batch_reader.cpp
index 5aea88e3..167fdc5f 100644
--- a/src/paimon/common/reader/concat_batch_reader.cpp
+++ b/src/paimon/common/reader/concat_batch_reader.cpp
@@ -19,12 +19,14 @@
#include "paimon/common/reader/concat_batch_reader.h"
+#include <algorithm>
#include <utility>
#include "arrow/c/abi.h"
#include "paimon/common/metrics/metrics_impl.h"
#include "paimon/common/reader/reader_utils.h"
#include "paimon/common/utils/arrow/mem_utils.h"
+#include "paimon/reader/file_batch_reader.h"
namespace paimon {
class MemoryPool;
@@ -34,7 +36,15 @@
ConcatBatchReader::ConcatBatchReader(std::vector<std::unique_ptr<BatchReader>>&&
: arrow_pool_(arrow_pool),
finished_reader_metrics_(std::make_shared<MetricsImpl>()),
readers_(std::move(readers)),
- current_(0) {}
+ file_readers_(readers_.size(), nullptr),
+ current_(0) {
+ // Non-file readers keep their null slots so the warmup window stays
aligned with readers_.
+ for (size_t i = 0; i < readers_.size(); i++) {
+ if (auto* file_reader =
dynamic_cast<FileBatchReader*>(readers_[i].get())) {
+ file_readers_[i] = file_reader;
+ }
+ }
+}
Result<BatchReader::ReadBatch> ConcatBatchReader::NextBatch() {
PAIMON_ASSIGN_OR_RAISE(BatchReader::ReadBatchWithBitmap batch_with_bitmap,
@@ -66,10 +76,23 @@ void ConcatBatchReader::CloseAndReleaseReader(size_t
reader_index) {
reader->Close();
finished_reader_metrics_->Merge(reader->GetReaderMetrics());
reader.reset();
+ file_readers_[reader_index] = nullptr;
+}
+
+void ConcatBatchReader::WarmupRange(size_t idx, size_t count) {
+ const size_t end = std::min(idx + count, file_readers_.size());
+ for (size_t i = idx; i < end; i++) {
+ if (file_readers_[i] != nullptr) {
+ file_readers_[i]->Warmup();
+ }
+ }
}
Result<BatchReader::ReadBatchWithBitmap>
ConcatBatchReader::NextBatchWithBitmap() {
while (current_ < readers_.size()) {
+ // Lookahead: when the file system reads asynchronously, the next
file's first read is paid
+ // while this file is still being consumed, instead of serially after
its EOF.
+ WarmupRange(current_, 1 + kWarmupLookahead);
auto& current_reader = readers_[current_];
PAIMON_ASSIGN_OR_RAISE(BatchReader::ReadBatchWithBitmap result,
current_reader->NextBatchWithBitmap());
diff --git a/src/paimon/common/reader/concat_batch_reader.h
b/src/paimon/common/reader/concat_batch_reader.h
index 2c889d44..031e6187 100644
--- a/src/paimon/common/reader/concat_batch_reader.h
+++ b/src/paimon/common/reader/concat_batch_reader.h
@@ -29,6 +29,7 @@
#include "paimon/result.h"
namespace paimon {
+class FileBatchReader;
class MemoryPool;
/// This reader is to concatenate a list of BatchReaders and read them
sequentially. The input list
@@ -44,11 +45,22 @@ class ConcatBatchReader : public BatchReader {
std::shared_ptr<Metrics> GetReaderMetrics() const override;
private:
+ /// How many child readers ahead of the one being consumed get their first
read started. Each
+ /// warm reader holds its own prefetched data, so one ahead is a
conservative trade-off between
+ /// overlapped I/O and extra memory.
+ static constexpr size_t kWarmupLookahead = 1;
+
+ /// Warms up \p count readers starting at \p idx, stopping at the end of
the list. Warmup() is
+ /// idempotent, so this is safe to call on every batch.
+ void WarmupRange(size_t idx, size_t count);
void CloseAndReleaseReader(size_t reader_index);
std::shared_ptr<arrow::MemoryPool> arrow_pool_;
std::shared_ptr<Metrics> finished_reader_metrics_;
std::vector<std::unique_ptr<BatchReader>> readers_;
+ // readers_[i] as a FileBatchReader, or nullptr when it is not one or has
been released.
+ // Warmup() lives on FileBatchReader, so the cast is done once instead of
on every batch.
+ std::vector<FileBatchReader*> file_readers_;
size_t current_;
};
} // namespace paimon
diff --git a/src/paimon/common/reader/concat_batch_reader_test.cpp
b/src/paimon/common/reader/concat_batch_reader_test.cpp
index 3cc66e08..4d3ff81c 100644
--- a/src/paimon/common/reader/concat_batch_reader_test.cpp
+++ b/src/paimon/common/reader/concat_batch_reader_test.cpp
@@ -98,6 +98,43 @@ class FixedMetricsBatchReader : public BatchReader {
std::shared_ptr<Metrics> metrics_;
};
+namespace {
+/// Logs its Warmup() and NextBatchWithBitmap() calls as "warmup <name>" and
"read <name>" into a
+/// log the test owns, because ConcatBatchReader destroys a child once it
reaches EOF and the
+/// child's own state goes with it.
+class EventLoggingFileBatchReader : public MockFileBatchReader {
+ public:
+ EventLoggingFileBatchReader(const std::shared_ptr<arrow::Array>& data,
const std::string& name,
+ std::vector<std::string>* events)
+ : MockFileBatchReader(data, data->type(), /*read_batch_size=*/1),
+ name_(name),
+ events_(events) {}
+
+ void Warmup() override {
+ events_->push_back("warmup " + name_);
+ MockFileBatchReader::Warmup();
+ }
+
+ Result<ReadBatchWithBitmap> NextBatchWithBitmap() override {
+ events_->push_back("read " + name_);
+ return MockFileBatchReader::NextBatchWithBitmap();
+ }
+
+ private:
+ std::string name_;
+ std::vector<std::string>* events_;
+};
+
+int64_t CountEvent(const std::vector<std::string>& events, const std::string&
event) {
+ return std::count(events.begin(), events.end(), event);
+}
+
+/// Position of the first \p event in \p events, or events.size() when it
never happened.
+size_t FirstEvent(const std::vector<std::string>& events, const std::string&
event) {
+ return static_cast<size_t>(std::find(events.begin(), events.end(), event)
- events.begin());
+}
+} // namespace
+
class ConcatBatchReaderTest : public ::testing::Test {
void SetUp() override {
pool_ = GetDefaultPool();
@@ -283,4 +320,69 @@ TEST_F(ConcatBatchReaderTest,
TestCollectMetricsAfterReleasingReaders) {
ASSERT_EQ(io_count, 5);
}
+TEST_F(ConcatBatchReaderTest, TestWarmupLooksOneReaderAhead) {
+ std::shared_ptr<arrow::DataType> type = arrow::struct_({arrow::field("f1",
arrow::int32())});
+ std::vector<std::string> events;
+ std::vector<std::unique_ptr<BatchReader>> readers;
+ readers.push_back(std::make_unique<EventLoggingFileBatchReader>(
+ arrow::ipc::internal::json::ArrayFromJSON(type, "[[1],
[2]]").ValueOrDie(), "a", &events));
+ readers.push_back(std::make_unique<EventLoggingFileBatchReader>(
+ arrow::ipc::internal::json::ArrayFromJSON(type, "[[3]]").ValueOrDie(),
"b", &events));
+ readers.push_back(std::make_unique<EventLoggingFileBatchReader>(
+ arrow::ipc::internal::json::ArrayFromJSON(type, "[[4]]").ValueOrDie(),
"c", &events));
+ ConcatBatchReader reader(std::move(readers), GetArrowPool(pool_));
+ ASSERT_TRUE(events.empty());
+
+ ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatchWithBitmap batch,
reader.NextBatchWithBitmap());
+ ASSERT_FALSE(BatchReader::IsEofBatch(batch));
+ ReaderUtils::ReleaseReadBatch(std::move(batch.first));
+ ASSERT_LT(FirstEvent(events, "warmup a"), FirstEvent(events, "read a"));
+ ASSERT_LT(FirstEvent(events, "warmup b"), FirstEvent(events, "read a"));
+ ASSERT_EQ(CountEvent(events, "warmup c"), 0);
+
+ // Still inside the first file, so the lookahead does not move on.
+ ASSERT_OK_AND_ASSIGN(batch, reader.NextBatchWithBitmap());
+ ASSERT_FALSE(BatchReader::IsEofBatch(batch));
+ ReaderUtils::ReleaseReadBatch(std::move(batch.first));
+ ASSERT_EQ(CountEvent(events, "warmup c"), 0);
+
+ ASSERT_OK_AND_ASSIGN(batch, reader.NextBatchWithBitmap());
+ ASSERT_FALSE(BatchReader::IsEofBatch(batch));
+ ReaderUtils::ReleaseReadBatch(std::move(batch.first));
+ ASSERT_LT(FirstEvent(events, "warmup c"), FirstEvent(events, "read b"));
+ reader.Close();
+}
+
+TEST_F(ConcatBatchReaderTest, TestWarmupSkipsReaderThatIsNotFileBatchReader) {
+ std::shared_ptr<arrow::DataType> type = arrow::struct_({arrow::field("f1",
arrow::int32())});
+ std::vector<std::string> events;
+ std::vector<std::unique_ptr<BatchReader>> readers;
+ readers.push_back(std::make_unique<EventLoggingFileBatchReader>(
+ arrow::ipc::internal::json::ArrayFromJSON(type, "[[1],
[2]]").ValueOrDie(), "a", &events));
+ readers.push_back(std::make_unique<LifetimeTrackingBatchReader>(
+ std::make_unique<EventLoggingFileBatchReader>(
+ arrow::ipc::internal::json::ArrayFromJSON(type,
"[[3]]").ValueOrDie(), "b", &events),
+ /*lifetime=*/nullptr));
+ readers.push_back(std::make_unique<EventLoggingFileBatchReader>(
+ arrow::ipc::internal::json::ArrayFromJSON(type, "[[4]]").ValueOrDie(),
"c", &events));
+ auto concat_reader =
+ std::make_unique<ConcatBatchReader>(std::move(readers),
GetArrowPool(pool_));
+
+ ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatchWithBitmap batch,
+ concat_reader->NextBatchWithBitmap());
+ ASSERT_FALSE(BatchReader::IsEofBatch(batch));
+ ReaderUtils::ReleaseReadBatch(std::move(batch.first));
+ // The lookahead window counts every child, so it does not reach past the
skipped one.
+ ASSERT_EQ(CountEvent(events, "warmup c"), 0);
+
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> result,
+
ReadResultCollector::CollectResult(concat_reader.get()));
+ std::shared_ptr<arrow::Array> expected =
+ arrow::ipc::internal::json::ArrayFromJSON(type, "[[2], [3],
[4]]").ValueOrDie();
+
ASSERT_TRUE(std::make_shared<arrow::ChunkedArray>(expected)->Equals(result))
+ << result->ToString();
+ ASSERT_EQ(CountEvent(events, "warmup b"), 0);
+ ASSERT_LT(FirstEvent(events, "warmup c"), FirstEvent(events, "read b"));
+}
+
} // namespace paimon::test