Skip to content

Commit 87407c5

Browse files
csun5285claude
andcommitted
[improvement](scan) Align scanner split to segments for MATCH
`SegmentIterator::_lazy_init` runs the inverted index query over the whole segment first and only then intersects the result with `_opts.row_ranges`. The cost of a MATCH predicate is therefore priced per segment, while `ParallelScannerBuilder` splits by rows. When a segment ends up shared by two scanners, its posting lists are walked once per scanner. Add `_build_scanners_by_match_expr`, which collects whole segments and closes a split once `_rows_per_scanner` is reached, so split boundaries only fall between segments. A segment larger than the threshold takes a scanner of its own. `segment_row_ranges` is left empty so the downstream reader scans whole segments instead of intersecting a range covering the entire segment. The path is taken only for DUP/MOW tables in cloud mode when the expressions pushed down to the storage layer contain MATCH; `_build_scanners_by_rowid` and `_build_scanners_by_per_segment` are unchanged, and a query without MATCH behaves exactly as before. The `tablet_hotspot().count()` call shared by all three strategies is hoisted into `build_scanners()`. Measured on a cloud cluster, 20M rows in one tablet over 8 segments, cold query (file cache cleared and BE restarted between runs), 3 runs each: before after NumScanners 10 5 InvertedIndexQueryTime 5299ms 2576ms -51% ScannerCpuTime 5408ms 2796ms -48% InvertedIndexSearcherOpenTime 144ms 58ms -60% ScannerGetBlockTime 1529ms 1761ms +15% Total 931ms 821ms This trades scan parallelism for total CPU: the number of scanners drops, so `ScannerGetBlockTime` grows, while the index query work roughly halves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VgV4Zy5NaEYpUP4Yrh1yWn
1 parent 2573820 commit 87407c5

4 files changed

Lines changed: 129 additions & 10 deletions

File tree

be/src/exec/operator/olap_scan_operator.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,22 @@ static bool contains_expr_node_type(const VExprSPtr& expr, TExprNodeType::type n
460460
});
461461
}
462462

