Skip to content

Commit 163b56b

Browse files
committed
perf(parquet): forward dictionary encoding through append compaction rewrite
An append-only compaction rewrite copies rows into the new file without inspecting any value, so expanding a dictionary-encoded Parquet column on read and hashing it again on write is work neither side needs. Forward the encoding instead. Reader: `parquet.read.enable-dictionary-passthrough`, off by default, makes ParquetFileBatchReader request `set_read_dictionary` for non-nested STRING/BINARY columns whose every data page, in every row group of the file, is dictionary-encoded. A dictionary page alone cannot be the signal - a column that outgrew its page limit still carries the page it had already emitted - so the gate reads the encoding statistics. Writer: ParquetFormatWriter recovers each batch's encoding from its layout, because exporting through the Arrow C data interface drops the type. A layout pins down neither the index nor the offset width, so only `dictionary(int32, utf8|binary)` is recoverable; CompactRewrite decodes anything else per column while the type is still known - the ORC reader's `dictionary(int64, large_utf8)` under lazy decoding, dictionaries below the top level - and leaves the rest encoded. A dictionary holding nulls in its values is flattened at the writer, the one shape parquet::arrow rejects outright. Compaction opts in only when the output is Parquet, `parquet.enable-dictionary` is on and no shredding plan is active; anything else forces the read option off. A file index on a forwarded column materializes that column alone. Note that a Parquet column chunk carries one dictionary, so when the input files supply different ones the output keeps the first and falls back to plain for the rest of the row group. The rewritten data is unchanged, but the output file may be larger than one written from materialized values.
1 parent 4027601 commit 163b56b

19 files changed

Lines changed: 2011 additions & 24 deletions

benchmark/parquet_format_benchmark.cpp

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
#include "arrow/c/helpers.h"
5252
#include "arrow/util/bit_util.h"
5353
#include "benchmark/benchmark.h"
54+
#include "fmt/format.h"
5455
#include "paimon/common/utils/arrow/arrow_input_stream_adapter.h"
5556
#include "paimon/common/utils/arrow/mem_utils.h"
5657
#include "paimon/common/utils/arrow/status_utils.h"
@@ -104,6 +105,10 @@ constexpr int32_t kWriteBatchSize = 1024;
104105
constexpr int64_t kPageSizeBytes = 64 * 1024;
105106
// Four row groups per read fixture, so row-group pruning and page pruning are both in play.
106107
constexpr int64_t kRowGroupLength = 25'000;
108+
// Only StringFixture lowers this from arrow's 1MB default: at 1MB a 25K-row row group of distinct
109+
// `value_<n>` entries still fits, so no cardinality this file writes would ever make the writer
110+
// fall back to plain and the passthrough gate would have nothing to decline.
111+
constexpr int64_t kDictionaryPageSizeBytes = 64 * 1024;
107112
constexpr int64_t kStringCardinality = 1'000;
108113
// Few enough distinct values that arrow keeps the column dictionary-encoded for the whole file,
109114
// which is the shape the wide-schema case wants: per-column work small, per-batch cost visible.
@@ -160,6 +165,11 @@ std::shared_ptr<arrow::Schema> DecimalSchema(int32_t precision) {
160165
return arrow::schema({MakeField("amount", arrow::decimal128(precision, 4), 0)});
161166
}
162167

168+
// One STRING column, so a dictionary case measures one encoder and nothing else.
169+
std::shared_ptr<arrow::Schema> StringSchema() {
170+
return arrow::schema({MakeField("name", arrow::utf8(), 0)});
171+
}
172+
163173
std::shared_ptr<arrow::Schema> DoubleSchema() {
164174
return arrow::schema({MakeField("value", arrow::float64(), 0)});
165175
}
@@ -422,6 +432,18 @@ BatchFactory SingleColumnBatch(const ColumnFactory& make_column) {
422432
};
423433
}
424434

435+
// The same, but the batch is typed by the column rather than by the schema, so it can carry an
436+
// encoding the schema does not declare. That is the shape a compaction rewrite produces: the file
437+
// writer is built from the table's logical schema while the reader forwards whatever encoding the
438+
// input file already had, leaving the writer to recover it from the batch.
439+
BatchFactory SingleEncodedColumnBatch(const ColumnFactory& make_column) {
440+
return [make_column](const std::shared_ptr<arrow::Schema>& schema, int64_t offset,
441+
int64_t rows) -> Result<std::shared_ptr<arrow::Array>> {
442+
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> column, make_column(rows, offset));
443+
return MakeStructArray({schema->field(0)->WithType(column->type())}, {column});
444+
};
445+
}
446+
425447
Result<std::shared_ptr<arrow::Array>> MakeNullableFlatBatch(
426448
const std::shared_ptr<arrow::Schema>& schema, int64_t offset, int64_t rows, int64_t null_pct) {
427449
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Array> ids, MakeInt64Column(rows, offset));
@@ -766,6 +788,27 @@ const ReadFixture& DoubleFixture() {
766788
return ColumnFixture("double", DoubleSchema(), &MakeDoubleColumn);
767789
}
768790

