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 c9bd8b0894f [fix](be) Stop preallocating compression-sized t-digest 
buffers per group (#69284)
c9bd8b0894f is described below

commit c9bd8b0894fb9c3315e5bc740be875034d0e90d2
Author: Jerry Hu <[email protected]>
AuthorDate: Thu Oct 22 11:43:54 2026 +0800

    [fix](be) Stop preallocating compression-sized t-digest buffers per group 
(#69284)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Problem Summary:
    
    `percentile_approx` and `quantile_union` keep one `TDigest` per GROUP BY
    group. The
    `TDigest(compression, unmerged_size, merged_size)` constructor eagerly
    reserved
    `2 * compression` processed centroids and `8 * compression + 1`
    unprocessed centroids before
    any value was stored. At the default `compression = 10000` this costs
    800008 bytes per group
    (160000 B + 640008 B), independent of how many rows the group actually
    holds.
    
    Both allocations exceed the ASAN primary allocator's 128 KiB size-class
    limit, so each digest
    occupied two secondary-allocator chunks. With roughly 524288 live groups
    the process exhausted
    the secondary allocator's chunk table (`kMaxNumChunks = 1 << 20`) and
    aborted in
    `sanitizer_allocator_secondary.h:42`. Release builds map about 480 GB of
    virtual address space
    for 600K singleton groups in the same scenario.
    
    Fix:
    
    - Let both vectors grow on demand in the constructor. `_max_processed`
    and `_max_unprocessed`
    are still initialized exactly as before: they are compression thresholds
    used by
    `is_dirty()`, the buffered `add()` path, and serialization, not capacity
    caches.
    - Remove the exact-size `reserve(total)` from `_merge_unprocessed`. Once
    the constructor no
    longer preallocates, `reserve(total)` would reallocate and copy the
    whole buffer on every
    merge of a small digest, degrading many sequential merges to O(n^2)
    copies. `insert()` grows
      geometrically instead, keeping amortized O(1).
    
    The compression thresholds, the t-digest algorithm, the numeric results,
    and the serialized
    format are unchanged.
    
    Tests updated/added:
    
    - New unit test: a default-constructed digest has zero buffer capacity,
    allocates less than
    4 KiB in total, keeps the historical thresholds (20000 / 80000), and
    still returns the right
      quantile after a single value.
    - New unit test: 1000 singleton groups allocate memory proportional to
    their data
      (< 1 KiB per group) and each returns the correct median.
    - Updated an existing accumulator test that previously asserted the
    80001 preallocation; it
    now asserts that the accumulator retains spare write-buffer capacity
    without encoding the
      removed preallocation.
    
    ### Release note
    
    Fix backend aborts for `percentile_approx` / `quantile_union` queries
    with a very high number
    of GROUP BY groups by no longer preallocating compression-sized buffers
    per group.
    
    ### Check List (For Author)
    
    - Test: Unit Test
    - BE unit tests under ASAN: 41 tests from 5 suites passed, covering
    `TDigestTest`
    (including the two new capacity/memory tests), `QuantileStateTest`,
    `AggregateFunctionPercentileApproxArrayTest`,
    `AggregateFunctionQuantileStateTest`,
          and `AggregateFunctionQuantileStateRangeTest`. Command:
    `./run-be-ut.sh --run
    
--filter='TDigestTest.*:QuantileStateTest.*:AggregateFunctionPercentileApproxArrayTest.*:AggregateFunctionQuantileStateTest.*:*AggregateFunctionQuantileStateRangeTest.*'
    -j 64`
    - A standalone ASAN program holding 600000 live `TDigest` instances now
    runs to
    completion; the same program aborted at about 524288 digests before the
    fix.
    - A negative-control build against the old header confirms the new
    assertions fail on the
    unfixed code (capacities 20000 / 80001 and 800008 bytes per digest; 1000
    singleton
          groups allocate 800008000 bytes).
    - `clang-format` (v16) passes on the changed files, the repository-wide
    format check
          passes, and the build-hygiene check passes.
        - `clang-tidy` could not run on this machine: its toolchain reports
    `'stddef.h' file not found` for the changed files, unrelated to this
    change.
    - No regression test was added: reproducing the abort requires more than
    ~524288 live
    digests, which no regression cluster can hold, and release builds
    neither abort nor
    return an error row. The new unit tests assert the exact capacity and
    allocation
          behavior at the layer where the fix lives.
    - Behavior changed: No
    - Does this need documentation: No
---
 be/src/util/tdigest.h                           | 18 +++++-----
 be/test/exprs/aggregate/agg_percentile_test.cpp |  5 ++-
 be/test/util/tdigest_test.cpp                   | 44 +++++++++++++++++++++++++
 3 files changed, 58 insertions(+), 9 deletions(-)

diff --git a/be/src/util/tdigest.h b/be/src/util/tdigest.h
index 1d5202a2d9b..1ce7d4394c2 100644
--- a/be/src/util/tdigest.h
+++ b/be/src/util/tdigest.h
@@ -143,8 +143,13 @@ public:
             : _compression(compression),
               _max_processed(processed_size(merged_size, compression)),
               _max_unprocessed(unprocessed_size(unmerged_size, compression)) {
-        _processed.reserve(_max_processed);
-        _unprocessed.reserve(_max_unprocessed + 1);
+        // Deliberately do not preallocate. Aggregations keep one digest per 
group
+        // (percentile_approx, quantile_union), so reserving 2*compression 
processed and
+        // 8*compression unprocessed centroids up front costs O(groups * 
compression) memory
+        // before any value is stored: 800KB per group at the default 
compression 10000, which
+        // is 480GB of virtual address space for 600K singleton groups. The 
buffers grow on
+        // demand instead; _max_processed and _max_unprocessed still bound 
them and drive
+        // exactly the same compression thresholds as before.
     }
 
     TDigest(std::vector<Centroid>&& processed, std::vector<Centroid>&& 
unprocessed,
@@ -630,12 +635,9 @@ private:
             return;
         }
 
-        size_t total = _unprocessed.size();
-        for (const auto& td : tdigests) {
-            total += td->_unprocessed.size();
-        }
-
-        _unprocessed.reserve(total);
+        // Insert directly instead of reserving the exact merged size: 
reserve() reallocates
+        // and copies the whole buffer whenever `total` exceeds the current 
capacity, so
+        // merging many small digests would cost O(n^2) copies. insert() grows 
geometrically.
         for (const auto& td : tdigests) {
             _unprocessed.insert(_unprocessed.end(), td->_unprocessed.cbegin(),
                                 td->_unprocessed.cend());
diff --git a/be/test/exprs/aggregate/agg_percentile_test.cpp 
b/be/test/exprs/aggregate/agg_percentile_test.cpp
index 28c31f0737f..7a1030e837c 100644
--- a/be/test/exprs/aggregate/agg_percentile_test.cpp
+++ b/be/test/exprs/aggregate/agg_percentile_test.cpp
@@ -185,7 +185,10 @@ TEST(AggregateFunctionQuantileStateTest, 
GrowingWindowKeepsResultsCompact) {
             EXPECT_NEAR(expected[i], 
result.get_value_by_percentile(quantiles[i]), 1.0);
         }
     }
-    EXPECT_GE(accumulator._mutable_tdigest().unprocessed().capacity(), 80001);
+    // The accumulator keeps its write buffer for later rows instead of 
shrinking like a
+    // result copy: its unprocessed vector still has spare capacity after the 
window, while
+    // the retained result digests above are sized exactly.
+    EXPECT_GT(accumulator._mutable_tdigest().unprocessed().capacity(), 0);
     EXPECT_FALSE(accumulator._mutable_tdigest().have_unprocessed());
 }
 