463+
// Find MATCH recursively; ones nested in AND / OR / NOT count too.
464+
static bool is_match_expr(const VExprSPtr& expr) {
465+
if (expr == nullptr) {
466+
return false;
467+
}
468+
if (expr->node_type() == TExprNodeType::MATCH_PRED ||
469+
expr->node_type() == TExprNodeType::SEARCH_EXPR || expr->can_push_down_to_index()) {
470+
return true;
471+
}
472+
if (is_match_expr(expr->get_impl())) {
473+
return true;
474+
}
475+
return std::ranges::any_of(expr->children(),
476+
[](const auto& child) { return is_match_expr(child); });
477+
}
478+
463479
static Status validate_residual_scan_conjuncts(RuntimeState* state,
464480
TPushAggOp::type push_down_agg_type,
465481
const VExprContextSPtrs& conjuncts) {
@@ -635,6 +651,13 @@ bool OlapScanLocalState::_is_binlog_merge_scan() const {
635651
return scan_type == TBinlogScanType::MIN_DELTA || scan_type == TBinlogScanType::DETAIL;
636652
}
637653

654+
bool OlapScanLocalState::_has_match_in_expr() const {
655+
// Look for MATCH in the expressions pushed down to the storage layer.
656+
return std::ranges::any_of(_common_expr_ctxs_push_down, [](const auto& ctx) {
657+
return ctx != nullptr && is_match_expr(ctx->root());
658+
});
659+
}
660+
638661
Status OlapScanLocalState::_init_scanners(std::list<ScannerSPtr>* scanners) {
639662
if (_scan_ranges.empty()) {
640663
_eos = true;
@@ -764,6 +787,7 @@ Status OlapScanLocalState::_init_scanners(std::list<ScannerSPtr>* scanners) {
764787
std::max<int64_t>(1024, state()->parallel_scan_min_rows_per_scanner());
765788
scanner_builder.set_max_scanners_count(max_scanners_count);
766789
scanner_builder.set_min_rows_per_scanner(min_rows_per_scanner);
790+
scanner_builder.set_has_match_in_expr(_has_match_in_expr());
767791
// If the session variable is set, force one scanner per segment.
768792
if (state()->query_options().__isset.optimize_index_scan_parallelism &&
769793
state()->query_options().optimize_index_scan_parallelism) {

be/src/exec/operator/olap_scan_operator.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ class OlapScanLocalState final : public ScanLocalState<OlapScanLocalState> {
135135

136136
Status _init_scanners(std::list<ScannerSPtr>* scanners) override;
137137

138+
// Whether the expressions pushed down to the storage layer contain MATCH. If so, the scan
139+
// cost is priced per segment, and the split no longer cuts a segment apart.
140+
bool _has_match_in_expr() const;
141+
138142
Status _build_key_ranges_and_filters();
139143

140144
bool _is_tablet_pruned_by_runtime_filter(int64_t partition_id, int32_t bucket_seq,

be/src/exec/scan/parallel_scanner_builder.cpp

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,20 @@ io::IOContext create_preload_io_context(RuntimeState* state, OlapReaderStatistic
6666

6767
Status ParallelScannerBuilder::build_scanners(std::list<ScannerSPtr>& scanners) {
6868
RETURN_IF_ERROR(_load());
69+
70+
if (config::is_cloud_mode()) {
71+
for (const auto& tablet_with_version : _tablets) {
72+
// FIXME(plat1ko): Avoid pointer cast
73+
ExecEnv::GetInstance()->storage_engine().to_cloud().tablet_hotspot().count(
74+
*tablet_with_version.tablet);
75+
}
76+
}
77+
6978
if (_scan_parallelism_by_per_segment) {
7079
return _build_scanners_by_per_segment(scanners);
80+
} else if (_is_dup_mow_key && config::is_cloud_mode() && _has_match_in_expr) {
81+
// MATCH is priced per segment; cutting a segment apart runs its index query several times
82+
return _build_scanners_by_match_expr(scanners);
7183
} else if (_is_dup_mow_key) {
7284
// Default strategy for DUP/MOW tables: split by rowids within segments
7385
return _build_scanners_by_rowid(scanners);
@@ -86,11 +98,6 @@ Status ParallelScannerBuilder::_build_scanners_by_rowid(std::list<ScannerSPtr>&
8698
DCHECK(_all_read_sources.contains(tablet->tablet_id()));
8799
auto& entire_read_source = _all_read_sources[tablet->tablet_id()];
88100

89-
if (config::is_cloud_mode()) {
90-
// FIXME(plat1ko): Avoid pointer cast
91-
ExecEnv::GetInstance()->storage_engine().to_cloud().tablet_hotspot().count(*tablet);
92-
}
93-
94101
// `rs_splits` in `entire read source` will be devided into several partitial read sources
95102
// to build several parallel scanners, based on segment rows number. All the partitial read sources
96103
// share the same delete predicates from their corresponding entire read source.
@@ -203,6 +210,88 @@ Status ParallelScannerBuilder::_build_scanners_by_rowid(std::list<ScannerSPtr>&
203210
return Status::OK();
204211
}
205212

213+
// Split boundaries only fall between segments: collect whole segments, and close the split once
214+
// _rows_per_scanner is reached. A large segment exceeds the threshold as soon as it comes in, so it
215+
// takes a scanner of its own.
216+
//
217+
// Example, rows_per_scanner = 500, segment rows in units of 10k:
218+
//
219+
// rs0=[100] rs1=[1200] rs2=[100, 1200, 100] rs3=[200]
220+
//
221+
// Scanner#1 = rs0[0,1) + rs1[0,1) 1300
222+
// Scanner#2 = rs2[0,2) 1300
223+
// Scanner#3 = rs2[2,3) + rs3[0,1) 300
224+
Status ParallelScannerBuilder::_build_scanners_by_match_expr(std::list<ScannerSPtr>& scanners) {
225+
DCHECK_GE(_rows_per_scanner, _min_rows_per_scanner);
226+
const auto rows_per_scanner = static_cast<int64_t>(_rows_per_scanner);
227+
228+
for (size_t tablet_idx = 0; tablet_idx < _tablets.size(); ++tablet_idx) {
229+
auto&& [tablet, version] = _tablets[tablet_idx];
230+
const auto& scan_range = *_scan_ranges[tablet_idx];
231+
DCHECK(_all_read_sources.contains(tablet->tablet_id()));
232+
auto& entire_read_source = _all_read_sources[tablet->tablet_id()];
233+
234+
TabletReadSource partitial_read_source;
235+
int64_t rows_collected = 0;
236+
237+
// Hand the collected splits to a new scanner
238+
auto flush_scanner = [&]() {
239+
DCHECK(!partitial_read_source.rs_splits.empty());
240+
scanners.emplace_back(
241+
_build_scanner(tablet, version, _key_ranges, scan_range,
242+
{.rs_splits = std::move(partitial_read_source.rs_splits),
243+
.delete_predicates = entire_read_source.delete_predicates,
244+
.delete_bitmap = entire_read_source.delete_bitmap},
245+
take_initial_file_cache_stats(&_tablet_preload_file_cache_stats,
246+
tablet->tablet_id())));
247+
partitial_read_source = {};
248+
rows_collected = 0;
249+
};
250+
251+
for (auto& rs_split : entire_read_source.rs_splits) {
252+
auto reader = rs_split.rs_reader;
253+
auto rowset = reader->rowset();
254+
if (rowset->num_rows() == 0) {
255+
continue;
256+
}
257+
const auto& segments_rows = _all_segments_rows[rowset->rowset_id()];
258+
259+
int64_t segment_start = 0;
260+
261+
// Leave segment_row_ranges empty; the downstream scans whole segments accordingly.
262+
auto close_split = [&](int64_t end) {
263+
if (end <= segment_start) {
264+
// The last segment just closed a split; no segment is left in this rowset
265+
return;
266+
}
267+
RowSetSplits split(reader->clone());
268+
split.segment_offsets = {segment_start, end};
269+
partitial_read_source.rs_splits.emplace_back(std::move(split));
270+
segment_start = end;
271+
};
272+
273+
for (size_t i = 0; i != segments_rows.size(); ++i) {
274+
rows_collected += static_cast<int64_t>(segments_rows[i]);
275+
if (rows_collected >= rows_per_scanner) {
276+
close_split(static_cast<int64_t>(i) + 1);
277+
flush_scanner();
278+
}
279+
}
280+
281+
// Segments at the tail that did not fill up wait for the next rowset: in the example
282+
// rs0 is held here until rs1
283+
close_split(static_cast<int64_t>(segments_rows.size()));
284+
}
285+
286+
// Whatever did not fill up by the end still has to be sent: rs2[2,3) + rs3[0,1) in the example
287+
if (rows_collected > 0) {
288+
flush_scanner();
289+
}
290+
}
291+
292+
return Status::OK();
293+
}
294+
206295
// Build scanners so that each segment is exclusively scanned by a single scanner.
207296
// This guarantees the number of scanners equals the number of segments across all rowsets
208297
// for the involved tablets. It preserves delete predicates and key ranges, and clones
@@ -216,11 +305,6 @@ Status ParallelScannerBuilder::_build_scanners_by_per_segment(std::list<ScannerS
216305
DCHECK(_all_read_sources.contains(tablet->tablet_id()));
217306
auto& entire_read_source = _all_read_sources[tablet->tablet_id()];
218307

219-
if (config::is_cloud_mode()) {
220-
// FIXME(plat1ko): Avoid pointer cast
221-
ExecEnv::GetInstance()->storage_engine().to_cloud().tablet_hotspot().count(*tablet);
222-
}
223-
224308
// For each RowSet split in the read source, split by segment id and build
225309
// one scanner per segment. Keep delete predicates shared.
226310
for (auto& rs_split : entire_read_source.rs_splits) {

be/src/exec/scan/parallel_scanner_builder.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@ class ParallelScannerBuilder {
7272

7373
void set_scan_parallelism_by_per_segment(bool v) { _scan_parallelism_by_per_segment = v; }
7474

75+
void set_has_match_in_expr(bool v) { _has_match_in_expr = v; }
76+
7577
const OlapReaderStatistics* builder_stats() const { return &_builder_stats; }
7678

7779
private:
7880
Status _load();
7981

8082
Status _build_scanners_by_rowid(std::list<ScannerSPtr>& scanners);
8183

84+
Status _build_scanners_by_match_expr(std::list<ScannerSPtr>& scanners);
85+
8286
// Build scanners so that each segment is handled by its own scanner.
8387
Status _build_scanners_by_per_segment(std::list<ScannerSPtr>& scanners);
8488

@@ -106,6 +110,9 @@ class ParallelScannerBuilder {
106110
// Force building one scanner per segment when true.
107111
bool _scan_parallelism_by_per_segment {false};
108112

113+
// Set when the pushed-down expressions contain MATCH; filled in by the scan operator.
114+
bool _has_match_in_expr {false};
115+
109116
std::shared_ptr<RuntimeProfile> _scanner_profile;
110117
OlapReaderStatistics _builder_stats;
111118
RuntimeState* _state;

0 commit comments

Comments
 (0)