791+
// A one-column STRING file at a chosen cardinality, written with a reduced dictionary page limit
792+
// so both regimes the passthrough gate distinguishes are reachable within a 100K-row file. Under
793+
// the limit every data page stays dictionary-encoded and the gate lets the column through; over
794+
// it the writer emits the dictionary page it has and encodes the rest as plain, which is the case
795+
// the gate has to decline. At kDictionaryPageSizeBytes a row group holds roughly 4K distinct
796+
// `value_<n>` entries before overflowing, so cardinality alone picks the regime.
797+
const ReadFixture& StringFixture(int64_t cardinality) {
798+
const std::string key = fmt::format("string_{}", cardinality);
799+
return GetFixture(key, [key, cardinality] {
800+
std::map<std::string, std::string> options;
801+
options[paimon::parquet::PARQUET_DICTIONARY_PAGE_SIZE] =
802+
std::to_string(kDictionaryPageSizeBytes);
803+
return std::make_unique<ReadFixture>(
804+
key + ".parquet", StringSchema(),
805+
SingleColumnBatch([cardinality](int64_t rows, int64_t offset) {
806+
return MakeStringColumn(rows, offset, cardinality);
807+
}),
808+
options);
809+
});
810+
}
811+
769812
// The same data with dictionary encoding off, giving the read side a plain baseline.
770813
const ReadFixture& PlainFlatFixture() {
771814
return GetFixture("flat_plain", [] {
@@ -930,6 +973,21 @@ void BM_ParquetWrite_DictionaryString(::benchmark::State& state) {
930973
kRowsPerBatch, /*options=*/{});
931974
}
932975

976+
// arg: dictionary cardinality. The shape the append compaction rewrite actually produces, and the
977+
// one BM_ParquetWrite_DictionaryString does not cover: there the schema itself is a DictionaryType,
978+
// here the writer is built from a plain STRING schema - as a rewrite builds it, from the table's
979+
// logical schema - and the batch arrives dictionary-encoded anyway. The delta against
980+
// BM_ParquetWrite_String at the same cardinality is what the passthrough buys on the write side,
981+
// including the per-batch schema fixup that recovers the encoding from the batch layout.
982+
void BM_ParquetWrite_DictionaryStringIntoStringSchema(::benchmark::State& state) {
983+
const int64_t cardinality = state.range(0);
984+
RunWriteBenchmark(state, StringSchema(),
985+
SingleEncodedColumnBatch([cardinality](int64_t rows, int64_t offset) {
986+
return MakeDictionaryStringColumn(rows, offset, cardinality);
987+
}),
988+
kRowsPerBatch, /*options=*/{}, kDefaultCompression);
989+
}
990+
933991
// The same axis on an INTEGER dictionary, which arrow cannot direct-write - is_base_binary_like
934992
// excludes int32, so it densifies first. Its baseline is BM_ParquetWrite_FlatInt32 at the same
935993
// cardinality, not the String case: only the flat INT32 control holds value, width and encoding
@@ -1197,6 +1255,22 @@ void BM_ParquetRead_Encoding(::benchmark::State& state, bool enable_dictionary)
11971255
/*selection_bitmap=*/std::nullopt, /*options=*/{}, kReadBatchSize);
11981256
}
11991257