diff --git a/be/test/util/tdigest_test.cpp b/be/test/util/tdigest_test.cpp
index 392724e1862..e7c9e9fab99 100644
--- a/be/test/util/tdigest_test.cpp
+++ b/be/test/util/tdigest_test.cpp
@@ -23,6 +23,7 @@
 #include <memory>
 #include <numeric>
 #include <random>
+#include <vector>
 
 #include "gtest/gtest_pred_impl.h"
 #include "testutil/test_util.h"
@@ -342,4 +343,47 @@ TEST_F(TDigestTest, Montonicity) {
     }
 }
 
+TEST_F(TDigestTest, DefaultConstructorDoesNotPrepareCompressionSizedBuffers) {
+    const auto allocated_bytes = [](const TDigest& digest) {
+        return (digest._processed.capacity() + digest._unprocessed.capacity()) 
* sizeof(Centroid) +
+               digest._cumulative.capacity() * sizeof(Weight);
+    };
+
+    TDigest digest(10000);
+    // The compression thresholds keep their historical semantics.
+    EXPECT_EQ(20000, digest.max_processed());
+    EXPECT_EQ(80000, digest.max_unprocessed());
+    // A fresh digest must not preallocate O(compression) buffers.
+    EXPECT_EQ(0, digest.processed().capacity());
+    EXPECT_EQ(0, digest.unprocessed().capacity());
+    EXPECT_LT(allocated_bytes(digest), 4 * 1024);
+
+    digest.add(42.0f);
+    EXPECT_EQ(1, digest.unprocessed().size());
+    EXPECT_LT(allocated_bytes(digest), 4 * 1024);
+    EXPECT_FLOAT_EQ(42.0f, digest.quantile(0.5f));
+}
+
+TEST_F(TDigestTest, SingletonGroupsKeepMemoryProportionalToData) {
+    // percentile_approx keeps one digest per group. With the old eager 
reserve every
+    // singleton group held 800000 bytes, so enough live groups exhausted 
ASAN's
+    // secondary allocator chunk limit.
+    constexpr size_t kGroups = 1000;
+    std::vector<std::unique_ptr<TDigest>> digests;
+    digests.reserve(kGroups);
+    size_t allocated_bytes = 0;
+    for (size_t i = 0; i < kGroups; ++i) {
+        auto digest = TDigest::create_unique(10000.0f);
+        digest->add(static_cast<float>(i));
+        allocated_bytes += (digest->processed().capacity() + 
digest->unprocessed().capacity()) *
+                                   sizeof(Centroid) +
+                           digest->_cumulative.capacity() * sizeof(Weight);
+        digests.push_back(std::move(digest));
+    }
+    EXPECT_LT(allocated_bytes, kGroups * 1024);
+    for (size_t i = 0; i < kGroups; ++i) {
+        EXPECT_FLOAT_EQ(static_cast<float>(i), digests[i]->quantile(0.5f));
+    }
+}
+
 } // namespace doris


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

Reply via email to