mirror of
https://github.com/apache/doris.git
synced 2026-11-04 07:31:17 +00:00
### What problem does this PR solve?
Issue Number: None
Related PR: #67180, #67538, #67918
Problem Summary:
MATCH and SEARCH each had their own Boolean, phrase, term-expansion and
scoring executors, written separately for CLucene (V1/V2) and SNII
indexes. Every query optimization had to be implemented once per
executor, the formats drifted apart in behavior, and cross-field queries
could mix document IDs from different physical partitions.
This PR runs both on one engine:
- **One logical form.** MATCH (`query/logical`) and SEARCH
(`exprs/function/search`) lower their queries to the same logical nodes.
- **One query engine.** Boolean logic (AND/OR/NOT, minimum_should_match,
WAND), term expansion (prefix, wildcard, regexp), exact and sloppy
phrases and BM25 scoring are implemented once, independent of the index
format.
- **Formats behind an SPI.** CLucene and SNII only provide dictionaries,
postings, positions, norms, statistics and batched I/O through
`query/spi`. The old per-format executors are removed.
- **Layered source tree.** `be/src/storage/index/` is reorganized by
layer, and `build-support/check-header-deps.py` enforces the allowed
include directions.
Index formats and writers are unchanged, and existing indexes remain
readable. FE is unchanged.
#### Source layout
```text
be/src/storage/index/
├── index_*.h/.cpp shared index files: file reader/writer, compound reader, storage formats
├── ann/ bloom_filter/ zone_map/
└── inverted/
├── analysis/ analyzers, tokenizers, char and token filters, normalizers, gram schemes
├── common/ descriptors, caches, statistics, profiles, single flight
├── reader/ text index reader, index selection, collection statistics
├── query/ format-independent query engine
│ ├── logical/ MATCH lowering and logical nodes (gram/ builds gram queries)
│ ├── engine/ Query/Weight/Scorer: boolean, term, phrase, expansion, WAND, union, collect
│ ├── exec/ doc-id sets and sinks, term waves, chained conjunctions
│ ├── phrase/ exact and sloppy phrase matchers
│ ├── scoring/ BM25
│ └── spi/ the source interface each format implements
├── clucene/ CLucene (V1/V2) reader, searcher, writer, compaction
│ └── spi/ CLucene index source and postings cursor
└── snii/ SNII: format/, encoding/, io/, bkd/, reader/, writer/, compaction/, query/
be/src/exprs/
├── vsearch.h/.cpp VSearchExpr, the SEARCH expression node
└── function/search/
├── function_search.* SEARCH entry: Boolean assembly, DSL result cache, NULL handling
├── search_lowering.* SEARCH DSL to logical nodes; binds each field to an index
├── function_search_leaf.* compiles each leaf into a query on its bound index
├── search_predicate_collector.* collects scored leaves for BM25 statistics
└── variant_inverted_index_search.* SEARCH on VARIANT subcolumns and NESTED groups
```
#### Performance
The 18 production SQL cases (V2 and SNII: point, range, absent-term and
phrase queries) were compared with master on fresh dual-BE pairs: 8
pairs, 16 samples per case and binary, alternating order. Every case is
within the 5% limit for both `InvertedIndexFilterTime` and client wall
time; the largest ratios are 1.032 and 1.038. SNII phrase cases take
25-60% less FilterTime. TPC-H, TPC-DS and ClickBench are within noise.
<details><summary>Per-case median ratios (PR / master)</summary>
Run on 2026-10-14 against master 7ac0eb2fdb on a busy shared host
(median 90% CPU), which widens the intervals; all 4,608 result checks
pass.
| Case | FilterTime | Client wall |
| --- | ---: | ---: |
| `v2/long/early/full` | 1.030 | 1.024 |
| `v2/long/early/range` | 1.018 | 0.999 |
| `v2/long/absent/full` | 1.032 | 1.014 |
| `v2/long/absent/range` | 1.018 | 0.995 |
| `v2/long/mid/full` | 1.006 | 1.038 |
| `v2/long/mid/range` | 1.013 | 1.012 |
| `v2/short/tiny` | 0.965 | 1.018 |
| `v2/search/scored_phrase` | 1.010 | 0.990 |
| `v2/search/phrase_2` | 0.954 | 0.951 |
| `snii/long/early/full` | 0.950 | 0.994 |
| `snii/long/early/range` | 0.961 | 1.022 |
| `snii/long/absent/full` | 0.728 | 0.847 |
| `snii/long/absent/range` | 0.891 | 0.983 |
| `snii/long/mid/full` | 0.821 | 0.924 |
| `snii/long/mid/range` | 0.975 | 1.007 |
| `snii/short/tiny` | 1.020 | 0.985 |
| `snii/search/scored_phrase` | 0.404 | 0.971 |
| `snii/search/phrase_2` | 0.746 | 0.986 |
</details>
241 lines
8.9 KiB
Python
Executable File
241 lines
8.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
"""Run saved BE benchmark binaries through run-be-ut.sh, alternating each sample.
|
|
|
|
Set DORIS_BE_UT_EXECUTABLE to this script and provide QUERY_ENGINE_BENCH_BASELINE,
|
|
QUERY_ENGINE_BENCH_CANDIDATE, QUERY_ENGINE_BENCH_CPU and QUERY_ENGINE_BENCH_OUTPUT.
|
|
The runner supplies the runtime environment and forwards its gtest arguments.
|
|
"""
|
|
|
|
import csv
|
|
from decimal import Decimal
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import select
|
|
import statistics
|
|
import subprocess
|
|
import sys
|
|
|
|
READY = "QUERY_ENGINE_READY"
|
|
RESULT = "QUERY_ENGINE_BENCH"
|
|
|
|
|
|
def file_hash(path):
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as source:
|
|
for block in iter(lambda: source.read(8 * 1024 * 1024), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
class Worker:
|
|
def __init__(self, name, binary, cpu, output):
|
|
self.name = name
|
|
self.binary = binary
|
|
self.sha256 = file_hash(binary)
|
|
self.pending = b""
|
|
self.peak_rss_kib = 0
|
|
self.log = (output / f"{name}.log").open("w")
|
|
slot = "a" if name == "baseline" else "b"
|
|
runtime = output / slot
|
|
(runtime / "ut_dir").mkdir(parents=True, exist_ok=True)
|
|
read_fd, self.control_fd = os.pipe()
|
|
env = dict(os.environ, QUERY_ENGINE_BENCH_CONTROL_FD=str(read_fd))
|
|
arguments = [
|
|
argument for argument in sys.argv[1:] if not argument.startswith("--gtest_output=")
|
|
]
|
|
arguments.append(f"--gtest_output=xml:{output / f'{slot}.xml'}")
|
|
affinity = os.sched_getaffinity(0)
|
|
try:
|
|
os.sched_setaffinity(0, {int(cpu)})
|
|
self.process = subprocess.Popen(
|
|
["doris_be_test", *arguments],
|
|
executable=str(binary),
|
|
cwd=runtime,
|
|
env=env,
|
|
pass_fds=(read_fd,),
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
)
|
|
except BaseException:
|
|
os.close(self.control_fd)
|
|
self.log.close()
|
|
raise
|
|
finally:
|
|
os.sched_setaffinity(0, affinity)
|
|
os.close(read_fd)
|
|
|
|
def next_line(self):
|
|
while b"\n" not in self.pending:
|
|
readable, _, _ = select.select([self.process.stdout], [], [], 60)
|
|
if not readable:
|
|
raise TimeoutError(f"{self.name}: no benchmark output for 60 seconds")
|
|
chunk = os.read(self.process.stdout.fileno(), 65536)
|
|
if not chunk:
|
|
if not self.pending:
|
|
return None
|
|
self.pending += b"\n"
|
|
break
|
|
self.pending += chunk
|
|
raw, self.pending = self.pending.split(b"\n", 1)
|
|
line = raw.decode("utf-8", errors="replace")
|
|
self.log.write(line + "\n")
|
|
self.log.flush()
|
|
return line
|
|
|
|
def event(self, expected):
|
|
while (line := self.next_line()) is not None:
|
|
prefix = line.split(",", 1)[0]
|
|
if prefix not in (READY, RESULT):
|
|
continue
|
|
if prefix != expected:
|
|
raise RuntimeError(f"{self.name}: expected {expected}, received {line}")
|
|
return line.split(",")[1:]
|
|
return None
|
|
|
|
def measure(self):
|
|
os.write(self.control_fd, b"r")
|
|
result = self.event(RESULT)
|
|
if result is None or len(result) != 5:
|
|
raise RuntimeError(f"{self.name}: missing benchmark sample")
|
|
try:
|
|
status = Path(f"/proc/{self.process.pid}/status").read_text()
|
|
except FileNotFoundError:
|
|
status = ""
|
|
for line in status.splitlines():
|
|
if line.startswith("VmHWM:"):
|
|
self.peak_rss_kib = max(self.peak_rss_kib, int(line.split()[1]))
|
|
break
|
|
return result
|
|
|
|
def close(self):
|
|
if self.process.poll() is None:
|
|
self.process.terminate()
|
|
try:
|
|
self.process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
self.process.kill()
|
|
self.process.wait()
|
|
os.close(self.control_fd)
|
|
self.log.close()
|
|
|
|
|
|
def summarize(samples):
|
|
baseline = [row[0] for row in samples]
|
|
candidate = [row[1] for row in samples]
|
|
ratios = [row[1] / row[0] for row in samples]
|
|
# Pair AB and BA samples so execution order has equal weight.
|
|
rounds = [
|
|
math.sqrt(ratios[index] * ratios[index + 1]) for index in range(0, len(ratios) - 1, 2)
|
|
]
|
|
return {
|
|
"samples": len(samples),
|
|
"baseline_median_ns": statistics.median(baseline),
|
|
"candidate_median_ns": statistics.median(candidate),
|
|
"paired_ratio_median": statistics.median(ratios),
|
|
"paired_ratio_min": min(ratios),
|
|
"paired_ratio_max": max(ratios),
|
|
"balanced_rounds": len(rounds),
|
|
"unpaired_samples": len(ratios) % 2,
|
|
"balanced_ratio_median": statistics.median(rounds) if rounds else None,
|
|
}
|
|
|
|
|
|
def run(workers, output, cpu):
|
|
cases = {}
|
|
pair_number = 0
|
|
previous_label = None
|
|
with (output / "pairs.csv").open("w", newline="") as destination:
|
|
writer = csv.writer(destination)
|
|
writer.writerow(
|
|
("case", "sample", "iterations", "baseline_ns", "candidate_ns", "checksum", "order")
|
|
)
|
|
while True:
|
|
ready = [worker.event(READY) for worker in workers]
|
|
if ready == [None, None]:
|
|
break
|
|
if ready[0] != ready[1] or ready[0] is None or len(ready[0]) != 2:
|
|
raise RuntimeError(f"Benchmark cases differ: {ready}")
|
|
label, sample = ready[0]
|
|
if previous_label is not None and label != previous_label:
|
|
print(previous_label, json.dumps(summarize(cases[previous_label])), flush=True)
|
|
previous_label = label
|
|
results = [None, None]
|
|
order = (0, 1) if pair_number % 2 == 0 else (1, 0)
|
|
for index in order:
|
|
results[index] = workers[index].measure()
|
|
left, right = results
|
|
if left[:3] != right[:3] or left[:2] != ready[0]:
|
|
raise RuntimeError(f"Benchmark sample shapes differ: {results}")
|
|
if Decimal(left[4]) != Decimal(right[4]):
|
|
raise RuntimeError(f"Benchmark checksums differ: {results}")
|
|
iterations = int(left[2])
|
|
elapsed = [int(left[3]), int(right[3])]
|
|
if iterations <= 0 or min(elapsed) <= 0:
|
|
raise RuntimeError(f"Invalid benchmark work or timing: {results}")
|
|
cases.setdefault(label, []).append(tuple(value / iterations for value in elapsed))
|
|
writer.writerow(
|
|
(label, sample, iterations, *elapsed, left[4], "AB" if order[0] == 0 else "BA")
|
|
)
|
|
destination.flush()
|
|
pair_number += 1
|
|
for worker in workers:
|
|
if worker.process.wait(timeout=10) != 0:
|
|
raise RuntimeError(f"{worker.name} benchmark tests failed; see {worker.name}.log")
|
|
if not cases:
|
|
raise RuntimeError("No paired benchmark samples were produced")
|
|
print(previous_label, json.dumps(summarize(cases[previous_label])), flush=True)
|
|
summary = {
|
|
"cpu": cpu,
|
|
"pairs": pair_number,
|
|
"artifacts": {
|
|
worker.name: {
|
|
"path": str(worker.binary),
|
|
"sha256": worker.sha256,
|
|
"process_peak_rss_kib": worker.peak_rss_kib,
|
|
}
|
|
for worker in workers
|
|
},
|
|
"cases": {label: summarize(samples) for label, samples in cases.items()},
|
|
}
|
|
(output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n")
|
|
print(f"Paired benchmark completed: {pair_number} pairs, {len(cases)} cases", flush=True)
|
|
|
|
|
|
def main():
|
|
output = Path(os.environ["QUERY_ENGINE_BENCH_OUTPUT"]).resolve()
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
cpu = os.environ["QUERY_ENGINE_BENCH_CPU"]
|
|
workers = []
|
|
try:
|
|
for name in ("baseline", "candidate"):
|
|
binary = Path(os.environ[f"QUERY_ENGINE_BENCH_{name.upper()}"]).resolve(strict=True)
|
|
workers.append(Worker(name, binary, cpu, output))
|
|
run(workers, output, cpu)
|
|
finally:
|
|
for worker in workers:
|
|
worker.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|