1258+
// args: string cardinality, and whether the parquet dictionary passthrough is on. With it on, a
1259+
// column the file stores dictionary-encoded end to end is handed back as a DictionaryArray instead
1260+
// of one materialized value per row, so the pair at a fixed cardinality is what the read half of
1261+
// the compaction rewrite saves. At a cardinality high enough that the writer fell back to plain,
1262+
// the gate declines and the two runs measure the same work - a divergence there means the gate
1263+
// stopped looking at the data page encodings and started trusting the dictionary page.
1264+
void BM_ParquetRead_DictionaryPassthrough(::benchmark::State& state) {
1265+
const int64_t cardinality = state.range(0);
1266+
const bool enable_passthrough = state.range(1) != 0;
1267+
std::map<std::string, std::string> options;
1268+
options[paimon::parquet::PARQUET_READ_ENABLE_DICTIONARY_PASSTHROUGH] =
1269+
enable_passthrough ? "true" : "false";
1270+
RunReadBenchmark(state, StringFixture(cardinality), StringSchema(), /*predicate=*/nullptr,
1271+
/*selection_bitmap=*/std::nullopt, options, kReadBatchSize);
1272+
}
1273+
12001274
// arg: decimal precision, the read side of BM_ParquetWrite_Decimal. Precision picks the physical
12011275
// type - INT32, INT64 or FIXED_LEN_BYTE_ARRAY, since ParquetWriterBuilder enables
12021276
// store_decimal_as_integer - and the three take different paths back to Decimal128Array.
@@ -1272,6 +1346,16 @@ BENCHMARK(BM_ParquetWrite_DictionaryString)
12721346
->Arg(10000)
12731347
->Unit(benchmark::kMillisecond)
12741348
->UseRealTime();
1349+
// Same cardinality axis as BM_ParquetWrite_String and BM_ParquetWrite_StringNoDictionary, which
1350+
// are its baselines: the three have to line up point for point or the low/medium/high comparison
1351+
// cannot be made.
1352+
BENCHMARK(BM_ParquetWrite_DictionaryStringIntoStringSchema)
1353+
->ArgName("cardinality")
1354+
->Arg(10)
1355+
->Arg(1000)
1356+
->Arg(kRowsPerFile)
1357+
->Unit(benchmark::kMillisecond)
1358+
->UseRealTime();
12751359
BENCHMARK(BM_ParquetWrite_DictionaryInt32)
12761360
->ArgName("cardinality")
12771361
->Arg(10)
@@ -1402,6 +1486,19 @@ BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, dictionary, true)
14021486
BENCHMARK_CAPTURE(BM_ParquetRead_Encoding, plain, false)
14031487
->Unit(benchmark::kMillisecond)
14041488
->UseRealTime();
1489+
// The same cardinality axis the write cases use, so the read and write halves of a rewrite can be
1490+
// added up at each point. At kRowsPerFile every value is distinct, which overflows
1491+
// kDictionaryPageSizeBytes and is the point where the gate has to decline.
1492+
BENCHMARK(BM_ParquetRead_DictionaryPassthrough)
1493+
->ArgNames({"cardinality", "passthrough"})
1494+
->Args({10, 0})
1495+
->Args({10, 1})
1496+
->Args({1000, 0})
1497+
->Args({1000, 1})
1498+
->Args({kRowsPerFile, 0})
1499+
->Args({kRowsPerFile, 1})
1500+
->Unit(benchmark::kMillisecond)
1501+
->UseRealTime();
14051502
BENCHMARK(BM_ParquetRead_Decimal)
14061503
->ArgName("precision")
14071504
->Arg(9)

docs/source/user_guide/compaction.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,40 @@ After compaction, if the last output file is still smaller than
8888
``compaction.file-size``, it is placed back into the compaction queue for future
8989
merging.
9090

91+
Dictionary Passthrough
92+
~~~~~~~~~~~~~~~~~~~~~~
93+
An append-only compaction rewrite copies rows into the new file without
94+
inspecting any value, so a Parquet column that an input file already stores
95+
dictionary-encoded is forwarded to the writer still encoded instead of being
96+
expanded to one copy of the value per row and re-encoded. This saves the reader
97+
materializing the values and the writer hashing them again; how much that is
98+
worth depends on the column, and low-cardinality ``STRING``/``BINARY`` columns
99+
benefit most. Primary-key compaction merges rows and is not covered.
100+
101+
This applies automatically. Eligibility is decided per input file: a non-nested
102+
``STRING``/``BINARY`` column is forwarded when its data pages are
103+
dictionary-encoded throughout every row group of *that* file, so one input file
104+
can be read encoded while the next one is read as ordinary values, and the
105+
writer takes both. A high-cardinality column that started dictionary-encoded and
106+
fell back to plain encoding therefore does not qualify, even though it still
107+
carries a dictionary page. Passthrough is also skipped when the table writes a
108+
format other than Parquet, when ``parquet.enable-dictionary`` is ``false``
109+
because the writer would only expand the values again, or when variant/map
110+
shredding is configured because those writers reshape each batch against a fixed
111+
physical schema.
112+
113+
If a file index is configured on a forwarded column, that column alone is
114+
materialized so the index still sees its values; the other columns stay encoded.
115+
116+
Passthrough changes what the rewrite costs, not what it produces, with one
117+
exception worth knowing: a Parquet column chunk can only carry one dictionary,
118+
so when the input files supply different dictionaries the output column keeps
119+
the first and falls back to plain encoding for the rest of the row group. The
120+
rewritten data is unchanged either way, but the output file may be larger than a
121+
rewrite that rebuilt a single dictionary from materialized values. Set
122+
``parquet.read.enable-dictionary-passthrough`` to ``false`` on the table to turn
123+
the optimization off and always rebuild.
124+
91125
Append-Only Table Compaction Options
92126
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
93127

