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 62cf8c75880 [improvement](be) Read SNII phrase-prefix batches on the 
calling thread when they hit the page cache (#69882)
62cf8c75880 is described below

commit 62cf8c75880d6ea02457eab651293a8b3ca2d20c
Author: Jack <[email protected]>
AuthorDate: Mon Nov 2 14:05:38 2026 +0800

    [improvement](be) Read SNII phrase-prefix batches on the calling thread 
when they hit the page cache (#69882)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69648
    
    Problem Summary: #69648 reads a SNII batch on the calling thread when
    the index file is local, the
    batch has at most `snii_inline_read_max_segments` (4) physical segments
    and 256 KiB, and the
    moving average of recent reads is under 20 us, i.e. they hit the page
    cache. A phrase prefix reads
    the postings of every term its prefix expands to, in batches of 5 to 16
    segments that stay well
    under 256 KiB, so those still went segment by segment through the shared
    buffered-reader prefetch
    pool: on a hot 99M-row table, about a fifth of the SNII time of such a
    query was spent on the
    pool's lock and latch (`ThreadPool::do_submit`, `CountDownLatch::wait`
    in `read_batch`).
    
    The default of `snii_inline_read_max_segments` becomes 16, which covers
    those batches, and two
    changes keep larger inline batches safe when the data is not in the page
    cache:
    - A batch read inline hands the segments it has left to the pool as soon
    as one of its reads is
    slow. When the average is low but this batch's data is not cached, the
    batch pays one read on
    the calling thread and overlaps the rest. A single segment left is still
    read inline.
    - A read counts by how much longer it took than copying its bytes out of
    the page cache (at 4
    bytes/ns, the low end for one thread), for both the average and the slow
    test. A page-cache hit
    of 64-256 KiB spends tens of microseconds on the copy alone, which
    pushed the average over 20 us
      and sent about 5% of a phrase query's small batches to the pool.
    
    On the 99M-row table, the phrase prefix's batches of 5-16 segments are
    now all read inline when
    hot, and the small batches of a phrase query that still went to the pool
    dropped by 77%. In a cold
    run (BE restarted, index files evicted from the page cache), the average
    was at least 40 us
    whenever such a batch was decided, so they all went to the pool, as
    before.
    
    | Query (99M rows) | Hot, new/previous rule | Cold, new/previous rule |
    |---|---|---|
    | phrase prefix 'retry budget dep' | 0.79 / 0.85 / 0.96 (three runs) |
    0.96 |
    | other phrase queries | 0.87-0.95 | 1.01 (A6) |
    | the other 19 queries | 0.98-1.03 | |
    
    Hot: both rules interleaved in one BE process. Cold: BE restarted and
    the index files evicted
    before every run, configuration order alternated, 5 runs each.
---
 be/src/common/config.cpp                           |  9 +--
 .../index/inverted/snii/snii_doris_adapter.cpp     | 83 +++++++++++++++-------
 .../index/inverted/snii/snii_doris_adapter.h       |  3 +
 .../inverted/snii/snii_doris_adapter_test.cpp      | 51 ++++++++++++-
 4 files changed, 114 insertions(+), 32 deletions(-)

diff --git a/be/src/common/config.cpp b/be/src/common/config.cpp
index 7523903515a..233db2cf991 100644
--- a/be/src/common/config.cpp
+++ b/be/src/common/config.cpp
@@ -1439,10 +1439,11 @@ DEFINE_mInt32(snii_prx_zstd_level, "3");
 DEFINE_mInt32(snii_prx_zstd_min_saving_percent, "-1");
 // 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");
+// Handing 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. 16 covers 
the batches of a
+// phrase prefix, which reads the postings of every term the prefix expands to.
+DEFINE_mInt32(snii_inline_read_max_segments, "16");
 // 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/storage/index/inverted/snii/snii_doris_adapter.cpp 
b/be/src/storage/index/inverted/snii/snii_doris_adapter.cpp
index 36a009c85d4..e6ca4ff9b42 100644
--- a/be/src/storage/index/inverted/snii/snii_doris_adapter.cpp
+++ b/be/src/storage/index/inverted/snii/snii_doris_adapter.cpp
@@ -44,18 +44,26 @@ 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.
+// than the reads when they hit it. A read counts by how much longer it took 
than copying its bytes
+// out of the page cache: a hit takes a few microseconds more, a miss on flash 
ten times that or
+// more. The average of that excess (1/8 weight) is shared by every reader. 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, and a batch read inline 
sends the segments it
+// has left to the pool as soon as one of its reads is slow.
 constexpr size_t kInlineMaxBatchBytes = 256 * 1024;
 constexpr int64_t kSlowSmallReadNs = 20 * 1000;
+// The low end of what one thread copies out of the page cache, so a hit is 
not taken for a miss.
+constexpr int64_t kPageCacheBytesPerNs = 4;
 std::atomic<int64_t> g_small_read_ns {0};
+std::atomic<bool> g_slow_read_hand_off {true}; // only tests turn this off
 
-void note_small_read(int64_t ns) {
+// Notes how much longer than copying its `len` bytes a read took; returns 
whether it was slow.
+bool note_read(int64_t ns, size_t len) {
+    const int64_t excess = ns - static_cast<int64_t>(len) / 
kPageCacheBytesPerNs;
     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);
+    g_small_read_ns.store(average + (excess - average) / 8, 
std::memory_order_relaxed);
+    return excess >= kSlowSmallReadNs;
 }
 } // namespace
 
