This is an automated email from the ASF dual-hosted git repository.

HappenLee 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 6104204b401 [improvement](be) Reduce map and string array 
deduplication overhead (#69690)
6104204b401 is described below

commit 6104204b401c82b60b402b7810aa7b0c6bf49f83
Author: HappenLee <[email protected]>
AuthorDate: Fri Oct 30 14:28:21 2026 +0800

    [improvement](be) Reduce map and string array deduplication overhead 
(#69690)
    
    `map(k, v)` serializes its keys and allocates deduplication buffers even
    though each output row has only one pair. String `array_distinct` grows
    its character buffer and copies each retained value separately.
    
    - Skip `deduplicate_keys()` for a single key/value pair. Multiple-pair
    construction keeps its existing last-value-wins deduplication.
    - Collect retained positions and call `insert_indices_from()` once for
    string batches without NULL elements. Use the existing SIMD NULL-map
    scan to select the scalar path when any NULL exists; this avoids copying
    hidden string payloads beneath NULLs. Nullable inputs with an all-zero
    NULL map still use batch copying.
    - Keep scalar copying when the nested element count exceeds the UInt32
    selector limit, and use `insert(...).second` to avoid separate hash
    lookups.
    
    First-occurrence ordering and SQL NULL semantics are unchanged. Tests
    cover NULL keys/values, constant keys, multiple-pair deduplication,
    string ordering, hidden NULL payloads, a NULL at the end of the input,
    empty inputs, long strings and selector growth.
    
    Paired Release microbenchmarks used the complete original and updated
    `execute_impl` entry points in one process with identical inputs.
    Results below are median CPU milliseconds per 4,096 rows/arrays on a
    pinned Xeon Platinum 8457C CPU, seven repetitions (selected earlier MAP
    cases had nine). These are function-level measurements, not end-to-end
    SQL speedups.
    
    | Case | Before (ms) | After (ms) | Before / after |
    | --- | ---: | ---: | ---: |
    | Single-pair MAP, BIGINT key/value | 0.0804 | 0.0629 | 1.28x |
    | Single-pair MAP, 16-byte string key, BIGINT value | 0.0986 | 0.0753 |
    1.31x |
    | Single-pair MAP, 128-byte string key, BIGINT value | 0.1665 | 0.1033 |
    1.61x |
    | ARRAY, 32 distinct 16-byte strings | 2.063 | 1.664 | 1.24x |
    | ARRAY, 32 distinct 128-byte strings | 22.993 | 10.550 | 2.18x |
    | ARRAY, 128 distinct 128-byte strings | 116.683 | 57.871 | 2.02x |
    | ARRAY, 128 x 128-byte strings, 25% retained | 31.968 | 20.596 | 1.55x
    |
    | ARRAY, 128 x 128-byte strings, one distinct value | 10.792 | 11.104 |
    0.97x |
    | ARRAY, 32 NULL elements with empty payloads | 0.1173 | 0.1219 | 0.96x
    |
    | ARRAY, 8 NULL elements with 4KiB hidden payloads | 0.0400 | 0.0413 |
    0.97x |
    
    Highly duplicated and all-NULL inputs showed little benefit; small
    differences in these controls were within the observed sample
    variability. NULL batches keep scalar copying. Temporary frozen-baseline
    benchmark code and build-entry changes are excluded from this PR.
    
    ### Release note
    
    Improve execution efficiency of single-pair MAP construction and string
    ARRAY_DISTINCT batches without NULL elements, preserving SQL results.
---
 .../exprs/function/array/function_array_distinct.h |  82 ++++++----
 be/src/exprs/function/function_map.cpp             |   5 +-
 .../function/function_array_distinct_test.cpp      | 177 +++++++++++++++++++++
 be/test/exprs/function/function_map_test.cpp       |  73 +++++++++
 4 files changed, 305 insertions(+), 32 deletions(-)

diff --git a/be/src/exprs/function/array/function_array_distinct.h 
b/be/src/exprs/function/array/function_array_distinct.h
index 01babcda365..ae8fa3f0368 100644
--- a/be/src/exprs/function/array/function_array_distinct.h
+++ b/be/src/exprs/function/array/function_array_distinct.h
@@ -23,6 +23,7 @@
 #include <glog/logging.h>
 
 #include <cstring>
+#include <limits>
 #include <memory>
 #include <ostream>
 #include <utility>
@@ -48,6 +49,7 @@
 #include "core/types.h"
 #include "exec/common/hash_table/hash.h"
 #include "exprs/function/function.h"
+#include "util/simd/bits.h"
 
 namespace doris {
 class FunctionContext;
@@ -188,18 +190,22 @@ private:
         return true;
     }
 
+    template <bool batch_copy>
     bool _execute_string(const IColumn& src_column, const 
ColumnArray::Offsets64& src_offsets,
                          IColumn& dest_column, ColumnArray::Offsets64& 
dest_offsets,
                          const NullMapType* src_null_map, NullMapType* 
dest_null_map) const {
-        const auto* src_data_concrete = reinterpret_cast<const 
ColumnString*>(&src_column);
-        if (!src_data_concrete) {
-            return false;
-        }
-
-        auto& dest_column_string = 
reinterpret_cast<ColumnString&>(dest_column);
+        const auto& src_data_concrete = assert_cast<const 
ColumnString&>(src_column);
+        auto& dest_column_string = assert_cast<ColumnString&>(dest_column);
         ColumnString::Chars& column_string_chars = 
dest_column_string.get_chars();
         ColumnString::Offsets& column_string_offsets = 
dest_column_string.get_offsets();
-        column_string_chars.reserve(src_column.size());
+        IColumn::Selector selector;
+        if constexpr (batch_copy) {
+            // _execute_by_type() passes nullptr after ruling out NULL 
elements.
+            DCHECK(src_null_map == nullptr);
+            selector.reserve(src_column.size());
+        } else {
+            column_string_chars.reserve(src_column.size());
+        }
 
         using Set = phmap::flat_hash_set<StringRef, DefaultHash<StringRef>>;
         Set set;
@@ -211,31 +217,34 @@ private:
             set.clear();
             size_t null_size = 0;
             for (size_t j = prev_src_offset; j < curr_src_offset; ++j) {
-                if (null_size != 0 && src_null_map && (*src_null_map)[j]) {
-                    // ignore duplicated nulls
-                    continue;
-                }
-                if (src_null_map && (*src_null_map)[j]) {
-                    DCHECK(dest_null_map != nullptr);
-                    // Note: here we need to update the offset of ColumnString
-                    
column_string_offsets.push_back(column_string_offsets.back());
-                    (*dest_null_map).push_back(true);
-                    null_size++;
-                    continue;
+                if constexpr (!batch_copy) {
+                    if (null_size != 0 && src_null_map && (*src_null_map)[j]) {
+                        // ignore duplicated nulls
+                        continue;
+                    }
+                    if (src_null_map && (*src_null_map)[j]) {
+                        DCHECK(dest_null_map != nullptr);
+                        
column_string_offsets.push_back(column_string_offsets.back());
+                        (*dest_null_map).push_back(true);
+                        null_size++;
+                        continue;
+                    }
                 }
 
-                StringRef src_str_ref = src_data_concrete->get_data_at(j);
-                if (!set.contains(src_str_ref)) {
-                    set.insert(src_str_ref);
-                    // copy the src data to column_string_chars
-                    const size_t old_size = column_string_chars.size();
-                    const size_t new_size = old_size + src_str_ref.size;
-                    column_string_chars.resize(new_size);
-                    if (src_str_ref.size > 0) {
-                        memcpy(column_string_chars.data() + old_size, 
src_str_ref.data,
-                               src_str_ref.size);
+                StringRef src_str_ref = src_data_concrete.get_data_at(j);
+                if (set.insert(src_str_ref).second) {
+                    if constexpr (batch_copy) {
+                        
selector.push_back(static_cast<IColumn::ColumnIndex>(j));
+                    } else {
+                        const size_t old_size = column_string_chars.size();
+                        const size_t new_size = old_size + src_str_ref.size;
+                        column_string_chars.resize(new_size);
+                        if (src_str_ref.size > 0) {
+                            memcpy(column_string_chars.data() + old_size, 
src_str_ref.data,
+                                   src_str_ref.size);
+                        }
+                        column_string_offsets.push_back(new_size);
                     }
-                    column_string_offsets.push_back(new_size);
 
                     if (dest_null_map) {
                         (*dest_null_map).push_back(false);
@@ -247,6 +256,10 @@ private:
             dest_offsets.push_back(res_offset);
             prev_src_offset = curr_src_offset;
         }
+        if constexpr (batch_copy) {
+            dest_column_string.insert_indices_from(src_column, selector.data(),
+                                                   selector.data() + 
selector.size());
+        }
         return true;
     }
 
@@ -255,8 +268,15 @@ private:
                           const NullMapType* src_null_map, NullMapType* 
dest_null_map,
                           DataTypePtr& nested_type) const {
         if (is_string_type(nested_type->get_primitive_type())) {
-            return _execute_string(src_column, src_offsets, dest_column, 
dest_offsets, src_null_map,
-                                   dest_null_map);
+            // NULL rows can carry hidden strings: one 4KiB payload per array 
adds 16MiB for
+            // 4096 arrays. Keep scalar copying when any NULL exists or UInt32 
indices overflow.
+            if (src_column.size() > 
std::numeric_limits<IColumn::ColumnIndex>::max() ||
+                (src_null_map && simd::contain_one(src_null_map->data(), 
src_null_map->size()))) {
+                return _execute_string<false>(src_column, src_offsets, 
dest_column, dest_offsets,
+                                              src_null_map, dest_null_map);
+            }
+            return _execute_string<true>(src_column, src_offsets, dest_column, 
dest_offsets,
+                                         nullptr, dest_null_map);
         }
         auto call = [&](const auto& type) -> bool {
             using DispatchType = std::decay_t<decltype(type)>;
diff --git a/be/src/exprs/function/function_map.cpp 
b/be/src/exprs/function/function_map.cpp
index c8139403104..01356116870 100644
--- a/be/src/exprs/function/function_map.cpp
+++ b/be/src/exprs/function/function_map.cpp
@@ -378,7 +378,10 @@ public:
             result_col_map_offsets[row] = offset;
         }
 
-        RETURN_IF_ERROR(map_column->deduplicate_keys());
+        // A single key/value pair per row cannot contain duplicate keys.
+        if (num_element > 2) {
+            RETURN_IF_ERROR(map_column->deduplicate_keys());
+        }
         block.replace_by_position(result, std::move(result_col));
         return Status::OK();
     }
diff --git a/be/test/exprs/function/function_array_distinct_test.cpp 
b/be/test/exprs/function/function_array_distinct_test.cpp
new file mode 100644
index 00000000000..559425ea3e8
--- /dev/null
+++ b/be/test/exprs/function/function_array_distinct_test.cpp
@@ -0,0 +1,177 @@
+// 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 "exprs/function/array/function_array_distinct.h"
+
+#include <gtest/gtest.h>
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "core/column/column_string.h"
+#include "core/data_type/data_type_string.h"
+
+namespace doris {
+
+class FunctionArrayDistinctTest : public testing::Test {
+protected:
+    using StringRow = std::vector<std::optional<std::string>>;
+    using StringRows = std::vector<StringRow>;
+
+    template <bool use_dispatch>
+    // NOLINTNEXTLINE(readability-function-cognitive-complexity): GTest 
assertions add branches.
+    static void check_strings(const StringRows& rows, const StringRows& 
expected, bool nullable,
+                              const std::string& null_payload) {
+        ASSERT_EQ(rows.size(), expected.size());
+        auto source = ColumnString::create();
+        ColumnArray::Offsets64 source_offsets;
+        FunctionArrayDistinct::NullMapType source_nulls;
+        for (const auto& row : rows) {
+            for (const auto& value : row) {
+                ASSERT_TRUE(nullable || value.has_value());
+                const auto& payload = value ? *value : null_payload;
+                source->insert_data(payload.data(), payload.size());
+                source_nulls.push_back(!value.has_value());
+            }
+            source_offsets.push_back(source->size());
+        }
+        const size_t source_bytes = source->get_chars().size();
+
+        auto result = ColumnString::create();
+        ColumnArray::Offsets64 result_offsets;
+        FunctionArrayDistinct::NullMapType result_nulls;
+        FunctionArrayDistinct function;
+        const auto* src_nulls = nullable ? &source_nulls : nullptr;
+        auto* dst_nulls = nullable ? &result_nulls : nullptr;
+        if constexpr (use_dispatch) {
+            DataTypePtr nested_type = std::make_shared<DataTypeString>();
+            ASSERT_TRUE(function._execute_by_type(*source, source_offsets, 
*result, result_offsets,
+                                                  src_nulls, dst_nulls, 
nested_type));
+        } else {
+            ASSERT_TRUE(function._execute_string<false>(*source, 
source_offsets, *result,
+                                                        result_offsets, 
src_nulls, dst_nulls));
+        }
+
+        ASSERT_EQ(result_offsets.size(), expected.size());
+        size_t result_row = 0;
+        size_t expected_bytes = 0;
+        for (size_t row = 0; row < expected.size(); ++row) {
+            for (const auto& value : expected[row]) {
+                ASSERT_LT(result_row, result->size());
+                if (nullable) {
+                    ASSERT_LT(result_row, result_nulls.size());
+                    EXPECT_EQ(result_nulls[result_row], !value.has_value());
+                }
+                if (value) {
+                    EXPECT_EQ(result->get_data_at(result_row).to_string(), 
*value);
+                    expected_bytes += value->size();
+                } else {
+                    EXPECT_EQ(result->get_data_at(result_row).size, 0);
+                }
+                ++result_row;
+            }
+            EXPECT_EQ(result_offsets[row], result_row);
+        }
+        EXPECT_EQ(result->size(), result_row);
+        EXPECT_EQ(result->get_chars().size(), expected_bytes);
+        EXPECT_EQ(result_nulls.size(), nullable ? result_row : 0);
+        EXPECT_EQ(source->get_chars().size(), source_bytes);
+        for (size_t row = 0; row < source->size(); ++row) {
+            if (source_nulls[row]) {
+                EXPECT_EQ(source->get_data_at(row).to_string(), null_payload);
+            }
+        }
+    }
+
+    static void check_both_paths(const StringRows& rows, const StringRows& 
expected, bool nullable,
+                                 const std::string& null_payload = 
std::string(4096, 'x')) {
+        check_strings<true>(rows, expected, nullable, null_payload);
+        // Exercise the oversized-input fallback without allocating over 2^32 
strings.
+        check_strings<false>(rows, expected, nullable, null_payload);
+    }
+};
+
+TEST_F(FunctionArrayDistinctTest, StringsPreserveFirstOccurrenceAcrossArrays) {
+    const std::string long_string(8192, 'a');
+    check_both_paths({{"b", "a", "b", "", ""},
+                      {},
+                      {"b", "b", "a"},
+                      {"中文", "中文", "tail"},
+                      {long_string, long_string, "tail"}},
+                     {{"b", "a", ""}, {}, {"b", "a"}, {"中文", "tail"}, 
{long_string, "tail"}},
+                     false);
+}
+
+TEST_F(FunctionArrayDistinctTest, NullableStringsWithHiddenPayloads) {
+    check_both_paths({{std::nullopt, "a", std::nullopt, "", "a", std::nullopt},
+                      {"b", std::nullopt, "b", "c", std::nullopt},
+                      {std::nullopt, std::nullopt},
+                      {},
+                      {"", std::nullopt}},
+                     {{std::nullopt, "a", ""},
+                      {"b", std::nullopt, "c"},
+                      {std::nullopt},
+                      {},
+                      {"", std::nullopt}},
+                     true);
+}
+
+TEST_F(FunctionArrayDistinctTest, NullableStringsWithEmptyNullPayloads) {
+    check_both_paths({{std::nullopt, "a", std::nullopt, "", "a"},
+                      {std::nullopt, std::nullopt},
+                      {"b", std::nullopt, "b"}},
+                     {{std::nullopt, "a", ""}, {std::nullopt}, {"b", 
std::nullopt}}, true, "");
+}
+
+TEST_F(FunctionArrayDistinctTest, EmptyStringsInput) {
+    check_both_paths({}, {}, false);
+    check_both_paths({}, {}, true);
+    check_both_paths({{}, {}}, {{}, {}}, true);
+}
+
+TEST_F(FunctionArrayDistinctTest, AllNullStringElements) {
+    check_both_paths({{std::nullopt, std::nullopt}, {}, {std::nullopt}},
+                     {{std::nullopt}, {}, {std::nullopt}}, true);
+}
+
+TEST_F(FunctionArrayDistinctTest, NullableStringsWithoutNulls) {
+    check_both_paths({{"b", "a", "b"}}, {{"b", "a"}}, true);
+}
+
+TEST_F(FunctionArrayDistinctTest, NullAfterManyStrings) {
+    StringRow input;
+    for (size_t i = 0; i < 256; ++i) {
+        input.emplace_back(std::to_string(i));
+    }
+    input.emplace_back(std::nullopt);
+    check_both_paths({input}, {input}, true);
+}
+
+TEST_F(FunctionArrayDistinctTest, ManyDistinctStringsAcrossArrays) {
+    StringRow input;
+    StringRow expected;
+    for (size_t i = 0; i < 40000; ++i) {
+        auto value = std::to_string(i);
+        input.emplace_back(value);
+        input.emplace_back(value);
+        expected.emplace_back(value);
+    }
+    check_both_paths({input, {}, {"0", "0", "last"}}, {expected, {}, {"0", 
"last"}}, false);
+}
+
+} // namespace doris
diff --git a/be/test/exprs/function/function_map_test.cpp 
b/be/test/exprs/function/function_map_test.cpp
index d61cc4acc2d..dcf99984ce5 100644
--- a/be/test/exprs/function/function_map_test.cpp
+++ b/be/test/exprs/function/function_map_test.cpp
@@ -177,6 +177,79 @@ TEST(FunctionMapTest, deduplicate_map) {
     }
 }
 
+TEST(FunctionMapTest, map_single_pair_preserves_nulls) {
+    auto nullable_int = make_nullable(std::make_shared<DataTypeInt32>());
+    auto map_type = std::make_shared<DataTypeMap>(nullable_int, nullable_int);
+    Block block;
+    block.insert({make_nullable_int_column({1, std::nullopt, 3}), 
nullable_int, "key"});
+    block.insert({make_nullable_int_column({10, 20, std::nullopt}), 
nullable_int, "value"});
+    block.insert({nullptr, map_type, "result"});
+
+    auto status = execute_map_function("map", block, {0, 1}, 2, map_type);
+    ASSERT_TRUE(status.ok()) << status.to_string();
+    const auto& result = assert_cast<const 
ColumnMap&>(*block.get_by_position(2).column);
+    ASSERT_EQ(result.size(), 3);
+    for (size_t row = 0; row < 3; ++row) {
+        EXPECT_EQ(result.get_offsets()[row], row + 1);
+    }
+    const auto& keys = assert_cast<const ColumnNullable&>(result.get_keys());
+    const auto& values = assert_cast<const 
ColumnNullable&>(result.get_values());
+    EXPECT_TRUE(keys.is_null_at(1));
+    EXPECT_TRUE(values.is_null_at(2));
+    EXPECT_EQ(get_nullable_int(keys, 0), 1);
+    EXPECT_EQ(get_nullable_int(keys, 2), 3);
+    EXPECT_EQ(get_nullable_int(values, 0), 10);
+    EXPECT_EQ(get_nullable_int(values, 1), 20);
+}
+
+TEST(FunctionMapTest, map_single_pair_with_constant_key) {
+    auto int_type = std::make_shared<DataTypeInt32>();
+    auto nullable_int = make_nullable(int_type);
+    auto map_type = std::make_shared<DataTypeMap>(nullable_int, nullable_int);
+    auto key = ColumnInt32::create();
+    key->insert_value(7);
+    auto values = ColumnInt32::create();
+    values->insert_value(10);
+    values->insert_value(20);
+    values->insert_value(30);
+    Block block;
+    block.insert({ColumnConst::create(std::move(key), 3), int_type, "key"});
+    block.insert({std::move(values), int_type, "value"});
+    block.insert({nullptr, map_type, "result"});
+
+    auto status = execute_map_function("map", block, {0, 1}, 2, map_type);
+    ASSERT_TRUE(status.ok()) << status.to_string();
+    const auto& result = assert_cast<const 
ColumnMap&>(*block.get_by_position(2).column);
+    ASSERT_EQ(result.size(), 3);
+    for (size_t row = 0; row < 3; ++row) {
+        EXPECT_EQ(result.get_offsets()[row], row + 1);
+        EXPECT_EQ(get_nullable_int(result.get_keys(), row), 7);
+        EXPECT_EQ(get_nullable_int(result.get_values(), row), (row + 1) * 10);
+    }
+}
+
+TEST(FunctionMapTest, map_multiple_pairs_still_deduplicate) {
+    auto nullable_int = make_nullable(std::make_shared<DataTypeInt32>());
+    auto map_type = std::make_shared<DataTypeMap>(nullable_int, nullable_int);
+    Block block;
+    block.insert({make_nullable_int_column({1, std::nullopt}), nullable_int, 
"key1"});
+    block.insert({make_nullable_int_column({10, 20}), nullable_int, "value1"});
+    block.insert({make_nullable_int_column({1, std::nullopt}), nullable_int, 
"key2"});
+    block.insert({make_nullable_int_column({30, 40}), nullable_int, "value2"});
+    block.insert({nullptr, map_type, "result"});
+
+    auto status = execute_map_function("map", block, {0, 1, 2, 3}, 4, 
map_type);
+    ASSERT_TRUE(status.ok()) << status.to_string();
+    const auto& result = assert_cast<const 
ColumnMap&>(*block.get_by_position(4).column);
+    ASSERT_EQ(result.size(), 2);
+    EXPECT_EQ(result.get_offsets()[0], 1);
+    EXPECT_EQ(result.get_offsets()[1], 2);
+    EXPECT_EQ(get_nullable_int(result.get_keys(), 0), 1);
+    EXPECT_TRUE(assert_cast<const 
ColumnNullable&>(result.get_keys()).is_null_at(1));
+    EXPECT_EQ(get_nullable_int(result.get_values(), 0), 30);
+    EXPECT_EQ(get_nullable_int(result.get_values(), 1), 40);
+}
+
 TEST(FunctionMapTest, map_from_arrays) {
     auto nullable_int = make_nullable(std::make_shared<DataTypeInt32>());
     auto array_type = std::make_shared<DataTypeArray>(nullable_int);


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to