src/paimon/common/reader/reader_utils_test.cpp

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@
2828
#include "arrow/api.h"
2929
#include "arrow/array/array_base.h"
3030
#include "arrow/c/abi.h"
31+
#include "arrow/c/bridge.h"
3132
#include "arrow/ipc/json_simple.h"
3233
#include "gtest/gtest.h"
34+
#include "paimon/common/utils/checked_cast.h"
3335
#include "paimon/status.h"
3436
#include "paimon/testing/utils/read_result_collector.h"
3537
#include "paimon/testing/utils/testharness.h"
@@ -104,4 +106,49 @@ TEST(ReaderUtilsTest, TestApplyBitmapToReadBatch) {
104106
"except eof");
105107
}
106108

109+
TEST(ReaderUtilsTest, TestApplyBitmapToReadBatchKeepsDictionaryEncoding) {
110+
// A deletion vector on an append table routes the Parquet dictionary passthrough through here:
111+
// ParquetFileBatchReader reports SupportPreciseBitmapSelection() == false, so RawFileSplitRead
112+
// wraps it and the surviving rows are cut out by slicing and concatenating. The encoding
113+
// survives that only because every slice shares one dictionary and arrow::Concatenate has a
114+
// fast path for it; if it ever unified or densified instead, a compaction with deletion
115+
// vectors would quietly stop forwarding the encoding the rewrite asked for.
116+
auto dictionary_type = arrow::dictionary(arrow::int32(), arrow::utf8());
117+
auto make_encoded = [&dictionary_type](const std::string& indices_json) {
118+
return arrow::DictionaryArray::FromArrays(
119+
dictionary_type,
120+
arrow::ipc::internal::json::ArrayFromJSON(arrow::int32(), indices_json)
121+
.ValueOrDie(),
122+
arrow::ipc::internal::json::ArrayFromJSON(arrow::utf8(), R"(["a", "b"])")
123+
.ValueOrDie())
124+
.ValueOrDie();
125+
};
126+
std::shared_ptr<arrow::Array> ids =
127+
arrow::ipc::internal::json::ArrayFromJSON(arrow::int32(), "[0, 1, 2, 3, 4]").ValueOrDie();
128+
auto src_array = arrow::StructArray::Make({make_encoded("[0, 1, 0, 1, 0]"), ids},
129+
std::vector<std::string>{"s", "id"})
130+
.ValueOrDie();
131+
132+
ASSERT_OK_AND_ASSIGN(auto src_batch, ReadResultCollector::GetReadBatch(src_array));
133+
// Two disjoint runs, so the filter has to concatenate rather than hand back a single slice.
134+
auto batch_with_bitmap =
135+
std::make_pair(std::move(src_batch), RoaringBitmap32::From(std::vector<int32_t>{0, 1, 4}));
136+
ASSERT_OK_AND_ASSIGN(auto result_batch,
137+
ReaderUtils::ApplyBitmapToReadBatch(std::move(batch_with_bitmap),
138+
arrow::default_memory_pool()));
139+
// Imported directly rather than through ReadResultCollector::GetArray, which decodes
140+
// dictionaries on the way out and would hide the very thing being asserted.
141+
auto& [c_array, c_schema] = result_batch;
142+
std::shared_ptr<arrow::Array> result =
143+
arrow::ImportArray(c_array.get(), c_schema.get()).ValueOrDie();
144+
ASSERT_EQ(3, result->length());
145+
auto result_struct = checked_pointer_cast<arrow::StructArray>(result);
146+
ASSERT_EQ(arrow::Type::DICTIONARY, result_struct->field(0)->type()->id());
147+
ASSERT_TRUE(result_struct->field(0)->Equals(*make_encoded("[0, 1, 0]")))
148+
<< "actual=" << result_struct->field(0)->ToString();
149+
std::shared_ptr<arrow::Array> expected_ids =
150+
arrow::ipc::internal::json::ArrayFromJSON(arrow::int32(), "[0, 1, 4]").ValueOrDie();
151+
ASSERT_TRUE(result_struct->field(1)->Equals(*expected_ids));
152+
}
153+
107154
} // namespace paimon::test

0 commit comments

Comments
 (0)