This is an automated email from the ASF dual-hosted git repository.

airborne12 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new a47d4613d2d [improvement](be) Read small SNII batches on the calling 
thread when reads hit the page cache (#69648)
a47d4613d2d is described below

commit a47d4613d2d475efb58a189fd8d43f4431ca642a
Author: Jack <[email protected]>
AuthorDate: Wed Oct 28 18:40:38 2026 +0800

    [improvement](be) Read small SNII batches on the calling thread when reads 
hit the page cache (#69648)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: None
    
    Problem Summary: DorisSniiFileReader::read_batch submits every physical
    segment of a batch with
    more than one segment to the shared buffered-reader prefetch thread pool
    and waits on a latch.
    When the index file is in the page cache the reads take microseconds and
    99% of the batches of a
    phrase workload are under 256 KB, so the submit, wake-up and wait cost
    more than the reads, and a
    query that issues thousands of batches from 16 scanner threads queues on
    the pool's lock. On 99M
    AgentLogs rows, 83% of the threads inside the SNII query path were
    blocked in
    ThreadPool::do_submit for the date-range phrase query.
    
    A batch of at most config::snii_inline_read_max_segments (default 4, 0
    disables) segments and
    256 KiB is now read on the calling thread when the index file is on the
    local filesystem and the
    moving average of recent small physical reads is below 20 us. Remote and
    cached-remote readers,
    larger batches and slow (page-cache miss) reads still fan out to the
    pool, because overlapping
    disk reads gain from it: with a fixed rule instead of the average, the
    cold run of two phrase
    queries was 4-8% slower. The serial read rounds counter counts one round
    per segment for a batch
    read inline.
    
    Measured on 99M AgentLogs rows (branch-4.2 build; the changed code is
    the same on master), same process, 25 queries, inline off against on, 16
    samples each:
    date-range phrase query 0.39x, any-of-three-phrases top-N 0.70x, no
    query above 1.02x. Cold runs
    (restart, page cache evicted, three repetitions) of eight queries are
    within 2% of the pool.
---
 be/src/common/config.cpp                           |   4 +
 be/src/common/config.h                             |   4 +
 be/src/storage/index/index_file_reader.cpp         |   3 +-
 .../index/inverted/snii/snii_doris_adapter.cpp     |  54 ++++++-
 .../index/inverted/snii/snii_doris_adapter.h       |   8 +-
 .../inverted/snii/snii_doris_adapter_test.cpp      | 161 +++++++++++++++++++++
 6 files changed, 227 insertions(+), 7 deletions(-)

diff --git a/be/src/common/config.cpp b/be/src/common/config.cpp
index b8cf6e3273e..fad0547b0ee 100644
--- a/be/src/common/config.cpp
+++ b/be/src/common/config.cpp
@@ -1434,6 +1434,10 @@ DEFINE_mInt32(snii_prx_zstd_level, "3");
 DEFINE_mInt32(snii_prx_zstd_min_saving_percent, "30");
 // Zstd level for direct-load .prx regions. Compaction rewrites them using 
snii_prx_zstd_level.
 DEFINE_mInt32(snii_prx_zstd_level_direct_load, "3");
+// Handing a few small page-cache reads to the shared prefetch pool costs more 
than the reads: a
+// query that issues thousands of them queues on the pool lock. Remote, larger 
and slow (cold)
+// batches still fan out, so overlapping disk reads keep their parallelism.
+DEFINE_mInt32(snii_inline_read_max_segments, "4");
 // G16-d: target SNII dict block size in bytes; 0 uses the format default
 // (64 KiB). Larger blocks compress better under the per-block zstd (the dict
 // is the dominant physical section on high-cardinality corpora) at the cost
diff --git a/be/src/common/config.h b/be/src/common/config.h
index 632cd8fd404..997d7d39fb6 100644
--- a/be/src/common/config.h
+++ b/be/src/common/config.h
@@ -1473,6 +1473,10 @@ DECLARE_mInt32(snii_prx_zstd_min_saving_percent);
 // import); compaction rewrites at snii_prx_zstd_level so settled segments are
 // unaffected. Full contract at the DEFINE in config.cpp.
 DECLARE_mInt32(snii_prx_zstd_level_direct_load);
+// A SNII batch read of at most this many physical segments (and 256 KiB) is 
read on the calling
+// thread when the index file is on the local filesystem and recent small 
reads were fast, i.e. hit
+// the page cache. 0 always fans out to the buffered-reader prefetch pool.
+DECLARE_mInt32(snii_inline_read_max_segments);
 // G16-d: target SNII dict block size in bytes; 0 = format default (64 KiB).
 // Bigger blocks -> better per-block zstd on the dict region, larger cold
 // fetch+decompress unit per dict-block miss. Write side only.
diff --git a/be/src/storage/index/index_file_reader.cpp 
b/be/src/storage/index/index_file_reader.cpp
index ad3f19d3d97..12ddda2711e 100644
--- a/be/src/storage/index/index_file_reader.cpp
+++ b/be/src/storage/index/index_file_reader.cpp
@@ -178,7 +178,8 @@ Status IndexFileReader::_init_snii(const io::IOContext* 
io_ctx) {
     const bool direct_remote_io = opts.cache_type == 
io::FileCachePolicy::NO_CACHE &&
                                   _fs->type() != io::FileSystemType::LOCAL;
     _snii_file_reader = std::make_shared<snii_doris::DorisSniiFileReader>(
-            std::move(reader), /*io_ctx=*/nullptr, direct_remote_io);
+            std::move(reader), /*io_ctx=*/nullptr, direct_remote_io,
+            /*local_fs=*/_fs->type() == io::FileSystemType::LOCAL);
     _snii_segment_reader = 
std::make_unique<doris::snii::reader::SniiSegmentReader>();
     io::IOContext meta_io_ctx;
     if (io_ctx != nullptr) {
diff --git a/be/src/storage/index/inverted/snii/snii_doris_adapter.cpp 
b/be/src/storage/index/inverted/snii/snii_doris_adapter.cpp
index d819044024c..36a009c85d4 100644
--- a/be/src/storage/index/inverted/snii/snii_doris_adapter.cpp
+++ b/be/src/storage/index/inverted/snii/snii_doris_adapter.cpp
@@ -20,11 +20,14 @@
 #include <fmt/format.h>
 
 #include <algorithm>
+#include <atomic>
+#include <chrono>
 #include <cstddef>
 #include <limits>
 #include <vector>
 
 #include "common/cast_set.h"
+#include "common/config.h"
 #include "cpp/sync_point.h"
 #include "runtime/exec_env.h"
 #include "runtime/thread_context.h"
@@ -38,8 +41,28 @@ namespace {
 // (mirrors the "at most one serial round" contract that the test-side
 // MeteredFileReader measures, and the S3 standalone reader's 16-way fan-out).
 constexpr size_t kMaxConcurrentReads = 16;
+
+// A batch is read on the calling thread only if it is small and reads have 
been fast lately. The
+// pool pays off when reads miss the page cache, because a few of them then 
overlap; it costs more
+// than the reads when they hit it. The average is of the duration of one 
small physical read
+// (1/8 weight), shared by every reader: a page-cache hit takes a few 
microseconds, a miss on flash
+// ten times that or more. On NVMe, cold phrase queries ran 4-8% slower with 
the threshold at 40 us
+// and level with the pool at 25 us and below, while the warm gain held from 
15 us up. It starts at
+// zero, so a cold start reads its first batches inline until the average has 
caught up.
+constexpr size_t kInlineMaxBatchBytes = 256 * 1024;
+constexpr int64_t kSlowSmallReadNs = 20 * 1000;
+std::atomic<int64_t> g_small_read_ns {0};
+
+void note_small_read(int64_t ns) {
+    const int64_t average = g_small_read_ns.load(std::memory_order_relaxed);
+    g_small_read_ns.store(average + (ns - average) / 8, 
std::memory_order_relaxed);
+}
 } // namespace
 
+void DorisSniiFileReader::reset_read_latency_for_test() {
+    g_small_read_ns.store(0, std::memory_order_relaxed);
+}
+
 thread_local const io::IOContext* DorisSniiFileReader::_scoped_io_ctx = 
nullptr;
 ThreadPool* DorisSniiFileReader::_io_pool_for_test = nullptr;
 
@@ -62,9 +85,10 @@ uint64_t DorisSniiFileWriter::bytes_written() const {
 }
 
 DorisSniiFileReader::DorisSniiFileReader(io::FileReaderSPtr reader, const 
io::IOContext* io_ctx,
-                                         bool direct_remote_io)
+                                         bool direct_remote_io, bool local_fs)
         : _reader(std::move(reader)),
           _direct_remote_io(direct_remote_io),
+          _local_fs(local_fs),
           _default_io_ctx(_make_index_io_context(io_ctx)) {}
 
 io::IOContext DorisSniiFileReader::_make_index_io_context(const io::IOContext* 
io_ctx) {
@@ -249,10 +273,31 @@ Status DorisSniiFileReader::read_batch(const 
std::vector<::doris::snii::io::Rang
 
     // Run the planned physical reads, concurrently when a pool is available.
     auto run_segment = [&](size_t s) {
+        // Only local reads are timed: the average decides about local 
readers, and a remote or
+        // cached-remote read takes a millisecond that says nothing about the 
page cache.
+        const bool timed = _local_fs && segs[s].len <= kInlineMaxBatchBytes;
+        const auto begin =
+                timed ? std::chrono::steady_clock::now() : 
std::chrono::steady_clock::time_point {};
         seg_status[s] = _read_at(segs[s].offset, segs[s].len, 
&outs->buffers[s], &seg_io_ctx[s]);
+        if (timed && seg_status[s].ok()) {
+            
note_small_read(std::chrono::duration_cast<std::chrono::nanoseconds>(
+                                    std::chrono::steady_clock::now() - begin)
+                                    .count());
+        }
     };
     ThreadPool* pool = _select_io_pool();
-    if (pool != nullptr && num_segs > 1) {
+    // Submitting to the shared pool and waiting costs far more than a small 
read from the page
+    // cache, and a query that issues thousands of such batches queues on the 
pool's lock. Only
+    // local files take this path: a remote or cached-remote read is slow 
enough that fanning out
+    // pays, and so is a local read that misses the page cache.
+    const int32_t inline_max_segments = config::snii_inline_read_max_segments;
+    const bool inline_read = _local_fs && inline_max_segments > 0 &&
+                             num_segs <= 
static_cast<size_t>(inline_max_segments) &&
+                             read_bytes <= 
static_cast<int64_t>(kInlineMaxBatchBytes) &&
+                             g_small_read_ns.load(std::memory_order_relaxed) < 
kSlowSmallReadNs;
+    // A read on the calling thread is one dependent round per segment.
+    const size_t serial_rounds = inline_read ? num_segs : 
_compute_num_waves(num_segs);
+    if (pool != nullptr && num_segs > 1 && !inline_read) {
         // Carried onto the pool threads below. They are "Orphan" threads with 
no MemTrackerLimiter
         // of their own (buffered_reader.cpp:426), and the read does allocate 
down there: in cloud
         // mode _read_at reaches CachedRemoteFileReader::read_at_impl ->
@@ -317,8 +362,7 @@ Status DorisSniiFileReader::read_batch(const 
std::vector<::doris::snii::io::Rang
         // Record only what actually completed so the logical counters stay
         // truthful for the failed batch; ranges/rounds reflect what was 
issued.
         _record_read_stats(completed_request_bytes, completed_read_bytes,
-                           cast_set<int64_t>(num_segs),
-                           cast_set<int64_t>(_compute_num_waves(num_segs)));
+                           cast_set<int64_t>(num_segs), 
cast_set<int64_t>(serial_rounds));
         return first_error;
     }
     for (size_t s = 0; s < num_segs; ++s) {
@@ -330,7 +374,7 @@ Status DorisSniiFileReader::read_batch(const 
std::vector<::doris::snii::io::Rang
         }
     }
     _record_read_stats(request_bytes, read_bytes, cast_set<int64_t>(num_segs),
-                       cast_set<int64_t>(_compute_num_waves(num_segs)));
+                       cast_set<int64_t>(serial_rounds));
     return Status::OK();
 }
 // NOLINTEND(readability-non-const-parameter)
diff --git a/be/src/storage/index/inverted/snii/snii_doris_adapter.h 
b/be/src/storage/index/inverted/snii/snii_doris_adapter.h
index bb6f2c13dc6..52c80ad4810 100644
--- a/be/src/storage/index/inverted/snii/snii_doris_adapter.h
+++ b/be/src/storage/index/inverted/snii/snii_doris_adapter.h
@@ -67,8 +67,11 @@ public:
     // account physical remote bytes, so this reader then counts its own reads 
as
     // physical remote IO. Never set it for cached or local readers: the former
     // double-counts, the latter reports local disk as remote fetch volume.
+    // `local_fs` marks a reader over the local filesystem. A small batch read 
from it runs on the
+    // calling thread while reads are fast 
(config::snii_inline_read_max_segments); remote readers
+    // always fan out.
     explicit DorisSniiFileReader(io::FileReaderSPtr reader, const 
io::IOContext* io_ctx = nullptr,
-                                 bool direct_remote_io = false);
+                                 bool direct_remote_io = false, bool local_fs 
= false);
 
     Status read_at(uint64_t offset, size_t len, std::vector<uint8_t>* out) 
override;
     // Fills the caller's buffer directly (no vector round-trip) under the same
@@ -84,6 +87,8 @@ public:
     // batch segment reads. nullptr (default) routes to the BE buffered-reader
     // prefetch pool when ExecEnv is ready, otherwise a serial fallback.
     static void set_io_thread_pool_for_test(ThreadPool* pool);
+    // Test-only: forget the measured read latency, so the next batch is 
judged afresh.
+    static void reset_read_latency_for_test();
 
 private:
     static io::IOContext _make_index_io_context(const io::IOContext* io_ctx);
@@ -110,6 +115,7 @@ private:
 
     io::FileReaderSPtr _reader;
     const bool _direct_remote_io = false;
+    const bool _local_fs = false;
     io::IOContext _default_io_ctx;
     static thread_local const io::IOContext* _scoped_io_ctx;
     // Test seam for the batch-read executor; see set_io_thread_pool_for_test.
diff --git a/be/test/storage/index/inverted/snii/snii_doris_adapter_test.cpp 
b/be/test/storage/index/inverted/snii/snii_doris_adapter_test.cpp
index 4938ee04d54..451a32c1864 100644
--- a/be/test/storage/index/inverted/snii/snii_doris_adapter_test.cpp
+++ b/be/test/storage/index/inverted/snii/snii_doris_adapter_test.cpp
@@ -34,6 +34,7 @@
 #include <utility>
 #include <vector>
 
+#include "common/config.h"
 #include "common/status.h"
 #include "io/fs/file_reader.h"
 #include "io/fs/local_file_system.h"
@@ -100,6 +101,9 @@ public:
 
     void xor_byte(size_t offset, uint8_t mask) { _data[offset] ^= 
static_cast<char>(mask); }
 
+    // Makes every read take at least `delay`, like a page-cache miss.
+    void set_read_delay(std::chrono::microseconds delay) { _read_delay = 
delay; }
+
     void block_once_on_range_containing(size_t offset) {
         std::lock_guard<std::mutex> guard(_control_mu);
         _block_offset = offset;
@@ -161,6 +165,9 @@ protected:
             }
         }
 
+        if (_read_delay.count() > 0) {
+            std::this_thread::sleep_for(_read_delay);
+        }
         if (result.size > 0) {
             std::memcpy(result.data, _data.data() + offset, result.size);
         }
@@ -204,6 +211,7 @@ private:
     mutable std::mutex _control_mu;
     std::condition_variable _control_cv;
     size_t _block_offset = std::numeric_limits<size_t>::max();
+    std::chrono::microseconds _read_delay {0};
     bool _block_armed = false;
     bool _blocked = false;
     bool _released = false;
@@ -991,4 +999,157 @@ TEST(DorisSniiFileReaderConcurrencyTest, 
ParallelPathMatchesSerialPath) {
     }
 }
 
