From 5a5f430918efaeacecee90287e5d3d208750d869 Mon Sep 17 00:00:00 2001 From: csun5285 Date: Sun, 13 Sep 2026 21:41:23 +0800 Subject: [PATCH] [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. An ann topn is priced per segment for the same reason and already asks for one scanner per segment through `optimize_index_scan_parallelism`. Reuse that path for MATCH: `_use_scan_parallelism_by_per_segment()` now decides both cases, and `_build_scanners_by_per_segment` gives every segment a scanner of its own, so a split boundary never falls inside a segment. The MATCH branch applies in cloud mode only. A query without MATCH and without an ann topn keeps taking `_build_scanners_by_rowid` and behaves exactly as before. Note that the predicate also matches `search()` and `multi_match()`, which are priced per segment in the same way. Measured on a cloud cluster, cold query (file cache cleared and BE restarted between runs). `NumSegmentTotal` counts how many times a segment is opened, so it is the direct measure of the duplicated work this removes: 8 segments over 4 rowsets, 20M rows before after NumScanners 10 8 NumSegmentTotal 16 8 duplicate reads removed InvertedIndexQueryTime 5.01s 2.55s -49% Total 801ms 627ms -22% 40 segments over 40 rowsets, 2M rows before after NumScanners 1 40 NumSegmentTotal 40 40 nothing to remove InvertedIndexQueryTime 323ms 411ms +27% Total 403ms 69ms -83% The gain comes from dropping duplicate segment reads, so it depends on the geometry. When the row split cuts segments apart the index work roughly halves. When it does not - many small rowsets, one segment each - there is no duplicate work to remove and the index cost grows by the extra per-scanner setup, while the wall clock drops because the scan is no longer serialized behind a single scanner. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VgV4Zy5NaEYpUP4Yrh1yWn --- be/src/exec/operator/olap_scan_operator.cpp | 36 +++++++++++++++------ be/src/exec/operator/olap_scan_operator.h | 3 ++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/be/src/exec/operator/olap_scan_operator.cpp b/be/src/exec/operator/olap_scan_operator.cpp index d8fcac7579f8a2..8de53b95464a62 100644 --- a/be/src/exec/operator/olap_scan_operator.cpp +++ b/be/src/exec/operator/olap_scan_operator.cpp @@ -460,6 +460,17 @@ static bool contains_expr_node_type(const VExprSPtr& expr, TExprNodeType::type n }); } +// Find MATCH recursively; ones nested in AND / OR / NOT count too. +static bool is_match_expr(const VExprSPtr& expr) { + DORIS_CHECK(expr != nullptr); + if (expr->node_type() == TExprNodeType::MATCH_PRED || + expr->node_type() == TExprNodeType::SEARCH_EXPR || expr->can_push_down_to_index()) { + return true; + } + return std::ranges::any_of(expr->children(), + [](const auto& child) { return is_match_expr(child); }); +} + static Status validate_residual_scan_conjuncts(RuntimeState* state, TPushAggOp::type push_down_agg_type, const VExprContextSPtrs& conjuncts) { @@ -635,6 +646,21 @@ bool OlapScanLocalState::_is_binlog_merge_scan() const { return scan_type == TBinlogScanType::MIN_DELTA || scan_type == TBinlogScanType::DETAIL; } +// Give each segment a scanner of its own for queries like: +// SELECT k1 FROM t ORDER BY l2_distance_approximate(embedding, [1.0, 2.0]) LIMIT 2 +// SELECT k1 FROM t WHERE msg MATCH_PHRASE 'error timeout' +bool OlapScanLocalState::_use_scan_parallelism_by_per_segment() { + // TODO: Use optimize_index_scan_parallelism for ann range search in the future. + // Currently, ann topn is enough + if (state()->query_options().__isset.optimize_index_scan_parallelism && + state()->query_options().optimize_index_scan_parallelism && _ann_topn_runtime != nullptr) { + return true; + } + return config::is_cloud_mode() && + std::ranges::any_of(_common_expr_ctxs_push_down, + [](const auto& ctx) { return is_match_expr(ctx->root()); }); +} + Status OlapScanLocalState::_init_scanners(std::list* scanners) { if (_scan_ranges.empty()) { _eos = true; @@ -764,15 +790,7 @@ Status OlapScanLocalState::_init_scanners(std::list* scanners) { std::max(1024, state()->parallel_scan_min_rows_per_scanner()); scanner_builder.set_max_scanners_count(max_scanners_count); scanner_builder.set_min_rows_per_scanner(min_rows_per_scanner); - // If the session variable is set, force one scanner per segment. - if (state()->query_options().__isset.optimize_index_scan_parallelism && - state()->query_options().optimize_index_scan_parallelism) { - // TODO: Use optimize_index_scan_parallelism for ann range search in the future. - // Currently, ann topn is enough - if (_ann_topn_runtime != nullptr) { - scanner_builder.set_scan_parallelism_by_per_segment(true); - } - } + scanner_builder.set_scan_parallelism_by_per_segment(_use_scan_parallelism_by_per_segment()); RETURN_IF_ERROR(scanner_builder.build_scanners(*scanners)); for (auto& scanner : *scanners) { diff --git a/be/src/exec/operator/olap_scan_operator.h b/be/src/exec/operator/olap_scan_operator.h index 11d40452f93132..1634968f976dd3 100644 --- a/be/src/exec/operator/olap_scan_operator.h +++ b/be/src/exec/operator/olap_scan_operator.h @@ -135,6 +135,9 @@ class OlapScanLocalState final : public ScanLocalState { Status _init_scanners(std::list* scanners) override; + // Whether each segment should be scanned by a scanner of its own. + bool _use_scan_parallelism_by_per_segment(); + Status _build_key_ranges_and_filters(); bool _is_tablet_pruned_by_runtime_filter(int64_t partition_id, int32_t bucket_seq,