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 efd566353bd [improvement](variant) Reduce Variant array shredding CPU
during import (#67983)
efd566353bd is described below
commit efd566353bd598f92605c12aa94efc988759291c
Author: lihangyu <[email protected]>
AuthorDate: Mon Sep 21 10:20:21 2026 +0800
[improvement](variant) Reduce Variant array shredding CPU during import
(#67983)
### What problem does this PR solve?
Issue Number: None
Related PR: #67551
Problem Summary:
Variant V2 import spends most of its BE CPU in the segment writer's
Variant shredder. Importing 1M rows of sparse JSON (about 20 of 2,000
keys per row: BIGINT, string, double, boolean, BIGINT arrays, and small
objects) into a default `VARIANT` table through a `local()` TVF used
49.94 BE CPU seconds, and 70.1% of the BE samples were in
`VariantShredder::append`. Two defects in the ARRAY path of
`VariantPathBuilder` caused most of it.
1. **Array type reuse never matched.** `infer_type()` tries to reuse the
path's existing `DataTypeArray` when the element type is unchanged.
`DataTypeArray` always wraps its element in `Nullable`, but inferred
element types are never nullable, so `Nullable(BIGINT).equals(BIGINT)`
failed for every array value. Each value then went through
`path_least_common_type()` and `get_least_supertype_jsonb()`, which
builds temporary `DataTypes` vectors, strips `Nullable`, and allocates a
new `DataTypeNullable` only to arrive at the same type. The first commit
compares the unwrapped element type.
2. **Static data types were copied per value.** Memtable flushes of
several tablets shred concurrently. For every ARRAY value the builder
copied process-wide static data types by value: `infer_type()` returned
each element's static type, and `append_array()` and
`value_is_representable()` unwrapped the element with
`remove_nullable()`. Each copy is an atomic reference-count update on a
control block shared by all flush threads. With eight concurrent segment
writers, `perf annotate` put most of `infer_type()`'s own samples, a
third of `value_is_representable()`'s, and a fifth of `append_value()`'s
on lock-prefixed reference-count instructions. The second commit returns
scalar element types by reference, resolves a common element type only
when elements differ, and borrows the unwrapped array element.
Inferred and promoted types are unchanged in both commits.
Results, RELEASE build, one single-node cluster, binaries swapped
between runs in two interleaved rounds, medians of 6 imports each:
| Build | BE CPU s | Wall s | Rows per BE CPU s | Shredder share of BE
CPU |
|---|---|---|---|---|
| master | 49.94 | 8.41 | 20,026 | 70.1% |
| + array type reuse | 38.19 | 7.18 | 26,185 | 51.1% |
| + borrowed static types | 24.71 | 5.61 | 40,461 | 22.4% |
The imported data checksum is identical across all 18 imports.
`get_least_supertype_jsonb()` dropped from 21.6% of BE CPU to not
sampled.
`BM_VariantSparseImport` from #67551, thread CPU seconds per 1M rows,
medians of 5 samples:
| Scenario | master | + array type reuse | + borrowed static types |
|---|---|---|---|
| MixedTypes, 8 concurrent writers | 25.66 | 18.81 | 11.23 |
| MixedTypes, 1 writer | 12.09 | 10.56 | 10.84 |
| NoArrays, 8 concurrent writers | 8.62 | 8.74 | 8.93 |
With both commits the eight-writer cost matches the single-writer cost.
The single-writer and NoArrays rows vary by about 10% between runs on
the shared test host.
### Release note
None
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [x] Regression test
- [x] Unit Test
- [x] Manual test (add detailed scripts or steps below)
- [ ] 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 <!-- Add your reason? -->
Validated on base 17ac3d9275f; this branch is rebased onto a newer
master whose two additional commits do not touch these files.
- Unit test:
`VariantPathBuilderTest.*:VariantShredderTest.*:VariantColumnWriterReaderTest.*`,
78 passed and 2 skipped (skipped on master too), including the new
`VariantPathBuilderTest.ArrayPathReusesElementTypeAcrossRows`.
- Regression test on a RELEASE cluster with each commit: `variant_p0`
suites `regression_test_variant`, `regression_test_variant_types`,
`test_variant_array_subscript`,
`regression_test_variant_array_with_predicate`,
`test_variant_array_function`, `variant_compute_v2`,
`regression_test_variant_multi_var`,
`regression_test_variant_predefine_schema` (10 suite files), all passed.
- Manual test: the 1M-row import and `BM_VariantSparseImport`
comparisons above; `build-support/check-format.sh` and
`build-support/check-build-hygiene.sh` pass. clang-tidy on the changed
test file reports nothing; on `variant_path_builder.cpp` the clang
static analyzer crashes on unchanged `__int128` code, and without
`clang-analyzer-*` the only diagnostic is the existing cognitive
complexity of the unchanged `VariantPathBuilder::append`.
- Behavior changed:
- [x] No.
- [ ] Yes. <!-- Explain the behavior change -->
- Does this need documentation?
- [x] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude <[email protected]>
---
.../segment/variant/v2/variant_path_builder.cpp | 58 ++++++++++++++------
.../variant/variant_column_writer_reader_test.cpp | 64 ++++++++++++++++++++++
2 files changed, 104 insertions(+), 18 deletions(-)
diff --git a/be/src/storage/segment/variant/v2/variant_path_builder.cpp
b/be/src/storage/segment/variant/v2/variant_path_builder.cpp
index 9c8704cc4b3..5d75ad5750d 100644
--- a/be/src/storage/segment/variant/v2/variant_path_builder.cpp
+++ b/be/src/storage/segment/variant/v2/variant_path_builder.cpp
@@ -219,8 +219,16 @@ const DataTypePtr& cached_decimal_type(uint32_t scale) {
return types[scale];
}
-DataTypePtr infer_type(VariantRef value, const DataTypePtr& reusable_type =
nullptr) {
- const ValueKind kind = value_kind(value);
+const DataTypePtr& array_element_type(const DataTypeArray& array) {
+ // DataTypeArray always wraps its element in Nullable. Borrow the element
instead of copying it
+ // through remove_nullable(): element types are process-wide statics
shared by concurrent
+ // flushes, so each shared_ptr copy is a contended reference-count update.
+ return assert_cast<const
DataTypeNullable&>(*array.get_nested_type()).get_nested_type();
+}
+
+// Every scalar storage type is a process-wide static, so it is returned by
reference.
+const DataTypePtr& infer_scalar_type(VariantRef value, ValueKind kind) {
+ DORIS_CHECK(kind != ValueKind::ARRAY);
switch (kind) {
case ValueKind::NULL_VALUE:
return nothing_type();
@@ -280,8 +288,18 @@ DataTypePtr infer_type(VariantRef value, const
DataTypePtr& reusable_type = null
case ValueKind::ARRAY:
break;
}
+ __builtin_unreachable();
+}
+
+DataTypePtr infer_type(VariantRef value, const DataTypePtr& reusable_type =
nullptr) {
+ const ValueKind kind = value_kind(value);
+ if (kind != ValueKind::ARRAY) {
+ return infer_scalar_type(value, kind);
+ }
- DataTypePtr element_type;
+ // Borrow the static element types and only resolve a common type when
elements differ.
+ DataTypePtr promoted_element;
+ const DataTypePtr* element_type = nullptr;
const uint32_t element_count = value.num_elements();
for (uint32_t index = 0; index < element_count; ++index) {
const VariantRef element = value.array_at(index);
@@ -291,17 +309,18 @@ DataTypePtr infer_type(VariantRef value, const
DataTypePtr& reusable_type = null
element.basic_type() == VariantBasicType::OBJECT)) {
return jsonb_type();
}
- DataTypePtr inferred = infer_type(element);
+ const DataTypePtr& inferred = infer_scalar_type(element, element_kind);
if (inferred->get_primitive_type() == INVALID_TYPE) {
continue;
}
- element_type = element_type == nullptr ? std::move(inferred)
- :
path_least_common_type(element_type, inferred);
- }
-
- if (element_type == nullptr) {
- element_type = nothing_type();
+ if (element_type == nullptr) {
+ element_type = &inferred;
+ } else if (element_type->get() != inferred.get()) {
+ promoted_element = path_least_common_type(*element_type, inferred);
+ element_type = &promoted_element;
+ }
}
+ const DataTypePtr& resolved_element = element_type == nullptr ?
nothing_type() : *element_type;
// A path commonly sees the same ARRAY element type on every row. Reuse
the builder's
// DataTypeArray in that case instead of allocating a temporary shared_ptr
per value. The
@@ -310,17 +329,20 @@ DataTypePtr infer_type(VariantRef value, const
DataTypePtr& reusable_type = null
if (const auto* reusable_array =
reusable_type == nullptr ? nullptr
: typeid_cast<const
DataTypeArray*>(reusable_type.get())) {
- const DataTypePtr& reusable_element =
reusable_array->get_nested_type();
- if (reusable_element.get() == element_type.get() ||
- reusable_element->equals(*element_type)) {
+ // Inferred element types are never nullable, so compare against the
unwrapped element;
+ // otherwise every ARRAY value would miss the equality check and pay
+ // get_least_supertype_jsonb() only to rebuild the same type.
+ const DataTypePtr& reusable_element =
array_element_type(*reusable_array);
+ if (reusable_element.get() == resolved_element.get() ||
+ reusable_element->equals(*resolved_element)) {
return reusable_type;
}
- DataTypePtr common_element = path_least_common_type(reusable_element,
element_type);
+ DataTypePtr common_element = path_least_common_type(reusable_element,
resolved_element);
if (reusable_element->equals(*common_element)) {
return reusable_type;
}
}
- return std::make_shared<DataTypeArray>(element_type);
+ return std::make_shared<DataTypeArray>(resolved_element);
}
bool is_small_or_regular_integer(PrimitiveType type) {
@@ -479,8 +501,8 @@ bool value_is_representable(VariantRef value, const
DataTypePtr& target_type) {
if (kind != ValueKind::ARRAY) {
return false;
}
- const DataTypePtr element_type =
- remove_nullable(assert_cast<const
DataTypeArray&>(*target_type).get_nested_type());
+ const DataTypePtr& element_type =
+ array_element_type(assert_cast<const
DataTypeArray&>(*target_type));
const uint32_t count = value.num_elements();
for (uint32_t index = 0; index < count; ++index) {
const VariantRef element = value.array_at(index);
@@ -807,7 +829,7 @@ void append_array(VariantRef value, const DataTypePtr&
target_type, IColumn* tar
const auto& array_type = assert_cast<const DataTypeArray&>(*target_type);
auto& array = assert_cast<ColumnArray&>(*target);
auto& elements = assert_cast<ColumnNullable&>(array.get_data());
- const DataTypePtr element_type =
remove_nullable(array_type.get_nested_type());
+ const DataTypePtr& element_type = array_element_type(array_type);
// infer_type() made the first borrowed pass. Revisit the encoded children
only after path type
// promotion is complete, appending directly without an owning recursive
scratch tree.
const uint32_t count = value.num_elements();
diff --git a/be/test/storage/variant/variant_column_writer_reader_test.cpp
b/be/test/storage/variant/variant_column_writer_reader_test.cpp
index 1c4dda3f89c..5148ff14e28 100644
--- a/be/test/storage/variant/variant_column_writer_reader_test.cpp
+++ b/be/test/storage/variant/variant_column_writer_reader_test.cpp
@@ -28,6 +28,7 @@
#include <thread>
#include "common/config.h"
+#include "core/column/column_array.h"
#include "core/column/column_nullable.h"
#include "core/column/column_string.h"
#include "core/column/column_vector.h"
@@ -773,6 +774,69 @@ TEST(VariantPathBuilderTest,
PreservesIncomingArrayWhenInferredDecimalPromotionO
"[9999999999999999999999999999999999999.9]");
}
+TEST(VariantPathBuilderTest, ArrayPathReusesElementTypeAcrossRows) {
+ VariantBatchBuilder value_builder;
+ const auto append_array = [&](auto&& fill) {
+ auto row = value_builder.begin_row();
+ auto array = row.start_array();
+ fill(row);
+ array.finish();
+ row.finish();
+ };
+ append_array([](auto& row) {
+ row.add_float(1.0F);
+ row.add_float(2.0F);
+ });
+ append_array([](auto& row) {
+ row.add_float(3.0F);
+ row.add_null();
+ });
+ append_array([](auto& row) { row.add_null(); });
+ append_array([](auto& row) { row.add_double(4.5); });
+ append_array([](auto& row) { row.add_float(5.0F); });
+ VariantBatchBuilder values = value_builder.finish_batch();
+
+ const auto element_primitive = [](const DataTypePtr& type) {
+ const DataTypePtr array = remove_nullable(type);
+ return remove_nullable(assert_cast<const
DataTypeArray&>(*array).get_nested_type())
+ ->get_primitive_type();
+ };
+ segment_v2::VariantPathBuilder builder(PathInData("metric"));
+ // FLOAT arrays, arrays with null elements, and all-null arrays share the
first row's type.
+ for (size_t row = 0; row < 3; ++row) {
+ ASSERT_TRUE(builder.append(values.value_at(row), row).ok());
+ EXPECT_EQ(builder.promotion_count(), 0) << "row=" << row;
+ ASSERT_EQ(element_primitive(builder.type()), TYPE_FLOAT) << "row=" <<
row;
+ }
+ ASSERT_TRUE(builder.append(values.value_at(3), 3).ok());
+ EXPECT_EQ(builder.promotion_count(), 1);
+ ASSERT_EQ(element_primitive(builder.type()), TYPE_DOUBLE);
+ // A narrower FLOAT element after promotion reuses the promoted DOUBLE
array type.
+ ASSERT_TRUE(builder.append(values.value_at(4), 4).ok());
+ EXPECT_EQ(builder.promotion_count(), 1);
+ ASSERT_EQ(element_primitive(builder.type()), TYPE_DOUBLE);
+
+ ColumnPtr materialized;
+ ASSERT_TRUE(builder.materialize(&materialized).ok());
+ const auto& array = assert_cast<const ColumnArray&>(
+ assert_cast<const
ColumnNullable&>(*materialized).get_nested_column());
+ const auto& elements = assert_cast<const
ColumnNullable&>(array.get_data());
+ const auto& doubles =
+ assert_cast<const
ColumnFloat64&>(elements.get_nested_column()).get_data();
+ const std::vector<std::optional<double>> expected {1.0, 2.0, 3.0,
std::nullopt,
+ std::nullopt, 4.5, 5.0};
+ EXPECT_EQ(std::vector<uint64_t>(array.get_offsets().begin(),
array.get_offsets().end()),
+ (std::vector<uint64_t> {2, 4, 5, 6, 7}));
+ ASSERT_EQ(elements.size(), expected.size());
+ for (size_t index = 0; index < expected.size(); ++index) {
+ SCOPED_TRACE(testing::Message() << "element=" << index);
+ EXPECT_EQ(elements.is_null_at(index), !expected[index].has_value());
+ if (expected[index].has_value()) {
+ EXPECT_DOUBLE_EQ(doubles[index], *expected[index]);
+ }
+ }
+}
+
TEST(VariantPathBuilderTest,
StringifiesArrayWithoutTreatingExistingNullAsCastFailure) {
VariantBatchBuilder value_builder;
auto row = value_builder.begin_row();
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]