@@ -63,6 +71,10 @@ void DorisSniiFileReader::reset_read_latency_for_test() {
     g_small_read_ns.store(0, std::memory_order_relaxed);
 }
 
+void DorisSniiFileReader::set_slow_read_hand_off_for_test(bool enabled) {
+    g_slow_read_hand_off.store(enabled, std::memory_order_relaxed);
+}
+
 thread_local const io::IOContext* DorisSniiFileReader::_scoped_io_ctx = 
nullptr;
 ThreadPool* DorisSniiFileReader::_io_pool_for_test = nullptr;
 
@@ -271,7 +283,8 @@ Status DorisSniiFileReader::read_batch(const 
std::vector<::doris::snii::io::Rang
         read_bytes += cast_set<int64_t>(seg.len);
     }
 
-    // Run the planned physical reads, concurrently when a pool is available.
+    // Run the planned physical reads, concurrently when a pool is available. 
Returns whether the
+    // read was timed and slow, i.e. probably missed the page cache.
     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.
@@ -279,25 +292,17 @@ Status DorisSniiFileReader::read_batch(const 
std::vector<::doris::snii::io::Rang
         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());
+        if (!timed || !seg_status[s].ok()) {
+            return false;
         }
+        return note_read(std::chrono::duration_cast<std::chrono::nanoseconds>(
+                                 std::chrono::steady_clock::now() - begin)
+                                 .count(),
+                         segs[s].len);
     };
     ThreadPool* pool = _select_io_pool();