+// Sets config::snii_inline_read_max_segments and clears the shared 
read-latency average; both are
+// restored on exit so a test never leaks them into its siblings.
+struct InlineReadConfigGuard {
+    explicit InlineReadConfigGuard(int32_t value) : 
_saved(config::snii_inline_read_max_segments) {
+        config::snii_inline_read_max_segments = value;
+        DorisSniiFileReader::reset_read_latency_for_test();
+    }
+    ~InlineReadConfigGuard() {
+        config::snii_inline_read_max_segments = _saved;
+        DorisSniiFileReader::reset_read_latency_for_test();
+    }
+    InlineReadConfigGuard(const InlineReadConfigGuard&) = delete;
+    InlineReadConfigGuard& operator=(const InlineReadConfigGuard&) = delete;
+
+private:
+    int32_t _saved;
+};
+
+// Reads `segments` disjoint 4-byte ranges through a reader backed by a 
two-thread pool and returns
+// how many of the physical reads ran on the calling thread.
+size_t reads_on_calling_thread(
+        bool local_fs, size_t segments, io::FileCacheStatistics* stats,
+        std::chrono::microseconds read_delay = std::chrono::microseconds(0)) {
+    const std::string data = make_pattern(segments * 8192 + 16);
+    auto recording_reader = std::make_shared<RecordingFileReader>(data);
+    recording_reader->set_read_delay(read_delay);
+    DorisSniiFileReader reader(recording_reader, /*io_ctx=*/nullptr, 
/*direct_remote_io=*/false,
+                               local_fs);
+
+    std::unique_ptr<ThreadPool> pool;
+    auto pool_st =
+            
ThreadPoolBuilder("snii_batch_test").set_min_threads(2).set_max_threads(4).build(&pool);
+    EXPECT_TRUE(pool_st.ok()) << pool_st.to_string();
+    IoPoolSeamGuard seam(pool.get());
+
+    io::IOContext io_ctx;
+    io_ctx.file_cache_stats = stats;
+    std::vector<::doris::snii::io::Range> ranges;
+    for (size_t i = 0; i < segments; ++i) {
+        ranges.push_back({static_cast<uint64_t>(i) * 8192, 4});
+    }
+    index_query::IoReadResult outs;
+    {
+        DorisSniiFileReader::ScopedIOContext scope(&io_ctx);
+        auto status = reader.read_batch(ranges, &outs);
+        EXPECT_TRUE(status.ok()) << status.to_string();
+    }
+    EXPECT_EQ(outs.views.size(), segments);
+    for (size_t i = 0; i < segments && i < outs.views.size(); ++i) {
+        EXPECT_EQ(as_string(outs.views[i]), data.substr(i * 8192, 4));
+    }
+    EXPECT_EQ(recording_reader->reads().size(), segments);
+    size_t on_caller = 0;
+    for (const CapturedRead& read : recording_reader->reads()) {
+        on_caller += read.thread_id == std::this_thread::get_id() ? 1 : 0;
+    }
+    return on_caller;
+}
+
+// A small batch on the local filesystem is read by the calling thread: 
handing it to the shared
+// pool costs more than the reads. The serial rounds then equal the segment 
count.
+TEST(DorisSniiFileReaderInlineReadTest, SmallLocalBatchRunsOnCallingThread) {
+    InlineReadConfigGuard config_guard(4);
+    io::FileCacheStatistics stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 4, &stats), 4U);
+    EXPECT_EQ(stats.inverted_index_range_read_count, 4);
+    EXPECT_EQ(stats.inverted_index_serial_read_rounds, 4);
+}
+
+// Above the segment limit the batch still fans out to the pool.
+TEST(DorisSniiFileReaderInlineReadTest, LargeLocalBatchStillFansOut) {
+    InlineReadConfigGuard config_guard(4);
+    io::FileCacheStatistics stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 8, &stats), 0U);
+    EXPECT_EQ(stats.inverted_index_range_read_count, 8);
+    EXPECT_EQ(stats.inverted_index_serial_read_rounds, 1);
+}
+
+// A remote or cached-remote reader always fans out, whatever the batch size.
+TEST(DorisSniiFileReaderInlineReadTest, RemoteSmallBatchStillFansOut) {
+    InlineReadConfigGuard config_guard(4);
+    io::FileCacheStatistics stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/false, 4, &stats), 0U);
+    EXPECT_EQ(stats.inverted_index_serial_read_rounds, 1);
+}
+
+// Reads that miss the page cache overlap in the pool, so once recent reads 
were slow a small batch
+// fans out again: the first batches are read inline while the average catches 
up, the later ones
+// are not.
+TEST(DorisSniiFileReaderInlineReadTest, SlowReadsReturnToThePool) {
+    InlineReadConfigGuard config_guard(4);
+    size_t on_caller_first = 0;
+    size_t on_caller_last = 0;
+    for (int batch = 0; batch < 12; ++batch) {
+        io::FileCacheStatistics stats;
+        const size_t on_caller = reads_on_calling_thread(/*local_fs=*/true, 4, 
&stats,
+                                                         
std::chrono::microseconds(300));
+        if (batch == 0) {
+            on_caller_first = on_caller;
+        }
+        on_caller_last = on_caller;
+    }
+    EXPECT_EQ(on_caller_first, 4U);
+    EXPECT_EQ(on_caller_last, 0U);
+}
+
+// Slow remote reads must not lower the local reads' chance of being read 
inline: with tiered
+// storage one BE serves local and remote rowsets, and only local readers 
consult the average.
+TEST(DorisSniiFileReaderInlineReadTest, RemoteReadsDoNotSlowTheLocalEstimate) {
+    InlineReadConfigGuard config_guard(4);
+    for (int batch = 0; batch < 12; ++batch) {
+        io::FileCacheStatistics remote_stats;
+        EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/false, 4, &remote_stats,
+                                          std::chrono::microseconds(1000)),
+                  0U);
+    }
+    io::FileCacheStatistics local_stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 4, &local_stats), 4U);
+}
+
+// A batch above the byte limit fans out even when reads are fast.
+TEST(DorisSniiFileReaderInlineReadTest, LargeBytesBatchStillFansOut) {
+    InlineReadConfigGuard config_guard(4);
+    const std::string data = make_pattern(2 << 20);
+    auto recording_reader = std::make_shared<RecordingFileReader>(data);
+    DorisSniiFileReader reader(recording_reader, nullptr, false, 
/*local_fs=*/true);
+    std::unique_ptr<ThreadPool> pool;
+    ASSERT_TRUE(ThreadPoolBuilder("snii_batch_test")
+                        .set_min_threads(2)
+                        .set_max_threads(4)
+                        .build(&pool)
+                        .ok());
+    IoPoolSeamGuard seam(pool.get());
+    // Two disjoint 300 KiB ranges, so they do not coalesce and the batch 
exceeds 256 KiB.
+    std::vector<::doris::snii::io::Range> ranges {{0, 300 * 1024}, {1 << 20, 
300 * 1024}};
+    index_query::IoReadResult outs;
+    ASSERT_TRUE(reader.read_batch(ranges, &outs).ok());
+    size_t on_caller = 0;
+    for (const CapturedRead& read : recording_reader->reads()) {
+        on_caller += read.thread_id == std::this_thread::get_id() ? 1 : 0;
+    }
+    EXPECT_EQ(recording_reader->reads().size(), 2U);
+    EXPECT_EQ(on_caller, 0U);
+}
+
+// Zero turns the shortcut off and restores the previous behaviour for local 
files.
+TEST(DorisSniiFileReaderInlineReadTest, ZeroDisablesInlineRead) {
+    InlineReadConfigGuard config_guard(0);
+    io::FileCacheStatistics stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 4, &stats), 0U);
+    EXPECT_EQ(stats.inverted_index_serial_read_rounds, 1);
+}
+
 } // namespace doris::segment_v2::snii_doris


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to