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 12f49adad21 [improvement](be) Add sorted docids to roaring bitmaps a
container at a time (#69895)
12f49adad21 is described below
commit 12f49adad21275637b5230dd195c0ad557956c4d
Author: Jack <[email protected]>
AuthorDate: Mon Nov 2 09:20:47 2026 +0800
[improvement](be) Add sorted docids to roaring bitmaps a container at a
time (#69895)
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary: An equality filter on an indexed string column, such as
`tenant = 'x'`, collects
its whole posting list into a roaring bitmap, and a value can cover tens
of percent of the table.
`RoaringDocIdSink::append_sorted` gets those documents in sorted windows
that mostly fall in one 64K
container, but `Roaring::addMany` goes through the container's type for
every document. On a 99M-row
table that was about 20% of the SNII stack samples of a filtered phrase
prefix query.
`append_sorted` now splits a span at container boundaries and fills each
container directly:
- A bitset container gets one write per 64-bit word, with the bits
gathered in a register, because
neighbouring documents mostly share a word and a store per document
would wait on the one before
it.
- An array container gets its tail appended in one loop, up to its limit
of 4096. The next document
turns it into a bitset, which then takes the rest.
- Anything else goes through `addMany` as before: run containers,
documents that fall inside an
existing array, and spans that are not strictly ascending, which the
sink does not rule out and a
damaged posting could produce.
The containers end up the same as with `addMany`.
Two things to look at in review:
- The sink is shared by every inverted index format, so V3 queries take
the same path.
- The code writes to CRoaring's container structs. `null_bitmap.cpp`
already includes those headers,
but only reads their sizes, so an upgrade of CRoaring has to keep this
code in mind. CRoaring 2.1.2
has no public call that adds a sorted run to a container.
| Query (99M rows), new/previous | SNII hot | V3 hot | SNII cold |
|---|---|---|---|
| phrase with tenant, app, type and status | 0.80 / 0.80 / 0.78 | 0.90 |
0.95 |
| phrase with a latency filter | 0.78 / 0.77 / 0.78 | 0.78 | |
| MATCH_ALL with the same four filters | 0.79 / 0.77 / 0.81 | 0.95 |
0.98 |
| filters and a latency range, no text | 0.83 / 0.87 / 0.88 | 0.80 | |
| phrase prefix with tenant and app | 0.93 / 0.92 / 0.92 | 0.93 | 0.98 |
| top-50 with tenant and payload equalities | 0.77 | 0.82 | |
| top-50 tool failures | 0.93 | 0.77 | |
| the other 18 queries | 0.89-1.04 | 0.91-1.04 | |
Hot: both versions interleaved in one BE process, three runs where three
are shown, on a host with a
load of about 100 to 140 from other builds. An earlier round at a load
of 230 to 400 read up to 1.41
on some queries that do not gain; their reruns gave 0.95-1.07. Cold: BE
restarted and the index files
evicted before every run, configuration order alternated, 5 runs each;
the change only saves CPU, so
cold runs stay about level. The build also had #69879, #69882, #69887
and #69891, with
`snii_inline_read_max_segments` at 16.
---
.../inverted/query/exec/roaring_docid_sink.cpp | 131 +++++++++++++++
.../index/inverted/query/exec/roaring_docid_sink.h | 7 +-
.../query/exec/roaring_docid_sink_test.cpp | 184 +++++++++++++++++++++
3 files changed, 316 insertions(+), 6 deletions(-)
diff --git a/be/src/storage/index/inverted/query/exec/roaring_docid_sink.cpp
b/be/src/storage/index/inverted/query/exec/roaring_docid_sink.cpp
new file mode 100644
index 00000000000..87ad0b97d40
--- /dev/null
+++ b/be/src/storage/index/inverted/query/exec/roaring_docid_sink.cpp
@@ -0,0 +1,131 @@
+// 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.
+
+#include "storage/index/inverted/query/exec/roaring_docid_sink.h"
+
+#include <algorithm>
+#include <bit>
+#include <cstddef>
+
+// clang-format off
+// CRoaring's public header defines ROARING_CONTAINER_T; its internal headers
undefine it.
+#include "roaring/roaring.hh"
+#include "roaring/containers/containers.h"
+// clang-format on
+
+namespace doris::index_query {
+namespace {
+
+using roaring::api::roaring_bitmap_add_bulk;
+using roaring::api::roaring_bitmap_add_many;
+using roaring::api::roaring_bitmap_t;
+using roaring::api::roaring_bulk_context_t;
+using roaring::internal::array_container_t;
+using roaring::internal::bitset_container_t;
+
+// A sink only promises sorted spans. Equal neighbours, or an unsorted span
from a damaged posting,
+// would break a sorted array container and could put a document in the wrong
chunk.
+bool all_strictly_ascending(std::span<const uint32_t> docids) {
+ uint32_t out_of_order = 0;
+ for (size_t i = 1; i < docids.size(); ++i) {
+ out_of_order |= static_cast<uint32_t>(docids[i - 1] >= docids[i]);
+ }
+ return out_of_order == 0;
+}
+
+// Gathers the bits of one 64-bit word in a register and writes it once:
consecutive documents
+// mostly share a word, and a store per document would wait on the one before
it.
+void set_sorted_bits(bitset_container_t* bitset, std::span<const uint32_t>
docids) {
+ uint64_t* const words = bitset->words;
+ int32_t added = 0;
+ size_t i = 0;
+ while (i < docids.size()) {
+ const uint32_t word = (docids[i] & 0xFFFF) >> 6;
+ uint64_t bits = 0;
+ do {
+ bits |= uint64_t {1} << (docids[i] & 63);
+ ++i;
+ } while (i < docids.size() && ((docids[i] & 0xFFFF) >> 6) == word);
+ added += std::popcount(bits & ~words[word]);
+ words[word] |= bits;
+ }
+ bitset->cardinality += added;
+}
+
+// Appends to the tail of an array container, which must stay sorted and
within its size limit.
+void append_sorted_to_array(array_container_t* array, std::span<const
uint32_t> docids) {
+ const int32_t cardinality = array->cardinality;
+ const auto total = cardinality + static_cast<int32_t>(docids.size());
+ if (total > array->capacity) {
+ roaring::internal::array_container_grow(array, total,
/*preserve=*/true);
+ }
+ uint16_t* const out = array->array + cardinality;
+ for (size_t i = 0; i < docids.size(); ++i) {
+ out[i] = static_cast<uint16_t>(docids[i]);
+ }
+ array->cardinality = total;
+}
+
+// Adds strictly ascending documents of one 64K chunk. roaring_bitmap_add_bulk
finds or makes the
+// container, and changes its type when the document needs that; the rest go
straight into it.
+void add_sorted_chunk(roaring_bitmap_t* bitmap, std::span<const uint32_t>
docids) {
+ while (!docids.empty()) {
+ roaring_bulk_context_t context {};
+ roaring_bitmap_add_bulk(bitmap, &context, docids.front());
+ docids = docids.subspan(1);
+ if (context.typecode == BITSET_CONTAINER_TYPE) {
+ set_sorted_bits(CAST_bitset(context.container), docids);
+ return;
+ }
+ if (docids.empty()) {
+ return;
+ }
+ auto* array = CAST_array(context.container);
+ if (context.typecode != ARRAY_CONTAINER_TYPE ||
+ array->array[array->cardinality - 1] >=
static_cast<uint16_t>(docids.front())) {
+ roaring_bitmap_add_many(bitmap, docids.size(), docids.data());
+ return;
+ }
+ // Up to the limit of an array: the next document, if any, turns the
container to a bitset.
+ const size_t room = roaring::internal::DEFAULT_MAX_SIZE -
array->cardinality;
+ const size_t count = std::min(docids.size(), room);
+ append_sorted_to_array(array, docids.first(count));
+ docids = docids.subspan(count);
+ }
+}
+
+} // namespace
+
+// addMany goes through the container's type for every document, while a
posting list is decoded in
+// windows of documents that are mostly in one container.
+Status RoaringDocIdSink::append_sorted(std::span<const uint32_t> docids) {
+ if (!all_strictly_ascending(docids)) {
+ bitmap_.addMany(docids.size(), docids.data());
+ return Status::OK();
+ }
+ while (!docids.empty()) {
+ const uint32_t chunk = docids.front() >> 16;
+ const auto chunk_end = std::ranges::partition_point(
+ docids, [chunk](uint32_t docid) { return docid >> 16 == chunk;
});
+ const auto count = static_cast<size_t>(chunk_end - docids.begin());
+ add_sorted_chunk(&bitmap_.roaring, docids.first(count));
+ docids = docids.subspan(count);
+ }
+ return Status::OK();
+}
+
+} // namespace doris::index_query
diff --git a/be/src/storage/index/inverted/query/exec/roaring_docid_sink.h
b/be/src/storage/index/inverted/query/exec/roaring_docid_sink.h
index dfa07cea0d1..7290eecbdc6 100644
--- a/be/src/storage/index/inverted/query/exec/roaring_docid_sink.h
+++ b/be/src/storage/index/inverted/query/exec/roaring_docid_sink.h
@@ -32,12 +32,7 @@ public:
void append(uint32_t docid) { bitmap_.add(docid); }
- Status append_sorted(std::span<const uint32_t> docids) override {
- if (!docids.empty()) {
- bitmap_.addMany(docids.size(), docids.data());
- }
- return Status::OK();
- }
+ Status append_sorted(std::span<const uint32_t> docids) override;
Status append_range(uint32_t first, uint64_t last_exclusive) override {
if (last_exclusive >
static_cast<uint64_t>(std::numeric_limits<uint32_t>::max()) + 1) {
diff --git
a/be/test/storage/index/inverted/query/exec/roaring_docid_sink_test.cpp
b/be/test/storage/index/inverted/query/exec/roaring_docid_sink_test.cpp
new file mode 100644
index 00000000000..088884fb957
--- /dev/null
+++ b/be/test/storage/index/inverted/query/exec/roaring_docid_sink_test.cpp
@@ -0,0 +1,184 @@
+// 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.
+
+#include "storage/index/inverted/query/exec/roaring_docid_sink.h"
+
+#include <gtest/gtest.h>
+
+#include <algorithm>
+#include <cstdint>
+#include <limits>
+#include <numeric>
+#include <random>
+#include <span>
+#include <string>
+#include <vector>
+
+namespace doris::index_query {
+namespace {
+
+using Spans = std::vector<std::vector<uint32_t>>;
+
+// The sink must leave exactly what adding the documents one by one leaves, in
containers that hold
+// to CRoaring's own invariants: sorted arrays, cardinalities that match their
bits.
+void ExpectSameAsAddingOneByOne(const roaring::Roaring& start, const Spans&
spans) {
+ roaring::Roaring expected = start;
+ roaring::Roaring actual = start;
+ RoaringDocIdSink sink(actual);
+ for (const auto& span : spans) {
+ for (const uint32_t docid : span) {
+ expected.add(docid);
+ }
+ ASSERT_TRUE(sink.append_sorted(span).ok());
+ }
+ EXPECT_EQ(actual.cardinality(), expected.cardinality());
+ EXPECT_TRUE(actual == expected);
+ const char* reason = "";
+
EXPECT_TRUE(roaring::api::roaring_bitmap_internal_validate(&actual.roaring,
&reason)) << reason;
+}
+
+Spans Batches(const std::vector<uint32_t>& docids, size_t batch) {
+ Spans spans;
+ for (size_t off = 0; off < docids.size(); off += batch) {
+ const size_t end = std::min(docids.size(), off + batch);
+ spans.emplace_back(docids.begin() + off, docids.begin() + end);
+ }
+ return spans;
+}
+
+std::vector<uint32_t> Iota(uint32_t first, uint32_t count) {
+ std::vector<uint32_t> docids(count);
+ std::iota(docids.begin(), docids.end(), first);
+ return docids;
+}
+
+roaring::api::roaring_statistics_t StatsOf(const roaring::Roaring& bitmap) {
+ roaring::api::roaring_statistics_t stats;
+ roaring::api::roaring_bitmap_statistics(&bitmap.roaring, &stats);
+ return stats;
+}
+
+// Densities on both sides of the array limit, documents spread evenly or in
bursts, batches from one
+// document to the whole posting.
+TEST(RoaringDocIdSinkTest, MatchesSingleAddsForRandomPostings) {
+ std::mt19937 rng(20261030);
+ for (const double density : {0.0005, 0.02, 0.0625, 0.07, 0.125, 0.4, 0.9})
{
+ std::bernoulli_distribution take(density);
+ for (const bool bursts : {false, true}) {
+ const uint32_t step = bursts ? 2000 : 1;
+ std::vector<uint32_t> docids;
+ for (uint32_t doc = 0; doc < 400000; doc += step) {
+ if (take(rng)) {
+ for (uint32_t i = 0; i < step; ++i) {
+ docids.push_back(doc + i);
+ }
+ }
+ }
+ for (const size_t batch : {size_t {1}, size_t {7}, size_t {1024},
docids.size() + 1}) {
+ SCOPED_TRACE("density=" + std::to_string(density) + " bursts="
+
+ std::to_string(bursts) + " batch=" +
std::to_string(batch));
+ ExpectSameAsAddingOneByOne({}, Batches(docids, batch));
+ }
+ }
+ }
+}
+
+// An array holds at most 4096 documents. One batch that ends at the limit
leaves an array; one that
+// crosses it, at any point, leaves a bitset holding every document.
+TEST(RoaringDocIdSinkTest, TurnsAnArrayIntoABitsetAtItsLimit) {
+ roaring::Roaring at_limit;
+ RoaringDocIdSink at_limit_sink(at_limit);
+ ASSERT_TRUE(at_limit_sink.append_sorted(Iota(0, 4096)).ok());
+ EXPECT_EQ(StatsOf(at_limit).n_array_containers, 1U);
+ EXPECT_EQ(at_limit.cardinality(), 4096U);
+
+ for (const uint32_t count : {4097U, 4098U, 5000U, 65536U}) {
+ const std::vector<uint32_t> docids = Iota(0, count);
+ ExpectSameAsAddingOneByOne({}, {docids});
+ roaring::Roaring crossed;
+ RoaringDocIdSink sink(crossed);
+ ASSERT_TRUE(sink.append_sorted(docids).ok());
+ EXPECT_EQ(StatsOf(crossed).n_bitset_containers, 1U);
+ }
+ // The limit is reached by the document that the bulk add puts in on its
own.
+ ExpectSameAsAddingOneByOne({}, {Iota(0, 4095), {5000}, {5001}, Iota(6000,
3000)});
+ ExpectSameAsAddingOneByOne({}, {Iota(0, 4096), {5000, 5001}});
+}
+
+TEST(RoaringDocIdSinkTest, MergesIntoExistingContainers) {
+ const std::vector<uint32_t> docids = Iota(1000, 3000);
+
+ // Array with documents after the batch, and one with a document inside it.
+ roaring::Roaring array_after;
+ array_after.add(60000);
+ ExpectSameAsAddingOneByOne(array_after, {docids});
+ roaring::Roaring array_inside;
+ array_inside.add(1500);
+ ExpectSameAsAddingOneByOne(array_inside, {docids});
+ roaring::Roaring array_before;
+ array_before.add(999);
+ ExpectSameAsAddingOneByOne(array_before, {docids});
+
+ // Bitset that already holds some of the batch: those must not be counted
twice.
+ roaring::Roaring bitset;
+ for (uint32_t doc = 0; doc < 20000; doc += 3) {
+ bitset.add(doc);
+ }
+ ASSERT_EQ(StatsOf(bitset).n_bitset_containers, 1U);
+ ExpectSameAsAddingOneByOne(bitset, {docids});
+
+ // Run container.
+ roaring::Roaring runs;
+ runs.addRange(500, 40000);
+ runs.runOptimize();
+ ASSERT_EQ(StatsOf(runs).n_run_containers, 1U);
+ ExpectSameAsAddingOneByOne(runs, {docids, Iota(39000, 3000)});
+}
+
+// A streamed union hands over independently sorted spans, so documents repeat
across them.
+TEST(RoaringDocIdSinkTest, UnionsIndependentlySortedSpans) {
+ std::mt19937 rng(7);
+ Spans spans;
+ for (int term = 0; term < 20; ++term) {
+ std::vector<uint32_t> docids;
+ for (uint32_t doc = rng() % 50; doc < 200000; doc += 1 + rng() % 30) {
+ docids.push_back(doc);
+ }
+ spans.push_back(std::move(docids));
+ }
+ ExpectSameAsAddingOneByOne({}, spans);
+}
+
+TEST(RoaringDocIdSinkTest, KeepsDocumentsOfNeighbouringChunksApart) {
+ ExpectSameAsAddingOneByOne({}, {{65534, 65535, 65536, 65537, 131071,
131072}});
+ constexpr uint32_t kLast = std::numeric_limits<uint32_t>::max();
+ ExpectSameAsAddingOneByOne({}, {{0, 1, kLast - 65536, kLast - 65535, kLast
- 1, kLast}});
+ ExpectSameAsAddingOneByOne({}, {{}, {kLast}, {}});
+}
+
+// A sink only promises sorted spans, and a damaged posting may not even be
that: every document
+// must still land in its own chunk, and an array must stay free of repeats.
+TEST(RoaringDocIdSinkTest, AcceptsSpansThatAreNotStrictlyAscending) {
+ ExpectSameAsAddingOneByOne({}, {{5, 5, 6, 6, 6, 7}});
+ ExpectSameAsAddingOneByOne({}, {{70000, 3, 140000, 2, 2, 70001}});
+ std::vector<uint32_t> repeated = Iota(0, 5000);
+ repeated.insert(repeated.begin() + 2500, 2499);
+ ExpectSameAsAddingOneByOne({}, {repeated});
+}
+
+} // namespace
+} // namespace doris::index_query
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]