This is an automated email from the ASF dual-hosted git repository.
yiguolei 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 53264e01c6a [fix](be) Reduce Iceberg equality delete index memory
(#67570)
53264e01c6a is described below
commit 53264e01c6ab7fcb10c53ad6c00c38c537a2b2ba
Author: Gabriel <[email protected]>
AuthorDate: Tue Sep 8 11:04:38 2026 +0800
[fix](be) Reduce Iceberg equality delete index memory (#67570)
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary:
FileScannerV2 keeps a node-based hash-to-row map for every row in a
multi-column Iceberg equality delete file. It also rebuilds that map for
every data split even though the parsed delete file is shared. This
amplifies memory usage and repeats index construction.
This change stores hash candidates in a sorted contiguous index and
shares the immutable index through the split cache. Full-key comparison
remains in place to resolve hash collisions. The optimization is
intentionally scoped to FileScannerV2; the legacy format reader is
unchanged.
### Optimization approach
Before this change, FileScannerV2 cached the parsed equality delete
block, but every split-local `EqualityDeletePredicate` still recomputed
all delete-row hashes and inserted them into a `std::multimap<hash,
row_index>`. This caused repeated `O(N log N)` construction and one
tree-node allocation per delete row for every predicate.
The new flow is:
1. When an equality delete file is loaded for the first time, compute
one hash for each delete row.
2. Store `{hash, row_index}` entries in a contiguous vector and sort it
by hash.
3. Cache this immutable index together with the parsed delete block.
4. Pass a shared reference to every split-local predicate instead of
rebuilding the index.
5. Use `lower_bound` and `upper_bound` to find candidates with the same
hash, then compare the complete equality key to preserve correctness
under hash collisions.
This removes the per-row node and allocator overhead of `std::multimap`,
improves cache locality, and changes index construction from once per
split to once per cached delete file. Runtime profile counters expose
index cache hits, misses, and index memory usage for validation.
### Release note
None
### Check List (For Author)
- Test
- [ ] Regression test
- [x] Unit Test
- [x] Manual test (add detailed scripts or steps below)
- Full BE build
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason
- Behavior changed:
- [x] No.
- [ ] Yes.
- Does this need documentation?
- [x] No.
- [ ] Yes.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
be/src/format_v2/expr/equality_delete_hash_index.h | 61 ++++++++++++++++++++++
.../format_v2/expr/equality_delete_predicate.cpp | 24 ++++++---
be/src/format_v2/expr/equality_delete_predicate.h | 12 +++--
be/src/format_v2/table/iceberg_reader.cpp | 23 +++++---
be/src/format_v2/table/iceberg_reader.h | 2 +
be/src/format_v2/table_reader.cpp | 8 +++
be/src/format_v2/table_reader.h | 3 ++
be/test/format_v2/table/iceberg_reader_test.cpp | 13 +++++
8 files changed, 128 insertions(+), 18 deletions(-)
diff --git a/be/src/format_v2/expr/equality_delete_hash_index.h
b/be/src/format_v2/expr/equality_delete_hash_index.h
new file mode 100644
index 00000000000..36685112b6d
--- /dev/null
+++ b/be/src/format_v2/expr/equality_delete_hash_index.h
@@ -0,0 +1,61 @@
+// 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.
+
+#pragma once
+
+#include <algorithm>
+#include <cstddef>
+#include <cstdint>
+#include <utility>
+#include <vector>
+
+namespace doris {
+
+class EqualityDeleteHashIndex {
+public:
+ struct Entry {
+ uint64_t hash;
+ size_t row_index;
+ };
+
+ using const_iterator = std::vector<Entry>::const_iterator;
+
+ explicit EqualityDeleteHashIndex(std::vector<uint64_t> hashes) {
+ _entries.reserve(hashes.size());
+ for (size_t row = 0; row < hashes.size(); ++row) {
+ _entries.push_back({hashes[row], row});
+ }
+ // A node-based map amplifies memory for large delete files. Keep
candidates contiguous;
+ // the retained delete block still resolves hash collisions with
full-key comparisons.
+ std::ranges::sort(_entries, {}, &Entry::hash);
+ }
+
+ std::pair<const_iterator, const_iterator> equal_range(uint64_t hash) const
{
+ const auto first = std::ranges::lower_bound(_entries, hash, {},
&Entry::hash);
+ const auto last = std::ranges::upper_bound(_entries, hash, {},
&Entry::hash);
+ return {first, last};
+ }
+
+ bool empty() const { return _entries.empty(); }
+
+ size_t memory_usage() const { return _entries.size() * sizeof(Entry); }
+
+private:
+ std::vector<Entry> _entries;
+};
+
+} // namespace doris
diff --git a/be/src/format_v2/expr/equality_delete_predicate.cpp
b/be/src/format_v2/expr/equality_delete_predicate.cpp
index 1d111aa7a74..57d7337f837 100644
--- a/be/src/format_v2/expr/equality_delete_predicate.cpp
+++ b/be/src/format_v2/expr/equality_delete_predicate.cpp
@@ -87,19 +87,27 @@ void update_varbinary_hashes(const ColumnWithTypeAndName&
entry, uint64_t* hashe
} // namespace
-EqualityDeletePredicate::EqualityDeletePredicate(Block delete_block,
std::vector<int> field_ids)
- : VExpr(), _delete_block(std::move(delete_block)),
_field_ids(std::move(field_ids)) {
+EqualityDeletePredicate::EqualityDeletePredicate(
+ Block delete_block, std::vector<int> field_ids,
+ std::shared_ptr<const EqualityDeleteHashIndex> delete_hash_index)
+ : _delete_block(std::move(delete_block)),
+ _field_ids(std::move(field_ids)),
+ _delete_hash_index(std::move(delete_hash_index)) {
_node_type = TExprNodeType::PREDICATE;
_opcode = TExprOpcode::DELETE;
_data_type = std::make_shared<DataTypeBool>();
_expr_name = "EqualityDeletePredicate";
DCHECK_EQ(_delete_block.columns(), _field_ids.size());
- _delete_hashes = _build_hashes(_delete_block);
- for (size_t row = 0; row < _delete_hashes.size(); ++row) {
- _delete_hash_map.emplace(_delete_hashes[row], row);
+ if (_delete_hash_index == nullptr) {
+ _delete_hash_index = build_hash_index(_delete_block);
}
}
+std::shared_ptr<const EqualityDeleteHashIndex>
EqualityDeletePredicate::build_hash_index(
+ const Block& delete_block) {
+ return std::make_shared<const
EqualityDeleteHashIndex>(_build_hashes(delete_block));
+}
+
Status EqualityDeletePredicate::prepare(RuntimeState* state, const
RowDescriptor& desc,
VExprContext* context) {
RETURN_IF_ERROR_OR_PREPARED(VExpr::prepare(state, desc, context));
@@ -171,15 +179,15 @@ Status
EqualityDeletePredicate::execute_column_impl(VExprContext* context, const
ColumnPtr EqualityDeletePredicate::_evaluate_key_block(const Block&
data_key_block) const {
const auto rows = data_key_block.rows();
auto res_col = ColumnBool::create(rows, 0);
- if (_delete_hash_map.empty() || rows == 0) {
+ if (_delete_hash_index->empty() || rows == 0) {
return res_col;
}
auto data_hashes = _build_hashes(data_key_block);
auto& result_data = res_col->get_data();
for (size_t row = 0; row < rows; ++row) {
- const auto range = _delete_hash_map.equal_range(data_hashes[row]);
+ const auto range = _delete_hash_index->equal_range(data_hashes[row]);
for (auto it = range.first; it != range.second; ++it) {
- if (_equal(data_key_block, row, it->second)) {
+ if (_equal(data_key_block, row, it->row_index)) {
result_data[row] = true;
break;
}
diff --git a/be/src/format_v2/expr/equality_delete_predicate.h
b/be/src/format_v2/expr/equality_delete_predicate.h
index 0e6f127cee2..8854cac00ca 100644
--- a/be/src/format_v2/expr/equality_delete_predicate.h
+++ b/be/src/format_v2/expr/equality_delete_predicate.h
@@ -19,7 +19,6 @@
#include <cstddef>
#include <cstdint>
-#include <map>
#include <memory>
#include <string>
#include <vector>
@@ -28,6 +27,7 @@
#include "core/block/block.h"
#include "exprs/function_context.h"
#include "exprs/vexpr.h"
+#include "format_v2/expr/equality_delete_hash_index.h"
namespace doris {
class RowDescriptor;
@@ -41,7 +41,9 @@ class EqualityDeletePredicate final : public VExpr {
ENABLE_FACTORY_CREATOR(EqualityDeletePredicate);
public:
- EqualityDeletePredicate(Block delete_block, std::vector<int> field_ids);
+ EqualityDeletePredicate(
+ Block delete_block, std::vector<int> field_ids,
+ std::shared_ptr<const EqualityDeleteHashIndex> delete_hash_index =
nullptr);
~EqualityDeletePredicate() override = default;
Status execute(VExprContext* context, Block* block, int* result_column_id)
const override;
@@ -55,6 +57,9 @@ public:
uint64_t get_digest(uint64_t seed) const override { return 0; }
const std::string& expr_name() const override { return _expr_name; }
+ static std::shared_ptr<const EqualityDeleteHashIndex> build_hash_index(
+ const Block& delete_block);
+
private:
static std::vector<uint64_t> _build_hashes(const Block& block);
ColumnPtr _evaluate_key_block(const Block& data_key_block) const;
@@ -63,8 +68,7 @@ private:
std::string _expr_name;
Block _delete_block;
std::vector<int> _field_ids;
- std::vector<uint64_t> _delete_hashes;
- std::multimap<uint64_t, size_t> _delete_hash_map;
+ std::shared_ptr<const EqualityDeleteHashIndex> _delete_hash_index;
};
} // namespace doris::format
diff --git a/be/src/format_v2/table/iceberg_reader.cpp
b/be/src/format_v2/table/iceberg_reader.cpp
index 61403ec0f36..3fba91a3a5b 100644
--- a/be/src/format_v2/table/iceberg_reader.cpp
+++ b/be/src/format_v2/table/iceberg_reader.cpp
@@ -1481,8 +1481,8 @@ Status
IcebergTableReader::_build_missing_equality_delete_key_expr(
Status
IcebergTableReader::_append_equality_delete_predicates(format::FileScanRequest*
request) {
DORIS_CHECK(request != nullptr);
for (const auto& filter : _equality_delete_filters) {
- auto delete_predicate =
- std::make_shared<EqualityDeletePredicate>(filter.delete_block,
filter.field_ids);
+ auto delete_predicate = std::make_shared<EqualityDeletePredicate>(
+ filter.delete_block, filter.field_ids, filter.hash_index);
DCHECK_EQ(filter.field_ids.size(), filter.key_types.size());
bool has_missing_key = false;
for (size_t idx = 0; idx < filter.field_ids.size(); ++idx) {
@@ -1792,6 +1792,7 @@ Status
IcebergTableReader::_load_equality_delete_file(const TIcebergDeleteFileDe
}
RETURN_IF_ERROR(reader->close());
result->delete_block = mutable_delete_block.to_block();
+ result->hash_index =
EqualityDeletePredicate::build_hash_index(result->delete_block);
return Status::OK();
}
@@ -1808,11 +1809,13 @@ Status
IcebergTableReader::_read_equality_delete_file(const TIcebergDeleteFileDe
cache_key << ':' << field_id;
}
Status read_status = Status::OK();
+ bool cache_hit = false;
// Include the ordered equality ids in the key because the same physical
delete file can be
- // projected with different key layouts. The cached block and its key
metadata are immutable
- // after construction and therefore safe to copy into each split-local
predicate.
+ // projected with different key layouts. The cached block, key metadata,
and contiguous index
+ // are immutable after construction and therefore safe to share across
split-local predicates.
auto* cached_filter = _split_cache->get<EqualityDeleteFilter>(
- cache_key.str(), [&]() -> EqualityDeleteFilter* {
+ cache_key.str(),
+ [&]() -> EqualityDeleteFilter* {
auto result = std::make_unique<EqualityDeleteFilter>();
read_status = _load_equality_delete_file(delete_file,
scan_params, delete_io_ctx,
result.get());
@@ -1820,9 +1823,17 @@ Status
IcebergTableReader::_read_equality_delete_file(const TIcebergDeleteFileDe
return nullptr;
}
return result.release();
- });
+ },
+ &cache_hit);
RETURN_IF_ERROR(read_status);
DORIS_CHECK(cached_filter != nullptr);
+ COUNTER_UPDATE(cache_hit ? _profile.equality_delete_index_cache_hit_count
+ : _profile.equality_delete_index_cache_miss_count,
+ 1);
+ if (!cache_hit) {
+ COUNTER_UPDATE(_profile.equality_delete_hash_index_memory,
+
static_cast<int64_t>(cached_filter->hash_index->memory_usage()));
+ }
_equality_delete_filters.push_back(*cached_filter);
return Status::OK();
}
diff --git a/be/src/format_v2/table/iceberg_reader.h
b/be/src/format_v2/table/iceberg_reader.h
index b9fc79f2416..cc4f6058aef 100644
--- a/be/src/format_v2/table/iceberg_reader.h
+++ b/be/src/format_v2/table/iceberg_reader.h
@@ -33,6 +33,7 @@
namespace doris {
class Block;
+class EqualityDeleteHashIndex;
struct DeleteFileDesc;
namespace io {
struct FileDescription;
@@ -201,6 +202,7 @@ private:
std::vector<std::string> field_names;
std::vector<DataTypePtr> key_types;
Block delete_block;
+ std::shared_ptr<const EqualityDeleteHashIndex> hash_index;
};
std::vector<EqualityDeleteFilter> _equality_delete_filters;
// Scanner-shared cache supplied in SplitReadOptions. Parsed delete files
outlive one data-file
diff --git a/be/src/format_v2/table_reader.cpp
b/be/src/format_v2/table_reader.cpp
index cf56a41f420..7161cf9adb5 100644
--- a/be/src/format_v2/table_reader.cpp
+++ b/be/src/format_v2/table_reader.cpp
@@ -955,6 +955,14 @@ Status TableReader::init(TableReadOptions&& options) {
TUnit::UNIT,
table_profile, 1);
_profile.parse_delete_file_time = ADD_CHILD_TIMER_WITH_LEVEL(
_scanner_profile, "ParseDeleteFileTime", table_profile, 1);
+ _profile.equality_delete_index_cache_hit_count =
+ ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile,
"EqualityDeleteIndexCacheHitCount",
+ TUnit::UNIT, table_profile, 1);
+ _profile.equality_delete_index_cache_miss_count =
+ ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile,
"EqualityDeleteIndexCacheMissCount",
+ TUnit::UNIT, table_profile, 1);
+ _profile.equality_delete_hash_index_memory =
ADD_CHILD_COUNTER_WITH_LEVEL(
+ _scanner_profile, "EqualityDeleteHashIndexMemory",
TUnit::BYTES, table_profile, 1);
_profile.decoded_dv_cache_hit_count =
ADD_CHILD_COUNTER_WITH_LEVEL(_scanner_profile,
"DeletionVectorDecodedCacheHitCount",
TUnit::UNIT, table_profile, 1);
diff --git a/be/src/format_v2/table_reader.h b/be/src/format_v2/table_reader.h
index c763a2301ba..ccee6f8e406 100644
--- a/be/src/format_v2/table_reader.h
+++ b/be/src/format_v2/table_reader.h
@@ -106,6 +106,9 @@ struct ReadProfile {
RuntimeProfile::Counter* num_delete_files = nullptr;
RuntimeProfile::Counter* num_delete_rows = nullptr;
RuntimeProfile::Counter* parse_delete_file_time = nullptr;
+ RuntimeProfile::Counter* equality_delete_index_cache_hit_count = nullptr;
+ RuntimeProfile::Counter* equality_delete_index_cache_miss_count = nullptr;
+ RuntimeProfile::Counter* equality_delete_hash_index_memory = nullptr;
RuntimeProfile::Counter* decoded_dv_cache_hit_count = nullptr;
RuntimeProfile::Counter* decoded_dv_cache_miss_count = nullptr;
RuntimeProfile::Counter* dv_file_cache_hit_count = nullptr;
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp
b/be/test/format_v2/table/iceberg_reader_test.cpp
index 8e2b7dc778b..65a905a9e18 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -4523,6 +4523,16 @@ TEST(IcebergV2ReaderTest,
IcebergEqualityDeleteFileIsReusedAcrossSplits) {
first_file_path,
{make_iceberg_equality_delete_file(delete_file_path, {0})}));
ASSERT_TRUE(reader.prepare_split(first_split).ok());
EXPECT_EQ(read_iceberg_ids(&reader, projected_columns),
std::vector<int32_t>({1, 3}));
+ auto* cache_misses =
profile.get_counter("EqualityDeleteIndexCacheMissCount");
+ auto* cache_hits = profile.get_counter("EqualityDeleteIndexCacheHitCount");
+ auto* index_memory = profile.get_counter("EqualityDeleteHashIndexMemory");
+ ASSERT_NE(cache_misses, nullptr);
+ ASSERT_NE(cache_hits, nullptr);
+ ASSERT_NE(index_memory, nullptr);
+ EXPECT_EQ(cache_misses->value(), 1);
+ EXPECT_EQ(cache_hits->value(), 0);
+ EXPECT_GT(index_memory->value(), 0);
+ const auto first_index_memory = index_memory->value();
// Removing the source after the first split proves that the second split
consumes the parsed
// delete block from SplitReadOptions.cache instead of reopening the
delete file.
@@ -4533,6 +4543,9 @@ TEST(IcebergV2ReaderTest,
IcebergEqualityDeleteFileIsReusedAcrossSplits) {
second_file_path,
{make_iceberg_equality_delete_file(delete_file_path, {0})}));
ASSERT_TRUE(reader.prepare_split(second_split).ok());
EXPECT_EQ(read_iceberg_ids(&reader, projected_columns),
std::vector<int32_t>({1, 3}));
+ EXPECT_EQ(cache_misses->value(), 1);
+ EXPECT_EQ(cache_hits->value(), 1);
+ EXPECT_EQ(index_memory->value(), first_index_memory);
ASSERT_TRUE(reader.close().ok());
std::filesystem::remove_all(test_dir);
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]