-    // 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) {
+    // Reads segments [first, num_segs) on the pool, at most 
kMaxConcurrentReads at a time.
+    auto fan_out = [&](size_t first) {
         // 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 ->
@@ -307,7 +312,7 @@ Status DorisSniiFileReader::read_batch(const 
std::vector<::doris::snii::io::Rang
         // Same shape as the hedged-read path in 
cached_remote_file_reader.cpp:500-505.
         const std::shared_ptr<ResourceContext> parent_resource_ctx =
                 thread_context()->resource_ctx();
-        for (size_t base = 0; base < num_segs; base += kMaxConcurrentReads) {
+        for (size_t base = first; base < num_segs; base += 
kMaxConcurrentReads) {
             const size_t wave_end = std::min(base + kMaxConcurrentReads, 
num_segs);
             ::doris::CountDownLatch latch(cast_set<int>(wave_end - base));
             for (size_t s = base; s < wave_end; ++s) {
@@ -329,6 +334,34 @@ Status DorisSniiFileReader::read_batch(const 
std::vector<::doris::snii::io::Rang
             }
             latch.wait();
         }
+    };
+    // 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;
+    size_t serial_rounds = _compute_num_waves(num_segs);
+    if (inline_read) {
+        // A read on the calling thread is one dependent round per segment. A 
slow one means the
+        // page cache missed although recent reads hit it, so the segments 
left overlap in the pool.
+        size_t next = 0;
+        while (next < num_segs) {
+            const bool slow = run_segment(next++);
+            if (slow && pool != nullptr && num_segs - next > 1 &&
+                g_slow_read_hand_off.load(std::memory_order_relaxed)) {
+                break;
+            }
+        }
+        serial_rounds = next + _compute_num_waves(num_segs - next);
+        if (next < num_segs) {
+            fan_out(next);
+        }
+    } else if (pool != nullptr && num_segs > 1) {
+        fan_out(0);
     } else {
         // Serial fallback: no executor (e.g. tools without ExecEnv) or a 
single
         // segment (avoids micro-batch scheduling overhead).
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 52c80ad4810..c9b6d1359e9 100644
--- a/be/src/storage/index/inverted/snii/snii_doris_adapter.h
+++ b/be/src/storage/index/inverted/snii/snii_doris_adapter.h
@@ -89,6 +89,9 @@ public:
     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();
+    // Test-only: whether a slow read sends the rest of an inline batch to the 
pool. Any read can be
+    // slow on a busy host, so a test that a batch stays on the calling thread 
turns this off.
+    static void set_slow_read_hand_off_for_test(bool enabled);
 
 private:
     static io::IOContext _make_index_io_context(const io::IOContext* io_ctx);
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 451a32c1864..1731059d379 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
@@ -1017,6 +1017,15 @@ private:
     int32_t _saved;
 };
 
+// For a test that a batch stays on the calling thread: on a busy host even a 
read from memory can
+// take longer than the slow-read threshold, which would send the rest of the 
batch to the pool.
+struct NoSlowReadHandOffGuard {
+    NoSlowReadHandOffGuard() { 
DorisSniiFileReader::set_slow_read_hand_off_for_test(false); }
+    ~NoSlowReadHandOffGuard() { 
DorisSniiFileReader::set_slow_read_hand_off_for_test(true); }
+    NoSlowReadHandOffGuard(const NoSlowReadHandOffGuard&) = delete;
+    NoSlowReadHandOffGuard& operator=(const NoSlowReadHandOffGuard&) = delete;
+};
+
 // 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(
@@ -1062,6 +1071,7 @@ size_t reads_on_calling_thread(
 // pool costs more than the reads. The serial rounds then equal the segment 
count.
 TEST(DorisSniiFileReaderInlineReadTest, SmallLocalBatchRunsOnCallingThread) {
     InlineReadConfigGuard config_guard(4);
+    NoSlowReadHandOffGuard no_hand_off;
     io::FileCacheStatistics stats;
     EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 4, &stats), 4U);
     EXPECT_EQ(stats.inverted_index_range_read_count, 4);
@@ -1086,8 +1096,8 @@ TEST(DorisSniiFileReaderInlineReadTest, 
RemoteSmallBatchStillFansOut) {
 }
 
 // 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.
+// fans out again: the first batch starts inline, while the average is still 
low, but sends the
+// segments it has left to the pool at its first slow read; the later ones fan 
out from the start.
 TEST(DorisSniiFileReaderInlineReadTest, SlowReadsReturnToThePool) {
     InlineReadConfigGuard config_guard(4);
     size_t on_caller_first = 0;
@@ -1101,14 +1111,49 @@ TEST(DorisSniiFileReaderInlineReadTest, 
SlowReadsReturnToThePool) {
         }
         on_caller_last = on_caller;
     }
-    EXPECT_EQ(on_caller_first, 4U);
+    EXPECT_EQ(on_caller_first, 1U);
     EXPECT_EQ(on_caller_last, 0U);
 }
 
+// A phrase prefix reads the postings of every term its prefix expands to, in 
batches of up to 16
+// segments; while reads are fast those stay on the calling thread too. One 
more fans out.
+TEST(DorisSniiFileReaderInlineReadTest, 
PhrasePrefixSizedBatchRunsOnCallingThread) {
+    InlineReadConfigGuard config_guard(16);
+    NoSlowReadHandOffGuard no_hand_off;
+    io::FileCacheStatistics stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 16, &stats), 16U);
+    EXPECT_EQ(stats.inverted_index_serial_read_rounds, 16);
+    io::FileCacheStatistics over_stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 17, &over_stats), 0U);
+    EXPECT_EQ(over_stats.inverted_index_serial_read_rounds, 2);
+}
+
+// The average can be low while this batch's data is not in the page cache. 
Its first slow read then
+// sends the segments it has left to the pool, so the batch pays one read on 
the calling thread and
+// overlaps the rest: one inline round plus one pool wave.
+TEST(DorisSniiFileReaderInlineReadTest, SlowReadSendsTheSegmentsLeftToThePool) 
{
+    InlineReadConfigGuard config_guard(16);
+    io::FileCacheStatistics stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 8, &stats, 
std::chrono::microseconds(300)),
+              1U);
+    EXPECT_EQ(stats.inverted_index_range_read_count, 8);
+    EXPECT_EQ(stats.inverted_index_serial_read_rounds, 2);
+}
+
+// One segment left after a slow read is read on the calling thread: alone it 
overlaps nothing.
+TEST(DorisSniiFileReaderInlineReadTest, LastSegmentAfterSlowReadStaysInline) {
+    InlineReadConfigGuard config_guard(16);
+    io::FileCacheStatistics stats;
+    EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/true, 2, &stats, 
std::chrono::microseconds(300)),
+              2U);
+    EXPECT_EQ(stats.inverted_index_serial_read_rounds, 2);
+}
+
 // 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);
+    NoSlowReadHandOffGuard no_hand_off;
     for (int batch = 0; batch < 12; ++batch) {
         io::FileCacheStatistics remote_stats;
         EXPECT_EQ(reads_on_calling_thread(/*local_fs=*/false, 4, &remote_stats,


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

Reply via email to